16.2 评测集与打分
上一节的 pytest 用例能告诉你"这一条行为对不对",但回答不了"这一版整体是不是变好了"。改动往往只影响一部分用例:换了个提示词,三道题变好、一道题变坏——单条断言看不到这种净效果。要衡量整体,需要把若干条用例打包成一个评测集,跑一遍、给每一条打分、再汇总成一个报告。
本节把 minagent 推进到评测层:定义
Case与Scorer,实现evaluate(cases, registry, scorer) -> Report,用假模型脚本化六条用例的正确与错误行为,真跑一遍打印每条得分与汇总。
16.2.1 离线评测集是 Agent 的回归网
评测集和上一节的 pytest 用例是同一件事的两种粒度:
| 维度 | pytest 用例 | 评测集 |
|---|---|---|
| 回答的问题 | “这条行为对不对” | “这一版整体好不好” |
| 粒度 | 单点断言 | 一批用例 + 一个分数 |
| 输出 | 红/绿 | 每条得分 + 汇总指标 |
| 用途 | 防回归 | 判断改动是否值得合并 |
“离线"两个字是关键。评测集里的用例全部由假模型脚本驱动——每条用例预先写死"模型该走什么轨迹”。这样评测不联网、不花钱、可复现,可以像跑单元测试一样在 CI 里每次提交都跑。它的角色就是回归网:你改了一个提示词、换了工具描述、调了步数上限,跑一遍评测集,分数掉没掉、掉在哪几条,一目了然。
一个常见误区是把评测集做得很小、很"漂亮"——全是能过的用例。那样分数永远是 100%,什么也发现不了。好的评测集必须包含已知的失败样例,让分数有区分度。本节故意留了两条"注定失败"的用例,就是为了让报告里的数字动起来。
用例从哪来?三个来源,按性价比排序:
- 线上踩过的坑——每修一个真实 bug,就把它固化成一条用例。这是最值钱的来源,因为它代表真实分布。
- 边界情况——空输入、超长输入、工具报错、引用缺失。这些真实模型未必稳定复现,但一旦出事就是事故。
- 能力清单——你声称 Agent 会做的事(算数、检索、带引用回答),逐项各写一条。
这三类凑在一起,评测集才既有"守住底线"的回归用例,又有"覆盖能力"的功能用例。
16.2.2 Case:一条用例长什么样
一条用例 = 输入 + 假模型脚本 + 若干期望。用 dataclass 描述:
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Case:
id: str
input: str
script: list[Any] # 脚本化这条的正确/错误行为
expected_tools: list[str] = field(default_factory=list)
expected_answer_contains: list[str] = field(default_factory=list)
expected_citations: list[str] = field(default_factory=list)
system: str = "你是一个严谨的助手。"
六个字段各管一摊:
| 字段 | 含义 | 打分时怎么用 |
|---|---|---|
input | 喂给 Agent 的用户输入 | 驱动 run() |
script | 假模型脚本,写死这条的轨迹 | 构造 FakeModel |
expected_tools | 期望调用的工具集合 | 算工具命中率 |
expected_answer_contains | 终答必须包含的关键点 | 算答案命中率 |
expected_citations | 终答必须出现的引用标记 | 算引用可回溯率 |
system | 系统提示 | 传进 run() |
注意 expected_tools 用的是集合语义而非序列——本节只问"该调的工具调没调",不问顺序。顺序断言上一节的 pytest 用例已经管了;评测集关心的是覆盖率,用集合更稳。
16.2.3 Scorer:把轨迹折成几个指标
一次运行产出一条完整轨迹,Scorer 负责把它折成几个 0~1 的指标再加权。指标选取围绕"Agent 到底做对了几件事"。先定义一条用例的打分结果:
@dataclass
class Score:
case_id: str
converged: bool # 是否成功收敛
tool_hit: float # 工具命中率
answer_rate: float # 答案关键点命中率
citation_rate: float # 引用可回溯率
steps: int # 步数(过程指标)
tokens: int # 估算 token
cost: float # 估算成本
score: float # 加权综合分
ok: bool # 是否严格全过
然后是打分器本体:
import json
PRICE_PER_1K = 0.05 # 元 / 千 token,估算口径
def estimate_tokens(messages) -> int:
"""无真实 usage 时的字符数估算:约 4 字符 1 token。"""
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
class Scorer:
"""把一次运行的轨迹折成一个 0~1 的分数,外加过程指标。"""
def __init__(self, w_tool: float = 0.4, w_answer: float = 0.4, w_cite: float = 0.2) -> None:
self.w_tool, self.w_answer, self.w_cite = w_tool, w_answer, w_cite
def score(self, case, result, converged: bool):
actual = [tc.name for m in result.messages for tc in m.tool_calls]
hit = (len(set(case.expected_tools) & set(actual)) / len(case.expected_tools)
if case.expected_tools else 1.0)
ans = (sum(k in result.final for k in case.expected_answer_contains)
/ len(case.expected_answer_contains) if case.expected_answer_contains else 1.0)
cite = (sum(c in result.final for c in case.expected_citations)
/ len(case.expected_citations) if case.expected_citations else 1.0)
tokens = estimate_tokens(result.messages)
cost = tokens / 1000 * PRICE_PER_1K
score = self.w_tool * hit + self.w_answer * ans + self.w_cite * cite
ok = converged and hit == 1.0 and ans == 1.0 and cite == 1.0
return Score(case.id, converged, round(hit, 3), round(ans, 3), round(cite, 3),
len(result.steps), tokens, round(cost, 5), round(score, 3), ok)
五个指标分两类。质量指标(工具命中率、答案命中率、引用可回溯率)是 0~1 的比例,加权的 score 是它们的综合;过程指标(步数、成本)不参与得分,但要单独报告——因为它们揭示"用多少代价换来的分数"。一个 100% 成功率但平均步数是别人三倍的 Agent,未必是赢家。
五个指标先摆成一张表,方便对照:
| 指标 | 计算方式 | 类型 | 谁关心 |
|---|---|---|---|
| 工具命中率 | 期望工具 ∩ 实际工具 / 期望工具 | 质量 | 流程对不对 |
| 答案命中率 | 命中的关键点 / 全部关键点 | 质量 | 结论对不对 |
| 引用可回溯率 | 终答出现的引用 / 期望引用 | 质量 | 有没有编造来源 |
| 步数 | len(result.steps) | 过程 | 绕没绕远路 |
| 成本 | 估算 token × 单价 | 过程 | 值不值 |
三处设计值得说明:
- 期望为空即满分:若某条用例没写
expected_tools,命中率记 1.0,而不是 0。语义是"这条不考工具",而不是"这条工具全错"。 ok是严格全过:只有三项都拿满分、且成功收敛,才算这条通过。score给的是"离满分差多远",ok给的是"过没过线",两者互补。- 成本是估算:假模型的
usage是空的,所以用"字符数 ÷ 4"粗略估算 token。这是一个稳定的近似——它不追求和真实计费一致,只追求"不同版本之间可比"。真实模型下这里应换成usage里的真实 token 数。
16.2.4 evaluate:跑一遍出报告
有了 Case 和 Scorer,把它们串起来:
def evaluate(cases, registry, scorer, model_factory=None, max_steps=8):
factory = model_factory or (lambda c: FakeModel(c.script))
scores = []
for case in cases:
try:
result = run(factory(case), registry, case.input, system=case.system,
max_steps=max_steps)
scores.append(scorer.score(case, result, converged=True))
except RuntimeError: # 未收敛(脚本耗尽 / 超步数)计为失败
empty = RunResult(messages=[], steps=[], final="")
scores.append(scorer.score(case, empty, converged=False))
n = len(scores)
return Report(
scores=scores,
pass_rate=sum(s.ok for s in scores) / n,
avg_score=sum(s.score for s in scores) / n,
avg_steps=sum(s.steps for s in scores) / n,
avg_cost=sum(s.cost for s in scores) / n,
)
两个细节是刻意的。其一,未收敛不抛出,而是计为失败——评测的价值就在于把"跑挂了"和"跑错了"都变成报告里的一行,而不是中断整轮评测。其二,model_factory 可替换——默认从 case.script 造 FakeModel,但下一节的对比实验会换一个工厂来改变模型行为,这个参数就是为它留的。
Report 汇总四个数字:通过率、平均得分、平均步数、平均成本。前两个是质量,后两个是代价。
16.2.5 六条用例的评测报告
现在构造一个小评测集。四条"正确行为"、两条"注定失败",故意让分数有区分度:
def build_cases(cite: bool = False) -> list[Case]:
return [
Case("calc_two_step", "计算 (2+3)*4",
script=[[ToolCall("c1", "add", {"a": 2, "b": 3})],
[ToolCall("c2", "mul", {"a": 5, "b": 4})], "结果是 20。"],
expected_tools=["add", "mul"], expected_answer_contains=["20"]),
Case("calc_single", "计算 6*7",
script=[[ToolCall("c1", "mul", {"a": 6, "b": 7})], "结果是 42。"],
expected_tools=["mul"], expected_answer_contains=["42"]),
Case("search_plain", "RAG 是什么", script=search_plain,
expected_tools=["search"], expected_answer_contains=["检索增强生成"],
expected_citations=["doc1"]),
Case("search_then_read", "详细讲讲 RAG",
script=[[ToolCall("c1", "search", {"q": "RAG"})],
[ToolCall("c2", "read", {"doc_id": "doc1"})],
"根据 [doc1],RAG 是检索增强生成。"],
expected_tools=["search", "read"], expected_answer_contains=["检索增强生成"],
expected_citations=["doc1"]),
Case("wrong_tool", "计算 2+3",
script=[[ToolCall("c1", "mul", {"a": 2, "b": 3})], "结果是 6。"],
expected_tools=["add"], expected_answer_contains=["5"]),
Case("search_no_cite", "讲讲 RAG", script=search_no_cite,
expected_tools=["search"], expected_answer_contains=["检索增强生成"],
expected_citations=["doc1"]),
]
两条失败用例各有讲究:wrong_tool 让模型调错工具(用 mul 代替 add),于是工具命中率和答案命中率双双归零;search_plain 与 search_no_cite 答案对但没给引用,引用可回溯率归零,得分 0.8 但 ok 为假。这正是"部分正确"该有的样子——不是非黑即白,而是差在哪一项。
跑一遍,打印报告:
$ python -m eval.evaluate
id 工具 答案 引用 步数 成本(元) 得分 结果
---------------------------------------------------------------
calc_two_step 1.00 1.00 1.00 3 0.00085 1.00 PASS
calc_single 1.00 1.00 1.00 2 0.00055 1.00 PASS
search_plain 1.00 1.00 0.00 2 0.00080 0.80 FAIL
search_then_read 1.00 1.00 1.00 3 0.00145 1.00 PASS
wrong_tool 0.00 0.00 1.00 2 0.00050 0.20 FAIL
search_no_cite 1.00 1.00 0.00 2 0.00080 0.80 FAIL
---------------------------------------------------------------
汇总:通过 3/6(50.0%)|平均得分 0.80|平均步数 2.3|平均成本 0.00082 元
读这张表要看三件事。先看汇总:通过率 50%、平均得分 0.80——平均得分高但通过率低,说明大多数用例只差临门一脚(两条差在引用)。再看 FAIL 行:wrong_tool 得分 0.20(工具和答案全错),两条 search 用例得分 0.80(只差引用),失败原因清清楚楚。最后看代价:search_then_read 成本最高(0.00145),因为它多走一步 read——多出来的成本买到了引用可回溯,这笔账值不值,要靠下一节的对比实验来算。
这张表就是回归网的读数:以后任何改动,只要重跑一遍,看通过率和平均得分是涨是跌、哪几条翻了颜色,就比"感觉好像变好了"靠谱得多。
16.2.6 本节小结
- 评测集是 pytest 用例的批量化:回答"整体好不好",而不是"单条对不对";它是 Agent 的回归网。
Case= 输入 + 假模型脚本 + 期望;Scorer把轨迹折成质量指标与过程指标。- 质量指标(工具、答案、引用)参与加权得分,过程指标(步数、成本)单独报告——因为分数要看代价。
evaluate把未收敛计为失败而非抛出,让"跑挂了"也变成报告里的一行。- 评测集必须包含已知失败样例,否则分数没有区分度,什么也发现不了。
- 报告要读三处:汇总、FAIL 行的失败原因、以及每条的成本代价。
16.2.7 真实模型下会怎样
真实模型下,script 字段会换成录制的真实轨迹——跑一次真实调用,把每轮的模型响应存成 JSON 夹具(附录 B 讲了加载器),评测时离线重放。这样评测集依然不联网、可复现,只是用例的来源从"手写脚本"变成"录制的真实行为"。
两个真实场景下的差异要提前想好。其一,真实模型会给出措辞不同的答案,expected_answer_contains 必须选稳定的关键点(数字、专有名词),而不是整句话——否则断言必然误报。其二,引用可回溯率在真实 RAG 里更重要:模型会编造引用(幻觉),所以"终答里的每个 [docN] 是否真的来自检索结果"要单独校验,这正是引用指标存在的意义。
还有一点:真实评测集的规模要足够大才有统计意义。六条用例只能发现"大方向对不对",要区分两个相差 2% 的版本,得几十上百条。这就是为什么工业界的评测集动辄几百条,且会持续扩充——评测集本身就是一项长期资产。
一句话:离线评测集回答"这一版整体比上一版好还是差",它是 Agent 的回归网。下一节 16.3 对比实验 会用它来回答更尖锐的问题——某次改动到底值不值得合并。想回看单条断言怎么写,翻 16.1 确定性测试 。
阅读导航:上一节:16.1 确定性测试 · 下一节:16.3 对比实验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。