3.1
Part III / Evaluate · The discipline that compounds

评估驱动开发。

你在第 1.4 章构建了第一套评估(eval)套件。本章将把那次一次性工作转化为日常实践。以分数增量(delta)作为进展的衡量单位。回归预算让你能够诚实地权衡子分项的变化。PR 评论工作流将评估结果推送到每一次代码审查面前。这套节奏让你摆脱凭感觉优化的陷阱。到本章结束时,你将拥有一个没有经过数字验证就无法合并变更的智能体代码库,而这些数字将在数月内持续向好,而非悄然退步。

STEP 1

增量,而非分数。

关于评估驱动开发,首先要内化的是:进展的衡量单位不是分数本身,而是针对某次具体变更的分数增量。如果你从本章只记住一件事,记住这个。73.4% 这个数字本身没有意义。"+1.8 vs main"这个数字有实际意义。而"+1.8 检索召回率,但 -3.1 扎根忠实度"则精确地告诉你下一步该做什么。

刚接触评估的团队总是重复同一个错误。他们运行一次套件,读取总分,然后要么庆祝("我们达到 78%,相当不错"),要么沮丧("我们只有 41%,还没准备好")。两种反应都错过了重点。没有对比基准,分数只是一个数字。没有逐子分项的拆解,你无法判断一次变更是否值得。而没有可重复测量的基准线,你甚至无法可靠地计算增量。

最简可用的计分板

你真正需要的是一小段基础设施:一个 CSV(或表格,或 scoreboard.jsonl),为每一次有意义的变更记录完整的子分项拆解,以及对应的提交 SHA。第 1.4 章你已经有了 scoreboard.csv——我们将在此基础上扩展。

# scoreboard.csv — what e-d-d depends on
commit,timestamp,branch,
  overall,
  retrieval_recall_at_5,
  retrieval_mrr,
  verifier_agreement,
  trajectory_pass_rate,
  trajectory_steps_avg,
  cost_per_run_usd,
  notes
b3a1f7e,2026-04-12T08:14:22Z,main,
  0.732, 0.81, 0.74, 0.88, 0.69, 4.2, 0.041, baseline
c9e2a44,2026-04-12T10:33:01Z,feat/rerank,
  0.768, 0.86, 0.79, 0.87, 0.71, 4.4, 0.044, add cross-encoder rerank
d1f5b88,2026-04-12T14:22:18Z,feat/judge-prompt,
  0.745, 0.81, 0.74, 0.92, 0.71, 4.1, 0.041, tighter verifier prompt

就这些。三次提交,三行记录,每个子分项均已捕获。这两次变更并非拨动同一个旋钮——重排序变更改善了检索(好),但代价是步骤略多、成本略高(稍差)。判断器提示词变更不影响检索,但大幅提升了验证器一致性(好)。这是两个不同的故事。单一加权平均值会掩盖两者。

差分命令

现在,编写最简化的增量计算器。它接受一个提交 SHA,显示自 main 以来的变化。五分钟的代码,一周用上百次:

# scripts/eval_delta.py
import argparse, csv, sys
from pathlib import Path

p = argparse.ArgumentParser()
p.add_argument("--against", default="main")
p.add_argument("--commit", required=True)
args = p.parse_args()

rows = list(csv.DictReader(open("scoreboard.csv")))

# most recent row on baseline branch
baseline = next(r for r in reversed(rows) if r["branch"] == args.against)
# row for the candidate commit
candidate = next(r for r in rows if r["commit"] == args.commit)

print(f"baseline:  {baseline['commit']} ({baseline['branch']})")
print(f"candidate: {candidate['commit']} ({candidate['branch']})")
print()

METRICS = ["overall", "retrieval_recall_at_5", "retrieval_mrr",
           "verifier_agreement", "trajectory_pass_rate",
           "trajectory_steps_avg", "cost_per_run_usd"]

for m in METRICS:
    b = float(baseline[m])
    c = float(candidate[m])
    delta = c - b
    sign = "+" if delta >= 0 else ""
    arrow = "↑" if delta > 0 else ("↓" if delta < 0 else "·")
    print(f"  {m:32s}  {b:6.3f} → {c:6.3f}  {sign}{delta:+.3f} {arrow}")
$ python scripts/eval_delta.py --commit c9e2a44

baseline:  b3a1f7e (main)
candidate: c9e2a44 (feat/rerank)

  overall                           0.732 → 0.768  +0.036 ↑
  retrieval_recall_at_5             0.810 → 0.860  +0.050 ↑
  retrieval_mrr                     0.740 → 0.790  +0.050 ↑
  verifier_agreement                0.880 → 0.870  -0.010 ↓
  trajectory_pass_rate              0.690 → 0.710  +0.020 ↑
  trajectory_steps_avg              4.200 → 4.400  +0.200 ↓ (worse)
  cost_per_run_usd                  0.041 → 0.044  +0.003 ↓ (worse)

这次变更可以发布。检索在两个维度上都有所改善,验证器一致性几乎没有下降(在噪声范围内),轨迹通过率有所提升,成本上涨约 7%。如果你的成本上限还有余量,这笔交易看起来是合算的。如果成本上限已经绷紧,这就是一个值得在 PR 上讨论的话题——而这恰恰是此类对话应该发生的地方。

噪声基线(noise floor):多大才算"真实"?

下一个问题来了,而且正是团队会跳过的那个:+0.036 的增量看起来是真实的,但它真的是真实的吗,还是落在了逐次运行的噪声范围内?诚实的回答是:在你测量过评估套件的噪声基线之前,你不知道。

测量一次就够了。对同一个提交运行相同的评估,共 5 次,观察每个指标的方差。

# scripts/noise_floor.py
import statistics, subprocess, csv

runs = []
for i in range(5):
    subprocess.run(["make", "eval-full"], check=True)
    # reads the row just appended for this commit
    rows = list(csv.DictReader(open("scoreboard.csv")))
    runs.append(rows[-1])

for metric in METRICS:
    vals = [float(r[metric]) for r in runs]
    mean, stdev = statistics.mean(vals), statistics.stdev(vals)
    print(f"{metric:32s}  μ={mean:.3f}  σ={stdev:.3f}  noise=±{2*stdev:.3f}")
$ python scripts/noise_floor.py

overall                           μ=0.732  σ=0.008  noise=±0.016
retrieval_recall_at_5             μ=0.810  σ=0.003  noise=±0.006  ← stable
retrieval_mrr                     μ=0.740  σ=0.005  noise=±0.010
verifier_agreement                μ=0.880  σ=0.018  noise=±0.036  ← noisy!
trajectory_pass_rate              μ=0.690  σ=0.022  noise=±0.044  ← noisy!
trajectory_steps_avg              μ=4.200  σ=0.150  noise=±0.300
cost_per_run_usd                  μ=0.041  σ=0.001  noise=±0.002
这告诉你什么

检索指标很稳定——它们基于固定标签计算,变化仅来自检索的随机性(可以通过种子控制)。作为小增量信号,它们是可信的。验证器一致性和轨迹通过率的噪声要大得多,因为它们涉及 LLM 作为评判者(LLM-as-judge)的调用和随机的智能体路径。轨迹通过率上 +0.02 的变化完全在噪声范围内;从单次运行中无法将其解读为真实的改进。

解决方法不是绝望,而是对噪声较大的指标要求多次运行测量。对于针对噪声指标的候选版本,运行套件 3 次,报告均值。拒绝接受小于 2σ 的单次运行"改进"。

将噪声基线编入差分工具

更新 eval_delta.py,使用测量得到的 σ 将每个增量标记为"真实"或"在噪声内"。你实际会用到的版本:

NOISE = {
    "overall":                 0.016,
    "retrieval_recall_at_5":   0.006,
    "retrieval_mrr":           0.010,
    "verifier_agreement":      0.036,
    "trajectory_pass_rate":    0.044,
    "trajectory_steps_avg":    0.300,
    "cost_per_run_usd":        0.002,
}

for m in METRICS:
    b, c = float(baseline[m]), float(candidate[m])
    delta = c - b
    real = abs(delta) > NOISE[m]
    flag = "REAL" if real else "noise"
    print(f"  {m:32s}  {b:.3f} → {c:.3f}  {delta:+.3f}  [{flag}]")
$ python scripts/eval_delta.py --commit c9e2a44

  overall                           0.732 → 0.768  +0.036  [REAL]
  retrieval_recall_at_5             0.810 → 0.860  +0.050  [REAL]
  retrieval_mrr                     0.740 → 0.790  +0.050  [REAL]
  verifier_agreement                0.880 → 0.870  -0.010  [noise]
  trajectory_pass_rate              0.690 → 0.710  +0.020  [noise]
  trajectory_steps_avg              4.200 → 4.400  +0.200  [noise]
  cost_per_run_usd                  0.041 → 0.044  +0.003  [REAL]

画面突然清晰了。重排序变更真正地改善了检索,也真正地略微提升了成本。其余一切都在噪声范围内——表面上的验证器下降毫无意义,表面上的轨迹改善同样毫无意义。在得出任何结论之前,你需要对这些指标进行多次运行测量。

这就是这套规范的意义所在。没有它,团队会花数周时间"优化"那些实际上与基准毫无差异的东西。有了它,你的评估会变得更安静——你不再把噪声中的模式当成信号——真实的信号会清晰地凸显出来。

Question
两倍标准差是 95% 置信度阈值。这不就是统计学吗?

是的——坦率地说:这只是为工程师包装过的假设检验。之所以值得明确标注,而不是假设人人皆知,是因为机器学习团队习惯性地将单次运行的改进当作确定性结论来报告,而在增量旁边标注 [noise] 的规范,能迫使对话真正发生。数学是本科阶段的统计学;实践却十分罕见。

你可以做得更严格(proper t 检验、自助法置信区间),关心这点的人应该这样做。2σ 规则是捕获最常见错误的最低门槛。

Question
我的评估套件需要 20 分钟才能运行完。每个候选版本跑 3 次不现实。

三个答案,按工作量递增排列:

  • 拆分套件。 大多数团队都有一个快速子集(低成本的单元测试/检索检查,每次提交都运行)和一个慢速子集(带判断器调用的完整轨迹,每晚运行或在 PR 合并到 main 时运行)。第 1.4 章已经提到过这一点。快速套件只跑一次;慢速套件每晚跑三次,报告均值。
  • 并行化。 评估用例天然支持并行。使用 asyncio.gather(第 0.4 章,或 Anthropic 的 Message Batches API),你可以在运行单个轨迹的时间内同时运行 50 个。突然之间,3× 变成了 3× 某个快速的东西。
  • 降低门槛。 如果你能接受更多误报,就用 1.5σ 代替 2σ。对廉价指标(检索)只跑一次,只对 LLM 判断器指标跑多次。这是工程权衡,不是道德问题。
STEP 2

回归预算:让权衡显式化。

STEP 1 给了你一种诚实解读增量的方法。STEP 2 讨论的是当增量是混合结果时该怎么做——当一次变更让某些指标上升、另一些指标下降,你必须决定是否发布。

最简单的答案是"计算一个加权平均值,看总体分数"。这样做的问题在于:加权平均值掩盖了你更希望明确讨论的权衡。一次将检索提升 5 分、忠实度下降 4 分的变更,加权后综合分数可能是 +1,但你几乎肯定希望发布"你的智能体检索更好但更容易编造内容"的版本。忠实度对大多数产品来说是不可妥协的;检索是在这一约束之内优化的对象。

更好的思维模型是回归预算。针对每个指标,提前决定:此指标绝不能下降超过 X 点;此指标在其他指标足够上升时可以略微下降;此指标可以自由波动。将预算编码为配置文件。让差分工具告诉你一次变更是否在预算之内。

定义预算

# evals/budgets.yaml
# Per-metric regression budget. Negative numbers are allowed drops.
# "hard" means the merge gate fails if exceeded.
# "soft" means warn but allow.

budgets:
  retrieval_recall_at_5:
    direction: maximize
    hard_floor: -0.01     # never drop by more than 1 point
    soft_floor: -0.005

  verifier_agreement:
    direction: maximize
    hard_floor: -0.005    # faithfulness is non-negotiable
    soft_floor: 0.0       # even noise-level drops get flagged

  trajectory_pass_rate:
    direction: maximize
    hard_floor: -0.02
    soft_floor: -0.01

  trajectory_steps_avg:
    direction: minimize
    hard_ceiling: +0.5    # never run >0.5 more steps on average
    soft_ceiling: +0.2

  cost_per_run_usd:
    direction: minimize
    hard_ceiling: +0.010  # never raise per-run cost by >$0.01
    soft_ceiling: +0.003

标签很重要。硬性下限/上限会阻断合并。 CI 运行评估、计算增量,如果任何指标突破了硬性阈值,该 PR 在没有明确覆盖声明的情况下无法发布。软性下限/上限则发出警告。 CI 在 PR 上标注"验证器下降了 0.4 点(在预算内但值得关注)",但不阻断。这种区分很重要,因为有些指标理应拥有一票否决权,而另一些则不然。

支持预算的差分工具

再次扩展差分工具:

# scripts/eval_delta.py (with budgets)
import yaml

budgets = yaml.safe_load(open("evals/budgets.yaml"))["budgets"]

def verdict(metric, delta):
    b = budgets.get(metric)
    if not b: return ""
    if b["direction"] == "maximize":
        if delta < b["hard_floor"]:  return "❌ HARD"
        if delta < b["soft_floor"]:  return "⚠ SOFT"
    else:  # minimize
        if delta > b["hard_ceiling"]:  return "❌ HARD"
        if delta > b["soft_ceiling"]:  return "⚠ SOFT"
    return "✓"

violations_hard = 0
for m in METRICS:
    b, c = float(baseline[m]), float(candidate[m])
    delta = c - b
    v = verdict(m, delta)
    if "HARD" in v: violations_hard += 1
    print(f"  {m:32s}  {delta:+.3f}  {v}")

sys.exit(1 if violations_hard > 0 else 0)
$ python scripts/eval_delta.py --commit c9e2a44

  retrieval_recall_at_5             +0.050  ✓
  retrieval_mrr                     +0.050  ✓
  verifier_agreement                -0.010  ❌ HARD
  trajectory_pass_rate              +0.020  ✓
  trajectory_steps_avg              +0.200  ⚠ SOFT
  cost_per_run_usd                  +0.003  ⚠ SOFT

❌ 1 hard violation. PR cannot merge until resolved.

现在 PR 的讨论有了结构。CI 告知作者:你的变更在忠实度这一不可妥协的不变量上发生了回归——在合并前请与团队讨论,或改进变更以避免该指标退步。有两种健康的结果:作者修复了回归,或团队明确决定预算设定有误并更新它(这本身是一个独立的 PR,需要单独审查)。不会发生的事情是悄无声息地发布一个降低了忠实度的变更。

你脑海中的帕累托前沿

预算处理的是简单情况。更难的情况是:一次变更越过了软性阈值,但你仍想发布它,因为它在另一个指标上的收益很大。这时,你需要开始明确地思考帕累托前沿(Pareto frontier)。

faithfulness ↑ | 1.0 | · | • A: rerank (current) 0.90 | · • | • • B: rerank + tighter judge | • • 0.80 | • • C: bigger model (cost+++) | • 0.70 | • | •_____________________________ retrieval recall 0.6 0.7 0.8 0.9 1.0 ↑ On the frontier: A, B, C — no other option dominates them. Off the frontier: every other point — strictly worse than something on it.

对于两个指标,你可以直接画出来。对于五个指标,你做不到,但原则依然适用:如果一次变更让至少一个指标改善、且没有任何指标在噪声范围之外变差,那它就是帕累托改进——是明确的进步,应该发布。如果一次变更改善了某些指标、但恶化了另一些,那就是帕累托权衡——它位于前沿上,发布它意味着将前沿向团队选择的方向移动。

这套规范所强制要求的是:每一次非帕累托改进的合并,都应当在 PR 描述中附上一行理由。不是长篇备忘录,一行就够。"尽管成本增加了 +0.003,仍然发布,因为 +0.05 的检索提升能解锁 Q3 的用例。"如果你写不出这一行,变更就还没准备好。

Question
初始预算怎么定?我不就是围绕当前值来设吗?

正是如此——这完全没问题。预算不是努力目标;它们是"不要在没有清醒决定的情况下从这里滑落"。对所有不可妥协的指标,将硬性下限设为"当前值减去一个略超过噪声的小余量"。将软性下限设得稍紧一些。

一旦预算被突破而团队决定放宽它,这就是一个信号:要么该指标的重要性确实低于预期,要么团队在为一次回归找理由。编辑预算配置的摩擦成本,使第二种情况变得可见。

Question
如果我有 12 个指标,预算管理感觉会让人不堪重负怎么办?

你可能不需要 12 个预算。选出 3–5 个真正对你的产品重要的指标。其余的可以软性追踪(记录,但不设预算)。一个常见的起始组合:

  • 一个端到端的任务成功指标(trajectory_pass_rate 或类似指标)。硬性下限。
  • 一个忠实度/正确性指标(verifier_agreement、扎根率)。硬性下限。
  • 一个成本上限。硬性。
  • 一个延迟上限。硬性。
  • 一个检索质量指标。软性。

五个预算,其中四个是硬性的。这足以防止静默回归,同时不会让每个 PR 淹没在红色 ❌ 中。

Question
这不就是在拖慢迭代速度吗?我最好的一些变更在某个指标上都有轻微回归。

很可能恰恰相反。没有预算时,团队会在数周"改进"之后发现某个没有人关注的指标下降了 8 点,而且不知道是哪次变更导致的。排查那团乱麻才是真正的时间黑洞。在导致问题的 PR 上就当场发现回归,而且作者还记得当时的上下文,只需要几秒钟。

而且:预算覆盖并不官僚。一行 PR 评论就够了。"覆盖:+0.003 的成本在 Q3 计划范围内。"完成。摩擦程度与决策的代价相匹配。

STEP 3

PR 评论工作流。

STEP 1 给了你诚实的增量。STEP 2 给了你预算。这两者都毫无价值,除非它们出现在真正做决策的地方——那是拉取请求(pull request),不是你的终端。这一步将评估接入你的版本控制工作流,让每次代码审查都自动包含评估审查。

整体形态:开发者打开一个 PR。CI 启动,对 PR 分支运行评估,再对 main 运行一次,计算带预算裁决的增量,将结果作为评论发布到 PR 上。每次推送时,评论都会更新,让审查者始终看到最新状态。硬性违规以失败的状态检查形式出现;软性违规以评论中的警告形式出现。代码审查和评估审查在同一个对话中进行。

CI 工作流

具体来说,使用 GitHub Actions。同样的结构适用于 GitLab、CircleCI、Buildkite——区别仅在语法上。

# .github/workflows/eval-pr.yml
name: eval-pr
on:
  pull_request:
    branches: [main]

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }   # need history for baseline lookup

      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }

      - name: Install deps
        run: pip install -r requirements.txt

      - name: Run fast eval suite (always)
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: make eval-fast

      - name: Run full suite if labeled 'eval-full'
        if: contains(github.event.pull_request.labels.*.name, 'eval-full')
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: make eval-full

      - name: Compute delta vs main
        id: delta
        run: |
          python scripts/eval_delta.py \
            --against main \
            --commit ${{ github.event.pull_request.head.sha }} \
            --format markdown \
            > delta.md

      - name: Post PR comment
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          path: delta.md
          header: eval-results   # sticky: replaces previous comment

      - name: Fail on hard violations
        run: python scripts/eval_delta.py --check-hard \
             --commit ${{ github.event.pull_request.head.sha }}

这个 YAML 中有两处值得关注。第一,标签触发的完整套件:大多数 PR 只运行快速套件(检索 + 单元检查,<2 分钟);触碰智能体循环、提示词或模型选择的 PR 会获得 eval-full 标签,并运行慢速轨迹套件。标签作为触发器比始终运行一切(慢且昂贵)或只在合并时运行(发现回归太晚)要好得多。

第二,置顶评论。每次推送都更新同一条评论,而不是创建新评论。审查者只看到当前状态,而不是"随着作者迭代,评估先变好、再变差、再变好"的历史。那段历史在 scoreboard.csv 里;PR 评论是一个快照。

评论格式

这是审查者应该看到的内容。经过多次迭代调整——目标是"3 秒扫一眼,好奇者可深入钻研":

## 📊 eval-results

vs main (b3a1f7e)
**overall: 0.732 → 0.768 (+0.036) ↑ [REAL]**

|  metric                   |  base  |   pr   |  delta  | verdict |
| ------------------------- | ------ | ------ | ------- | ------- |
|  retrieval_recall_at_5    | 0.810  | 0.860  | +0.050  |   ✓     |
|  retrieval_mrr            | 0.740  | 0.790  | +0.050  |   ✓     |
|  verifier_agreement       | 0.880  | 0.870  | -0.010  | ⚠ SOFT  |
|  trajectory_pass_rate     | 0.690  | 0.710  | +0.020  |   ✓     |
|  trajectory_steps_avg     | 4.200  | 4.400  | +0.200  | ⚠ SOFT  |
|  cost_per_run_usd         | 0.041  | 0.044  | +0.003  | ⚠ SOFT  |

cost: $1.84 (full suite, 50 trajectories)
runtime: 14m 22s

— [scoreboard.csv](link) · [full results](link)

关于这个格式,有几点值得注意。最重要的信号以最高优先级排在顶部。每个指标都有一个表格行,右侧是裁决列——审查者扫的就是这一列。软性违规出现了,但不会让人恐慌;硬性违规会以 ❌ 出现在裁决列,同时触发 PR 顶部的失败状态检查。

运行时间和成本行的存在出于同样的理由:它们是审查者可能想要的元数据,其中一项本身就是预算管控的指标(成本)。不要将它们埋起来;它们属于增量旁边。

这实际上改变了什么

评估驱动开发不是一个工具,而是一种习惯。这种习惯是:我不会在没有看到评估增量的情况下合并对智能体的变更,团队也不会在没有阅读评估增量的情况下批准对智能体的变更。CI 基础设施的存在,是为了让这种习惯变得廉价。没有 CI,习惯就会消亡——人们会忘记、匆忙行事、不经测量就发布。有了 CI,习惯就是自动的:评估结果每次都直接摆在 PR 上。

第一次,当一位队友看起来很聪明的重构因为评估在忠实度上下降了 4 点而被拒绝——那就是本章开始发挥价值的时刻。第一次,有人在一个草稿 PR 中探索了 5 种提示词变体,而你可以看到哪一种实际上更好——那就是本章发挥价值的时刻。第一次,一名初级工程师因为数字证明这是一项改进,而充满信心地发布了他们的第一次变更——那就是本章发挥价值的时刻。

如果你从本章只做一件事:在做其他任何事之前,先把 PR 评论工作流搭起来。预算、帕累托思维、多次运行测量——所有这些都建立在"我的队友在审查我的代码时能否看到评估结果"这个前提之上。没有这个前提,其余的都不会发生。

Question
在每个 PR 上运行完整评估会花真金白银。我怎么为此制定预算?

实际数字:一套带判断器调用的 50 问轨迹套件,根据你的智能体平均令牌数和判断器模型,每次执行花费 $1–5 美元。有了标签触发的完整运行,你每周可能只跑 5 次,而不是 50 次——也就是每周 $25,每月 $100。对大多数团队来说,这完全在实验预算之内。对于早期项目,每月 $50 的支出微不足道。

真正累积起来的是:在每个 PR 的每次推送上运行完整套件(×10–20 次推送/PR)、在尚未准备好的特性分支提交上运行、在没有触及任何相关内容的 PR 上运行。标签门控正是保持账单合理的手段。

Question
我的团队不用 GitHub Actions / 我们的 CI 受到限制 / 无法轻松运行外部评估工作流。最简可用版本是什么?

最简可用版本是一个脚本加上一个 Slack/Discord webhook。开发者在推送前在本地运行 make eval-pr,脚本将增量发布到团队频道,审查者在批准前阅读它。

这比 CI 明显差——规范依赖于人的记忆——但比什么都没有明显好。我见过很多采用这种手动工作流的团队最终都迁移到了 CI,但真正重要的工作流是"每次变更都有可见的增量",而不是"CI 来运行它"。从今天能搭起来的开始。

Question
对于同一个 PR 中包含非智能体代码的单体仓库,这如何运作?

对工作流使用路径过滤。只在 PR 触及 agent/prompts/retrieval/tools/evals/ 本身下的文件时才运行评估。GitHub Actions 在工作流触发器层面支持 paths: 过滤器。只触及前端或无关服务的 PR 会跳过评估,节省成本和 CI 时间。任何触及智能体代码的 PR 则获得完整处理。

STEP 4

版本管理与节奏:一天实际是什么样的。

最后一块是团队几乎总是吃了亏才学会的:如果底层模型在你不知情的情况下发生了变化,你的评估分数就毫无意义。Sonnet 4.5 上的 0.732 和 Sonnet 4.6 上的 0.741 不具有可比性——但如果你不管三七二十一地拿它们比较,你会得出结论说你做了什么改进了智能体,而实际上是模型提供商在你睡觉时让它变好了。更糟的是,当提供商悄悄切换了一个检查点时,你可能会得出结论说你做了什么让智能体退步了。

这是可复现性问题,有一个部分解答:将所有影响分数的因素都做版本管理,并与分数本身一起记录。

四个版本化轴

每一行评估记录至少需要记录以下四件事:

  • 代码 SHA——评估运行时刻你的智能体代码。你已经有了,来自 git rev-parse HEAD
  • 提示词版本——你的系统提示词和工具描述,经过哈希或版本标记。提示词在快速迭代中会独立于代码发生变化。
  • 模型标识符——包括提供商暴露的任何版本后缀。claude-sonnet-4-5 还不够;claude-sonnet-4-5-20250929 才够。
  • 语料库版本——智能体检索的文档集。语料库刷新会改变分数;假装没有发生就是许多"神秘"回归的根源。
# Updated scoreboard schema with versioning
commit, timestamp, branch,
  prompt_hash,     # sha256 of prompts/ directory
  model_id,        # claude-sonnet-4-5-20250929 (full string)
  corpus_version,  # git tag of corpus repo, or DVC hash
  overall, retrieval_recall_at_5, ...

计算版本戳

# evals/version.py
import hashlib, subprocess
from pathlib import Path

def prompt_hash() -> str:
    """Hash every file in prompts/ — order-stable."""
    h = hashlib.sha256()
    for p in sorted(Path("prompts").rglob("*.txt")):
        h.update(p.read_bytes())
    return h.hexdigest()[:12]

def model_id(response) -> str:
    """Pull the resolved model ID from a real API response.
    Providers may resolve aliases to a specific dated snapshot;
    record what we actually got, not what we asked for."""
    return response.model  # Anthropic/OpenAI both return this

def corpus_version() -> str:
    """Whatever your corpus uses for versioning."""
    # If your corpus is a git submodule:
    return subprocess.check_output(
        ["git", "-C", "corpus", "rev-parse", "--short", "HEAD"]
    ).decode().strip()
    # If you use DVC: return dvc.api.read_metadata("corpus.dvc")["md5"]

这能让你做什么

现在差分工具可以更智能了。在计算增量之前,它会检查版本戳。如果任何非代码轴有差异,它会标记这次比较:

$ python scripts/eval_delta.py --commit d1f5b88

⚠ baseline and candidate differ on non-code axes:
  - model_id:   claude-sonnet-4-5-20250929 → claude-sonnet-4-5-20251015
  - corpus_v:   a3f2c1d → a3f2c1d  (same)
  - prompts:    7b2a... → c1e8...  (changed in this PR)

  This delta mixes prompt changes AND a model snapshot change.
  Re-run baseline on the new model_id before drawing conclusions.

  overall                           0.732 → 0.768  +0.036
  ...

这一个警告让你免于智能体开发中最常见的错误结论。当模型快照在你不知情的情况下更新,你的旧基准就过时了——在得出任何候选版本增量的结论之前,你必须在新快照上重新测量基准。

修复方法很机械:每晚,用当前 model_id 对 main 重新运行评估,并向计分板添加一行新的基准记录。这样,白天的 PR 比较总是使用同一模型快照上的近期基准。廉价、自动,从根本上消除了这类问题。

日常节奏

这就是评估驱动开发作为一种工作实践的实际样子。以天为单位描述,你可以将其与你当前的周二进行对比。

┌─────────────────────────────────────────────────────────────────┐ │ MORNING │ │ │ │ 1. Check last night's nightly run │ │ → baseline refreshed? any silent drift? │ │ 2. Open scoreboard.csv, scan last 7 days of main │ │ → any metric drifting in a direction you don't like? │ │ 3. Pick today's work from the queue │ │ │ ├─────────────────────────────────────────────────────────────────┤ │ AFTERNOON: one change at a time │ │ │ │ 1. Hypothesis: "I think doing X will improve metric Y" │ │ → write the hypothesis in the PR description NOW. │ │ │ │ 2. Implement the smallest version of X. │ │ → not the cleanest, not the prettiest. The smallest │ │ possible change that lets you measure. │ │ │ │ 3. Push. Wait ~5 min for fast eval, ~20 min for full. │ │ → if labeled eval-full. │ │ │ │ 4. Read the delta. │ │ ─ Hypothesis confirmed AND no soft violations? │ │ → polish the code, get review, merge. │ │ ─ Hypothesis confirmed but with regressions? │ │ → decide: is the trade worth it? Discuss on PR. │ │ ─ Hypothesis not confirmed? │ │ → close the PR. Open a new one with hypothesis 2. │ │ → write what you learned in the closed PR's notes. │ │ │ ├─────────────────────────────────────────────────────────────────┤ │ END OF DAY │ │ │ │ 1. Look at the day's PRs that got merged. │ │ → did the aggregate move overall in the right direction? │ │ 2. If multiple PRs in a row had "hypothesis not confirmed" │ │ → pause. Your priors might be wrong. Read traces, talk │ │ to a colleague, change strategy before iterating more. │ └─────────────────────────────────────────────────────────────────┘

这套节奏中最重要的部分,看起来像个脚注:在开始实现之前,将假设写入 PR 描述中。这是区分评估驱动开发与评估装点开发的规范。如果你在看到结果之后才写假设,你会对无论什么发现都做出合理化解释。如果你在看到结果之前就写下来,你就有了一份诚实的记录,说明你预测了什么,以及你是否预测对了。随着时间推移,这正是你了解自己的直觉在哪里准确、在哪里失准的方式。

最后一个需要指出的反模式

在采用评估驱动开发(EDD)的团队中,最常见的失败模式是:调整评估套件以拉高分数。这无声无息地发生——有人注意到第 17 个用例是一个已知的奇怪边缘情况,把它从套件中移除,总体分数上升了,大家都感觉不错。几个月下来,你的评估已经什么也衡量不了,因为所有难以通过的用例都被移除了。

规则是:评估套件默认只增不减。你可以自由添加用例。不能在没有独立 PR 和明确理由的情况下移除或修改用例,且该 PR 需要单独审查。移除一个难以通过的用例是一个严肃的决定;将其变成一个独立的 PR 并附带独立审查,可以让这个决定浮出水面,获得它应有的审视。

留意这个模式:一个团队的总体评估分数在三个月内持续攀升,所有人都在庆祝。然后他们发布了一个重大版本,用户对团队已经不再测试的那些失败模式发出了抱怨。分数上去了;智能体并没有变好。只增不减的评估规范正是防止这种情况发生的手段。

Question
如果我的假设太模糊,无法提前写下来怎么办?

这本身就是一个信号。如果你无法说清楚哪个指标应该向哪个方向移动,你还没有假设——你只有一个直觉。直觉没问题,但它应该在一个"探索性试验"中被探索——一个随意的分支,在没有期望的情况下随意探究。一旦你有了可以写下来的假设("如果我把检索 top_k 从 5 增加到 8,召回率应该提升 ≥0.02,成本增加最多 +0.003"),再开正式的 PR。

提前写下来的规范,迫使你在动手之前知道自己要做什么。这就是全部的意义所在。

Question
我的团队只有我一个人。这一切是不是过度了?

CI 基础设施对一个人来说确实过度了。假设优先的规范则不然。即使是单打独斗,也要把假设写在提交信息里。即使是单打独斗,也要拒绝说服自己一次回归"没问题"。即使是单打独斗,在决定一次变更有效之前也要先看增量。原因是:三个月后,你会忘记自己做了什么以及为什么。这些笔记是写给未来的你的,不是写给队友的。

轻量级的单人版本:scoreboard.csv + 一个打印增量的脚本 + 假设写在提交信息里的习惯。大约 100 行代码。两周内就能回本。

Question
如果我在对 Anthropic 和 OpenAI 做基准测试,如何处理跨提供商的模型漂移?

把它们视为不同的产品。它们本来就是。不要试图计算"这次变更在 Anthropic 上是 +0.02,在 OpenAI 上是 -0.01,净值为正"——这个计算是不连贯的。相反,维护两个计分板文件(或两列),分别独立报告增量,让团队分别决定每个方向是否可以接受。大多数团队最终会把一个主要提供商用于生产环境,把另一个作为探针,用来回答"我们的智能体设计是否偏向某一个提供商的行为?"这是不同的问题,有不同的答案。

WORKED EXAMPLE

三个 PR:从 73% 到 81%,节奏清晰可见。

STEP 4 中的节奏图描述了节奏。这里是一个实际序列——同一个智能体连续三天——展示它如何随着 PR 的落地而展开。智能体是第一部分中的研究助手。起点:在 50 问评估集上 overall = 0.732。目标:在本周内超过 0.80。

周一:假设驱动,得到验证

早晨计分板扫描。智能体的 trajectory_pass_rate 在 0.69 上停滞了两周。retrieval_recall_at_5 是 0.81——不错,但有提升空间。直觉:检索是瓶颈。如果模型看不到正确的文本块,就无法合成好的答案。

PR 描述,在任何代码编写之前写好:

"""
Add cross-encoder reranking to retrieval.

HYPOTHESIS: Initial BM25 + embedding fusion gets the right document
into the top 20 results in ~95% of cases (measured), but the right
chunk only makes top-5 in ~81%. A cross-encoder rerank on the
top-20 should push more of those right chunks into the top 5.

PREDICTION:
- retrieval_recall_at_5: +0.04 to +0.06  (real, above 2σ noise)
- retrieval_mrr:         +0.04 to +0.05
- trajectory_pass_rate:  +0.02 (downstream of retrieval improvement)
- cost_per_run_usd:      +0.002 to +0.004 (one extra small-model call)
- trajectory_steps_avg:  no change

REJECTION CRITERION: if retrieval_recall_at_5 doesn't beat baseline
by at least 2σ (≥0.012), the hypothesis is wrong and I close this PR.
"""

下午:实现、推送、等待。花 45 分钟添加一个对前 20 个候选结果的 Cohere rerank 调用。推送。CI 中的快速评估套件在 3 分钟内运行完毕;完整套件在 eval-full 标签后面等候,PR 模板已自动添加该标签,因为差异触及了 retrieval/。完整套件需要 18 分钟。

落地的置顶 PR 评论:

## 📊 eval-results

vs main (b3a1f7e)
**overall: 0.732 → 0.768 (+0.036) ↑ [REAL]**

|  metric                   |  base  |   pr   |  delta  | verdict |
| ------------------------- | ------ | ------ | ------- | ------- |
|  retrieval_recall_at_5    | 0.810  | 0.860  | +0.050  |   ✓ REAL|
|  retrieval_mrr            | 0.740  | 0.790  | +0.050  |   ✓ REAL|
|  verifier_agreement       | 0.880  | 0.870  | -0.010  | noise   |
|  trajectory_pass_rate     | 0.690  | 0.710  | +0.020  | noise   |
|  trajectory_steps_avg     | 4.200  | 4.400  | +0.200  | noise   |
|  cost_per_run_usd         | 0.041  | 0.044  | +0.003  | ⚠ SOFT  |

cost: $1.84 (full suite)  ·  runtime: 18m 12s
解读这份结果

预测几乎落在了预测区间内。检索移动幅度真实且显著;轨迹和验证器的变化在噪声范围内(单次运行无法确定,需要 3× 才能确认);成本恰好增加了预测的金额,达到软性上限但未触及硬性上限。PR 描述中的拒绝标准是检索召回率达到 ≥0.012 的基准;实际变化是 +0.050,远超该标准。假设得到验证。

软性成本违规引发了一段简短的 PR 讨论:一位审查者问,$0.003/次运行 × 约 3000 次运行/天 = 约 $270/月,是否值得 +0.05 的检索召回率。作者写了一行理由:"值得——这解锁了需要该召回率提升的 Q3 产品用例。"审查者批准。合并。

当天项目日志中的记录:"检索重排序:验证了假设。总体 +0.036。单次运行的轨迹和验证器噪声基线使得无法判断是否存在下游影响——应在 2–3 次更多变更落地后重新测量,或使用 3× 多次运行。"

周二:假设驱动,被拒绝

早晨。昨天的成果已经进入 main。今天的假设:验证器提示词太宽松——当证据只是间接相关时,它把声明标记为"SUPPORT"。更严格的提示词应该能提升 verifier_agreement 与手标数据集的一致性。

PR 描述:

"""
Tighten verifier prompt to require explicit evidence.

HYPOTHESIS: Current prompt asks "is the claim supported?" which
the judge interprets loosely. Asking "does the source contain a
sentence that explicitly states the claim, or that the claim
follows from by a single inference step?" should reduce false-
positive SUPPORT verdicts.

PREDICTION:
- verifier_agreement:    +0.03 to +0.05 (REAL)
- trajectory_pass_rate:  no change (verifier is downstream)
- retrieval_recall_at_5: no change (orthogonal)

REJECTION: if verifier_agreement is below the noise floor (0.036)
above baseline, the new prompt isn't actually tighter, just
different. Close.
"""

结果:

## 📊 eval-results

vs main (c9e2a44)
**overall: 0.768 → 0.766 (-0.002) · noise**

|  metric                   |  base  |   pr   |  delta  | verdict |
| ------------------------- | ------ | ------ | ------- | ------- |
|  verifier_agreement       | 0.870  | 0.882  | +0.012  | noise   |
|  retrieval_recall_at_5    | 0.860  | 0.858  | -0.002  | noise   |
|  trajectory_pass_rate     | 0.710  | 0.705  | -0.005  | noise   |
|  cost_per_run_usd         | 0.044  | 0.044  | +0.000  | ✓       |

假设预测 verifier_agreement 会有 +0.03 到 +0.05 的提升;实际是 +0.012,低于噪声基线(0.036)。按照 PR 自身的拒绝标准,假设未得到验证。作者有一个选择:

  • 选项 A:运行评估 3 次以获得更精确的测量,因为 verifier_agreement 是噪声较大的指标之一。+0.012 可能是真实 +0.04 效果的下界,只是单次运行没有体现出来。
  • 选项 B:关闭 PR。假设是具体的;它按照自身的拒绝标准失败了。不要移动球门柱。

除非有具体理由怀疑这次运行不具有代表性,否则规范的答案是 B。作者写了一条关闭评论:"假设未得到验证(+0.012,低于 0.036 噪声)。新提示词可能略好,也可能是噪声。关闭 PR 以避免事后合理化。教训:验证器提示词的变更在单次运行评估中不能可靠地体现——下次直接进行 3× 测量。"关闭。

为什么这很重要

周二是评估驱动开发真正发挥价值的一天。没有 PR 描述中的拒绝标准,自然的倾向是看到 +0.012 然后想"新提示词稍微好一点,发布吧"。这个标准让决定变得机械:预测成立了吗?不是"新代码能不能站得住脚?"——工程师几乎能为任何事辩护。预测成立了吗?

而关闭评论在做真正的工作:它是写给未来自己的一条注释("对噪声指标需要 3× 测量")。随着时间推移,这些注释是让直觉变得更精准的方式。没有它们,你会在三周后再次犯同样的错误。

周三:计划内的多次运行 + 小型帕累托权衡

早晨。昨天学到了教训。今天的假设更加谨慎——PR 明确计划了 3× 测量:

"""
Use Sonnet for synthesis step instead of Haiku.

HYPOTHESIS: The synthesis step (final answer generation) currently
runs on Haiku for cost reasons. Several hand-reviewed failures
trace to the synthesis dropping or distorting facts from retrieved
chunks. Upgrading just this step (not retrieval ranking or
verifier) should raise trajectory_pass_rate.

PREDICTION:
- trajectory_pass_rate:  +0.04 to +0.06 (REAL, above 0.044 noise)
- cost_per_run_usd:      +0.008 to +0.012 (Sonnet vs Haiku, 1 call)
- verifier_agreement:    +0.01 to +0.02 (downstream — Sonnet
                         produces more faithful synthesis)

MEASUREMENT PLAN: 3× run on trajectory_pass_rate (it's noisy), 1×
on the deterministic metrics. Report mean and σ.

PARETO TRADE: cost will rise, possibly hitting soft ceiling. If
trajectory delta lands >+0.04, the trade is favorable. If
<+0.02, close.
"""

CI 按请求运行 3 次(eval-full 标签触发了对噪声指标的多次运行)。总计 55 分钟。

## 📊 eval-results  (n=3 for noisy metrics)

vs main (c9e2a44)
**overall: 0.768 → 0.812 (+0.044) ↑ [REAL]**

|  metric                 |  base  |  pr (μ ± σ)        |  delta  | verdict |
| ----------------------- | ------ | ------------------ | ------- | ------- |
|  trajectory_pass_rate   | 0.710  | 0.764 ± 0.018      | +0.054  | ✓ REAL  |
|  verifier_agreement     | 0.870  | 0.891 ± 0.014      | +0.021  | ✓ REAL  |
|  retrieval_recall_at_5  | 0.860  | 0.860              | +0.000  | ✓       |
|  trajectory_steps_avg   | 4.400  | 4.350              | -0.050  | ✓       |
|  cost_per_run_usd       | 0.044  | 0.053              | +0.009  | ⚠ SOFT  |

cost: $4.42 (3× full suite)  ·  runtime: 55m

两个预测都落在了各自的区间内。帕累托权衡是真正合算的:最高价值指标(轨迹通过率)提升了 +0.054,忠实度作为额外收益提升了 +0.021,代价是每次运行额外约 $0.009。审查者询问 $0.009 × 3000 次运行/天 ≈ $810/月 是否合理。作者:"合理——合成步骤的失败率是上一个冲刺中用户报告的质量问题的最大贡献者。帕累托前沿向了正确的方向移动。"批准。合并。

周末计分板

三个 PR,三种不同结果——验证、拒绝、验证。总体的聚合变化:0.732 → 0.812 (+0.080)。三天内超过了 0.80 的目标。计分板讲述的故事:

commit, timestamp, branch, overall, retrieval, verifier, trajectory, cost
b3a1f7e, Mon  8:14, main,      0.732,  0.810, 0.880, 0.690, 0.041  # baseline
c9e2a44, Mon 14:33, main,      0.768,  0.860, 0.870, 0.710, 0.044  # +rerank
# tue: hypothesis closed (verifier prompt) — no merge, no scoreboard row
e7b3c20, Wed 16:20, main,      0.812,  0.860, 0.891, 0.764, 0.053  # +Sonnet syn

数据中可见的节奏:两次真实合并,一次明确关闭。成本清晰可见。子分项变化清晰可见。分数增量始终是决策的单位。每次都是在写代码前先写预测,裁决是机械性的。

计分板上看不到、却是实践中最重要的部分:周二关闭的 PR 与周一和周三的 PR 同样是进步。一个只合并验证了假设的 PR 的团队,比一个不管结果如何都合并所有 PR 的团队学得更快。关闭的 PR 积累的知识指导了周三的测量计划。这就是复利效应。

End of chapter 3.1

交付物

一个没有可见评估增量就无法合并变更的智能体代码库——让你的数字持续向好而非悄然退步的规范。对噪声指标进行多次运行测量;预算在重要指标上设置合并门控;置顶的 PR 评论供审查者真正阅读;捕获静默模型漂移的版本戳;以假设优先开发为核心的日常节奏。第 3.2 章将这些评估拆分为三个层次(单元、集成、端到端);第 3.3 章强化产生部分最棘手指标的 LLM 作为评判者;第 3.4 章将一切整合到 CI 和外部基准中。

  • scoreboard.csv,包含子分项拆解,每行都有版本戳
  • eval_delta.py,附带噪声基线标签(基于 2σ 的 REAL vs noise)
  • budgets.yaml,为 3–5 个重要指标设置了硬性/软性阈值
  • 针对噪声指标的多次运行协议(3 次取中位数,标记 σ)
  • CI 工作流,发布带有增量表格的置顶 PR 评论
  • 标签触发的完整套件('eval-full' 触发慢速运行)
  • 每晚针对当前模型快照重新运行的基准
  • 带有"我的预期"字段的假设优先 PR 模板
  • CONTRIBUTING.md 中的只增不减评估套件政策