合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 67%

第 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 的“成功”很容易造假:

  • 改测试,让测试变绿;
  • 删测试;
  • 硬编码返回值;
  • 跳过测试,直接说“应该通过了”。

所以评分器第一件事不是“看它说得多好”,是“看它有没有作弊”。

python
success = (
    result.subtype == "success"      # 进程正常结束
    and tests_pass                  # 测试真的绿
    and not tests_changed           # 测试文件没被动过
)

三个条件缺一不可。

  • subtype == "success" 只说明 Harness 正常结束,不说明任务完成;
  • tests_pass 要真的跑,不能信模型自述;
  • tests_changed 是“作弊检测”:测试改了,即使绿了,也算失败。

评分器本身不复杂,难的是“判得对”。run_eval.py 里大概是:

python
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, output

before / 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 大概长这样:

python
@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 长这样:

text
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,但没有跑测试的 BashStop 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 只有一句:

text
You are a coding agent. Fix the failing test.

workflow 写了流程:

text
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.

结果:

配置成功平均轮数平均成本测试改动平均时延
baseline2/28.0$0.0552012.1s
workflow2/210.0$0.0688015.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,不是“都很好”,是“这个任务区分度不够”。

小样本评测的正确姿势:

  1. 报原始计数,不报百分比;
  2. 报每次的轮数/成本,不只报平均;
  3. 任务太简单就加难度,别在 ceiling 上做 A/B;
  4. 结论写“在这个任务、这个模型、这个预算下”,不写“普遍更好”。

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:

bash
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,不报百分比,不声称统计显著。