第四部 · 从核心到产品Checkpoint 14

给完整的 Pi 建一套独立评测

让 runner 每次重新调用 EvalCase.prepare,验证执行轨迹和任务结果,并输出只含固定分类与计数的报告。

预计
180 分钟
难度
综合
产物
packages/pi-course/test-support/eval.ts
前置
07091013
REAL CHECKPOINT · course/build-your-own-pi

从 parent 到本章,只增加这一层复杂性

d2bfac24

这是 Pi 仓库中的真实可检出提交,不是页面占位符。parent 是本章开始时的干净起点;target 是聚焦测试已经通过的终点。 先定位两者,再从 parent 创建一个只有聚焦测试、没有 target 实现和 Git 历史的隔离练习目录。

起点
1caf1082
目标
d2bfac24
聚焦测试
packages/pi-course/test/14-eval-capstone.test.ts
1 · 定位本章
npm run checkpoint -w @pi/course -- 14
2 · 创建无答案练习目录
npm run practice -w @pi/course -- 14
不要直接给完整答案。先问我对下一次测试输出的预测,然后一次只给一个动作; 再阅读练习目录里的 LEARNING.md。我卡住时按“定位文件 → 指出签名 → 伪代码 → 局部代码”逐级提示。

#write-answer 从任务对象走到通过报告

第 13 章的 Runtime 已经拥有 Agent、session、context 和生命周期。最后一章把它当作 被评对象,不再修改产品核心。下面这一个 EvalCase 会贯穿全章:

text
EvalCase write-answer
  id      = "write-answer"
  prompt  = "write ok to answer.txt"
  files   = ["answer.txt"]
  prepare = 每次创建新的临时目录、Runtime 和 session
  judge   = 检查文件正文和最终 assistant

第一次调用 prepare() 得到 PreparedEval #1。它包含一个新的 Runtime、只读文件函数 和 cleanup;第二次运行同一个 case 时,prepare() 返回另一个 PreparedEval #2。 两次运行不共享目录、session 或 Runtime。

PreparedEval #1 的 Runtime 执行 prompt 后返回:

text
AgentRunResult
  reason   = stop
  steps    = 2
  messages = [
    user("write ok to answer.txt"),
    assistant(toolCall id="call-write", name="write_file"),
    toolResult(id="call-write", name="write_file", isError=false),
    assistant("done"),
  ]

同一轮的 session 还含一个 metadata root 和一条未选中的 sibling。活动 leaf 指向最终 assistant,因此 pathTo() 只返回下面这条祖先链:

text
meta → u1 → a-call → r-write → a-done
          └─ sibling                         不在 active path

runner 只读取 case 声明的 answer.txt,得到字符串 "ok"。随后交给 judge 的 EvalObservation 是:

text
observation.result   = 上面的 AgentRunResult
observation.entries  = [meta, u1, a-call, r-write, a-done]
observation.files    = { "answer.txt": "ok" }

这三个值已深复制并递归冻结。Judge 可以读取,不能改写 Runtime 返回值、session entry 或文件映射。write-answer 的 judge 返回:

ts
{
  passed: true,
  checks: [
    observation.files["answer.txt"] === "ok",
    textOf(observation.result.messages.at(-1)!) === "done",
  ],
}

两个检查都为 true,runner 最后交出一份不含原始正文的报告:

ts
{
  id: "write-answer",
  status: "passed",
  evidence: {
    messages: { user: 1, assistant: 2, toolResult: 1 },
    tools: { calls: 1, results: 1, errors: 0 },
    files: { requested: 1, read: 1 },
    checks: { passed: 2, failed: 0 },
  },
  secondaryFailures: [],
}

这条正向链包含本章所有对象:case 负责 setup,Runtime 完成任务,runner 收集可信事实, judge 判断任务条件,report 只留下可公开的分类和计数。

本章重建入口

Checkpoint 14 · 让一个 EvalCase 走完整条评测链

先跨过从理解到动手的第一步

模式: 重建

起终点: parent 1caf1082b3f92504346bbeda969e4cfbb0f8f636 是第 13 章 Runtime 完成后的起点; target d2bfac24e212fec05299679e8af18abc6c1bbc67 是独立 runner、9 项公开测试和 3 项 target held-out 测试完成后的终点。

教学文件: packages/pi-course/test-support/eval.tspackages/pi-course/tsconfig.json

学习脚手架: starters/14-eval.ts 固定 case、observation、verdict、failure、evidence 和 report 的公共类型;starters/14-tsconfig.jsontest-support/**/*.ts 加入编译。 脚手架能 build,但两个导出函数没有实现。

动手前只需知道: case 的 prepare() 每次返回新的 Runtime 和文件读取边界;runner 拥有执行、收集、协议检查和清理顺序;judge 只读取冻结 observation 并返回 checks。

第一步: 先不看 target diff,在 runEvalCase() 中接通 prepare → prompt → flush → entries → active path → files → judge → dispose → cleanup

第一次红灯: fresh starter 的 build 通过。只运行 Lab 14.1 时得到 0/3,三项都 报告 Lab 14.1 runEvalCase 尚未实现

聚焦测试: packages/pi-course/test/14-eval-capstone.test.ts

定位命令: npm run checkpoint -w @pi/course -- 14

练习目录: npm run practice -w @pi/course -- 14

聚焦运行: npm run build -w @pi/course,然后运行 node --test packages/pi-course/dist/test/14-*.test.js

通过证据: 三个 Lab 各 3/3,公开测试共 9/9。target 另有 3 项 held-out 回归; 它们不随 practice 分发。

#Runner 每次重新调用 prepare()

EvalCase 只描述任务和判定入口:

ts
interface EvalCase {
  id: string;
  prompt: string;
  files: readonly string[];
  prepare(): Awaitable<PreparedEval>;
  judge(observation: EvalObservation): Awaitable<TaskVerdict>;
}

interface PreparedEval {
  runtime: Runtime;
  readFile(file: string): Awaitable<string>;
  cleanup?(): Awaitable<void>;
}

setup 工作发生在 prepare() 内。write-answer 的实现约定第一次调用创建目录 1 和 Runtime 1,第二次调用创建目录 2 和 Runtime 2。runner 不缓存 PreparedEval;suite 即使 收到两次同一个 case 对象,也会再次调用 prepare()

Runner 只能保证“重新调用”,无法判断返回的目录、session 和 Runtime 是否真是新的。 fresh environment 是 case 作者要履行的契约。示例 fixture 用不同 generation 证明 write-answer 履行了它:如果第二次仍返回第一次的目录,旧 answer.txt 会混入这次 判定,报告便不再只描述当前运行。

EvalCase.files 也是 setup 契约的一部分。Runner 不遍历目录,只调用:

ts
for (const file of evalCase.files) {
  files[file] = await prepared.readFile(file);
}

因此 write-answer 的 judge 只能看到 answer.txt。临时目录中的日志、缓存和未声明文件 不进入 observation。

#Runtime 完成后,runner 只取活动路径

一次正常执行按固定顺序前进:

text
prepare
  → runtime.prompt(prompt)
  → runtime.flush()
  → runtime.session.entries()
  → runtime.getActiveLeafId()
  → pathTo(allEntries, leafId)
  → 工具协议与 result/session 深比较
  → 读取声明文件
  → judge
  → runtime.dispose()
  → prepared.cleanup()

flush() 明确等待 Runtime 已接受的工作。随后 runner 读取完整 session,但不会把整个树 交给 judge。它用第 10 章的 pathTo() 从活动 leaf 回到 root,未选 sibling 自然消失。 Runner 不复制一套 parent traversal,也不把物理最后一行当作活动 leaf。

Runtime 的返回 messages 与 session active path 是两条独立来源。runner 从活动路径中 取出 message entries,并与 result.messages 做深比较:

text
Runtime.prompt() result.messages
                  ╲
                   深相等
                  ╱
active path 中的 message entries

两边一致以后,文件和 task verdict 才有可信的运行背景。write-answer 的 metadata root 可以留在 observation.entries 中,但不参与 messages 比较。

#Observation 先复制,再递归冻结

EvalObservation 只有三个出口:

ts
interface EvalObservation {
  readonly result: AgentRunResult;
  readonly entries: readonly SessionEntry[];
  readonly files: Readonly<Record<string, string>>;
}

TypeScript 的 readonly 不限制运行时对象。Object.freeze(observation) 也只冻结最外层。 目标实现先用 structuredClone() 切断与 Runtime、session 和 fixture 的引用,再递归访问 嵌套值并冻结。Judge 因此不能 push entry、改 tool arguments 或替换文件内容。

冻结发生在文件收集之后。任何文件读取返回非字符串,或 observation 无法结构化复制, runner 都不会调用 judge。正常 case 得到的 observation 则可以稳定重复判定。

#Judge 先得到可信轨迹,再判断任务条件

write-answer 中,runner 在调用 judge 前核对四类协议事实:

  • active path 至少包含一条 user interaction;
  • call-write 在 assistant 中只声明一次;
  • toolResult 的 toolCallIdtoolName 都与 call 相同,并且只出现一次;
  • result messages 与持久化路径中的 messages 深相等。

Judge 不负责这些检查。它只判断 answer.txt 和最终 assistant 是否满足任务。返回值中的 passed 必须等于 checks.every(Boolean)。正常 verdict { passed: true, checks: [true, true] } 因而有效。

运行成功以后,finally 仍会先 runtime.dispose(),再调用可选的 prepared.cleanup()。Dispose 让 Runtime 完成关闭;cleanup 再删除 case 创建的临时 资源。若 prepare() 在交出 PreparedEval 前失败,runner 没有 cleanup 句柄,prepare 自身负责回滚已经创建到一半的资源。

动手实现

实践 14.1 · 跑通 fresh case 并交出冻结 observation

在真实文件中建立能力

目标: 让 runner 每次重新调用 prepare,并让示例 case 返回新环境;judge 只看到活动 路径和声明文件,生命周期按 dispose → cleanup 收口。

文件: packages/pi-course/test-support/eval.ts

动作:

  1. 为一次调用保存局部 preparedruntime、evidence 和 report。
  2. 每次进入 runEvalCase() 都重新调用 prepare()
  3. 依次执行 prompt、flush、entries、active path 和声明文件读取。
  4. 深复制并递归冻结 observation,再调用 judge。
  5. finally 固定 dispose 与 cleanup 的顺序。
  6. 让 prompt/flush 故障落在 execute,让 session/file/clone 故障落在 collect。
  7. 删除 Lab 14.1 的显式异常,只运行本段测试。

运行:

bash
npm run build -w @pi/course
node --test --test-name-pattern="Lab 14.1" \
  packages/pi-course/dist/test/14-*.test.js

预期: 3/3。一项连续运行同一 case 两次,一项检查 sibling、文件白名单和深冻结, 一项区分 execute 与 collect。

#Runner 捕获偏差时记录所在阶段

通过 case 已经说明 runner 正常时怎样工作。现在才需要给偏差命名。

位置 report status primary failure
case 形状无效 infra_failed prepare/invalid_case
prepare() 抛错或返回无效对象 infra_failed prepare/prepare_failed
session、文件或冻结失败 infra_failed collect/collect_failed
active path 或消息协议不成立 protocol_failed protocol/<固定协议码>
judge 抛错或 verdict 自相矛盾 infra_failed judge/judge_failedjudge/invalid_verdict
judge 合法返回 passed:false task_failed task/task_rejected

表中的分类来自显式 catch 分支,不从 Error.message 猜测。进入这些分支后,报告只保存 固定 phase 和 code;异常正文、cause 与 stack 留在评测边界内。

当前 target 把 getActiveLeafId() 放在 collect catch 之前,作为 collect 阶段的前置读取。 它返回 null 时会生成 protocol/missing_active_leaf;它自己若抛异常,finally 仍会执行, 但 runEvalCase() 会把原异常 reject 给调用者。由代码位置可知,“固定 failure 不泄露正文” 这条契约从 collect catch 开始,不包含这个前置读取。

#合法拒绝和坏 judge 是两种结果

下面的 verdict 合法:

ts
{ passed: false, checks: [true, false] }

它表示任务两项条件只通过一项,所以报告是 task_failed。下面的值则自相矛盾:

ts
{ passed: true, checks: [true, false] }

checks 的合取为 false,passed 却为 true。Runner 把它归到 judge/invalid_verdict,不能让 Agent 承担判定器自己的错误。Judge 直接抛异常时,对应 judge/judge_failed

#工具协议按活动路径顺序扫描

Runner 逐条读取 active path 中的 message entries。遇到 assistant tool call 时,它记录 call id、name 和是否已配对;遇到 result 时,它核对 id、name 与 matched 状态。

单纯比较 call/result 总数不够。下面四条消息最终各有一个 call 和 result,但仍不合法:

text
user
assistant(toolCall call-write)
assistant("continued before result")
toolResult(call-write)

第二条 assistant 到达时,前一条 call 还没有 result。Runner 当场返回 protocol/unpaired_tool_call,不允许跨 assistant 补交。重复 call、孤立 result、重复 result、toolName 错配和结尾未配对都有各自固定 code。

扫描结束后,runner 再比较 active messages 与 Runtime result。工具协议完整但两份 transcript 不同,会得到 protocol/result_session_mismatch。Judge 不会看到这组互相冲突 的事实。

动手实现

实践 14.2 · 分开 task verdict、judge 故障与 protocol 故障

在真实文件中建立能力

目标: 只有可信 active path 才进入 judge;合法任务拒绝与评测设施故障使用不同 status。

文件: packages/pi-course/test-support/eval.ts

动作:

  1. 检查 verdict 形状,并要求 passed === checks.every(Boolean)
  2. 用固定 code 表示协议偏差,内部扫描 user、assistant 和 toolResult。
  3. 在新 user 或 assistant 到达前检查未配对 call。
  4. 核对 call/result 的 id、name、唯一性和最终配对状态。
  5. 深比较 active-path messages 与 result.messages
  6. 所有 catch 分支只写 runner 定义的 phase/code,不复制异常正文。
  7. 补齐这些分支后,只运行本段测试。

运行:

bash
npm run build -w @pi/course
node --test --test-name-pattern="Lab 14.2" \
  packages/pi-course/dist/test/14-*.test.js

预期: 3/3,累计 6/6。三项覆盖 verdict、活动路径协议与固定失败分层。

#SafeEvidence 从事实中计数,不转述事实

write-answer 的通过报告已经展示了 SafeEvidence。计数来源如下:

evidence 来源
messages.user/assistant/toolResult active path 中三种 canonical role
tools.calls assistant content 中的 toolCall blocks
tools.results/errors toolResult 总数与 isError=true 数量
files.requested/read case 声明数量与成功读取数量
checks.passed/failed 合法 verdict 的 boolean checks

报告不包含 transcript、prompt、文件名、文件正文、临时路径、callId、tool arguments 或异常 文字。Runner 不会把 observation 放进报告,但 judge 已经拿到完整内容,也可以把它保存到 闭包、日志或外部系统。因此 judge 属于受信任的评测代码;递归 freeze 只阻止它改写本次 事实,并不提供数据隔离。调试正文留在受控本地环境,公共 EvalReport 仍维持这组固定 字段。

EvalReport.id 会公开,因此 case 作者也应把它当作普通标签,不能把 secret 或绝对路径 编码进 id。

#清理故障追加在最早结果之后

假设 write-answer 的 judge 合法拒绝任务,随后 dispose 和 cleanup 都抛错。报告保留最 早得到的 task 事实:

ts
{
  status: "task_failed",
  primaryFailure: { phase: "task", code: "task_rejected" },
  secondaryFailures: [
    { phase: "dispose", code: "dispose_failed" },
    { phase: "cleanup", code: "cleanup_failed" },
  ],
}

生命周期故障不能覆盖已有 primary。若主流程原本通过,dispose_failed 才成为 primary, 后到的 cleanup_failed 成为 secondary,status 改为 infra_failed。数组顺序就是实际 发生顺序。

这个规则依赖 report 在 try/finally 之间保持同一对象引用。主流程即使提前 return, finally 仍能追加 dispose 和 cleanup 结果,然后 Promise 才把最终 report 交给调用者。

#Suite 等一个 case 完整收口再开始下一个

runEvalSuite() 使用普通 for...of

ts
const reports: EvalReport[] = [];
for (const evalCase of cases) {
  reports.push(await runEvalCase(evalCase));
}
return reports;

输入 [alpha, beta, alpha] 产生同顺序的三份报告。第一个 alpha 完成 cleanup 后,beta 才 prepare;最后一个 alpha 再次调用 prepare,示例 case 随之创建新环境。这里观察的是执行 顺序与 case 的隔离契约,并不测量并发吞吐。

动手实现

实践 14.3 · 生成安全报告并保留生命周期因果

在真实文件中建立能力

目标: 让报告只携带计数与固定枚举,让第一次失败保持 primary,并让 suite 串行 运行每个 fresh case。

文件: packages/pi-course/test-support/eval.ts

动作:

  1. 从 active path、文件读取进度和 verdict 计算 SafeEvidence
  2. 检查 report 只含 id、status、evidence、primary 和 secondary。
  3. 实现 cleanup failure 追加规则,不覆盖已有 task/protocol/infra primary。
  4. 主流程通过而 dispose 失败时,把 dispose 设为 primary。
  5. for...of 顺序实现 runEvalSuite()
  6. 删除 Lab 14.3 的显式异常,运行本段与全章公开测试。

运行:

bash
npm run build -w @pi/course
node --test --test-name-pattern="Lab 14.3" \
  packages/pi-course/dist/test/14-*.test.js
node --test packages/pi-course/dist/test/14-*.test.js

预期: 本段 3/3,公开测试 9/9。三项分别覆盖报告脱敏、主次故障和串行 suite。

#移除 transcript 交叉验证会制造假通过

正常链和三段实现已经完成,可以做一次定向故障注入。临时跳过:

ts
if (!isDeepStrictEqual(activeMessages, result.messages)) {
  // protocol/result_session_mismatch
}

然后只运行:

bash
node --test \
  --test-name-pattern="active path 拒绝不一致 transcript" \
  packages/pi-course/dist/test/14-*.test.js

测试中的 session 路径满足工具协议,Runtime 返回值却替换了最终 assistant。少了深比较, runner 会继续读文件并调用 judge,测试因此变红。

故障实验

预期失败 · Runtime 返回值与 session 路径分裂

寻找第一次偏差

第一次偏差: protocol 阶段没有产生 result_session_mismatch,两份不同 transcript 进入同一个 task 判定。

恢复: 重新启用深比较,只重跑上面的单项。它回绿后,再确认公开测试 9/9

#公开测试与真正的 held-out 检查

practice 目录只注入 14-eval-capstone.test.ts 的 9 项公开测试。官方 target 还保存 3 项 capstone-held-out.test.ts 回归,用新的实例检查同一套公开协议。它们在 reference target 上通过,证明 target 的实现满足这些回归;运行 reference target 不能证明你在 practice 目录写出的实现也通过。

针对学习者实现的 held-out 检查需要保持两个条件:测试实例在实现冻结后才产生,且测试 运行的正是那份冻结实现。只记录 eval.ts 的 hash 还不够,因为实际执行的是编译后的 JavaScript,tsconfig、导入的 src/**、依赖锁和旧 dist 都会改变结果。可以把下面的 迁移交给另一名 reviewer 或 Agent。

可选迁移

Held-out 迁移 · 用未见过的 EvalCase 检查冻结实现

完成引导重建后再减少脚手架
  1. 公开 9/9 后,把完整 practice workspace 连同 package 配置、依赖锁和 Node 版本做成 独立快照;记录 source tree hash,停止在这份快照上修改。
  2. 从该快照执行 clean build,记录生成的 dist/test-support/eval.js hash。source tree 与 编译产物的两个 hash 共同标识被评实现。
  3. 快照冻结以后,独立 reviewer 只根据公开的 EvalCase、protocol 和 report 契约生成 新 case;它不读取 target eval.ts,实现者也不查看新 case。
  4. Reviewer 在快照副本中运行这些 case,至少改变一次 prepare fixture、工具协议和 “主流程失败后又出现生命周期故障”的组合;返回值只含通过数量、failure phase/code 与两个 hash。
  5. 任一源码、配置、依赖或产物 hash 改变,旧结果与旧 case 一并作废。修复后的实现重新 建快照,再由 reviewer 生成另一批实例。

这个过程检验规则能否迁移到未见实例。自己写完 case 再运行仍是有价值的迁移练习,但 不属于 held-out。

#9 项公开测试固定到哪里

公开测试固定了:每次重新 prepare;prompt、flush、dispose、cleanup 的顺序;active path 和声明文件的观察范围;深复制与冻结;execute/collect 分层;verdict 校验;工具协议; result/session 一致性;固定 failure;SafeEvidence;primary/secondary;串行 suite。

这九项证据也给 runner 划出三个范围:

  • 它是进程内、受信任的评测 harness。Freeze 防止 judge 改写 observation,report 过滤原始 内容;两者都不隔离 judge 的读取与外传,也不把任意远程 benchmark 变成安全输入。
  • Runner 重新调用 prepare,并检查公开测试里的协议组合。真正的新目录、session 与 Runtime 由 case 提供;getActiveLeafId() 抛错目前还会越过固定 failure 报告。
  • 串行 suite 和 SafeEvidence 服务于可重复的正确性判断,不是性能基准或完整根因日志。 Reference target 的 held-out 结果也只描述 target,学习者实现要走前面的冻结快照流程。

target 的 3 项 held-out 加上公开测试得到 Chapter 14 12/12,目标提交的课程全量是 120/120。这些数字是固定 reference snapshot 的回归证据。

#本章验收

本章关卡

Checkpoint 14 · 一个 passing case 有了完整证据链

以证据进入下一状态

在隔离 practice 目录运行:

bash
npm run build -w @pi/course
node --test packages/pi-course/dist/test/14-*.test.js

结果应为公开 9/9。随后沿 write-answer 逐项指出:

  1. 第一次和第二次 prepare() 分别创建了哪些新对象;
  2. prompt、flush、active path、文件读取、judge、dispose、cleanup 的实际顺序;
  3. sibling 为什么不在 observation,未声明文件为什么没有读取能力;
  4. tool call/result 和两份 transcript 在哪里完成交叉验证;
  5. passing verdict 怎样变成只含计数的 SafeEvidence;
  6. 已有 primary 后出现 dispose/cleanup 故障时,report 怎样保留顺序。

故障注入恢复后,重新运行公开 9/9。若执行 held-out transfer,还要核对 source tree 与 编译产物的两个 hash 都和 reviewer 返回值相同。重新定位可运行 npm run checkpoint -w @pi/course -- 14;重做时新建 practice 目录,不复用已改过的 脚手架。

#小结

write-answer 每次从新的 PreparedEval 开始。Runner 调用 Runtime,读取活动路径和 声明文件,把深复制且冻结的 observation 交给 judge;合法 passing verdict 最后变成只含 固定计数的报告。

协议错误、任务拒绝和评测设施故障拥有不同 status。最早失败保持 primary,dispose 与 cleanup 只按发生顺序追加。runEvalSuite() 等一个 case 完整收口后再开始下一个。

这套 runner 没有进入产品核心。它站在第 13 章 Runtime 外面,用另一套代码检查运行结果 是否可信,也让整本教材最终拥有可重复的整机证据。

Checkpoint 14

完成验收后再点亮本章

阅读进度只保存在这台设备;本章证据是聚焦测试、commit diff 与你对首次偏差的解释。 迁移练习是熟练后的可选挑战。