开源编程 Agent pi 的评测包:它怎么给一个 Agent 建回归

2026-07-29

本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。

pi 的评测包里最值得抄走的东西不是那两个用例,而是它把「一次 Agent 运行」压成了一个固定形状的结果对象:最终文本、完整事件流、用量统计。 有了这个形状,断言才写得下去;没有这个形状,你写的所有 Agent 测试都会退化成对着一大段自然语言做字符串匹配。整个包一共三个源文件、一个启动脚本、一个配置,加起来两百多行,但每一处都在回答同一个问题:怎么让一个天生不确定的东西产生可比较的输出。

pi 是 earendil-works 开源的编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。下面说的所有路径都在这个仓库里,你可以边读边对照。

站内已经有三篇讲通用方法论的文章:怎么搭一套 Agent 评测集讲评测集从哪来,Agent 评测方法讲打分与判定,Agent 回归测试怎么做讲回归的组织方式。这篇不重复那些,它只做一件事:把一个真实项目的评测代码逐行读开,看方法论落到工程里长什么样、哪些地方它选择了不做。

一、Agent 回归难在哪,这个包针对的是哪一段

给普通函数写回归很直接:输入固定,输出固定,比一下就完了。Agent 不是这样。同一句提示,模型两次生成的措辞可能不同;跑到一半它会去读文件、开子进程、写盘,环境被改动之后下一次运行的起点就变了。

所以 Agent 回归的第一道坎不是「怎么打分」,而是「怎么让两次运行处在可比的条件下」,以及「怎么把运行过程变成结构化的东西」。pi 的 evals 包整个精力都花在这两件事上。它的包名是 @earendil-works/pi-evals,在 package.json 里标了 "private": true,不发布,只在仓库内部用。底层跑的是 vitest-evals

关键在于它接的位置。src/pi-harness.ts 里 import 的是 @earendil-works/pi-coding-agent 导出的 createAgentSessionServicescreateAgentSessionFromServicesModelRuntimeSessionManagerSettingsManager,也就是说它绕过了 CLI,直接拿编程 Agent 的会话对象来跑。README 里那句「adapts Pi’s AgentSession directly to vitest-evals」说的就是这个。这个选择很重要:测的是内核行为,不是终端界面的渲染。

二、一次运行被压成什么形状

runPiCodingAgent 是整个包的心脏。它接受的输入有两种形态,类型定义写得很清楚:

type PiCodingAgentInput = string | Array<{ type: "prompt"; content: string } | { type: "reload" }>;

要么是一句提示词,要么是一串步骤——提示、重载、再提示。这个设计把「多轮场景」变成了可序列化的数据,而不是一段测试代码里的过程式调用。

跑完之后它组装出来的结果长这样:output 是最终的助手文本(或者你自定义的选择器产物),events 是完整事件流,usage 里放 provider、model、inputTokensoutputTokenstotalTokenstoolCalls。事件流由 toTranscriptEvents 生成,它遍历 session.messages,把用户消息和助手消息转成 message 事件,把助手消息里 part.type === "toolCall" 的部分单独抽成 tool_call 事件,把 toolResult 角色的消息转成 tool_result 事件;工具结果如果各部分都是文本就存文本,否则走 toJsonValue;如果 message.isError 为真,再补一个 error 字段。

这一步是所有断言的地基。有了 tool_call 事件,你才能断言「它确实调了那个工具、参数是这个」,而不是从一段散文里猜它干了什么。工具返回值的结构化在运行时是为了让模型少猜,在测试时则是为了让断言有地方落脚——同一件事的两面。

还有一层容易被忽略的校验,在 promptAgent 里:

const previousMessageCount = session.messages.length;
await session.prompt(input);
const assistant = session.messages
	.slice(previousMessageCount)
	.reverse()
	.find((message) => message.role === "assistant");
if (!assistant) throw new Error("Agent run completed without an assistant message.");
if (assistant.stopReason !== "stop") {
	throw new Error(
		assistant.errorMessage ?? `Agent run ended with unexpected stop reason: ${assistant.stopReason}.`,
	);
}

先记住调用前的消息数,跑完只在新增的那一段里找助手消息;找不到就抛;找到了还要看 stopReason 是不是 stop,不是就把 errorMessage 抛出来;最后取 getLastAssistantText(),空文本同样抛。三层都过了才算这一步成功。这是很多人自己写 Agent 测试时漏掉的:进程没崩不等于跑通了,中途被截断、被工具卡死、拿到空回复,都会伪装成「成功」混过去。

三、隔离是断言出来的,不是约定出来的

每次运行开头,harness 做的第一件事是开临时目录:

const root = await mkdtemp(join(tmpdir(), "pi-eval-"));
const cwd = join(root, "workspace");
const agentDir = join(root, "agent");

工作区和 agent 目录分开,两个都是新建的。会话服务用 SettingsManager.inMemory(),会话管理器用 SessionManager.inMemory(cwd),都不落盘。thinkingLevel 设成 "off",少一个变量。

最有意思的是紧接着的这一行断言:

assert.strictEqual(
	evalSession.extensionRunner.getExtensionPaths().length,
	0,
	"Expected an isolated eval session to start without extensions",
);

它不假设隔离成立,它检查隔离成立。如果哪天配置加载路径改了、某个全局扩展被读了进来,测试会在这里当场炸掉,而不是等到几个用例的结果莫名其妙变化之后你才去查。这条思路可以直接搬:凡是你的测试依赖的前置条件,都写成断言而不是注释。工作区隔离怎么做那篇讲的是隔离本身的设计,这里补的是隔离的验证。

收尾同样讲究。无论成败都走清理:先 session?.dispose(),再 rm(root, { recursive: true, force: true }),两步各自捕获异常收进 cleanupErrors。如果主流程失败且清理也失败,用 AggregateError 把两个错一起抛;主流程成功但清理出错,照样抛出去。它不吞任何一个错误——临时目录删不掉这种事,在 CI 上攒几百次就是磁盘满了。

四、两个用例分别演示了什么

smoke.eval.ts 只有十几行,但示范了最基础的一档:

const piCodingAgentHarness = createPiCodingAgentHarness({ noTools: "all" });

describeEval("Pi Coding Agent smoke", { harness: piCodingAgentHarness }, (it) => {
	it("runs a basic prompt end to end", async ({ run }) => {
		const result = await run("What's the capital of France? Respond with only the city name.");

		expect(result.output.trim()).toBe("Paris");
		expect(result.errors).toEqual([]);
		expect(result.usage.provider).toBe(process.env.PI_PROVIDER);
		expect(result.usage.model).toBe(process.env.PI_MODEL);
		expect(result.usage.totalTokens).toBeGreaterThan(0);
	});
});

noTools: "all" 这个选项在 packages/coding-agent/src/core/sdk.ts 里有注释:"all" 表示一个工具都不开,"builtin" 表示关掉默认的 read、bash、edit、write 但保留扩展和自定义工具。冒烟用例把工具全关了,因为它要验的是「提示进去、文本出来、用量记上」这条最短链路是否还通。用例还顺手断言了跑的确实是环境变量里指定的那个模型——防止某处偷偷回落到别的模型,结果你以为在测 A 实际在测 B。

extensions.eval.ts 是另一档。它给 harness 传了 output 选择器,把 responsesession.extensionRunner.getExtensionPaths()session.getAllTools().map(({ name, description }) => ({ name, description })) 一起打包成结果。然后用三步输入:让 Agent 创建一个 hello 扩展,触发 reload,再让它调用这个 hello 工具。断言覆盖了响应文本、扩展路径数量为 1、工具定义里出现名为 hello 的项,以及用 toolCalls(result.session) 检查调用记录里包含名字、参数、状态和结果都对得上的那一条。

这个用例的价值在于它测的是一条跨越会话生命周期的链路——写文件、重新加载、新能力生效。这类链路在日常开发里最容易被无声破坏,也最难靠人工点一遍发现。想深入这一层的可以看运行过程的复现与回放

五、包的组成一览

组成部分它负责什么仓库位置你什么时候会碰到它
harness 适配层把 AgentSession 跑一遍并压成 output/events/usagepackages/evals/src/pi-harness.ts想加新维度的断言数据时
冒烟用例验证最短链路:提示进、文本出、用量记packages/evals/src/smoke.eval.ts换模型或改会话初始化后
扩展用例验证创建扩展、重载、调用新工具这条链packages/evals/src/extensions.eval.ts改动扩展加载逻辑后
运行入口脚本解析 provider/model 参数并转交 Vitestpackages/evals/scripts/run-evals.mjs命令行报「没选模型」时
运行配置串行执行、超时、专用报告器packages/evals/vitest.config.ts用例超时或想并行时
说明文档运行方式与鉴权来源packages/evals/README.md第一次上手时

模型选择这块单独说一句。run-evals.mjs 手工解析 --provider--model(两种写法都支持),只给一个就报错退出;命令行没给就退回读 PI_PROVIDERPI_MODEL;两处都没有就打印提示并以非零码退出。harness 里的 getRequiredModelSelection() 再兜一层,两个值缺任意一个就抛 "PI_PROVIDER and PI_MODEL must both be set for eval runs."。README 把这个态度写成了一句话:runner 要求两个值都有,绝不回落到别的模型。

README 给的运行方式是这样的:

npm run eval -- --provider openai-codex --model gpt-5.4

另外它提到,从 pi 自己的 Bash 工具里调用时,当前会话会提供这两个环境变量,直接 npm run eval 就够;加 -t 之类的参数会原样转给 Vitest。鉴权走 pi 常规的 ModelRuntime:订阅型 provider 用用户配置里的凭据,API 型 provider 用各自的环境变量。这里提到的都是海外模型服务商,官方对中国大陆存在区域限制、不支持直连;市面上存在第三方中转,本文不背书也不给具体渠道。各家的规则不同且会调整,以官方最新说明为准。

六、边界与代价:它明确不管的事

这套设计是有取舍的,把取舍看清楚比照抄更重要。

它不在 CI 里跑。 仓库的 CI 工作流跑的是构建、npm run checknpm test,评测有自己独立的 npm run eval 入口,不在那条流水线的步骤里。原因不难理解:每跑一次都要真的调模型,既花钱又慢,还依赖外部服务的可用性。它的定位是「改了相关代码之后手动跑一遍」,不是「每次提交自动挡」。

它是串行的,而且超时给得很宽。 vitest.config.tsfileParallelism: falsetestTimeout: 120000hookTimeout: 30000。串行是必要的——并发跑多个会话会让用量统计和外部限流互相干扰——但代价是用例一多,整轮时间线性增长。

断言方式偏硬。 冒烟用例要求输出恰好等于 Paris,扩展用例要求恰好等于 Hello, Bob!。这在这两个场景里成立,因为提示词把输出格式钉死了。但这套断言不适合开放式任务——你没法用 toBe 去比一段代码解释。README 提到判定用的 harness 与应用 harness 是分开配置的,但这个包里现成的两个用例都是确定性断言,没有现成的开放式打分范例可抄。

它不覆盖交互层。 因为直接接 AgentSession,终端界面的渲染、按键绑定、交互模式的状态机都不在射程内。

它不管线上质量。 这是回归,不是监控。用例通过只说明这两条链路没坏,不说明模型在真实任务上表现如何。

七、搬到自己项目上的清单

先定结果形状,再写第一个用例。 会踩的坑是反过来做:先写用例,断言直接怼在原始响应上,等到第三个用例想断言工具调用时才发现拿不到数据,只能回头重构全部。避法是照 pi 的次序来——先想清楚一次运行要产出哪几块(最终产物、过程事件、资源用量),把这个接口定下来,用例只消费接口。

把前置条件写成断言。 会踩的坑是靠约定:注释里写「本测试假设无扩展加载」,半年后加载逻辑一改,测试结果慢慢变形却没人知道是环境变了。避法是像 getExtensionPaths().length === 0 那样,把每一条你依赖的假设变成失败会当场报错的断言,并带上人话说明。

清理路径必须捕获异常并合并上报。 会踩的坑是把清理写在 finally 里直接 await,一旦删目录失败,这个错会覆盖掉主流程真正的失败原因,你查半天以为是文件系统的问题。避法是把主流程结果和清理错误分开收集,两边都有错就用 AggregateError 一起抛。

别让模型选择有默认值。 会踩的坑是「没配就用默认模型」这种贴心设计:某次你忘了设环境变量,测试照跑照绿,但跑的根本不是你要验的模型。避法是缺参数直接失败,并且在用例里断言实际使用的 provider 和 model 与预期一致——两道锁。

多轮场景用数据描述,不要用代码描述。 会踩的坑是每个多轮用例都写成一串手写的 await 调用,步骤顺序散落在各处,想批量改或者从文件加载用例时全部推倒重来。避法是像那个 prompt/reload 步骤数组一样,把步骤定义成 JSON 安全的结构,执行器只认结构不认场景。

先关工具跑通最短链路。 会踩的坑是第一个用例就上完整工具集,失败了不知道是模型的问题、工具的问题还是会话初始化的问题。避法是留一个把工具全关掉的冒烟档,它一红就说明是地基塌了,跟业务逻辑无关。

接受它跑得慢,然后据此安排触发条件。 会踩的坑是把这类测试塞进每次提交的必经流水线,几周后因为太慢被整体禁用,等于白建。避法是明确触发条件——改了会话初始化、扩展加载、模型接入这几类代码时手动跑,发版前跑一轮全量。

收束

如果要用一句话概括这个包的做法:它没有试图解决「怎么给 Agent 打分」这个难题,而是先把「怎么让一次运行变得可观察、可比较、可重复」这件更基础的事做扎实了。打分标准可以后加,结果形状定错了后面全是返工。

给你三条自检:你的 Agent 测试能不能拿到工具调用的参数?两次运行之间有没有共享的目录或配置文件?跑的是哪个模型,是断言出来的还是猜的?三条里有一条答不上来,就先补这一条。

接着往下读的话,从 packages/evals/src/pi-harness.ts 开始,重点看 runPiCodingAgent 的 try/catch/清理三段和 toTranscriptEvents 的转换规则;然后跳到 packages/coding-agent/src/core/sdk.ts 看会话创建选项,那里能看清 harness 到底关掉了什么、留下了什么。

本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 给开源编程 Agent pi 接入自定义模型服务开源编程 Agent pi 的仓库结构导读

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。