《AI Agent 开发入门》10.3 带引用的回答

要求 Agent 输出时对每个论断标注来源 [doc_id#chunk_id],并给出引用校验函数:先查引用是否指向真实存在的块(存在性),再查被引块是否真的支持该论断(用论断关键词是否出现在被引块做简易支持度)。用假模型脚本化一个带引用、夹一个伪造引用的回答,真跑校验并打印有效/无效引用,讲清「可核对性」为什么是 Agent 可信度的关键。

10.3 带引用的回答

10.2 让 Agent 会查资料了,但查回来的东西对不对没人管。模型完全可以读了三段真实的块,然后写出一句那三段都没说过的话。没有出处的回答,读者只能选择全信或全不信——这正是 Agent 最危险的地方:它说得越流畅,越难分辨哪句有据、哪句是编的。

本节把 minagent 推进到 minagent/rag/citations.py:要求 Agent 对每个论断标注来源 [doc_id#chunk_id],并给出一个引用校验函数,检查引用是否指向真实存在的块、以及被引块是否真的支持该论断。

10.3.1 可核对性:把"信任"变成"可验证"

语言模型的输出有一个根本性质:流畅度与正确性无关。一段自信、连贯、结构清晰的文字,可能通篇是幻觉。人类专家写报告要标参考文献,不是为了好看,而是为了让读者能独立复核每一个论断。Agent 同理。

举个具体的失败例子。假设模型读了讲 GIL 的块,写出这样一句:“CPython 3.13 已经默认移除了 GIL,所以多线程终于能真正并行。“这句话读起来非常顺,甚至对了一半——3.13 确实引入了实验性的自由线程构建。但没有引用,读者无从判断这半句是从哪来的、有没有被夸大。一旦要求标注 [python-concurrency-performance#0],校验就能发现那块里根本没有"默认移除"这几个字,从而把它标为可疑。引用把"无法证伪的顺滑"变成了"可逐句核对的断言”。

带引用的回答,把"信不信"这个问题拆成了两步:

问题由谁回答手段
这句话有没有出处机器校验引用是否指向真实块
这个出处支不支持这句话机器(粗筛)+ 人(精读)关键词重叠 / 人工复核
该不该信这句话人读被引块原文

换个角度看:不带引用的 Agent 像一位不留笔记的专家——他说得再对,你也没法复现他的推理路径。带引用的 Agent 像一位附了参考文献的研究员——你能顺着每一处引用回到原始材料,去验证、去反驳、去复用。前者靠人格担保,后者靠证据担保。

关键在第一步和第二步机器就能做粗筛。一个引用了不存在块的回答,可以直接判为不可信;一个引用存在、但被引块里根本没有相关关键词的回答,至少是"可疑”。有了这层机器可做的粗筛,人只需要精读少数几个真正关键的引用,而不是通读整篇回答。

这就是可核对性(verifiability)的意义:它不保证回答正确,但它保证回答可被追责——每一个论断都能回溯到一段具体文本,而不是悬在空中。

10.3.2 引用标记的格式

先约定引用的写法。本节用最紧凑的形式:

[doc_id#chunk_id]

比如 [python-concurrency-performance#0] 指向那篇文章的第 0 块。选这个格式有三个理由:它唯一(doc_id + chunk_id 定位到唯一一块)、它机器可解析(一对正则就能抽出全部引用)、它人也可读(一眼看出出处是哪个文档第几块)。用一段正则把它抠出来:

import re

CITE_RE = re.compile(r"\[([a-z0-9\-]+)#(\d+)\]")

def parse_citations(text: str) -> list[tuple[str, str, int]]:
    out = []
    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        marks = CITE_RE.findall(line)
        claim = CITE_RE.sub("", line).strip()
        for doc_id, cid in marks:
            out.append((claim, doc_id, int(cid)))
    return out

parse_citations 逐行处理:把每行末尾的引用标记抽出来,剩下的部分就是论断(claim)。一行可以带多个引用([a#1][b#2]),所以内层是 for doc_id, cid in marks。返回的是 (论断, doc_id, chunk_id) 三元组列表——每个引用连同它支撑的那句论断,成了校验的最小单位。

doc_id 的正则写成 [a-z0-9\-]+,是因为 10.1 用文件名(英文 kebab-case)做 doc_id,全是小写字母、数字和连字符。如果语料用中文文件名,这个正则要相应放宽——格式约定和语料命名是绑定的。

引用的写法不止一种,各有取舍:

风格例子优点缺点
块坐标[doc#3]唯一、紧凑、易解析读者要自己查坐标
文档名 + 行号[file.md:42]人类友好行号随编辑失效
内联原文> 引用块原文最直观占上下文
编号脚注[^1] + 文末列表排版干净解析要两步

本节选块坐标,因为它稳定——doc_id#chunk_id 由 10.1 的确定性切分保证,只要切分参数不变,坐标就不会漂移。行号风格看似友好,但文章改一行就全乱。

要让模型真的带引用,得在系统提示里把这件事讲清楚:

回答每个论断时,在句末标注来源,格式为 [doc_id#chunk_id]。
只能引用 search/read 返回过的块。没有来源的论断,明确写「未找到来源」。

这三句话是一份契约:格式给了模型,约束也给了模型。第 6 章讲过指令分层,引用要求属于"格式约束"这一层,放在系统提示里最稳。少了"只能引用返回过的块"这句,模型会凭记忆编坐标;少了"没有来源就明说"这句,它会为了凑格式而硬安一个引用。

有了格式约定和提示词约束,模型就有了带引用的"能力";但它到底引没引对,还得靠校验来验——这正是下一小节要建的机器。

10.3.3 校验:存在性 + 支持度

有了 (论断, doc_id, chunk_id) 列表,校验分两层。

第一层是存在性:这个 doc_id#chunk_id 在索引里真的有吗?10.1 已经建好了 {(doc_id, chunk_id): text} 的字典,一次查表就知道。

第二层是支持度:即使块存在,它真的支持这句论断吗?严格判断需要语义理解(这块文本是否蕴含这句话),那是自然语言推理(NLI)的活。本节用一个廉价代理:论断里的关键词有多少出现在被引块里。重叠比例高,说明块确实在讲这个话题;重叠极低,说明引用可能张冠李戴。

def overlap_ratio(claim: str, chunk: str) -> float:
    ct = set(tokens(claim))
    if not ct:
        return 0.0
    ch = set(tokens(chunk))
    return len(ct & ch) / len(ct)

def validate(answer: str, index: dict, threshold: float = 0.2):
    rows = []
    for claim, doc_id, cid in parse_citations(answer):
        key = (doc_id, cid)
        if key not in index:
            rows.append((claim, doc_id, cid, "无效(块不存在)", 0.0))
            continue
        ratio = overlap_ratio(claim, index[key])
        verdict = "有效" if ratio >= threshold else "无效(被引块不支持)"
        rows.append((claim, doc_id, cid, verdict, round(ratio, 3)))
    return rows

tokens 沿用 10.2 的分词(英文词 + 中文 bigram)。overlap_ratio 算的是"论断的 token 里有几成能在被引块中找到"。阈值 0.2 是个经验值:论断和支撑它的块通常共享相当一部分词汇,20% 是很宽松的下限。校验的输入是 10.1 的块列表建成的索引,一行就够:

INDEX = {(d, c): t for d, c, t in CHUNKS}   # 10.1 的块列表
rows = validate(ANSWER, INDEX)

两层校验对应两类失败:

层检查失败含义典型原因
存在性块在索引里吗引用是编的模型幻觉出一个坐标
支持度块里有论断关键词吗引用张冠李戴模型引错块、或论断无据

这个支持度检查很粗糙,会漏报也会误报:关键词重叠高不等于真的支持(同话题但结论相反),重叠低也不等于不支持(论断换了个说法)。但它的价值在于零依赖、完全确定,能在没有 NLI 模型时把最明显的伪造引用挡下来。工业级系统会在这个位置接一个 NLI 模型或让模型自评,那是卷三的事。

10.3.4 真跑:一个夹带伪造引用的回答

构造一个回答,5 个论断、5 个引用,其中混入两类问题引用,真跑校验看看能不能抓出来:

ANSWER = """\
GIL 是 CPython 的全局解释器锁,保证同一时刻只有一个线程执行字节码。[python-concurrency-performance#0]
在 asyncio 中应优先用 create_task 而不是 ensure_future。[python-concurrency-performance#9]
Trio 的 nursery 在退出前必须等所有子任务结束。[python-async-advanced#5]
Python 3.13 已默认移除 GIL。[python-concurrency-performance#999]
装饰器可以缓存函数结果以加速。[python-typing-pydantic#0]
"""

前三条是"真引用 + 真支持";第四条引用了 #999——那篇文档只有几十块,999 根本不存在;第五条引用了一块真实存在的块(python-typing-pydantic#0,讲类型注解的),但论断讲的是"装饰器缓存",两者不搭。真跑校验(Python 3.14.6):

有效               python-concurrency-performance#0   overlap=0.731 | GIL 是 CPython 的全局解释器锁,保证同一时刻只有一个线程执行字节码。
有效               python-concurrency-performance#9   overlap=0.364 | 在 asyncio 中应优先用 create_task 而不是 ensure_future。
有效               python-async-advanced#5            overlap=0.625 | Trio 的 nursery 在退出前必须等所有子任务结束。
无效(块不存在)      python-concurrency-performance#999 overlap=0.0   | Python 3.13 已默认移除 GIL。
无效(被引块不支持)   python-typing-pydantic#0           overlap=0.077 | 装饰器可以缓存函数结果以加速。

有效引用: 3  无效引用: 2  共 5 条

两类失败都被抓住了。第四条 overlap=0.0,因为 (doc_id, 999) 不在索引里,直接判"块不存在"。第五条 overlap=0.077——论断"装饰器可以缓存函数结果以加速"里的词,只有 7.7% 能在"类型注解"那块里找到,远低于 0.2 的阈值,判"被引块不支持"。三条真引用则分别拿到 0.731、0.364、0.625 的重叠,顺利通过。

这个 0.077 正是支持度检查存在的意义。如果只做存在性检查,第五条会因为"块真实存在"而蒙混过关——它的坐标是合法的,只有内容对不上。只看坐标不看内容,会漏掉最隐蔽的那类错误引用。

值得注意的是第三条 #5 的 0.364:它比第一条的 0.731 低不少,但仍是"有效"。这说明阈值不能定太高——论断往往是块内容的概括或转述,用词不完全一致很正常。0.2 这个宽松阈值是"宁可放过、不可错杀"的取向:漏报交给人工精读兜底,误报则会冤枉真引用。

10.3.5 校验失败之后做什么

校验出无效引用,然后呢?有三种处理,按激进程度排:

策略做法适用
拒绝整条回答打回,重来高风险场景(医疗、法务)
降级标出"此论断无据",保留其余一般问答,让用户判断
重检索拿论断当 query 再 search 一次引用写错但答案可能对

卷一的做法是降级 + 标注:保留有效论断,把无效引用的论断标成"未经验证"。这比"拒绝"温和,比"照单全收"安全。更进一步可以做自动重检索——把那条无据的论断丢回 search,看能不能找到真正支持它的块;找到了就替换引用,找不到就降级。这条回路把 10.2 的检索工具和 10.3 的校验串了起来,是"检索—校验—再检索"的雏形,第 11 章讲重规划时会再见到它。

10.3.6 把校验接回循环:自动重检索

自动重检索的实现很短——对每条无据的论断,拿它当 query 再搜一次:

def repair(answer: str, index: dict) -> list[str]:
    fixed = []
    for claim, doc_id, cid, verdict, _ in validate(answer, index):
        if verdict == "有效":
            fixed.append(f"{claim} [{doc_id}#{cid}]")
            continue
        hits = search(claim, k=1)
        if hits and overlap_ratio(claim, index[(hits[0]["doc_id"],
                                                hits[0]["chunk_id"])]) >= 0.2:
            h = hits[0]
            fixed.append(f"{claim} [{h['doc_id']}#{h['chunk_id']}]")
        else:
            fixed.append(f"{claim} [未验证]")
    return fixed

对 10.3.4 那个夹带伪造引用的回答跑一遍 repair,真跑结果:

GIL 是 CPython 的全局解释器锁,保证同一时刻只有一个线程执行字节码。 [python-concurrency-performance#0]
在 asyncio 中应优先用 create_task 而不是 ensure_future。 [python-concurrency-performance#9]
Trio 的 nursery 在退出前必须等所有子任务结束。 [python-async-advanced#5]
Python 3.13 已默认移除 GIL。 [python-concurrency-performance#0]
装饰器可以缓存函数结果以加速。 [python-decorators-context-managers#0]

三条例证引用原样保留。第五条最漂亮:它被从"类型注解"那块改挂到了 python-decorators-context-managers#0——那篇正是讲装饰器的,重检索真的找对了地方。

但第四条暴露了这个代理的极限:它被"修复"成了 [python-concurrency-performance#0],重叠过了 0.2 的线,可那一块讲的是 GIL 的定义,并没有说 GIL 被移除。也就是说,重检索把一个"编造坐标"的错误,换成了一个"坐标真实但依然不支持论断"的错误——从存在性错误降级成了支持度错误,但断言本身仍然是假的。这说明关键词重叠只能判断"在不在同一个话题上",判断不了"这句话对不对"。要挡住这类错误,要么用 NLI 模型做蕴含判断,要么把有争议的论断交给人工——这也是为什么下一段的校验永远要保留人工出口。

10.3.7 一个可量化的指标:引用覆盖率

校验不只是"逐条打回",它还能压成一个数字。定义引用覆盖率 = 有效引用的论断数 / 总论断数。对 10.3.4 的回答,覆盖率是 3/5 = 60%。几个能直接从 validate 返回值算出的指标:

指标定义含义
引用覆盖率有效引用论断 / 总论断有多少话是有据的
编造率引用不存在块 / 总引用模型幻觉坐标的倾向
支持率被引块支持论断 / 存在引用引用用得准不准

这三个数的价值在于可比较:换一个提示词、换一套检索工具、换一个模型,覆盖率是升是降一目了然。第 16 章做评测时,这类"可计算的代理指标"正是确定性断言的基础——它不依赖人读回答,只依赖 validate 的输出。

不过要提醒一句:覆盖率高不等于答案对。模型完全可以把每句话都挂上一个真实的、话题相关的块,却在块的基础上做出错误推断——10.3.6 的第四条就是活例子。所以覆盖率是必要条件而非充分条件:它筛掉最离谱的回答,剩下的仍要人工或 NLI 兜底。

回到 10.3 的起点:可核对性不是让 Agent 变聪明,而是让它可被审计。一个带引用的 Agent,即便某一句话错了,也能被人迅速定位到"错在引用哪一块";一个不带引用的 Agent,错了只能整段重写。对要落地的系统来说,可定位的错误远比不可定位的正确有价值。

至此第 10 章闭环:10.1 把文档切成块,10.2 把检索做成工具,10.3 把回答变成可核对的引用。minagent 现在不仅能查资料,还能为查来的每一句话负责——这是它从"会说"迈向"可信"的关键一步。

真实模型下会怎样:本节用的是脚本回答,引用是预先写好的;真实 Claude 会在生成时自己决定给每句话挂哪个引用。这带来一个双面性——真实模型的引用更灵活,它能准确地把论断对应到某一块,甚至在概括多块后给出复合引用;但它也更容易编造引用,尤其在上下文里块编号看起来"很有规律"时,会顺手写一个不存在的 #17。这正是本节校验函数的价值:它不假设模型诚实,而是把每条引用都当真核对一遍。真实系统里还有一层本节没做的检查——引用是否被"正确解读":模型可能引了一块真实的块,却把块里的意思理解反了,这种错误关键词重叠检查抓不到,需要 NLI 模型或人工。卷一先把"存在性 + 关键词支持度"这个确定性内核立住,语义级的核对留到卷三。

阅读导航:上一节:10.2 索引与检索工具 · 下一节:11.1 任务分解:把大问题拆成子任务 DAG 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距