Pi Agent 为什么设计得好:从源码拆解一个极简 Agent Harness
从 Pi Agent 0.84.3 源码出发,拆解其模型工具循环、append-only 会话树、上下文压缩、扩展中间件、Provider 适配与安全取舍,总结构建 Agent Harness 最值得借鉴的设计原则。
最近我一直在用 Pi Agent,也在不断给它增加插件、Skills、MCP 和多 Agent 编排。用得越多,我越觉得它的设计很特别:核心看起来并不复杂,却很容易被改造成适合自己的工作环境。
很多 Coding Agent 的思路是继续往产品里增加功能:计划模式、任务列表、子 Agent、审批弹窗、后台命令、MCP、记忆系统。Pi 反过来,主动不内置其中的大部分能力,只保留模型循环、工具协议、会话、事件和扩展接口。
这种做法表面上像“功能少”,实际却对架构边界提出了更高要求。因为只要核心协议不稳定,外面的插件很快就会变成一堆互相冲突的补丁。
我把 Pi Agent 的官方文档和本机安装包源码完整看了一遍。最后发现,它真正值得参考的不是某个神奇算法,而是一套很成熟的 Agent Harness 设计:把事实记录成事件,把运行状态做成投影,把能力做成分层插件,把模型厂商差异留在边界。
本文基于我本机安装的
@earendil-works/pi-coding-agent 0.84.3分析。Pi 更新很快,后续版本的模块和行为可能变化。
先说结论
Pi 的底层仍然是一个工具增强的 Agent 循环:
用户消息
↓
模型流式响应
↓
模型调用工具
↓
执行工具并回填结果
↓
模型继续推理
↓
没有更多工具调用后结束
这和大部分 ReAct 类 Agent 没有本质区别。Pi 做得好的地方,是把这个容易失控的循环工程化成了几个稳定边界:
- 模型循环足够薄,只理解消息、工具、事件、队列和流;
- 会话是 append-only 事件树,不是一个随意修改的聊天数组;
- 完整历史、当前分支和模型上下文是三个不同对象;
- 权限、上下文、压缩、UI 和 Provider 都能通过中间件插入;
- Anthropic、OpenAI、Google 等厂商差异被压缩到统一流协议;
- Prompt、Skill、Extension 和 Package 按成本分层;
- 用户在 Agent 工作期间发来的消息有明确的并发语义;
- Plan Mode、Sub-agent、MCP 等工作流观点被留在核心之外。
所以我认为,Pi 更准确的定义不是“终端里的 AI 编程工具”,而是:
一个以事件流、会话树和插件中间件为核心的本地 Agent Harness。
Pi 的整体分层
先看一张简化后的架构图:
┌─────────────────────────────────────────────┐
│ Interactive TUI / Print / JSON / RPC / SDK │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ AgentSessionRuntime │
│ 管理 new、resume、fork、import 和 cwd 切换 │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ AgentSession │
│ 输入、重试、压缩、扩展、工具和会话持久化 │
└─────────────┬──────────────────┬────────────┘
│ │
┌─────────────▼────────────┐ ┌──▼────────────────────┐
│ pi-agent-core │ │ SessionManager │
│ Agent + agentLoop │ │ append-only JSONL 树 │
└─────────────┬────────────┘ └───────────────────────┘
│
┌─────────────▼───────────────────────────────────────┐
│ ModelRuntime / pi-ai │
│ Auth、模型目录、Provider 组合、统一流式协议 │
└─────────────┬───────────────────────────────────────┘
│
Anthropic / OpenAI / Google / Bedrock / Custom
横向还有几组独立能力:
ResourceLoader负责加载AGENTS.md、Skills、Prompts、Themes 和 Extensions;ExtensionRunner负责生命周期事件和中间件链;- Tool Registry 组合内置工具、SDK 工具和扩展工具;
pi-tui独立负责终端组件、焦点和差分渲染。
这里最重要的是依赖方向。低层 pi-agent-core 不知道 TUI、JSONL、项目目录、Skills 和具体模型厂商。上层可以换 UI、换存储、换 Provider,却不需要重写 Agent Loop。
一次请求到底是怎么跑起来的
以用户在 TUI 或 RPC 里发送一个普通任务为例,完整链路大致如下:
用户输入
↓
扩展命令检查
↓
input 中间件转换或拦截
↓
展开 Skill / Prompt Template
↓
检查模型、认证和上下文容量
↓
before_agent_start
↓
Agent.prompt()
↓
agent_start / turn_start
↓
调用 Provider 流
↓
assistant message
↓
工具参数准备和 Schema 校验
↓
tool_call 权限与策略检查
↓
执行工具
↓
tool_result 结果修正
↓
结果回填模型上下文
↓
继续下一轮模型调用
↓
重试 / 压缩 / Follow-up
↓
agent_settled
这条链路里有几个容易被忽略的细节。
输入不是直接塞进 messages
AgentSession.prompt() 会先判断它是不是扩展命令,然后触发 input 中间件。扩展可以继续、修改输入,或者直接处理,不再进入模型。
之后才会展开 /skill:name 和 Prompt Template。如果 Agent 已经在运行,新消息还必须明确选择 steer 或 followUp,否则 Pi 会拒绝这个输入,而不是靠到达时间猜测用户意图。
每次模型调用前都可以重建上下文
在调用 Provider 之前,Extension 的 context Hook 会拿到消息的深拷贝,可以过滤旧消息、注入外部信息,或者执行其他上下文策略。
然后 convertToLlm() 才把 Pi 自己的消息类型转换成模型能理解的 user、assistant 和 toolResult。
这个顺序说明了一件很重要的事:Agent 内部消息和模型协议消息不是同一个类型。 UI 状态、压缩摘要、分支摘要和扩展消息可以先保留自己的语义,直到最后一个边界才投影成模型输入。
agent_end 还不一定真的结束
一次低层 Agent Run 结束后,AgentSession 还会检查:
- 是否遇到可重试的限流、过载或服务端错误;
- 是否发生上下文溢出,需要压缩后重试;
- 是否超过主动压缩阈值;
- 扩展是否在
agent_end阶段追加了新消息; - Follow-up Queue 里是否还有任务。
所有自动动作完成后,Pi 才发出 agent_settled。
所以 agent_end 表示“一次低层运行结束”,agent_settled 才表示“这次任务不会再因为重试、压缩或队列自动继续”。做状态栏、通知和自动化插件时,这个区别非常关键。
核心循环为什么要保持薄
pi-agent-core 里的 Agent 主要保存:
system prompt
model
tools
messages
thinking level
streaming state
pending tool calls
steering queue
follow-up queue
abort controller
真正的 agentLoop 只处理:
turn -> model stream -> tool calls -> tool results -> queue -> stop
它没有内置计划、待办事项、子 Agent、Git 工作流、MCP 或权限界面。这些能力都可以通过外层 Session 或 Extension 实现。
我很认同这个边界。因为 Plan Mode、Sub-agent 和审批系统并不是所有 Agent 都应该共享的基础语义,它们只是不同产品对工作流的选择。
如果把这些功能全部塞进核心循环,循环很快就会知道太多事情:任务状态、子进程、审批、远程服务、UI、预算和持久化。最后任何一个功能升级都可能破坏整个 Agent。
Pi 的做法是让 Loop 只负责推进,把“应该怎样工作”留给外层。
工具并发设计比看起来更严谨
模型一次可能返回多个 Tool Call。Pi 默认允许并发,但并不是直接 Promise.all()。
它会先按模型输出顺序完成 Preflight:
- 找到对应工具;
- 执行
prepareArguments(),兼容旧版参数; - 通过 TypeBox Schema 校验;
- 顺序执行
beforeToolCall; - 允许扩展修改参数或阻止执行。
只有通过检查的调用才进入执行阶段。
如果全局配置要求串行,或者这一批里有工具声明 executionMode: "sequential",整批工具会依次执行。否则允许的工具并发运行,并通过 tool_execution_update 持续发送进度。
更值得注意的是结果顺序:
tool_execution_end按实际完成顺序发出,方便 UI 及时显示;- 最终写回模型上下文的
toolResult,仍保持 Assistant 原始调用顺序。
这同时解决了三个问题:
- 权限和参数检查有确定顺序;
- 无依赖工具可以并发;
- 模型看到的结果顺序稳定,不会因为网络和磁盘速度随机变化。
如果一个自定义工具会修改文件,Pi 还提供 withFileMutationQueue()。同一个文件的修改会串行,不同文件仍可并发。已有文件会通过 realpath() 归一化,因此不同符号链接指向同一文件时,也不会落入两条独立队列。
这是很实际的保护。否则两个并发的 Read-Modify-Write 都读到旧内容,最后完成的操作会直接覆盖另一个操作。
会话不是一个可变的聊天数组
Pi 的 Session 使用 JSONL 保存,每一行是一个 Entry:
{
id: string,
parentId: string | null,
type: string,
timestamp: string
}
leafId 表示当前会话位置。正常追加会创建当前 Leaf 的子节点;Branch 只移动 Leaf,历史节点不修改也不删除。
因此 Pi 实际区分了三个对象:
完整 Session Log
≠
当前 Active Branch
≠
发给模型的 LLM Context
这是我认为整个项目里最值得借鉴的设计。
很多 Agent 框架只维护一个可变的 messages[]。开始时很简单,但加入分支、压缩、恢复、审计和扩展状态后,问题会迅速出现:旧消息应该删除还是隐藏?切换分支后怎样恢复模型和 Thinking Level?压缩后的摘要应该替换历史还是附加在历史上?UI 状态要不要发给模型?
Pi 把这些问题拆开了:
getEntries()返回完整事件日志;getBranch()从当前 Leaf 回溯活动路径;buildContextEntries()应用压缩边界;buildSessionContext()再把选中的 Entry 投影成模型消息。
这个模型带来的收益很直接:
- 分支几乎不需要复制完整对话;
- 模型切换、Thinking Level、标签和压缩也能记录成事件;
- JSONL 逐行追加,崩溃后已经写入的内容仍然存在;
- 扩展状态可以持久化,但不一定进入模型上下文;
- Session Format 可以通过版本迁移演进。
简单说,日志保存事实,Context 只是根据当前状态计算出来的视图。
Compaction 不是删除历史,而是增加 Checkpoint
Pi 默认会在上下文接近窗口上限时自动压缩:
contextTokens > contextWindow - reserveTokens
当前版本默认预留 16384 Tokens 作为输出空间,并尽量保留最近约 20000 Tokens。
它会从最新消息向前寻找截断点,但不会在 toolResult 上切断。这样可以避免模型看到一个没有对应 Tool Call 的孤立结果。
如果单个 Turn 自身就很长,Pi 会把这个 Turn 的前缀单独总结,再和历史摘要合并。重复压缩时,旧摘要作为 Previous Summary 增量更新,文件读取与修改记录也会继续累计。
压缩完成后,Pi 不会删除旧 Entry,而是追加一条新的 compaction Entry:
完整历史仍然保留
↓
追加 Compaction Checkpoint
↓
下次只把 Summary + 最近消息投影给模型
所以 Compaction 改变的是 LLM Context,不是 Session Log。
当然,这仍然是有损压缩。摘要可能遗漏隐含约束,也可能错误描述进度。Pi 没有假装解决“无限上下文”,而是让损失集中发生在一个明确、可替换、可审计的步骤,同时保留原始历史供用户回看。
我在分析 0.84.3 时还遇到一个版本细节:文档提到了带 retainedTail 的新版压缩格式,但同一安装包中的代码仍主要使用 firstKeptEntryId 重建上下文。这也提醒我,做 Session 兼容时必须锁定具体版本,并以实际读写代码为准,不能只看格式说明。
Extension 不是插件列表,而是一套中间件协议
Pi Extension 可以注册工具、命令和 UI,但真正让它强大的,是完整的生命周期 Hook:
input
before_agent_start
agent_start
turn_start
context
before_provider_headers
before_provider_request
after_provider_response
message_*
tool_execution_*
tool_call
tool_result
turn_end
agent_end
agent_settled
session_before_*
session_shutdown
这使权限确认、Prompt 改写、上下文过滤、Provider Tracing、自定义压缩、Git Checkpoint、Plan Mode 和 Sub-agent 都可以在不修改核心的情况下实现。
更重要的是,这些 Hook 不是简单的 EventEmitter,而是有明确组合语义:
input的 Transform 会按加载顺序传给下一层,Handled 会短路;before_agent_start对 System Prompt 的修改会逐层传递;context每一层看到上一层处理后的消息;tool_call按顺序执行,第一个 Block 会立即阻止工具;tool_result允许字段级 Patch 链式合并;message_end可以替换消息,但不能改变 Role。
普通 Extension Handler 出错时,Pi 会记录错误并尽量继续。但 tool_call Handler 抛错时会阻断工具,采用 Fail-safe 语义。这是合理的,因为执行一个未经策略检查的 Shell 或写文件操作,通常比少执行一次更危险。
扩展加载本身也是事务式的
Extension Factory 运行时,Flags、Provider 注册和 Runtime Changes 不会立刻全部生效。初始化成功后统一 Commit;如果 Factory 抛错,则 Discard,并清理加载期间建立的 Event Bus Subscription。
这避免了“插件加载到一半失败,但一部分全局状态已经留下”的问题。
Session 替换后,旧 Context 会主动失效
执行 Reload、Resume 或 Fork 后,旧的 pi 和 ctx 会被标记为 Stale。扩展如果继续使用旧 SessionManager、UI 或 Runtime Closure,会直接收到错误,而不是静默操作旧对象。
这个细节很值得抄。插件系统最大的隐患之一不是功能错误,而是生命周期结束后,异步任务还持有旧引用。Fail-fast 比让错误数据慢慢传播更可靠。
Prompt、Skill、Extension 和 Package 是不同成本的能力
Pi 没有把所有定制都放进 System Prompt,而是提供了一个能力阶梯:
| 层 | 进入上下文的方式 | 适合解决的问题 |
|---|---|---|
| Context File | 通常常驻 | 项目规范、约束和命令 |
| Prompt Template | 用户显式展开 | 可复用任务模板 |
| Skill | 描述常驻,正文按需读取 | 专业工作流、脚本和参考资料 |
| Extension | 进程内代码 | 工具、策略、UI 和 Provider |
| Package | 资源分发容器 | 组合并共享上述能力 |
Skills 最能体现 Progressive Disclosure。
Pi 启动时只把 Skill 的 Name、Description 和 Location 放进 System Prompt。模型判断任务相关后,再使用 read 加载完整 SKILL.md 和它引用的资料。
能力目录常驻上下文
↓
模型判断任务是否匹配
↓
只加载需要的 Skill 正文
这比把几十份工作流说明全部塞进 System Prompt 节省得多,也减少了不相关规则之间的冲突。
动态工具使用了类似思路。扩展可以先只暴露一个搜索工具,模型找到所需能力后,再激活具体工具。Agent 不需要每次请求都携带整个工具世界。
Provider 差异只能留在适配边界
不同模型厂商对流式文本、Thinking、Tool Call、缓存、Token Usage 和认证的格式都不一样。如果这些差异渗透到 Agent Loop,上层代码很快就会充满:
if (provider === "anthropic") { ... }
else if (provider === "openai") { ... }
Pi 的上层只面对统一事件:
start
text_start / text_delta / text_end
thinking_start / thinking_delta / thinking_end
toolcall_start / toolcall_delta / toolcall_end
done / error
ModelRuntime 还统一管理 API Key、OAuth、环境变量、模型目录、可用性快照和请求级认证刷新。长任务执行期间 OAuth Token 过期时,每次模型请求都可以重新解析认证,而不需要重启 Agent。
Provider 配置则按层组合:
Built-in Provider
↓
models.json
↓
Extension Provider
↓
Model Overrides
因此,替换 Endpoint、增加模型、覆盖兼容参数、添加 OAuth 或完全自定义 Stream,都可以沿用同一套组合逻辑。
这是多模型 Agent 必须守住的边界:Agent Loop 不应该知道供应商是谁。
Steer 和 Follow-up 解决了人类输入的并发语义
Agent 工作时,用户经常会继续发消息。但“新消息什么时候生效”并不是小问题。
Pi 提供两种明确语义:
steer:当前 Assistant Turn 的工具执行完成后,在下一次模型调用前注入;followUp:Agent 原本已经没有更多工作时,再作为后续任务注入。
Steer 不会粗暴打断正在执行的一批工具,因为那可能留下半完成的副作用。Follow-up 也不会在当前推理链中间突然改变目标。
队列还可以选择一次取一条,或者一次取出全部消息。
我认为这是比“运行中也允许继续 Append User Message”更成熟的设计。后者看起来简单,但实际行为完全依赖消息抵达时机,很难测试,也很难向用户解释。
一些不起眼但很重要的工程细节
工具输出有硬上限
Pi 的内置工具默认最多向模型返回 2000 行或 50KB,哪个先到就按哪个截断。
Read 和 Search 更适合保留头部,Bash 和日志更适合保留尾部。发生截断时,工具会明确告诉模型,并提供完整输出的位置。
Context Budget 不只是 Prompt 的问题。一次构建日志或压缩文件输出就可能直接撑满窗口,所以限制必须放在工具边界。
TUI 和 RPC 都考虑了背压
Print、JSON 和 RPC 模式会订阅同一套 Agent Event,并在标准输出发生背压时等待。TUI 则把普通渲染合并到约 16ms 的帧间隔,键盘输入走 Immediate Render,避免交互延迟。
这说明 Pi 的事件系统不是只为“看起来流畅”服务,它还承担了不同消费者之间的流量控制。
Session Replacement 使用 Teardown + Rebuild
New、Resume、Fork 或切换 CWD 时,Pi 不会在旧 Session 上改一堆字段,而是:
Abort 并等待旧任务落盘
↓
session_shutdown
↓
旧 Extension Context 失效
↓
为目标 CWD 重建 Services
↓
创建新 AgentSession
↓
重新绑定 UI 和 Extensions
这比在一个巨大 Runtime 对象上做原地切换更容易保证一致性。
Pi 为什么故意不内置很多功能
Pi 官方 README 很直接地写着:
- No MCP;
- No Sub-agents;
- No Permission Popups;
- No Plan Mode;
- No Built-in To-dos;
- No Background Bash。
它不是说这些能力没有价值,而是认为它们存在多种合理实现,不应该由核心强制决定。需要时可以安装 Package、写 Extension,或者使用 Tmux、容器和外部工具。
这是一种很克制的产品判断:核心负责提供机制,用户和生态负责选择策略。
但这也不是普遍真理。如果一个产品面向固定工作流,例如企业代码审查、云端任务执行或数据迁移平台,内置经过验证的审批和编排可能比让每个用户自己拼装更合适。
Pi 的极简是一种定位,不是所有 Agent 产品的标准答案。
安全边界不能照搬
Pi 明确说明自己没有内置 Sandbox。Shell、文件工具和 TypeScript Extension 都以当前用户权限运行。
Project Trust 只决定是否加载仓库里的设置、Skills、Prompt 和 Extensions。它不是运行时沙箱,也不会阻止模型之后通过工具读取其他文件或访问网络。
这种边界适合有主动监督的本地开发环境,但不适合直接照搬到:
- 云端 Agent;
- 多租户服务;
- 企业 IDE;
- 无人值守自动化;
- 面向非技术用户的产品。
这些场景还需要真正的安全能力:
容器或 VM 隔离
文件系统和网络 Allowlist
Secret Broker
Capability Policy
人工审批和审计记录
扩展签名或权限 Manifest
我反而欣赏 Pi 对这一点的诚实。它没有把 Permission Popup 包装成安全边界,而是明确建议把真正的隔离放在操作系统、容器或虚拟机层。
我认为最值得借鉴的 9 个设计原则
1. 先稳定内部协议,再增加产品功能
Message、Tool、Agent Event、Provider Stream、Session Entry 和 Extension Lifecycle 才是基础。协议稳定后,TUI、RPC、SDK 和插件都只是适配器。
2. 核心循环不要拥有业务工作流
Loop 只负责 Turn、模型流、工具和队列。计划、审批、子 Agent 和任务管理应该留在外层。
3. 日志是事实,Context 是投影
完整历史永久保存。当前分支、压缩和中间件共同决定这次真正发给模型什么。
4. 人类输入必须有明确的 Turn Boundary
Steer 和 Follow-up 比按到达时间直接 Append 更可预测。
5. 中间件要定义组合契约
每个 Hook 都要明确是否可修改、是否短路、异常时 Fail-open 还是 Fail-closed,以及是否参与 Abort。
6. Provider 差异只能存在于一个边界
Agent Loop 不应该出现模型厂商特判。
7. 能力应该按需暴露
Skill 正文和工具定义不必全部常驻上下文。
8. 生命周期结束后,旧引用必须主动失效
Reload、Resume 和 Fork 后继续使用旧 Runtime,应该立刻失败,而不是静默写错状态。
9. 不要伪造安全边界
如果 Shell 和插件拥有完整用户权限,就应该明确说明,并把真正隔离放到进程外。
如果让我实现一个类似的 Agent Harness
我不会一开始就做 Plan Mode、Memory、MCP 和多 Agent,而会按下面的顺序推进。
第一阶段:最小 Agent Core
只实现:
AgentMessage
AgentTool + Schema Validation
Provider Stream Protocol
agentLoop
AbortSignal
Agent / Turn / Message / Tool Events
同一个 Core 必须能被 CLI 和单元测试驱动,不依赖 UI 和文件系统。
第二阶段:事件化 Session
增加:
append-only entries
id + parentId
leaf pointer
active branch projection
model / config change entries
先把 Event Schema 定清楚,再决定使用 JSONL 还是数据库。
第三阶段:Context Projection
实现:
Custom Message Conversion
Context Transform
Compaction Checkpoint
Recent Tail Retention
Structured Work State
完整历史、活动分支和模型上下文必须是三个独立 API。
第四阶段:少量稳定中间件
第一批 Hook 有这些就够了:
input
before_agent_start
context
before_tool_call
after_tool_call
agent_settled
session_shutdown
每个 Hook 都要先写清顺序、短路、错误、Abort 和可变性,再开放给插件。
第五阶段:接入层和多 Provider
让 TUI、HTTP/RPC 和 SDK 只消费同一套 Event Stream。所有模型厂商都必须产出统一的 Partial Message、Final Message、Tool Call 和 Usage。
第六阶段:可靠性和安全
最后补上:
- Per-file Mutation Queue;
- Tool Output Truncation;
- Retry Classification;
- Overflow Compact-and-retry 上限;
- Stale Runtime Invalidation;
- Project Resource Trust;
- 按产品场景选择容器、VM 或远程沙箱。
这个顺序的重点,是先让运行时边界稳定,再增加看得见的功能。否则功能越多,Agent 内部状态越难解释。
最后
看完 Pi 的实现后,我最大的感受是:一个好的 Agent Framework,不应该努力把模型变成无所不能的黑盒,而应该让每一步都拥有清楚的协议和生命周期。
模型调用是不可靠的流,工具是有副作用的并发操作,会话是需要分支和恢复的历史,Context 是有预算的临时投影,Provider 是不断变化的协议适配,Extension 则是高能力也高风险的进程内代码。
Pi 没有掩盖这些复杂性,也没有把它们全部塞进一个 Agent 类。它把复杂度放到了各自应该承担的位置。
这就是我认为它设计得好的原因。
真正值得参考的不是 AgentSession 或 ExtensionRunner 这个类名,而是这条整体路线:
把事实记录成事件,把运行状态做成投影,把能力做成分层插件,把厂商差异留在边界,把工作流观点留给用户或上层产品。
核心足够小,边界足够清楚,系统才能长期扩展而不必不断推倒重来。