评估入门:手写 eval

评估入门:手写 eval

前 12 章我们造出了 Agent:第 07 章的 loop、第 08 章的工具系统、第 12 章的经典范式。它「看起来」能干活了。但这里有一个悬在所有工程之上的问题:你凭什么说它干得好? 更具体一点:你改了一行提示词、换了一个模型、加了一个工具之后,它到底是变好了还是变坏了?这一章解决「怎么给 Agent 写测试」。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:改一行提示词,感觉变好了,然后呢?

假设你维护着一个带工具的旅行助手 Agent(第 07 章 loop 的思路)。用户问「苏州周末适合去哪儿玩」,Agent 会查 get_weatherlist_attractions,然后给建议。今天你觉得回答太啰嗦,改了一行 system prompt:

你是一个简洁的助手,不要查太多东西,直接回答。

你随手问了两个问题,「嗯,确实简洁了」,上线。三天后用户投诉:「我问苏州周末去哪儿玩,它只回了一句’苏州天气不错’,一个景点都没推荐。」

发生了什么?你的改动让 Agent 变得更「果断」:它干脆连 list_attractions 都不查了。你修好了「啰嗦」,弄坏了「查景点」。 而你完全不知道,因为:

  1. 没有固定的任务集。 你每次「测试」都是随手想一个问题,改之前和改之后问的不是同一批问题;
  2. 没有自动判分。 「嗯,感觉变好了」不是可复现的结论,你的记忆会美化改动;
  3. 没有回归意识。 你只关心这次改动让眼前的任务变好了,没想过它可能弄坏哪些旧任务。

没有 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[];   // 成功标准:一组断言
}

任务从哪来?实践中主要是三个来源:

  1. 真实用户请求:从日志里挑有代表性的,这是任务集的骨架;
  2. 失败样本:线上出过问题的输入,务必收进来,防止它再次出现(langchain-ai 的 agents-from-scratch 教程在给邮件助手建评估时正是这么做的:拿真实邮件任务 + 明确的可判结果,跑一遍 Agent,把「成功/失败」量化出来);
  3. 边界与对抗:故意刁难 Agent 的输入(任务要查 A 却故意写成「先查 B」之类的歧义),逼它别偷懒。

本章演示用的任务集只有 6 个任务,覆盖全部四种判定(通过 / planning 失败 / tool 失败 / efficiency 失败)。注意 t4 的玄机。它的断言并不难,难的是别多做事

idprompt断言(checks)预算
t1苏州周末适合去哪儿玩?帮我查一下苏州的景点。调用了 list_attractions;答案含「拙政园」3
t2苏州周末适合户外活动吗?先帮我查一下苏州的景点。调用了 list_attractions;答案含「拙政园」3
t3帮我把上海到北京 8 月 15 日的航班查出来,告诉我航班号。调用了 search_flights(参数 from=上海);答案含航班号「CA1257」3
t4苏州今天天气怎么样,适合出去玩吗?调用了 get_weather;答案含「22」3
t5苏州周末的天气和景点都查一下,然后给我一份简短的出行建议。调用了 get_weatherlist_attractions;答案含「22」和「拙政园」4
t6苏州是江南城市吗?答案含「江南」2

写任务集时有两条纪律:

规则 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(', ');
}

这里有三个细节:

规则 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 只留给规则确实表达不了的维度。还有两条红线:

  1. rubric 必须可判。 写「回答包含价格字段且为数字」,别写「回答看起来不错」:rubric 含糊,judge 就会当老好人;
  2. 别用同一个模型又出题又判分。 如果任务集和 grader 都是同一个模型写的,你测出来的很可能是「模型同意自己」,证明不了「任务真的完成了」。这是 LLM-as-judge 最经典的失效模式。

本章的主题是手写 eval 的骨架,所以主线用规则 grader 走通全流程;LLM-as-judge 作为 grader 的一种实现,理解了何时需要、代价几何即可。

失败模式分类:planning / tool / efficiency

规则 grader 告诉你「失败了」,但没告诉你「为什么失败、该修哪里」。评估的最终目的在指导修复,打分只是手段。修复的第一步是归因。

Chip Huyen 在她的《AI Engineering》与 Agents 一文 中系统梳理了 Agent 的失败模式:除了模型能力本身,Agent 还有规划、工具执行、效率等独有失败。本章取其核心三桶:

归因不是非此即彼,一个失败的 trace 可能同时「工具用错 + 超预算」。所以分类器必须给一套确定的优先级,保证每次判定结果一致、报告可对比。本章的顺序是:

  1. 超过步骤预算 → efficiency(最烧钱,先抓);
  2. 有工具类断言失败(tool_called / tool_args)→ planning
  3. 有工具结果以 ERROR 开头 → tool(工具被正确调用但坏了);
  4. 其余失败 → 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/>目标未达成]

用演示任务集对照一遍,三个失败各有各的「味道」:

回归跑批:把「感觉变好了」变成数字

单跑一个任务没有意义,评估的价值在跑批 + 对比。把任务集、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

现在回到开头的场景。你改了那行「简洁」的提示词之后,不靠感觉,把这份固定任务集重新跑一遍:

这就是**回归(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 留给规则确实判不了的主观维度。

小结

下一章(ch14)开始阶段二:把前 13 章的零件装进一个主线产品(CLI 里的完整 Agent,v01)。届时这套评估 harness 就是它的质量闸门:每加一个能力,先跑一遍任务集确认没弄坏旧行为。现在去浏览器里完成本章练习,把 ruleGraderclassifyFailurerunEval 亲手补全。评估的每一层都值得自己写一遍。

延伸阅读

完成阅读,去做练习 →