首页
/ pi evals 实践指南:基于 vitest-evals 为 Pi Coding Agent 构建模型驱动的行为评测集

pi evals 实践指南:基于 vitest-evals 为 Pi Coding Agent 构建模型驱动的行为评测集

2026-09-04 16:08:32作者:农烁颖Land

pi evals 是 Pi 仓库中用于验证端到端行为的模型驱动评测子系统:它把真实的 AgentSession 适配到 vitest-evals 框架,在隔离的临时项目目录与 agent 目录中运行,并自动附上原生 Pi 会话的 JSONL 制品。读完本文,你将掌握如何配置并运行评测、编写单个与对比式 eval 套件、利用 judge 打分与 evalHarnessTable 做基线对照实验,并理解 pi-harness.ts 底层的隔离、快照与遥测机制。

一、pi evals 的定位与总体结构

pi evals 是"行为性、模型驱动"的检查:它不测纯逻辑函数,而是驱动一个完整的 Pi 编码代理去执行提示词任务,再用确定性断言或模型打分(judge)衡量结果。根据 README 的描述,其核心设计有三点:

  1. 真实会话:评测运行的是真正的 AgentSession,而非 mock,因此可以度量提示词、工具、技能、模型或其他 harness 配置对端到端行为的影响;
  2. 完全隔离:每次 run 在临时目录中创建独立的工作区与 agent 目录,避免污染仓库与本地配置;
  3. 制品留存:每次 harness 运行都会生成原生 Pi 会话 JSONL 附件,便于事后复盘。

包的整体结构(见 packages/evals)如下:

二、运行 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_MODELrun-evals.mjs),防止残留环境变量造成误导。

认证完全复用 Pi 常规的 ModelRuntime 通道,包括 Pi 订阅凭据与各 provider 的 API-key 环境变量,评测侧不需要单独的认证配置。

2.2 向 Vitest 转发额外参数

--provider--model 外的所有参数都会原样转发给 Vitest(run-evals.mjs 通过 spawnSyncvitest 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 记录,包含 schemaVersionrunId、所属测试的 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.modelPI_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 === 0pi-harness.ts),确保隔离评测会话不携带任何外部扩展——临时 agent 目录保证了这一点。

四、编写对比式 eval 套件(comparative eval sets)

4.1 evalHarnessTable + describe.for

当需要把同一批输入跑在多个 harness 上(差异可以是提示词、工具、技能、模型或任意 Pi 配置),使用 harness-table.tsevalHarnessTable(...) 配合 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

baselinecandidate / candidates 的命名约定:candidate 表示一个处理组,candidates 表示多个;每个 candidate 只与声明的 baseline 两两比较

4.2 judge 与 judgeThreshold: null

对比套件应当用确定性或模型驱动的 judge 记录正确性,并设置 judgeThreshold: null。这样低分只作为"观察"被记录,不会让 Vitest 调用本身失败;硬性断言只用于套件不变量与基础设施契约。特别注意:expect.soft(...) 仍然会让测试失败,它不是打分机制。

仓库中现成的对比示例是 extensions.eval.tsExtensionAuthoringJudge 是纯确定性 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.tsCorrectnessLiftSummarybaselinePassRate / candidatePassRate / lift / 胜负平计数)与 PairedMetricSummarytotalTokens / 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() 并存入名为 piSessionJsonlPI_SESSION_SNAPSHOT_ARTIFACT)的 harness artifact;随后 artifacts.tsrecordEvalSessionArtifact 将其作为 session.jsonl 附件(contentType 为 application/jsonl)登记到测试任务,最终由 reporter 持久化到制品目录。若模型产生了可留存的文件(如生成的扩展源码),还可在 eval 内用 recordEvalSourceArtifact(task, runId, { name, contentType, body, bodyEncoding }) 追加源码附件,extensions.eval.tshello.ts 就是如此处理的。

五、pi-harness 底层机制:从临时目录到运行遥测

以下实现细节均可在 pi-harness.ts 中逐行核对:

  1. 临时工作区布局L122-L124):mkdtemp(join(tmpdir(), "pi-eval-")) 创建根目录,其下 workspace/ 作为会话 cwd,agent/ 作为 agent 目录,sessions/ 存放会话文件;
  2. 服务与会话创建L131-L151):createAgentSessionServices 使用 SettingsManager.inMemory(),保证评测不读写用户真实设置;会话以 thinkingLevel: "off"noTools: options.noTools 创建,runId artifact 即 sessionManager.getSessionId()
  3. 提示词发送与失败检测promptAgent 发送提示词后,从新增消息中定位最后一条 assistant 消息,stopReason !== "stop" 即抛出携带 errorMessage 的错误,空响应同样视为失败;
  4. 规范化 tracetoTranscriptEvents 把会话消息转换为 vitest-evalsTranscriptEvent 序列——user/assistant 文本、tool_call(含 id、name、规范化参数)与 tool_result(含工具名、错误标记),这是 judge 与 toolCalls(...) 断言的数据来源;
  5. 遥测usage 取自 evalSession.getSessionStats(),含 input/output/total tokens、cacheRead/cacheWrite、toolCalls;仅当模型(含计价层级)配置了非零价格时才附加 estimatedCostUsdL181-L201);总耗时由 performance.now() 差值写入 timings.totalMs
  6. 清理与错误聚合:无论成败,快照登记后都会 session.dispose() 并递归删除临时根目录;清理失败会与运行错误合并为 AggregateError 抛出,避免吞掉任何一侧的问题(L234-L239)。

六、依赖与运行环境

  • 包名为 @earendil-works/pi-evals(私有,见 packages/evals/package.json),直接以源码方式依赖工作区内的 @earendil-works/pi-ai@earendil-works/pi-coding-agentvitest.config.ts 还通过 alias 将 @earendil-works/pi-coding-agent 解析到工作区源码入口,保证评测针对的是当前仓库代码而非构建产物;
  • 关键第三方依赖:vitest 4.1.9 与 vitest-evals 0.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.tstest/vitest-evals/harness-table.test.ts

七、实践要点小结

  1. 每次运行都会真实调用模型并产生 token 消耗与 .eval/ 制品,制品可能含提示词与源码,注意不要提交到版本库;
  2. 对比实验务必设置 judgeThreshold: null,把"低分"与"测试失败"区分开;硬性 expect 只留给基础设施不变量;
  3. harness 命名要稳定、唯一——它同时是报告分组键的组成部分,改名会破坏跨次运行的可比性;
  4. 做提示词消融时用 transformSystemPrompt 接收完整默认提示词做定点截断/替换,而不是重写整个提示词;
  5. 需要多轮"变更 → 生效"的场景用 prompt/reload 步骤序列;需要模型间对比时给 harness 显式 model,摆脱 runner 默认值;
  6. 深入方法论(对比实验设计、重复次数策略、可信 judge 与遥测解读)可进一步参考 vitest-evals 包自身的文档及 skill-eval-harness 的方法论资料。
登录后查看全文
热门项目推荐
相关项目推荐