外观
从零搭一套 Agent 评测
评测体系讲清了 Agent 评测的原理和分层方法;渐进式教程最后那个 30 行的 evals.py 给了你一个最小基线。这篇是两者的中间地带:把教程 V2 的研究助理 Agent(v2_researcher.py)当成被测对象,从零搭一套能进 CI 的评测系统。
搭完之后你会得到:
- 一个 20-50 个任务、带难度梯度和防污染设计的任务集;
- 代码断言与 LLM-as-judge 结合的分层评分器(附完整可用的 judge prompt);
- 每次运行自动记录轨迹、成本、延迟,生成 Markdown 报告;
- 一个 GitHub Actions 回归工作流:改提示词前后跑一遍,掉分就拦下来。
方法论上以 Anthropic 2026 年 1 月发布的工程博客 Demystifying Evals for AI Agents 为主要依据——这是 Claude Code 团队公开过最系统的 Agent 评测经验,本文多处结论(capability/regression 二分、pass@k/pass^k、grader 设计原则)直接来自它,并在引用处注明。
一、场景设定:评测对象与术语对齐
被测对象就是教程里的研究助理:收到课题 → update_todo 拆子问题 → 循环 web_search → read_url → save_note → read_all_notes 汇总 → write_report 产出 workspace/report.md。工具 6 个,max_turns=25。
先统一术语(这套词汇来自 Anthropic 的评测指南,业界基本通用):
- task:一个测试用例 = 输入 + 成功判据;
- trial:一个 task 的一次执行。模型输出有随机性,一个 task 要跑多个 trial;
- grader:评分逻辑。一个 task 可以挂多个 grader,每个 grader 里有多个断言(check);
- transcript:一次 trial 的完整记录(每条消息、每次工具调用和返回),又叫 trace / trajectory;
- outcome:trial 结束时环境的最终状态。对研究助理来说,outcome 是
workspace/report.md的真实内容,不是 Agent 最后那句「报告已生成」。
capability eval 与 regression eval 是两套东西
Anthropic 指南里最重要的一个区分:capability eval 问「这个 Agent 能做好什么」,应该挑它做不好的任务,通过率低正常,给你一座要爬的山;regression eval 问「以前能做的是不是还能做」,通过率应该接近 100%,掉了就是坏了。我们这篇搭的主体是 regression 套件(20-50 个「已通过」的任务做防退化),外加一小撮 capability 任务(故意挑难题)用于驱动改进。当一个 capability 任务的通过率稳定升高,就把它「毕业」进 regression 套件。
整条流水线长这样,后面逐节实现:
tasks.jsonl ──► eval_runner ──► 对每个 task 跑 k 个 trial
│ (隔离的 workspace/)
▼
┌── transcript 记录:消息、工具调用、token、耗时
│
▼
分层评分器 ──► 第一层:代码断言(结构/关键词/工具序列)
│ 挂了 → 直接判 FAIL,不烧 judge 的钱
▼
第二层:LLM-as-judge(覆盖度/有据性/来源质量)
│
▼
results.jsonl + report.md ──► 与 baseline 对比 ──► CI 门禁二、任务集设计:20-50 个任务的学问
任务集是整个评测里最值得花时间的资产。脚本两天能写完,任务集要养几个月。
难度梯度
研究助理的任务按「需要什么能力」分三层,每层的失败模式不同:
| 层级 | 能力要求 | 示例 | 占比建议 |
|---|---|---|---|
| L1 单点事实 | 搜一次、答对一个具体事实 | 「某公司 2025 年 Q3 营收是多少」 | ~30% |
| L2 多源对比 | 搜多个来源、做结构化对比 | 「对比 LangGraph 与 OpenAI Agents SDK 的选型场景」 | ~50% |
| L3 开放综述 | 多轮调研、综合、指出争议点 | 「总结 2026 年 Agent 评测的主流方法并比较优劣」 | ~20% |
L1 的期望产物接近封闭答案,可以上强断言;L3 只能给「必须覆盖的要点清单」,靠 judge。这个分布刻意压在 L2——那才是研究助理的主战场。
一个 task 长什么样
用 JSONL 存任务集,一行一个 task:
json
{"id": "l2-frameworks-01", "level": "L2",
"input": "对比 LangGraph 与 OpenAI Agents SDK 的适用场景,给出选型建议",
"must_cover": ["LangGraph 用显式状态图编排", "OpenAI Agents SDK 抽象更少、上手更快",
"复杂编排选 LangGraph", "快速原型选 Agents SDK"],
"min_sources": 3, "expect_tools": ["web_search", "read_url", "save_note", "write_report"],
"max_turns": 25}设计规则,逐条都有出处或血泪:
- 每个 task 必须自己先能过。Anthropic 的原话:一个 task 应该是「按指令正确执行的 Agent 一定能通过」的。给每个新 task 手工跑一遍,确认你心目中的「满分答案」能过全部 grader——这就是 reference solution,同时验证 grader 没写错。
must_cover写的是「要点」,不是「句子」。Judge 按要点核对,Agent 用什么措辞都行。写死句子的 grader 会对合法变体误判(CORE-Bench 上 Opus 4.5 因为 grader 嫌「96.12」不是「96.124991…」被压到 42 分,修好 grader 后是 95 分——grader 太死板会把模型真实能力砍掉一半)。- 任务要双向平衡。既要有「应该搜索」的任务,也要有「不该搜索、凭常识该拒绝或说明信息不足」的任务(比如让它「调研一个根本不存在的公司」)。单向任务集训练出单向的 Agent。Anthropic 给 Claude.ai 做搜索评测时就刻意覆盖两个方向:该搜的(天气)和不该搜的(苹果创始人是谁)。
- 难度要够。全过的任务集没有信息量。保留几个当前 Agent 过不了的 L3 难题,这是 capability eval 的部分。
任务来源与防污染
任务从哪来,按优先级:
- 真实使用记录:你自己用研究助理时翻车的案例(第一时间补成 task);
- 手工编写:对着 Agent 的能力边界出题;
- 模型生成 + 人工筛选:让 LLM 批量出题,人来挑——效率最高,但必须人工过一遍,模型出的题普遍偏简单、措辞雷同。
防污染有三层含义:
- 别让 task 出现在 Agent 能读到的地方。研究助理会联网搜索,如果你的任务集在某个公开仓库里,它理论上能搜到「答案」。任务集仓库设为私有,或者至少把
must_cover要点与题面分开存放。 - 别把 task 写进提示词。在 instructions 里写「比如你能对比 LangGraph 和 Agents SDK」等于泄题。
- 研究类 ground truth 会过期。Anthropic 指南专门提到研究 Agent 的 ground truth 随网页内容漂移。「某公司最新营收」的期望要点三个月后就是错的。对策:
must_cover里少写具体数字、多写结构性要点;纯事实题标注expires日期,到期刷新或淘汰。
任务集只加不改,定期扩充
改提示词后通过率涨了但体感没变,先怀疑任务集过拟合——你很容易不自觉地把提示词调成「刚好通过现有这些题」。纪律:任务只新增、不修改原题;每轮大的 Agent 迭代后补 3-5 个新题;capability 题稳定通过后毕业进 regression 套件。教程里也提过这个坑,这里再强调一次是因为它真的是评测失效的第一大原因。
三、评分器实现:代码断言 + LLM-as-judge 分层
原则一句话(Anthropic 原话):能用确定性 grader 的地方就不用 LLM judge。代码断言快、免费、可复现、好调试;judge 贵、有随机性、还需要跟人工判断做校准。所以分层:先跑代码断言,挂了直接判 FAIL,judge 只对「代码层全过」的 trial 开放。
第一层:代码断言
对研究助理,确定性能查的东西比想象中多:
python
# graders_code.py
import re
from pathlib import Path
def grade_structure(workspace: Path, task: dict) -> list[str]:
"""结构性断言,返回失败原因列表(空 = 全过)。"""
failures = []
report_path = workspace / "report.md"
if not report_path.exists():
return ["report.md 未生成"]
report = report_path.read_text(encoding="utf-8")
# 1. 报告不是空壳:长度下限
if len(report) < 500:
failures.append(f"报告过短({len(report)}字符)")
# 2. 来源标注:统计文中 http(s) 链接数
urls = re.findall(r"https?://[^\s)\]]+", report)
if len(urls) < task["min_sources"]:
failures.append(f"来源不足: {len(urls)} < {task['min_sources']}")
# 3. 必备小节(结构要点,不查具体措辞)
for section in ["摘要", "参考"]:
if section not in report:
failures.append(f"缺少「{section}」小节")
# 4. 计划与笔记确实落盘(流程产物断言)
if not (workspace / "plan.md").exists():
failures.append("plan.md 未生成(跳过了规划)")
notes = list((workspace / "notes").glob("*.md")) if (workspace / "notes").exists() else []
if not notes:
failures.append("没有保存任何笔记(没走研究流程)")
return failures注意第 4 条:查的是环境里的真实状态(文件是否存在),不是 transcript 里 Agent 说了什么。这就是 outcome grading——Anthropic 举的例子是:订机票 Agent 说「订好了」不算数,SQL 库里有没有那条订单才算数。
工具调用序列断言放在轨迹层做,见第四节。
第二层:LLM-as-judge 提示词模板
代码断言查不了「报告内容对不对、全不全」。这是 judge 的地盘。三条设计原则(全部来自 Anthropic 指南和 judge 最佳实践):
- 每个维度单独评,不要一次调用评所有维度——一个 prompt 里塞五个维度,judge 会互相干扰、顾此失彼;
- 给 judge 留「信息不足」的出口,否则它会被迫瞎编判断;
- 要求先给理由再给分(critique-then-score),分数放 JSON 最后——先出分的 judge 会围绕分数编理由。
下面是覆盖度维度的完整 judge prompt,可直接用:
text
你是一名严格的研究报告评审员。请评估下面这份研究报告对给定要点的覆盖程度。
# 研究课题
{task_input}
# 必须覆盖的要点清单
{must_cover_bullets}
# 研究报告全文
{report}
# 评审要求
1. 逐个要点判断报告是否覆盖:「covered」(明确论述)、「partial」(提及但不充分)、
「missing」(未涉及)。
2. 判断只依据报告文本,不依据你自己的知识。如果报告内容不足以判断,标 "unknown"。
3. 先输出逐要点的分析(evidence 引用报告中的原句),再输出汇总分。
4. 汇总分 coverage_score = covered 数 / 要点总数(partial 计 0.5)。
# 输出格式(严格 JSON,不要输出其他内容)
{
"checks": [
{"point": "...", "verdict": "covered|partial|missing|unknown", "evidence": "..."}
],
"coverage_score": 0.0
}同构地再写两个维度的 prompt:
- 有据性(groundedness):给 judge 报告全文 + transcript 里收集到的来源 URL 清单,要求抽查报告中的 5 个具体事实断言,每个判定「有无来源支撑」;
- 来源质量(source quality):要求判断引用来源是「权威一手来源(官方文档/论文/财报)」还是「二手转载」,权威占比低于阈值扣分。
judge 本身也需要被评测
LLM-as-judge 不是写完就信的。Anthropic 的建议是与人类专家判断做校准:抽 20-30 个已评分样本人工复核,看 judge 与人类的一致率;一致率太低就改 prompt(通常是维度定义不够具体)。Hamel Husain 的 LLM-as-a-Judge 完整指南把这套校准流程讲得非常细,值得一读。另一个工程技巧:judge 用 temperature=0,并且 judge 模型不一定要用最强的——便宜模型做要点核对往往够用,省下的钱可以多跑 trial。
两层怎么合
最终一个 trial 的得分:
- 代码断言任一失败 →
score = 0,不调用 judge(省钱,且代码层挂了的东西 judge 打出高分也没意义); - 代码层全过 → judge 出三个维度分,按权重合成:
score = 0.4 * coverage + 0.4 * groundedness + 0.2 * source_quality; - task 级判定:
score >= 0.7记 PASS。这就是 Anthropic 说的 weighted 打分;如果团队更保守,用 binary(全部 grader 过才算过)也行,选一种固定下来不要来回换。
四、轨迹分析:看 Agent 怎么走到结果的
只评 outcome 会漏掉一大类问题:结果碰巧对了,但过程是错的(绕路、重复调用、最后一轮才瞎猫碰上死耗子)。轨迹分析查过程。对研究助理,四个指标加一类断言:
| 指标 / 断言 | 健康信号 | 危险信号 |
|---|---|---|
| 步骤数(turns) | 8-20 轮 | 撞 max_turns=25 上限被截断 |
| 工具调用序列 | web_search → read_url → save_note 循环出现 | 全程没调 read_url(没读原文) |
| 重复调用 | 偶发 | 同一 URL 连读 3 次、同一 query 连搜 |
| 工具失败率 | <10% 调用返回错误 | 大量「抓取失败」后 Agent 不调整策略 |
「卡住检测」用一条简单规则就够:transcript 里出现连续 3 次相同工具 + 相同参数的调用,标记 stuck=True。这比任何 LLM 判断都可靠,还免费。
工具序列断言(接第三节的代码断言层):
python
def grade_trajectory(tool_calls: list[str], task: dict) -> list[str]:
"""tool_calls 是按时间顺序的工具名列表。"""
failures = []
for t in task["expect_tools"]: # 关键工具必须出现过
if t not in tool_calls:
failures.append(f"未调用 {t}")
# 卡住检测:连续 3 次相同调用
for i in range(len(tool_calls) - 2):
if tool_calls[i] == tool_calls[i + 1] == tool_calls[i + 2]:
failures.append(f"疑似卡住: {tool_calls[i]} 连续调用 3 次")
break
return failures注意「期望工具」断言只查出现过,不查顺序、不查次数。Agent 找到一条你没预想到的合法路径是常态——评它产出了什么,别评它走了哪条路。这也是 Anthropic 指南里「Don't check for specific paths」的落地。
轨迹数据从哪来:OpenAI Agents SDK 的 RunResult.new_items 按顺序包含本次运行的所有条目,其中 type == "tool_call_item" 的条目就是工具调用(工具名在 item.raw_item.name);token 用量在 result.context_wrapper.usage。SDK 版本间字段名可能微调,以 官方文档 为准——第七节的脚本里用 getattr 做了防御性处理。更重的轨迹需求(可视化、线上 trace 与评测打通)交给 LangSmith / Langfuse 这类平台,见可观测性。
五、成本与延迟记录
评测脚本顺手把每次 trial 的成本和延迟记下来,这是你日后做成本优化的决策依据。记录粒度到 trial,聚合时看分布而不只是均值:
- 每次 trial:
n_turns、n_toolcalls、input_tokens、output_tokens、墙钟耗时; - 每次评测运行(run):总 token、按价格表折算的总成本、P50/P95 延迟;
- 趋势:每个 run 追加一行到
results.jsonl,成本突增一眼可见。
价格表不要硬编码在脚本里——模型价格会变,放一个 pricing.json(模型名 → 每百万 input/output token 价格),从各厂商官网定价页抄当前值。评测的两个延伸用途都来自这份记录:换了便宜模型后成功率掉没掉(评测告诉你值不值);某次改动后平均轮次从 12 涨到 20(大概率提示词改坏了,即使成功率没变)。
关于非确定性,两个必须知道的指标(Anthropic 指南的核心内容之一):
- pass@k:k 次尝试中至少过一次的概率。k 越大越高,衡量「蒙对一次就行」的场景;
- pass^k:k 次全部过的概率。k 越大越低,衡量「每次都要稳」的场景——面向用户的 Agent 应该看这个。单次成功率 75% 的 Agent,pass^3 只有约 42%。
工程含义:每个 task 跑 3 个 trial,报告同时给 pass@1(单次平均成功率)和 pass^3(三次全过的任务占比)。改动前后对比时看 pass^3,它对退化敏感得多。
六、CI 集成:让评测拦住坏改动
评测不进 CI 就只是个玩具。工作流设计:
- 触发:改了
v2_researcher.py、instructions或工具代码的 PR,触发评测 workflow;全量跑太贵,可以用 PR 标签控制(run-evals标签才跑)。 - 门禁:regression 套件通过率相对
main分支 baseline 下降超过阈值(如 10 个百分点),workflow 退出码非零,PR 标红。 - 报告:生成 Markdown 汇总,作为 PR comment 或 CI artifact 贴出来——改的人当场能看到哪几个 task 掉了、judge 给的失败理由是什么。
yaml
# .github/workflows/agent-evals.yml
name: agent-evals
on:
pull_request:
paths: ["v2_researcher.py", "evals/**"]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -U openai-agents httpx
- name: 下载 baseline
uses: actions/download-artifact@v4
with: { name: eval-baseline, path: evals/baseline/ }
continue-on-error: true # 首次运行没有 baseline
- name: 跑评测(3 trial × 全任务集)
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: python evals/eval_suite.py --trials 3 --baseline evals/baseline/results.jsonl
- name: 上传报告与新 baseline
uses: actions/upload-artifact@v4
with: { name: eval-report, path: evals/out/ }eval_suite.py 的职责:跑完评测 → 与 baseline 对比 → 退化超阈值时 exit 1。报告长这样(evals/out/report.md,直接贴进 PR):
text
## Eval Report · run 2026-08-21T09:30 · trials=3
| 指标 | baseline | 本次 | Δ |
| --- | --- | --- | --- |
| pass@1 | 82% | 74% | -8pp |
| pass^3 | 71% | 58% | -13pp ❌ |
| 平均轮次 | 11.8 | 16.2 | +4.4 |
| 总成本 | $1.42 | $2.10 | +48% |
退化任务:
- l2-frameworks-01: coverage 0.83→0.50(judge: 缺少「复杂编排选 LangGraph」要点)
- l3-eval-methods-02: 代码断言失败「来源不足: 2 < 3」改动前后的对比纪律:同一 commit 只改一处变量(提示词或工具或模型),跑评测,记录。一次改三处,涨了不知道谁的功劳,掉了不知道谁的锅——这和调参没有 baseline 是同一个错误。
评测频率的现实取舍
全量评测(30 任务 × 3 trial × 多轮工具调用)可能要 20-40 分钟、几美元 API 费用,不适合每个 commit 都跑。常见做法:PR 上跑 10 个核心 task 的 smoke 子集;nightly 定时跑全量;发版前跑全量 + 人工抽查 transcript。这与评测体系里「离线评测 + 线上监控分层」的思路一致。
七、完整评测脚本
把前面所有零件拼起来。单文件,依赖只有 openai-agents 和 httpx(被测 Agent 的依赖),judge 用 OpenAI 兼容客户端直连:
python
# evals/eval_suite.py
"""研究助理评测脚本:任务集 → 多 trial 运行 → 分层评分 → 报告与门禁。"""
import argparse
import asyncio
import json
import re
import shutil
import sys
import tempfile
import time
from pathlib import Path
from agents import Runner
from openai import OpenAI
from v2_researcher import agent # 被测 Agent
from graders_code import grade_structure # 第三节的代码断言
JUDGE_MODEL = "gpt-5.6-luna" # judge 模型,够用即可,不必最强
JUDGE_PROMPT = (Path(__file__).parent / "judge_coverage.md").read_text(encoding="utf-8")
client = OpenAI()
def extract_trajectory(result) -> tuple[list[str], dict]:
"""从 RunResult 提取工具调用序列与用量(防御性取值,防 SDK 字段变动)。"""
tools = [getattr(i.raw_item, "name", "?") for i in result.new_items
if getattr(i, "type", "") == "tool_call_item"]
usage = getattr(result.context_wrapper, "usage", None)
metrics = {
"n_turns": len(result.new_items),
"n_toolcalls": len(tools),
"input_tokens": getattr(usage, "input_tokens", 0),
"output_tokens": getattr(usage, "output_tokens", 0),
}
return tools, metrics
def grade_trajectory(tool_calls: list[str], task: dict) -> list[str]:
failures = []
for t in task.get("expect_tools", []):
if t not in tool_calls:
failures.append(f"未调用 {t}")
for i in range(len(tool_calls) - 2):
if tool_calls[i] == tool_calls[i + 1] == tool_calls[i + 2]:
failures.append(f"疑似卡住: {tool_calls[i]} 连续 3 次")
break
return failures
def judge_coverage(task: dict, report: str) -> float:
"""LLM-as-judge:覆盖度评分,返回 0-1。失败时保守返回 0。"""
prompt = (JUDGE_PROMPT
.replace("{task_input}", task["input"])
.replace("{must_cover_bullets}",
"\n".join(f"- {p}" for p in task["must_cover"]))
.replace("{report}", report))
try:
resp = client.chat.completions.create(
model=JUDGE_MODEL, temperature=0,
response_format={"type": "json_object"},
messages=[{"role": "user", "content": prompt}],
)
return float(json.loads(resp.choices[0].message.content)["coverage_score"])
except Exception as e:
print(f" judge 调用失败: {e}")
return 0.0
async def run_trial(task: dict, trial_idx: int) -> dict:
"""单个 trial:隔离 workspace → 跑 Agent → 分层评分。"""
ws = Path(tempfile.mkdtemp(prefix=f"eval_{task['id']}_{trial_idx}_"))
t0 = time.time()
result = await Runner.run(agent, task["input"], max_turns=task.get("max_turns", 25))
latency = time.time() - t0
# 注意:v2_researcher 的工具写死 WORKSPACE=Path("workspace")。
# 实现时应把它改成读环境变量 WORKSPACE_DIR,这里用环境变量隔离。
tools, metrics = extract_trajectory(result)
report_path = ws / "report.md"
report = report_path.read_text(encoding="utf-8") if report_path.exists() else ""
failures = grade_structure(ws, task) + grade_trajectory(tools, task)
if failures: # 代码层挂了:FAIL,不调用 judge
score = 0.0
else: # 代码层全过:judge 打分(示例只用覆盖度,可自行扩展)
score = judge_coverage(task, report)
record = {"task_id": task["id"], "trial": trial_idx,
"pass": score >= 0.7, "score": round(score, 3),
"failures": failures, "latency_s": round(latency, 1), **metrics}
shutil.rmtree(ws, ignore_errors=True) # 隔离环境用完即焚
return record
async def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--tasks", default="evals/tasks.jsonl")
ap.add_argument("--trials", type=int, default=3)
ap.add_argument("--baseline", default=None)
ap.add_argument("--out", default="evals/out")
args = ap.parse_args()
tasks = [json.loads(l) for l in Path(args.tasks).read_text(encoding="utf-8").splitlines() if l.strip()]
out = Path(args.out); out.mkdir(parents=True, exist_ok=True)
records = []
for task in tasks: # 任务串行跑,避免联网工具互相干扰
for k in range(args.trials):
r = await run_trial(task, k)
records.append(r)
print(f"[{task['id']} t{k}] {'PASS' if r['pass'] else 'FAIL'} "
f"score={r['score']} turns={r['n_turns']} {r['failures'] or ''}")
# 聚合:pass@1 = 全部 trial 的平均通过率;pass^k = 全 trial 通过的 task 占比
pass1 = sum(r["pass"] for r in records) / len(records)
by_task = {t["id"]: [r for r in records if r["task_id"] == t["id"]] for t in tasks}
passk = sum(all(r["pass"] for r in rs) for rs in by_task.values()) / len(tasks)
total_tokens = sum(r["input_tokens"] + r["output_tokens"] for r in records)
# 落盘:results.jsonl(原始记录)+ report.md(人读)
with open(out / "results.jsonl", "a", encoding="utf-8") as f:
for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
summary = {"pass@1": pass1, f"pass^{args.trials}": passk, "total_tokens": total_tokens}
(out / "report.md").write_text(
f"## Eval Report · trials={args.trials}\n\n"
+ "\n".join(f"- {k}: **{v:.0%}**" if isinstance(v, float) and v <= 1 else f"- {k}: {v}"
for k, v in summary.items()) + "\n\n退化任务:\n"
+ "\n".join(f"- {tid}" for tid, rs in by_task.items()
if any(r["pass"] for r in rs) and not all(r["pass"] for r in rs)),
encoding="utf-8")
print(f"\npass@1={pass1:.0%} pass^{args.trials}={passk:.0%} tokens={total_tokens}")
# 门禁:与 baseline 对比,pass@1 下降超 10pp 退出码 1
if args.baseline and Path(args.baseline).exists():
base = [json.loads(l) for l in Path(args.baseline).read_text(encoding="utf-8").splitlines() if l.strip()]
base_pass1 = sum(r["pass"] for r in base) / len(base)
if pass1 < base_pass1 - 0.10:
print(f"❌ 回归: pass@1 {base_pass1:.0%} → {pass1:.0%}")
return 1
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))落地注意两点:
- workspace 隔离是硬要求。上面脚本里每个 trial 用独立临时目录,跑完删除。Agent 之间共享状态会制造假象——Anthropic 内部就踩过:Claude 在某些评测任务里靠读上一次 trial 留下的 git 历史「作弊」获得了不公平优势。
v2_researcher.py的WORKSPACE常量需要改成读环境变量,让每个 trial 指向自己的临时目录。 - judge prompt 存成独立文件(
judge_coverage.md),和被测 Agent 的提示词一样纳入版本管理。judge 的 prompt 改动本身也要跑一遍校准样本,否则你分不清分数变化来自 Agent 还是 judge。
一个 task 在所有 trial 全挂(0% pass@k),先怀疑 task 坏了
这是 Anthropic 指南里反复强调的调试直觉:0% pass@100 几乎总是任务定义或 grader 有 bug,而不是 Agent 无能。常见原因:期望要点写错了、URL 失效导致 read_url 抓空、min_sources 设得太苛刻。先手工跑一遍 reference solution,能过才轮到怀疑 Agent。
八、迭代案例:一次「退化 → 定位 → 修复」复盘
最后一节走一遍这套系统的真实使用过程(数字为演示用例):
- 改动:有人觉得报告太啰嗦,在
instructions里加了一句「研究要高效,每个子问题最多读 1 个来源」。 - 评测报警:CI 里 pass@1 从 82% 掉到 68%,pass^3 从 71% 掉到 55%,门禁拦下 PR。报告显示退化的全是 L2/L3 任务,judge 的失败理由集中在「要点覆盖不全」。
- 读 transcript 定位:抽一个退化任务的轨迹——Agent 严格遵守了新指令,每个子问题只
read_url一次;问题是搜到的第一个来源经常是二手转载,缺少关键数据。以前它会读 2-3 个来源交叉验证,现在没有了。 - 定性结论:不是「读 1 个来源」错了——对 L1 单点事实题它确实更快更省;错在一刀切,把需要多源交叉的任务也限制住了。
- 修复:指令改成「单点事实读 1 个来源即可;涉及对比、数据、争议论断时至少读 2 个独立来源交叉验证」。重新跑评测:pass@1 84%,平均轮次还比原来略降。
- 沉淀:把这次暴露的「单来源轻信二手信息」失败模式补成一个新 task 加入任务集(
l2-crosscheck-01),防止未来再退化。
这个流程的关键在于第 3 步:没有 transcript 和 judge 的逐要点失败理由,你只能看到「通过率掉了」,不知道往哪修。评测系统一半的价值在报警,另一半在给你修的方向。这也是为什么 Anthropic 把「Read the transcripts」列为指南的最后一条军规——分数永远只是入口, transcript 才是事实。
到这里,这套评测 + 上一篇教程的研究助理,已经可以写进简历了:「为研究型 Agent 搭建 30 任务分层评测集与代码断言 + LLM-as-judge 双层评分管线,接入 CI 回归门禁,通过轨迹分析定位并修复多源验证退化,pass@1 提升 16pp」——具体怎么写见简历诊断。
参考资料
- Demystifying Evals for AI Agents — Anthropic Engineering —— 本文方法论主依据:task/trial/grader 术语、capability vs regression、pass@k/pass^k、grader 设计原则
- Using LLM-as-a-Judge For Evaluation: A Complete Guide — Hamel Husain —— judge prompt 编写与人工校准流程最细的公开指南
- LLM Evaluation: Methods, Best Practices, and a Practical Roadmap — Langfuse —— LLM-as-judge 与评测方法综述,含平台化落地视角
- LLM Agent Evaluation Metrics in 2026 — Confident AI —— 工具调用、任务完成、轨迹类评测指标的分类整理
- How to build LLM-as-a-Judge evaluators that hold up in production — Arize —— 生产环境 judge 的可靠性与校准实践
- OpenAI Cookbook: Evaluation examples —— OpenAI 官方的评测飞轮(eval flywheel)实操示例