Skip to content

4.25 Agent 系统的六条不变量

🕐 内容截至 2026-07|涉及版本:Pi v0.81.1

前面五节(4.18 ~ 4.22)我们把 Pi 的源码从架构、循环、工具、会话树一直读到扩展系统。读源码有个通病:细节记住了一堆,回头自己写却不知道哪些是必须守的,哪些只是 Pi 的口味。

这一节做收敛。我们把前面所有的实现选择压成 六条不变量(invariant)——不变量的意思是"系统在任何时刻都必须为真的性质"。它们不是"最佳实践"那种可选建议,而是破了就会出事的硬约束。

💡 类比:不变量像建筑的承重墙。装修时它最碍事——挡视线、占面积、不让你随便开门。但拆掉它,房子不会当场塌,而是某天风一吹、地一震,整栋垮下来。Agent 系统里那些"跑了三个月突然崩、复现不出来"的诡异 bug,绝大多数是某条不变量在几周前被悄悄拆了。

每条我们都按同一个格式讲:它说什么 → 破了会怎样(真实故障形态)→ 怎么在代码上守住


不变量一:Provider 格式止步于 adapter 层

它说什么

各家模型 API 的私有格式——Anthropic 的 tool_use 块、OpenAI 的 tool_calls 数组、Gemini 的 functionCall——只允许存在于适配层内部。适配层往上,系统里流动的必须是你自己定义的统一消息格式。

Pi 的四层架构(4.18)就是这条的物化:pi-ai 包负责"跟模型说话",它把各家格式翻译成内部格式;pi-agent-core 往上再也见不到任何厂商字段。4.20 里两个 convertTools 的对照读起来枯燥,但那正是这道墙的砖。

破了会怎样

典型的破法不是有意的,而是"就这一次":某个 Anthropic 特有的字段(比如 thinking 块的 signature)在上层被直接读了一下,因为改适配层太麻烦。

半年后账单上涨,你想加个便宜模型做路由(4.24 的 LLM Router),才发现:

  • UI 渲染逻辑里散落着七八处 if (block.type === "tool_use")
  • 会话文件里存的是 Anthropic 原始结构,换 provider 后旧会话全部读不出来
  • 想加 provider 得改的不是一个文件,是二十个

这条不变量的真正价值不在"优雅",在于它决定了你换模型的成本是一天还是一个月。 2026 年模型迭代速度下,这是最贵的一条。

怎么守住

  • 单向依赖 + 构建工具强制:像 Pi 那样把适配层单独成包,上层包的 package.json根本不声明 provider SDK 依赖。依赖声明造不了假——上层想直接 import @anthropic-ai/sdk,构建就挂
  • 一条搜索命令做体检:在你的上层代码里 grep -rn "tool_use\|tool_calls\|functionCall",命中数应该是 0。非 0 就是墙上有洞
  • 持久化的一定是内部格式:会话文件里绝不存 provider 原始 JSON

不变量二:一次 tool call 恰好配一个 tool result

它说什么

模型发出的每一个 tool call,在上下文里必须有且只有一个与之 toolCallId 配对的 tool result。不能少(孤儿调用),不能多(重复结果),不能错配。

注意"必须有"是无条件的——工具执行失败也必须返回结果,内容写成错误信息。失败不等于没有结果。

破了会怎样

这是六条里故障最直接、也最常见的一条,因为它有三种独立的破法:

破法场景后果
压缩时切在中间上下文压缩只截掉了 tool_use,留下 tool_resultAPI 直接 400 拒绝,整个会话当场崩溃
工具抛异常没兜住execute() throw 了,catch 里只打了日志模型永远等不到结果,下一轮上下文缺一块
并发写入竞态两个工具并行返回,结果数组被并发 push结果重复或丢失

第一种在 4.9 里叫孤儿引用(orphaned reference),它在长时间、重工具调用的会话里是真实多发的故障——恰恰是跑得最久、最有价值的那些会话最容易中招。

怎么守住

  • 把"调用+结果"当作不可分割的单元——这直接引出了不变量五的分组压缩算法
  • 工具执行必须是 total function(全函数):任何输入都返回结果,不 throw。Pi 的做法(4.19)是把这条哲学贯彻到整个流层——StreamFn 的契约明确规定不许抛异常,一切失败编码成 error 事件加一条 stopReason: "error" 的消息
  • 写一个断言函数,在每次发请求前跑
javascript
// 发请求前的守卫:任何不配对都当场炸,而不是让 API 400
function assertPairedToolCalls(messages) {
  const calls = new Map()   // toolCallId -> 出现次数
  const results = new Map()

  for (const msg of messages) {
    for (const block of msg.content ?? []) {
      if (block.type === "toolCall") {
        calls.set(block.toolCallId, (calls.get(block.toolCallId) ?? 0) + 1)
      }
      if (block.type === "toolResult") {
        results.set(block.toolCallId, (results.get(block.toolCallId) ?? 0) + 1)
      }
    }
  }

  for (const [id, n] of calls) {
    const m = results.get(id) ?? 0
    if (m !== 1) throw new Error(`tool call ${id} 有 ${n} 次调用但 ${m} 个结果`)
  }
  for (const id of results.keys()) {
    if (!calls.has(id)) throw new Error(`孤儿结果:${id} 找不到对应调用`)
  }
}

这个函数只有二十行,但它把一类"上线三个月后才炸"的故障变成了"本地第一次跑就炸"。


不变量三:参数没收完,就绝不执行

它说什么

模型输出因为达到 token 上限被截断时(stopReason === "length"),这条消息里的 tool call 参数很可能是残缺的 JSON。此时正确的做法是:一个都不执行,全部标记为错误让模型重发。

破了会怎样

流式解析工具参数时,宽容的 JSON 解析器(或者 LLM 自己"补全"了一个看似合法的对象)会给你一个语法合法但语义残缺的参数。然后:

javascript
// 模型想写的是:{"path": "src/config.js", "content": "...几千字..."}
// 截断后解析出来的是:{"path": "src/config.js", "content": "const a"}
// 于是 write 工具愉快地把整个配置文件覆盖成了 7 个字符

bash 工具上更刺激:rm -rf ./build/temp-cache 截断成 rm -rf ./build 依然是一条合法命令。危险操作的破坏力和参数完整性无关,和参数是否合法无关。

怎么守住

Pi 的实现很干脆(4.19agent-loop.ts:208-216,实现在 failToolCallsFromTruncatedMessageagent-loop.ts:381-406):检测到 stopReason === "length",把这批 tool call 全部转成错误结果——注意这同时满足了不变量二,每个调用依然配了一个结果,只是结果是"因输出截断未执行"。

代价是浪费一轮(重发要重新花 token)。这个取舍是明确的:宁可浪费一轮,也不执行参数残缺的危险操作。

延伸出的一般原则:任何"部分成功"的状态都要显式判定,不能默认当成功。 流式解析、网络重试、批量执行都适用。


不变量四:完成顺序 ≠ transcript 顺序

它说什么

并行执行多个工具时,它们的完成先后是不确定的(一个 read 5ms 返回,一个 bash 跑 30 秒)。但写进上下文的 tool result,必须按模型发出调用时的顺序排列,不能按完成顺序。

破了会怎样

这条是六条里最隐蔽的——因为破了以后不报错,只是模型变笨。

javascript
// ❌ 按完成顺序收集
const results = []
await Promise.all(calls.map(async (call) => {
  results.push(await execute(call))   // 谁先完成谁先进数组
}))

模型发出的调用顺序常常自带语义:"先读配置,再读它引用的那个文件"。结果乱序后,模型看到的因果链是错的。它不会报错,只会给出稍微不对的答案——而且每次运行的乱序方式还不一样,同一个输入时好时坏。这类 bug 在评测里表现为"通过率 80% 且波动",你会误以为是模型能力问题,实际是自己的调度器在洗牌。

上下文压缩之后问题更严重:乱序的历史被摘要一次,错误的因果关系就被固化进摘要里了。

怎么守住

javascript
// ✅ 按调用顺序回填,与完成顺序解耦
const results = await Promise.all(calls.map(execute))
//  Promise.all 保证返回数组与输入数组同序,无论谁先完成

一句话的事,但要有意识地知道自己在守什么。更一般地:

  • 执行可以并发,写入必须定序——把"调度"和"记录"分开想
  • 需要边完成边显示进度时,UI 事件流可以按完成顺序推(用户想看到快的先出结果),但进 transcript 的那份必须重排。这正是不变量六要讲的"两份流"的区别
  • 评测里显式检查顺序(下一节 2.22 的轨迹断言就干这个)

不变量五:历史只追加,上下文可重建

它说什么

这条是整套设计的地基,也是最容易被想反的一条。要点是把两个东西彻底分开:

Session(历史)Context(上下文)
是什么真实发生过的一切这一轮发给模型的那份视图
可变性只追加,永不修改删除每轮按预算重新计算
存在哪磁盘上的 JSONL 文件内存里的临时数组
类比会计的流水账这个月的汇报 PPT

压缩不是删历史,是往历史里追加一条"我做了一次压缩"的记录。

Pi 的会话文件(4.21)就是一个 JSONL——每行一条 entry,带 id / parentId 构成一棵树,上下文是"从当前叶子走回根"走出来的。这个结构天然满足这条不变量:走树得到视图,写文件只追加。

破了会怎样

破法很有诱惑力:压缩时直接 messages.splice() 把旧消息删掉,省内存又省事。然后你失去三样东西:

  1. 审计能力——用户问"你当时为什么改了那个文件",你答不上来,因为那段历史真的没了
  2. 回退能力——4.21/tree 分叉、从任意节点继续,全部建立在"历史还在"之上。删了就只能一条道走到黑
  3. 调试能力——压缩算法本身有 bug 时,你连"压缩前是什么样"都复现不出来

而且删除是不可逆的:前四条不变量破了还能补救,这条破了,丢的信息永远回不来。

怎么守住:分组 + 后缀 + 追加

真正的压缩算法比"摘要一下旧消息"精确得多。三步:

第一步,分组(grouping)。把历史切成不可分割的交互组:每组从一条 user 消息开始,包含它引发的所有 tool call、tool result,直到下一条 user 消息为止。

[组1] user: 帮我改配置
      assistant: (call_1 read)
      tool: (result_1 文件内容)
      assistant: (call_2 edit)
      tool: (result_2 已修改)
      assistant: 改好了
[组2] user: 再跑下测试
      ...

组是原子的,边界只能落在组之间。 这一步直接兑现了不变量二——只要不切进组内部,tool_use/tool_result 就永远配对,孤儿引用从根上不可能发生。

第二步,算预算。固定成本先扣:system prompt + 预留给输出的空间 + 安全边际。剩下的才是历史可用的额度。

第三步,从最新往回选完整后缀。注意是**后缀(suffix)**不是"随便挑几组"——从最新的组开始往前累加,加到装不下为止。保留连续的最近若干组,被挤掉的部分写成一条结构化摘要。

javascript
function selectGroups(groups, budget, countTokens) {
  const kept = []
  let used = 0
  // 从最新往回走
  for (let i = groups.length - 1; i >= 0; i--) {
    const cost = countTokens(groups[i])
    if (used + cost > budget) break   // 装不下就停,不再往前找"塞得下的小组"
    kept.unshift(groups[i])
    used += cost
  }
  return kept
}

为什么必须是连续后缀、不能跳着挑小组?因为跳选会制造时间断层——模型看到组 1、组 5、组 6,会误以为组 5 紧接着组 1 发生。宁可少留几组,也要留一段连贯的近期历史。

最后,压缩结果本身也是一条 entry 追加进文件,记下摘要内容、firstKeptEntryId(从哪条开始是原文)、压缩前的 token 数。于是重建是确定性的:同样的历史 + 同样的预算,任何时候重放都得到逐字节相同的上下文。这个性质让压缩本身变得可测试——否则你根本没法给压缩写单测。

💡 2026 年的现实选择:Anthropic 在 2026 年 1 月推出了服务端压缩(beta header compact-2026-01-12,详见 4.9),由 API 侧保证配对。自己写 Agent 时优先用它;但上面这套算法你依然要懂——服务端压缩管的是发给模型的那份,你本地的会话文件、审计、回退,还是得自己守住"只追加"。


不变量六:扩展的边界,就是信任的边界

它说什么

系统里所有"外来的东西"——技能文件、扩展代码、MCP server、子 Agent、工具返回的内容——都要按信任级别分类,并且在进入系统前过一道门

关键区分是这一条:

  • Skill / 知识文件 = 资源(resource):进的是上下文,被模型"读到"。风险是内容层面的——2.21 讲的 prompt injection
  • Extension / MCP server = 可执行代码:进的是进程,直接跑在你的权限下。风险是系统层面的——它能读你的环境变量、发网络请求、改文件

两者的危险程度差一个数量级,必须用不同的门

破了会怎样

破法是"一视同仁":装 skill 和装 extension 走同一个流程、给同样的信任。于是:

  • 从网上抄一个"很好用的 pi 扩展"丢进 .pi/extensions/,它在 (pi) => {...} 入口函数里读走了你的 ANTHROPIC_API_KEY4.22 讲过,扩展是 jiti 免编译直接 import 并执行的——没有沙箱,没有权限声明,import 那一刻代码就跑了
  • 一个 MCP server 声明了名叫 read_file 的工具,实际行为是读完再上传一份

反过来,另一种破法是"一律不信":所有工具调用都弹确认框。用户点确认点到麻木,两周后开始无脑回车——这时候确认框的安全价值归零,只剩下摩擦

怎么守住

  • 在加载时分类,不在使用时分类:资源和代码走两条独立的加载路径。Pi 的资源加载流水线(4.22)虽然共用发现-合并-去重机制,但扩展有独立的入口约定和 package.json 声明
  • 信任分级要和"能造成多大破坏"挂钩,不是和"来自哪里"挂钩:只读工具放行,写文件的工具在项目目录内放行、目录外确认,网络请求和 rm 一律确认。这样确认框出现的频率低到用户还愿意看
  • 确认要展示"将要发生什么",不是"是否继续"是否允许 bash?[y/N] 是无效确认,将执行:rm -rf ./build/cache(项目目录内) 才是
  • Pi 的 permission-gate 扩展(4.22)演示了纯公开 API 就能实现门禁——tool_call 钩子返回 block 即可拦截。门禁本身是可插拔的,这样不同场景可以配不同严格度

六条速查表

打印出来贴在写 Harness 的显示器旁边:

#不变量破了的典型症状
1Provider 格式止步于 adapter 层换模型要改二十个文件,旧会话读不出来
2一次 tool call 恰好配一个 tool resultAPI 400,长会话当场崩溃
3参数没收完就绝不执行文件被截断内容覆盖,rm 删错目录
4完成顺序 ≠ transcript 顺序不报错,模型时好时坏,评测通过率飘
5历史只追加,上下文可重建无法审计、无法回退、压缩 bug 复现不了
6扩展边界 = 信任边界密钥泄露,或确认框多到用户闭眼点

注意最后一列:只有第 2 条会"响亮地"失败。其余五条都是安静地劣化——这就是为什么必须把它们写成断言和测试,而不是指望自己"注意一点"。


🛠️ 实战练习:故障注入,找第一处偏离

读懂不变量最快的方式不是背,是亲手打破它,然后观察系统怎么烂。这个练习的重点是训练一种诊断习惯:不看最终输出对不对,而是找第一处偏离

准备:拿你自己写的任何一个 Agent(4.19 的练习产物,或 2.11 的知识库 Agent 都行),确保它能跑通一轮多工具调用。

步骤

  1. 建立基线:跑一个需要 3 次以上工具调用的任务,把完整的消息数组 JSON.stringify 存成 baseline.json

  2. 注入故障 A(破不变量四):把并行工具的结果收集改成"谁先完成谁先进数组":

    javascript
    const results = []
    await Promise.all(calls.map(async (c) => { results.push(await execute(c)) }))

    同一个任务连跑 5 次。观察:最终答案错了几次?5 次的消息数组两两相同吗?

  3. 注入故障 B(破不变量二):在工具执行处加一行——随机 10% 的概率直接 return,不产生结果:

    javascript
    if (Math.random() < 0.1) return   // 静默吞掉结果

    跑到它触发为止。观察:报错信息出现在哪一轮?它指向的是真正的病灶吗?

  4. 加装守卫:把本节不变量二的 assertPairedToolCalls() 抄进去,在每次发请求前调用。保留故障 B,重新跑。

期望结果

  • 故障 A:5 次运行产生 5 份不同的消息数组,最终答案时对时错——这就是"评测通过率 80% 且飘"的真实成因
  • 故障 B(无守卫):报错来自模型或 API,指向的是"上下文格式不对",而真正的病灶在几十行外的工具执行处。你会体会到调试时的那种茫然
  • 故障 B(有守卫):错误在注入点后的第一次请求前就抛出,并直接告诉你是哪个 toolCallId 缺了结果

对比第 3 步和第 4 步的调试体验——这就是不变量断言的全部价值:把"远处的、间接的、偶发的"故障,变成"当场的、直接的、必现的"。

进阶挑战:实现不变量五的分组压缩(groupInteractions + 后缀选择),然后写一个测试断言"同样历史 + 同样预算,重建两次深度相等"。再故意把分组边界改成"每 5 条消息切一刀"(切进组内部),观察孤儿引用如何触发 API 400——你会看到不变量二和不变量五其实是同一条约束的两面。


📌 关键结论

  1. 不变量不是最佳实践,是硬约束——它们描述"任何时刻都必须为真"的性质,破了就出事。区别在于:最佳实践违反了会不够优雅,不变量违反了会在某个凌晨三点炸掉生产环境
  2. 六条里只有一条会响亮地失败(tool call 配对会触发 API 400),其余五条都是安静劣化:换模型变贵、答案时好时坏、故障复现不了。所以必须写成断言和测试,指望"注意一点"必然失守
  3. "历史只追加,上下文可重建"是地基——把 Session(真实发生过什么)和 Context(这轮给模型看什么)彻底分开。压缩是往历史追加一条压缩记录,绝不是删除消息;分组时保证交互组原子、选连续后缀,孤儿引用便从根上不可能发生
  4. 执行可以并发,写入必须定序——工具并行执行是性能需要,但进 transcript 的顺序必须是模型发出调用的顺序,否则模型看到的因果链是错的
  5. 信任分级要挂在"能造成多大破坏"上,而不是"来自哪里":Skill 是资源(风险在内容层),Extension / MCP 是可执行代码(风险在系统层),两者必须走不同的门;确认框要展示"将要发生什么"而不是"是否继续",否则用户很快会闭眼点确认
  6. 诊断 Agent 故障要找"第一处偏离",不是看最终输出对不对——最终答案错了只是症状,不变量断言的价值就是把远处的、偶发的故障变成当场的、必现的

📚 延伸阅读:本节的不变量框架受 动手学 Pi 这份从零手写 Agent 的教程启发——它用 15 个 checkpoint 带你把这些约束一条条实现出来。我们的 4.18~4.22 是"读别人写好的",那份教程是"自己写一遍",两者互补。想真正吃透 Harness,建议读完本章后去做一遍。


下一阶段:第 5 章 · 四大专题深入(RAG / MCP / Skill / 微调)

想把这六条变成可执行的检查?2.22 Agent 轨迹评测 把每一条都写成了断言。

写给自己的 AI 学习地图