第二部分

工程化

原理大家都能讲,差距在这里:状态恢复、事件协议、扩展机制、安全边界、可靠性、测试与评测。

第 10 章 重要

状态、持久化与故障恢复

基础只追加的会话树

agent 任务可能跑几十分钟,期间进程会崩、会发布。会话状态要写入磁盘,常用设计是只追加的事件日志:每条记录带 id 和 parentId,组成一棵树。重放即可恢复;回到历史节点即可分支;完整记录可审计。点击节点,看哪些记录会发给模型:

会话树

进阶pi 的记录类型与上下文重建

type内容进上下文
messagesystem / user / assistant / toolResult是
compaction摘要 + firstKeptEntryId + tokensBefore以摘要形式
branch_summary切换分支时对离开分支的摘要是
context_edit从后续上下文中删除或替换某条旧记录,原记录不动影响其他记录
custom_message扩展注入的消息是
custom model_change usage label扩展状态、模型切换、额外用量、书签否
pi 的做法沿 parentId 走出当前路径,套用最新的压缩和 context_edit,再转换成模型消息。core/session-manager.ts · buildContextEntries() / buildSessionContext()
  • 从当前叶子沿 parentId 走到根,得到路径。
  • 路径上如果有 compaction,取最新的一条:先放它,再放 firstKeptEntryId 到它之间的非 system 记录,再放它之后的记录。压缩前的 system 消息折叠进 compaction 自带的完整检查点。
  • 应用每个目标的最新 context_edit(只影响模型看到的内容,原始记录、导出和计费不变)。
  • 转换为模型消息:compaction → 检查点 + 摘要;branch_summary → 摘要消息;custom / usage → 不产生消息。

context_edit 是个巧妙的设计:既要“让模型忘掉某条失败的尝试”,又要保持只追加。做法是追加一条“编辑记录”,而不是修改原记录。而且它是分支相对的:回到编辑之前的节点,原内容又会重新可见。

深入崩溃恢复、幂等与并发

  • 按 turn 边界持久化:assistant 消息写入磁盘后再执行工具;工具结果写入磁盘后再发起下一次请求。
  • 恢复时的“悬空调用”:最后一条是带工具调用的 assistant 消息却没有结果,说明执行状态未知。只读工具直接重跑;写操作要么能安全重试(幂等,见下面的说明),要么先查询外部状态;都做不到,就回填“执行状态未知”,让模型先检查。
  • 单写者:同一会话同时只能有一个循环在跑。分布式部署用会话级锁或按会话 ID 分区的队列;运行中的新消息进 steering / follow-up 队列。
  • 大对象外置:工具输出、图片放对象存储,事件里存引用。
“用 toolCallId 幂等”要分清三样东西 调用标识:toolCallId 标识模型发出的这一次调用。它只在 assistant 消息先落盘、恢复时沿用同一条消息的前提下才稳定;如果消息没落盘、重新请求模型,新回复里的调用会换一个 ID。
业务幂等键:交给执行端去重的键,可以由“会话 ID + toolCallId”派生。前提是执行端认识它:下游 API 支持幂等键(例如 Idempotency-Key 请求头),或者你自己的执行层把“这个键已执行”和业务副作用记在同一个事务里。
执行结果记录:harness 自己写下的“执行了没有、结果是什么”,也就是 toolResult。
失败例子:agent 调用 create_merge_request,Git 平台已经建好了 MR,worker 却在写入 toolResult 之前崩溃。恢复后拿同一个 toolCallId 重试,如果 Git 平台不认这个键,就会建出第二个 MR:toolCallId 只让我们认出这是同一次调用,没让对方去重。可行的做法:把幂等键写进请求本身(比如放进分支名或 MR 描述),恢复时先按键查询是否已存在;或者用 outbox 模式,先把“要执行的动作”写库,由单独的执行器负责去重并记录结果。即使这样,“副作用已发生、结果还没记下”的窗口依然存在,所以恢复逻辑必须能查询外部状态(参考 AWS:Making retries safe with idempotent APIs)。
会话事件表(PostgreSQL)示意
create table session_entry (
  session_id   uuid        not null,
  seq          bigint      not null,           -- 会话内单调递增,用于断线续传和乐观并发
  entry_id     char(8)     not null,
  parent_id    char(8),                          -- 树结构
  type         text        not null,           -- message / compaction / branch_summary / context_edit …
  payload      jsonb       not null,
  created_at   timestamptz not null default now(),
  primary key (session_id, seq),
  unique (session_id, entry_id)
) partition by hash (session_id);

-- 追加时带上期望的 seq,冲突即说明有并发写者:insert … values (:sid, :expectedSeq, …)
想一想
服务挂了,agent 执行到一半,怎么恢复?会不会重复执行写操作?
  • 按 turn 边界持久化,重放事件日志恢复上下文。
  • 对悬空的工具调用分类处理:只读重跑;写操作用执行端认得的幂等键重试,或先查外部状态;无法确认则告诉模型。
  • 更进一步可以用持久化执行框架(类似 Temporal)把每次工具调用作为可重试的活动。
前瞻 · 本章
  • 已有证据状态分成两层并存:会话日志记录“对话发生了什么”,git 和项目文件记录“世界变成了什么样”。Anthropic 的长任务做法两者都用(第 9 章)。
  • 已有证据只追加的事件日志是常见设计:Claude Code、Codex CLI、pi 的会话文件都是 JSONL 事件流,都支持恢复会话(见 Claude Code 文档、Codex 源码 rollout/src/recorder.rs、pi 的 docs/session-format.md)。
  • 作者判断Temporal 这类持久化执行框架会更多地用来承载 agent:每次模型调用和工具调用都是可重试、可恢复的活动,等审批时不必占着 worker(沙箱等资源怎么保留,见第 17 章)。
  • 有争议状态放客户端还是厂商服务端(第 3 章)。个人工具倾向客户端;托管平台倾向服务端。
第 11 章 延伸

事件流、流式输出与集成协议

基础事件序列

pi 一次运行的事件json / rpc 模式输出
agent_start
  turn_start
    message_start / message_end                     ← 用户消息
    message_start / message_update* / message_end   ← 流式 assistant 消息
    tool_execution_start / _update* / _end          ← 每个工具调用
    message_start / message_end                     ← toolResult 消息
  turn_end
  …
agent_end                                           ← 一次底层运行结束
agent_settled                                       ← 不会再自动继续
  • 流式增量只用于展示,message_end 里的完整消息才是权威结果,落库以它为准。
  • agent_end 之后还可能有自动重试、压缩恢复、排队消息;要知道“彻底完成”,看 agent_settled。

进阶推送给前端与外部系统

  • SSE 足够大多数场景(单向、自动重连、穿透代理友好);需要双向交互(审批、插话)用 WebSocket。事件带递增序号,断线后从序号续传。
  • pi 提供四种接入方式:print(跑完输出结果)、JSON(单次运行的事件流)、RPC(长驻子进程,stdin 收命令、stdout 出事件)、TypeScript SDK(进程内调用)。
从 Java 驱动 pi(RPC 模式)Java 17 + Jackson · 示意
var pb = new ProcessBuilder("pi.cmd", "--mode", "rpc", "--no-session")   // Windows 下 npm 装的是 pi.cmd
        .directory(new File("E:/repo/demo"))
        .redirectError(ProcessBuilder.Redirect.INHERIT);                 // stderr 是日志,stdout 只有协议
Process pi = pb.start();
var json = new ObjectMapper();
var stdin = new BufferedWriter(new OutputStreamWriter(pi.getOutputStream(), UTF_8));
var stdout = new BufferedReader(new InputStreamReader(pi.getInputStream(), UTF_8));

stdin.write(json.writeValueAsString(Map.of("id", "req-1", "type", "prompt", "message", "列出所有 Controller")) + "\n");
stdin.flush();

String line;
while ((line = stdout.readLine()) != null) {                       // 生产代码应放在独立线程里持续读
    JsonNode ev = json.readTree(line);
    switch (ev.path("type").asText()) {
        case "response" -> System.out.println("[accepted] " + ev.path("success"));   // 只代表已接受
        case "message_update" -> {
            JsonNode d = ev.path("assistantMessageEvent");
            if ("text_delta".equals(d.path("type").asText())) System.out.print(d.path("delta").asText());
        }
        case "tool_execution_start" -> System.out.println("\n[tool] " + ev.path("toolName").asText());
        case "agent_settled" -> { pi.destroy(); return; }
        default -> {}
    }
}

深入协议细节与 await 屏障

pi 的做法严格按换行符分帧;客户端必须持续读输出;命令和响应用 id 关联。docs/rpc.md · docs/json.md · agent/src/agent.ts
  • 严格 JSONL 分帧:只按 LF 切分。文档特别警告 Node 的 readline 会把 U+2028 / U+2029 当成换行,而这两个字符可以合法地出现在 JSON 字符串里。Java 的 BufferedReader.readLine() 只认 \n、\r,而 JSON 里的这两个都已转义,所以可以直接用。
  • 背压:客户端必须持续读 stdout,否则管道缓冲区写满,pi 会阻塞。
  • 线上体积:wire 上的 message_update 只带增量,去掉了累计的 partial 快照,让流的大小随输出线性增长而不是平方增长。
  • 命令与响应用 id 关联,因为处理是异步的,不能按顺序对应。
  • await 屏障:Agent 类会依次 await 每个事件监听器;直接消费底层 agentLoop 生成器则只是“观察”,事件顺序不变但不会等待处理器。需要改变行为的逻辑(例如扩展的 tool_call 拦截)走的是 beforeToolCall 这类钩子,循环会 await 它的返回值再决定是否执行。

深入可观测性

指标为什么重要
任务成功率最核心的质量指标,靠评测或用户反馈判定
每任务 token / 费用 / turn 数成本控制;turn 数过高常意味着模型在打转
缓存命中率直接影响成本和延迟;突降说明前缀被改动
工具错误率(按工具)定位描述不清或实现有问题的工具
首 token 延迟、端到端耗时用户体验
压缩、拦截、人工介入、重试次数上下文压力、安全与稳定性

链路追踪:一次任务一个 trace,每个 turn、每次模型调用、每次工具执行一个 span,属性记录 token、缓存命中、耗时、错误,可对接 OpenTelemetry。OpenTelemetry 已有专门的 GenAI 语义约定(还在演进中),规定了模型调用、token 用量、agent 和工具 span 的标准属性名,按它埋点,换观测平台不用改代码。排查线上问题时,能完整回放每个 turn 发给模型的上下文是最关键的能力。

Agent 协议家族仍在演变MCP、A2A、ACP、AG-UI:每个协议连接的是不同的两端
协议连接的两端出现现状
function callingharness ↔ 模型2023-06事实标准(第 3 章)
MCPagent ↔ 工具与数据2024-11已成标配,由 Linux Foundation 治理(第 16 章)
A2Aagent ↔ 其他组织的 agent2025-04(Google)2025-06 交给 Linux Foundation,主要在企业平台里用
AG-UIagent 后端 ↔ 前端界面2025-05(CopilotKit)标准化本章讲的这类事件流
ACPcoding agent ↔ 编辑器2025-08(Zed)同一个 agent 接进不同编辑器,类似 LSP 之于语言服务器

怎么看:pi 的 RPC 模式(stdin 收命令、stdout 出事件)和 ACP、AG-UI 解决的是同一类问题。自己设计事件协议时,先看这几个标准有没有已经覆盖你的场景。

想一想
线上 agent 回答错了,怎么排查?
  • 按 trace 回放完整轨迹:每个 turn 的上下文、模型回复、工具输入输出。
  • 定位出错环节:上下文缺信息?工具返回错误数据?选错工具?总结出错?
  • 把案例加入评测集,修复后跑回归。
前瞻 · 本章
  • 已有证据agent 接入的每一段都已出现标准或候选标准(采用程度差别很大):工具(MCP)、编辑器(ACP)、前端(AG-UI)、agent 之间(A2A)、观测(OTel GenAI 约定)。
  • 作者判断任务变长后,界面从“看着流式输出”转向“异步通知 + 事后回放”,断线续传和事件回放的重要性上升。
第 12 章 延伸

扩展机制与钩子

基础为什么 harness 需要插件化

不同团队对 agent 的要求差异很大:有人要审批、有人要接内部系统、有人要换 UI。把这些都写进核心会让它臃肿且难以维护。插件化让核心只提供事件点 + 注册接口,其余交给扩展。

Java 类比

扩展 ≈ Spring Boot starter:默认导出一个工厂函数,拿到 pi 这个“容器上下文”,往里注册 Bean(工具、命令)和监听器。事件钩子 ≈ HandlerInterceptor 链 / AOP @Around。

进阶三个最小示例

pi 的做法注册工具、拦截工具调用、改写 system prompt,各只要十几行。examples/extensions/hello.ts · permission-gate.ts · pirate.ts
① 注册工具hello.ts
const helloTool = defineTool({
  name: "hello", label: "Hello",
  description: "A simple greeting tool",
  parameters: Type.Object({ name: Type.String({ description: "Name to greet" }) }),
  async execute(_id, params, _signal, _onUpdate, _ctx) {
    return { content: [{ type: "text", text: `Hello, ${params.name}!` }],   // 给模型
             details: { greeted: params.name } };                             // 给界面 / 状态重建
  },
});
export default function (pi: ExtensionAPI) { pi.registerTool(helloTool); }
② 拦截工具调用permission-gate.ts
pi.on("tool_call", async (event, ctx) => {
  if (event.toolName !== "bash") return undefined;
  const command = event.input.command as string;
  if (dangerousPatterns.some((p) => p.test(command))) {
    if (!ctx.hasUI) return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
    const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n  ${command}\n\nAllow?`, ["Yes", "No"]);
    if (choice !== "Yes") return { block: true, reason: "Blocked by user" };
  }
  return undefined;
});
③ 每次运行前改写 system promptpirate.ts(节选)
pi.on("before_agent_start", async (event) => {
  if (pirateMode) return { systemPrompt: event.systemPrompt + "\n\nIMPORTANT: You are now in PIRATE MODE…" };
  return undefined;
});

深入契约设计:失败安全、组合与状态

pi 的做法钩子自己出错就拦截;状态放进工具结果,跟着分支走;资源在会话开始时创建、结束时清理。docs/extensions.md · core/extensions/types.ts
  • 失败即拦截(fail-safe):tool_call 处理器自己抛异常时,pi 拦截这次调用,而不是放行。安全相关的钩子必须这样设计:钩子坏了宁可不执行。
  • 怎么标记工具失败:execute 抛异常会生成失败结果;也可以正常 return 一个带 isError: true 的结果,在标记失败的同时带上结构化数据。只是 return 而不标记,就不算错误。
  • 组合语义:处理器按加载和注册顺序执行;tool_result 处理器是链式组合的,每个看到前一个的修改;不同事件的返回值语义不同(有的只是通知,有的可以改写或取消),必须看事件声明的结果类型。
  • 状态放哪里:跟随分支的工具状态放工具结果的 details(切换分支时自动正确);不给模型看的持久数据用 pi.appendEntry();要给模型看的用 pi.sendMessage();跨会话的放外部存储。启动时从当前分支(而不是整个文件)重建状态。
  • 生命周期:工厂函数里不要启动进程、定时器,因为有些场景只加载扩展而不启动会话;放到 session_start,在幂等的 session_shutdown 里清理。
  • 可续跑的边界:turn_end、agent_before_settle 处理器可以返回 continue: true 请求再跑一轮,文档提醒必须加条件,否则会死循环。
  • 供应链:项目级扩展(.pi/extensions)是可执行代码,必须先信任项目才加载(第 13 章)。
Hooks / Plugins稳定ChatGPT 插件停用了,但“harness 可扩展”这件事成了标配
  • ChatGPT Plugins:第一次尝试让第三方给模型加能力。半年后被 GPTs 取代,2024 年停用。
  • Claude Code 推出 hooks:在工具调用前后、会话开始结束等时机执行用户脚本,可以拦截。
  • Claude Code 推出 plugins(打包命令、子 agent、hooks、MCP 配置、skills)和插件市场;OpenAI 推出基于 MCP 的 Apps SDK,ChatGPT 里的“插件”以新的形式回归。

两种“插件”要分清:2023 年的 ChatGPT Plugins 是“给模型加工具”,这个职责后来交给了 MCP;今天 harness 的 hooks / extensions 是“改变 harness 自己的行为”(拦截、改写上下文、加命令),是本章讲的东西。pi 的扩展系统属于后者,而且比多数产品开放得多:工具、命令、事件、UI 都能扩展。

前瞻 · 本章
  • 已有证据主流 coding agent 都提供了 hooks 或插件机制。第 1 章说的“针对当前模型的补丁”最适合放在这里,方便以后删掉。
  • 已有证据扩展、skill、MCP server 都是可执行代码或可注入的文本,已经出现过恶意 MCP server 和工具投毒的公开案例;分发生态越大,供应链安全越重要:它们都是可执行代码或可注入的文本(第 13 章)。
  • 作者判断“让 agent 给自己写扩展”会更常见:pi 的 README 就鼓励“让 pi 去构建你想要的功能”。扩展 API 的稳定性和文档质量,决定了模型能不能自己扩展 harness。
第 13 章 重要

安全、权限与人在环路

基础提示词注入

  • 直接注入:用户在输入里写“忽略之前的指令”。
  • 间接注入:模型读到的网页、文件、邮件、工具结果里藏着指令。它不是 agent 特有的:只读检索文档再回答的 RAG 问答系统也会中招(Greshake 等人 2023 年的论文演示的就是这类集成了检索的 LLM 应用)。agent 的额外危险在于,注入能变成真实的动作:发请求、改文件、执行命令。

Simon Willison 的“致命三要素”(lethal trifecta)是分析这类风险的好框架:当 agent 同时具备 ① 能访问私有数据、② 会接触不可信内容、③ 能向外发送信息,攻击者就可能通过注入把数据偷走。防御思路是至少切断一条。

Prompt injection未解决词出现四年了,问题仍没有模型层面的根治办法
  • Simon Willison 命名 prompt injection,类比 SQL 注入。
  • Greshake 等人的论文提出间接注入:攻击指令藏在模型会读到的网页、邮件、文档里。
  • Google DeepMind 的 CaMeL:用一个只看可信输入的模型生成执行计划,不可信数据只当数据流过,不能改变控制流。
  • MCP “工具投毒”被公开演示:恶意指令藏在 MCP 工具描述里。
  • “致命三要素”提出;多家机构联合发表《Design Patterns for Securing LLM Agents against Prompt Injections》,总结出计划先行、双 LLM、动作选择器等架构模式。

现状:模型的抗注入能力在提升,但各厂商都不承诺它是安全边界。工程上的共识是:用架构限制注入成功后的危害,也就是本章的沙箱、最小权限、出网控制和人工确认。

进阶纵深防御

层次做法
隔离工具在容器 / 虚拟机 / OS 沙箱里执行,只挂载需要的目录,出网白名单
最小权限凭据不进沙箱,或用短期、窄权限令牌;按任务只开放必要工具
拦截钩子工具执行前检查参数,危险操作拦截或要求确认
人在环路不可逆操作(删数据、转账、发邮件、合并代码)必须人确认
审计全量记录轨迹,可追溯
工具前置钩子模拟模型想执行一条 shell 命令
关键认知正则黑名单、提示词里的“不要做 X”都不是安全边界,只能防误操作:命令可以换种写法绕过,模型也可能被说服。真正的边界在操作系统和虚拟化层。

深入pi 的安全模型:坦率承认边界

pi 的做法坦白说明没有逐个审批,推荐整个放进容器;项目信任只防“陌生仓库的扩展自动运行”。docs/security.md · examples/extensions/sandbox
  • 不逐个审批:工具和扩展以 pi 进程的系统权限运行。文档明确说“看着对话记录、项目信任、审查改动都不构成安全边界”,推荐把整个 pi 放进容器或虚拟机。
  • 项目信任(project trust)管什么:防止 clone 一个陌生仓库、一启动就执行了里面的扩展。发现 .pi/settings.json、.pi/extensions、.pi/SYSTEM.md 等项目资源时,先问是否信任,决定保存在 ~/.pi/agent/trust.json。
  • 项目信任不管什么:AGENTS.md / CLAUDE.md 不受信任控制,总会加载,所以陌生仓库里的规则文件仍可能是注入载体;信任也不限制工具能访问的路径。
  • 非交互模式:print / JSON / RPC 没法弹窗,没有预设决定时按 defaultProjectTrust 处理,默认 "ask" 在这些模式下等于跳过项目资源。
  • OS 级沙箱示例:sandbox 扩展用 @anthropic-ai/sandbox-runtime 替换内置 bash,在 macOS 用 sandbox-exec、Linux 用 bubblewrap 限制文件系统和网络,配置如 "allowedDomains": ["github.com"]、"denyRead": ["~/.ssh", "~/.aws"]、"denyWrite": [".env"]。
想一想
如何防范 agent 的提示词注入?
  • 承认无法在模型层彻底解决,用架构限制影响范围。
  • 用致命三要素分析,至少切断一条。
  • 沙箱 + 最小权限 + 出网白名单 + 敏感操作确认 + 审计。
  • 不可信内容标记来源;规则文件、MCP 工具描述也可能是注入载体。
人工审批拖慢 agent,怎么平衡?
  • 按风险分级:只读自动放行;沙箱内可逆写操作自动放行;不可逆或对外操作审批。
  • 用工具副作用标注(只读、破坏性、幂等、是否与外部世界交互)辅助判断。前提是标注来自可信的来源:第三方 server 自报的标注可能是假的。
  • 支持“本会话同类操作都允许”。
  • 无人值守默认拒绝,或放进完全隔离的环境。
前瞻 · 本章
  • 已有证据沙箱从可选变成默认:Codex CLI 默认在沙箱里执行命令,Claude Code 提供了 OS 级沙箱(pi 示例用的就是它开源的 sandbox-runtime),云端 agent 每个任务一个隔离容器。
  • 已有证据目前没有厂商把模型的抗注入能力当作安全边界来承诺,安全靠架构。
  • 作者判断提示词注入在可预见的时间内不会被模型单独解决。
  • 作者判断“凭据不进沙箱、由外部代理注入”成为标准做法;CaMeL 式的数据流控制会出现在高风险场景(处理邮件、财务)的生产系统里。
  • 作者判断skills、扩展、MCP server 的供应链审计会像 npm 包审计一样成为日常工作。
  • 有争议逐条审批的价值。审批太多,人会习惯性点“允许”;所以越来越多的产品选择“沙箱内自动放行,越界才问”。
第 14 章 重要

可靠性:重试、降级与恢复

基础常见故障与对策

故障对策
限流(429)、过载、网络抖动指数退避 + 抖动重试;尊重 Retry-After
厂商整体故障多厂商降级;降级组合要提前评测
上下文溢出压缩后重试一次
流式中断丢弃不完整消息重新请求,不执行半截工具调用
成本失控按任务 / 用户 / 租户设 token 与费用预算
工具挂起超时 + 杀进程树

进阶重试放在哪一层

pi 的做法HTTP 层默认不重试,重试放在懂语义的 agent 层,避免两层重试次数相乘。docs/settings.md · Retry
设置默认含义
retry.maxRetries3agent 层重试次数
retry.baseDelayMs2000指数退避初始延迟
retry.maxAgentDelayMs60000agent 层最大延迟
retry.provider.maxRetries0HTTP 客户端层重试次数

重点在最后一行:底层 HTTP 重试默认关闭,文档解释是“底层重试会拖延 pi 自己处理额度和用量限制错误”。也就是说,重试应该放在理解业务语义的那一层:agent 层知道这是额度耗尽(不该重试,应提示用户或换模型)还是瞬时错误(该重试),而 HTTP 层只看到一个 429。两层都重试还会造成重试次数相乘。

Java 类比

这和微服务里“只在最外层重试、内层不重试”避免重试风暴是同一个原则。LLM 网关可以用 Resilience4j 的重试、熔断、限流、舱壁实现;区别是下游又贵又慢、输出不确定,所以“预算”和“评测”必须单独设计。

深入恢复的顺序

pi 的做法失败的尝试先写入磁盘,再用追加的编辑记录对模型隐去,最后作为一次新运行重试。docs/compaction.md · Overflow and Length Recovery Ordering
溢出 / 长度恢复的执行顺序compaction.md
persist final assistant response           ← 失败的这次尝试也要先写入磁盘
→ extension/public turn_end
→ extension/public agent_end              ← 事件照常发出,监听者看到完整生命周期
→ append context_edit omissions           ← 用追加的编辑记录让模型“看不到”失败的尝试
→ for overflow/length: append compaction
→ start the retry as a fresh run          ← 作为新的一次运行重试
  • 失败的尝试不删除,计费、导出、审计仍能看到,只是从模型上下文中隐去。
  • 恢复压缩本身失败或被取消,就不再重试,避免“压缩失败 → 重试 → 再溢出”的循环。
前瞻 · 本章
  • 已有证据在企业平台里,多模型路由和降级很常见:主模型、便宜模型、备用厂商同时在用,LLM 网关是常见组件。
  • 作者判断任务变长后,可靠性的单位从“一次请求”变成“一个任务”:重点从重试转向检查点和可续跑(第 9、10 章)。
  • 作者判断降级要考虑模型和 harness 的绑定:备用模型可能需要不同的工具格式和提示(第 7 章),所以降级组合必须提前评测。
第 15 章 重要

测试与评测

基础两件不同的事

  • 测试 harness 本身(确定性):循环、工具、会话恢复、钩子在各种模型输出下是否表现正确。用假的模型。
  • 评测 agent 的效果(非确定性):在真实模型下任务完成得怎么样。用评测集。

进阶用脚本化的假模型做确定性测试

pi 的做法用预先编排好回复的假模型,确定性地测试循环的各种边界情况。packages/ai · Faux Provider for Tests
fauxProviderai/README.md
const faux = fauxProvider({ tokensPerSecond: 50 });
const models = createModels();
models.setProvider(faux.provider);

faux.setResponses([                                          // 预先编排模型的每一次回复
  fauxAssistantMessage([
    fauxThinking('Need to inspect package metadata first.'),
    fauxToolCall('echo', { text: 'package.json' }),
  ]),
  // … 下一轮的回复 …
]);

有了它,就能确定性地测试“模型返回截断的工具调用时不执行”“工具抛异常后循环继续”“steering 消息在正确的位置注入”这类边界情况,还能控制输出速度来测流式和中止。这也是第 4 章说“钩子注入让循环可测试”的具体做法。

Java 里的等价做法JUnit 5 · 示意
@Test
void truncatedToolCallIsNotExecuted() {
    var llm = ScriptedLlm.of(
        assistant(LENGTH, toolCall("bash", "{\"command\":\"rm -rf")),   // 被截断的参数
        assistant(STOP, text("done")));
    var bash = spy(new BashTool());
    new AgentLoop(llm, Map.of("bash", bash), ToolGuard.allowAll()).run(session, "clean up", CancelToken.none());
    verify(bash, never()).execute(any(), any());
    assertThat(session.last(2).get(0)).isToolError();
}

深入评测集的设计

评什么怎么评
最终结果能程序判定就程序判定:跑隐藏测试(SWE-bench 的做法)、核对数据库状态
轨迹是否调用了该调用的工具、参数是否正确、有无危险操作(“退款前必须先查订单”)
开放式输出LLM 当评委,给明确评分标准,定期用人工标注校准
效率token、费用、turn 数、耗时
  • 非确定性:每个用例跑多次。pass@1(单次成功的概率,用多次运行的平均成功率估计)是最基本的数字;pass@k(k 次里至少成功一次)衡量能力上限;pass^k(k 次全部成功)衡量稳定性,τ-bench 用的就是这个思路,生产更关心后者。
  • 让数字可比:每次运行前把环境重置到同一个初始状态(仓库、数据库、沙箱),否则上一次的残留会影响下一次;比较两个版本时固定模型、温度、最大轮数和 token 预算;调提示词用的任务和最后报告成绩的任务分开,否则成绩会虚高。
  • 小样本会抖:20 个任务里多做对 1 个就是 5 个百分点。20 个任务各跑 3 次适合练手、发现明显问题,但撑不起“提升了 3 个点”这种结论:要么加任务、加次数,要么看同一批任务上逐题的成败变化,并报告波动范围(参考 Anthropic:Demystifying evals for AI agents)。
  • LLM 评委的偏差:偏好更长的回答、位置偏差、偏好同家模型。缓解:具体评分标准、成对比较时交换顺序、换模型当评委、和人工标注算一致性。
  • 来源:线上真实失败案例最有价值,再加人工构造的边界情况和公开基准(SWE-bench、Terminal-Bench、τ-bench)。
  • 进 CI:改提示词、换模型、改工具都跑回归,像单元测试一样。
  • 读基准成绩时,harness 也是被测对象。Terminal-Bench 这类基准测的是“模型 + harness”的组合,同一个模型在不同 harness 里能差十几个百分点;很多榜单成绩是各家用自己的 harness 跑出来的。比较模型要在同一个 harness 下比,比较 harness 要在同一个模型下比。
  • 消融评测:换模型时,逐个关掉提示里的提醒、规划步骤、额外工具,看成绩是否下降。不下降就删,这是让 harness 跟着模型“变薄”最可靠的办法(第 1 章)。
同一个验证器,四个用处判定“做对了”的程序(隐藏测试、状态检查、评分标准),在 agent 运行时是自我检查,在这里是评测,写回文件时决定哪条经验值得留(第 9 章),在厂商那里是强化学习的奖励(第 2 章)。所以它的盲区也会出现在四个地方:只看最终答案的检查,会放过过程错误的解;只看测试通过的检查,会放过改了测试本身的“修复”。设计评测时先问:有没有办法在不完成任务的情况下拿到分?

深入RAG 和检索的评测

检索类系统要分三层评:找没找到、用没用对、划不划算。三层分开测,出问题才知道该改哪一环。

评什么指标说明
找全了吗Recall@k、Precision@k标注好每个问题的相关段落,看前 k 条里命中多少
排得好吗MRR、NDCG有用的证据是否排在前面;NDCG 还能区分“完全相关”和“部分相关”
多跳问题All-Recall需要三段证据才能回答时,少一段就算没找到
答对了吗归一化后比对、LLM 评委“11 月 27 日 17:00”和“27 号下午五点”要先归一化再比
有依据吗忠实度(faithfulness)答案是否被引用的出处支持
依据新吗出处版本忠实于去年的旧文件,答案照样是错的。正确、有依据、够新要分开检查
划算吗建索引成本、查询延迟、每问费用GraphRAG 的建图成本尤其要单独记

常见误读:论文里“A 方法对 B 方法的胜率 80%”往往是 LLM 评委的偏好,不等于事实准确率提高了 80%。

SWE-bench、Terminal-Bench 与时间跨度更新很快基准被做到饱和的速度越来越快,衡量方式从“做对几道题”扩展到“能完成多长的任务”
  • SWE-bench:用真实 GitHub issue 和隐藏测试评测 coding agent,最初最好成绩只有个位数百分比。
  • τ-bench:模拟客服场景,提出 pass^k 衡量稳定性。
  • SWE-bench Verified:人工筛选出 500 道可解的题。之后两年里头部成绩快速攀升、趋于饱和,头部系统之间的分差越来越小,这个基准越来越难区分它们。比较时还要看是不是同一个 harness、同样的预算和设置。
  • METR 提出“时间跨度”:agent 能以 50% 成功率完成的任务,相当于人类专家要干多久。这个数大约每 7 个月翻一番。
  • Terminal-Bench(5 月,2.0 版 11 月)测终端里的综合任务;SWE-bench Pro(9 月)用更难、防污染的题替代饱和的 Verified。

规律:一个基准从发布到饱和通常只要一两年,所以不要背具体分数,要理解每个基准测的是什么、有什么偏差。自己业务的评测集永远比公开榜单更有用。

想一想
怎么知道你的 agent 变好了?
  • harness 逻辑用脚本化假模型做确定性单测。
  • 效果用评测集:结果 + 轨迹 + 效率,每次运行前重置环境、固定预算,多次运行看 pass@1、pass@k 和 pass^k;样本小时别把几个点的差异当结论。
  • LLM 评委要有标准并校准。
  • 接 CI 回归;线上结合用户反馈和 A/B。
前瞻 · 本章
  • 已有证据SWE-bench Verified 已接近饱和,更难的基准(SWE-bench Pro、Terminal-Bench 2.0)和新的尺子(METR 时间跨度、pass^k)在接替。
  • 已有证据评测是 harness 变薄的前提:没有评测,就不知道哪段脚手架可以删。
  • 作者判断验证会成为瓶颈:agent 产出代码的速度远超人 review 的速度,自动验证(测试、类型检查、浏览器端到端、LLM 审查)在 harness 里的比重会继续上升。
第 16 章 延伸

MCP 与工具生态:从热门话题到基础设施

基础MCP 是什么

MCP(Model Context Protocol)是 Anthropic 在 2024 年底推出的开放协议,把外部工具和数据标准化地接入 agent。以前每个 agent 要为每个系统各写一套集成(M×N),有了 MCP,系统实现一次 server,所有支持 MCP 的 agent 都能用(M+N)。

  • host 里的 MCP client 连接 MCP server,基于 JSON-RPC 2.0。
  • 传输:本地 stdio(启动子进程),远程 Streamable HTTP。
  • server 提供 tools(函数)、resources(数据)、prompts(模板)。
Java 类比

MCP ≈ JDBC。JDBC 刚出来时是热门话题,现在没人讨论 JDBC,但每个 Java 应用都在用。没人讨论不等于过时,往往意味着它成了默认的接入方式。

MCP已成标配讨论变少是因为它已经普及,讨论转移到了“怎么把它用省”
  • Anthropic 发布 MCP,配套 Claude Desktop 和一批参考 server。最初主要是本地 stdio。
  • 规范加入 Streamable HTTP 和 OAuth 授权,开始支持远程 server。同月 OpenAI 宣布支持 MCP,这是它成为事实标准的转折点。
  • Google、Microsoft 跟进;同时安全研究者公开演示“工具投毒”。各类 MCP server 目录涌现,热度达到顶峰。
  • 反思开始:工具定义全量进上下文,接几个大型 server 就占掉几万 token;pi 的作者写了《MCP vs CLI》,认为很多 MCP server 只是给 CLI 套了一层很薄的封装。
  • Cloudflare 提出 Code Mode,Anthropic 发表《Code execution with MCP》(一个示例流程从约 15 万 token 降到约 2 千)并推出 Tool Search 和 Programmatic Tool Calling:不再把所有 MCP 工具定义直接塞给模型。
  • Anthropic 推出 Skills,社区开始说“skills 可能比 MCP 更重要”;同月 OpenAI 的 Apps SDK 建立在 MCP 之上。
  • Anthropic 把 MCP 捐给 Linux Foundation 下新成立的 Agentic AI Foundation(AAIF,与 OpenAI、Block 共同发起)。
  • MCP Apps 扩展:server 可以返回交互式界面。2 月“MCP 已死,CLI 万岁”的文章引发一轮争论。
  • 2026-07-28 版规范:协议核心改为无状态,可以直接挂在负载均衡后面;弃用 Roots、Sampling、Logging;工具列表可缓存且顺序确定;授权加固;Tasks、MCP Apps、企业托管授权改为正式扩展。

进阶为什么现在不怎么讨论了:过时了吗

没有过时。热度下降有三个原因,只有一个是坏消息:

  1. 它成了基础设施。主流模型厂商、IDE、桌面客户端都已支持,治理交给中立基金会,规范在按部就班地迭代。剩下的话题是部署、鉴权、网关、审计,这些在企业平台团队里讨论,不在社交媒体上。
  2. 热点转移了。2025 年下半年起,大家的注意力转向 context engineering、skills、code mode、harness engineering。这些新词大多是在解决“怎么把 MCP 用好”,而不是替换它。
  3. 它暴露了真实缺陷(这是坏消息)。最大的问题是上下文成本:传统做法把所有工具定义在会话开始时塞进上下文,接几个 server 就占掉几万 token。其次是安全(工具描述可以藏注入、本地 server 以你的权限运行)和早期设计偏重(有状态会话难以横向扩展)。

这三个缺陷都有了回应:上下文成本靠 tool search 和 code mode 缓解,状态问题靠 2026-07 的无状态规范处理,安全问题仍然要靠第 13 章的沙箱和权限。

它真正失去的阵地是“有 shell 的本地 coding agent”。模型天生会用 git、gh、curl、jq,训练数据里有大量用法;写一个 CLI 加一份 README(或一个 skill),比起一个本地 stdio MCP server 更省上下文、能用管道组合。pi 早期就不内置 MCP,靠扩展桥接(本手册对照的 2026 年 10 月版本已内置 MCP 支持,pi mcp add)。但在没有 shell 的场景(网页版和手机上的聊天助手、多租户 SaaS 后端),以及需要统一鉴权、审计、分发的企业场景,远程 MCP 仍然是主流。

进阶和新东西之间是什么关系

相关的词它解决什么和 MCP 的关系
Function calling模型怎么表达“我要调用工具”下层。MCP 工具最终都转成工具定义交给模型(第 3 章)
Tool search / 按需加载工具太多占上下文改变暴露方式。工具仍来自 MCP,只是定义不再全量进上下文
Code mode / 代码执行多次调用的中间结果占上下文改变调用方式。MCP 工具被包装成代码 API,模型写脚本批量调用,只有结果进上下文
Skills模型不知道某类任务该怎么做互补。skill 讲方法,MCP 给能力;skill 里可以指定用某个 MCP 工具或 CLI
CLI + bash同样是“让 agent 能操作外部系统”部分替代。在有 shell 的 coding agent 里替代了很多本地 MCP server
A2Aagent 之间协作不同层。MCP 连工具,A2A 连 agent(第 11 章)
AGENTS.md项目规则放哪无关但同门。同属 AAIF 管理的开放标准
ChatGPT Plugins → Apps SDK → MCP Apps第三方给助手加能力和界面前身与延伸。2023 年的插件停用,2025 年的 Apps SDK 直接建在 MCP 上,2026 年并入 MCP 成为 MCP Apps 扩展

风险不因热度下降而消失:第三方 server 返回的内容是不可信输入;工具描述本身可能藏注入;本地 stdio server 以你的权限运行;远程 server 的凭据要妥善管理。

深入工具太多怎么办:暴露方式

pi 的做法工具有五种暴露方式:直接可见、搜索后激活、只能在代码里调用、只给模型、隐藏。docs/extensions.md · Tool exposure · docs/codemode.md
exposure模型能否直接看到用途
direct能(默认)常用工具
deferred不能,需 tool_search 找到并激活大量低频工具,按需加载
codemode不能,只能在 codemode 脚本里调用批量调用、过滤大结果
model-only能,但不能被其他工具调用编排其他工具、或需要问用户的工具
hidden不能撤回工具(工具无法注销,只能隐藏)
  • codemode:模型写一段 JavaScript,在 QuickJS 沙箱里调用其他工具(无文件系统、网络、定时器),可以并行调用、过滤大结果,只有脚本输出进入上下文。这是“用代码执行替代大量工具调用”的思路。
  • MCP 的工具标注(readOnlyHint、destructiveHint、idempotentHint、openWorldHint)可以被权限扩展当作“哪些调用需要确认”的参考;缺省值按最保守的假设处理(可能有破坏性、可能与外部世界交互;openWorldHint 说的是工具会不会碰到外部实体,不是网络权限开关)。但它们只是 server 自报的提示(hint),不是保证:恶意 server 可以把删除操作标成只读。不可信来源的标注不能用来放宽权限,真正的限制要靠沙箱和权限系统执行。

2026-07 规范的工程含义:新规范去掉了初始化握手和会话 ID,每个请求自带协议版本和客户端能力,服务端需要的状态改为“工具返回一个句柄,模型在后续调用里传回来”。这和第 10 章的设计原则一致:状态显式化,服务无状态,才能横向扩展。弃用 Sampling(server 反过来调用模型)和 Roots,说明早期一些“看起来很强大”的设计在实践中用得很少,被收回了。

想一想
MCP 现在没人讨论了,是不是过时了?
  • 没有过时,是变成了基础设施:主流厂商都支持,Linux Foundation 治理,2026-07 发布了面向大规模部署的无状态规范。
  • 热点转移到 skills、code mode、harness engineering,而这些大多在解决“怎么用好 MCP”。
  • 真实的退让:有 shell 的本地 coding agent 里,CLI + skill 替代了很多本地 MCP server;但无 shell 场景和企业集成仍以远程 MCP 为主。
给一个内部系统做 agent 集成,用 MCP、CLI 还是 skill?
  • 只给开发者在本地 coding agent 里用:CLI + skill(或 README)最省事、最省上下文。
  • 要给网页助手、多个团队、不同客户端用,需要统一鉴权和审计:远程 MCP server。
  • 操作有固定的最佳实践:再配一个 skill,写清楚步骤和注意事项。
  • 三者不冲突,常见的组合是“MCP 或 CLI 提供能力,skill 提供用法”。
前瞻 · 本章
  • 已有证据MCP 是远程工具和企业系统接入 agent 最常用的协议;2026-07-28 版规范朝无状态、可缓存、强鉴权的方向改,面向大规模部署。
  • 已有证据工具多时,定义可以不再全量进上下文:按需加载和代码执行已是多家厂商 API 提供的能力。
  • 作者判断本地 coding agent 更多使用 CLI + skills,MCP 主要出现在远程和企业集成里。
  • 有争议agent 之间的协议(A2A,以及 MCP 自己路线图上的 agent 间通信和身份)能否在开发者侧流行起来。目前主要在大型企业平台里使用。