跳到主内容
AI
文章阅读

Pi Agent 为什么设计得好:从源码拆解一个极简 Agent Harness

从 Pi Agent 0.84.3 源码出发,拆解其模型工具循环、append-only 会话树、上下文压缩、扩展中间件、Provider 适配与安全取舍,总结构建 Agent Harness 最值得借鉴的设计原则。

2026/08/27436 次阅读21 分钟

最近我一直在用 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 做得好的地方,是把这个容易失控的循环工程化成了几个稳定边界:

  1. 模型循环足够薄,只理解消息、工具、事件、队列和流;
  2. 会话是 append-only 事件树,不是一个随意修改的聊天数组;
  3. 完整历史、当前分支和模型上下文是三个不同对象;
  4. 权限、上下文、压缩、UI 和 Provider 都能通过中间件插入;
  5. Anthropic、OpenAI、Google 等厂商差异被压缩到统一流协议;
  6. Prompt、Skill、Extension 和 Package 按成本分层;
  7. 用户在 Agent 工作期间发来的消息有明确的并发语义;
  8. 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:

  1. 找到对应工具;
  2. 执行 prepareArguments(),兼容旧版参数;
  3. 通过 TypeBox Schema 校验;
  4. 顺序执行 beforeToolCall;
  5. 允许扩展修改参数或阻止执行。

只有通过检查的调用才进入执行阶段。

如果全局配置要求串行,或者这一批里有工具声明 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 这个类名,而是这条整体路线:

把事实记录成事件,把运行状态做成投影,把能力做成分层插件,把厂商差异留在边界,把工作流观点留给用户或上层产品。

核心足够小,边界足够清楚,系统才能长期扩展而不必不断推倒重来。

相关资料

返回顶部