Skip to content

第 3 章:解构 Pi 架构的终端渲染与洋葱模型 ​

:::

阅读建议:如果时间有限,聚焦双队列打断机制和记忆树两个核心设计,即可理解 Pi 的差异化思路;嵌套循环伪代码、互斥锁实现等工程细节适合实装时回读。

:::

与闭源商业产品不同,开源的 Pi (下文也称 Coding-Agent)为我们提供了可直接检查的分层样本。其结构可以用洋葱模型来解释:系统分成由内向外的四层同心结构,内核是 Agent 循环,外层依次是会话编排、I/O 适配与 LLM 网关,每层可独立替换而不伤及内核。Pi 同时吸收了六边形架构的端口—适配器思路,终端渲染则采用了不依赖 React 的方案。

如果你的目标不仅是写一个能在自己电脑上跑的脚本,而是要开发一个既能作为独立 CLI 工具,又能作为后端服务(RPC),甚至能内嵌到 IDE 里的 Agent 框架,那么 Pi 的架构设计值得作为参考。

3.1 架构全景与渲染选型 ​

章标题里有两个容易被当成“营销词”的短语,四层洋葱模型和摒弃 React 的终端渲染,它们不是装饰,而是 Pi 能在 CLI / RPC / SDK 三种形态下复用同一套核心的根本原因。下面先把整张地图铺平,再进入各节的源码解剖。

3.1.1 四层洋葱模型与职责划分 ​

Pi 的 monorepo 把 Agent 系统切成四个同心层,依赖方向严格由外指向内:外层可以调用内层,内层对 UI、文件系统、具体 LLM 供应商一无所知。这与六边形架构的“端口—适配器”思想高度相似:Core 只定义端口(接口),Adapter 负责落地。两者的关注点并不完全相同:洋葱架构强调领域内核及同心分层,六边形架构更强调应用与外部世界之间的端口边界。本章用“洋葱模型”描述分层,用“端口—适配器”解释依赖反转,如表 3-1 所示。

表 3-1   Pi 四层洋葱模型职责

层级名称职责源码映射
L1 (最内核)Core Engine纯 Agent 循环:LLM 调用 → 工具执行 → 结果回流;双队列注入;emit 事件流packages/agent(agent-loop.ts、Agent 类)
L2Session Orchestration会话生命周期、JSONL 记忆树、压缩、扩展编排、模型/思考级别切换packages/coding-agent/src/core(AgentSession、SessionManager、compaction/)
L3I/O Adapters工具实现(bash/read/edit/write)、TUI 渲染、RPC/JSON 模式、子进程隔离packages/coding-agent/src/core/tools、packages/tui、modes/
L4 (最外层)LLM Gateway多供应商统一 API、streamSimple 流式调用、Token 计数、API Key 解析packages/ai

为什么叫“洋葱”而不是“三层 MVC”? 因为每一层都可以被整体替换而不伤及内核。换 Anthropic 为 OpenAI?只动 L4。把 TUI 换成 WebSocket 推送?只动 L3。Compaction 策略升级?只动 L2。Agent 循环的嵌套状态机?L1 一行不改。这种可替换性(Substitutability)正是工业级 Agent 与 Demo 脚本的分水岭。

与经典六边形架构的对照:L1 是 用例/领域逻辑(Application Core);L2 是 编排与持久化(Application Services);L3/L4 分别是 驱动适配器(Driving Adapter,如 TUI、RPC)和 被驱动适配器(Driven Adapter,如 LLM API、文件系统)。Pi 没有引入 DI 框架,但 AgentLoopConfig 里的 convertToLlm、transformContext、getSteeringMessages 等回调,其实就是手动注册的端口。这张表中最值得关注的是 L1 与 L2 的切分,Compaction、记忆树、扩展编排若渗入 Core,换 UI 或换供应商时将牵一发动全身。

3.1.2 终端渲染方案对比 ​

Claude Code 选择 React + Ink 渲染终端,组件化、声明式、生态成熟,但代价是:每个 React 组件都是一次 Virtual DOM diff,在 120Hz 流式 Token 场景下,Reconciliation 开销与 Ink 的布局计算会争抢主线程。输出交错由此产生。React 的生命周期与 Agent 事件流天然不同步,模型吐 Token、工具并发回传、用户 Ctrl+C 打断,三路事件同时到达时,React 的 setState 批处理往往来不及对齐。

Pi 走了另一条路:自研 @mariozechner/pi-tui,一套极简的 Component + 差分渲染 框架。核心机制:

  1. 无 Virtual DOM:每个组件实现 render(): string[],返回当前帧应显示的终端行数组。TUI 容器拿新旧两帧做 diff,只重绘变化行,而不是全屏刷新。
  2. 同步输出:通过 DEC 私有模式 2026 (控制序列 \e[?2026h 开启、\e[?2026l 关闭,此处 2026 是模式号,与年份无关),保证一帧内的所有行原子写入,消除闪烁,这在 Claude Code 的 Ink 方案中需要额外 hack 才能实现。
  3. 事件驱动刷新:AgentSession 订阅 message_update、tool_execution_start 等事件,每收到事件调用 tui.requestRender(),渲染是事件的结果,不是轮询的产物。数据流是单向的:Event → Model 更新 → View 重算 → 差分输出。

总之,Claude Code 用 React 换开发效率;Pi 用原生差分渲染换性能与事件对齐。对于要在 RPC 模式(无 TUI)、SDK 模式(嵌入 IDE)、Interactive 模式(全功能 TUI)之间无缝切换的框架而言,把 UI 层做成可拔插的 L3 适配器,比将 UI 耦合在 React 树中,架构弹性明显更高。这一选择意味着放弃 React 生态的组件库与调试工具,尚无定论哪条路径在团队规模扩大后总成本更低。

3.2 核心循环与队列注入 ​

在很多业余的 Agent 框架中,大模型 API 调用、工具执行和对话记录往往被揉成一团,写在一个臃肿的类里。一旦遇到用户中途打断、或者需要重试报错的命令,整个状态就全乱了。

Pi 架构在 packages/agent/src/agent-loop.ts 中给出了一个较为清晰的解法:将“循环”本身变成一个纯粹的、无状态的异步函数,而将“状态”交给外部的代理对象管理。 同时,它引入了独特的双队列注入机制——steeringQueue(转向队列,用于人类中途介入)和 followUpQueue(追问队列,用于自动补充上下文):前者让用户的紧急打断指令可插队执行,后者在 Agent 准备退出时仍接收追加任务,有效解决了外部事件在循环运行期间的注入问题。本节先阐明循环的无状态设计原则,再展开双队列的工作方式。

容易被忽视的一点:纯函数 循环 与外部 State 的边界

这里“无状态”不是说系统没有记忆,而是说 runLoop / agentLoop 函数本身不持有任何跨轮次的 mutable 字段。每次调用时,所有可变数据都通过参数注入:

  • AgentContext(传入):messages[]、systemPrompt、tools[],这是 LLM 看到的“世界快照”。循环可以往 context.messages 里 push 新消息,但不会自己创建或持久化 Context。
  • AgentLoopConfig(传入):convertToLlm、transformContext、getSteeringMessages、getFollowUpMessages,全是回调端口,循环只调用,不实现。
  • emit(传入):事件_sink,循环只广播,不订阅。

哪些 mutable 留在外部? Agent 类持有 steeringQueue / followUpQueue(PendingMessageQueue 实例)、streaming 状态、订阅者列表;AgentSession 持有 SessionManager(JSONL 树)、settings、extension runner。循环通过 config.getSteeringMessages?.() 一次性 drain 队列,读后即焚,队列的生命周期完全在 Agent 对象里,循环连队列的引用都拿不到。

为什么这样切? 可测试性。测试 循环 时,构造一个假的 AgentContext(3 条 messages)、一个 stub emit(收集事件数组)、一个 mock streamFn(返回固定 assistant 响应),不需要启动 TUI、不需要写 JSONL、不需要网络。这就是函数式核心 / 命令式外壳模式在 Agent 领域的典型应用,内核是纯逻辑,外壳(Session、UI、磁盘)是可替换的副作用层。

3.2.1 内外双循环设计 ​

为什么需要两层循环?单层循环在内循环结束时只能退出,无法处理“Agent 刚说完话,但追问队列又进来新任务”的场景。Pi 通过外层循环持续检查追问队列,只有队列彻底清空时才真正退出,避免了丢弃后台追加的异步任务。

Pi 的核心循环不是一个简单的 while(true),而是包含了内、外两个嵌套的循环:

typescript
// Pi 核心循环伪代码提炼 (agent-loop.ts)
async function runLoop(context, config, emit) {
  // --- 外层循环:生命周期级 ---
  // 它的存在是为了处理那些在 Agent 想要停止时,突然追加进来的任务(Follow-ups)
  while (true) {
    let hasMoreToolCalls = true;

    // --- 内层循环:执行级 ---
    // 处理连续的工具调用和模型思考
    while (hasMoreToolCalls || pendingSteer.length > 0) {
      
      // 1. 【关键】在每次发给大模型前,检查操控队列并注入用户强行插入的话
      if (pendingSteer) injectToContext(pendingSteer);

      // 2. 流式请求大模型
      let assistant = await streamLLM(context);
      if (assistant.stopReason === "error") return;

      // 3. 并发执行大模型返回的工具
      let toolResults = await executeToolCalls(assistant.toolCalls);
      
      // 如果所有工具都同意结束(terminate: true),内循环终止
      hasMoreToolCalls = !toolResults.every(r => r.terminate);
      
      // 检查是否有新的插话
      pendingSteer = drainSteeringQueue();
    }

    // 内循环结束,Agent 准备收工了。此时检查“追问队列”
    let followUps = drainFollowUpQueue();
    if (followUps.length > 0) {
      pendingSteer = followUps; // 把追问变成下一轮的操控指令
      continue; // 继续外层循环
    }

    break; // 真没任务了,彻底退出
  }
  emit("agent_end");
}

3.2.2 操控队列与追问队列 ​

长周期任务(如持续十分钟的代码重构)执行过程中,用户随时可能发现 Agent 偏离了预期方向。传统做法是抛出异常终止进程,但这会丢失此前的所有进度。Pi 团队的解法是在运行时引入双队列注入机制,既允许用户实时纠偏,又不中断任务执行链。

  1. 操控队列:急刹车与打方向盘 当用户在模型正在思考或工具执行的间歇输入指令时,这句话会被投入 steeringQueue。在内循环的每一次转折点(请求大模型前),引擎都会去清空(drain)这个队列。这相当于在原本的对话末尾强行塞入一句用户提示,大模型在下一轮收到这句话后,会结合刚获取的上下文调整动作,而整个任务执行链不会断裂。

  2. 追问队列:排队等候的批处理 有些任务(比如扩展插件在后台静默生成的分析报告,或者用户连贴了三段长命令)不能中途打断大模型的思路。它们会被放入 followUpQueue。只有当内循环彻底干完活、Agent 打算进入“闲置”状态时,外循环才会提取这个队列,顺滑地开启下一阶段任务。

双队列背后的取舍

Pi 的双队列不是 OS 级 pipe,也不是 Redis Stream,而是 Agent 类内部的两个 PendingMessageQueue 实例,关键在于内存数组 + drain 清空语义:

typescript
// packages/agent/src/agent.ts 简化
private readonly steeringQueue: PendingMessageQueue;
private readonly followUpQueue: PendingMessageQueue;
// AgentLoopConfig 回调:
getSteeringMessages: async () => this.steeringQueue.drain(),
getFollowUpMessages: async () => this.followUpQueue.drain(),

drain() 是“取出并清空”,不是 peek。 每次内循环转折点(即将调用 LLM 前)调用 getSteeringMessages(),队列里积累的所有 steering 消息会被一次性 splice 出来,然后逐条 push 进 context.messages,格式化为标准 { role: “user”, content: [...], timestamp } 消息。队列清空后,同一条消息不会被重复注入。

Steering 会不会打断进行中的 tool batch? 不会。 源码注释写得很明确:steering 消息在 “current assistant turn finishes executing its tool calls” 之后才投递,也就是说,如果模型刚返回 3 个并行 tool call,这 3 个工具会全部执行完毕、toolResult 全部 append 到 context 后,下一轮 LLM 调用前才会注入 steering。这是协作式中断,不是抢占式杀进程。用户按 Enter 发出的“等等别改那个文件”,会排队等到当前 tool batch 安全落地,既不打断文件写入的原子性,又不丢失已完成的工具结果。

队列模式:steeringMode / followUpMode 支持 “one-at-a-time”(默认,每轮只投递一条)和 “all”(一次性投递全部)。AgentSession 层还维护 _steeringMessages: string[] 用于 TUI 显示排队状态,与 Agent 内核队列通过 steer() / followUp() API 同步,如图 3-1 所示。

图3-1 Pi内外双循环与操控/追问队列

3.2.3 事件驱动下的状态还原 ​

注意看,在上面的 runLoop 中,没有直接操作界面的代码。引擎只做一件事:不断地 emit(“message_end”)、emit(“tool_execution_start”)。

外层的 Agent 和 AgentSession 就像是一个监听者,它们将这些离散的事件汇总、记录到全局的 transcript(对话记录)中,并最终通知 TUI 或者 RPC 客户端去刷新渲染。这种高度解耦的设计,是构建复杂智能体软件系统的基石。

原理深挖:Event-Driven 事件总线与订阅模型

Pi 的事件系统分两层,不要混淆:

L1 — Agent 核心事件(packages/agent/src/types.ts):由 agentLoop 内部的 emit 回调广播,类型包括 agent_start、turn_start、message_start、message_update(流式 Token 增量)、message_end、tool_execution_start / tool_execution_update / tool_execution_end、turn_end、agent_end。Agent.subscribe(listener) 注册监听,返回 unsubscribe 函数。这些事件是同步 emit、异步 handler,循环不会 await 订阅者的处理完成。

L2 — Extension 扩展事件(packages/coding-agent/src/core/extensions/types.ts):由 ExtensionRunner 管理,在 Agent 事件之上叠加了 before_agent_start、session_before_compact、session_before_tree、tool_call(可 block/mutate)、tool_result 等 30+ 种生命周期钩子。Extension 事件支持 返回值修改行为(如 tool_call 返回 { block: true } 拦截执行),而 Agent 核心事件是纯通知。

事件总线实现:Extension 层使用 createEventBus()(packages/coding-agent/src/core/event-bus.ts),底层是 Node.js 的 EventEmitter,但封装了 safeHandler,handler 抛异常不会拖垮 循环,只 console.error。每个 channel 对应一种 ExtensionEvent 的 type 字段,payload 是强类型的 discriminated union (如 { type: “tool_call”, toolName: “bash”, input: {...} })。

Schema 设计哲学:所有事件都携带 type 字面量字段作为 discriminant,TypeScript 编译器可在 switch (event.type) 中自动窄化。Agent 核心事件与 Extension 事件命名空间隔离,前者驱动 UI 渲染和 JSONL 持久化,后者驱动插件逻辑和上下文注入,互不污染。

3.3 上下文处理机制 ​

当你的 Agent 运行了成百上千轮,上下文字数超过了 10 万字,该如何处理?简单地切除头部数据会导致逻辑断裂;使用 Claude Code 的 AutoCompact 会将原始对话替换为摘要,丢失细节。

Pi 在 packages/coding-agent/src/core/compaction/ 下给出了一套极具极客精神的解决方案:不删任何数据,只是在状态树上打“压缩补丁”,并在读取时重新投影(Projection)。

3.3.1 触发压缩的边界条件 ​

在 AgentSession 处理完一轮运行后,会进入 _checkCompaction() 判断是否需要“记忆瘦身”:

  1. 阈值触发(Threshold): 当发现估算的 Token 已经跨越了 contextWindow - reserveTokens(比如预留 1.6 万 Token 缓冲)。此时主动拦截,发起压缩。此时 Agent 还能继续接下来的任务(willRetry = false)。
  2. 溢出补救(Overflow): 当 LLM API 明确报出上下文超限错误(如 prompt_too_long 或 HTTP 413),或者检测到大模型输出 Token 为 0 且被强行截断(静默溢出)时。此时必须先压缩历史,然后拿着压缩后的新上下文,自动重试刚刚失败的那个请求(willRetry = true)。这对用户来说是完全无感的。

_checkCompaction 的边界条件

_checkCompaction() 在 AgentSession 每轮 message_end 后触发,两条路径的触发条件、用户感知、日志行为截然不同,如表 3-2 所示。

表 3-2  压缩触发路径对照

维度阈值触发溢出补救
触发条件contextTokens > contextWindow - reserveTokens(默认 reserve 16384)API 返回 context overflow 错误,或 usage 超过窗口
willRetryfalse成功响应溢出 → false;错误响应溢出 → true
用户感知TUI 弹出 compaction 进度,完成后等待用户继续自动 compact → 移除错误 assistant 消息 → 静默重发上一轮请求
日志事件compaction_start { reason: “threshold” } → compaction_end { willRetry: false }compaction_start { reason: “overflow” } → compaction_end { willRetry: true }
防重入检查 compaction 时间戳,防止刚 compact 完又被旧 usage 触发_overflowRecoveryAttempted 标志位,最多 1 次 compact-and-retry

在实际使用中,Overflow 路径的“静默重试”与 Threshold 路径的“等用户继续”对用户感知的影响,远高于 reserveTokens 数值本身的微调,前者掩盖了上下文危机,后者把主动权交还给人。

Overflow 重试的“一次限次”是关键安全阀:如果 compact 后上下文仍然超限(比如单条 tool result 就超过窗口),第二次 overflow 不会无限循环,而是 emit compaction_end 并附带 errorMessage: “Context overflow recovery failed after one compact-and-retry attempt”,让用户手动处理。Threshold 路径则永远不会 auto-retry,因为此时 assistant 回答已成功完成,agent.continue() 无法从 assistant 消息续接,强行重试语义不对。

3.3.2 树状追加与物化视图 ​

这是 Pi 最反直觉、也是最安全的设计。在 _runAutoCompaction() 的流程中:

typescript
// 伪代码:Pi 的无损压缩逻辑
async function _runAutoCompaction() {
  // 1. 寻找切割点
  const preparation = prepareCompaction(pathEntries);
  
  // 2. 调用模型生成摘要
  const summary = await compact(preparation, model);
  
  // 3. 【核心】并没有去修改以前的 messages 数组!
  // 而是向存储引擎(JSONL)追加一条特殊记录
  sessionManager.appendCompaction(summary, tokensBefore, details);
  
  // 4. 重建当前视图
  agent.state.messages = sessionManager.buildSessionContext().messages;
}

为何不能简单地一次压缩

Compaction 被刻意拆成 Prepare (纯计算) 和 Compact (调 LLM) 两个阶段,中间留出了 Extension 拦截面:

Prepare 阶段输入:当前分支的 pathEntries[](从 leaf 回溯到 root 的完整路径)+ CompactionSettings(reserveTokens: 16384, keepRecentTokens: 20000)。输出 CompactionPreparation:

  • messagesToSummarize[]:将被折叠丢弃的消息
  • turnPrefixMessages[]:如果切割点落在一轮对话中间,前缀部分单独摘要
  • firstKeptEntryId:切割后保留的第一条 entry 的 UUID
  • previousSummary:上一次 compaction 的摘要(用于增量更新而不是重写)
  • fileOps:从 tool call 中提取的 { read, written, edited } 文件集合

摘要 Prompt 设计:System prompt 固定为 “You are a context summarization assistant... Do NOT continue the conversation. ONLY output the structured summary.”,防止小模型把摘要任务当成对话续写。User prompt 要求按 Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context 六段式 Markdown 输出,并保留精确的文件路径、函数名、错误信息。如果有 previousSummary,切换为 UPDATE 模式,“PRESERVE all existing information, ADD new progress”,实现增量压缩而不是每次从零摘要。

Compact 阶段:把 serializeConversation(messagesToSummarize) 的纯文本+ 结构化 prompt 发给 compaction 模型(通常与主模型相同),返回 { summary, firstKeptEntryId, tokensBefore }。Extension 可通过 session_before_compact 事件完全替换这一步的输出。

为什么说它是“物化视图”?

在底层的 JSONL 文件里,那几万字啰嗦的历史记录全都在。 appendCompaction 只是在树里追加了一个特殊节点:“从这条往前的冗长历史,都可以用 [总结了刚才的调试] 来代替”。 当 buildSessionContext() 读取树来拼接发给大模型的消息时,只要它撞见了 Compaction 节点,它就会跳过前面的所有细枝末节,直接把这条总结作为替代品抛出。历史永远不会被销毁,只会在需要发给 LLM 计费的时候被折叠。

原理深挖:buildSessionContext() 的树遍历投影算法

buildSessionContext(entries, leafId?) 是一个纯函数投影器,输入 append-only 的 entry 数组 + 当前 leaf 指针,输出 { messages, thinkingLevel, model }。算法分四步:

  1. 定位 leaf:若 leafId === null(navigate 到首条消息之前),返回空 messages;若未指定,取数组最后一条 entry。
  2. 回溯路径:从 leaf 沿 parentId 链走到 root,path.reverse() 得到 root→leaf 的有序路径。路径上所有 entry 类型都会被遍历(message、compaction、model_change、thinking_level_change、branch_summary、custom_message 等),但不一定都变成 LLM message。
  3. Compaction 折叠规则(路径上存在 compaction entry 时):
  • 先 emit 一条 compactionSummary 消息(摘要文本)
  • 从 firstKeptEntryId 到 compaction 节点之间的 entry → 保留
  • compaction 节点之后的所有 entry → 保留
  • compaction 节点之前、且在 firstKeptEntryId 之前的 entry → 跳过(折叠进摘要)
  1. 无 Compaction 时:路径上所有 message、custom_message、branch_summary entry 按序 append。

节点类型映射:message → 原样;custom_message → { role: “custom”, customType, content };branch_summary → { role: “branchSummary”, summary };compaction → { role: “compactionSummary”, summary, tokensBefore }。label、session_info、model_change 等元数据 entry 不参与 LLM 上下文,只影响 settings 提取(thinkingLevel、model)。

3.3.3 安全切割点算法 ​

如果你刚好在一次“调用工具 -> 返回错误 -> 纠正代码”的逻辑闭环中间砍了一刀做摘要,大模型的逻辑链就断了。 Pi 在 findCutPoint() 中实现了一套逆向寻找安全缝隙的算法。

它从最新的消息开始往回找,累加 Token 数量。当触及 keepRecentTokens 的底线时,它不会立刻切断,而是继续往回找,直到遇到明确的对话边界(比如用户的全新提问、或者 Agent 刚说出的一句自然语言)。绝不在 toolResult (工具返回结果) 前后下刀。 这种基于逻辑完整性的安全切割,保证了压缩后的大模型不会“产生混乱”。

findCutPoint 的工程代价

Token 估算方法:优先使用最后一条 assistant 消息的 usage.totalTokens(API 返回的精确值);其后的消息用 estimateTokens() 启发式估算,文本按 ceil(chars / 4),tool result 按 content 长度,image 按固定值。这种 “精确锚点 + 尾部估算” 混合策略避免了全量 re-tokenize 的开销。

keepRecentTokens 默认值:20000 (DEFAULT_COMPACTION_SETTINGS)。算法从最新 entry 向前累加,当 accumulatedTokens >= keepRecentTokens 时,向前搜索最近的合法切割点。

边界类型枚举(findValidCutPoints):允许切割的 entry 类型,user、assistant、bashExecution、custom、branchSummary、compactionSummary、branch_summary、custom_message。明确禁止在 toolResult 处下刀。

Split Turn 处理:如果切割点落在一轮中间(assistant 消息处),标记 isSplitTurn: true,记录 turnStartIndex(该轮 user 消息的位置)。前缀部分生成单独的 TURN_PREFIX_SUMMARIZATION_PROMPT 摘要,后缀保留原文,一轮对话被拦腰斩断时,两半各自摘要,逻辑链不完全丢失。

Fallback:若 cutPoints.length === 0(极端情况:路径上全是 tool result),返回 { firstKeptEntryIndex: startIndex },保留全部,宁可不 compact 也不破坏结构。

3.3.4 插件对上下文的拦截 ​

Pi 还在上下文拼接的最后一公里留了一个扩展钩子:transformContext。 由于 Agent 可以挂载第三方插件,在 streamAssistantResponse() 把数据正式扔给网络之前,插件有权通过 emitContext(messages) 拦截整个消息数组,塞入特定格式的 custom 角色内容,或者临时屏蔽某段历史。这种将上下文拼接权下放给微内核的设计,为定制各种专属行业的 Agent 留下了极大空间。

原理深挖:transformContext / emitContext 扩展钩子的调用顺序

上下文从 JSONL 树到 LLM API 的完整流水线有 四道关卡,顺序不可乱;前三道每轮必经,第四道仅在新 prompt 首轮触发:

  1. buildSessionContext()(Session 层):树 → AgentMessage[],处理 compaction/branch 折叠。
  2. transformContext(messages, signal)(Agent 层钩子):Extension 或 SDK 用户注册的纯函数,可任意增删改 messages 数组。在 streamAssistantResponse() 内部、convertToLlm() 之前调用。
  3. convertToLlm(messages)(Agent 层):把 AgentMessage[](含 custom、branchSummary、compactionSummary 等内部角色)转换为 LLM API 兼容的 Message[](通常 filter 为 user | assistant | toolResult,custom 角色映射为带标记的 user 消息或 system 注入)。
  4. before_agent_start Extension 事件(仅新 prompt 时):在 user message append 之前,Extension 可返回 { messages: [{ customType, content, display }] } 注入额外上下文,以及 { systemPrompt } 覆盖系统提示词。

Custom role 如何进 LLM API? Pi 内部用 role: “custom” + customType 字段区分扩展消息,不直接发给 LLM。convertToLlm() 负责翻译,例如 { customType: “context”, content: “项目使用 pnpm” } 可能被渲染为 [Context: 项目使用 pnpm] 前缀的 user 消息,或注入 系统提示词 尾部。具体映射由 convertToLlm 实现决定,Extension 只管生成 custom 消息,不管 wire format。

插件冲突处理:transformContext 是单一函数(后注册覆盖先注册),不存在链式 pipeline,如果多个 Extension 都需要改 context,应在 Extension 层自行编排。Extension 事件(before_agent_start、tool_call)则按注册顺序串行 emit,后到的 handler 能看到前一个 handler 的 mutation (如 tool_call 的 event.input 就地修改),但不做自动 merge,冲突由 Extension 作者自行协调。

3.4 工具执行与并发控制 ​

让大模型并行调用工具能极大地缩减响应时间,但当模型决定“同时修改文件 A,读取文件 B,又对文件 A 执行一个 sed 命令”时,竞争条件会导致本地代码被改得面目全非。

Pi 架构将工具批调度统一收归到 executeToolCalls 函数——它是每轮循环中工具阶段的单一入口,负责决定串行还是并行、分配并发组、汇总结果。通过分析 packages/coding-agent/src/core/tools/ 下的源码,我们来看它如何像构建操作系统一样去构建 Agent 的工具系统。

3.4.1 并发组的生命周期管理 ​

在 executeToolCalls 中,系统会判断当前的这一批工具调用是走 Sequential(串行)还是 Parallel(并行)。

Sequential vs Parallel 的判定逻辑

判定逻辑在 executeToolCalls() 入口处,任一条件命中即走串行:

  1. 全局配置:config.toolExecution === “sequential”,SDK/RPC 用户显式要求串行。
  2. 工具级声明:当前 batch 中任何一个 tool 的 executionMode === “sequential”,比如某些有副作用链的工具(写后读)在 Schema 注册时就标记了串行。
  3. 默认:以上都不满足 → Parallel。

注意:判定粒度是 per batch (一次 assistant 返回的所有 tool call),不是 per tool。Parallel 模式下,Prepare (Schema 校验 + beforeToolCall hook)仍然串行执行,先过安检,再放行并发。这避免了并发 race 导致的重复 block 检查。

如果在并发模式下:

  1. 预处理(Prepare)是串行的:依次校验 JSON Schema,并触发 beforeToolCall 钩子(防止越权)。
  2. 执行是真并行的:所有放行的工具并发启动。
  3. 退出的共识机制:每一批并发的工行完,都会返回一个状态 terminate。只有当所有的工具都同意结束循环(terminate: true)时,内循环才会被打破。如果任何一个工具说“我还得继续查”,整个引擎的齿轮就会继续滚动。

容易被忽视的一点:terminate 共识机制

shouldTerminateToolBatch() 的逻辑较为严格:

typescript
return finalizedCalls.length > 0 
  && finalizedCalls.every(r => r.result.terminate === true);

全部工具都声明 terminate: true,内循环才停。 任何一个工具返回 terminate: false(或 undefined,默认为 false),循环就会带着 tool results 再次请求 LLM。

典型 terminate: true 场景:

  • ask_user / HITL 工具:需要人类输入才能继续,循环应暂停等待下一条 user message (通过 follow-up 队列注入)。
  • Subagent 工具:子智能体已完成任务并返回最终结果,父 循环 无需再调 LLM 做二次总结。
  • Explicit “done” 工具:某些 Extension 注册的 task_complete 工具,显式标记任务结束。

与 follow-up 队列的交互:terminate: true 只终止内循环,不终止外循环。内循环结束后,外循环仍会检查 followUpQueue,如果有排队的追问,它们会被提升为 steering messages,开启新一轮内循环。这意味着:工具层说“我干完了” ≠ Agent 整体收工,用户提前排队的 follow-up 依然会被执行。

3.4.2 全局变动队列与协程锁 ​

为了解决多个并行工具同时编辑同一个文件带来的冲突,Pi 设计了一个精细的纯内存级“文件互斥锁”:

typescript
// 伪代码:极其轻量且优雅的文件级写入串行化
const fileMutationQueues = new Map<string, Promise<void>>();

export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
  const key = getRealPath(filePath);
  
  // 拿到上一个锁的 Promise,如果没有就是个立即解决的 Promise
  const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
  
  let resolveNext: () => void;
  const nextQueue = new Promise<void>(res => { resolveNext = res; });
  fileMutationQueues.set(key, nextQueue);

  // 死等前面的任务干完
  await currentQueue;
  
  try {
    return await fn(); // 执行真正的读写修改
  } finally {
    resolveNext!(); // 放行后续等待的任务
  }
}

有了这段代码,当模型密集地同时抛出 3 个针对同一文件的 edit 调用时,底层会将它们排队并依次执行,从而避免这一调度边界内的并发写入冲突。它不能替代跨进程文件锁,也不能防止其他进程同时修改目标文件。

原理深挖:file-mutation-queue 与 getRealPath 的归一化

Symlink 归一化:getMutationQueueKey() 先 resolve() 相对路径,再 realpath() 解析符号链接,/project/src 和 /project/link-to-src(symlink)会映射到同一个队列 key,防止两个并行 edit 从不同路径绕过锁。

读操作是否排队? 不排队。 withFileMutationQueue 只在 write / edit 工具内部调用。read 工具直接读文件,不经过队列,读无状态,不需要互斥。这避免了“读等待写、写等待读”的经典死锁模式。

死锁/饥饿防护:

  • 队列是 Promise 链(currentQueue → nextQueue),不是 mutex spinlock,等待者挂起在 Promise 上,不占用 CPU。
  • registrationQueue:全局注册队列保证多个并发 withFileMutationQueue 调用对 Map 的注册操作本身也是串行的,避免两个 Promise 同时 set 同一个 key 导致链断裂。
  • finally { releaseNext() }:即使 fn() 抛异常,后续等待者也会被放行,不会永久阻塞。
  • 队列 entry 在完成后自动删除(fileMutationQueues.delete(key)),不会内存泄漏。

3.4.3 命令输出的流控机制 ​

当大模型意外执行了 find / -name “*.js” 这种命令时,几百兆的终端输出会迅速淹没所有的上下文。 在 output-accumulator.ts 和 truncate.ts 中,Pi 构建了一个带溢出保护的“三级水库”。

  1. 滚动缓冲:对于流式回传的 Bash 出,系统在内存里只保留最后那一点活水(通常是 maxRollingBytes = 2 × maxBytes,约 100KB),前面的内容直接在内存中抛弃,防止 Node.js 进程 OOM。
  2. 截断反馈:它会统计究竟抛弃了多少行少字节,然后通过 truncateTail(),只把最后 2000 行喂给大模型,并在末尾贴心地加上一句:[...截断了 5231 行...],让模型有感知。
  3. 落盘追溯(Spill-to-Temp-File):那被丢弃的几千行真的消失了吗?没有。当系统发现发生了溢出,它会把完整的日志默默落盘写入到 /tmp/pi-output-xxxx.log 临时文件中,确保人类如果想审计,依旧有迹可循。

原理深挖:三级输出截断水库的阈值关系

三级水库不是三个独立模块,而是 OutputAccumulator 内部的三个阶段,阈值有明确的包含关系,如表 3-3 所示。

表 3-3  工具输出三级水库

级别触发条件行为
L1 滚动缓冲流式 append 过程中 tailBytes > maxRollingBytes内存 tail 区滑窗丢弃旧字节,不影响最终 snapshot
L2 截断反馈snapshot() 时 totalLines > maxLines(2000) 或 totalBytes > maxBytes(50KB)truncateTail() 只保留尾部 N 行/字节,追加 [...truncated N lines...] 元数据
L3 落盘追溯L2 触发了 truncation完整 raw output 写入 /tmp/pi-output-{random}.log,路径附在 tool result metadata 里

maxRollingBytes = 2 × maxBytes 的原理:流式 Bash 输出是单向追加、不可回溯的。如果 rolling buffer 和最终截断阈值相同(都是 50KB),在到达 50KB 之前内存就要保留全部数据,OOM 风险。设为 2 倍(100KB rolling / 50KB 最终),意味着内存里最多保留约 2 倍最终限额的数据,给 UTF-8 多字节字符边界和行边界对齐留出缓冲,同时保证在触发落盘之前内存不会无限增长。

流式 Bash incremental 截断:OutputAccumulator.append(data: Buffer) 使用 TextDecoder({ stream: true }) 增量解码,chunk 可能切在多字节 UTF-8 字符中间,trimTail() 会向前扫描到合法字符边界((byte & 0xc0) !== 0x80),避免截出乱码。getSnapshotText() 如果 tail 不是从行首开始,会丢弃第一个不完整行,宁可少显示半行,也不喂给 LLM 一个断句。

总之,Pi 的工具执行模块在并行自主权与工程约束之间做了折中:文件队列锁防 torn write,输出截断阀防 OOM。然而并行 batch 仍可能在语义层面制造竞态,两个 edit 针对同一函数的不同段落,锁能保文件完整性,保不了逻辑一致性。

3.5 树状记忆设计 ​

绝大多数大语言模型客户端和 Agent 框架,不管底层怎么封装,记忆结构仍是一个一维的 messages 数组。

这种结构有一个致命弱点:无法吃后悔药。 如果 Agent 在过去的 5 步推理中选错了方案,导致后面连续报错。由于上下文已经被错误的代码和逻辑污染,即便你强行要求它“重选方案”,它的思路依然会被那些错乱的尝试影响(所谓的“认知锚定”效应)。

认知锚定与路径依赖

当上下文中保留了大量失败尝试的完整日志时,模型会将这些错误路径当作隐式锚点,即使用户明确要求换方向,注意力仍被旧方案占据,新路径的推理空间被压缩。这与 Lost in the Middle 的机制不同:后者是检索精度下降,前者是推理方向被污染。

Pi 的 branch summarization (3.5.3 节)针对此问题设计:通过 navigateTree 切换分支,物理隔离错误路径的完整日志,同时用结构化摘要保留经验教训。相比 Claude Code 沿时间轴压缩的 compact,branch summary 是沿决策维度的去污染,粒度更细。

Pi 架构在 session-manager.ts 中打破了这个桎梏,实现了一个关键设计:把聊天记录做成 Git 级记忆树——每条消息以 parentId 链接成追加树而非线性数组,可从任意节点回溯到根,支持分支切换与“撤销最近几步操作”。

3.5.1 不可变的事件溯源 ​

在 Pi 中,你的会话并不是一个数组,而是一个 .jsonl 文件。每一次用户提问、工具调用、大模型回复,都是一条追加的日志。

json
// JSONL 中的单条记录样例
{"type":"message", "id":"msg_1", "parentId":"root", "timestamp":...}
{"type":"toolCall", "id":"tool_2", "parentId":"msg_1", "timestamp":...}

最重要的是那个 parentId。 这意味着,当前的会话上下文,实际上是:从某个特定的叶子节点,顺着 parentId 顺藤摸瓜回溯到根节点所串起的一条路径。

原理深挖:Append-Only Event Sourcing 模式

Pi 的 JSONL 存储是 事件溯源 的经典实现,与 CRUD 有本质区别,如表 3-4 所示。

表 3-4   CRUD 与事件溯源对照

维度CRUDEvent Sourcing (Pi)
存储单元当前状态快照不可变事件 log
修改方式UPDATE / DELETE只 APPEND 新 event
读取方式SELECT 当前行Projection:从 event log 推导当前视图
历史覆盖/丢失完整保留,可 replay
撤销需要 soft delete移动 leaf 指针即可

Event log → Projection → Replay 三板斧:

  1. Event log:.jsonl 文件,每行一个 event (message / toolCall / compaction / branch_summary / model_change...)。
  2. Projection:buildSessionContext() 是纯投影函数,输入 event log + leaf pointer,输出 LLM 可用的 messages 数组。Compaction 和 branch summary 是投影规则(折叠/摘要),不是 DELETE。
  3. Replay:SessionManager.loadEntriesFromFile() 逐行 parse JSONL 重建内存索引(byId Map + leafId),crash 后重启即从 log 完整恢复,无需 checkpoint 文件。

对比 CRUD 的 messages 数组:UPDATE 一条 message 会丢失旧版本;DELETE 不可恢复。Event Sourcing 的代价是 log 会增长,Pi 用 Compaction 投影规则在不删 log 的前提下控制 LLM 看到的“视图”大小。但这并非没有代价:JSONL 文件随会话拉长而膨胀,全量 replay 的启动时间在万条 entry 量级上开始可感知。

3.5.2 分支切换与历史回溯 ​

由于有了这棵树,当 Agent 思路走偏时,用户可以在界面上操作,调用 navigateTree(targetId) 接口。

它的逻辑如下:

  1. 重置 HEAD 指针:直接把游标指向 5 分钟前、出错之前的那句话对应的 targetId。
  2. 重建上下文:重新 buildSessionContext()。对大模型来说,后面的那几十条错误对话不再进入上下文,世界回到了刚开始做决策的那个路口。
  3. 开启新分支:当对话继续时,新的消息会作为 targetId 的子节点被追加。这就好像在 Git 里,你在某个旧 Commit 处 checkout -b new-branch,而原本那条错误的线路,变成了一个永远被封存的废弃分支。

navigateTree 背后的取舍

AgentSession.navigateTree(targetId, { summarize?, label? }) 是用户-facing 的“时光机” API,内部流程:

  1. 收集废弃分支:collectEntriesForBranchSummary(session, oldLeafId, targetId) 从 oldLeaf 回溯到两路径的最近公共祖先(LCA),收集将被放弃的所有 entry。
  2. Extension 拦截:emit session_before_tree 事件,Extension 可 cancel / 替换摘要 / 修改 label。
  3. 可选摘要:调小模型生成 branch summary (见 3.5.3)。
  4. 移动 HEAD:sessionManager.branch(targetId) 或 branchWithSummary(targetId, summary),只改内存中的 leafId 指针,不修改任何已有 entry。
  5. 重建 Agent 状态:agent.state.messages = buildSessionContext().messages,Agent 的 in-memory context 与新的 HEAD 同步。
  6. 持久化:下一次 appendMessage 时,新 entry 的 parentId = targetId,JSONL 追加新行,旧分支 entry 原封不动留在文件中。

并发编辑冲突:Pi 是单进程 CLI,不存在 Git 式 merge conflict。但如果用户在 Agent 运行中 navigateTree,_branchSummaryAbortController 会 abort 正在进行的摘要生成,导航优先于后台摘要。运行中的 Agent Loop 不会被 navigate 打断(需要等当前 turn 结束或用户 abort),但 navigate 后下一次 prompt 会使用新分支的 context。

HEAD 持久化:leafId 不单独存盘,它隐含在最后一条 append 的 entry 的 parentId 链中。加载 JSONL 时,默认 leaf = 最后一条 entry。如果 crash 发生在 append 前,leaf 仍指向上一个稳定位置,最多丢一条未落盘的消息,不会 corrupt 整棵树。

3.5.3 分支废弃与记忆回收 ​

在传统的撤销操作中,我们虽然消除了污染,但也丧失了试错的经验。比如“原来那个包有依赖冲突,不能用”。

Pi 架构在此处的处理是一个设计亮点: 在 collectEntriesForBranchSummary 中,当系统检测到你从 oldLeafId 切回了旧的 targetId。 它会顺着废弃的那条分支向上爬,收集那些错误的尝试日志。然后用一个后台小模型,把这些失败总结成一段话(比如:“在上一条废弃分支中,证明了使用 Webpack 打包会产生循环依赖报错”)。

最后,通过 branchWithSummary() 接口,把这段经验作为一个名为 BranchSummaryEntry 的特殊节点,直接挂载到你新切回的那条分支的最开头。

此时的大模型:

  1. 没有被错乱的报错日志挤占上下文窗口。
  2. 没有被错误的代码带偏注意力。
  3. 并且获得了一条清晰的“避坑指南”。

原理深挖:branchWithSummary 的收集算法与节点 Schema

收集范围算法(collectEntriesForBranchSummary):

  1. 计算 oldPath = session.getBranch(oldLeafId) 的 id 集合。
  2. 计算 targetPath = session.getBranch(targetId),从叶到根反向扫描,找到最深的 LCA。
  3. 从 oldLeafId 沿 parentId 回溯到 LCA,收集路径上所有 entry (包括 compaction entry,它们的摘要会被当作上下文纳入 branch summary,而不是停止收集)。
  4. 反转数组 → 时间正序。

摘要 Prompt:复用 Compaction 的 SUMMARIZATION_SYSTEM_PROMPT + 结构化格式,但侧重点不同,Branch summary 强调 “这条路上踩了什么坑、结论是什么”,而不是 “当前任务进度”。Tool result 同样截断至 2000 字符。Token 预算由 reserveTokens(默认 16384)控制,超出预算时从最早的 entry 开始丢弃(但保留 LCA 之后的所有 entry 不进入摘要)。

BranchSummaryEntry 节点 Schema:

json
{
  "type": "branch_summary",
  "id": "uuid",
  "parentId": "targetId",
  "fromId": "oldLeafId",
  "summary": "在上一条废弃分支中,证明了 Webpack 会产生循环依赖...",
  "details": { "readFiles": [...], "modifiedFiles": [...] },
  "timestamp": "ISO-8601",
  "fromHook": false
}

branchWithSummary(branchFromId, summary, details) 把此 entry 作为 branchFromId 的子节点 append,然后 leafId = entry.id,新对话从摘要节点开始,LLM 看到的第一条消息就是“避坑指南”。

3.5.4 持久化同步策略 ​

如此复杂的树形结构,会不会导致读写变慢? 并不会。因为 AgentSession 监听了核心循环的 message_end 事件。无论是大模型吐出的一句话,还是某个工具的执行结果,一旦落定,立马转化为 JSONL 追加到磁盘。这是一种纯天然的 WAL (Write-Ahead Log),即使你突然拔掉电脑电源,Agent 的记忆树也完好无损,重启后它依旧知道自己所在的那个树杈位置。

原理深挖:WAL 原理与 Pi JSONL 追加的对应

WAL 是数据库的核心持久化策略:先写 log,再改状态。PostgreSQL 的 WAL、MySQL 的 redo log、RocksDB 的 memtable flush,都遵循同一原则,崩溃恢复时,replay log 即可重建一致状态。

Pi 的 JSONL 追加与 WAL 的对应关系,如表 3-5 所示。

表 3-5   JSONL 与预写式日志对应

WAL 概念Pi 实现
Log recordJSONL 中的一行(一个 SessionEntry)
Append (顺序写)sessionManager._appendEntry() → fs.appendFileSync 追加一行 JSON
fsync 策略Node.js appendFile 默认不 fsync 每条,OS 页缓存批量刷盘。Crash 窗口:最多丢 OS buffer 中的最后几条 entry
Checkpoint不需要,每次 load 从 log 全量 replay 重建 byId 索引
Crash recovery重启 → loadEntriesFromFile() → parse 每行 JSON → 重建树 → leafId = 最后一条 entry.id
ProjectionbuildSessionContext() = WAL replay + 视图规则

与数据库 WAL 的差异:Pi 没有 undo log (因为 append-only 不做 UPDATE),“撤销”通过移动 leaf 指针实现,旧 log record 永不删除,这更像 Git object store 而不是传统 DB。如果需要更强 durability,可以在 _appendEntry 后加 fs.fsyncSync(),Pi 选择了性能优先的批量刷盘策略,在 CLI 场景下足够。

总之,Git 级状态树解决的不是并发控制问题,而是决策回溯,让用户在 Agent 走偏时能“checkout”到分岔路口。Branch summary 把废弃分支的经验压缩成避坑指南,而不是简单丢弃。

3.6 插件第一性的微内核设计 ​

§3.5 的记忆树解决了决策回溯的问题;本节讨论另一个架构问题:能力如何插件化扩展。在 Pi 的架构中,连内置的“工具 UI 渲染”和“指令”都是作为扩展(Extensions)存在的。

微内核的 Core / Extension 切分

Pi 的微内核设计不是学术概念,而是代码层面的物理隔离,packages/agent(内核)的源码中没有任何 tool 实现、没有任何 TUI 代码、没有任何 provider 配置,如表 3-6 所示。

表 3-6  内核与扩展职责划分

归属 Core (不可移除)归属 Extension / Adapter (可替换)
agentLoop 嵌套状态机bash / read / edit / write 工具
AgentEvent 类型定义TUI 组件(Markdown 渲染、Editor、Spinner)
Tool execution 协议/command 斜杠指令
Steering / Follow-up 队列机制Compaction prompt 定制(session_before_compact)
convertToLlm / transformContext 端口Subagent 子进程扩展
Themes、技能库、Prompt Templates

内核只做调度,不做决策。 “要不要 block 这个 bash 命令?”,Core 不管,Extension 的 tool_call handler 返回 { block: true }。“用什么 prompt 做 compaction?”,Core 提供 prepareCompaction() 纯函数,Extension 可完全替换 summary。

这种切分的好处:npm install -g @mariozechner/pi-coding-agent 装完就能跑,但你可以不写一行 TypeScript 就通过配置禁用所有内置工具、替换为纯 HTTP API 工具,因为 Core 对 Tool 的定义只是一个 { name, execute, parameters } 接口。

  • 事件总线: 核心会话抛出多达 30 种精细的生命周期事件(如 before_agent_start, tool_call, session_before_compact)。

30 种生命周期事件分类大纲(代表性 12 类),如表 3-7 所示。

表 3-7  生命周期事件分类大纲

类别事件同步/异步能否改变行为
Session 生命周期session_start, session_before_switch, session_before_fork异步 await可 可 cancel
Agent 循环before_agent_start, agent_start, agent_end, turn_start, turn_end异步 await可 可注入 message/systemPrompt
消息流message_start, message_update, message_end同步 emit否,纯通知
工具tool_call, tool_result, tool_execution_start/end/update异步 await可 可 block/mutate
Compactionsession_before_compact, session_compact异步 await可 可替换 summary / cancel
Tree 导航session_before_tree异步 await可 可 cancel / 替换 branch summary
Providerbefore_provider_request, after_provider_response异步 await可 可替换 payload
Inputinput异步 await可 可 transform / handled
Resourcesresources_discover异步 await否,通知
User Bashuser_bash异步 await否,通知
Model/Thinkingmodel_select, thinking_level_select同步 emit否,通知
Project Trustproject_trust异步 await可 可决策

Dispatch 模型:Extension 事件通过 ExtensionRunner.emit() 串行 await 每个 handler,后一个 handler 能看到前一个的 mutation。Agent 核心事件(L1)则是 fire-and-forget,不阻塞 循环。这是刻意的性能取舍:Extension 需要一致性(barrier 语义),UI 需要低延迟(observational 语义)。在实际使用中,tool_call 与 session_before_compact 的重要性远高于 model_select 等纯通知事件,前者直接决定命令能否执行、摘要能否替换,后者只影响界面展示。

  • 进程隔离的子智能体(Subagent): 在 examples/extensions/subagent 中,我们看到 Pi 支持通过插件动态拉起新的、完全隔离的子进程 Agent。这种松耦合的架构,使得我们可以无限横向扩展 Agent 的能力(如让它挂载专门读数据库的插件、专门发邮件的插件),而无需修改底层的一行核心 TypeScript 代码。

原理深挖:Subagent 的 IPC 与上下文隔离

Subagent Extension 的实现策略是 Process-per-Agent,而不是 Thread-per-Agent:

  1. IPC 机制:父 Agent 通过 spawn('pi', ['--mode', 'json', ...]) 拉起子进程,stdin/stdout JSON Lines 协议通信,父进程写 { “type”: “prompt”, “message”: “...” },子进程 stream { “type”: “message_update”, ... } 事件。不共享内存,不共享 AgentContext。
  2. 上下文隔离:每个 Subagent 有独立的 context window、独立的 JSONL session (或 ephemeral session)、独立的 tool registry。父 Agent 的 10 万 token 历史不会泄漏给子智能体,子智能体只看到父 Agent 通过 prompt 传递的任务描述。
  3. 生命周期管理:子进程 timeout + AbortSignal 级联取消。Parallel 模式最多 8 个 task、4 并发(MAX_PARALLEL_TASKS / MAX_CONCURRENCY)。子智能体完成后返回 { result, usage, terminate: true },父 循环 的 tool batch 收到 terminate 信号后停止。
  4. 为什么用进程而不是 Goroutine/Worker Thread? Typcript 单线程 Event Loop 无法做真正的 CPU 隔离;子智能体如果 OOM 或死循环,进程边界保证父 Agent 不受影响,这是微内核“扩展运行在用户态”的 Unix 哲学。

总之,Pi 的分层、双队列与 Git 级记忆树各自回答不同问题,可替换性、中途纠偏、决策回溯。三者能否在同一产品里长期共存,尚无定论;但作为参照,它们至少表明 Agent 框架可以比“一个 while 循环 + messages 数组”复杂得多,而不必复杂到不可维护。下一章将解构第三家标杆,OpenCode 的并发模型;此后,我们便开始用 Go 从零构建自己的 Agent 引擎。