第 17 章
评测:别用一次运行判断 Agent 好坏
你改的是提示词,还是运气?
前面几章,我们每次跑完都说“成功”。
但那些不是评测:任务不同、配置不同、只跑一次、评分靠眼睛。换一个任务、换一天、换一次采样,结论可能就变了。
这一章做一件很朴素的事:同一个任务,同一组工具/权限,只改一个变量,跑多次,用代码评分。
我先跑了一个小 pilot,结果有点反直觉。
17.1 为什么之前的运行不算评测
把“运行”和“评测”放一起看:
| 之前的运行 | 评测需要 |
|---|---|
| 第 10 章修测试,第 11 章读 README | 固定任务集,每轮跑同一组 |
| 每章配置不同 | 固定配置,只改一个变量 |
| 每个配置跑一次 | 同一配置跑多次 |
| “它说修好了” | 确定性评分:测试、diff、文件 |
| 输出在终端里 | 结构化记录:JSONL |
一次成功,可能是任务简单、模型今天状态好、或者它恰好走对了路。** anecdotes are not metrics.**
评测不是“刷榜”,是让你在改一个变量时,知道“有没有变好”,而不是“感觉变好了”。
17.2 一个最小评测的四个要素
任务集:一组固定任务,每个都有明确的成功标准。比如“修好 days_left,测试通过,且不改测试”。
配置:模型、system prompt、工具面、权限、max_turns、预算。做 A/B 时,只改一个。
运行:每个配置跑 N 次;每次用干净的 fixture,不要复用上一轮的脏工作区。
评分器:优先确定性检查(测试、退出码、diff、文件哈希),其次才是 LLM/人工 rubric。
第 17 章的 code/eval/run_eval.py 就是这四件事的最小实现:
- 任务:
fix-due-date; - 配置:
baseline/workflow,只改system_prompt; - 运行:每个配置 2 次,复制干净 fixture;
- 评分:测试通过 + 测试文件未改 + 记录 changed files。
17.3 评分器:先测“有没有作弊”
Agent 的“成功”很容易造假:
- 改测试,让测试变绿;
- 删测试;
- 硬编码返回值;
- 跳过测试,直接说“应该通过了”。
所以评分器第一件事不是“看它说得多好”,是“看它有没有作弊”。
success = (
result.subtype == "success" # 进程正常结束
and tests_pass # 测试真的绿
and not tests_changed # 测试文件没被动过
)三个条件缺一不可。
subtype == "success"只说明 Harness 正常结束,不说明任务完成;tests_pass要真的跑,不能信模型自述;tests_changed是“作弊检测”:测试改了,即使绿了,也算失败。
评分器本身不复杂,难的是“判得对”。run_eval.py 里大概是:
def grade(workspace: Path, before: dict[str, str]):
tests_pass, output = run_tests(workspace)
after = snapshot(workspace)
changed = sorted(
k for k in after.keys() | before.keys()
if before.get(k) != after.get(k)
)
tests_changed = any(k.startswith("tests/") for k in changed)
return tests_pass, tests_changed, changed, outputbefore / after 是文件哈希快照;tests_changed 只要有一个测试文件变了,就判失败。run_tests 用退出码,不信模型自述。
更严格的评分器还会:
- 只允许改指定文件:比如只允许
taskflow/core.py,多改一个文件就失败; - 加隐藏测试:仓库里的测试可能被改,隐藏测试放在评分器里,跑完再验;
- 检查 diff 大小:改 200 行 vs 改 2 行,都是“通过”,但质量不同;
- 检查最终回答:有没有说“未验证”却声称完成。
这次 pilot 的评分器只做了前三件事(subtype、tests、tests unchanged),changed files 只记录、不强制。已经够抓住“改测试”这类作弊,但还不够严。
17.4 指标:成功率之外
只看成功率,会漏掉成本和体验。至少记六个字段:
- 成功率:通过评分的 run / 总 run;
- 轮数:
ResultMessage.num_turns,通常和成本/时延同向; - 成本:
ResultMessage.total_cost_usd(估算,可做相对比较); - 时延:
perf_counter的墙钟时间; - 测试改动:文件哈希/diff,用来抓“改测试”;
- 假完成:说“完成”但测试没跑/没过,比失败更危险。
第 10 章那次“10 轮、$0.1706”就是例子:它成功了,但比脚本预期的 6 轮多。只统计“成功”,你会漏掉这笔成本。
这些字段最后要落成一条记录,而不是散落在日志里。run_eval.py 里的 Run 大概长这样:
@dataclass
class Run:
config: str
task: str
repeat: int
success: bool = False
tests_pass: bool = False
tests_changed: bool = False
changed_files: list[str] = field(default_factory=list)
turns: int | None = None
cost_usd: float | None = None
duration_s: float = 0.0
subtype: str = ""
tool_calls: list[dict] = field(default_factory=list)
final_text: str = ""
error: str = ""一个 run 一行。json.dumps(asdict(run)) 落盘,后面才能做汇总、对比、回放。
17.5 一条 trace 里有什么
评测的原始单位不是“成功率”,是“每个 run 的一行记录”。
没有 trace,你只知道“失败了”;有 trace,你才知道“它为什么失败”。
这次 pilot 的 baseline-1 长这样:
baseline-1: Bash ×4 → Edit → Bash
turns=7 tests_pass=true tests_changed=false
changed=taskflow/core.py cost=$0.0492 duration=10.5s它没有用 Read 读 README/测试,而是用 Bash 去 ls/cat/跑测试;workflow-1 则先 Read 了 README 和测试。两者都修好了,但行为不一样——只看“成功”,你看不到这个差别。
所以 tool_calls 不能只记数量,要记名字和关键参数;changed_files 不能只记数量,要记路径。没有这些,你没法回答“它为什么用了 7 轮”。
17.6 失败分类:别只写“Agent 出错了”
只有“失败”两个字,没法改。至少要分到下面几类:
| 类型 | trace 里长什么样 | 先改哪 |
|---|---|---|
| 工具选错 | 用 Bash cat README 而不是 Read | 工具面/描述 |
| 参数错 | Edit 的 old_string 找不到 | 工具描述/示例 |
| 没验证 | subtype=success,但没有跑测试的 Bash | Stop Hook |
| 提前停 | 轮数很低,测试没跑 | 完成标准 |
| 上下文溢出 | compact_boundary / 历史被截断 | 压缩/笔记 |
| 权限拒绝 | tool_result.is_error 里有 DENIED | 权限/Hook |
| 胡编 | 最终文本说“通过”,测试其实没跑/没过 | 评分器/Stop |
第 15 章那个 Stop/权限死锁,在 trace 里就是“权限拒绝 + 没验证”的组合:Stop 要求测试,Bash 被拒,模型只能反复试。
17.7 Pilot:同一任务,两种提示
我跑了一个 2×2 的 pilot:
- 任务:修
tests/test_core.py的失败测试,不改测试; - 模型:
deepseek-flash; - 工具/权限:两边一样(
Read/Edit/Bash,acceptEdits,12 轮,$0.3 预算); - 唯一变量:
system_prompt。
baseline 只有一句:
You are a coding agent. Fix the failing test.workflow 写了流程:
Read README.md and tests/test_core.py first.
Do not edit tests; fix the implementation.
Make the smallest change.
Run the tests before claiming done.
Summarize what changed.结果:
| 配置 | 成功 | 平均轮数 | 平均成本 | 测试改动 | 平均时延 |
|---|---|---|---|---|---|
baseline | 2/2 | 8.0 | $0.0552 | 0 | 12.1s |
workflow | 2/2 | 10.0 | $0.0688 | 0 | 15.1s |
两边的成功率一样,都是 2/2;workflow 多花了 2 轮、成本高约 25%、时延高约 25%。
这不是“workflow 没用”,也不是“baseline 更好”。它说明的是:
- 这个任务太简单。 两种提示都能修好,测不出差异;这叫 ceiling effect。
- 更详细的提示不一定更高效。
workflow让它多读了文件、多跑了几次命令;在这个任务上,这些步骤没有带来额外成功。 - n=2 太小。 2/2 vs 2/2 是平局,不是“100% vs 100%”;再跑几次,结果可能变。
我把原始记录写在 code/.eval/results.jsonl(gitignore,不入库)。每条包含 config、repeat、turns、cost、duration、tool_calls、changed_files、final_text。
17.8 小样本的诚实
n=2 的时候,不要写“成功率 100%”。写“2/2”。
原因:
- 一次失败就能改变结论。 2/2 再加一次失败就是 2/3;你没法说“稳定”。
- 方差可能比差异大。 同一配置两次的轮数/成本已经不一样(baseline 7/9 轮,workflow 9/11 轮);A/B 差异可能被噪声淹没。
- 任务难度决定天花板。 两个配置都 2/2,不是“都很好”,是“这个任务区分度不够”。
小样本评测的正确姿势:
- 报原始计数,不报百分比;
- 报每次的轮数/成本,不只报平均;
- 任务太简单就加难度,别在 ceiling 上做 A/B;
- 结论写“在这个任务、这个模型、这个预算下”,不写“普遍更好”。
17.9 A/B:一次只改一个变量
这次 pilot 只改了 system_prompt,其它都一样:
- 同一个 fixture(复制干净工作区);
- 同一个模型/端点;
- 同一组工具和权限;
- 同样的
max_turns/ 预算; - 同样的评分器。
如果你同时改提示词、工具面、权限,成功了也不知道是谁的功劳。
这也是为什么第 10–15 章的那些“成功”不能直接比较:它们任务不同、配置不同、只跑一次。它们是好故事,不是评测。
17.10 把评测接进开发流程
最小可用的一套:
- golden tasks:5–20 个固定任务,覆盖修 bug、加功能、重构、只读问答;
- 每次改动跑一遍:改提示词/工具/权限,先跑评测,再看 diff;
- holdout:留 1–2 个任务不参与调参,防止过拟合;
- 预算上限:每个 run 设
max_budget_usd,整批设总预算; - 产物:
results.jsonl+ 失败样例 + 工具序列;不要只留一个“成功率”数字; - 回归:上次通过的任务,这次失败,要能定位是哪次改动。
这次 pilot 的 harness 只有 200 多行,但已经能接 CI:
PYTHONPATH=.deps python3 code/eval/run_eval.py --configs baseline,workflow --repeats 2普通读者按正文 pip install 后,不需要 PYTHONPATH=.deps。
一份能看的评测报告,至少包含:
- 配置差异:只改了什么,其余是否相同;
- 原始计数:
2/2,不是“100%”; - 每次运行:turns/cost/duration/tool_calls;
- 失败样例:哪一轮、什么错误、trace 链接;
- 成本:平均值 + 总成本 + 预算;
- 结论边界:任务、模型、n、日期。
只写“成功率提升 20%”的报告,通常没法复现,也没法定位问题。
17.11 下一章:成本
评测告诉你“有没有变好”,但没告诉你“值不值”。
第 18 章算成本:token 怎么涨、缓存怎么省、max_budget_usd 什么时候检查、兼容端点的 total_cost_usd 为什么不是账单。
本章代码 最小评测器:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/eval/run_eval.py 真实 pilot 记录:
code/.eval/results.jsonl(gitignore,本地生成) 环境:SDK 0.2.168 + CLI 2.1.296 +deepseek-flash;2 个配置 × 2 次,2026-10-22。 说明:这是 pilot,不是 benchmark;n=2,不报百分比,不声称统计显著。