Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Pi/第12章

第12章:测试模式 —— 怎么测一个不确定的系统

14 分钟 · 更新于 2026-09-01

第12章:测试模式 —— 怎么测一个不确定的系统

补全章节说明:原教程(dg-ai-notes)插图版只覆盖到第 10 章。本章基于 Pi v0.81.1 官方仓库的 337 个测试文件(packages/{ai,agent,tui,coding-agent}/test/)与测试基础设施(test.shvitest.config.tstest/suite/harness.ts)补全,行文风格与粒度对齐前 10 章。

上一章末尾留了一个问题:你写了个扩展,注册 tool_call 处理器返回 { block: true },怎么验证它真的拦住了?

把问题再放大一层:Pi 自己呢?Agent Loop 的循环逻辑、工具的并行调度、上下文压缩的切割点、TUI 的差分渲染——这些前 11 章讲过的机制,Pi 官方怎么保证改一行代码不把它们改坏?

答案藏在仓库里的 337 个测试文件、约 3600 个测试用例中。这一章不逐个讲测试(那没有意义),而是回答一个更值钱的问题:面对一个核心依赖"不确定输出"的系统,测试体系应该怎么设计?


一、问题:Agent 是一个"三重不可测"的系统

传统软件测试的黄金公式是:给定输入 → 调用函数 → 断言输出add(2, 3) 永远返回 5,断言写死就行。

但 Agent 系统的核心是 LLM,这个公式在它面前三处碰壁:

困境 1:输出不确定。 同一个 prompt 问两次,LLM 可能给出措辞完全不同的回答。你没法写 expect(answer).toBe("...")——今天绿的断言明天就红。

困境 2:调用要花钱。 每跑一次测试套件都真调 API,3600 个用例跑下来是一笔真金白银。CI 每次 push 都跑?没人付得起这个账单。

困境 3:依赖网络和密钥。 测试要 ANTHROPIC_API_KEY,意味着新贡献者 clone 下来跑不了测试;网络抖一下,CI 就假失败。

一个直觉的错误答案是:"那就少测点,靠人工验收。" Pi 的仓库证明了相反的路线——测试可以又多又稳又免费,前提是把系统"切"对地方。

先看 Pi 测试体系的全景:

测试文件数测试框架测什么
ai112Vitest协议层:流式解析、缓存、思考块、工具调用归一化 + 真模型 e2e
agent18VitestAgent Loop 编排、事件序列、工具调度
tui27Node 内置 node:test终端渲染、差分渲染、输入键位
coding-agent180Vitest会话、扩展、工具、压缩的集成测试

有意思的第一个细节:tui 包用的不是 Vitest,而是 Node 内置的 node:test。UI 库零外部依赖的洁癖(第 1 章讲过它只依赖两个包),延伸到了测试框架的选择上——渲染测试不需要 Vitest 的任何高级特性,那就不引入。

下面按"从内核到外围"的顺序,拆 Pi 应对三重困境的四套打法。


二、第一刀切在哪:把不确定性收敛到一个函数边界

复习:Agent Loop 的模型依赖长什么样

第 3 章讲过,Agent Loop 的入口是:

typescript
agentLoop(prompt, context, config, signal, streamFn)

注意最后一个参数 streamFn。整个 Agent Loop 与 LLM 的全部联系,就是这一个函数——传入上下文,返回一个 AssistantMessage 事件流。Loop 不关心流的背后是 Anthropic 的 SSE、还是 Google 的分块响应。

这个设计在第 4 章叫"协议 > 实现"。到了测试这一章,它兑现出第二重红利:streamFn 是可以造假的。

LLM 的不确定、花钱、依赖网络,全部被封在 streamFn 这一个边界后面。把它换成假的,边界内的一切——循环逻辑、工具调度、事件发射、消息组装——瞬间变成确定的、免费的、离线的普通代码。

造一个假流

packages/agent/test/agent-loop.test.ts 开头就定义了假流(agent-loop.test.ts:16):

typescript
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
    constructor() {
        super(
            (event) => event.type === "done" || event.type === "error",
            (event) => {
                if (event.type === "done") return event.message;
                if (event.type === "error") return event.error;
                throw new Error("Unexpected event type");
            },
        );
    }
}

配套一个假模型对象——baseUrl: "https://example.invalid"cost 全 0,物理上保证绝不触网。

完整模式:造假流 → 跑 loop → 断言事件序列

有了假流,测试 Agent Loop 的标准姿势是三步(agent-loop.test.ts:119-164):

typescript
// 第 1 步:造假流——脚本化"模型会说什么"
const streamFn = () => {
    const stream = new MockAssistantStream();
    queueMicrotask(() => {    // 异步 push,模拟真实流的时序
        const message = createAssistantMessage([{ type: "text", text: "Hi there!" }]);
        stream.push({ type: "done", reason: "stop", message });
    });
    return stream;
};

// 第 2 步:跑 loop,收集所有事件
const events: AgentEvent[] = [];
const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
for await (const event of stream) { events.push(event); }

// 第 3 步:断言事件序列
const eventTypes = events.map((e) => e.type);
expect(eventTypes).toContain("agent_start");
expect(eventTypes).toContain("turn_start");
expect(eventTypes).toContain("message_start");
expect(eventTypes).toContain("message_end");
expect(eventTypes).toContain("turn_end");
expect(eventTypes).toContain("agent_end");

那"模型调工具"这种多轮交互怎么造假?用闭包计数器状态机:假 streamFn 第 0 次被调用时吐一个 toolCallstopReason: "toolUse"),第 1 次吐普通文本(stop)。回忆第 3 章的循环规则——"有 toolCall 就继续转"——这两条假响应恰好驱动 loop 走完"模型 → 工具 → 模型"的完整回合。测试脚本化的不是模型的智能,而是模型的输出模式。 loop 只认输出模式,这就够了。

这一刀能测到多深?

你可能以为假流只能测"冒烟"级别的东西。看几个 agent-loop.test.ts 里的真实断言,它们全是第 3、5 章讲过的最微妙的语义:

typescript
// 并行工具:完成顺序 vs 持久化顺序是两回事(第 5 章讲过的三阶段设计)
expect(toolExecutionEndIds).toEqual(["tool-2", "tool-1"]);  // tool-2 先跑完
expect(toolResultIds).toEqual(["tool-1", "tool-2"]);        // 但按源序持久化

其它同一模式覆盖的边界:被 length 截断的 toolCall 绝不执行、beforeToolCall 钩子改参后不重新校验、executionMode: "sequential" 强制串行、shouldStopAfterTurn 触发时精确断言 12 步事件序列(agent-loop.test.ts:1185-1198)。

并发时序这种最容易出 heisenbug 的地方,反而是假流测得最狠的地方——因为假流让时序可复现了。


三、断言什么:事件序列是主断言面

上一节的代码里藏着本章第二个关键决策,值得单独拎出来。

传统测试断言"返回值"。但 Agent Loop 的"返回值"只是最终消息列表——中间过程全部丢失。而 Agent 系统的 bug 恰恰多发于过程:事件发早了、发漏了、顺序反了、流式中间状态错了。

Pi 的做法:把第 7 章的事件系统直接征用为测试的断言面。 Agent 每步都 emit 事件(这是产品需求,UI 靠它渲染),测试只需订阅并收集,就得到一份完整的"行为心电图":

text
["agent_start", "turn_start", "message_start", "message_update", ...,
 "message_end", "tool_execution_start", "tool_execution_end", "turn_end",
 "turn_start", ..., "agent_end"]

对这份心电图断言,比对最终结果断言强在三处:

  1. 粒度——"工具在 message_end 之后才开始执行"这种时序约束,只有事件序列能表达
  2. 中间态——packages/agent/test/e2e.test.ts:96-99 甚至断言"流式过程中 pendingToolCalls 的瞬时值":
typescript
expect(pendingToolCallsDuringEvents).toEqual([
    { type: "tool_execution_start", ids: ["calc-1"] },
    { type: "tool_execution_end", ids: [] },
]);
  1. 免费——事件系统本来就有,测试没要求 Agent 为它做任何改造

这里有一个值得回味的因果倒置:第 7 章说事件驱动是为了"UI 和内核解耦"。但从测试视角看,一个把每步都 emit 出来的系统,天然就是一个可测试的系统。可观测性和可测试性是同一枚硬币的两面。


四、替身的成色:faux provider——假到能测边界

手写 MockStream 的局限

MockAssistantStream 够测 Agent Loop,但再往上一层就不够用了。AgentSession(产品层)关心的是:流式 delta 逐 token 到达时 UI 状态对不对、Esc 中断时消息怎么收尾、token 用量怎么累计——这些行为依赖"流的真实质感",一次性 push 完整消息的手写 mock 模拟不出来。

Pi 的解法是把替身升级成一等公民packages/ai/src/providers/faux.ts——一个内置在 ai源码(不是测试目录!)里的假 provider。

faux provider 有多真

它可以像真 provider 一样注册进 ModelRegistry,然后把"预设消息"渲染成逐 token 的真实事件流:

typescript
// 测试脚本化模型行为(packages/agent/test/e2e.test.ts:185-205)
const faux = createFauxRegistration();
faux.setResponses([
    fauxAssistantMessage(
        [fauxText("Let me calculate that."),
         fauxToolCall("calculate", { expression: "123 * 456" }, { id: "calc-1" })],
        { stopReason: "toolUse" }),
    fauxAssistantMessage("The result is 56088."),
]);
await toolExecution(faux.getModel());

它的拟真度清单(faux.ts:308-467):

  • 逐 token 推送 text_delta / thinking_delta / toolcall_delta,可配置 tokensPerSecondtokenSize
  • usage 估算缓存前缀命中模拟
  • 响应 abort——signal.aborted 时产出一条 aborted 消息,让"Esc 中断"成为可复现的测试场景
  • 响应可以是工厂函数FauxResponseFactory)——根据收到的上下文动态生成回答,能测"模型记住上文"类行为

这里的设计判断值得抄走:替身的价值不在于"省掉真依赖",而在于"够不够真,真到能测边界"。 一个空壳 mock 只能测 happy path;一个会逐 token 吐字、会被打断、会命中缓存的替身,才能测流式渲染、中断处理、并发时序——恰恰是 Agent 系统最容易出 bug 的三个地方。

全仓有 40 个测试文件直接建立在 faux provider 之上。

顺带说清一个容易误读的词:Pi 仓库里 agent/test/e2e.test.tstest/suite/ 说的 "e2e" 是"端到端跑完整 Agent(用 faux)",不等于"调真模型"。真模型测试另有一套管理办法(见第七节)。


五、集成层:一个 harness 把所有替身装配到位

createHarness:coding-agent 测试的中枢

到 coding-agent 这一层(180 个测试文件,全仓一半),要测的是完整产品行为:会话持久化、扩展加载、压缩触发、工具读写文件。依赖多到手工装配会淹死人。

Pi 的答案是一个统一的测试装配函数 createHarness()(test/suite/harness.ts:100-189):

typescript
const tempDir = createTempDir();                       // ① 临时目录当 cwd
const fauxProvider = registerFauxProvider({ models }); // ② faux 模型
const sessionManager  = SessionManager.inMemory();     // ③ 内存会话存储
const settingsManager = SettingsManager.inMemory(options.settings);
const authStorage     = AuthStorage.inMemory();        // ④ 内存凭据(塞假 key)
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
const agent = new Agent({ getApiKey: () => "faux-key", streamFn: streamSimple, ... });
const session = new AgentSession({ agent, sessionManager, cwd: tempDir, ... });
session.subscribe((event) => { events.push(event); }); // ⑤ 事件收集——断言面

五个替身各堵一个"副作用出口":

副作用替身来源
LLM 调用faux provider第 4 节
会话写盘SessionManager.inMemory()第 10 章讲过的 memory-storage 一族
设置读写SettingsManager.inMemory()同上
凭据读取AuthStorage.inMemory() + 假 key同上
文件系统mkdtemp 临时目录当 cwd,用完 rmSync工具读写全落在沙盒内

还记得第 10 章说 memory-storage.ts 时标注了一句"测试用"吗?现在看到了它的完整用途——Pi 在设计存储抽象的那一天,就同时给测试准备好了替身。接口化不只是"允许换数据库",也是"允许换成内存假货"。

典型用例长这样(suite/agent-session-prompt.test.ts:43-78):

typescript
harness.setResponses([
    fauxAssistantMessage(fauxToolCall("echo", { text: "hello" }), { stopReason: "toolUse" }),
    fauxAssistantMessage("done"),
]);
await harness.session.prompt("start");
expect(toolRuns).toEqual(["hello"]);
expect(harness.session.messages.map(m => m.role))
    .toEqual(["user", "assistant", "toolResult", "assistant"]);

四行脚本、两行断言,测完一个完整的"提问 → 调工具 → 回答"产品级回合。

扩展系统怎么测:把源码写进临时目录,真加载

回到上一章留下的问题。扩展 = 磁盘上的 TS 文件 + jiti 动态加载,测试怎么办?Pi 的做法朴素到出人意料——测试里现场把扩展源码写成文件,然后走真实加载路径(test/extensions-discovery.test.ts:14-52):

typescript
beforeEach(() => {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ext-test-"));
    extensionsDir = path.join(tempDir, "extensions");
    fs.mkdirSync(extensionsDir);
});

// 测试体内:
fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode);  // 扩展源码是字符串
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.extensions).toHaveLength(2);

发现、jiti 转译、工厂执行——整条第 11 章第六节讲的旅程,一步不 mock。而对"事件拦截"类扩展逻辑,harness 提供 extensionFactories 选项直接注入内联工厂函数(不落盘),把 runner 接进 Agent 回调后,tool_call 拦截、context 注入、before_provider_request 改写全部可测。

上一章问"返回 { block: true } 怎么验证真的拦住了",现在有了完整答案:faux 模型吐一个 toolCall → 内联扩展返回 block → 断言事件序列里没有 tool_execution_start、且 toolResult 消息带着拦截 reason。全程零网络、毫秒级。

压缩测试:固化的会话文件当考卷

第 9 章的压缩算法怎么测?切割点选择依赖"一份足够长、结构足够刁钻的会话"。Pi 把这种会话固化成 fixture 文件test/fixtures/large-session.jsonlbefore-compaction.jsonl——测试读入、跑压缩、断言切点和摘要结构(compaction.test.ts:35-38)。JSONL 会话格式(第 10 章)在这里兑现了又一重红利:会话即文本文件,测试样本可以像代码一样被 review 和版本管理。


六、TUI 测试:给终端造一个替身

UI 测试是公认的老大难,终端 UI 更难——输出是一串 ANSI 转义序列,肉眼都难读,怎么断言?

虚拟终端:不解析 ANSI,直接"放映"它

Pi 的方案:用 xterm.js 的 headless 版(@xterm/headless)做虚拟终端(test/virtual-terminal.ts:11)。TUI 以为自己在往真终端写 ANSI 序列,实际写进了一个内存中的终端仿真器;测试再从仿真器的屏幕缓冲区读回渲染后的文本

typescript
this.xterm = new XtermTerminal({ cols: columns, rows, disableStdin: true, allowProposedApi: true });
// 读回口:
getViewport()      // 当前可见区域的文本行
getScrollBuffer()  // 含滚动历史的全部行

这就把"断言 ANSI 序列"这个不可读的问题,转换回了"断言屏幕上第 N 行是什么字"这个人类问题:

typescript
// 差分渲染测试:spinner 转动时,头尾行必须纹丝不动(tui-render.test.ts:540)
const spinnerFrames = ["|", "/", "-", "\\"];
for (const frame of spinnerFrames) {
    component.lines = ["Header", `Working ${frame}`, "Footer"];
    tui.requestRender();
    await terminal.waitForRender();
    const viewport = terminal.getViewport();
    expect(viewport[0]).includes("Header");
    expect(viewport[1]).includes(`Working ${frame}`);
    expect(viewport[2]).includes("Footer");
}

三种断言力度:结果、过程、计数

差分渲染(第 1 章提过的"每帧只重绘变化的单元格")的正确性分三个层次验证:

  1. 结果断言:改中间行后 deepStrictEqual(getViewport(), [...]) ——屏幕最终对
  2. 过程断言:子类 LoggingVirtualTerminal 捕获原始 ANSI 写入串,直接断言转义序列本身——不但结果对,写法也对(该清的行清了、光标移动最少)
  3. 计数断言:TUI 暴露 fullRedraws 计数器——内容收缩应触发全量重绘(+1),尾部追加必须走差分路径(不变)

第 2 层最能体现"测 UI 库和测 UI 应用的区别":pi-tui 的卖点是差分渲染的效率,只断言最终画面等于没测卖点。断言 ANSI 写入串,才测到了"少写了多少字节"这个真正的承诺。


七、真模型测试:作为"可选层"存在

前六节的一切都不碰真 LLM。但协议层(ai 包)绕不开——SSE 解析、思考块方言、工具调用格式,这些代码的对手方是真实 API,用假流测它们是自己骗自己。所以 ai 包 112 个测试文件里有 30 个是真模型测试。三重困境的"花钱、要网络"在这里正面遭遇。Pi 的管理办法是三板斧:

板斧一:没 key 就跳过,而不是失败

typescript
// packages/ai/test/anthropic-opus-4-8-smoke.test.ts:24
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.8 smoke", () => {
    it("streams Claude Opus 4.8 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => {
        // ...
    });
});

全仓 379 处 .skipIf。每个 provider 各带各的 key gate(abort.test.ts 一个文件里按 provider 排了近 20 个 describe.skipIf),缺哪家的 key 就跳哪家的测试。新贡献者 clone 下来 ./test.sh,全绿——真模型层显示为 skipped,不是 failed。"跳过"是一种诚实:它告诉你这部分没验证,而不是假装验证过了。

板斧二:test.sh 用白名单环境物理隔离

npm test 直接跑 Vitest(有 key 就会真调 API)。而根目录 test.sh 是 CI 安全入口,核心是一行 env -i——清空全部环境变量,只放行白名单(test.sh:44-71):

bash
test_env=(
    "PATH=$PATH"  "PWD=$PWD"
    "HOME=$test_root/home"           # 假 HOME(mktemp 临时目录)
    "TMPDIR=$test_root/tmp"
    "GIT_CONFIG_GLOBAL=/dev/null"    # 屏蔽用户 git 配置
    "NPM_CONFIG_USERCONFIG=$test_root/npm-userconfig"
    "PI_NO_LOCAL_LLM=1"              # 关掉本地 LLM 探测
    "AWS_EC2_METADATA_DISABLED=true"
)
env -i "${test_env[@]}" npm test

白名单里没有任何 *_API_KEY,于是所有 skipIf 门自动关闭——不是靠纪律"别忘了 unset key",而是靠机制"key 根本进不来"。假 HOME 顺带解决了另一类脏测试:任何不小心读写 ~/.pi/ 的代码在测试里碰到的都是一次性目录。AGENTS.md 把这条写成了团队铁律:

"Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run ./test.sh."

板斧三:真要花钱时,把每一分钱花在刀刃上

被 key 门放行的测试,成本控制也有一套组合拳:

手段做法例子
确定性小题让模型算一道固定算术题、按固定格式回答expect(text).toBe("sum=353418362; divisibleBy11=yes")
封顶输出maxTokens: 1024同一个 smoke 测试
容忍抖动{ retry: 2 }——重试而不是放宽断言真模型偶发波动不误报
超时上限全局 testTimeout: 30000,注释直言 "for API calls"vitest.config.ts

注意"确定性小题"的巧思:它没有向不确定性投降(改成模糊断言),而是把题目设计到只有一个正确答案,硬是在真模型上保住了 toBe 级别的精确断言。


八、总结:五条可迁移的方法论

Pi 的测试体系没有用到任何新奇的测试技术——Vitest、mock、fixture、临时目录,全是家常菜。值钱的是五个结构性决策:

1. 把不确定性收敛到单一边界注入点。 整个 Agent Loop 对 LLM 的依赖只有一个 streamFn 函数参数。边界越窄,可替换性越强,边界内的可测面积越大。设计 Agent 系统时,第一天就把"LLM 调用"抽成可注入的函数——这个决定同时买到了多供应商支持(第 4 章)和可测试性(本章)。

2. 事件序列 = 主断言面。 不只断言最终结果,而是收集事件流断言其类型与顺序。可观测性和可测试性是同一件事:给系统定义稳定的事件协议,UI 和测试就有了共同的抓手。

3. 替身要真到能测边界。 faux provider 会逐 token 吐字、会被 abort、会模拟缓存命中。空壳 mock 只能测 happy path;拟真替身才能测流式、中断、并发这些最容易出 bug 的地方。替身的投入产出比,取决于它复刻了多少"边界行为"。

4. 内存替身 + 临时目录,双层隔离副作用。 存储接口全部提供 inMemory() 实现,文件操作全部指向 mktemp 沙盒,test.sh 再用假 HOME 兜底。每个测试拿到独立无菌环境——可并行、可重复、可随手删。

5. 真实 LLM 测试作为可选层,缺 key 优雅降级。 skipIf + 环境白名单让默认路径全绿且免费;真要跑时用确定性小题、封顶 token、retry 控制成本与抖动。分层的本质是给每类测试标好价格:免费层随手跑,付费层刻意跑。

最后回看第 5 章的一个伏笔。当时讲 Operations 抽象的好处,列了一条"测试可以 Mock"。现在你可以看到这不是孤例,而是 Pi 全库的系统性习惯:streamFn 可注入、SessionStorage 有内存实现、工具操作走接口、终端是个可替换的 Terminal 接口。可测试性不是测试阶段"加"上去的,而是每个抽象边界设计时"留"出来的。 337 个测试文件只是结果,原因分布在前 11 章讲过的每一个接口里。


九、下一站

到这一章为止,Pi 的机制拼图完整了:从 Agent Loop 到模型调用,从工具到消息,从事件到上下文,从会话到扩展,再到验证这一切的测试体系。

最后一章我们换一个视角——不再钻某一个子系统,而是把 12 章讲过的所有设计决策放在同一张桌子上,问三个收官问题:这些决策之间有什么共同的思维方式?哪些是 Pi 独有的语境产物,哪些可以搬进你自己的项目?以及,"做减法"作为一种工程哲学,边界到底在哪里?


本章关键源码索引(Pi v0.81.1):

  • packages/agent/test/agent-loop.test.ts — MockAssistantStream(:16)、造假流→跑 loop→断言事件序列全模式(:119-164)、并行工具双顺序断言(:656-678)、12 步精确事件序列(:1185-1198)
  • packages/ai/src/providers/faux.ts — 一等公民假 provider:fauxText/fauxToolCall 构造器(:49-94)、逐 token 流生成与 abort 模拟(:308-467)
  • packages/coding-agent/test/suite/harness.ts:100-189 — createHarness 统一装配(faux + 三件套 inMemory + 临时 cwd + 事件收集)
  • packages/coding-agent/test/extensions-discovery.test.ts:14-52 — 扩展源码写盘真加载测试
  • packages/coding-agent/test/fixtures/ — large-session.jsonl 等压缩测试考卷
  • packages/tui/test/virtual-terminal.ts — xterm headless 虚拟终端替身;tui-render.test.ts — 结果/ANSI 过程/fullRedraws 计数三级断言
  • test.sh — env -i 白名单隔离 + mktemp 沙盒 + 防误删校验;AGENTS.md:30-32 — 测试分层铁律

数字速览:337 个测试文件(ai 112 / agent 18 / tui 27 / coding-agent 180),约 3600 个用例;379 处 .skipIf 门控;40 个文件基于 faux provider;89 个文件使用临时目录/内存替身隔离。

版本说明:本章基于 Pi v0.81.1 仓库撰写(前 10 章基于 v0.80.2)。

本章目录
一、问题:Agent 是一个"三重不可测"的系统二、第一刀切在哪:把不确定性收敛到一个函数边界三、断言什么:事件序列是主断言面四、替身的成色:faux provider——假到能测边界五、集成层:一个 harness 把所有替身装配到位六、TUI 测试:给终端造一个替身七、真模型测试:作为"可选层"存在八、总结:五条可迁移的方法论九、下一站
苏ICP备2025204887号-2