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 离线回放与夹具:假模型 + 录制样本确定性回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。