Evals 怎样把 Agent 行为变成可检查结果
沿着 pi-harness 的隔离装配、消息归一化、停止条件与两条 eval case,说明真实 Agent 行为如何进入 Vitest 断言,以及这种证据不能替代什么。
第 41 章把 supervisor 的边界停在“进程仍活着、协议仍可通”。要判断 Agent 做得对不对,还需要比 status=online 更贴近行为的证据。packages/evals 处理的正是这件事:它不启动 TUI,也不经过 pi-server,而是直接创建 AgentSession,执行真实 prompt,再把会话里的文本、工具轨迹和用量转换成 vitest-evals 可以检查的结果。
先控制预期。固定标签中这个 package 标记为 private: true,源码下只有 smoke.eval.ts 和 extensions.eval.ts 两个 eval 文件,各包含一条 case。它们验证了一条无工具问答和一条“创建扩展、reload、调用新工具”的完整行为路径。这个范围足以展示 eval harness 怎样接入 Pi,不足以代表模型、Provider、内置工具、压缩、重试和 session navigation 都已有行为覆盖。
Harness 先把环境收窄,再调用真实 AgentSession
getRequiredModelSelection() 同时要求 PI_PROVIDER 与 PI_MODEL。少一个就直接报错,给出的 provider/model 在 ModelRuntime 中找不到也报错。Harness 没有“随便找一个可用模型”的 fallback,因此测试报告里的模型身份可以回到明确输入,而不是机器当时碰巧可用的 catalog 项。
每次 run 都创建一个 pi-eval-* 临时根目录,下面再分 workspace 与 agent。createAgentSessionServices() 使用这两个路径和新的 ModelRuntime,settings 走内存;createAgentSessionFromServices() 再使用 SessionManager.inMemory(cwd)、指定模型和关闭 thinking 的设置创建 session。随后还有一条硬断言:初始 ExtensionRunner 的 extension path 数量必须是 0。
这份隔离有明确含义。eval 不复用用户当前项目目录,不把 session JSONL 写回真实工作区,也不应该从临时 agentDir 发现已有扩展。它仍然可能调用真实 Provider,extension case 也保留了能写临时 workspace 的工具;所以“临时目录”不是 mock Agent,“内存 session”也不是 mock 模型。
输入可以是一条字符串,也可以是 prompt/reload step 数组。Harness 顺序执行每一步:prompt 进入 AgentSession.prompt(),reload 直接调用 AgentSession.reload()。这让 extension case 能在第一轮要求 Agent 写出扩展文件,第二步刷新 ResourceLoader 与 extension runtime,第三轮再使用新注册工具。场景编排没有塞进 production Agent 代码,只存在于 eval input。
flowchart LR
accTitle: Pi eval 从真实会话提取可断言结果
accDescr: Runner 固定 provider 和 model,Harness 在临时目录创建 AgentSession,顺序执行 prompt 与 reload,然后把 transcript 和 usage 交给 Vitest assertions。
SELECT["PI_PROVIDER + PI_MODEL"] --> TEMP["temporary cwd + agentDir"]
TEMP --> SERVICES["in-memory settings + session manager"]
SERVICES --> SESSION["real AgentSession"]
SESSION --> STEPS["prompt / reload steps"]
STEPS --> MESSAGES["session messages + stats"]
MESSAGES --> NORMALIZE["transcript events + usage"]
NORMALIZE --> ASSERT["Vitest eval assertions"]
ASSERT --> CLEAN["dispose session + remove temp root"]
“这一轮完成”有三条最低条件
await session.prompt() 返回后,Harness 只查看本次新增消息,倒序寻找最后一条 assistant message。找不到就失败;stopReason 不是 stop 也失败;getLastAssistantText() 为空仍然失败。因 length、aborted、error 等原因停止的回答不会伪装成一次成功 eval,即使会话里已经出现了部分文本。
成功输出也不只是一段 final text。toTranscriptEvents() 把 user/assistant 文本转成 message event,把 assistant content 里的每个 toolCall 转成带 id、name 和规范化 arguments 的 tool_call,再把 toolResult 转成 tool_result,保留 toolCallId、工具名、内容与错误。run 结束时又读取 session stats,记录 provider、model、输入/输出/总 token 与 tool call 数量。
这里的“可检查”包含两层。case 可以直接断言输出 selector 返回的业务状态,也可以通过规范化 transcript 检查模型是否真的调用工具、参数是什么、工具是否成功。只看最终一句 Hello, Bob!,无法区分模型调用了新工具还是自己复述了预期字符串;tool trace 补上了这块证据。
两条 case 分别证明了什么
smoke case 把 noTools 设为 all,向真实模型询问法国首都,并要求只输出城市名。断言只有五项:输出 trim 后等于 Paris、errors 为空、usage 中 provider/model 等于环境选择、totalTokens 大于 0。它证明所选 Provider 的基本 end-to-end 文本路径在这次运行中成立,没有覆盖任何工具行为。
extension case 更接近 Agent 行为。第一轮让模型创建接受 name 的 hello tool,中间 reload,第二轮要求用它问候 Bob。输出 selector 同时取最终 response、extension paths 和全部 tool definitions;断言要求 response 精确等于 Hello, Bob!、只加载一个 extension、工具定义包含 hello,并且 transcript 中存在 name=hello、arguments 为 {name: "Bob"}、status=ok、result 为目标问候的工具调用。
这个 case 能证明一次真实轨迹满足预期,但它不是确定性单元证明。模型可能没有按要求创建正确文件,可能输出额外文字,也可能在同一 provider/model 的不同采样中选择不同步骤。网络、凭据、Provider 限流和模型版本也会影响结果。精确断言让漂移尽早暴露,却不会消除漂移来源。
Runner 固定身份,不承诺稳定答案
run-evals.mjs 解析 --provider 与 --model,要求 CLI 选择时两者同时出现,也允许从环境变量读取;剩余参数原样转发给 Vitest。它把最终选择重新写进子进程环境,并在 stderr 打印 provider/model。没有有效选择时,runner 在启动 Vitest 前退出。
Vitest config 把 src/**/*.eval.ts 作为唯一 include,关闭 file parallelism,把 test timeout 设为 120 秒、hook timeout 设为 30 秒,并使用 vitest-evals/reporter。串行文件减少了多个真实 Agent eval 同时争抢资源的变量,但 case 内的模型行为仍然是真实外部依赖。timeout 只规定最多等多久,不保证在某个时点得到同一答案。
先盘点范围,再决定是否花一次真实调用
第一段命令只读固定 tag,可以确认 package 身份、case 数量和断言位置,不需要安装依赖或提供凭据:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" ls-tree -r --name-only v0.83.0 -- packages/evals/src |
grep '\.eval\.ts$'
git -C "$repo" grep -n 'describeEval\|it(' v0.83.0 -- \
packages/evals/src/smoke.eval.ts \
packages/evals/src/extensions.eval.ts
要运行真实行为评测,需要在完整 checkout 安装仓库依赖,并准备所选 Provider 对应的有效凭据。先跑 smoke 可以缩小费用和失败面,-t 会由 runner 转发给 Vitest:
npm run eval -- \
--provider openai-codex \
--model gpt-5.4 \
-t "capital of France"
一次通过应该记录 provider、model、执行时间与 commit;一次失败也要先区分 harness 隔离、Provider 请求、模型行为和断言差异。不要通过更换未记录的 fallback 模型把失败“跑绿”,否则结果已经不是同一个实验。
每次 run 的最后还有一条常被忽略的边界:Harness 无论成功或失败都会尝试 dispose session 并递归删除临时根目录;主流程与 cleanup 同时失败时会用 AggregateError 保留两边错误。行为结果和实验清理因此分别结算,不会因为主断言已经失败就静默留下临时工作区。
第七部到这里完成了同一套 AgentSession 的五种宿主视角:SDK 自己接管事件,print/JSON 做一次性投影,RPC 暴露长驻控制协议,pi-server 监管 RPC 子进程,Evals 再把行为轨迹变成断言输入。下一部回到用户真正看见的终端,从第 43 章的 TUI component、focus 与 overlay 开始,检查协议事件怎样变成可操作界面。