第 8 章:Evals 评估
为你的 agent 建一套评估集,把感觉变好了变成一个可以拿出来辩护的数字。
小满 · 试炼场
小满本事见长,可你还没法证明它靠谱。今天,开考。
草稿章节。跑通格式用的第一版,正式索引前会再打磨。
本章目标
为 PR reviewer 建一套真实的评估集:一个装着测试用例的文件夹,每个用例是一份存好的 diff,加上「好评审必须指出的发现」,再加一个 harness(评估骨架),它跑完每个用例、逐个打分、输出一个总的任务成功率。你会用两种打分器:明确无歧义的发现用精确匹配(评审有没有标出第 12 行的 SQL 注入?),开放的部分用带书面评分标准的 LLM 裁判(建议的修法对不对、语气有没有建设性?)。最后你把 harness 接进 CI,这样一次悄悄让 reviewer 退步的 prompt 改动会直接让构建失败,而不是被发出去。
多数 agent 教程跳过这一章,但它正好是 demo 和产品的分界线。没有 evals,每次改 prompt 都是凭感觉:你改一处,瞄一个 PR,说服自己变好了,然后发出去一个要等到生产环境才暴露的回退。OpenAI 的 evals 框架和 Hugging Face 的 agents 课程用不同说法讲了同一件事:你度量不了的 agent,你就没法有意识地去改进它。
前置准备
- 前几章那个能跑通的 reviewer,能 headless 运行,好让你把输出捕获成 JSON(第 7 章用
codex exec和结构化输出 schema 把这条铺好了)。 - 起步阶段十到三十个存好的用例。每个用例是一份真实 diff,加上一份「好评审应当抓到什么」的预期。质比量重要:十个取自真实失败的用例,比一百个凭空造的强。
- 一个能用低温度调用、给裁判用的 LLM,消息和工具格式见官方文档。
动手做
1. 定义用例格式
用例是数据,不是代码:一份输入 diff,加一份机器能校验的预期。形态可以照 OpenAI evals:它把样本存成 JSONL、引用数据,而不是把数据写死在 harness 里。把预期拆成 must_find(精确、硬要求)和 rubric(开放、靠判断)。这样拆,便宜的确定性检查就能承担大部分打分,把贵的 LLM 裁判只留给那些只有判断才能评的部分。
// cases/001-sql-injection.json
{
"id": "001-sql-injection",
"diff_path": "cases/001-sql-injection.diff",
"must_find": [
{ "file": "api/users.py", "line": 12,
"category": "security", "keyword": "injection" }
],
"must_not_find": [
{ "category": "style" } // reviewer 不该在这里挑风格毛病
],
"rubric": "建议的修法用参数化查询,而不是字符串转义。"
}
2. 从真实失败里收集用例,别凭空想象
每次 reviewer 让你失望(漏了真 bug、造了个假 bug、在一个安全攸关的 PR 上大讲风格),就把那次输入存成一个新用例。来自生产失败的用例集是诚实的;你凭空造的集合会偏向 agent 本来就能过的那种用例。这就是回归集:它把你已经付过代价的每个错误都记下来,让你不必为同一个错误付第二次钱。
新增用例的流程:
1. reviewer 在 PR #1234 上产出一份糟糕审查
2. 把 diff 存下来 -> cases/0NN-short-name.diff
3. 写下好评审本应说什么 -> must_find / rubric
4. 跑 harness:新用例失败(证明它确实抓到了这个 bug)
5. 修 prompt/skill;用例现在通过;两者一起提交
3. 用 query() headless 跑 reviewer,把审查捕获成数据
打分之前,先得有能打分的东西。run_reviewer_headless 就是一个真实的 SDK 调用:给 reviewer 那份审查契约当 system prompt,只放行 Read,用 query() 跑完一整个回路,把模型最终那段文字 review 攒起来返回。这就是「你写的普通代码 + 跑 query() + 断言」里中间 query() 那一截,作用是把一次 agent 运行变成可校验的字符串。
import anyio
from claude_agent_sdk import (
query, ClaudeAgentOptions, AssistantMessage, TextBlock,
)
CONTRACT = """你是 PR 审查者。要评判任何文件,必须先用 Read 读它。
读够了就输出审查清单:severity、file:line、一句话风险。绝不臆造没读过的代码。"""
async def run_reviewer_headless(target: str) -> str:
"""对一个文件 headless 跑一次 reviewer,返回它最终的审查文字。"""
options = ClaudeAgentOptions(
system_prompt=CONTRACT,
allowed_tools=["Read"],
max_turns=5,
cwd="./repo",
)
review = ""
async for message in query(prompt=f"审查 {target} 的 bug 和风险。", options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
review += block.text
return review
import { query } from "@anthropic-ai/claude-agent-sdk";
const CONTRACT = `你是 PR 审查者。要评判任何文件,必须先用 Read 读它。
读够了就输出审查清单:severity、file:line、一句话风险。绝不臆造没读过的代码。`;
async function runReviewerHeadless(target: string): Promise<string> {
const q = query({
prompt: `审查 ${target} 的 bug 和风险。`,
options: {
systemPrompt: CONTRACT,
allowedTools: ["Read"],
maxTurns: 5,
cwd: "./repo",
},
});
let review = "";
for await (const message of q) {
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "text") review += block.text;
}
}
}
return review;
}
最小的一个评估,就是对那个已知有 bug 的退款文件跑一次上面的函数,再断言审查命中了它:refund.py:13 那个 < 本该是 <=,导致整额退款被静默拒绝。这就是「跑 query() + 断言」里断言的那一截,硬要求用精确匹配,模糊的地方再交给第 4 步的 LLM 裁判。
async def test_catches_refund_bug():
review = await run_reviewer_headless("src/payments/refund.py")
assert "refund.py:13" in review # 点到了正确的行
assert "<=" in review or "<" in review # 提到了那个比较符 bug
# 若硬匹配不够稳,再用第 4 步的 LLM 裁判当二审:
# verdict = await llm_judge(rubric="指出 < 应为 <=", review=review)
anyio.run(test_catches_refund_bug)
async function testCatchesRefundBug() {
const review = await runReviewerHeadless("src/payments/refund.py");
if (!review.includes("refund.py:13")) throw new Error("漏了那一行");
if (!review.includes("<=") && !review.includes("<")) throw new Error("漏了那个比较符 bug");
// 若硬匹配不够稳,再用第 4 步的 LLM 裁判当二审:
// const verdict = await llmJudge({ rubric: "指出 < 应为 <=", review });
}
testCatchesRefundBug().catch((e) => { console.error(e); process.exit(1); });
把这一对(跑 + 断言)放进一个 harness,就能遍历整套用例:对每份 diff headless 跑一遍 reviewer,先上确定性打分器,只在有 rubric 的地方才上 LLM 裁判,记下每个用例的结果,再算出任务成功率。逐用例的输出要留着:只有一个汇总数字,你看不出到底是哪类任务退步了。
def run_evals(cases, threshold=0.85):
results = []
for case in load_cases("cases/"):
diff = read(case.diff_path)
review = anyio.run(run_reviewer_headless, case.target) # 发现的 JSON 列表
# 先上廉价的确定性检查
hard_ok = all(matches(review, m) for m in case.must_find) and \
all(absent(review, n) for n in case.must_not_find)
# 只在有 rubric 且硬检查通过时才上昂贵裁判
soft_ok = True
if case.rubric and hard_ok:
soft_ok = llm_judge(case, review) # 见第 4 步
results.append({"id": case.id,
"pass": hard_ok and soft_ok,
"hard": hard_ok, "soft": soft_ok})
rate = sum(r["pass"] for r in results) / len(results)
print_table(results) # 逐用例,不只是成功率
return rate, rate >= threshold
async function runEvals(threshold = 0.85) {
const results = [];
for (const c of loadCases("cases/")) {
const review = await runReviewerHeadless(c.target); // 发现的 JSON 列表
// 先上廉价的确定性检查
const hardOk =
c.mustFind.every((m) => matches(review, m)) &&
c.mustNotFind.every((n) => absent(review, n));
// 只在有 rubric 且硬检查通过时才上昂贵裁判
let softOk = true;
if (c.rubric && hardOk) softOk = await llmJudge(c, review); // 见第 4 步
results.push({ id: c.id, pass: hardOk && softOk, hard: hardOk, soft: softOk });
}
const rate = results.filter((r) => r.pass).length / results.length;
printTable(results); // 逐用例,不只是成功率
return { rate, ok: rate >= threshold };
}
4. 用收紧的评分标准写 LLM 裁判 prompt
裁判拿到用例、reviewer 的输出和一份简短标准,返回一个严格的通过/不通过,加一句理由。温度调低,让判定每次跑都稳定;强制结构化输出,好让你解析。LLM 裁判会在两种地方出问题:一是标准太含糊(像「好不好?」这种只会给你噪声),二是太宽松(裁判倾向于让模棱两可的答案过)。所以标准必须具体,输出必须是二元的,而不是一个你最后还要争来争去的 1 到 10 分。
SYSTEM: 你是严格评分者。输出 JSON:{"verdict":"pass"|"fail","reason":"..."}。
除非标准被清楚满足,否则默认 "fail"。理由一句话。
USER:
reviewer 拿到的任务:
{task: 审查这份 diff 的安全/性能/测试,输出发现}
评分标准(你唯一要评的东西):
"{case.rubric}" 例如「建议的修法用参数化查询」
reviewer 输出:
{review_json}
reviewer 的输出满足这条标准了吗?只评这条标准,
不评整体质量。除非标准提到语气,否则忽略语气。
裁判本身也是个模型,所以把它的判定当成要审计的数据,别当成真理。定期抽样它的调用,手工核对(见「如何验证」那一步)。你不认同它的时候,改的是标准,不是 agent。
5. 跑完整集合并接入 CI
对 reviewer 自己仓库的每个 pull request,都跑一遍全部用例,算出成功率,跌破阈值就让构建失败。这就把「我觉得 prompt 更好了」变成了一道闸。失败时打印逐用例的表格,让弄坏某个用例的那处 diff 一眼就能看出来。
# .github/workflows/evals.yml (示意;语法请查 GitHub Actions 文档)
name: reviewer-evals
on: [pull_request]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: python run_evals.py --threshold 0.85
env:
MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
# run_evals.py 在 rate < threshold 时以非零退出 -> 构建失败
习得「第一次被考试」小满被一套从真实失败里攒出来的用例逐个考了一遍,硬要求用精确匹配,模糊部分交给 LLM 裁判,于是它能给出一个任务成功率,不用再靠一句我觉得变好了搪塞过去。
如何验证
- 故意写坏 prompt(删掉「检查注入」那一行)再重跑。成功率必须下降,而且挂掉的应该是那个 SQL 注入用例。如果成功率不变,说明你的用例太简单,或者打分器太松。
- 手工评十条裁判判定。如果你经常不认同,bug 在标准上:收紧措辞,别碰 agent。一个你不信任的裁判,比没有裁判还糟。
- 确认 CI 在成功率回退时真的会拦住合并。一个永远不会变红的绿勾是摆设,不是闸。
- 检查
must_not_find有没有生效:一个往每个 PR 里灌满风格挑刺的 reviewer,就算抓到了真 bug,也该在那些禁止风格噪声的用例上挂掉。
习得「承认自己错在哪」你现在能故意删掉检查注入那行 prompt 再重跑,看着成功率下降、那个 SQL 注入用例变红,确认评估抓的正是小满漏掉的那处,而不是它本来就能过的简单题。
原理
确定性打分器和 LLM 裁判分开,这就是全部窍门。好评审要做的事,大部分不靠模型就能校验:一条发现要么引用了第 12 行、带关键词「injection」,要么没有。花一次 LLM 调用去评这个,比字符串匹配更慢、更贵、更吵。裁判只用在真正模糊的地方(修法对不对、语气有没有建设性),那里没有规则能裁。OpenAI evals 的设计也是这样:它把精确匹配模板和模型评分模板做成两个独立的工具,就是为了这个。回归集管用,是因为它天生带对抗性:每个用例都是在 agent 栽在它上面的那一刻被加进来的,所以这套集合都集中在 agent 真实的薄弱点上,而不是它本来就能轻松通过的题。
更进一步
盯成功率随时间的走势,而不只是单次运行的通过/不通过,这样你能看见改 prompt 时的缓慢漂移。加一个小小的「裁判校准」集:几个手工标好判定的用例,拿裁判去跑,好让你及时发现裁判自己退步了。再给用例加权:漏一个 SQL 注入应该比漏一个风格挑刺扣得更多,而不加权的成功率会把这个差别盖掉。
小结
你用一个能拿出来辩护的数字换掉了凭感觉。这个数字建在真实失败之上,用能干这活儿的最便宜工具来打分:答案无歧义的地方用精确匹配,否则用带收紧二元标准的 LLM 裁判。这套集合现在是你的回归网,跑在 CI 里,于是重构和 prompt 改动都要被打分,而不是被白白信任。下一章让单次运行变得可观测,这样某个用例失败时,你能看清为什么,而不只是知道它失败了。
常见坑
- 单一数字盖住一切。 一个成功率没法告诉你哪类任务退步了。逐用例结果要留着、要打印。
- 裁判本身也是模型。 它可能出错、太宽松,或者被钻空子。审计它的判定,把标准写窄、写成二元,不认同时改标准(不是改 agent)。
- 静态集合会过时。 你不再往里加用例,集合就会偏向 agent 本来就能过的那些题。持续从生产里补新的失败进来。
- 用例太简单。 如果写坏 prompt 都不让数字动一下,这套评估什么都证明不了。靠故意制造退步、看着成功率掉下去来校准。
- 没有
must_not_find。 只评「该出现什么」,一个吵闹的 reviewer 就能蒙混过去。误报也要惩罚。
第一次给小满考试,它考砸了一道。然后它沉默了两秒,默默贴出自己错在哪。它学会了自我怀疑,这是从「听话」迈向「可信」的第一步。试炼场,亮了。
刚点亮 试炼场 · 地图已点亮 9 / 16
来源
- OpenAI Evals · official
- Agent 可观测性与评估(Hugging Face Agents Course) · official