Skip to content

Evaluation Runner

本课交付结果

你将交付 EvaluationRunner.runAll:任务按 ID 稳定执行,Agent 输出交给 grader 判定,超时会 Abort;报告包含逐任务 pass/score/cost/steps、总体成功率、分类成功率和 SHA-256。

岗位问题

“我试了几个例子感觉不错”不能证明 Agent 改进。评测必须固定输入、顺序、超时、评分和聚合公式;否则同一版本因任务顺序或对象 key 顺序产生不同报告,无法做回归比较。

前置检查

前置知识快照

先保证基准夹具独立:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 29

Runner 负责调度,grader 负责判断;二者通过注入接口分离。

第 29 课已经固定每次实验的初始条件,本课负责让执行与测量同样固定。开始前要区分 Agent output、Grade、EvaluationResult 和 Report:output 是被测系统产生的过程结果,grade 是独立评分器的判断,result 合并任务元数据与成本步骤,report 才做总体聚合。任何一层混在一起,都会让被测者参与定义自己的分数。

确定性不等于模型每次输出逐字相同,而是相同任务集合、运行结果与评分语义会生成相同报告。任务输入顺序、对象键顺序、当前时间和临时路径都不应改变报告哈希。真实模型仍有随机性,可以通过重复试验和置信区间描述;Runner 先保证自身不额外制造噪声。

原理拆解

mermaid
flowchart TD
  A[预检任务 ID 唯一] --> B[按 ID 稳定排序]
  B --> C[创建夹具副本]
  C --> D[启动 Agent 与超时计时器]
  D --> E{完成或超时}
  E -->|完成| F[独立 grader 评分]
  E -->|超时| G[Abort 并记录失败]
  F --> H[生成逐任务结果]
  G --> H
  H --> I[总体与分类聚合]
  I --> J[规范序列化并计算哈希]

任务按 id 排序,逐项建立 AbortController 与 timeout race。成功后 grade;异常或超时形成失败结果。若 3 项中 2 项通过,总成功率为 2 / 3 ≈ 0.6667;bugfix 两项通过一项为 0.5,refactor 一项通过为 1.0

规范哈希输入只包含排序 results、successRate 和按名称排序的 byCategory,不包含当前时间。

代码实验

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/30-evaluation-runner/starter test
pnpm --dir bootcamps/coding-agent/labs/30-evaluation-runner/solution test

Starter 应声明 实现分类评测聚合;Solution 应通过 5 项测试。

失败实现

ts
async function evaluateUnsafe(tasks: EvaluationTask[]) {
  const results = await Promise.all(tasks.map(async (task) => {
    const output = await run(task);
    return { ...output, passed: output.answer.includes("done") };
  }));
  return { results, hash: hash(JSON.stringify({ results, at: Date.now() })) };
}

它按调用方输入和并发完成顺序组织结果,让 Agent 文本自行决定通过,并把当前时间纳入哈希。没有超时、取消、重复 ID 预检或分类指标;慢任务返回后还可能继续占用进程。这样的脚本可以演示,却无法比较两个版本。

正确的单任务边界同时停止等待与通知底层:

ts
async function runOne(task: EvaluationTask): Promise<EvaluationResult> {
  const controller = new AbortController();
  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    const timeout = new Promise<never>((_, reject) => {
      timer = setTimeout(() => {
        controller.abort();
        reject(new Error("EVALUATION_TIMEOUT"));
      }, timeoutMs);
    });
    const output = await Promise.race([deps.run(task, controller.signal), timeout]);
    const grade = await deps.grade(task, output);
    return toResult(task, output, grade);
  } catch (error) {
    return failedResult(task, controller.signal.aborted, error);
  } finally {
    if (timer) clearTimeout(timer);
  }
}

关键实现讲解

timeout 必须既 reject race 又 abort 底层运行,避免 Runner 返回后任务仍消耗资源。空报告成功率定义为 0。重复任务 ID 在执行前拒绝,防止结果覆盖或统计双计。

预检必须在任何任务执行前完成。若重复 ID 到第二次才发现,第一批任务已经产生费用和副作用,报告却不能可靠索引。用 Set 比较数量只是基础,还应校验 ID 格式、类别、超时和预算,确认 fixture manifest 一致。预检失败返回配置错误,不生成一个看似正常的零分报告。

任务按 ID 排序后顺序执行,最容易证明且避免资源竞争。并发能缩短总时间,却会引入端口、CPU、Provider 限流和成本速率干扰;若采用并发,仍要在收集后按 ID 排序,并设置固定并发上限。基准比较时并发策略也是环境元数据,两个报告策略不同不应直接解释为 Agent 性能变化。

Promise.race 只停止等待,不会自动停止 loser。AbortController 把取消意图传入 Runtime,Runtime 还必须把 signal 传播到 Provider fetch、子进程、工具和沙箱。子进程收到 abort 后先温和终止,宽限期后强杀,并等待退出;否则 Runner 已记录 timedOut,后台进程仍修改 workspace 或消耗 token。

计时器无论成功失败都要 clear,避免句柄泄漏与稍后错误 abort。超时与 Runtime 主动抛错要区分 failureKind;grader 抛错又是第三类,fixture、预算与基础设施错误也应独立。Lab 用 timedOut 布尔和零分聚焦核心,总项目 report 使用 status 与 failureKind,能判断是 Agent 能力回归还是评测系统坏了。

timeoutMs 应来自任务并受全局上下限约束。所有任务一个固定超时简单,却对小文档任务过宽、对大型重构过窄。任务可声明预算,Runner 取声明值与平台上限较小者。超时从 workspace 准备完成、Agent 真正开始时计,fixture 复制与依赖准备可单独设基础设施超时,避免混淆模型执行性能。

grader 是机器可执行判据,不读 Agent 自述。命令测试看退出码,文件规则检查规范路径与内容,Diff grader 限制修改范围,组合 grader 按固定权重计算。Agent output 中的 answer 只用于解释,不应因为写了“已完成”就得分。grader 自身在受限沙箱运行,输出脱敏与截断,防止恶意修改测试或泄漏秘密。

score 与 passed 不必相同。组合检查可能得零点八分却因关键测试失败而 passed false;报告同时保留连续分数和门槛结果。总体 successRate 使用 passed 数除总数,meanScore 单独计算,不能把两者混称成功率。权重、阈值和 graderVersion 写进报告,否则分数口径改变无法比较。

cost 来自模型 usage 与版本化价格表,steps 来自 Runtime contract。异常任务不能简单都填零,因为零会把平均成本与步骤拉低,看似优化;更严谨的 summary 分别给完成任务均值、所有已知消耗总量与缺失计数。Lab 为确定结构使用零占位,博客必须说明它不是生产统计的唯一选择。

总体成功率空集合定义为零,避免除零 NaN 污染 JSON;但报告还应标记 total 为零,消费者不能把零解释为所有任务失败。分类聚合只为实际存在类别生成键,类别按名称排序。每类保存 total、passed、successRate 或 count、meanScore,让总体稳定掩盖某一关键类别回归时仍能发现。

例如两个 bugfix 一过一失败、一个 refactor 通过,总体是三分之二,bugfix 是二分之一,refactor 是一。若下一版 bugfix 全失败、refactor 仍通过,总体下降可见;若通过类别互换导致总体不变,分类门禁仍应报告回归。产品发布不能只看单一总分。

规范报告先按 taskId 排序 results,再按 category 排序聚合键,固定数值表达并排除 timestamp、临时路径、原始耗时噪声等易变字段。显示报告可以保留 generatedAt、真实 duration 和证据消息;哈希输入只包含稳定语义。总项目测试证明 duration 与 evidence hash 改变时 display 不同而 reportHash 相同,这是一种有意的语义哈希。

ts
function canonicalReport(results: EvaluationResult[]) {
  const ordered = [...results].sort((a, b) => a.id.localeCompare(b.id));
  const byCategory = aggregateBySortedCategory(ordered);
  const successRate = ordered.length === 0
    ? 0
    : ordered.filter((row) => row.passed).length / ordered.length;
  return { results: ordered, successRate, byCategory };
}

const canonical = canonicalReport(results);
const hash = createHash("sha256")
  .update(JSON.stringify(canonical))
  .digest("hex");

JSON.stringify 只有在对象构造顺序受控时才规范。动态对象键从排序类别循环插入,嵌套 grader evidence 若进入哈希也要递归规范化。浮点分数可固定精度或保留分子分母,避免跨语言序列化差异。SHA-256 证明内容身份,不证明发布者身份;需要防伪时对 reportHash 追加数字签名并保护私钥。

哈希的价值是检测“语义相同”与缓存、比较和作品集引用,不是把坏评测变好评测。若任务、fixture 或 grader 版本不同,即使结果恰好相同也不应视为同一实验;规范输入应包含 suiteVersion、taskDefinitionHash、fixtureHash、graderVersion 和 Agent commit。Lab 为聚焦只哈希 results 与聚合,生产报告扩展身份元数据。

Runner 与 fixture copy 的组合顺序是:预检整个集合,逐任务建立全新副本,在副本创建 Runtime,执行并评分,导出受控证据,清理,再进入下一任务。不要先为所有任务复制目录后慢慢运行,失败中止会留下大量资源;也不要让 grader 在 source fixture 上读取 expected 后意外写入。

预算超过与超时不同。maxSteps、maxToolCalls、tokens 或 cost 到达上限时 Runtime 主动返回 budget_exceeded,保留已知使用量和最后状态;超时是墙上时间控制器中止。分类报告可把两者都算未通过,但 failureKind 分开,优化方向完全不同:预算问题调策略,超时可能查工具挂起或外部服务。

重试会改变评测含义。基础设施暂时错误可在同一任务最多重试固定次数,并记录 attempts;Agent 逻辑失败不应自动重试后只保留最好结果。若评估 pass@k,应明确运行 k 个独立 seed 并用公式汇总,不把它伪装成单次成功率。重试策略与 seed 进入报告元数据。

随机化任务顺序能发现污染,却与确定报告顺序不冲突。可以在诊断模式用固定 seed 洗牌执行,结果最后仍按 ID 排序,并记录 seed;正式基线选择固定策略。若交换顺序导致结果变化,先查共享环境和限流,而不是取更高的一轮。第 29 课的隔离正是为了消除这种依赖。

评测套件要防止 Agent 针对 task ID 或测试源码投机。Runner 可把 prompt 与 workspace 提供给 Agent,但隐藏 grader、期望输出和任务分类中可能泄露的答案;任务 ID 使用无语义标识或在分析时检查策略。课程环境公开 Lab 是为了学习,正式能力测量需要独立保留集。

测试 Runner 时注入 deps,而不是启动真实模型。run fixture 返回固定 answer、cost、steps;grade 根据 answer 给通过;慢 run 监听 signal 并在 abort 时拒绝。使用短但有余量的计时器或 fake timers,断言 controller.signal.aborted。反转任务数组再次运行并比较 hash,证明顺序稳定。

ts
it("aborts slow work and preserves deterministic reports", async () => {
  const run = vi.fn((_task, signal: AbortSignal) => new Promise((_, reject) => {
    signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
  }));
  const report = await new EvaluationRunner(deps({ run }), 20)
    .runAll([{ id: "slow", category: "bugfix" }]);
  expect(report.results[0]).toMatchObject({ passed: false, timedOut: true });
});

还要测 grade 抛错、run 同步拒绝、空任务、重复 ID、相同类别多任务、反序输入和定时器清理。使用资源 spy 确认超时后子任务真正停止,而不只是 result 已返回。集成测试在临时 fixture 中运行一个可控子进程,超时后检查 PID 不存在与 workspace 不再变化。

报告应能解释每个结果:任务版本、状态、score、checks、steps、toolCalls、tokens、cost、duration、failureKind 和安全证据引用。不要把所有 stdout 塞入 JSON;保存退出码、截断标记和输出 hash,详细 artifact 受 TTL 控制。报告本身不能含 Provider key、HOME 或临时绝对路径。

回归门禁同时检查总体成功率下降、每类下降、成本增长和步骤增长。阈值允许统计波动,例如 success drop 不超过五个百分点、成本增长不超过百分之十;样本很小时还要展示置信区间而非武断通过。硬安全任务可设 mustPass,任何失败直接阻止发布,不被总体平均稀释。

基线报告应来自同一套件与环境,存入版本控制或受控 artifact。比较工具先校验 suite、grader 与投影版本,再计算差异;若不兼容,返回 incomparable 而不是 regress。选择最近一次绿色报告还是固定发布基线也要明确,避免团队通过更换基线掩盖退化。

运行大量任务时,可以固定并发池并为 Provider 设置速率限制。结果收集使用 taskId map,最终排序;每个任务独立 AbortController,全局取消再级联所有控制器。某任务基础设施故障是否 fail-fast 取决于目标:发布门禁通常继续收集完整失败分布,安全边界破坏如源 fixture 污染则立即停止整个 suite。

评测系统自身要被观测,但 Trace 与被测 Agent 分开 runId。记录调度开始、fixture hash、超时、grader 状态与报告 hash,不记录隐藏答案。若 Runner Trace 混入 Agent Trace,Viewer 的步骤和成本会错误聚合。两个证据流通过 evaluationId 关联,而不共用 sequence。

一次完整验收先以 b、a、c 输入,确认结果 a、b、c;c grader 失败,总成功率三分之二,分类公式正确;再反转输入,hash 相同;加入 slow,确认 signal abort 且 timedOut;加入重复 ID,确认没有任何 run 被调用。最后核对 fixture 源 hash 和临时清理,形成 Week 6 从输入到报告的闭环。

报告中的统计精度要统一。界面可以把三分之二显示为百分之六十六点七,JSON 保留原始浮点或通过 passed 与 total 重算;不要先四舍五入每个类别再汇总,否则总体与分类加权不一致。跨语言比较时优先保存整数计数和明确公式,百分比只是展示派生值。

平均成本与步骤还要处理失败样本。只统计 passed 会产生幸存者偏差,只统计 completed 会隐藏超时前已经花掉的成本。最透明做法是同时报告所有已知 usage 总量、每次 attempt 均值、完成样本均值和 missingUsage 数。成本预算门禁基于总量或每任务上限,不让缺失值自动等于零。

置信区间提醒团队不要过度解读小样本。十个任务从六个成功变七个,不一定证明真实能力提升;可用 bootstrap 或二项区间展示范围,并通过多 seed 重复估计方差。课程 Lab 先掌握确定聚合,生产决策再增加统计层,且统计算法与 seed 写入报告版本。

任务权重是另一个容易滥用的旋钮。若按业务重要性加权,应在运行前固定,并同时保留未加权成功率;看到结果后再提高通过任务权重属于数据操纵。安全边界、路径穿越与秘密泄露类任务更适合 mustPass,而不是用高权重与普通功能平均。

grader 校准需要正反样本。对每个任务保存一个应通过参考实现、一个保持初始错误的失败实现,必要时加入看似完成但作弊的对抗实现。套件发布前运行三者:参考不通过说明 grader 过严,初始通过说明没有测到目标,作弊通过说明规则可被绕过。

报告比较要展示绝对值和差值。例如成功率从零点九到零点八下降零点一,成本从一到一点二增加百分之二十,steps 从十到九减少百分之十。门禁按预先声明的最大下降与增长判断,并列出触发阈值的任务;只有一个红色 regress 标签无法指导修复。

评测结果应可追到具体 Trace,但默认报告只保存受控相对链接与 Trace hash。查看详情时再经授权 Query 获取,不能把完整事件嵌进公开作品集。Trace 的 runId、evaluationId 与 taskId 建立三向关联,重复运行 attempt 再加 attemptId,避免覆盖同一任务历史。

长期趋势需要处理套件演进。新增任务时总体分母变化,直接连接新旧成功率会产生假跳点。比较工具可计算共同任务子集,也展示新套件全量值;类别新增单独标记。发布说明写清基准版本,让读者知道数字不一定跨版本直接可比。

失败归因可形成稳定枚举:runtime、timeout、budget、grader、fixture、infrastructure 与 policy。message 供人排查且经过脱敏,自动化只读 failureKind。未知异常映射 infrastructure 并保留内部 cause hash,不能把任意 Error 文本当类别,否则同类错误会碎成大量不可聚合字符串。

取消整个 suite 时,全局 signal 要在任务开始前检查,并与单任务超时 signal 组合。尚未开始的任务标 cancelled 而不是 failed,正在运行的任务收到 abort,已完成结果保留。报告标 complete false,发布门禁拒绝用半份结果与完整基线比较;人工查看仍可利用已完成证据。

断点恢复可以按 report journal 保存每个完成任务的规范结果,重启后仅在 Agent、suite、fixture 与 grader identity 全部相同时跳过。恢复不能只按 taskId,因为代码或清单可能已经改变。最终报告仍按完整集合排序重算 hash,journal 是执行缓存,不是权威报告。

供作品集使用时,除了成功率还要展示任务分布、成本、失败案例和改进前后对比。只挑绿色 demo 会让数字缺少可信度;提供可复现命令、报告 hash 与一个失败 Trace,反而能证明工程判断。任何公开证据先做秘密扫描和路径匿名化,不发布内部 prompt 或隐藏测试。

运行环境元数据至少包含操作系统、架构、Node 与包管理器版本、模型标识、Provider 参数、并发策略和网络模式。它们通常不进入语义结果 hash,却进入 compatibility identity;比较工具发现关键项不同就告警。温度、seed 或工具版本变化都可能比代码改动影响更大。

最后,Evaluation Runner 的职责是公正执行既定实验,不负责挑选对自己有利的任务、修改 grader 或解释产品价值。任务集治理、统计解释和发布决策属于上层流程。保持这种分离,Runner 才能成为团队共同信任的测量仪器,而不是另一段会迎合结论的自动化脚本。

运行与验证

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 30
pnpm --filter @coding-agent/evaluator test

将输入数组反转后再次运行,报告 hash 必须相同;slow 任务收到 abort 并产生 timedOut: true

真实运行输出

text
✓ tests/lab.test.ts (5 tests)
Test Files  1 passed (1)
Tests       5 passed (5)

项目级验证还应运行 evaluator 全套测试与 Week 6 checkpoint,保存 reportHash、分类摘要、Abort 证据和 fixture 原态哈希。单个绿色数字不是结论,输入身份与失败分布必须一同归档。

常见失败与排查

故障案例 1

症状:相同任务反转输入后 reportHash 改变,分类对象顺序也不同。

根因:results 与动态类别键没有稳定排序,或哈希混入生成时间和临时路径。

定位:固定 deps 连续运行原序、反序与随机序,比较规范 JSON 的第一个差异字段。

修复:任务与结果按 ID、类别键按名称排序;哈希只含稳定语义,易变显示字段保留但排除。

故障案例 2

症状:任务已标超时,Provider 请求、子进程或文件修改仍继续,下一任务受到影响。

根因:Promise.race 只拒绝等待,没有把 AbortSignal 传播到底层资源。

定位:慢任务监听 signal 并记录退出,Runner 返回后继续观察 PID、网络 mock 和 workspace hash。

修复:超时先 abort,再由 Runtime 级联 fetch、工具与子进程;宽限后强杀并等待回收,finally 清计时器。

故障案例 3

症状:总体成功率稳定,但关键 bugfix 全退化;或 grader 崩溃被记成 Agent 普通失败。

根因:只看总分且 failureKind 被压平,调度与评分耦合。

定位:比较每类 count、pass、meanScore 与错误分布,注入 grader exception 检查归因。

修复:独立 grader 接口,报告分类指标与稳定错误类型;关键类别配置回归阈值或 mustPass。

课后作业

加入并发上限、bootstrap 置信区间、成本/步骤均值和失败原因分布;设计 grader 版本号,使跨版本报告不会被误比较。

验收 Rubric

维度通过标准常见扣分
调度ID 稳定、重复预检输入顺序决定结果
超时Abort + 失败记录后台任务泄漏
指标总体与分类公式正确只给总分
确定性相同语义同一 hash包含时间或无序 key

总项目增量

总项目包:@coding-agent/evaluator

总项目路径:packages/evaluator/src/evaluation-runner.tspackages/evaluator/src/report.ts

总项目验证命令:pnpm --filter @coding-agent/evaluator test

Week 6 完成从安全 Trace 到可视化与评测闭环。Week 7 将接入 Skill、Plugin、MCP 和受限 Subagent。

延伸阅读

  • Benchmark variance 与 confidence interval。
  • Pass@k、cost-adjusted score。
  • Canonical JSON 和报告签名。

方案对比与工程取舍

顺序执行最慢但资源隔离、成本节奏和复现最清楚,适合课程基线;固定并发池提高吞吐,却要求独立 workspace、端口、限流和结果排序。先建立可靠顺序版,再用同一套件证明并发版语义一致,不能一开始就用无界 Promise.all。

规则 grader 便宜、确定、可解释,却可能漏掉语义质量;模型 grader 覆盖开放任务,但有偏差、成本和提示注入风险。Coding Agent 首选测试、Diff 与内容规则的组合,必要时模型评分只做辅助维度,固定 rubric、版本和盲测样本,不让它单独决定安全发布。

语义哈希排除 duration 等噪声,便于确认能力结果一致;完整取证哈希则应覆盖显示报告全部字段。可以同时提供 reportHash 与 artifactHash,前者用于回归比较,后者用于文件完整性。只提供一个含义模糊的 hash,会在稳定性与审计需求之间摇摆。

下一课衔接

Week 6 已形成安全 Trace、统一 Query、可访问 Viewer、无污染 Fixture 与确定性 Evaluation 的完整证据链。Week 7 将让 Agent 获得可扩展能力:先加载受版本与边界约束的 Skill,再接入 Plugin 与 MCP,最后用受限 Subagent 分解任务;所有扩展仍必须进入本周建立的 Trace 与评测体系。

从零实现 Mini Code Agent Runtime