评估入门:手写 eval
评估入门:手写 eval
前 12 章我们造出了 Agent:第 07 章的 loop、第 08 章的工具系统、第 12 章的经典范式。它「看起来」能干活了。但这里有一个悬在所有工程之上的问题:你凭什么说它干得好? 更具体一点:你改了一行提示词、换了一个模型、加了一个工具之后,它到底是变好了还是变坏了?这一章解决「怎么给 Agent 写测试」。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说清楚为什么 Agent 开发不能靠「感觉」,以及没有评估时「改一行代码」为什么危险;
- 设计一个任务集:任务 = 输入 + 成功标准(断言 + 步骤预算),并理解 trace 为什么是评估的判题对象;
- 手写一个规则 grader:字符串匹配 / 工具调用 / 工具参数三类断言,逐条跑在 trace 上;
- 判断什么时候才需要 LLM-as-judge(主观维度),以及它的代价与校准要求;
- 把失败归入 planning / tool / efficiency 三类,并解释分类的优先级规则;
- 用跑批汇总做出回归报告:改动前后各跑一遍同一个任务集,用数字确认「没变坏」。
开场反例:改一行提示词,感觉变好了,然后呢?
假设你维护着一个带工具的旅行助手 Agent(第 07 章 loop 的思路)。用户问「苏州周末适合去哪儿玩」,Agent 会查 get_weather、list_attractions,然后给建议。今天你觉得回答太啰嗦,改了一行 system prompt:
你是一个简洁的助手,不要查太多东西,直接回答。
你随手问了两个问题,「嗯,确实简洁了」,上线。三天后用户投诉:「我问苏州周末去哪儿玩,它只回了一句’苏州天气不错’,一个景点都没推荐。」
发生了什么?你的改动让 Agent 变得更「果断」:它干脆连 list_attractions 都不查了。你修好了「啰嗦」,弄坏了「查景点」。 而你完全不知道,因为:
- 没有固定的任务集。 你每次「测试」都是随手想一个问题,改之前和改之后问的不是同一批问题;
- 没有自动判分。 「嗯,感觉变好了」不是可复现的结论,你的记忆会美化改动;
- 没有回归意识。 你只关心这次改动让眼前的任务变好了,没想过它可能弄坏哪些旧任务。
没有 eval 的 Agent 开发就是盲改。模型是概率系统,提示词、模型、工具、上下文任何一环的改动,都可能让一部分任务变好、另一部分悄悄变坏,而且往往是在你注意不到的地方。业界对这种情况有一句很直接的话:评估(eval)就是给 AI 系统写的测试:给它一个输入,用判定逻辑检查它的输出,衡量成功与否(Anthropic 工程博客:Demystifying evals for AI agents)。
要走出盲改,你需要三样东西:一个固定且覆盖关键场景的任务集、一个自动判分的 grader、一个能跑批并出汇总报告的 harness。这三样,本章全部手写。
概念与动机:评估的最小模型
先统一术语。Anthropic 的定义(同上链接)非常干净,本章用到其中四个:
| 术语 | 含义 | 本章对应 |
|---|---|---|
| task(任务) | 一个有输入与成功标准的测试单元 | { prompt, checks, budget } |
| transcript / trace(轨迹) | 一次运行的完整记录:回答、轮数、工具调用与结果 | Trace 对象 |
| grader(判分器) | 给某方面表现打分的逻辑,可含多个断言 | ruleGrader |
| eval suite / 任务集 | 一簇测同一能力的任务集合 | Task[] |
核心洞察:判题的对象是 trace,不是模型,也不是最终答案。
interface Trace {
finalText: string; // 最终回答
steps: number; // 模型调用次数(轮数)
thoughts: string[]; // 每轮的文本
toolCalls: Array<{ name: string; args: Record<string, unknown> }>; // 每次工具调用
toolResults: Array<{ name: string; output: string }>; // 每次工具输出
}
为什么是 trace?因为「最终答案正确」远不等于「行为良好」。本章后面你会看到:一个任务可能所有断言都过、却仍然是个失败的运行。它烧了 6 轮才答出一个 1 轮就能答出的问题。只看最终答案,这种失败永远发现不了。这和第 12 章「测行为不测实现」的精神一脉相承:我们断言可观察的行为(查了哪个工具、带什么参数、答案含不含关键事实),不锁定内部写法。
整个评估流程可以画成一条流水线:
flowchart LR
A[任务集 Task[]] --> B[跑 Agent<br/>每个任务跑一次]
B --> C[trace 记录]
C --> D[ruleGrader 逐条断言]
D --> E{通过?}
E -- 是 --> F[PASS]
E -- 否 --> G[classifyFailure<br/>归入失败模式]
G --> H[planning / tool / efficiency]
F --> I[汇总报告<br/>passRate + byMode]
H --> I
任务集:先定义「成功长什么样」
评估的第一步不是写代码,是定义成功。任务集是这份定义的载体。一个任务声明它的输入和成功标准:
interface Task {
id: string;
prompt: string; // 输入:一句话用户请求
budget?: number; // 可选:步骤预算(超过即效率失败)
checks: Check[]; // 成功标准:一组断言
}
任务从哪来?实践中主要是三个来源:
- 真实用户请求:从日志里挑有代表性的,这是任务集的骨架;
- 失败样本:线上出过问题的输入,务必收进来,防止它再次出现(langchain-ai 的 agents-from-scratch 教程在给邮件助手建评估时正是这么做的:拿真实邮件任务 + 明确的可判结果,跑一遍 Agent,把「成功/失败」量化出来);
- 边界与对抗:故意刁难 Agent 的输入(任务要查 A 却故意写成「先查 B」之类的歧义),逼它别偷懒。
本章演示用的任务集只有 6 个任务,覆盖全部四种判定(通过 / planning 失败 / tool 失败 / efficiency 失败)。注意 t4 的玄机。它的断言并不难,难的是别多做事:
| id | prompt | 断言(checks) | 预算 |
|---|---|---|---|
| t1 | 苏州周末适合去哪儿玩?帮我查一下苏州的景点。 | 调用了 list_attractions;答案含「拙政园」 | 3 |
| t2 | 苏州周末适合户外活动吗?先帮我查一下苏州的景点。 | 调用了 list_attractions;答案含「拙政园」 | 3 |
| t3 | 帮我把上海到北京 8 月 15 日的航班查出来,告诉我航班号。 | 调用了 search_flights(参数 from=上海);答案含航班号「CA1257」 | 3 |
| t4 | 苏州今天天气怎么样,适合出去玩吗? | 调用了 get_weather;答案含「22」 | 3 |
| t5 | 苏州周末的天气和景点都查一下,然后给我一份简短的出行建议。 | 调用了 get_weather 和 list_attractions;答案含「22」和「拙政园」 | 4 |
| t6 | 苏州是江南城市吗? | 答案含「江南」 | 2 |
写任务集时有两条纪律:
- 断言可判定,不写空话。 「回答要友好」不是规则能判的(那是 LLM-as-judge 的活,见下文);「答案包含拙政园」才是。成功标准必须能被代码检查;
- 任务集一旦建立就冻结。 回归的意义在于「同一批任务,改前改后各跑一遍」:任务集今天加一个、明天删一个,分数就没法对比了。想加新任务,往新的一批里加,别动旧的。
规则 grader:把断言写成代码
任务集定义「成功长什么样」,grader 负责「逐条检查」。规则 grader 是评估的默认选择:确定性代码,零成本,结果可复现。本章支持三种断言类型,恰好覆盖 Agent 行为的三个观察面:
type Check =
| { type: 'contains', text: string } // 最终回答包含 text
| { type: 'tool_called', name: string } // 调用过名为 name 的工具
| { type: 'tool_args', name: string, args: Record<string, unknown> }; // 工具带指定参数调用过
ruleGrader 的实现很直白:遍历 task.checks,逐个跑,失败就记一条 { check, message }(消息是给人看的,直接进报告):
function ruleGrader(task: Task, trace: Trace): { passed: boolean; failures: Failure[] } {
const failures: Failure[] = [];
for (const check of task.checks) {
if (check.type === 'contains') {
if (!trace.finalText.includes(check.text)) {
failures.push({ check, message: `final answer must include "${check.text}"` });
}
} else if (check.type === 'tool_called') {
if (!trace.toolCalls.some((c) => c.name === check.name)) {
failures.push({ check, message: `expected tool "${check.name}" to be called` });
}
} else if (check.type === 'tool_args') {
// 找到调用,逐个检查期望参数:键存在且值相等(允许多余参数)
const call = trace.toolCalls.find((c) => c.name === check.name);
if (!call) {
failures.push({
check,
message: `expected tool "${check.name}" to be called (to check ${formatArgs(check.args)})`,
});
} else {
const missing = Object.entries(check.args).filter(([k, v]) => call.args[k] !== v);
if (missing.length > 0) {
const list = missing.map(([k, v]) => `${k}=${v}`).join(', ');
failures.push({
check,
message: `tool "${check.name}" called with wrong args: expected ${list}`,
});
}
}
}
}
return { passed: failures.length === 0, failures };
}
function formatArgs(args: Record<string, unknown>): string {
return Object.entries(args).map(([k, v]) => `${k}=${v}`).join(', ');
}
这里有三个细节:
tool_args只校验给出的键。 断言「from=上海」时,Agent 多传一个date不算错:规则 grader 检查的是「必须满足的条件」,不是「精确相等」;- 失败带原因。 报告不是「任务 t2 失败了」,而是「expected tool “list_attractions” to be called」,原因本身就是在指导你修什么;
- 判行为不判实现。 我们不检查 Agent 内部怎么写的(循环结构、变量名),只检查它可观察地做了什么。
规则 grader 的局限 → 什么时候才需要 LLM-as-judge
规则 grader 有一个明显的短板:僵化。contains: '拙政园' 能抓住「没提拙政园」,但抓不住「提到了拙政园,语气却极其生硬」;「回答是否简洁」「是否让用户感到被尊重」这类维度,规则根本无从下手。
这些无法用规则表达的判断,就是 LLM-as-judge 的用武之地:用一个(通常更强、更便宜的)模型,按你写好的 rubric(评分细则)给输出打分。Anthropic 的评估文章把 grader 分为三类,取舍一目了然:
| grader | 方法 | 优点 | 缺点 |
|---|---|---|---|
| 规则 grader | 字符串匹配 / 工具调用检查 / 静态分析 | 快、便宜、客观、可复现、易调试 | 僵化,认不出「对但换说法」;判不了主观维度 |
| LLM-as-judge | 按 rubric 打分 / 自然语言断言 / 成对比较 | 灵活、可扩展、能处理开放式与主观输出 | 不确定(同输入可能不同分)、更贵、需与人工标定校准 |
| 人工评审 | 专家抽查 / A/B 对比 | 金标准,贴合真实用户判断 | 慢、贵、无法规模化 |
(对照表内容综合自 Anthropic:Demystifying evals for AI agents 的 grader 分类。)
LLM-as-judge 的典型形态:rubric 是核心,rubric 写得含糊,judge 就成了「凭感觉」。
// 规则判不了的主观维度:让 judge 按 rubric 打分
async function judgePoliteness(task, trace) {
const rubric = `
判断下面这个助手回答是否礼貌、专业。只输出 JSON:
{"score": 1 或 0, "reason": "一句话理由"}
规则:用了「请/谢谢」、没有命令式口吻得 1 分,否则 0 分。
回答:${trace.finalText}
`;
// 用另一个模型调用(通常是更便宜的小模型),解析 JSON 取分
const verdict = await callJudgeModel(rubric);
return { passed: verdict.score === 1, message: verdict.reason };
}
什么时候该上 LLM-as-judge?记住这条判断线:能用代码判的,永远先用代码。LLM-as-judge 只留给规则确实表达不了的维度。还有两条红线:
- rubric 必须可判。 写「回答包含价格字段且为数字」,别写「回答看起来不错」:rubric 含糊,judge 就会当老好人;
- 别用同一个模型又出题又判分。 如果任务集和 grader 都是同一个模型写的,你测出来的很可能是「模型同意自己」,证明不了「任务真的完成了」。这是 LLM-as-judge 最经典的失效模式。
本章的主题是手写 eval 的骨架,所以主线用规则 grader 走通全流程;LLM-as-judge 作为 grader 的一种实现,理解了何时需要、代价几何即可。
失败模式分类:planning / tool / efficiency
规则 grader 告诉你「失败了」,但没告诉你「为什么失败、该修哪里」。评估的最终目的在指导修复,打分只是手段。修复的第一步是归因。
Chip Huyen 在她的《AI Engineering》与 Agents 一文 中系统梳理了 Agent 的失败模式:除了模型能力本身,Agent 还有规划、工具执行、效率等独有失败。本章取其核心三桶:
- planning(规划):计划或动作出错,调了不该调的工具、该调的工具没调、参数传错、或者干脆没达成任务目标。修法:改提示词、改工具描述、换更强的规划模型;
- tool(工具):工具本身出错,调用是正确的,但工具返回了错误(上游 API 挂了、数据缺失、实现有 bug)。修法:修工具、加重试与兜底;
- efficiency(效率):轮数 / token 超预算,哪怕答案全对,烧了太多轮数、做了太多冗余调用。修法:限制最大轮数、优化工具编排、精简上下文。
归因不是非此即彼,一个失败的 trace 可能同时「工具用错 + 超预算」。所以分类器必须给一套确定的优先级,保证每次判定结果一致、报告可对比。本章的顺序是:
- 超过步骤预算 →
efficiency(最烧钱,先抓); - 有工具类断言失败(
tool_called/tool_args)→planning; - 有工具结果以
ERROR开头 →tool(工具被正确调用但坏了); - 其余失败 →
planning(工具都对、没超预算,但目标没达成)。
flowchart TD
A[失败的任务] --> B{steps > budget?}
B -- 是 --> C[efficiency<br/>烧轮数/烧钱]
B -- 否 --> D{工具类断言失败?<br/>tool_called / tool_args}
D -- 是 --> E[planning<br/>工具用错/参数错]
D -- 否 --> F{工具输出以 ERROR 开头?}
F -- 是 --> G[tool<br/>调用对但工具坏了]
F -- 否 --> H[planning<br/>目标未达成]
用演示任务集对照一遍,三个失败各有各的「味道」:
- t2 → planning:任务要查景点,Agent 却调了
get_weather(「先查一下天气」)。动作选错了,grader 报expected tool "list_attractions" to be called; - t3 → tool:Agent 正确地调用了
search_flights(from=上海、to=北京),但工具返回ERROR: no flights from 上海 to 北京...:上游数据没有这条航线。这是工具的问题,不是 Agent 的问题; - t4 → efficiency:所有断言都过了(天气查了、答案里也有「22」),但 Agent 用 6 轮才答完(预算 3):同一问题查了两遍、又顺带查了上海天气、又把景点查了一遍。断言全过 ≠ 行为良好,efficiency 因此单列为一类。
回归跑批:把「感觉变好了」变成数字
单跑一个任务没有意义,评估的价值在跑批 + 对比。把任务集、Agent、grader 组装成一个 harness,逐个任务跑、判、归类,最后汇总:
async function runEval(tasks: Task[], agent: Agent): Promise<EvalReport> {
const results: EvalResult[] = [];
for (const task of tasks) {
let trace: Trace | null = null;
let agentError: unknown = null;
try {
trace = await agent(task); // 注入的 agent:输入任务,返回 trace
} catch (err) {
agentError = err;
}
if (agentError !== null || trace === null) {
// 单个任务崩溃不能中断整个跑批
results.push({
taskId: task.id,
passed: false,
failures: [{ check: { type: 'agent_error' },
message: `agent run failed: ${agentError instanceof Error ? agentError.message : String(agentError)}` }],
mode: null,
steps: null,
trace: null,
});
continue;
}
const grade = ruleGrader(task, trace);
const overBudget = task.budget !== undefined && trace.steps > task.budget;
results.push({
taskId: task.id,
passed: grade.passed && !overBudget, // 预算也是判定的一部分
failures: overBudget
? [...grade.failures, { check: { type: 'budget' }, message: `steps ${trace.steps} exceed budget ${task.budget}` }]
: grade.failures,
mode: classifyFailure(task, trace),
steps: trace.steps,
trace,
});
}
// 汇总:total / passed / passRate / byMode
const failed = results.filter((r) => !r.passed);
const byMode: Record<FailureMode, number> = { planning: 0, tool: 0, efficiency: 0 };
for (const r of failed) if (r.mode !== null) byMode[r.mode] += 1;
return {
results,
summary: {
total: results.length,
passed: results.length - failed.length,
passRate: results.length === 0 ? 0 : (results.length - failed.length) / results.length,
byMode,
},
};
}
把 6 个任务跑一遍,报告长这样(这是本章演示代码的真实输出):
=== ch13 评估报告:任务集(6 个任务)× 规则 grader ===
[t1] PASS — steps 2 / budget 3
[t2] FAIL (planning(规划)) — steps 2 / budget 3
failures : expected tool "list_attractions" to be called; final answer must include "拙政园"
[t3] FAIL (tool(工具)) — steps 2 / budget 3
tool : search_flights({"from":"上海","to":"北京","date":"2026-08-15"}) => ERROR: no flights...
failures : final answer must include "CA1257"
[t4] FAIL (efficiency(效率)) — steps 6 / budget 3
failures : steps 6 exceed budget 3
(checks all passed — this task failed only on the step budget)
[t5] PASS — steps 3 / budget 4
[t6] PASS — steps 1 / budget 2
=== 汇总 ===
total : 6
passed : 3 (50.0%)
failed : 3
byMode : planning=1, tool=1, efficiency=1
现在回到开头的场景。你改了那行「简洁」的提示词之后,不靠感觉,把这份固定任务集重新跑一遍:
- 改动前:t1–t6 全过,100%;
- 改动后:t1、t5、t6 通过,t2、t3、t4 挂:通过率从 100% 掉到 50%,而且 byMode 告诉你 t2 是 planning 失败(不查景点就回答了),正是线上投诉的那个症状。
这就是**回归(regression)评估**:能力评估(capability eval)问「能不能做到」,回归评估问「改动之后,以前能做到的还做不做得来」。Anthropic 的建议很实用:能力评估通过率爬到接近 100% 的任务,可以「毕业」进回归套件,长期盯防倒退(同上链接)。要做到这一点,两个前提缺一不可:任务集冻结(同一批任务才能对比)+ 判分可复现(同样的 trace 永远得到同样的分数)。
而「可复现」正是 mock model 的用武之地:真实模型每次输出都不同,同一条 prompt 跑三次三个结果,没法对比。确定性脚本模型(本教程练习判题全程使用的那种)让评估零成本、零随机、可进 CI。langchain-ai 的 agents-from-scratch 教程在给邮件助手做评估时就是先保证「能跑」,再谈「判得准」:把 Agent 跑起来、留下 trace、逐任务判分、量化改进,不在假设里打转。
常见坑
坑一:任务集随手建、随手改。 每次「测试」都是新问题,任务集从来不稳定:改前改后比的不是同一批任务,回归无从谈起。任务集要固定、有代表性,想加新任务往新批次里加。
坑二:只判最终答案,不判过程。 答案对了就 PASS,轮数、工具调用一概不看。于是效率失败永远发现不了。t4 那种「查两遍、顺带查上海」的浪费,在真实系统里就是白烧的 API 费用。
坑三:断言写得太松或太紧。 太松:contains: '苏州' 什么都能过,抓不住「没推荐景点」;太紧:tool_args 要求参数精确相等,Agent 多传一个合理参数就被误杀。断言检查「必须满足的条件」,不是「输出与我设想的完全一致」。
坑四:用真实模型跑评估。 结果带随机性,同一任务这次过下次挂,跑批不可复现,回归对比全是噪声。评估(尤其回归)要用确定性脚本模型;真实模型留给上线前的抽样人工验收。
坑五:分类器没有优先级。 同一个失败 trace,这次归 planning、下次归 efficiency,报告的数字对不上,也没法指导「先修哪个」。分类必须按文档化的顺序判定,保证可复现。
坑六:LLM-as-judge 的自我一致陷阱。 出题和判分用同一个模型,测出来的是「模型认同自己」;rubric 写「看起来不错」、没有可判的具体标准,judge 就当老好人。能用规则判的先用规则,LLM-as-judge 留给规则确实判不了的主观维度。
小结
- Agent 开发不能靠「感觉」:模型是概率系统,改一行提示词可能修好一个任务、弄坏另一个,没有评估就是盲改;
- 评估的最小模型:任务集(固定、可判定的成功标准)+ grader(规则优先)+ 跑批汇总(回归对比);
- 判题对象是 trace,不是最终答案:轮数、工具调用、工具结果都是可观察行为,只看答案会漏掉「答案对了但行为浪费」;
- 规则 grader:
contains/tool_called/tool_args三类断言,快、确定、便宜,但僵化;主观维度才上 LLM-as-judge(明确 rubric + 校准,别用同一个模型又出题又判分); - 失败模式三桶:planning(动作/目标错)、tool(工具坏了)、efficiency(超预算),按确定优先级归类,报告才可对比、可指导修复;
- 回归:任务集冻结 + 判分可复现,改动前后各跑一遍,通过率与 byMode 的对比就是「没变坏」的证明;mock model 让回归零成本进 CI。
下一章(ch14)开始阶段二:把前 13 章的零件装进一个主线产品(CLI 里的完整 Agent,v01)。届时这套评估 harness 就是它的质量闸门:每加一个能力,先跑一遍任务集确认没弄坏旧行为。现在去浏览器里完成本章练习,把 ruleGrader、classifyFailure、runEval 亲手补全。评估的每一层都值得自己写一遍。
延伸阅读
- Anthropic Engineering — Demystifying evals for AI agents:本章术语(task/trace/grader/harness/suite)、三类 grader 取舍、能力评估与回归评估划分的权威来源。
- Anthropic Docs — Prompt Evaluations:官方评估文档,
ideal_output(精确匹配)与rubric(模型判分)两种任务形态,是本章「规则 grader vs LLM-as-judge」的完整对照。 - Chip Huyen — Agents (2025):Agent 失败模式的系统梳理(规划 / 工具执行 / 效率等),本章三桶分类(planning/tool/efficiency)的底稿。
- langchain-ai/agents-from-scratch — Evaluation notebook:给邮件助手建评估的实战教程,「先能跑、再判得准」:真实任务 + 可判结果 + 逐任务判分与量化改进。