2026-06-11 · harness

第 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

可它在背后到底做了什么,你还是看不见。下一站:琉璃室。

来源

  1. OpenAI Evals · official
  2. Agent 可观测性与评估(Hugging Face Agents Course) · official
下一章 · 第 9 章 可观测性