返回课程首页

第 36 章

生产化与测试

完成安全、恢复、评估、诊断、打包和升级检查。

源码基线:Pi v0.83.0 · commit 845d6ff

一个 Agent 能回答问题,只说明主路径可用。一个桌面 Agent 能进入生产,还必须证明:

  • 不该执行的动作确实不会执行;
  • 崩溃、取消和重试不会制造第二次副作用;
  • Session 能恢复;
  • Skills 与 Extensions 更新后仍兼容;
  • 模型行为变化能被评估;
  • 出错时能定位到 Provider、Tool、Extension、Session 或 Bridge。

本章把示例项目变成可发布、可升级、可回退的产品。

1. 学习目标

完成本章后,你应能够:

  1. 为 Prompt、Tool、Extension、Broker 与 OS 建立分层安全边界;
  2. 设计 Abort、Timeout、Retry 与 Idempotency 的组合;
  3. 使用 Faux Provider 和固定 Event Sequence 测试,不消耗真实模型 Token;
  4. 分别测试 Skill 的内容契约与 Extension 的运行时契约;
  5. 建立评估、诊断、兼容性、打包与回滚门禁。

2. 生产化不是一个开关

flowchart TD
    P["Prompt / Skill rules"] --> X["Extension Gate"]
    X --> A["Approval"]
    A --> B["Final Tool Broker Policy"]
    B --> S["OS / Container Sandbox"]
    S --> AU["Audit / Recovery"]

上层规则改善行为,下层边界限制能力:

  • Prompt/Skill:告诉模型应该怎样做;
  • Extension Gate:在受控执行链中检查/转换;
  • Approval:让用户对具体 Canonical 动作做决定;
  • Broker:以服务端规则重新验证;
  • Sandbox:限制进程实际能访问的文件、网络和能力;
  • Audit:为恢复、对账和责任追踪提供证据。

任何一层都不能单独代替全部安全设计。

3. Threat Model 先于测试用例

先列资产与攻击面:

资产 主要风险 最终防线
Workspace 文件 越界读写、路径穿越 Canonical Path + Broker + Sandbox
Shell 命令注入、继承环境 Structured Policy + Env Allowlist + Sandbox
API Key Prompt/ToolResult 泄漏 Auth Store + Redaction + Process Boundary
Session 篡改、跨租户读取 File Permission + Tenant Binding
Extension 任意本地代码 来源固定、审查、隔离、最小权限
Approval UI 欺骗、参数替换 Canonical Args + One-shot Capability
Provider Response Prompt Injection Trust Boundary + Tool Final Policy

ExtensionAPI、Project Trust、TypeScript 类型与子进程都不等于 OS Sandbox。

4. 权限模型

不要只问“这个 Tool 能不能用”,还要问:

谁
在什么 Session/Workspace
对哪个 Canonical Resource
执行什么动作
在什么时间窗口
可以执行几次

一次性 Capability 可以包含:

interface ToolCapability {
  toolCallId: string;
  sessionId: string;
  action: "write";
  canonicalTarget: string;
  argsHash: string;
  expiresAt: number;
  remainingUses: 1;
}

这是宿主设计。真正执行前,Broker 重新验证全部字段;不要只相信 UI 返回 approved: true。

5. Timeout、Abort 与 Cleanup

三者含义不同:

  • Timeout:宿主不愿再等;
  • Abort:请求底层停止;
  • Cleanup:释放已经创建的资源。
timeout
→ abort controller
→ driver observes signal
→ process/fetch stops
→ finally cleanup
→ return explicit interrupted result

只用 Promise.race() 会停止等待,却不一定停止底层工作。Tool/Extension 必须把 Signal 传到 Fetch、Child Process、Database Driver 或 Remote Executor。

Pi 的取消也不是一个总开关:AgentSession.abort() 会停止 Auto-retry 和当前 Agent Run,并 等待 Agent Idle;独立 Bash、Compaction、Branch Summary 分别使用 abortBash()、 abortCompaction()、abortBranchSummary()。测试必须按操作类型调用对应取消 API,并验证 其 Signal 到达底层进程。

6. 两层模型 Retry Budget

Pi v0.82.0 有两层模型重试:

层 配置/行为
Provider Transport Retry retry.provider;处理可重试传输错误,可读取 Retry-After
AgentSession Auto-retry retry.enabled/maxRetries/baseDelayMs;最终 Assistant Error 后延迟并 continue(),发 auto_retry_* Event

两层可能叠加,所以次数、总时间、Token 与成本要分别计算,也要分别验证 Abort。

两层共同满足:

  • 操作可安全重试,或有 Idempotency Key;
  • Retry 次数和总时间有上限;
  • Abort 后不再重试;
  • 用户能看见正在恢复;
  • 最终失败会收敛到 Settled/Diagnostic。

只有 Provider Transport Retry 能读取 Response Header 并尊重 Retry-After。AgentSession Auto-retry 拿不到这个 Header,只使用 baseDelayMs * 2^(attempt-1) 指数退避。

Provider/Auto-retry 与 Tool 副作用重试要分开。不要因为 Provider 暂时失败,自动重放一个可能已 写入成功的 Tool。

7. Idempotency 与不确定结果

最危险的状态不是“失败”,而是:

后端可能已经成功,但桌面进程在收到确认前崩溃。

用业务 ID 连接:

runId + toolCallId + operationId

恢复后先查询 Audit/Backend:

  • 已成功:恢复为 Completed;
  • 明确未执行:允许重新提交;
  • 无法判断:标为 Indeterminate,要求人工确认;
  • 可补偿:显示补偿动作,而不是静默回滚。

Exactly-once 不能靠 RPC Command ID 或再次发送相同 Prompt 获得。

8. Session 与多会话隔离

测试两个并发 Session:

  • Extension Closure 是否串状态;
  • Module Singleton 是否按 Tenant/Workspace Key;
  • Approval 是否路由到正确 Window;
  • Abort A 是否影响 B;
  • Model 切换是否泄漏;
  • Session Entry 是否写入错误文件;
  • Shared ModelRuntime/Auth 是否只有预期共享。

如果要隔离 Extension Instance,每个 Session 需要独立 ResourceLoader/Services;只创建两个 AgentSession 但复用同一个 Extension Runtime,并不会自动隔离。

9. Token、Context 与成本

记录三个层次:

层次 用途
Turn Usage 单次 Provider 调用与 Retry
Agent Run Usage 一次用户任务的多 Turn 总计
Session Usage 长期趋势与预算

还要记录:

  • Provider/Model;
  • Input/Output/Cache Token;
  • Tool Nested Model Usage(若有);
  • Compaction 前后 Context Size;
  • Estimated 与 Final 的区别;
  • Retry 造成的额外成本。

成本告警不要阻塞关键 Session Flush;但超预算时应在下一次 Provider 调用前由宿主 Policy 阻止或要求确认。

10. 测试金字塔

flowchart TD
    U["Pure Unit<br/>schema/policy/reducer"] --> F["Faux Agent Loop"]
    F --> E["Extension/Skill Contract"]
    E --> S["Session/Recovery"]
    S --> D["Desktop Bridge"]
    D --> R["Real Provider Smoke"]

越靠上越快、越确定、越便宜。真实 Provider 测试只保留少量 Smoke/Eval,不能把所有回归 都建立在外部模型上。

11. Pure Unit Test

优先测试纯函数:

  • Path Canonicalization;
  • SQL/Command Risk Classifier;
  • Approval Capability 校验;
  • Tool Schema;
  • Event Reducer;
  • Redaction;
  • Retry Budget;
  • Version Matrix。

这些测试不需要创建 AgentSession。

12. Faux Provider 测 Agent Loop

Pi 仓库内部 Test Harness 使用 Faux Provider 与预设 Assistant Response,可以确定性地产生:

text
tool call
parallel tool calls
provider error
thinking
stop

应用可以建立等价公开夹具:

import { join } from "node:path";
import {
  fauxAssistantMessage,
  fauxProvider,
  fauxText,
  fauxToolCall,
} from "@earendil-works/pi-ai";
import {
  createAgentSession,
  ModelRuntime,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const faux = fauxProvider();
const modelRuntime = await ModelRuntime.create({
  authPath: join(fixturePath, "auth.json"),
  modelsPath: null,
});
modelRuntime.registerNativeProvider(faux.provider);
faux.setResponses([
  fauxAssistantMessage(
    fauxToolCall("read", { path: "logs/error.log" }),
    { stopReason: "toolUse" },
  ),
  fauxAssistantMessage(
    fauxToolCall("controlled_write", {
      path: "config.json",
      content: "{}",
    }),
    { stopReason: "toolUse" },
  ),
  fauxAssistantMessage(fauxText("修复完成")),
]);

const { session } = await createAgentSession({
  cwd: fixturePath,
  sessionManager: SessionManager.inMemory(fixturePath),
  modelRuntime,
  model: faux.getModel(),
  customTools: [readTool, controlledWriteTool],
});

try {
  await session.prompt("读取错误日志并修复配置");
  // 断言事件、工具调用、ToolResult 与 Session Entry。
} finally {
  session.dispose();
}

断言 Event Order、Tool Execute Count、ToolResult、最终消息与 Session Entry。不要在测试中 调用真实付费 Provider。fauxProvider() 是不修改全局 API Registry 的现代组合;要把它的 Native Provider 注册进被测 ModelRuntime,再把同一 ModelRuntime 与 faux.getModel() 传给 AgentSession。Legacy registerFauxProvider() 只注册 Compat 全局 API Registry,不能单独让 独立 ModelRuntime 认识 Faux Provider。 测试使用 Fixture Auth Path 和内存 Model Store,避免读取真实用户的认证、模型与缓存;结束时 还要 Dispose Session,清理监听器和会话资源。

并行 Event Order 还要按配置断言:默认 Tool Batch 并行,但全局 toolExecution === "sequential",或批次中任一 Tool 的 executionMode === "sequential",都会让整批顺序执行。并行时 tool_execution_end 可按完成顺序到达,最终 ToolResult Message 则恢复为模型 ToolCall 顺序。

13. Skill 测试

Skill 需要三层测试。

静态结构

  • Frontmatter/Name/Description;
  • 触发摘要是否清楚;
  • 依赖文件是否存在;
  • 相对路径是否能解析;
  • 指令是否要求未提供的 Tool;
  • 是否意外包含 Secret/绝对机器路径。

触发与读取

脚本化 Faux 可验证:

  • 显式 Skill 展开;
  • 自动路径中模型发出的 Read 请求;
  • Tool/Event 编排;
  • 读取了完整 SKILL.md;
  • 需要的 Reference 按顺序读取;
  • 输出/动作满足约束。

但 Faux Response 是预先写死的,不能证明模型“应触发时能识别、不相关时不误触发”。真正的 触发准确率必须进入固定模型的 Golden Eval 或少量 Real-provider Test。

安全与回归

  • 外部内容不能覆盖 System/Host Policy;
  • 缺文件时 Fail 可见;
  • Tool 被禁用时有合理降级;
  • Skill 更新后 Golden Task 仍通过。

Skill 是文本工作流,测试重点是“模型在给定工具和上下文下是否遵守契约”,不是只校验文件 能被 Loader 发现。

14. Extension 测试

至少覆盖:

  1. Factory 成功与失败;
  2. Pre-bind Provider/Flag 副作用;
  3. Handler 顺序与分类型错误语义;
  4. tool_call Block/Transform/Throw;
  5. tool_result Patch/Redaction,以及宿主 Timeout/Worker Isolation;
  6. Abort 传递;
  7. Reload Shutdown/Cleanup;
  8. New/Resume/Fork State Reconstruction;
  9. EventBus Unsubscribe;
  10. 多 Session/Module Singleton;
  11. Tool/Flag/Command 冲突;
  12. Pi 版本兼容。

ExtensionRunner 没有内置 Handler Timeout;一个永不 Resolve 的 Handler 会一直阻塞。测试中 要验证宿主 Timeout/进程终止策略。错误语义也不统一:tool_result Throw 会被记录后继续 后续 Handler;tool_call Throw 会终止后续 Hook,并最终变成阻止工具执行的 Error Result。

安全关键 Extension 加载失败时,要测试“宿主确实阻止高风险动作”,而不是只断言出现一条 Warning。

15. Session 与 Replay 测试

固定一份 Event/Entry Fixture,测试:

  • JSONL 打开与恢复;
  • Branch 切换;
  • Fork Before/At;
  • Compaction Summary;
  • Custom Entry;
  • Tool Details State;
  • 中断的 Assistant/Tool;
  • 文件尾损坏;
  • CWD/Trust Replacement;
  • 旧 Generation Event 被拒绝。

generation、runtimeId、runId、messageUiKey、operationId 以及 Extension Source/Version 多数不是 AgentSessionEvent 自带字段。它们由 Desktop Controller/Bridge 生成并包装进 Fixture;测试要同时验证 Envelope 生成与 Reducer,而不是在 Pi Event 类型里 寻找这些字段。

Replay 的目标是重建应用状态,不是重新执行 Tool。任何回放逻辑都必须保证 Side Effect 为零。

16. Desktop Bridge 测试

SDK

  • Session Replacement 前拆旧 Bridge;
  • 新 Session 先订阅再绑定 Extension;
  • runtime.dispose() 发 Shutdown;
  • Store 不保存 Session Object;
  • Window Close 取消 Approval。

RPC

  • LF-only Framing;
  • Command Response 乱序;
  • JSON Parse Error;
  • stdout/stdin Backpressure;
  • Extension UI Request/Response;
  • Worker Exit;
  • Pending 清除;
  • Snapshot + Live Event Rehydrate;
  • 无版本握手时的外层版本门禁。

测试时随机切分 JSON Byte Chunk,不能假设一行恰好等于一次 Pipe Read。

17. Evals:测“效果”,不是测代码分支

建立 Golden Task Dataset:

task
workspace fixture
allowed tools
expected invariants
forbidden actions
quality rubric
cost/latency budget

示例:

给定一个缺少端口配置的项目,Agent 应先读取日志和配置,提出最小修改;未经批准不能写入 Workspace 外部,也不能运行网络命令。

评估维度:

  • 任务完成率;
  • Tool 选择;
  • 权限违规率;
  • 不必要写入;
  • 引用证据正确性;
  • Turn/Token/Cost;
  • 延迟;
  • 恢复成功率。

模型输出非确定,所以比较分布和不变量,不要只 Snapshot 一段自然语言。

18. 日志与 Trace

每条记录至少带:

timestamp
runtimeId / generation
sessionId / runId / turn
messageUiKey / toolCallId / operationId
provider / model
extension source/version
event / duration / result class

默认不记录:

  • API Key/OAuth Token;
  • 完整 Environment;
  • 未脱敏 Prompt/Tool Content;
  • Approval 中的敏感参数;
  • 用户本地文件正文。

需要内容级调试时,使用显式 Opt-in、短期保留和访问控制。

19. 诊断不是一个 errors 数组

Controller 聚合:

  • Resource Loader 的 Extension Errors;
  • Skill/Prompt/Theme Diagnostics;
  • Runtime Diagnostics;
  • Model Fallback Message;
  • Provider/Auth Error;
  • Tool Result Error;
  • Session Recovery/Replacement Exception;
  • RPC Parse/Exit;
  • Host Policy/Audit Failure。

每条诊断应包含 Scope、Severity、Recoverable、User Action 与 Correlation ID。

20. 打包

SDK/Electron

  • 固定 Node 与 Pi Package 版本;
  • 审查 Production Dependencies;
  • 将 Auth/Session 放入正确用户目录;
  • 不把开发环境变量带入发布包;
  • 为 Native Addon/Child Process 建签名策略。

RPC/Sidecar

  • 把固定版本 Worker 随 App 打包;
  • 不从任意系统 PATH 发现 Pi;
  • 校验 Binary Hash/Signature;
  • 明确 CWD、Environment 与 Sandbox Profile;
  • 捕获 stdout/stderr/Exit;
  • Worker 与 App 使用同一兼容矩阵。

当前 Pi RPC 没有内置 Version/Capabilities Handshake,所以版本校验要在协议外完成。

21. 升级与兼容性

每次升级 Pi:

read changelogs
→ type/build checks
→ skill static tests
→ extension contract tests
→ session fixture replay
→ SDK/RPC bridge tests
→ golden eval comparison
→ canary
→ rollback ready

重点观察:

  • Agent/Session Event 顺序;
  • Tool Result Schema;
  • Extension Handler 错误语义;
  • Reload/Replacement;
  • Provider Model Metadata;
  • RPC Union;
  • Session Entry/Compaction;
  • Node Runtime 要求。

TypeScript 编译通过不代表行为兼容。

22. 发布门禁

门禁 必须通过
安全 越权/注入/审批替换测试
确定性 Unit、Faux、Replay
生命周期 Abort、Reload、Dispose、Crash
并发 Parallel Tool、多 Session、多窗口
质量 Golden Evals 不低于基线
成本 Token/Latency 在预算内
兼容 Pi/Node/Worker/Extension Matrix
运维 Log、Diagnostic、Canary、Rollback

安全关键失败不能用“已知问题”静默放行。

23. 综合验收测试

发布前完成以下 12 项:

  1. Text + Thinking Streaming;
  2. 模型切换与能力降级;
  3. Read Tool 与 Parallel Tool;
  4. Write Approval 通过、拒绝、超时和窗口关闭;
  5. Agent Run、独立 Bash、Compaction、Branch Summary 分别取消,并传播到对应底层进程;
  6. Steering/Follow-up Queue;
  7. Session Save/Resume/Branch;
  8. Compaction 后历史仍可回看;
  9. Skill 触发、读取、遵循与缺文件诊断;
  10. Extension Reload、冲突、失败与 Cleanup;
  11. Worker Crash/Rehydrate/Indeterminate Tool;
  12. 两 Session 两窗口无状态或审批串线。

每项都要有自动化证据;真实 Provider 只补充少量端到端 Smoke。

24. 常见误区

“有 Approval 就不用 Sandbox”

错误。Approval 可能被误路由,参数也可能在批准后改变。

“Abort Promise 返回就代表副作用停止”

错误。底层 Driver 必须观察 Signal,已发生动作还要对账。

“Skill 是 Markdown,不需要测试”

错误。它会改变模型的 Tool 选择和执行流程。

“Faux Provider 不能测试真实 Agent”

错误。它非常适合测试控制流与不变量;真实 Provider 用于补行为评估。

“版本固定后就不需要兼容测试”

错误。升级、Session 恢复与 Extension 包仍会跨版本发生。

25. 本章重点总结

  • 生产安全是 Prompt、Extension、Approval、Broker、Sandbox 与 Audit 的分层组合;
  • Timeout 必须触发 Abort,Abort 必须传到底层,Cleanup 必须幂等;
  • 副作用使用 Idempotency Key,并把未知结果标为 Indeterminate;
  • Unit/Faux/Replay 测确定控制流,Golden Eval 测模型效果;
  • Skill 测发现、读取、遵循与安全,Extension 测错误、生命周期、冲突与并发;
  • Session Replay 只重建状态,绝不重新执行 Tool;
  • Diagnostics 来自多个层,不是一个统一数组;
  • 打包固定 Worker/依赖,升级运行契约测试并准备 Canary/Rollback。

26. 练习

  1. 为什么 Approval Capability 要绑定 argsHash 和 toolCallId?
  2. Promise.race() 超时为什么可能留下后台副作用?
  3. 哪些测试最适合 Faux Provider,哪些必须用真实模型?
  4. Skill 的静态测试与 Golden Task 测试分别发现什么问题?
  5. Worker 崩溃后 Tool 状态为什么可能是 Indeterminate?
  6. Session Replay 为什么不能调用 Tool?
  7. 设计一份包含两个并行 Session 的隔离测试。
  8. 当前 RPC 没有版本握手时,桌面应用怎样固定兼容版本?