Skip to content

第28章 每次考试换新考场——pi-evals ​

本章导读: 源码路线第 ⑩ 站(评测层)。看基础层的 pi-evals 包,读 pi-harness.ts 和 vitest-evals/setup.ts。这一站要泼一盆冷水——"跑通了"不等于"改对了",看看评测 harness 怎么用临时环境和 artifact 留下可复查的证据,以及 faux 测试和真实模型测试的证据等级差在哪。

小a终于把自己的 Agent 改到了能跑。他长舒一口气,说:“跑通了!这次应该没问题了吧?”

“跑通一次,和改得好不好,是两回事。”老z说,“协议和远程状态可以被测试,但‘模型改得好不好’不能只靠一次成功运行。我们跟 pi-evals 的 harness,看环境怎么隔离、模型怎么选择、judge 到底能证明什么。别把评测分数当普适能力排名。”

本章源码证据均来自 pi-mono 提交 583f153d(packages/evals/src/,包版本 0.83.0)。

harness 建立一次隔离运行 ​

“先看运行环境。”老z打开 packages/evals/src/pi-harness.ts:“createPiCodingAgentHarness、runPiCodingAgent 以 vitest-evals/harness 创建 harness。每次运行使用 mkdtemp(join(tmpdir(), "pi-eval-")) 建临时根目录,再建立独立 workspace、agent 与 session 路径;结束时读取 session artifact(若存在)、dispose session 并递归删除临时目录。”

ts
const root = await mkdtemp(join(tmpdir(), "pi-eval-"));
const cwd = join(root, "workspace");
const agentDir = join(root, "agent");
await Promise.all([mkdir(cwd), mkdir(agentDir)]);

比喻:每次考试换新考场

临时 cwd 像每次换新考场——上一次的草稿纸不会留在桌上。

小a追问:“那这个临时目录到底隔离了什么?模型服务也不在里面吧?”

“隔离边界要逐项看。”老z指着 runPiCodingAgent 的 try 块,“mkdtemp 只把 workspace、agent、sessions 三条路径放进临时根目录——它们由 createAgentSessionServices 和 SessionManager.create(cwd, join(root, "sessions")) 消费。在临时目录外的有:process.env 里的 PI_PROVIDER/PI_MODEL 等凭据、ModelRuntime.create() 读取的真实模型服务、宿主网络、以及宿主文件系统里用户已登录的任何账号。SettingsManager.inMemory() 只让设置不落盘,不影响这些。”

小a:“那 cleanup 是按什么顺序删的?”

“顺序是固定的,并且要和 artifact 收集的顺序对上。”老z顺着源码往下读。runPiCodingAgent 的 try 块结束后,先在 sessionManager 上读 getSessionFile(),若文件存在就把整份 JSONL 通过 setArtifact(PI_SESSION_SNAPSHOT_ARTIFACT, ...) 注入;之后才 session?.dispose(),最后 rm(root, { recursive: true, force: true })。artifact 读取必须在 rm 之前,否则 JSONL 一删就再读不到。三条 cleanup 各自有独立 try/catch,错误全部推进 cleanupErrors——这样即使 dispose 抛错,rm 仍会执行,临时目录不会被泄漏在 tmpdir 里。

text
try 块结束
 → 读 session JSONL 注入 artifact(若存在)
 → session.dispose()
 → rm(root, recursive)
 → 若 outcome 失败 + cleanupErrors 非空 → AggregateError 合并抛出

“这套隔离降低测试之间共享工作目录的污染,但不等于运行绝对无副作用:模型服务、环境凭据和宿主网络仍在临时目录外,需要另行控制。”

小a:“如果不用临时目录,用仓库自己的工作区跑,行不行?”

“行,但每次都要自己还原子目录。”老z说,“这正是 mkdtemp 省下的那部分。换到固定目录跑,你得手动 rm 上次的 agent/sessions,还要担心并发跑两个 eval 时互相踩 workspace;临时目录把‘每轮从零开始’变成了路径性质,而不是约定。代价也有:临时目录在 tmpdir,跑完即删,你想事后检查现场就得靠 artifact 和 output callback 主动留下——不能假设失败时目录还在。用固定目录是‘可复现但易污染’,用临时目录是‘干净但证据要靠显式导出’,pi-evals 选了后者。还有个细节:runId 通过 setArtifact("runId", sessionManager.getSessionId()) 在会话创建后立刻记下,后续 artifact 都挂这个 runId,失败的 run 也会留下 runId 线索,不会因为 rm 就完全断链。”

小a:“那 cleanup 出错和 run 失败,会怎么合并报告?”

“分两种,别混。”老z回到 runPiCodingAgent 的尾部,“outcome 成功但 cleanup 出错:1 个错误直接 throw,多个错误包成 AggregateError("Agent cleanup failed.")。outcome 失败且 cleanup 也出错:多个错误和 outcome.error 一起包成 AggregateError("Agent run failed and cleanup also failed.");而 cleanup 干净时只抛 outcome.error。这个分支保证‘run 失败的原因’永远是第一手证据,cleanup 失败只是附加信息——你永远不会只看到 cleanup 报错而丢了 run 本身的错误。把一次 eval 的完整生命周期画出来是这样。”

text
createPiCodingAgentHarness → createHarness(vitest-evals)
   │ run 回调
   v
runPiCodingAgent
   ① signal.throwIfAborted / resolveModelSelection / ModelRuntime.create / getModel
   ② mkdtemp → mkdir(workspace, agent) → createAgentSessionServices
   ③ SessionManager.create → setArtifact(runId)
   ④ createAgentSessionFromServices → 检查无 extensions → 逐步 prompt / reload
   ⑤ output() → toTranscriptEvents → usage(含估算成本)
   ⑥ finally: signal abort 桥接到 evalSession.abort()
   ⑦ catch: outcome = { success: false }
   ⑧ 读 session JSONL → setArtifact(piSessionJsonl) → dispose → rm(root)
   ⑨ 合并 outcome / cleanupErrors,决定 throw 或返回 timings

fake 与真实模型的边界 ​

小a:“那测试用哪个模型?随便用一个吗?”

“不能随便。”老z说,“resolveModelSelection 要求显式模型或同时设置 PI_PROVIDER、PI_MODEL;ModelRuntime.create() 后找不到模型会直接失败。”

ts
if (!provider || !id) {
	throw new Error("Select a harness model explicitly or set both PI_PROVIDER and PI_MODEL as defaults.");
}
return { provider, id };

“仓库的 pi-ai 另有 providers/faux.ts,可供不依赖付费模型的测试路径使用;是否使用 faux 取决于具体 suite 的配置。smoke.eval.ts 的 smoke 用例断言使用环境变量选择的 provider 与 model——说明它是可访问真实模型时的端到端检查,而不是离线确定性单元测试。”

小a追问:“faux provider 长什么样?它真的‘跑模型’吗?”

“不跑。”老z打开 packages/ai/src/providers/faux.ts,“createFauxCore 维护一个 pendingResponses 队列,stream 被调用时 shift() 出一条预先装好的 AssistantMessage,再用 streamWithDeltas 切成 token 大小的 chunk 模拟流。withUsageEstimate 把 token 数从字符长度除以 4 估出来——所以 usage 是估算的不是真计费。队列空了它会直接产 stopReason: "error" 的 assistant message,错误信息是 No more faux responses queued。也就是说 faux 测的是控制流和事件序列,不是模型推理。”

小a:“那为什么 harness 不直接默认用 faux?”

“因为 faux 跑过不代表真模型跑过。”老z说,“resolveModelSelection 不接受隐式默认——它宁可让测试失败,也不让你误以为测了真模型。fauxProvider() 只出现在 pi-ai/pi-agent/pi-coding-agent 的单元测试 harness里(比如 packages/agent/test/harness/ 和 packages/coding-agent/test/suite/harness.ts 的 registerFauxProvider),它们用 setResponses([fauxAssistantMessage("hi")]) 预先装好回复,再跑 Agent 循环断言行为。而 packages/evals/src/ 下的 pi-harness.ts 和 smoke.eval.ts 走的都是 ModelRuntime.create() + 真实 getModel(provider, id) 路径——pi-evals 本身不引入 faux。两条路径服务不同的证据等级:一条证明控制流,一条证明真实链路。”

要点:两种证据等级

真实模型会受服务端版本、采样、限流和价格影响;faux provider 可为确定性单测提供替身,但不能证明真实 API 兼容或质量。反过来,真实模型 smoke 能证明一次实际链路可用,却不能证明所有 prompt 的正确性。不要把 harness 存在误写成所有 eval 都是 fake,或把真实模型结果误写成可重复的确定性证明。

一次 eval 的实际生命周期 ​

小a:“那一次 eval 从开始到结束,到底怎么走?”

“packages/evals/src/vitest-evals/setup.ts 的模块顶层 afterEach 是 Vitest task 结束后的 artifact 钩子,不是 Agent 的 setup。”老z打开它:

ts
afterEach(async ({ task }) => {
	const run = task.meta.harness?.run;
	if (run) await recordEvalSessionArtifact(task, run);
});

“真正一次 run 从 createPiCodingAgentHarness 提供给 vitest-evals 的 run 回调开始,进入 runPiCodingAgent:选择模型、建立临时根目录、创建 services 与 session、执行 prompt、抽取 transcript 和 usage,最后保存 session 文件并清理目录。”

text
Vitest task → harness.run → ModelRuntime.create
 → mkdtemp → createAgentSessionServices → AgentSession.prompt
 → output/events/usage → session artifact → dispose + rm

“SettingsManager.inMemory() 和‘无 extensions’检查进一步缩小配置污染,但安全边界仍取决于所启用工具和宿主权限。临时 workspace 会在 cleanup 删除;若要断言文件结果,评测必须在 output callback 中或 cleanup 前显式读取并保存目标文件。”

小a追问:“‘无 extensions’检查具体卡在哪一步?”

“在 prompt 之前。”老z指回 runPiCodingAgent,“session 创建之后、prompt 之前有一行 if (evalSession.extensionRunner.getExtensionPaths().length !== 0) throw new Error("Expected an isolated eval session to start without extensions.")。这是隔离性的断言而不是配置——它确保临时 agentDir 不会意外加载到宿主的扩展,否则同一台机器上不同测试会互相污染系统提示和工具集。这条断言失败会抛进 catch,outcome 标为失败,但 cleanup 仍照常跑。”

小a:“那 signal(AbortSignal)在这条链路里怎么传播?”

“三段都接。”老z顺着读,“runPiCodingAgent 入口先 signal?.throwIfAborted(),模型选择后第二次 throwIfAborted(),然后 signal?.addEventListener("abort", abort, { once: true }) 把 abort 桥接到 evalSession.abort()。abort 不是同步的——abort() 返回的 Promise 存进 abortPromise,在 finally 里 await。所以一次 Ctrl-C 的效果是:当前 prompt 抛出 → catch 记 outcome 失败 → 等 session 真正停下 → 再走 artifact 收集和 rm。signal 不能跳过 cleanup,否则临时目录会泄漏。”

小a:“那 usage 里的 cost 是什么时候算的?我记得模型本身有定价。”

“在 outcome 组装时按模型成本表判断,而不是无脑填。”老z指回 runPiCodingAgent,“hasPricing = [model.cost, ...(model.cost.tiers ?? [])].some(...)——只有当模型声明了非零成本才把 estimatedCostUsd 放进 metadata。零成本模型(比如 faux 的默认定义就是全 0)不会得到一个假造的 0 元账单。usage 里的 provider/model 直接取自运行时拿到的 Model,inputTokens/outputTokens/toolCalls 取自 evalSession.getSessionStats()。所以 smoke 用例能断言 result.usage.provider === process.env.PI_PROVIDER——它证明这次 run 真的用了环境变量指定的模型,而不是某个隐式兜底,这正是‘显式模型选择’在结果里的落点。”

阶段证据产物正常路径错误/边界
模型选择provider/model显式或双环境变量缺失或目录找不到即失败
会话临时 cwd、sessionprompt 完成signal 触发 abort()
结果output、events、usageassistant 正常 stop无文本或非 stop 失败
收尾session artifactdispose、递归删除 rootcleanup 错误合并报告

judge 能说明什么 ​

小a:“那 harness 到底把什么交给 judge?”

“最终输出、转录事件、token/工具使用统计及 session snapshot artifact。”老z打开 toTranscriptEvents:

ts
events.push({
	type: "tool_result",
	toolCallId: message.toolCallId,
	name: message.toolName,
	content: message.content.every((part) => part.type === "text") ? text : toJsonValue(message.content),
});

“promptAgent 还要求找到 assistant message、stopReason === "stop" 且有文本;error、aborted、length 都会使这条 smoke 风格路径失败——它适合严格的回归条件,但不覆盖‘输出被截断仍可能部分有用’的产品策略。”

小a:“那 judge 到底长什么样?能不能看一个真实的?”

“可以,extensions.eval.ts 里就有一个完整的例子。”老z打开它,“ExtensionAuthoringJudge = createJudge("ExtensionAuthoringJudge", ({ output, toolCalls }) => ...)。它检查的事全是显式可核对的事实:extensionSource 是否非空、import 里有没有 @earendil-works/pi-coding-agent、有没有混入 @mariozechner/ 或 @sinclair/typebox 这类 legacy 包、extension loader 是否报了错、有没有加载出注册了 hello 工具的扩展、toolCalls 里是否出现过 hello({ name: "Bob" }) 且返回 "Hello, Bob!"、以及最终 response 是否正好等于 "Hello, Bob!"。每个 failure 都对应一条可复查的规则,最后 score: failures.length === 0 ? 1 : 0——它是二元判定,不是给模型打分数。注意它读的 output 是 output() callback 在 run 时从 session 里现取的文件路径和扩展列表,也就是说 judge 的大部分证据来自 output 对象,只有 tool call 轨迹来自 transcript。这就是前面证据表在真实代码里的样子。”

小a:“那这类 judge 失败时,怎么定位是哪一步坏了?”

“每条 failure 都带原因字符串,metadata.rationale 里用 "; " 拼起来。”老z说,“失败时你能直接看到‘no successful hello call returned Hello, Bob!’而不是一个抽象的 0 分。配合 runId、session artifact 和 harness table 的 baseline/candidate 对比,一条失败可以回放到具体是系统提示缺文档、还是扩展没加载、还是模型没按格式回复。judge 的价值不在打分,在把‘好不好’拆成一组可核对的命题。”

小a:“那我断言模型‘理解了需求’,行吗?”

“不行。”老z说,“judge 不能从单次输出证明模型‘理解了需求’、没有遗漏边界、对所有输入安全,或在未来模型版本保持同样质量。断言能证明格式、预期文件、工具调用轨迹或一组明确事实——**不能仅凭匹配文本证明推理过程。**对开放式质量,建议保留输入、模型版本、提示、评判规则和人工抽样,并把行为回归与主观质量分开报告。”

小a追问:“那 promptAgent 自己的失败条件,算证据还是噪音?”

“是行为证据,但覆盖面很窄。”老z回到 promptAgent,“它有三条硬失败:找不到 assistant message(Agent run completed without an assistant message.)、stopReason !== "stop"(带 errorMessage 或 fallback 文本)、以及 getLastAssistantText() 返回空。这意味着 error/aborted/length 三种 stopReason 一律失败——它证明了‘本次运行严格收尾’,但不覆盖‘截断但仍部分有用’的产品策略。如果你的产品愿意接受 length 截断的输出,smoke 这条路径会误报失败;反过来,smoke 通过也不代表截断场景没问题,因为 smoke 根本没构造超长输出。”

小a:“那 transcript events 里的 tool result,能不能当成‘工具真的执行成功’的证据?”

“要分清两层。”老z指回 toTranscriptEvents,“它从 message.content 读 tool result,并且只在 message.isError 为真时挂一个 error 字段。也就是说:isError: false 不等于工具副作用成功——它只代表工具返回了一个非错误 envelope。比如写文件的工具可能返回‘已写入’但磁盘其实满了,或者远程命令返回 0 退出码但语义错了。transcript 是‘模型可见的工具调用轨迹’,不是‘外部世界真实状态’的证明。要证明文件真写了,得在 output callback 里读那个文件;要证明远程命令真执行了,得查远端审计。”

小a:“那 judge 拿到手的证据,到底分几个等级?”

“按‘可复查性’分三级,每级能下的结论不一样。”老z列了一张证据表:

证据从哪来能证明不能证明
output 返回的对象output() callback格式、字段、output.call 里的显式断言推理过程、未覆盖输入
transcript eventstoTranscriptEvents(session.messages)消息序列、tool call 参数与结果、isError 标记工具副作用真实成功、模型质量
session snapshot artifactgetSessionFile() 的整份 JSONL回放输入、复现边界、审计运行外部世界的当前状态

“三级的区别其实是‘离现场有多远’。”老z解释,“output 是你自己写的代码断言,离结果最近;transcript 是模型视野里的轨迹,isError: false 只是非错误 envelope;artifact 是原始 JSONL,能让下一次 review 的人重放同一份输入。**越靠后越能复现,但三者都不能证明‘外部世界真实状态’。**要证明副作用,得在 output 里读文件或查远端;要把分数当质量结论,还得靠人工抽样。”

要点:证据等级

它不能仅凭匹配文本证明推理过程、未覆盖输入的安全性或未来模型版本的稳定性。开放质量应保留提示、模型版本、评判规则和人工抽样;行为断言和主观评分应分开报告。

小结 ​

评测 harness 的核心职责,是为一次 Agent 运行建立可复查的证据。pi-evals 用 mkdtemp 为每次运行建独立的临时根目录,隔离 workspace、agent 和 session 路径,结束时读取 session artifact、dispose 会话、递归删除目录——这降低了运行之间的工作目录污染,但不等于运行绝对无副作用,因为模型服务、环境凭据和宿主网络仍在临时目录之外。模型选择被要求显式化:要么传入 provider 和 model,要么同时设置两个环境变量,找不到模型就直接失败,不做隐式选择。运行结束后,最终输出、转录事件、token 和工具使用统计、session snapshot artifact 都被收集起来,供断言使用。

这一章要反复强调的是证据等级的差别。faux provider 能为确定性测试提供替身,证明控制流逻辑,但不能证明真实 API 兼容或质量;真实模型 smoke 能证明一次实际链路可用,却受服务端版本、采样、限流影响,不能重复成确定性结论。断言能证明被覆盖的行为——格式对不对、预期的文件在不在、工具调用轨迹符不符合——但它不能仅凭匹配文本证明模型"理解了需求",更不能把有限样本升级成全局质量结论。开放质量要靠保留输入、模型版本、评判规则和人工抽样来逼近,行为断言和主观评分必须分开报告。

隔离与证据是一体两面。临时目录换来了干净,代价是现场即删,事后再查只能靠 artifact、runId 和 output callback 里显式留存的文件——所以 cleanup 顺序固定为"先读 JSONL 再 dispose 再 rm",三条 cleanup 各自 try/catch、错误并入 cleanupErrors,run 失败和 cleanup 失败分开报告,不会让清理问题吞掉运行本身的错误。faux 与真实模型、transcript 与外部副作用、分数与质量,每一个看似相近的概念之间都有一条明确的证据边界。评测的意义不在"跑了就过了",而在于你清楚每个断言到底站在哪一级证据上,以及它证明不了的那一部分该由谁来补。

源码走查 ​

  1. 阅读 pi-harness.ts 中 mkdtemp、createAgentSessionServices、cleanup 与 artifact 保存路径。
  2. 运行前移除 PI_PROVIDER/PI_MODEL,验证模型选择失败而非隐式选择。
  3. 阅读 smoke.eval.ts,列出其能证明和不能证明的内容。
  4. 对一个确定性断言保留 transcript 与 session artifact,检查失败时能否复现输入边界。
  5. 分别用 faux 与真实模型跑同一明确任务,记录两者各自能支持的结论。