Skip to content

Benchmark Fixtures

本课交付结果

你将交付 loadBenchmarkTaskwithFixtureCopy:任务只接受 bugfix/refactor/feature 类别和安全 ID;每次运行复制独立临时工作区,成功失败都清理,并比较源目录前后规范树哈希。

岗位问题

若第一个评测直接修改基准源,第二个评测会在已修复代码上运行,成功率失去意义。夹具必须像实验室对照组:每次实验取新副本,源样本始终可证明未污染。

前置检查

前置知识快照

回顾第 17 课事务和工作区边界:

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

task.json 至少包含 id、category、prompt、fixture。

第 17 课用事务保护真实工作区,本课反过来要求评测工作区可以被 Agent 任意修改,但源夹具绝不改变。Benchmark 是受控实验:task 描述自变量,fixture 是初始条件,Agent 运行是处理,grader 是测量。只要第一次运行污染初始条件,后面所有成功率、耗时和成本都失去可比性。

开始前应熟悉 realpath、relative 和符号链接的区别。字符串看起来位于 root 下,不代表解析后的对象仍在 root;../、绝对路径、平台分隔符与 symlink 都可能越界。任务 ID 先用严格 slug 限制,fixture 再相对任务目录解析并校验,复制器还要明确拒绝或安全处理链接,路径安全需要多层共同成立。

原理拆解

mermaid
flowchart TD
  A[读取 task.json] --> B[校验 ID 类别与相对 fixture]
  B --> C[计算源树 beforeHash]
  C --> D[创建唯一临时根]
  D --> E[复制到全新 workspace]
  E --> F[运行 Agent 与 grader]
  F --> G[计算源树 afterHash]
  G --> H{哈希一致?}
  H -->|是| I[清理并返回结果]
  H -->|否| J[清理并报告污染]

ID 采用严格 slug 正则,fixture 必须是任务目录内相对路径。树哈希按排序后的相对路径与文件字节更新 SHA-256,拒绝 symlink。operation 只拿到临时副本路径;finally 再 hash 源并清理临时根。

json
{"id":"task-1","category":"bugfix","prompt":"修复边界错误","fixture":"fixture"}

代码实验

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/29-benchmark-fixtures/starter test
pnpm --dir bootcamps/coding-agent/labs/29-benchmark-fixtures/solution test

Starter 应声明 实现基准夹具原态校验;Solution 应通过 5 项临时目录测试。

失败实现

ts
export async function runFixture(task: BenchmarkTask) {
  const workspace = join(root, task.fixture);
  return operation(workspace);
}

这段代码直接把源目录交给 Agent。第一次修复把测试变绿,第二次即使什么都不做也会通过;失败后产生的临时文件和依赖继续影响下一轮。更危险的是 fixture 若包含点点斜线或绝对路径,评测可能修改仓库外数据。

清单加载应先缩小语法,再解析真实边界:

ts
export async function loadBenchmarkTask(root: string, id: string) {
  if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw new Error("任务 ID 无效");
  const realRoot = await realpath(root);
  const directory = resolve(realRoot, id);
  if (isOutside(realRoot, directory)) throw new Error("任务超出基准目录");
  const row = JSON.parse(await readFile(join(directory, "task.json"), "utf8"));
  if (!categories.has(row.category) || !isSafeRelative(row.fixture)) {
    throw new Error("任务 schema 无效");
  }
  return row as BenchmarkTask;
}

关键实现讲解

哈希包含路径,防止文件内容相同但重命名被忽略;目录遍历排序,避免文件系统顺序导致哈希抖动。operation 抛错也要清理;若源哈希变化,污染错误优先暴露。

任务 ID 是存储定位符,不是展示标题。采用小写字母、数字与内部短横线的 slug,可以在进入文件系统前拒绝斜线、点段、空字节和编码歧义。task.json 内的 id 必须与目录 ID 相等,否则复制报告可能把一个任务结果归到另一个任务。显示名称可以单独设 title,不能为了好看放宽路径标识符。

category 必须来自有限枚举。Lab 使用 bugfix、refactor、feature,总项目 contract 使用 bug_fix、refactor、feature、docs_update 等更细类别;关键不是具体拼写,而是评测集合与报告聚合共享同一版本。未知类别不能自动归入 other,否则拼写错误会悄悄改变分类成功率。增加类别时同步 schema、grader 与历史比较规则。

prompt 要非空且有长度预算。它描述 Agent 看到的任务,不应包含机器绝对路径或本轮动态答案。fixture 是任务目录内相对路径,拒绝绝对路径和任何点点段。单纯 startsWith(root) 不安全,因为 /bench/task-10 也以 /bench/task-1 开头;应使用 path.relative,结果为点点、以点点加分隔符开头或绝对时才判越界。

realpath 解决已有路径中的符号链接与规范化问题,但它要求目标存在。加载顺序应先验证字符串、解析任务目录真实路径,再读取清单,最后确认 fixture 存在并仍在边界内。若允许任务目录本身是链接,root 管理策略必须明确;最简单的 hermetic 基准拒绝所有链接,减少平台差异和指向外部的风险。

树哈希不是只把所有文件字节相加。若两个文件内容相同,交换路径后纯内容 hash 不变;所以每项输入至少包含规范相对路径、节点类型、文件内容,必要时包含可执行权限。目录枚举必须排序,路径统一成正斜线与明确编码,字段之间加长度或空字节分隔,避免 ab+ca+bc 拼接歧义。

是否把权限、空目录、换行和忽略目录纳入 hash 是 contract 选择。总项目包含 mode,忽略 node_modules、dist、build、版本控制目录和教程构建目录,使安装与构建缓存不污染源证明;Lab 更严格拒绝 symlink 并聚焦路径与字节。忽略集合必须固定、版本化且不能由任务随意覆盖,否则恶意任务可把真实源文件放进忽略目录逃避检测。

符号链接带来两个不同问题:hash 若只记录链接文本,复制器可能保留一个指向源目录的链接,Agent 修改副本实际修改源;若跟随链接遍历,可能读到 root 外。最安全教学策略是发现任何 symlink 立即拒绝。生产若必须支持,应只复制链接本身、验证解析目标仍在源树,并在沙箱中禁止通过链接写出 workspace,复杂度明显更高。

每次运行使用 mkdtemp 创建唯一父目录,再把 fixture 复制成固定 workspace 子目录。不能只复用 /tmp/task-id 并在开始前删除,因为并行运行会互删;随机临时根天然隔离。operation 只获得 workspace,不能获得 source。接口能力越小,误写源的概率越低,也更容易在代码评审中证明边界。

ts
export async function withFixtureCopy<T>(source: string, operation: Work<T>) {
  const before = await treeHash(source);
  const temporary = await mkdtemp(join(tmpdir(), "coding-agent-eval-"));
  const workspace = join(temporary, "workspace");
  let value: T | undefined;
  let failure: unknown;
  try {
    await copyTree(source, workspace);
    value = await operation(workspace);
  } catch (error) {
    failure = error;
  } finally {
    await rm(temporary, { recursive: true, force: true });
  }
  if (await treeHash(source) !== before) throw new Error("源 fixture 被污染");
  if (failure !== undefined) throw failure;
  return value as T;
}

实际顺序要更谨慎:afterHash 必须在源仍可读时计算,临时清理无论如何执行。可以在 finally 中先 hash source,再清理 temporary,并分别捕获 hash 与 cleanup 错误。若 operation 失败且源也被污染,应优先报告污染,因为它会影响所有后续实验;原始 operation 错误作为 cause 保存。若清理失败但源没变,报告清理错误并把路径交给受控回收任务,不能静默泄漏。

上面示例把 afterHash 放在清理后以突出返回逻辑,生产实现应显式组合异常,避免 finally 中的新错误覆盖旧错误而丢失上下文。错误对象只记录任务 ID、哈希和安全临时标识,不包含 workspace 文件内容。清理使用 recursive 与 force,但 root 必须来自刚刚创建的 mkdtemp,绝不能对用户输入路径直接执行递归删除。

复制行为要定义隐藏文件、权限、空目录与链接。Node cp 简单,但不同版本的过滤和链接选项要锁定;手写 copyTree 可跳过缓存目录并复制 mode,更可控。复制完成后可再次 hash workspace,证明它与按同一规则计算的 source 相等,再让 Agent 开始。这个检查能发现复制器漏文件,却会增加一次完整读取成本。

源前后 hash 相同证明没有最终差异,但不能证明运行期间从未短暂改写后还原。对防止后续基准污染已经足够;若安全上要求绝不触碰源,应配合只读挂载、文件权限、独立容器或复制到另一用户。哈希是检测,权限隔离是预防,两者同时使用比任何单一机制可靠。

临时 workspace 还要避免继承宿主依赖。若复制 node_modules,测试可能使用仓库当前安装状态,机器差异破坏复现;若完全不安装,任务又无法运行。合理方案是 fixture 提交 lockfile,评测在受限网络或预热依赖缓存中执行确定安装,依赖缓存只读挂载,不成为源树一部分。Node、包管理器和系统工具版本写入报告。

环境变量也是 fixture 初始条件。只传递 allowlist,例如 PATH、HOME 指向临时目录和明确的测试变量;删除云密钥与个人配置。Agent 可能读取 Git 全局配置、编辑器规则或父目录 AGENTS,因此运行容器要提供隔离 HOME,并让 Instruction Loader root 固定在 workspace。只复制文件而继承宿主环境,并不算 hermetic。

时间、随机数和网络会制造非确定性。fixture 可提供固定时钟、seed 和 mock server;评测 Runner 在任务 metadata 记录这些参数。不是所有真实 Agent 都能完全 deterministic,但输入与外部条件越固定,版本差异越能归因于实现而非噪声。对有意测试网络能力的任务,也要使用受控录制响应与明确失效策略。

并行评测要求每个 workspace、端口、HOME 和缓存命名空间都唯一。只给目录加随机名而所有任务共享数据库或固定端口,仍会互相污染。资源分配器将任务 runId 映射到独立端口和临时服务,finally 统一回收。源 hash 在每个 operation 前后计算,可以检测越权写源,却不能发现两个副本之间通过共享服务串扰。

任务 schema 还应包含超时、预算、grader 与 fixture 版本。总项目 loader 验证所有预算为正数,grader 类型来自允许集合,fixture 必须在 fixtures 目录。越早拒绝无效任务,Runner 越不需要处理半初始化状态。Schema 库或手写验证都可以,但错误必须指出安全字段路径,不把整个恶意 JSON 回显。

fixture 版本可以由 tree hash 本身标识。报告记录 task id、task definition hash 与 fixture hash,后来比较两次 Agent 结果时先确认输入相同。若课程作者修复了 fixture,却仍沿用同一 task id,报告工具应提示不可直接回归比较;可以增加 version 或把内容 hash 纳入唯一任务版本。

Mutation 是验证 grader 的好方法。在副本中故意植入一个已知错误,正确测试应该失败;Agent 修复后通过。mutation 只能发生在副本,且每轮重新创建。若 mutation 在源上预生成并被前一次运行改掉,kill rate 会虚高。夹具隔离因此不仅保护 Agent 评测,也保护 grader 自身校准。

测试必须观察第二次运行仍读到 original,而不只是第一次副本可写。还要分别让 operation 成功和抛错,随后 access 临时路径都失败;读取源字节并比较 before;非法 ID 在任何 readFile 前拒绝;symlink fixture 明确拒绝。测试临时目录只用专门前缀,afterEach 再做兜底回收。

ts
it("starts every run from the same source", async () => {
  await withFixtureCopy(root, task, async (workspace) => {
    await writeFile(join(workspace, "a.txt"), "changed");
  });
  await withFixtureCopy(root, task, async (workspace) => {
    expect(await readFile(join(workspace, "a.txt"), "utf8")).toBe("original");
  });
  expect(await readFile(sourceFile, "utf8")).toBe("original");
});

树 hash 测试可以构造同内容重命名:before 有 a.txt,after 改成 b.txt,预期 hash 不同;以相反 readdir 顺序 mock,预期 hash 相同;内容不变只改可执行位,根据 contract 预期不同。这样的对抗用例比只调用两次相同目录更能证明“规范”二字。

清理失败在持续集成中会逐渐吃满磁盘。运行器应统计临时目录创建与删除数量,启动时仅清理带可信前缀且超过 TTL 的孤儿,绝不扫描删除任意 tmp。Windows 文件句柄未关闭可能导致删除失败,测试与工具必须释放 watcher、子进程和文件流后再清理,必要时有限重试并报告。

性能上,复制与全树 hash 可能比 Agent 小任务本身更贵。可以使用文件系统快照、写时复制或内容寻址缓存,但交给 Agent 的视图仍必须可写且与源隔离,前后完整性证据仍要保留。优化前测量文件数量、总字节和耗时;不要为了省几百毫秒复用脏 workspace,牺牲整个评测可信度。

发布基准集时应检查许可证、秘密、个人信息和大文件。fixture 进入仓库前运行 secret scan,README 说明来源与预期行为。任务 prompt 不透露答案,测试可以隐藏关键断言但仍要让失败可诊断。基准集本身也是产品资产,需要 code review 和变更记录,而不是随手堆几个示例目录。

验收演练可启动两个并行运行,都先读取 original,一个修改文件并成功,一个抛错;结束后两个 workspace 都不存在,源 hash 与开始相同。随后故意让 operation 通过越权路径改源,系统应把污染作为最高优先级失败并阻止后续任务。这个演练同时覆盖隔离、清理、并发与检测。

基准仓库需要独立的变更门禁。每个任务清单都经过 schema 校验,每个 fixture 都运行初始测试确认处于预期失败或预期基线,所有 source hash 汇总成 manifest。合并请求若改变 hash,必须说明任务意图、评分标准与历史报告是否仍可比较。这样基准不会在普通重构中悄悄漂移。

初始状态不一定全部测试失败。bugfix 任务可能只有一个隐藏边界测试失败,refactor 应在行为测试全绿的同时要求结构变化,feature 则缺少新能力。manifest 记录 expectedBaseline,而不是用红灯作为统一判断。Runner 开始前执行基线探针,发现 fixture 已偏离预期立即拒绝评测。

测试命令自身也要固定。依赖锁、脚本名、环境变量和超时属于任务定义,不能让 Agent 自由选择一个更容易通过的命令。grader 在副本内执行受控参数,工作目录固定,输出截断并脱敏。Agent 可以运行探索性测试,但最终分数只由清单声明的独立 grader 决定。

隐藏测试提高抗投机性,却会降低调试透明度。可将公共测试放在 fixture,隐藏 grader 在隔离目录或容器注入;失败只返回足够定位的类别,不泄露完整答案。课程环境重视学习,可公开大部分测试并保留少量泛化断言;正式排行榜则需要更严格的任务保密和轮换。

若 fixture 包含 Git 仓库,要决定是否保留版本目录。保留便于 grader 检查 diff,却可能携带远端、hooks 与历史秘密;更安全做法是在副本中初始化全新本地仓库,以 fixture 内容创建基线提交,禁用 hooks 和网络。总项目复制默认忽略版本目录,grader 可在副本显式初始化。

文件名大小写在不同平台有差异。两个只差大小写的路径在 Linux 可共存,在默认 macOS 文件系统可能冲突;规范基准应在生成阶段拒绝大小写折叠冲突。路径 Unicode 还要选择规范化形式,避免视觉相同字符串产生不同 hash。跨平台报告记录平台,核心任务尽量使用可移植路径。

大文件与二进制的 hash 要流式读取,避免一次加载到内存;复制也要保留失败原子性。若中途磁盘满,operation 不得启动,临时目录被清理,源 hash 仍验证。可以先估算 fixture 字节数并检查配额,但预检不是保证,真正写入错误仍需处理。

工作区网络默认关闭能减少依赖外部服务与数据外泄。需要安装依赖时使用内部只读镜像与锁文件完整性校验;需要 API fixture 时启动本地假服务。任何允许公网的任务单独标记,并从离线主成功率中区分,以免网络波动主导结果。

基准任务还应有弃用流程。发现答案泄露、测试错误或许可证问题时,不删除旧报告证据,而是把任务标为 retired,注明原因与最后有效版本;新报告不再运行它,趋势比较只取共同有效集合。直接替换同 ID fixture 会让历史成功率含义改变。

失败产物保留是清理策略的例外。为排障可以把副本中的 Diff、测试摘要和 Trace 导出到受控 artifact 后再删除 workspace,但导出走白名单、脱敏、大小限制和保留期。不能为了以后查看保留完整临时目录,因为其中可能有依赖缓存、秘密或大文件。

安全对抗应尝试链接到源、任务路径穿越、清单绝对路径、子进程占用文件、修改源后还原以及并行端口冲突。每个攻击都有明确期望:入口拒绝、沙箱阻断、hash 报警或清理告警。威胁模型变成测试清单后,隔离声明才不会停留在文档。

最终的原态包含文件树、执行环境与任务定义三层。树 hash 解决第一层,容器或环境白名单解决第二层,task manifest hash 解决第三层。报告同时记录三者,才能解释两次运行是否在相同实验条件下。只写 Agent 版本而不写基准版本,无法得出可靠回归结论。

运行与验证

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

第一个运行把副本改成 changed,第二个运行仍读到 original;运行结束后副本路径不存在,源文件保持原字节。

真实运行输出

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

输出只证明基础 Lab;项目验收还要记录 source before/after hash、临时目录已删除以及失败分支结果。报告保留哈希而非源内容,既可比较又避免复制潜在敏感 fixture。

常见失败与排查

故障案例 1

症状:同一任务第一次失败、第二次不改代码却通过,或执行顺序改变成功率。

根因:Agent 直接在源 fixture 或复用 workspace 上运行,上一轮修复残留。

定位:在每轮开始读取已知哨兵文件并记录 fixture hash,交换任务顺序后比较结果。

修复:每轮 mkdtemp 并全新复制,只把副本交给 operation;报告绑定 task 与 source hash。

故障案例 2

症状:文件重命名、权限改变或链接越界后,原态校验仍称哈希一致。

根因:hash 只累计文件内容,忽略规范路径、类型和 contract 要求的 mode;或跟随 symlink。

定位:构造同内容重命名、反序枚举、权限变化与外链四组对抗 fixture。

修复:排序遍历并哈希相对路径、类型、权限和字节;教学基准直接拒绝所有符号链接。

故障案例 3

症状:operation 抛错后 tmp 不断增长,或者污染错误被原始运行异常覆盖。

根因:清理只写在成功路径,finally 中异常组合没有优先级。

定位:分别注入 operation、afterHash 与 rm 失败,检查目录和最终 error cause。

修复:finally 始终校验与清理;源污染最高优先,保留原异常为 cause,清理失败进入受控回收。

课后作业

增加 fixture manifest、预期失败测试和磁盘配额;并行运行 20 个副本,证明互不影响且源哈希始终一致。

验收 Rubric

维度通过标准常见扣分
清单ID/category/fixture 严格验证任意路径可加载
隔离每次全新副本复用上次工作区
清理成功失败均删除临时目录泄漏
原态前后树哈希一致只凭意图判断

总项目增量

总项目包:@coding-agent/evaluator

总项目路径:packages/evaluator/src/benchmark-fixtures.ts

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

夹具保证输入可信;下一课按确定顺序运行任务并生成可比较报告。

延伸阅读

  • Hermetic test 与 reproducible benchmark。
  • Merkle tree 和内容寻址。
  • Benchmark contamination。

方案对比与工程取舍

普通目录复制跨平台且易理解,适合课程与中小 fixture;容器镜像和文件系统快照能更强隔离环境、提高大型基准启动速度,但构建与调试成本更高。无论底层手段,公开 contract 都是全新可写视图、源只读、结束可证明清理,不能因为使用容器就省略输入哈希。

检测型 hash 可以发现污染但无法阻止短暂写入,只读挂载能预防却可能被更高权限绕过。可靠系统组合两者:权限让普通错误失败,hash 揭示配置漏洞。hash 全树成本随规模增长,可用 Merkle 增量优化,但根哈希的规范规则必须版本化。

严格拒绝 symlink 限制了某些真实仓库 fixture,却显著缩小安全与复现问题。基准目标是稳定测量,不是完整复制所有仓库特性;先用无链接任务建立可信基线。确需链接能力时新增专门类别与隔离容器,而不是悄悄放宽默认 loader。

下一课衔接

本课把每个任务的初始条件固定下来,下一课才能谈成功率。Evaluation Runner 将按稳定 ID 顺序创建副本、执行 Agent、处理超时与 Abort、调用独立 grader,并生成总体与分类指标及规范报告哈希。夹具 hash 会成为报告可比较性的输入证据。

从零实现 Mini Code Agent Runtime