工程化
原理大家都能讲,差距在这里:状态恢复、事件协议、扩展机制、安全边界、可靠性、测试与评测。
状态、持久化与故障恢复
基础只追加的会话树
agent 任务可能跑几十分钟,期间进程会崩、会发布。会话状态要写入磁盘,常用设计是只追加的事件日志:每条记录带 id 和 parentId,组成一棵树。重放即可恢复;回到历史节点即可分支;完整记录可审计。点击节点,看哪些记录会发给模型:
进阶pi 的记录类型与上下文重建
| type | 内容 | 进上下文 |
|---|---|---|
message | system / 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 队列。
- 大对象外置:工具输出、图片放对象存储,事件里存引用。
业务幂等键:交给执行端去重的键,可以由“会话 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)。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 章)。个人工具倾向客户端;托管平台倾向服务端。
事件流、流式输出与集成协议
基础事件序列
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(进程内调用)。
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 calling | harness ↔ 模型 | 2023-06 | 事实标准(第 3 章) |
| MCP | agent ↔ 工具与数据 | 2024-11 | 已成标配,由 Linux Foundation 治理(第 16 章) |
| A2A | agent ↔ 其他组织的 agent | 2025-04(Google) | 2025-06 交给 Linux Foundation,主要在企业平台里用 |
| AG-UI | agent 后端 ↔ 前端界面 | 2025-05(CopilotKit) | 标准化本章讲的这类事件流 |
| ACP | coding 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 约定)。
- 作者判断任务变长后,界面从“看着流式输出”转向“异步通知 + 事后回放”,断线续传和事件回放的重要性上升。
扩展机制与钩子
基础为什么 harness 需要插件化
不同团队对 agent 的要求差异很大:有人要审批、有人要接内部系统、有人要换 UI。把这些都写进核心会让它臃肿且难以维护。插件化让核心只提供事件点 + 注册接口,其余交给扩展。
扩展 ≈ Spring Boot starter:默认导出一个工厂函数,拿到 pi 这个“容器上下文”,往里注册 Bean(工具、命令)和监听器。事件钩子 ≈ HandlerInterceptor 链 / AOP @Around。
进阶三个最小示例
pi 的做法注册工具、拦截工具调用、改写 system prompt,各只要十几行。examples/extensions/hello.ts · permission-gate.ts · pirate.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); }
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; });
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。
安全、权限与人在环路
基础提示词注入
- 直接注入:用户在输入里写“忽略之前的指令”。
- 间接注入:模型读到的网页、文件、邮件、工具结果里藏着指令。它不是 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 沙箱里执行,只挂载需要的目录,出网白名单 |
| 最小权限 | 凭据不进沙箱,或用短期、窄权限令牌;按任务只开放必要工具 |
| 拦截钩子 | 工具执行前检查参数,危险操作拦截或要求确认 |
| 人在环路 | 不可逆操作(删数据、转账、发邮件、合并代码)必须人确认 |
| 审计 | 全量记录轨迹,可追溯 |
深入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 包审计一样成为日常工作。
- 有争议逐条审批的价值。审批太多,人会习惯性点“允许”;所以越来越多的产品选择“沙箱内自动放行,越界才问”。
可靠性:重试、降级与恢复
基础常见故障与对策
| 故障 | 对策 |
|---|---|
| 限流(429)、过载、网络抖动 | 指数退避 + 抖动重试;尊重 Retry-After |
| 厂商整体故障 | 多厂商降级;降级组合要提前评测 |
| 上下文溢出 | 压缩后重试一次 |
| 流式中断 | 丢弃不完整消息重新请求,不执行半截工具调用 |
| 成本失控 | 按任务 / 用户 / 租户设 token 与费用预算 |
| 工具挂起 | 超时 + 杀进程树 |
进阶重试放在哪一层
pi 的做法HTTP 层默认不重试,重试放在懂语义的 agent 层,避免两层重试次数相乘。docs/settings.md · Retry
| 设置 | 默认 | 含义 |
|---|---|---|
retry.maxRetries | 3 | agent 层重试次数 |
retry.baseDelayMs | 2000 | 指数退避初始延迟 |
retry.maxAgentDelayMs | 60000 | agent 层最大延迟 |
retry.provider.maxRetries | 0 | HTTP 客户端层重试次数 |
重点在最后一行:底层 HTTP 重试默认关闭,文档解释是“底层重试会拖延 pi 自己处理额度和用量限制错误”。也就是说,重试应该放在理解业务语义的那一层:agent 层知道这是额度耗尽(不该重试,应提示用户或换模型)还是瞬时错误(该重试),而 HTTP 层只看到一个 429。两层都重试还会造成重试次数相乘。
这和微服务里“只在最外层重试、内层不重试”避免重试风暴是同一个原则。LLM 网关可以用 Resilience4j 的重试、熔断、限流、舱壁实现;区别是下游又贵又慢、输出不确定,所以“预算”和“评测”必须单独设计。
深入恢复的顺序
pi 的做法失败的尝试先写入磁盘,再用追加的编辑记录对模型隐去,最后作为一次新运行重试。docs/compaction.md · Overflow and Length Recovery Ordering
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 章),所以降级组合必须提前评测。
测试与评测
基础两件不同的事
- 测试 harness 本身(确定性):循环、工具、会话恢复、钩子在各种模型输出下是否表现正确。用假的模型。
- 评测 agent 的效果(非确定性):在真实模型下任务完成得怎么样。用评测集。
进阶用脚本化的假模型做确定性测试
pi 的做法用预先编排好回复的假模型,确定性地测试循环的各种边界情况。packages/ai · Faux Provider for Tests
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 章说“钩子注入让循环可测试”的具体做法。
@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 章)。
深入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 里的比重会继续上升。
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(模板)。
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、企业托管授权改为正式扩展。
进阶为什么现在不怎么讨论了:过时了吗
没有过时。热度下降有三个原因,只有一个是坏消息:
- 它成了基础设施。主流模型厂商、IDE、桌面客户端都已支持,治理交给中立基金会,规范在按部就班地迭代。剩下的话题是部署、鉴权、网关、审计,这些在企业平台团队里讨论,不在社交媒体上。
- 热点转移了。2025 年下半年起,大家的注意力转向 context engineering、skills、code mode、harness engineering。这些新词大多是在解决“怎么把 MCP 用好”,而不是替换它。
- 它暴露了真实缺陷(这是坏消息)。最大的问题是上下文成本:传统做法把所有工具定义在会话开始时塞进上下文,接几个 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 |
| A2A | agent 之间协作 | 不同层。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 间通信和身份)能否在开发者侧流行起来。目前主要在大型企业平台里使用。