Agent 工程体系 · 第 31/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 知识引用:证据片段、来源映射、冲突和可验证回答

Agent 的回答如果只返回一段自然语言,用户无法判断其中哪些内容来自检索结果,哪些是模型推断,哪些只是模型凭经验补全。知识引用要解决的不是“在回答末尾附几个链接”,而是建立一条可以回溯、检查和复现的证据链:

用户问题
  ↓
检索与查询分解
  ↓
证据片段
  ↓
来源映射
  ↓
事实主张
  ↓
冲突检测与决策
  ↓
带引用的回答
  ↓
验证、拒答或升级

本文把知识引用视为 Agent 的一个状态化子系统,重点讨论四个对象:

  • 证据片段:回答所依据的最小信息单元;
  • 来源映射:主张与证据、文档、版本、位置之间的关系;
  • 冲突:多个证据对同一主张给出不一致结论时如何处理;
  • 可验证回答:用户能够沿着引用重新定位原文,并判断回答是否超出了证据支持范围。

这里的“引用”不是语言格式问题,而是一个从检索结果到最终回答的可追溯性约束


一、先区分四种内容:事实、证据、推断和回答

Agent 生成回答时,至少存在四个不同层次。

1. 事实主张

事实主张是回答中可以被判断为真或假的命题。例如:

LangGraph 的 checkpointer 按 thread 保存图状态快照。

这句话包含了一个明确命题:

主体:LangGraph checkpointer
关系:保存
客体:图状态快照
作用域:单个 thread

工程上应把回答拆成主张,而不是把整段回答视为一个不可分割的字符串。

设回答包含主张集合:

C={c1,c2,,cn}C = \{c_1,c_2,\dots,c_n\}

其中每个 cic_i 都应能独立判断:

  • 是否有证据支持;
  • 证据支持的是全部主张还是部分主张;
  • 是否存在相互冲突的证据;
  • 是否因时间、版本、权限或数据域而改变。

2. 证据片段

证据片段是从某个来源中截取出来、足以支持一个或多个主张的最小内容单元。

例如,文档中有一段:

Checkpointers persist a thread’s graph state as checkpoints. Stores persist application-defined data outside the graph state.

可以拆成两个证据片段:

E1:
  checkpointer → thread-scoped graph state snapshots

E2:
  store → application-defined data outside graph state

证据片段不等于搜索结果标题,也不等于整个文档。搜索结果只能说明“可能相关”,不能自动证明命题成立。

3. 来源

来源是证据片段的载体,例如:

  • 官方文档;
  • API 参考;
  • 数据库记录;
  • 工单;
  • 代码提交;
  • 业务系统返回结果;
  • 用户明确提供的信息。

来源通常需要包含:

source_id
source_type
canonical_locator
title
version
published_at
retrieved_at
authority
content_hash
access_scope

其中:

  • canonical_locator:稳定定位信息,例如 URL、文档 ID、文件路径;
  • version:文档或业务数据版本;
  • published_at:来源发布或生效时间;
  • retrieved_at:Agent 获取该来源的时间;
  • authority:来源权威等级;
  • content_hash:内容指纹,用于判断引用时内容是否已经变化;
  • access_scope:用户或 Agent 是否有权访问该来源。

4. 推断

推断是 Agent 根据一个或多个证据片段得到的结论,但该结论未必在原文中直接出现。

例如:

E1:checkpointer 保存单个 thread 的图状态快照
E2:长对话会积累 checkpoint
推断:如果不设置保留策略,长会话可能增加存储成本

“可能增加存储成本”是合理推断,但不是 E1 单独直接陈述的事实。

因此,引用系统必须区分:

direct_support      # 原文直接支持
derived_support     # 由多个证据推导
unsupported         # 没有可接受证据
contradicted        # 被证据反驳

最常见的错误是把推断写成事实,把相关证据伪装成直接证据。


二、证据片段不是“相关文本”,而是可验证的最小单元

2.1 证据片段的四个必要条件

一个可用于引用的证据片段,至少应满足四个条件。

条件一:定位稳定

用户必须能够重新找到它。

只保存:

{
  "text": "checkpointers persist state"
}

是不够的,因为这段文本可能出现在多个文档、多个版本甚至不同上下文中。

更可靠的表示是:

{
  "source_id": "langgraph-persistence",
  "locator": {
    "url": "https://docs.langchain.com/oss/python/langgraph/persistence",
    "section": "Checkpointer vs. store",
    "line_start": 64,
    "line_end": 72
  },
  "content_hash": "sha256:..."
}

行号不是所有来源都具备,因此可以使用多级定位:

URL
→ 文档版本
→ 章节标题
→ 页面内锚点
→ 段落序号
→ 字符范围
→ 内容哈希

条件二:语义完整

片段必须保留足够上下文,避免断章取义。

错误示例:

“does not persist”

这句话没有主语和作用域。它可能指:

  • 内存保存器;
  • 某个 response;
  • 某种数据;
  • 某一类会话。

更好的片段应包括完整命题:

“InMemorySaver stores checkpoints in RAM. When the process restarts,
all checkpoints are lost.”

条件三:时间可解释

知识不是永恒不变的。一个配置项在 2025 年有效,不代表在 2026 年仍有效。

因此,证据至少要记录两个时间:

published_at  # 来源声明的发布时间或生效时间
retrieved_at  # Agent 实际检索时间

当用户问“当前版本如何配置”时,retrieved_at 说明 Agent 何时看到它;当用户问“2025 年当时的行为”时,published_at 或历史版本更重要。

条件四:权限可解释

证据可能来自用户无权查看的内部系统。Agent 可以内部使用它,但不能把敏感原文直接暴露给用户。

因此引用对象要区分:

evidence_visibility:
  internal_only
  user_visible
  redacted
  aggregate_only

“有引用”不等于“可以把原文全部展示出来”。生产系统通常需要把内部证据映射为:

来源名称 + 脱敏摘要 + 可授权链接

三、推荐的数据模型:把回答从字符串变成带证据的对象

3.1 来源、片段和主张三层模型

一个实用的数据模型如下:

{
  "sources": [
    {
      "source_id": "s1",
      "type": "official_documentation",
      "title": "Persistence",
      "locator": {
        "url": "https://docs.langchain.com/oss/python/langgraph/persistence",
        "section": "Checkpointer vs. store"
      },
      "version": "2026-09",
      "retrieved_at": "2026-09-01T10:00:00+08:00",
      "authority": 0.95,
      "content_hash": "sha256:..."
    }
  ],
  "evidence": [
    {
      "evidence_id": "e1",
      "source_id": "s1",
      "text": "Checkpointers persist graph state snapshots...",
      "span": {
        "line_start": 64,
        "line_end": 72
      },
      "retrieval_score": 0.88,
      "support_type": "direct"
    }
  ],
  "claims": [
    {
      "claim_id": "c1",
      "text": "checkpointer 用于保存单个 thread 的图状态",
      "evidence_ids": ["e1"],
      "status": "supported",
      "confidence": 0.93
    }
  ]
}

这里有三个不能混淆的分数:

  • retrieval_score:片段与查询的相关性;
  • authority:来源的权威程度;
  • confidence:该主张在当前证据下的可信程度。

高相关性不代表高权威。例如,一篇博客可能非常贴合问题,但不能压过同版本官方 API 文档。

3.2 主张—证据映射是有向图

可以把引用关系表示为图:

graph LR
    Q[用户问题] --> C1[主张 c1]
    Q --> C2[主张 c2]

    S1[来源 s1: 官方文档] --> E1[证据 e1]
    S2[来源 s2: 运行日志] --> E2[证据 e2]
    S3[来源 s3: 旧版本文档] --> E3[证据 e3]

    E1 --> C1
    E2 --> C2
    E3 --> C1

    C1 --> A[回答]
    C2 --> A

关键点是:

  1. 一个来源可以包含多个证据片段;
  2. 一个主张可以由多个片段共同支持;
  3. 一个片段可以支持多个主张;
  4. 来源之间可能处于冲突关系;
  5. 回答只是主张集合的语言化结果,不是证据本身。

如果系统只保存最终回答和几个 URL,就无法知道某个句子究竟由哪一段内容支持。


四、来源映射:引用必须回答“哪句话由什么证据支持”

4.1 映射的基本关系

定义来源映射:

MC×EM \subseteq C \times E

其中:

  • CC 是主张集合;
  • EE 是证据片段集合;
  • (ci,ej)M(c_i,e_j)\in M 表示证据 eje_j 支持主张 cic_i

但单纯的二元关系仍然不够。还需要记录支持类型和覆盖范围:

m(ci,ej)=(support,coverage,scope,time)m(c_i,e_j) = (\text{support}, \text{coverage}, \text{scope}, \text{time})

例如:

{
  "claim_id": "c1",
  "evidence_id": "e1",
  "support": "direct",
  "coverage": 1.0,
  "scope": "LangGraph Python OSS",
  "valid_at": "2026-09"
}

4.2 覆盖率不等于可信度

假设主张为:

LangGraph 的 checkpointer 保存单个 thread 的状态,并且生产环境重启后不会丢失这些状态。

第一半可能由官方文档支持,第二半却与内存实现有关。即使一个片段覆盖了主句的大部分内容,也不能把整个句子标记为已验证。

可定义主张覆盖率:

coverage(ci)=被证据直接或可接受地支持的语义单元数主张总语义单元数\operatorname{coverage}(c_i) = \frac{\text{被证据直接或可接受地支持的语义单元数}} {\text{主张总语义单元数}}

若把该句拆成两个语义单元:

u1:checkpointer 保存单个 thread 的状态
u2:生产环境重启后状态不会丢失

而证据只支持 u1,则:

coverage(ci)=12=0.5\operatorname{coverage}(c_i)=\frac{1}{2}=0.5

此时正确做法不是降低语气后继续输出整句话,而是拆句:

Checkpointer 用于保存单个 thread 的图状态。是否能跨进程重启保留状态,取决于具体实现;内存保存器不会持久化到进程外。

官方 LangGraph 文档明确区分了 checkpointer 与 store:前者保存单个 thread 的图状态快照,后者保存跨 thread 的应用数据;同时,InMemorySaverMemorySaver 的数据位于内存中,进程重启后会丢失。(docs.langchain.com)

4.3 引用粒度要与主张粒度一致

以下引用方式不可靠:

LangGraph 有短期记忆、长期记忆、故障恢复、时间旅行、跨线程共享知识等能力。[引用一个 URL]

因为这句话包含多个独立主张,应该拆成:

c1:checkpointer 提供 thread 级短期状态
c2:store 提供跨 thread 的长期数据
c3:checkpointer 可用于故障恢复
c4:checkpointer 支持 time travel

每一个主张都要有独立证据,或者明确标记为“由文档能力组合推导”。


五、从检索结果到引用:Agent 的完整数据流

知识引用通常位于 Agentic RAG 的后半段,但它会反向约束前半段的检索设计。

5.1 检索结果必须保留元数据

普通检索接口经常只返回:

[
    "一段文本",
    "另一段文本"
]

这样的结果无法可靠引用。至少应该返回:

[
    {
        "document_id": "doc-001",
        "chunk_id": "doc-001#p12",
        "text": "...",
        "score": 0.91,
        "source_url": "...",
        "section": "...",
        "version": "...",
        "retrieved_at": "...",
        "access_scope": "user-visible"
    }
]

chunk_id 是内部稳定标识,source_url 是用户可访问定位,二者不应混为一谈。

5.2 查询分解会改变引用结构

用户问题可能同时包含多个子问题:

OpenAI 的会话状态如何跨轮次保存?LangGraph 的 checkpointer 和 store 有什么区别?内存保存器重启后会怎样?

可以分解为:

q1:OpenAI 如何传递上一轮上下文?
q2:OpenAI Conversations API 的作用域是什么?
q3:LangGraph checkpointer 保存什么?
q4:LangGraph store 保存什么?
q5:内存保存器是否跨重启持久化?

如果不分解,检索器可能找到一段“看起来相关”的材料,然后让模型用它回答全部问题。这会制造证据越权:一段证据只支持 q1,却被用于 q2~q5。

5.3 重排不能替代证据判断

重排模型只负责判断“这段材料是否更相关”,不能直接判断:

这段材料是否真的蕴含该主张?

因此应至少有两个阶段:

相关性排序:
  query ↔ chunk

证据验证:
  claim ↔ chunk

形式化表示为:

R(q,e)relevanceR(q,e) \rightarrow \text{relevance}

V(c,e){entailed,partially_entailed,unknown,contradicted}V(c,e) \rightarrow \{\text{entailed},\text{partially\_entailed},\text{unknown},\text{contradicted}\}

其中:

  • relevance 表示证据是否与问题主题相关;
  • entailed 表示证据是否蕴含主张;
  • partially_entailed 表示只支持主张的一部分;
  • unknown 表示无法判断;
  • contradicted 表示证据与主张相反。

只有 relevance 高且 V(c,e)entailed 或可接受的 partially_entailed,片段才适合作为引用依据。


六、冲突不是异常数据,而是知识系统的正常状态

6.1 什么是冲突

两个证据发生冲突,不是指它们文字不同,而是它们对同一个语义命题给出了不可同时成立的结论。

例如:

证据 e1:内存保存器的数据在进程重启后丢失
证据 e2:内存保存器的数据在进程重启后保留

二者对命题:

P:内存保存器可以跨进程重启持久化

分别给出:

e1:¬P
e2:P

但下面两段不一定冲突:

e1:Responses response 默认保存 30 天
e2:Conversation 对象中的项目不受 30 天 TTL 限制

它们作用于不同对象:

response object ≠ conversation item

OpenAI 的会话状态文档同时说明,Response 对象默认保存 30 天,而附加到 Conversation 的项目不受该 30 天 TTL 限制;如果忽略对象类型,就会错误地把两条规则判定为冲突。(developers.openai.com)

6.2 冲突检测的五步

对每个候选冲突,按以下顺序处理。

第一步:规范化主张

把自然语言转成带作用域的结构:

subject:response object
predicate:retained_for
object:30 days
scope:default

和:

subject:conversation item
predicate:retained_for
object:unlimited by 30-day TTL
scope:attached to conversation

对象不同,因此不冲突。

第二步:对齐时间

两个证据可能分别适用于:

v1.0, 2025-01
v2.0, 2026-09

这时不是简单选择“最新文本”,而是回答:

  • 用户问哪个版本;
  • 哪个版本在目标环境中实际运行;
  • 旧行为是否仍影响历史数据;
  • 是否存在迁移期或兼容模式。

第三步:对齐作用域

需要检查:

产品
API
模型
语言
部署模式
租户
用户权限
数据中心

例如,“默认行为”与“配置后行为”不能直接比较;“Cloud Agent Server 自动持久化”也不能推导出“本地内存保存器自动持久化”。

LangGraph 文档指出,使用 Agent Server 时,持久化基础设施由服务器处理;而在直接编译图时,需要显式配置 checkpointer 或 store。两种运行形态不同,不能把一个环境中的保证扩展到另一个环境。(docs.langchain.com)

第四步:判定证据等级

可采用一个明确但可配置的优先级:

同版本官方规范 / API 文档
  >
官方源码与测试
  >
运行时观测结果
  >
官方示例
  >
经过认证的内部文档
  >
社区文章
  >
模型记忆

但“官方文档优先”不是绝对规则。对于实时业务数据,数据库当前读数可能比静态文档更能说明当前状态;对于已部署系统,运行时观测可能揭示文档未覆盖的配置行为。

第五步:保留未解决冲突

如果冲突无法消解,不应强行选一条。输出应明确:

来源 A 在版本 v1 中说明 X;
来源 B 在版本 v2 中说明 Y;
当前无法仅凭公开材料确定你的部署使用哪种行为。

这比生成一个看似确定但不可验证的答案更安全。

6.3 冲突决策函数

可以把证据优先级写成一个评分模型:

S(e,c)=waA(e)+wtT(e,c)+wsG(e,c)+wvV(e,c)wxX(e)S(e,c)= w_a A(e)+ w_t T(e,c)+ w_s G(e,c)+ w_v V(e,c)- w_x X(e)

其中:

  • A(e)A(e):来源权威度;
  • T(e,c)T(e,c):时间匹配度;
  • G(e,c)G(e,c):作用域匹配度;
  • V(e,c)V(e,c):对主张的直接支持程度;
  • X(e)X(e):冲突风险;
  • ww_*:业务配置的权重。

直觉是:一条证据即使很新,如果版本和作用域不匹配,也不应该压过一条适用范围明确的旧证据。


七、可验证回答:不是“有链接”,而是“可复核”

7.1 可验证回答的定义

一个回答 AA 是可验证的,至少满足:

ciA,Ei:locatable(Ei)supports(Ei,ci)scope_matches(Ei,ci)\forall c_i \in A,\quad \exists E_i: \operatorname{locatable}(E_i) \land \operatorname{supports}(E_i,c_i) \land \operatorname{scope\_matches}(E_i,c_i)

也就是说,每个可核查主张都必须有:

  1. 可定位的证据;
  2. 足以支持主张的内容;
  3. 匹配的版本、时间和作用域。

这不要求每句话都附引用。寒暄、格式说明和纯粹的组织性文字不需要引用;但以下内容通常需要:

  • API 行为;
  • 版本差异;
  • 存储、权限、计费和安全保证;
  • 业务数据;
  • “默认”“始终”“不会”“保证”等绝对化表述。

7.2 引用位置

引用应尽量紧邻主张:

Responses API 可以通过 `previous_response_id` 将响应串成线程式上下文。([developers.openai.com](https://developers.openai.com/api/docs/guides/conversation-state))

而不是把多个段落的所有证据集中到末尾。OpenAI 文档把 previous_response_id 定义为用于链接响应、创建 threaded conversation 的参数;它与 Conversations API 的持久化会话对象是两种不同的状态管理方式。(developers.openai.com)

7.3 “引用存在”仍可能不可验证

以下情况都会导致伪可验证:

1. 引用指向搜索结果而非原文;
2. 链接需要用户没有的权限;
3. 来源内容已经变化但没有版本或哈希;
4. 片段被截断,缺失否定词或作用域;
5. 引用只支持句子的前半部分;
6. 回答使用了证据中没有的数字、因果或绝对结论;
7. 引用对象与回答对象不一致。

例如:

证据:系统支持持久化会话状态
回答:系统永远不会丢失任何状态

这里从“支持持久化”跳到了“永远不会丢失”,中间缺少:

  • 故障模型;
  • 事务边界;
  • 写入确认;
  • 备份策略;
  • 恢复策略;
  • 数据保留策略。

因此,回答即使附了正确链接,仍然不可验证。


八、一个最小可运行的引用决策器

下面的示例不依赖模型和向量数据库,用 Python 标准库演示三个核心动作:

  1. 保存证据片段及其来源;
  2. 对主张进行直接支持、部分支持和冲突标记;
  3. 只把达到阈值的主张写入回答。
from dataclasses import dataclass
from typing import Literal


Support = Literal["supports", "contradicts", "unknown"]


@dataclass
class Source:
    source_id: str
    title: str
    locator: str
    authority: float
    version: str


@dataclass
class Evidence:
    evidence_id: str
    source_id: str
    text: str
    support: Support
    scope: str
    version: str


@dataclass
class Claim:
    claim_id: str
    text: str
    evidence_ids: list[str]


sources = {
    "s1": Source(
        source_id="s1",
        title="LangGraph Persistence",
        locator="official-docs#checkpointer-vs-store",
        authority=0.95,
        version="2026-09",
    )
}

evidence = {
    "e1": Evidence(
        evidence_id="e1",
        source_id="s1",
        text="Checkpointers persist a thread's graph state as checkpoints.",
        support="supports",
        scope="LangGraph OSS Python",
        version="2026-09",
    ),
    "e2": Evidence(
        evidence_id="e2",
        source_id="s1",
        text="InMemorySaver stores checkpoints in RAM; after restart they are lost.",
        support="contradicts",
        scope="InMemorySaver",
        version="2026-09",
    ),
}


def evaluate_claim(claim: Claim, evidence_map: dict[str, Evidence]) -> dict:
    matched = [evidence_map[eid] for eid in claim.evidence_ids]

    supporting = [
        e for e in matched
        if e.support == "supports"
    ]
    contradicting = [
        e for e in matched
        if e.support == "contradicts"
    ]

    if not supporting:
        status = "unsupported"
    elif contradicting:
        status = "conflict"
    else:
        status = "supported"

    # 这里的分数只是示例规则,不是通用概率。
    score = 0.0
    if supporting:
        score = max(
            sources[e.source_id].authority
            for e in supporting
        )

    return {
        "claim_id": claim.claim_id,
        "text": claim.text,
        "status": status,
        "score": round(score, 2),
        "evidence_ids": claim.evidence_ids,
    }


claims = [
    Claim(
        claim_id="c1",
        text="checkpointer 用于保存单个 thread 的图状态",
        evidence_ids=["e1"],
    ),
    Claim(
        claim_id="c2",
        text="所有 checkpointer 都能跨进程重启保存状态",
        evidence_ids=["e1", "e2"],
    ),
]

for claim in claims:
    print(evaluate_claim(claim, evidence))

预期输出类似:

{
  'claim_id': 'c1',
  'text': 'checkpointer 用于保存单个 thread 的图状态',
  'status': 'supported',
  'score': 0.95,
  'evidence_ids': ['e1']
}
{
  'claim_id': 'c2',
  'text': '所有 checkpointer 都能跨进程重启保存状态',
  'status': 'conflict',
  'score': 0.95,
  'evidence_ids': ['e1', 'e2']
}

这个示例有一个重要限制:它把证据的 support 作为输入,实际系统不能依赖人工预填。真实流程应由证据验证器根据主张和片段计算:

claim + evidence
  ↓
蕴含判断
  ↓
scope/version 判断
  ↓
支持、部分支持、未知或冲突

示例中的 score 也不是概率。它只是来源权威度的简单演示,不能直接解释为“95% 正确”。


九、回答生成应采用“先验证、后表述”

不可靠的流程是:

检索 → 直接把片段塞进 prompt → 生成答案

更可控的流程是:

检索
  ↓
候选证据归一化
  ↓
生成原子主张
  ↓
验证主张—证据关系
  ↓
冲突分类
  ↓
决定回答、限定回答或拒答
  ↓
渲染引用

9.1 主张状态机

可以把每个主张建模为状态机:

stateDiagram-v2
    [*] --> Candidate
    Candidate --> Supported: 有直接证据且作用域匹配
    Candidate --> PartiallySupported: 仅支持部分语义
    Candidate --> Conflicted: 存在未消解矛盾
    Candidate --> Unknown: 证据相关但不能蕴含
    Candidate --> Unsupported: 没有可接受证据

    PartiallySupported --> Supported: 拆分主张或补充证据
    Conflicted --> Supported: 完成版本/作用域消歧
    Conflicted --> Escalated: 无法消歧
    Unknown --> RetrievedMore: 继续检索
    RetrievedMore --> Supported
    RetrievedMore --> Escalated

每次状态变化都应记录原因:

{
  "claim_id": "c2",
  "from": "candidate",
  "to": "conflicted",
  "reason": "同一作用域下存在相反证据",
  "evidence_ids": ["e1", "e2"],
  "timestamp": "2026-09-01T10:03:00+08:00"
}

这样在生产事故中,工程师可以回答:

  • Agent 为什么相信这句话;
  • 哪个证据触发了拒答;
  • 是检索失败、版本误判,还是冲突策略失败;
  • 重新运行时是否得到相同结论。

9.2 三种回答形态

证据充分

结论:可以这样配置。

依据:
- 证据 e1 明确说明……
- 证据 e2 明确说明……

限制:
- 该结论仅适用于版本 2026-09。

证据部分充分

已确认:checkpointer 保存 thread 级图状态。
尚未确认:你的部署是否能跨进程重启保留状态,因为这取决于保存器实现。

证据冲突

当前存在冲突:
- 文档 A 对版本 v1 的描述是 X;
- 文档 B 对版本 v2 的描述是 Y。

请先确认实际运行版本或提供部署配置;在此之前不应把 X 或 Y 当作无条件结论。

拒答不是“我不知道”的同义改写,而是一个有证据原因的状态:

拒答原因 = 证据不足 ∨ 未解决冲突 ∨ 权限不足 ∨ 作用域不匹配

十、与会话状态和 Agent 持久化的关系

知识引用属于上下文与记忆模块,但它不应简单等同于聊天历史。

10.1 会话历史不是来源证明

OpenAI 文档指出,单次文本生成请求本身是独立且无状态的;应用可以通过手动传入历史消息来实现多轮对话。(developers.openai.com)

这意味着:

聊天历史 = 模型输入上下文
来源映射 = 证据审计关系

把引用信息仅放在聊天消息中,会产生几个问题:

  • 压缩上下文时引用关系可能被丢弃;
  • 同一来源被重复复制;
  • 后续 Agent 无法知道引用对应哪个版本;
  • 用户编辑或删除消息后,审计链断裂;
  • 工具结果和最终回答之间没有结构化关系。

因此,推荐把引用作为独立状态:

state = {
    "messages": [...],
    "claims": [...],
    "evidence": [...],
    "source_map": [...],
    "conflicts": [...],
    "answer_status": "supported",
}

10.2 previous_response_id 与 Conversation 的区别

OpenAI 的 Responses API 支持使用 previous_response_id 链接前后响应,形成线程式对话;也支持创建具有持久标识符的 Conversation 对象,并在后续响应中传入该 Conversation。(developers.openai.com)

从引用系统角度看,二者都只解决“上下文如何继续传递”的问题,不自动解决:

哪些事实被引用;
引用来自哪个版本;
某条主张是否与新证据冲突;
引用是否仍对当前问题有效。

此外,官方文档说明,即使使用 previous_response_id,链中之前的输入 token 仍会作为输入计费;上下文窗口也包含输入、输出和推理 token。(developers.openai.com)

因此,长时间运行的 Agent 不应把完整检索片段无限追加到会话历史。更合理的做法是:

会话消息:
  保存用户意图和必要上下文

引用状态:
  保存 claim/evidence/source 图

回答摘要:
  保存已经验证的结论和失效条件

10.3 LangGraph 的 checkpointer 与 store

LangGraph 将持久化分为两个互补层次:

  • checkpointer:保存单个 thread 的图状态快照,用于短期、线程级状态;
  • store:保存图状态之外的应用定义数据,用于跨 thread 的长期记忆。(docs.langchain.com)

可以这样划分引用数据:

checkpointer:
  当前任务的 claims
  本轮检索结果
  未解决冲突
  当前回答草稿
  人工审核暂停点

store:
  文档目录
  来源版本索引
  用户可授权的来源映射
  跨会话的知识实体
  已确认的业务规则

最小配置示例:

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

checkpointer = InMemorySaver()
store = InMemoryStore()

graph = builder.compile(
    checkpointer=checkpointer,
    store=store,
)

result = graph.invoke(
    {
        "messages": [
            {"role": "user", "content": "检查这个结论是否有证据"}
        ],
        "claims": [],
        "evidence": [],
        "conflicts": [],
    },
    {
        "configurable": {
            "thread_id": "citation-review-001"
        }
    },
)

官方示例要求在调用图时通过 configurable.thread_id 指定线程;文档还说明,内存保存器位于 RAM,进程重启后 checkpoint 会丢失,生产环境应使用持久化保存器。(docs.langchain.com)

这里要注意一个边界:

保存引用状态 ≠ 保证引用内容永久可访问

如果来源是外部网页,网页可能更新或删除;如果来源是业务数据库,记录可能被修改;如果来源是权限控制系统,用户之后可能失去访问权。因此应保存:

source_id
source_version
content_hash
retrieved_at
引用片段快照
用户可访问性

十一、并发和故障路径:引用状态必须可合并

Agent 常常并行执行多个检索器:

查询分支 A:官方文档
查询分支 B:代码仓库
查询分支 C:运行时配置

三个分支可能同时写入:

evidence[]
claims[]
conflicts[]

如果直接覆盖状态,会出现:

分支 A 写入 e1
分支 B 写入 e2
分支 B 的旧快照覆盖分支 A
最终只剩 e2

11.1 使用追加集合而不是覆盖字段

适合并发合并的数据结构是集合:

state["evidence_by_id"][evidence_id] = evidence
state["claims_by_id"][claim_id] = claim

合并规则:

同 evidence_id + 相同 content_hash → 幂等合并
同 evidence_id + 不同 content_hash → 版本冲突
同 claim_id + 新证据增加 → 重新评估

不要使用:

state["evidence"] = latest_branch_result

除非图框架明确保证单写者或事务串行化。

11.2 检索失败与验证失败要分开

两种失败具有不同含义:

检索失败:
  没有得到足够候选证据

验证失败:
  得到相关材料,但材料不蕴含主张

来源不可访问:
  证据可能存在,但当前凭证无法读取

冲突失败:
  多条高质量证据无法同时成立

对应的回退动作不同:

失败类型 合理动作
检索失败 查询改写、查询分解、扩大来源范围
验证失败 降低主张范围、拆句或拒答
来源不可访问 请求授权、使用公开摘要或标记不可验证
冲突失败 版本消歧、请求环境信息或升级人工

不能把所有失败都处理成“再检索一次”。如果问题是作用域不明确,重复检索只会增加更多相互矛盾的文本。


十二、常见错误及其失败机制

错误一:引用整个文档

回答:系统支持持久化、回滚和跨会话记忆。[引用整篇文档]

失败原因是文档中可能分别描述多个组件,而回答没有说明每个能力由什么组件提供。正确做法是把能力拆为主张,并分别映射到证据片段。

错误二:把检索排名当作真实性

top_k[0] = 相关性最高
因此 top_k[0] = 事实依据

相关性只是检索排序信号。广告页、过期文档和错误配置示例都可能与查询高度相关。

错误三:忽略否定词和限定词

以下词语会改变命题:

默认
通常
仅在……时
不包括
除非
实验性
单个 thread
跨 thread
进程重启后

切片时如果把“在默认配置下不会……”截成“不会……”,引用会反转原意。

错误四:把相关来源混成同一来源

用户问的是运行时行为,Agent 引用了设计文档;用户问的是当前版本,Agent 引用了旧版本;用户问的是企业租户,Agent 引用了公共环境。

这不是“引用不够多”,而是来源映射的作用域错误。

错误五:只存 URL,不存证据快照

URL 可以继续访问,但内容已经变化。此时用户打开链接看到的新内容可能不再支持原回答。

至少要保存:

抓取时间
版本
片段文本
内容哈希
章节定位

错误六:把模型自检当作事实验证

让同一个模型生成回答后再问“这句话是否有依据”,只能提高格式质量,不能构成独立验证。更可靠的组合是:

生成主张
→ 独立检索
→ 证据蕴含判断
→ 规则检查
→ 必要时人工审核

所谓“独立”不一定意味着使用不同模型,但至少应避免验证器直接复用生成器未经审查的结论。


十三、生产诊断:从错误回答反查证据链

当用户指出回答错误时,不要只记录最终文本。应按以下顺序检查。

1. 检查主张拆分

错误是否来自一句话包含多个未拆分命题?

“支持持久化并且不会丢数据”

可能实际包含:

支持写入
支持跨重启
支持故障恢复
支持零数据丢失

2. 检查证据覆盖范围

确认每个主张是否有:

direct support
partial support
derived support

如果只有“相关”,不能标记为“支持”。

3. 检查版本和作用域

记录:

回答目标版本
证据版本
运行时版本
部署模式
租户和权限

4. 检查冲突是否被吞掉

日志中应能看到:

{
  "claim_id": "c7",
  "candidate_evidence": ["e10", "e11"],
  "conflict_detected": true,
  "resolution": "latest_version",
  "resolution_reason": "用户指定 v2.1"
}

如果只看到最终引用,看不到被放弃的冲突证据,后续无法解释 Agent 的决策。

5. 检查回答是否超出证据

可以做一个简单的后处理:

回答句子 → 原子主张
原子主张 → 证据映射
无映射的事实句 → 阻止发布或标记未验证

特别需要拦截:

所有、始终、保证、绝不会、唯一、零风险

这些词通常意味着回答引入了证据中不存在的强断言。


十四、引用质量的评估指标

引用评估不能只测“回答是否正确”,还要测“回答是否能被证明”。

14.1 证据覆盖率

EvidenceCoverage=#有可接受证据的主张#需要证据的主张\operatorname{EvidenceCoverage} = \frac{\#\text{有可接受证据的主张}} {\#\text{需要证据的主张}}

它衡量回答中有多少需要验证的主张具备证据。

14.2 引用精确率

CitationPrecision=#引用确实支持对应主张#全部引用\operatorname{CitationPrecision} = \frac{\#\text{引用确实支持对应主张}} {\#\text{全部引用}}

引用很多但大部分只“看起来相关”,精确率仍然很低。

14.3 主张完整性

ClaimCompleteness=#被完整支持的主张#全部主张\operatorname{ClaimCompleteness} = \frac{\#\text{被完整支持的主张}} {\#\text{全部主张}}

它区别于覆盖率:只支持句子一半,不应算完整支持。

14.4 冲突召回率

构造带版本、时间和作用域冲突的测试集,测量:

ConflictRecall=#被正确识别的冲突#实际冲突\operatorname{ConflictRecall} = \frac{\#\text{被正确识别的冲突}} {\#\text{实际冲突}}

如果冲突召回率低,系统会倾向于给出过度确定的答案。

14.5 可定位率

Locatability=#用户能够重新定位的引用#全部引用\operatorname{Locatability} = \frac{\#\text{用户能够重新定位的引用}} {\#\text{全部引用}}

一个 URL 能打开,但无法定位到支持句,也不应被视为完整可验证。


十五、设计结论

Agent 知识引用的核心不是把链接附加到答案后面,而是让回答中的每个重要主张都拥有明确的证据路径:

主张
  → 证据片段
  → 来源
  → 版本、时间、作用域
  → 支持或冲突判断
  → 最终回答中的引用

其中最重要的工程边界有四个:

  1. 相关性不是支持性:检索排名不能代替证据验证;
  2. 引用不是来源列表:必须映射到具体主张和片段;
  3. 冲突不是噪声:无法消解时应保留冲突并限制回答;
  4. 持久化不是可验证性:会话状态可以被保存,但来源内容、版本和权限仍需单独管理。

OpenAI 的会话状态能力可以帮助 Agent 跨轮次传递上下文,Conversation 对象可以提供具有持久标识的会话状态;LangGraph 则通过 checkpointer 和 store 区分 thread 级图状态与跨 thread 的长期数据。(developers.openai.com)

但无论采用哪种框架,真正可审计的知识 Agent 都必须额外维护:

claims
evidence
sources
source_map
conflicts
validity_scope
verification_status

当证据足够时,回答应清晰地说明结论;当证据只支持一部分时,应缩小主张;当来源冲突或无法定位时,应拒答、请求更多上下文或升级人工。可验证回答的价值不在于让 Agent 显得更确定,而在于让用户能够判断:这句话为什么成立,它在什么条件下成立,以及什么时候不再成立。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。