《AI Agent 工程实战》12.1 评测集与评分器:成功率/引用准确率/成本

本节给 ResearchCopilot 建一个可版本化的评测集:JSON 存问题、期望要点与必须引用的来源,配套「加样本写理由、改期望留痕」的维护纪律;实现成功率、引用准确率、成本三个评分器,对 10 条样本真跑并打印逐条得分与汇总,最后给出自动打分与人工评审的分界表。

12.1 评测集与评分器:成功率/引用准确率/成本

卷一第 16 章已经把「怎么测 Agent」讲透了:用假模型脚本驱动 run(),把轨迹折成几个 0~1 的指标。但那是引擎视角的评测——它关心 minagent 本身对不对。到了卷二,ResearchCopilot 是一个真实服务,它的问题不再是「循环转不转」,而是「这一版上线会不会让用户拿到的报告变差」。要回答后者,评测集就不能再散落在 pytest 用例里,而必须是一份能被版本管理、能被 CI 消费的资产。

本节把 ResearchCopilot 推进到「评测资产化」:把问题、期望要点、必须引用的来源存进一份可版本化的 JSON,配三个评分器(成功率、引用准确率、成本),对 10 条样本真跑一遍,打印逐条得分与汇总。

12.1.1 为什么评测集必须版本化

先看一个真实会发生的场景。你改了一句系统提示,跑评测发现成功率从 90% 掉到 80%。你想确认是提示词的锅,于是打开评测集想核对——结果发现「评测集」其实是三个人各自在本地 tmp/ 里攒的一堆脚本,没有人知道上一版长什么样。分数掉了,但你没法证明是代码退化的,还是评测集本身被人悄悄改动了。

评测集要能当回归网,前提是它本身也是受控的。这意味着三件事:

纪律含义违反的后果
加样本写理由每条用例带 reason 字段,说明它守住什么说不清评测集覆盖了什么
改期望值留痕期望值改动走版本控制,有 diff 可查偷偷放宽期望,分数虚高
样本只增不删旧样本过时标 retired,不物理删除历史回归能力被抹掉

这三条不是官僚主义。对比实验(卷一 16.3)的全部前提就是「两版跑的是同一批用例」;如果评测集本身可以随意改,那对比就退化成了「凭感觉」。把评测集当成代码来管——评审、版本、变更理由——是它值得信任的唯一来源。

12.1.2 评测集的形状:JSON + 维护纪律

ResearchCopilot 的评测集是一份 JSON。每条样本四样东西:问题、期望要点、必须引用的来源、以及为什么有这条样本:

{
  "version": "2026.10.1",
  "cases": [
    {"id": "q001-rag-def", "question": "什么是 RAG?",
     "expected_points": ["检索增强生成"], "must_cite": ["doc-rag"],
     "reason": "基本能力用例:检索 + 带引用作答",
     "script": [
       {"tool_calls": [{"id": "c1", "name": "search_corpus", "arguments": {"q": "RAG"}}]},
       {"tool_calls": [{"id": "c2", "name": "fetch_doc", "arguments": {"doc_id": "doc-rag"}}]},
       {"text": "RAG 是检索增强生成 [doc-rag]。"}]},
    {"id": "q006-no-cite", "question": "讲讲 pgvector",
     "expected_points": ["向量"], "must_cite": ["doc-pgvector"],
     "reason": "已知失败样例:答案对但漏引用",
     "script": [
       {"tool_calls": [{"id": "c1", "name": "search_corpus", "arguments": {"q": "pgvector"}}]},
       {"text": "pgvector 是向量扩展,无需独立向量库。"}]}
  ]
}

script 字段是离线轨迹:它写死这一条该走哪几步。这样评测不联网、不花钱、秒级可复现。reason 字段是维护纪律的抓手——它逼你在加样本时回答「这条守住什么」。好样本的来源按性价比排:线上踩过的坑 > 边界情况 > 能力清单。本节 10 条里,q004-rag-vs 标的就是「线上踩坑」,q007-empty 是边界(空答案),其余是能力清单。

关键是必须包含已知会失败的样本。本节故意留了 5 条失败样例(q006~q010),否则分数永远 100%,什么也发现不了——这是卷一 16.2 已经强调过、卷二继续沿用的原则。

还有一份东西要和评测集一起钉住版本,那就是语料。评测集里的 must_cite 指向 doc-rag 这类文档 id,而引用准确率的判定要拿语料正文做依据——如果语料被改了(比如 doc-rag 被重写、要点挪走了),引用判定就会莫名其妙地翻红。所以 ResearchCopilot 的离线评测用一份固定的本地语料快照,而不是线上的实时库:

CORPUS: dict[str, str] = {
    "doc-rag": "RAG 即检索增强生成:先检索外部语料,再把命中片段作为上下文交给模型,"
               "让回答有据可依。检索质量决定上限,生成只负责组织语言。",
    "doc-pgvector": "pgvector 是 Postgres 的向量扩展,提供 vector 类型与近邻运算符,"
                    "可在 SQL 里直接做相似度检索,无需独立向量库。",
    # ……其余文档同构……
}

一句话:评测集、语料、评分器三者一起构成回归网,任何一份变了,历史分数就不可比。把这三样都纳入版本控制,是对比实验能成立的前提。

12.1.3 三个评分器

一次运行产出一条轨迹,评分器把它折成三个指标。先定义一条样本的打分结果:

@dataclass
class SampleScore:
    case_id: str
    success: float      # 成功率:非空且格式合法
    cite_acc: float     # 引用准确率:存在且支持
    tokens: int         # 成本:估算 token
    calls: int          # 成本:模型调用次数
    ok: bool

三个评分器各管一摊。

成功率只问「有没有产出一个能用的答案」——非空、格式合法(本节口径是句号收尾):

def score_success(final: str) -> float:
    """非空、以句号收尾,视为格式合法。"""
    f = final.strip()
    return 1.0 if f and f.endswith("。") else 0.0

引用准确率是 ResearchCopilot 最看重的指标,因为它直接对应「报告有没有编造来源」。它查两件事:答案里的每个 [doc-x] 是否真在语料里存在,以及该文档正文是否支持这条论断:

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

def score_citation(final: str, case: dict) -> float:
    """引用里的每个 [doc-x] 都要在语料里存在,且其正文支持期望要点。"""
    cited = CITE_RE.findall(final)
    must = case.get("must_cite", [])
    if not cited:
        return 1.0 if not must else 0.0
    points = case.get("expected_points", [])
    good = 0
    for doc_id in cited:
        text = CORPUS.get(doc_id)
        if text is None:
            continue                              # 不存在:不计数
        if all(p in text for p in points):
            good += 1                             # 存在且支持
    return round(good / len(cited), 3)

「支持论断」这一步是近似的:本节用「期望要点是否出现在被引文档正文里」做词面判定。它能抓住「引了一个完全不相干的文档」,但抓不住「文档相关却没覆盖这层意思」的细微情况——所以它必须和人工评审配合,见 12.1.5 的分界表。

成本不参与「对不对」的判定,但必须单独报告,因为它回答「这个分数花了多少代价换来的」:

def estimate_tokens(messages) -> int:
    chars = 0
    for m in messages:
        chars += len(m.content)
        for tc in m.tool_calls:
            chars += len(tc.name) + len(json.dumps(tc.arguments, ensure_ascii=False))
    return chars // 4

调用次数直接用 len(model.calls) 拿——它就是「这一条问了几次模型」。假模型的 usage 是空的,所以 token 用「字符数 ÷ 4」估算:它不追求和真实计费一致,只追求不同版本之间可比。真实模型下这里应换成 usage 里的真实 token 数。

三个评分器先摆成一张表,方便对照:

评分器计算方式类型谁关心
成功率非空且格式合法质量有没有可用产出
引用准确率引用存在且支持论断的比例质量报告有没有编来源
成本估算 token + 调用次数过程这个分数值不值

质量指标决定「过没过线」,过程指标决定「这笔账划不划算」。和卷一 16.2 一样,过程指标不参与 ok 判定,但必须单独报告——否则「省 token」会诱导你把 Agent 砍成什么都不做。

12.1.4 真跑:10 条样本的逐条得分

把样本喂进 evaluate,逐条打分并汇总:

def evaluate(cases: list[dict]) -> list[SampleScore]:
    reg = build_registry()
    out = []
    for case in cases:
        model = build_model(case["script"])
        try:
            result = run(model, reg, case["question"], system="你是严谨的研究助手。")
            final = result.final
            success = score_success(final)
        except RuntimeError:                        # 未收敛计为失败,不中断整轮
            final, success = "", 0.0
        cite = score_citation(final, case)
        out.append(SampleScore(case["id"], success, cite, estimate_tokens(result.messages),
                               len(model.calls), success == 1.0 and cite == 1.0))
    return out

本机真跑(Python 3.14.6,假模型离线):

评测集版本 2026.10.1|样本 10 条
样本                    成功     引用   token    调用  结果
----------------------------------------------------------
q001-rag-def        1.00   1.00      65     3  PASS
q002-pgvector       1.00   1.00      78     3  PASS
q003-hnsw           1.00   1.00      66     3  PASS
q004-rag-vs         1.00   1.00      68     3  PASS
q005-quota          1.00   1.00      73     3  PASS
q006-no-cite        1.00   0.00      37     2  FAIL
q007-empty          0.00   0.00       0     1  FAIL
q008-bad-cite       1.00   0.00      35     2  FAIL
q009-unsupported    1.00   0.00      36     2  FAIL
q010-no-tool        1.00   0.00       7     1  FAIL
----------------------------------------------------------
汇总:成功 90.0%|引用准确 50.0%|平均 token 46|平均调用 2.3|全过 5/10

读这张表要看三件事。先看汇总:成功率 90%、引用准确率 50%——成功率很高而引用率很低,说明「能答」没问题,问题全出在「答得有没有据」。再看 FAIL 行:五条失败里四条(q006、q008、q009、q010)都是引用项归零,只有 q007-empty 是答案本身为空。最后看代价:成功样本平均 3 次调用、约 70 token,失败样本因为少走一步反而更省——这提醒我们不能只看 token 排名,省下来的成本可能是靠少干活换的。

五个失败样例各有讲究,正是它们让分数有区分度:

样本失败点想守住什么
q006-no-cite答案对但不给引用引用是硬要求,不是加分项
q007-empty模型返回空串空答案必须判失败,不能被「非异常」蒙混
q008-bad-cite引了不存在的 doc-fake幻觉来源必须被逮住
q009-unsupported引了存在但不相干的文档「有引用」不等于「引用对」
q010-no-tool不检索直接凭记忆作答跳过检索就是幻觉的温床

q008 与 q009 的区别值得单独说:前者引用不存在,后者引用存在但不支持。很多团队只校验「引用的 id 存不存在」,于是 q009 这类「引对了 id、说错了话」会一路绿灯——引用准确率必须同时管存在性和支持性,这是 ResearchCopilot 评测里最容易漏的一格。

12.1.5 哪些指标适合自动打分

不是所有质量维度都能自动打分。把本节三个评分器和常见的几个维度摆在一起,划一条清晰的分界线:

维度能否自动判据 / 为什么
成功率(非空、格式合法)能纯结构判定,无歧义
引用存在性能引用 id 是否在语料里,集合查询
引用支持性(粗粒度)半自动词面近似,能抓明显错配,漏细微
成本(token / 调用数)能直接可数
答案要点是否覆盖半自动选稳定关键点(名词、数字)可靠;整句匹配会误报
论证是否严密不能需要读懂推理链,人工
语气、可读性不能主观,人工
是否答非所问(语义)半自动可用嵌入相似度辅助,仍需人工兜底

分界线的原则很简单:「结构」能自动,「语义」要人看。 凡是能被数出来、能查表判定的(非空、id 存在、token 数),一律自动;凡是需要判断「这段话讲得对不对」的,自动化只能做粗筛,最终要人工抽样确认。

这也是为什么引用准确率用词面近似而非更复杂的语义判定:粗筛阶段宁可漏报一点,也不能因为误报让 CI 天天红。真正的语义误引交给发布前的人工抽样——自动打分负责高频、便宜的守门,人工评审负责低频、可信的验收,这个分工和卷一 16.3 的「快筛/验收」两段式是同一个道理。

12.1.6 真实模型下会怎样

真实模型下评测集的形状不变,变的是两个来源。其一,script 会换成录制的真实轨迹——跑一次真实调用,把每轮的请求与响应存成夹具(下一节讲),评测时离线重放。这样评测依然不联网、可复现,只是用例的来源从「手写脚本」变成「录制的真实行为」。其二,expected_points 必须选稳定的关键点:真实模型措辞千变万化,写「检索增强生成」能匹配上,写整句话必然误报。

还有一点要提前想好:本节 10 条样本只能发现「大方向对不对」,要区分两个相差 2% 的版本,得几十上百条。评测集本身是一项长期资产——它每拦下一个线上 bug 就固化一条样本,越用越值钱。

本节验收清单:

  • 评测集是独立文件、有 version,且纳入版本控制
  • 每条样本带 reason,能说清它守住什么
  • 评测集包含已知失败样例,分数有区分度
  • 三个评分器:成功率(结构)、引用准确率(存在 + 支持)、成本(token + 调用数)
  • 引用准确率同时管存在性与支持性,不只查 id

12.1.7 小结

  • 评测集要当受控资产:加样本写理由、改期望留痕、旧样本只标不删。
  • 用 JSON 存问题、期望要点、必须引用的来源;script 是离线轨迹,评测不联网、可复现。
  • 三个评分器:成功率管「能不能用」,引用准确率管「有没有编来源」,成本管「值不值」。
  • 引用准确率必须同时查存在性与支持性——只查 id 会让「引对了 id、说错了话」蒙混过关。
  • 自动打分与人工评审的分界线是「结构 vs 语义」:结构自动,语义人工抽样。

评测集有了、评分器有了,但它的 script 还是手写的。下一节 12.2 离线回放与夹具:假模型 + 录制样本确定性回归 会把「手写脚本」升级成「回放真实请求与响应」,让评测用上录制来的真实行为。想回看引擎层的评测原理,翻卷一 16.2 评测集与打分 。

阅读导航:上一节:11.3 记忆治理:TTL、隐私与按用户删除 · 下一节:12.2 离线回放与夹具:假模型 + 录制样本确定性回归 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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