前言

过去两年,AI Agent 经历了从概念到落地的快速演进。编码 Agent 率先成熟——Claude Code、Codex、OpenCode 等工具已经成为很多开发者的日常。

这些系统的底层架构有很多共通之处:LLM 调用、工具执行、上下文管理、会话持久化、扩展机制……但如果你想深入理解一个 Agent 是怎么跑起来的,去读 Claude Code 或 OpenCode 的源码并不现实——前者闭源,后者规模庞大且耦合度高。

pi 是一套开源的 Agent 开发包,涵盖 LLM 抽象、Agent 引擎、终端 UI 等核心能力,其中的编码助手只是它的一种产品形态。它适合作为学习 Agent 架构的入口,原因很简单:pi 的设计哲学是最小核心 + 扩展优先。Agent Loop、Context、工具、会话树和扩展系统这些基础层都放在明处,很多具体工作流可以通过扩展组合出来。

不过,“扩展优先”不等于“所有能力都来自社区插件”。当前 CLI 已附带 MCP、codemode、tool_search 等内置扩展,重试、压缩和会话管理也属于运行时能力;常规 coding-agent 则仍没有内置 Plan Mode 或完整的子代理编排。权限审批可以由扩展实现,但它和项目资源信任、操作系统级隔离不是一回事。

这篇博客会逐层深入 pi 的架构,不只看"能做什么",更关注"为什么这样设计"。

1. Agent Loop:LLM 与工具的循环

1.1 Tool Call 循环

当 LLM 的回复中包含 ToolCall 时,循环不会停止。pi 会:

  1. 从 LLM 回复中提取所有 ToolCall
  2. 执行工具,拿到 ToolResult
  3. 把 ToolResult 加入上下文
  4. 再次调用 LLM,让它看到工具结果并决定下一步

当 LLM 的回复中不再包含 ToolCall 时,当前 Turn 通常结束;如果包含 ToolCall,pi 会执行工具、把结果交回 LLM,再开始下一轮。这就是 Agent 的核心——LLM 不只是生成文本,它通过工具与外部世界交互,根据结果自主决定下一步操作。

不过,“没有 ToolCall”只说明当前回复没有要执行的工具,不一定代表整个运行已经结束。队列消息、自动重试、上下文恢复和扩展逻辑都可能让 pi 再发起模型请求,后面会继续拆解。

举一个具体例子:你对 pi 说"把 config.json 里的 port 改成 8080"。

Turn 1: LLM → ToolCall(read, {path: "config.json"})
         → ToolResult: 文件内容
Turn 2: LLM → ToolCall(edit, {path: "config.json", oldText: "port: 3000", newText: "port: 8080"})
         → ToolResult: 编辑成功
Turn 3: LLM → "已将 port 从 3000 改为 8080" (纯文本,无 ToolCall)
         → 循环结束

三个 Turn,两次工具调用,LLM 自主完成了读取→修改→确认的全流程。

当一次回复中包含多个 ToolCall 时,pi-agent-core 默认并行执行。工具可以声明自己需要串行;只要一个批次中有工具要求串行,整个批次就会串行执行。内置 edit/write 还会通过文件修改队列避免对同一文件的并发写入。

1.2 Steering 和 FollowUp:两种干预方式

Agent 在执行过程中,用户不是只能干等。pi 提供了两种消息注入机制,对应一个两层循环结构。

Steering(实时干预):当 agent 正在执行工具时,用户发送的消息会进入 steering 队列。当前 Turn 的工具全部执行完后,steering 消息被注入上下文,LLM 在下一个 Turn 就能看到用户的指示。比如 agent 正在批量修改文件,你发现方向错了,发一条"停,只改 config.json",agent 在当前这批工具跑完后就会收到你的指示。

FollowUp(后续追加):当 agent 完成所有工作、即将停止时,pi 会检查 followUp 队列。如果有消息,agent 不会退出,而是开启新的一轮循环处理这些消息。这适合"顺便帮我做个XXX"的场景。

结合起来看,内层循环处理 tool call 和 steering,外层循环处理 followUp。两层队列都为空时,Agent Loop 可以自然结束;但在 coding-agent 外层,还可能继续执行重试、压缩恢复或扩展续跑。这里的“停止”需要结合运行时生命周期一起理解。

1.3 一次运行不只是一轮 Loop

pi 把通用的 Agent 循环和编码助手的会话管理分成了两层:pi-agent-core 负责模型响应、工具执行、队列和基础事件;pi-coding-agent 的 AgentSession 则负责把它接到会话持久化、扩展系统、自动重试和 Compaction 上。

因此,agent_end 只表示一段底层 Agent Loop 暂时结束,并不一定表示用户提交的工作已经彻底完成。coding-agent 可能在它之后重试模型请求、压缩上下文后重试、处理队列消息,或让扩展请求继续运行。扩展可以在 agent_before_settle 这个最后的可操作边界追加工作;agent_settled 才表示 pi 不会再自动继续。

Agent Loop 没有统一的“最多运行 N 轮”限制,但这不代表一次运行中的所有操作都没有超时或重试上限。Provider 请求、工具执行和自动重试各自有独立的控制策略。

2. Context:LLM 看到的完整世界

Agent Loop 每次调用 LLM 时,传递的是一个 Context 对象。LLM 能做什么、知道什么,完全取决于这个对象里装了什么。理解 Context 的组装过程,就理解了 pi 如何向 LLM 描述世界。

从模型视角,可以把一次请求概括为三部分:System Prompt、Messages 和 Tools。但在 pi 内部,它不是一个每轮都从零拼起来的静态对象:会话管理器先从当前分支生成模型可见上下文,扩展还可以转换消息;之后 convertToLlm() 再把内部消息转换成 Provider 能理解的格式。模型、思考等级等请求参数也会随请求一起传递。

2.1 System Prompt

System Prompt 是 LLM 的“角色卡”,告诉它你是谁、能做什么、该怎么做。默认提示由多个 section 组成,包括角色定义、行为规则、工具说明、项目上下文文件、Skills 列表和工作目录;扩展也可以添加自己的 section。Skills 采用渐进式披露:System Prompt 只列出名称、描述和路径,完整内容由 LLM 需要时再读取。

这里还有一个容易忽略的实现细节:提示内容和工具声明会以结构化状态写入会话。提示或工具集变化时,pi 记录变化部分,而不是把这些状态当成不留痕迹的临时参数。回放会话时,可以据此重建当时生效的 System Prompt 和工具集。

2.2 Messages

Messages 是 Agent Loop 中积累的对话历史:用户输入、LLM 回复和工具调用结果。内部消息类型比 Provider 协议更丰富,经过 convertToLlm() 后,才会转换或筛选为 Provider 能理解的消息;例如扩展自定义消息可以只用于界面,也可以转换成模型可见的上下文。

2.3 Tools

Tools 是工具的 JSON Schema 定义,告诉 LLM 每个工具接受什么参数、参数的类型和含义。这不是 System Prompt 里的文本描述,而是结构化的 schema,LLM 据此生成合规的 ToolCall。

3. 工具设计:LLM 的手和眼

工具不是简单的“函数调用列表”,而是 LLM 观察世界和改变世界的接口。默认启用的四个工具是 read、bash、edit、write;完整内置工具还包括可选的 grep/find/ls,以及 Windows 上可用的 powershell。其中 read/grep/find/ls 负责观察项目,edit/write 负责改变文件,bash/powershell 负责调用外部环境。

这几类能力的风险不同:读操作主要提供信息,写操作会改变项目状态,Shell 的边界最大。因此,工具层不能只关心“能不能调用”,还要处理输出截断、并发冲突、错误反馈和权限控制。默认只有四个工具,不代表整个运行时永远只有四个:扩展和 MCP server 可以加入更多工具。

3.1 截断策略

工具输出最终会进入上下文,但 LLM 的窗口有限。pi 用行数和字节数做双重限制,哪个先到就截哪个。典型的两个方向是:

  • read 用 truncateHead——保留文件开头,截掉尾部。截断后会在输出末尾追加提示,比如 [Showing lines 1-2000 of 50000. Use offset=2001 to continue.],告诉 LLM 可以用 offset 参数继续往下读。
  • bash 用 truncateTail——保留命令输出的末尾,截掉开头。因为命令执行的关键信息通常在最后:错误信息、退出码、最终结果。

这让 LLM 具备了“翻页”能力:先看关键部分,必要时再继续读取。

3.2 Codemode 和 MCP:工具如何继续扩展

当前 Pi CLI 除了传统的单个工具调用,还内置 codemode 和 tool_search 等扩展。codemode 允许模型编写一段 JavaScript,在 QuickJS sandbox 中组合多个工具、并行执行,再把筛选后的结果返回给模型;它还可以调用分类模型和图像生成模型。tool_search 则负责从尚未直接展示给模型的工具中搜索、启用合适的工具。

Pi 也提供内置 MCP 支持。MCP server 的工具可以直接暴露给模型,也可以通过 codemode 或搜索按需使用,从而避免一开始就把大量工具说明塞进上下文。需要注意:codemode 脚本本身没有 Node、文件系统或网络 API,但它调用的工具是真实操作;脚本失败不会撤销已经完成的工具调用。

4. Compaction:上下文的瘦身术

Agent 的对话历史会不断增长,但 LLM 的上下文窗口有限。pi 的解决方案是 Compaction(压缩):用一段摘要替代旧消息,释放上下文空间。

4.1 触发时机

压缩会在接近上下文窗口上限时自动触发;如果已经触发 context overflow,pi 也会先压缩再重试。用户也可以手动触发,并用自定义指令控制摘要侧重点。

4.2 压缩流程

压缩真正难的地方不是“总结聊天记录”,而是安全地切分历史。一次 Agent 操作往往由用户请求、assistant 的工具调用、toolResult、下一次 assistant 决策组成。如果从中间切开,LLM 可能只看到工具结果,却看不到对应调用。

所以 pi 会从最新消息往回保留一段近期上下文,并寻找安全切割点。切割点不会落在 toolResult 中间,因为工具结果必须和对应的工具调用保持连续。被切掉的旧消息会序列化成文本,再由 LLM 总结为目标、进度、关键决策、下一步和文件操作记录。

加载上下文时,旧消息不再全部进入 LLM,而是由摘要加上最近保留的消息共同组成新的上下文。

压缩是有损的——细节会丢失。但完整的对话历史仍然保存在会话文件中。当前实现还允许通过 context_edit entry 调整后续模型上下文:例如溢出恢复时,把失败的尝试从后续请求中省略,再压缩并重试。原始 entry 仍在历史中,只是当前分支的模型上下文投影不再包含它。

5. 会话树:无损回溯与分支探索

编码场景下,LLM 试错是常态。会话树解决的不是存储问题,而是探索问题:Agent 的执行路径可能出错,但错误路径本身也有信息价值。

线性对话模型下,你要么截断后面的历史,要么新建一个无关联会话。pi 则用树结构管理会话:除 header 外,每条 entry 都有一个 parentId 指针,文件仍然是 append-only 的 JSONL,只是通过指针决定当前走哪条分支。

5.1 parentId 链式结构

新的 entry 会追加到文件末尾,挂在当前 leaf 下面。branch() 只移动 leaf 指针到更早的 entry,不复制、不删除数据;下一次 append 自然形成新分支。

5.2 从 leaf 到 root 的路径遍历

Agent Loop 仍然需要线性消息列表。buildSessionContext() 会从当前 leaf 回溯到 root,只收集当前路径上的 entry;树上其他分支不会进入 LLM 上下文。

5.3 分支切换时的摘要

切换分支时,pi 可以对“被放弃的那条路径”生成摘要,并挂在新分支起点。这样新分支不会继承旧分支的完整上下文,但仍能知道之前探索过什么。

5.4 与压缩的关系

树结构解决“回到之前某个节点重新开始”,压缩解决“上下文太长需要瘦身”。两者都只追加 entry、不修改已有数据。

5.5 会话文件不只是聊天消息

当前 JSONL 会话除了消息,还会记录模型和思考等级变化、Compaction、分支摘要、扩展状态、用量统计、标签和会话名称等 entry。custom 用来保存扩展状态,通常不进入模型上下文;custom_message 则可以成为模型可见消息。context_edit 可以让一条旧消息在当前分支的后续请求中被省略或替换,而不改写原始历史。

所以,SessionManager 给 LLM 的不是“把文件里所有 message 按顺序读一遍”,而是从当前 leaf 构造分支路径,再应用压缩和 context edit,生成模型实际看到的上下文。切换分支后,同一条原始消息也可能重新出现在上下文里。

6. 插件化:最小核心,无限扩展

pi 的扩展系统是它区别于其他 Agent 框架的核心设计。前面提到的 Agent Loop、工具、Context 都可以在扩展中注册和拦截。

6.1 整体架构

扩展本质上是一段加载到 Pi 进程内的 TypeScript 模块。pi 会扫描并加载扩展,再执行工厂函数完成注册;Agent 运行时,ExtensionRunner 在关键节点按顺序调用对应 handler。前一个 handler 的输出会成为后一个的输入,部分事件可以短路(block / cancel)。扩展因此可以深入影响运行时行为,但也意味着扩展代码与 Pi 进程共享操作系统权限,并不是隔离的沙箱。

抛开终端 UI 这类偏产品层的能力不谈,pi 的扩展能力主要可以分成三类:注册、拦截、注入。

6.2 注册——给系统加东西

注册的本质是给 Agent 增加新的可见对象:工具进入 LLM 的 tool schema,命令进入用户交互层,Provider 进入模型选择层。模型不关心工具来自核心还是扩展,只会根据当前上下文里的描述和 schema 决定是否调用它。除了注册 Provider,扩展还可以注册 virtual model:用户选择的是一个逻辑模型,Pi 在每次请求时再把它路由到实际 Provider 和模型。这样可以按任务、成本或会话阶段选择不同模型,而工具后续请求也可以保持在合适的物理模型上。

这也是 pi 能保持小核心的原因:核心只定义扩展点,具体能力在外部生长;同时,MCP、codemode 和 tool_search 等常用能力也可以由 Pi 自带的扩展提供。

6.3 拦截——在关键节点介入

拦截让扩展可以在 Agent 生命周期的关键节点介入:工具执行前的 tool_call、工具执行后的 tool_result、LLM 调用前的 context、Provider 请求前的 before_provider_request、压缩前的 session_before_compact、分支/会话操作前的 session_before_tree 等。

这些事件是链式传递的。多个扩展监听同一事件时,前一个扩展的修改会叠加到后一个扩展看到的输入上。扩展还可以在 turn_end 或 agent_before_settle 边界提交会话 entry,并请求一次后续模型调用。

一个典型的拦截场景是工具审批。权限确认并不需要写进 Agent Loop 本身,而是可以作为 tool_call 之前的一道门:

LLM 生成 bash ToolCall
→ permission-gate 扩展检查命令
→ 安全则放行
→ 危险则请求用户确认
→ 用户拒绝时返回 block,工具不执行

这样 Agent Loop 不需要内置固定的逐工具审批策略,不同团队可以用扩展实现自己的确认流程。但这不是完整的安全边界:扩展本身运行在 Pi 进程内,工具仍拥有该进程的操作系统权限。

Pi 另有 project trust 机制,用来决定是否加载项目中的设置、MCP 配置和可执行扩展等资源;它不负责批准每次工具调用,也不会把 Shell 限制在工作目录里。AGENTS.md 等上下文文件仍可能影响模型。需要更强隔离时,应使用容器或操作系统 sandbox,而不是把 project trust 或一个确认弹窗当成隔离措施。

6.4 注入——主动操作 Agent 状态

注册和拦截主要是在 Agent 经过某个节点时被动生效,注入则让扩展主动改变 Agent 状态:塞入新消息、写入自定义会话 entry、切换模型、调整思考深度、动态启停工具。

这让扩展不只是“插件按钮”,而是可以参与运行过程。比如 git checkpoint、文件监听、自动消息注入都不是核心功能,但都可以通过同一套生命周期和状态注入机制实现。

参考资源