pi evals 实践指南:基于 vitest-evals 为 Pi Coding Agent 构建模型驱动的行为评测集
pi evals 是 Pi 仓库中用于验证端到端行为的模型驱动评测子系统:它把真实的 AgentSession 适配到 vitest-evals 框架,在隔离的临时项目目录与 agent 目录中运行,并自动附上原生 Pi 会话的 JSONL 制品。读完本文,你将掌握如何配置并运行评测、编写单个与对比式 eval 套件、利用 judge 打分与 evalHarnessTable 做基线对照实验,并理解 pi-harness.ts 底层的隔离、快照与遥测机制。
一、pi evals 的定位与总体结构
pi evals 是"行为性、模型驱动"的检查:它不测纯逻辑函数,而是驱动一个完整的 Pi 编码代理去执行提示词任务,再用确定性断言或模型打分(judge)衡量结果。根据 README 的描述,其核心设计有三点:
- 真实会话:评测运行的是真正的
AgentSession,而非 mock,因此可以度量提示词、工具、技能、模型或其他 harness 配置对端到端行为的影响; - 完全隔离:每次 run 在临时目录中创建独立的工作区与 agent 目录,避免污染仓库与本地配置;
- 制品留存:每次 harness 运行都会生成原生 Pi 会话 JSONL 附件,便于事后复盘。
包的整体结构(见 packages/evals)如下:
- scripts/run-evals.mjs:评测入口脚本,负责模型选择、制品目录与 Vitest 启动;
- src/pi-harness.ts:Pi 专用 harness,把
AgentSession适配为vitest-evals的 harness; - src/vitest-evals/:制品登记(artifacts.ts)、对比表格(harness-table.ts)、自定义 reporter(reporter.ts)与汇总逻辑(summary.ts);
- src/smoke.eval.ts 与 src/extensions.eval.ts:两个内置评测示例,分别演示冒烟测试与系统提示词对比实验。
二、运行 evals:模型选择、认证与参数转发
2.1 指定默认模型
在仓库根目录运行,并通过 CLI 指定默认 provider 与 model:
npm run eval -- --provider openai --model gpt-5.6-sol
等价的写法是环境变量:
PI_PROVIDER=openai PI_MODEL=gpt-5.6-sol npm run eval
从仓库根 package.json 看,eval 脚本实际是 npm run eval --workspace=@earendil-works/pi-evals -- 的转发,最终执行的是 evals 包内的 node scripts/run-evals.mjs。
模型选择的规则(见 run-evals.mjs 的参数解析逻辑):
- CLI 值优先:
--provider/--model(同时支持--provider=xxx与--provider xxx两种形式)优先于环境变量,并成为所有"未显式选择模型"的 harness 的默认值; - 必须成对提供:CLI 或环境变量中,provider 与 model 必须同时给出,只给其一会直接报错退出;
- 允许无默认模型:只要每一个实际执行的 harness 都通过
model选项自行选择了模型,就可以不提供任何默认值; - 未提供默认值时显式清除:runner 会在无 CLI 选择时从子进程环境中删除
PI_PROVIDER/PI_MODEL(run-evals.mjs),防止残留环境变量造成误导。
认证完全复用 Pi 常规的 ModelRuntime 通道,包括 Pi 订阅凭据与各 provider 的 API-key 环境变量,评测侧不需要单独的认证配置。
2.2 向 Vitest 转发额外参数
除 --provider 与 --model 外的所有参数都会原样转发给 Vitest(run-evals.mjs 通过 spawnSync 以 vitest run --config vitest.config.ts ... 方式启动子进程)。典型用法:
# 只运行某个评测文件
npm run eval -- src/extensions.eval.ts
# 用名称模式过滤测试用例
npm run eval -- -t "creates, reloads, and uses"
评测专用配置文件为 vitest.config.ts:只收集 src/**/*.eval.ts 文件(与单测文件天然分离),关闭文件级并行(fileParallelism: false),测试超时 120 秒、hook 超时 30 秒,并挂载 vitest-evals/reporter 与 Pi 自定义 reporter。
2.3 制品目录 .eval/
每次调用都会在 stderr 打印一个被 git 忽略的 .eval/ 制品目录:
[eval] default-model=openai/gpt-5.6-sol
[eval] artifacts=/path/to/packages/evals/.eval/2026-09-04T03-43-53.123Z_<uuid>
目录默认名为 ISO 时间戳加随机 UUID(run-evals.mjs),也可用环境变量 PI_EVAL_ARTIFACT_DIR 显式指定(相对于 evals 包根目录解析)。目录内:
runs.jsonl:索引每一条已完成的 harness 运行。自定义 reporter(reporter.ts)为每条运行追加一条 JSONL 记录,包含schemaVersion、runId、所属测试的 id/文件/名称/状态、harness 名称、usage(token、成本)、timings、错误,以及持久化后的制品引用;sessions/:原生 Pi 会话 JSONL 附件,按sessions/<sha256(runId)>/session.jsonl落盘;sources/:通过recordEvalSourceArtifact(...)登记的源码类附件(例如模型生成的扩展源码),路径为sources/<sha256(runId)>/<name>。
需要特别注意 README 的提醒:这些文件可能包含提示词、模型响应、源码和工具输出,属于敏感数据。落盘时也做了权限收敛——目录以 0o700、文件以 0o600 创建(artifacts.ts)。
三、编写 eval:harness、run 输入与输出转换
3.1 最小示例:绑定 harness 的 describeEval
通用评测套件、judge、断言与规范化 trace 的写法应遵循 vitest-evals(Sentry 开源的 vitest 评测扩展)自身的约定;Pi 侧的特化点是使用 pi-harness.ts 中的 createPiCodingAgentHarness(...),且一个 harness 绑定一个 describeEval(...) 套件:
import { expect } from "vitest";
import { describeEval } from "vitest-evals";
import { createPiCodingAgentHarness } from "./pi-harness.ts";
const harness = createPiCodingAgentHarness({ noTools: "all" });
describeEval("Pi smoke", { harness }, (it) => {
it("answers a factual question", async ({ run }) => {
const result = await run("What is the capital of France? Reply with only the city name.");
expect(result.output).toBe("Paris");
});
});
仓库自带的 smoke.eval.ts 还额外验证了运行遥测:result.errors 为空、result.usage.provider / result.usage.model 与 PI_PROVIDER / PI_MODEL 环境变量一致、result.usage.totalTokens 大于 0。这些字段正是 harness 底层从会话统计中采集的(见第五节)。
3.2 createPiCodingAgentHarness 的选项
createPiCodingAgentHarness(...) 接受以下配置(类型定义见 pi-harness.ts):
| 选项 | 类型 | 说明 |
|---|---|---|
name |
string |
稳定的 harness 标识,用于报告与对比;缺省值为 "pi-coding-agent",且在同一 eval set 内必须唯一 |
model |
{ provider, id } |
可选的显式模型选择,覆盖 runner 的默认模型 |
noTools |
Pi 的工具禁用配置 | 透传给 CreateAgentSessionOptions.noTools,如 "all" 表示无工具模式 |
transformSystemPrompt |
(defaultPrompt: string) => string |
在评测开始前对完整的默认系统提示词做变换 |
output |
({ response, session }) => T | Promise<T> |
把最终响应与 AgentSession 转换为 JSON 安全的领域结果 |
显式选择模型可使对比 harness 完全独立于 runner 默认模型,这是做模型横向对比的关键:
const harness = createPiCodingAgentHarness({
name: "claude-opus-4-6",
model: { provider: "anthropic", id: "claude-opus-4-6" },
});
模型解析的优先级见 resolveModelSelection:显式 model 优先,否则回落到 PI_PROVIDER / PI_MODEL;两者都取不到时直接抛错,不会静默使用未知模型。
run 输入:单提示词或多步序列。run(...) 接受单个提示词字符串,或"提示词 + reload"步骤序列。当某一步提示词会创建或变更 Pi 资源(如生成扩展、技能)时,插入 { type: "reload" } 步骤可让会话重新加载资源:
const result = await run([
{ type: "prompt", content: "Create a Pi extension." },
{ type: "reload" },
{ type: "prompt", content: "Use the extension." },
]);
输入类型定义为 PiCodingAgentInput;harness 要求序列中至少包含一个 prompt 步骤(pi-harness.ts)。
3.3 用 output 转换暴露领域结果
output 选项用于在不改动通用 Pi 适配层的前提下,暴露场景专属的 JSON 安全行为。例如同时暴露响应、活动工具名与扩展加载错误:
const harness = createPiCodingAgentHarness({
output: ({ response, session }) => ({
response,
activeTools: session.getActiveToolNames(),
extensionErrors: session.resourceLoader.getExtensions().errors,
}),
});
断言的分层原则:应用行为断言在 result.output 上;模型与工具轨迹断言在 result.session 上,可配合 vitest-evals 提供的 toolCalls(...) 等助手。
3.4 隔离保证:源码级验证
transformSystemPrompt 有一个容易忽略的细节:它接收的是会话实际组装后的完整默认提示词。从 runPiCodingAgent 的实现看,harness 先通过 createAgentSessionServices 建立带 systemPromptOverride 的服务,创建会话后调用 options.transformSystemPrompt(evalSession.systemPrompt),要求变换结果非空(trim 后为空即抛错),随后 session.reload() 使新提示词生效。
仓库的 extensions.eval.ts 演示了两个典型变换:excludeGuidelinesAndDocumentation 在默认提示词中定位 "\nGuidelines:\n" 并截断该部分;prepareDefaultPromptOverride 在 "\nCurrent working directory: " 处截断。这正是做"系统提示词消融实验"的常用手法。
此外,harness 在每次 run 开始时强制校验 evalSession.extensionRunner.getExtensionPaths().length === 0(pi-harness.ts),确保隔离评测会话不携带任何外部扩展——临时 agent 目录保证了这一点。
四、编写对比式 eval 套件(comparative eval sets)
4.1 evalHarnessTable + describe.for
当需要把同一批输入跑在多个 harness 上(差异可以是提示词、工具、技能、模型或任意 Pi 配置),使用 harness-table.ts 的 evalHarnessTable(...) 配合 Vitest 原生的 describe.for(...):
import { describe } from "vitest";
import { createJudge, describeEval } from "vitest-evals";
import { evalHarnessTable } from "./vitest-evals/harness-table.ts";
const TargetTaskJudge = createJudge<string, string>("TargetTaskJudge", ({ output }) => ({
score: output === "expected result" ? 1 : 0,
}));
const harnessTable = evalHarnessTable(
"target skill effectiveness",
{
baseline: withoutTargetSkillHarness,
candidate: withTargetSkillHarness,
repetitions: 6,
},
);
describe.for(harnessTable)("$name repetition $repetition", ({ harness }) => {
describeEval("target skill effectiveness", { harness, judges: [TargetTaskJudge], judgeThreshold: null }, (it) => {
it("completes the target task", async ({ run }) => {
await run("Complete the target task.");
});
});
});
evalHarnessTable 接受两种形态:{ baseline, candidate, repetitions }(单一处理组)或 { baseline, candidates, repetitions }(多个处理组);repetitions 缺省为 1。返回值是一组展开后的行,每行含被包装的 harness、名称与重复序号,可直接喂给 describe.for。
baseline 与 candidate / candidates 的命名约定:candidate 表示一个处理组,candidates 表示多个;每个 candidate 只与声明的 baseline 两两比较。
4.2 judge 与 judgeThreshold: null
对比套件应当用确定性或模型驱动的 judge 记录正确性,并设置 judgeThreshold: null。这样低分只作为"观察"被记录,不会让 Vitest 调用本身失败;硬性断言只用于套件不变量与基础设施契约。特别注意:expect.soft(...) 仍然会让测试失败,它不是打分机制。
仓库中现成的对比示例是 extensions.eval.ts:ExtensionAuthoringJudge 是纯确定性 judge,依次检查生成的扩展源码是否导入了规范包 @earendil-works/pi-coding-agent、是否注册并成功调用了 hello 工具(hello({ name: "Bob" }) 返回 "Hello, Bob!")、最终响应是否精确匹配;全部通过记 1 分,否则记 0 分并在 metadata.rationale 中给出失败原因。其对比表格用 baseline system-prompt-without-docs(剥离了 Guidelines 与文档段的提示词)对照 candidate default-system-prompt(截断工作目录段的完整默认提示词),任务提示词为"创建一个 hello 扩展 → reload → 用 hello 工具问候 Bob"(extensions.eval.ts),完整覆盖了第三节的 prompt/reload 多步输入用法。
4.3 分组键与指标计算
对比报告的配对逻辑由迭代制品驱动,可以推断出以下机制:
- 分组键(groupKey):由重复序号与输入键组合而成。输入键优先取输入对象上非空的字符串
input.id,否则对严格规范化 JSON(键排序、拒绝循环引用/稀疏数组/非有限数字)求 SHA-256 哈希,见 deriveEvalGroupKey。harness 名必须稳定且在同一 eval set 内唯一,evalHarnessTable会显式校验(harness-table.ts)。 - 正确性 lift:对每个匹配的"输入 + 重复"对,reporter 从每次运行记录的 judge 平均得分计算通过率提升,得分不低于
1视为通过;lift = candidate 通过率 − baseline 通过率,以百分点计。缺失 judge 得分会作为"不完整观察"上报。 - 配对遥测:token、延迟与估算成本是独立的 candidate − baseline 配对差值汇总(含双方均值与 meanDelta);缺失遥测保持"不可用"状态而非填 0。
- 汇总结构见 summary.ts:
CorrectnessLiftSummary(baselinePassRate/candidatePassRate/lift/ 胜负平计数)与PairedMetricSummary(totalTokens/totalMs/estimatedCostUsd)。 - 若后续确实需要随机化执行顺序,README 建议直接使用 Vitest 内置的 sequence shuffling,而不是自行打乱。
自定义 reporter(EvalHarnessReporter)在 onTestRunEnd 时收集所有带迭代制品的测试观察,运行 summarizeHarnessComparisons 并打印格式化对比报告;若测试运行被中断,则打印 "Eval comparisons unavailable" 并跳过汇总。
4.4 会话快照与制品登记时机
Pi harness 会在删除临时工作区之前对原生会话 JSONL 做快照,并通过一个仅评测使用的 afterEach hook(vitest-evals/setup.ts 注册)在 reporter 运行前把快照登记到显式的 Vitest 测试任务上。源码对应 runPiCodingAgent:读取 sessionManager.getSessionFile() 并存入名为 piSessionJsonl(PI_SESSION_SNAPSHOT_ARTIFACT)的 harness artifact;随后 artifacts.ts 的 recordEvalSessionArtifact 将其作为 session.jsonl 附件(contentType 为 application/jsonl)登记到测试任务,最终由 reporter 持久化到制品目录。若模型产生了可留存的文件(如生成的扩展源码),还可在 eval 内用 recordEvalSourceArtifact(task, runId, { name, contentType, body, bodyEncoding }) 追加源码附件,extensions.eval.ts 对 hello.ts 就是如此处理的。
五、pi-harness 底层机制:从临时目录到运行遥测
以下实现细节均可在 pi-harness.ts 中逐行核对:
- 临时工作区布局(L122-L124):
mkdtemp(join(tmpdir(), "pi-eval-"))创建根目录,其下workspace/作为会话 cwd,agent/作为 agent 目录,sessions/存放会话文件; - 服务与会话创建(L131-L151):
createAgentSessionServices使用SettingsManager.inMemory(),保证评测不读写用户真实设置;会话以thinkingLevel: "off"与noTools: options.noTools创建,runIdartifact 即sessionManager.getSessionId(); - 提示词发送与失败检测:promptAgent 发送提示词后,从新增消息中定位最后一条 assistant 消息,
stopReason !== "stop"即抛出携带errorMessage的错误,空响应同样视为失败; - 规范化 trace:toTranscriptEvents 把会话消息转换为
vitest-evals的TranscriptEvent序列——user/assistant 文本、tool_call(含 id、name、规范化参数)与tool_result(含工具名、错误标记),这是 judge 与toolCalls(...)断言的数据来源; - 遥测:
usage取自evalSession.getSessionStats(),含 input/output/total tokens、cacheRead/cacheWrite、toolCalls;仅当模型(含计价层级)配置了非零价格时才附加estimatedCostUsd(L181-L201);总耗时由performance.now()差值写入timings.totalMs; - 清理与错误聚合:无论成败,快照登记后都会
session.dispose()并递归删除临时根目录;清理失败会与运行错误合并为AggregateError抛出,避免吞掉任何一侧的问题(L234-L239)。
六、依赖与运行环境
- 包名为
@earendil-works/pi-evals(私有,见 packages/evals/package.json),直接以源码方式依赖工作区内的@earendil-works/pi-ai与@earendil-works/pi-coding-agent;vitest.config.ts 还通过 alias 将@earendil-works/pi-coding-agent解析到工作区源码入口,保证评测针对的是当前仓库代码而非构建产物; - 关键第三方依赖:
vitest4.1.9 与vitest-evals0.15.0; - 仓库要求 Node.js
>=22.19.0(根 package.json); - 常用脚本:
npm run eval运行评测,npm run clean清理.eval/目录,npm test(对应vitest run --config vitest.test.config.ts)运行 evals 包自身的单元测试,如 test/pi-harness.test.ts 与 test/vitest-evals/harness-table.test.ts。
七、实践要点小结
- 每次运行都会真实调用模型并产生 token 消耗与
.eval/制品,制品可能含提示词与源码,注意不要提交到版本库; - 对比实验务必设置
judgeThreshold: null,把"低分"与"测试失败"区分开;硬性expect只留给基础设施不变量; - harness 命名要稳定、唯一——它同时是报告分组键的组成部分,改名会破坏跨次运行的可比性;
- 做提示词消融时用
transformSystemPrompt接收完整默认提示词做定点截断/替换,而不是重写整个提示词; - 需要多轮"变更 → 生效"的场景用 prompt/reload 步骤序列;需要模型间对比时给 harness 显式
model,摆脱 runner 默认值; - 深入方法论(对比实验设计、重复次数策略、可信 judge 与遥测解读)可进一步参考
vitest-evals包自身的文档及skill-eval-harness的方法论资料。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00