源码篇第11章 每次考试换新考场——pi-evals

本章导读: 源码路线第 ⑩ 站(评测层)。看
pi-evals包,读src/harness.ts、src/plan.ts、src/report.ts和evals/下的用例。这一站要泼一盆冷水:“跑通了”不等于“改对了”,看看评测 harness 怎么用临时环境和 artifact 留下可复查的证据,faux 测试和真实模型测试的证据等级差在哪,以及“有文档和没文档”的对照实验怎样避免把偶然当成结论。
小A终于把自己的 Agent 改到了能跑,长舒一口气:“跑通了!这次应该没问题了吧?”
“跑通一次,和改得好不好,是两回事。”老Z说,“协议和远程状态可以被测试,但‘模型改得好不好’不能只靠一次成功运行。这一章我们跟着 pi-evals 的 harness,看环境怎么隔离、模型怎么选择、judge 到底能证明什么。别把评测分数当普适能力排名。”
本章源码证据均来自上游 pi 标签 v1.0.0(提交 a13d35a7,packages/evals/,包版本 1.0.0,private: true)。v1.0.0 重新组织了这个包:运行器代码在 src/,用例和夹具在 evals/,镜像构建文件在 docker/。README.md 把用例分成两类:*.docs.eval.ts 是文档增益评测,其余 *.eval.ts 是在本机跑的普通 host 评测。
harness 建立一次隔离运行
“先看运行环境。”老Z打开 packages/evals/src/harness.ts:“createPiCodingAgentHarness 用 vitest-evals/harness 的 createHarness 包出一个 harness,run 回调进入 runPiCodingAgent。每次运行都用 mkdtemp(join(tmpdir(), "pi-eval-")) 建临时根目录,再在里面放独立的 workspace、home、agent 目录和 session 目录。”
// packages/evals/src/harness.ts,runPiCodingAgent(v1.0.0,节选)
const root = await mkdtemp(join(tmpdir(), "pi-eval-"));
const workspace = join(root, "workspace");
const isolatedHome = join(root, "home");
const agentDir = join(isolatedHome, ".pi", "agent");
// ……
const restoreEnvironment = applyIsolatedEnvironment(isolatedHome, agentDir);比喻:每次考试换新考场
临时 cwd 像每次换新考场,上一次的草稿纸不会留在桌上。
“applyIsolatedEnvironment 把 HOME、USERPROFILE 和 PI_CODING_AGENT_DIR 临时指向这个根目录,并先删掉所有 PI_EVAL_ 开头的环境变量,运行结束再全部还原。”老Z说,“用例还能用 workspaceFiles 预先放入夹具文件;seedWorkspace 拒绝绝对路径和跳出 workspace 的相对路径。”
小A猜:“那临时目录把模型服务也关进去了吧?测试期间什么都不碰外面?”
“模型服务恰好在名单之外。”老Z指着 try 块,“它先从宿主 agent 目录的 auth.json 读出所选 provider 的已存凭据,放进一个 InMemoryCredentialStore,再用它 ModelRuntime.create。也就是说,临时目录隔离的是工作区、配置和会话;凭据是有意从宿主带进来的,模型请求照样走真实网络。这套隔离压住的是测试之间共享工作目录的污染,不等于运行绝对无副作用。”
“还有一道更严的隔离只在容器里启用。”老Z接着说,“设置了 PI_EVAL_SANDBOX_UID 和 PI_EVAL_SANDBOX_GID 时,runPiCodingAgent 先删掉宿主的 auth.json,把凭据环境变量从 process.env 里藏起来,然后 enterToolSandbox:要求进程以 root 启动,把临时根目录 chown 给沙箱用户,再 setgid/setuid 永久降权,最后自检降权后读不到 Vitest 转译过的用例模块。注释和 README 的说法一致:降权之后,用例定义、评判辅助代码和夹具对 Agent 不可读。”
小A:“cleanup 呢?按什么顺序删?”
“顺序固定,而且要和 artifact 收集的顺序对上。”老Z顺着源码往下读。finally 块里先读 sessionManager.getSessionFile():没有会话文件本身就算一条 cleanup 错误,‘Pi eval produced no session file.’;有就把整份 JSONL 通过 setArtifact(PI_SESSION_SNAPSHOT_ARTIFACT, ...) 存下。之后才 session?.dispose(),再 rm(root, { recursive: true, force: true }),最后还原环境变量和藏起来的凭据变量。artifact 读取必须排在 rm 之前,JSONL 一删就再读不到了。每一步各自 try/catch,错误全部推进 cleanupErrors,即使 dispose 抛错,rm 仍会执行。
try 块结束
→ 读 session JSONL 注入 artifact(没有文件也记一条错误)
→ session.dispose()
→ rm(root, recursive)
→ 还原环境变量与凭据变量
→ 合并 runError 与 cleanupErrors,决定 throw 或返回“runId 在会话创建后立刻通过 setArtifact("runId", sessionManager.getSessionId()) 记下。”老Z补充,“失败的 run 也会留下 runId 线索,不会因为 rm 就完全断链。”
小A:“cleanup 出错和 run 失败撞到一起,报告怎么合并?”
“分两种,别混。”老Z回到尾部,“run 成功但 cleanup 出错:一个错误直接 throw,多个包成 AggregateError("Agent cleanup failed.")。run 失败且 cleanup 也出错:和 run 的错误一起包成 AggregateError("Agent run failed and cleanup also failed.")。如果失败前已经收集到 transcript 和 usage,还会用 attachHarnessRunToError 把这份部分结果挂到错误上。run 失败的原因永远是第一手证据,cleanup 失败只是附加信息。”
createPiCodingAgentHarness → createHarness(vitest-evals)
│ run 回调
v
runPiCodingAgent
① signal.throwIfAborted / resolveModelSelection / mkdtemp / 隔离环境变量
② 宿主凭据 → InMemoryCredentialStore → ModelRuntime.create → getModel / getAuth
③ (容器内)删 auth.json、藏凭据变量
④ createAgentSessionServices → SessionManager.create → setArtifact(runId)
⑤ createAgentSessionFromServices → 检查没有意外扩展
⑥ (容器内)enterToolSandbox 降权
⑦ 逐步 prompt / reload;signal abort 桥接到 session.abort()
⑧ toTranscriptEvents → usage → verifySystemPrompt → output()
⑨ finally: 读 JSONL → dispose → rm(root) → 还原环境
⑩ 合并错误,或返回结果与 timingsfake 与真实模型的边界
小A:“测试用哪个模型?随便挑一个?”
“不能随便。”老Z说,“resolveModelSelection 要求显式模型,或同时设置 PI_PROVIDER、PI_MODEL;之后找不到模型、或模型没有配置认证,都直接失败。”
// packages/evals/src/harness.ts,resolveModelSelection(v1.0.0)
const provider = (explicitModel?.provider ?? environment.PI_PROVIDER)?.trim();
const id = (explicitModel?.id ?? environment.PI_MODEL)?.trim();
if (!provider || !id) {
throw new Error("Select a harness model explicitly or set both PI_PROVIDER and PI_MODEL as defaults.");
}
return { provider, id };小A抢着接话:“那 faux 呢?我猜就是个假模型,装装样子,不用真跑?”
“不跑。”老Z翻开 packages/ai/src/providers/faux.ts,“createFauxCore 维护一个 pendingResponses 队列,stream 被调用时 shift() 出一条预先装好的响应,再用 streamWithDeltas 切成小块模拟流。withUsageEstimate 按字符长度除以 4 估算 token,所以 usage 是估算的,不是真计费。队列空了,它会产一条错误消息,文本是 ‘No more faux responses queued’。faux 测的是控制流和事件序列,模型推理它碰都没碰。”
小A:“那为什么 harness 不干脆默认用 faux?”
“因为 faux 跑过不代表真模型跑过。”老Z说,“resolveModelSelection 不接受隐式默认,它宁可让测试失败,也不让你误以为测了真模型。faux 用在 pi-coding-agent 的单元测试里,例如 test/suite/harness.ts;packages/evals 里没有任何 faux 引用,走的都是 ModelRuntime.create() 加真实 getModel(provider, id)。两条路径对应两种证据等级:单元测试证明控制流,pi-evals 证明真实链路。”
要点:两种证据等级
真实模型会受服务端版本、采样、限流和价格影响;faux provider 可为确定性单测提供替身,但不能证明真实 API 兼容或质量。反过来,真实模型 smoke 能证明一次实际链路可用,却不能证明所有 prompt 的正确性。
一次 prompt 怎样算“完成”
小A:“promptAgent 判成功的标准是什么?”
“三条硬失败。”老Z打开 promptAgent,“run 结束找不到新的 assistant message,抛 ‘Agent run completed without an assistant message.’;stopReason 既不是 stop 也不是 toolUse,带上 errorMessage 或兜底文本抛错;stop 结尾却没有文本,抛 ‘Agent run produced no assistant text.’。error、aborted、length 一律让这次 run 失败。”
小A追问:“这么严?产品本来就想接受被截断的输出呢?”
“那这条路径会误报失败。”老Z说,“这几条证明的是‘本次运行严格收尾’,适合当回归条件;‘输出被截断但仍部分有用’是另一套产品策略,它不覆盖。”
“还有两道运行前后的自检。”老Z接着说,“会话建好之后,getExtensionPaths() 里除了 harness 自己注入的内联扩展,不能有别的扩展,否则抛 ‘Isolated eval loaded unexpected extensions’。运行结束后 verifySystemPrompt 检查 system prompt 里还有 <rules> 段,并且 <docs> 段的有无和本次变体相符。前者防宿主扩展混进来,后者防对照实验的两个变体其实没分开。”
小A又想到中断的情形:“signal 呢?”
“入口先 signal?.throwIfAborted(),建 services 后再查一次,然后把 abort 桥接到 session.abort()。”老Z说,“abort() 返回的 Promise 存进 abortPromise,在内层 finally 里 await。所以中断的效果是当前 prompt 抛出,等会话真正停下,再走 artifact 收集和 rm;signal 不能跳过 cleanup。”
小A:“usage 里的 cost 是什么时候算的?”
“按模型成本表判断,不是无脑填。”老Z说,“只有模型声明了非零成本,estimatedCostUsd 才放进 metadata;零成本模型不会得到一个假造的 0 元账单。provider/model 取自运行时拿到的 Model,token 和工具调用数取自 session.getSessionStats()。evals/smoke.eval.ts 因此能断言 result.usage 的 provider 和 model 等于环境变量,证明这次 run 真的用了指定的模型。”
| 阶段 | 证据产物 | 正常路径 | 错误/边界 |
|---|---|---|---|
| 模型选择 | provider/model | 显式或双环境变量 | 缺失、目录找不到、无认证即失败 |
| 会话 | 临时 cwd、session、runId | prompt 完成 | 意外扩展、signal 触发 abort() |
| 结果 | output、events、usage、system prompt 摘要 | stop 或 toolUse 结尾 | 无 assistant、无文本、非预期 stop reason |
| 收尾 | session artifact | dispose、递归删除 root | 无会话文件也算错误;cleanup 错误合并报告 |
对照实验:有文档和没文档
小A:“*.docs.eval.ts 是什么?”
“这是 v1.0.0 新加的一类评测,问的问题是:给 Agent 看 pi 自己的文档,能不能提高完成率。”老Z说,“createPiDocumentationEvalHarness 只能在容器沙箱里用,PI_EVAL_CONTAINER 不是 1 或没有沙箱身份就直接抛错。变体由 PI_EVAL_VARIANT 决定:without_docs 用 excludePiDocumentation 从默认 system prompt 里删掉 <docs> 段,with_docs 保持原样。工具默认只给 read、write、edit、grep、find、ls,不给 shell。”
“src/plan.ts 把用例展开成任务。”老Z打开 createTaskPlan,“每个用例、每个重复轮次各产生两个任务,一个变体一个;奇数轮先跑 without_docs,偶数轮先跑 with_docs,README 说这是为了减少顺序偏差。默认每个变体只跑一次,要测稳定性得显式加 --runs-per-variant。”
“src/report.ts 再把同一用例、同一模型、同一轮次的两条结果配成一对。”老Z说,“README 写得很硬:两边都恰好得到一个分数,这一对才计入通过率增益;缺失、重复、跳过、未评分或出错,这一对就被阻断。只要有一对被阻断,这个评测集的总体通过率就不发布,进程以非零退出。报告还会标出 no-lift、negative-delta、对照或实验组饱和、flaky 这些情况。”
“一次重复证明不了稳定性。”老Z强调,“README 原话就是 ‘One repetition cannot establish stability.’ 对照实验的设计,是为了让你不把一次偶然的通过当成文档有用的证据。”
README 还如实写了一条边界:模型请求仍需要容器有网络,所以光靠 Docker 证明不了 Agent 写出的任意代码从不访问网络。
judge 能说明什么
小A:“harness 到底把什么交给 judge?”
“最终输出、转录事件、token/工具使用统计,以及 session snapshot artifact。”老Z展开 toTranscriptEvents:
// packages/evals/src/harness.ts,toTranscriptEvents(v1.0.0,节选)
events.push({
type: "tool_result",
toolCallId: message.toolCallId,
name: message.toolName,
content: message.content.every((part) => part.type === "text") ? text : toJsonValue(message.content),
...(message.isError ? { error: { message: text || "Tool failed" } } : {}),
});小A想看个实物:“judge 长什么样?”
“看 evals/extensions.docs.eval.ts。”老Z翻到它,“它让 Agent 先写一个带 hello 工具的扩展,reload,再用这个工具问候 Bob。output 回调在运行结束时从会话里现取四个事实:最终回答、扩展加载错误、hello 工具是否已加载、最后一次成功的 hello 工具结果。然后用两个现成的 judge:StructuredOutputJudge 要求这四个字段严格等于期望值,ToolCallJudge 要求轨迹里出现过 hello({ name: "Bob" })。”
“注意 judgeThreshold: null。”老Z说,“README 的说法是:对比评分时,低分是数据,不是基础设施故障;Vitest 断言只留给‘用例本身坏了’的情况。所以 judge 的结果进报告参与配对,而不是让测试挂掉。”
小A:“那我能不能断言模型‘理解了需求’?”
“不行。”老Z说,“单次输出证明不了模型‘理解了需求’、没有遗漏边界、对所有输入安全,也证明不了未来模型版本保持同样质量。断言能证明的是格式、预期文件、工具调用轨迹或一组明确事实。对开放式质量,要保留输入、模型版本、提示、评判规则和人工抽样,并把行为回归与主观质量分开报告。”
小A:“transcript 里的 tool result,能当成‘工具真的执行成功’的证据吗?”
“要分清两层。”老Z指回 toTranscriptEvents,“它只在 message.isError 为真时挂 error 字段。isError: false 不等于工具副作用成功,只代表工具返回了一个非错误结果。transcript 是‘模型可见的工具调用轨迹’,不是‘外部世界真实状态’的证明。要证明文件真写了,得在 output 回调里读那个文件,extensions.docs.eval.ts 去查扩展是否真的加载,就是这个思路。”
| 证据 | 从哪来 | 能证明 | 不能证明 |
|---|---|---|---|
output 返回的对象 | output() 回调 | 格式、字段、回调里现查的事实 | 推理过程、未覆盖输入 |
| transcript events | toTranscriptEvents(session.messages) | 消息序列、tool call 参数与结果、isError 标记 | 工具副作用真实成功、模型质量 |
| session snapshot artifact | getSessionFile() 的整份 JSONL | 回放输入、复现边界、审计运行 | 外部世界的当前状态 |
| 配对报告 | report.ts | 两个变体在同一批任务上的通过率差 | 少量重复下的稳定性 |
“区别在于离现场有多远。”老Z解释,“output 是你自己写的代码断言,离结果最近;transcript 是模型视野里的轨迹;artifact 是原始 JSONL,越靠后越能复现;配对报告是在这些之上做的统计。但它们都不能证明‘外部世界真实状态’,要把分数当质量结论,还得靠人工抽样。”
小结
评测 harness 的核心职责,是为一次 Agent 运行建立可复查的证据。pi-evals 用 mkdtemp 为每次运行建独立的临时根目录,把 workspace、home、agent 目录和会话都放进去,并临时改写相关环境变量;结束时先保存会话 JSONL,再 dispose、递归删除、还原环境。凭据是有意从宿主带进来的,模型请求走真实网络,所以这降低的是运行之间的污染,不等于无副作用。容器模式更进一步:藏起凭据、降权运行,让用例和评判代码对 Agent 不可读。模型选择必须显式,找不到模型或认证就失败,不做隐式选择。
这一章反复强调证据等级的差别。faux provider 能为确定性测试提供替身,证明控制流,但不能证明真实 API 兼容或质量;真实模型 smoke 能证明一次实际链路可用,却受服务端版本、采样、限流影响。文档增益评测用成对的任务、交替的顺序和“有一对被阻断就不发布总体结果”的规则,防止把偶然当成结论,但 README 也明说一次重复证明不了稳定性。
隔离与证据是一体两面。临时目录换来了干净,代价是现场即删,事后再查只能靠 artifact、runId 和 output 回调里显式留存的事实。所以 cleanup 顺序固定为“先读 JSONL,再 dispose,再 rm”,每一步各自 try/catch,run 失败和 cleanup 失败分开报告。faux 与真实模型、transcript 与外部副作用、分数与质量,两两之间都有一条明确的证据边界。
源码走查
- 阅读
src/harness.ts中mkdtemp、applyIsolatedEnvironment、cleanup 与 artifact 保存路径,写出环境变量在哪一步被还原。 - 运行前移除
PI_PROVIDER/PI_MODEL,验证模型选择失败而非隐式选择。 - 阅读
promptAgent,列出会让一次 run 失败的三种情况。 - 阅读
evals/smoke.eval.ts,列出其能证明和不能证明的内容。 - 阅读
src/plan.ts的createTaskPlan,写出--runs-per-variant 2时一个用例展开出的四个任务及其顺序。 - 在
src/report.ts找到resolvePair与publishHeadline,说明什么情况下总体通过率会被隐藏。
