Pi Agent 架构解剖
结构性描述来自前身仓库 badlogic/pi-mono 的代码索引(commit dd6bea41),包名和模块边界与当前的 earendil-works/pi 一致。CLI 版本参照 npm 上的 @earendil-works/pi-coding-agent 0.84.2。行号和函数签名请对着源码校。
为什么是 Pi
跟它在市场上占什么位置无关。是因为它把 less is more 执行到了近乎固执的程度。
在所有项目都在往上堆功能的时代,克制是稀缺的。Pi 的自我描述只有一句 minimal terminal coding harness,不内置 sub agents,不内置 plan mode。不是做不了,是认为这些应该由使用者自己长出来——你要么让 pi 给你造一个,要么装一个第三方 pi package。口号也是同一个意思:让 pi 适配你的工作流,而不是反过来,并且不需要 fork 和修改内部。
但佩服的不是少,是少得有章法。pi-ai / pi-agent-core / pi-coding-agent / pi-tui 这套分层和 SDK 设计干净到可以当教材,价值早已外溢到 Pi 自身之外。extension 的分层与隔离同样一流,边界划得极清楚。
我试过一圈 harness,最后想自己设计一个的时候,发现绕来绕去还是回到 Pi。不是因为别家不好,各家取舍不同,都值得尊重。是因为 Pi 给出的那些答案,你即使不采纳也必须先理解。
包结构
npm workspaces 管的 monorepo,包之间严格分层,无循环依赖。
graph TD
subgraph APP["应用层"]
CA["pi-coding-agent<br/>CLI · 四种模式 · 扩展系统"]
end
subgraph CORE["核心层"]
AC["pi-agent-core<br/>Agent · AgentLoop · AgentHarness"]
end
subgraph FOUND["基础层"]
AI["pi-ai<br/>15+ provider 统一流式 API"]
TUI["pi-tui<br/>终端差分渲染"]
TEL["pi-telemetry<br/>厂商中立遥测契约"]
end
CA --> AC
CA --> TUI
AC --> AI
CA -.-> TEL
pi-agent-core 没有 CLI 入口,是纯库。它被设计成能脱离编码场景复用的通用 agent 运行时,pi-chat(独立仓库,做 Slack 自动化)就是另一个消费者。core + extension 的分界,在包边界上就已经画好了。
pi-ai 抹平认证、消息格式、成本计算,模型目录是构建期从各家 API 拉取后代码生成的。它支持跨 provider 切换会话——一个跑了半天、带着 thinking block 和 tool call 的历史,要能原地换家继续。这是 provider 抽象层里最脏的部分,因为各家对"助手消息里能有什么"的约束都不一样。
Agent Loop
整个系统里最小的一块。这是我读完之后印象最深的一点:Pi 的循环是它最简单的组件,不是最复杂的。
flowchart TD
START([prompt]) --> TC["transformContext<br/>裁剪 / 修改历史"]
TC --> CV["convertToLlm<br/>过滤纯 UI 消息<br/>归一为 user/assistant/toolResult"]
CV --> ST["StreamFn(默认 streamSimple)"]
ST --> CHK{"请求工具?"}
CHK -->|否| END([agent_end])
CHK -->|是| TOOLS["工具执行流水线"]
TOOLS --> APPEND["toolResult 按原始顺序写回"]
APPEND --> CV
四步:变换上下文,翻译成 LLM 消息,流式取回,有工具就执行然后回到第二步。
convertToLlm 这一步藏着一个类型双轨制。系统内部用 AgentMessage,里面可以有纯 UI 用途的消息;发给 LLM 之前统一过滤归一。这个分离让 UI 层可以往对话流里塞任何东西,而不污染模型看到的上下文。
agentLoopContinue 接受一个以 user 或 toolResult 结尾的上下文继续跑,最后一条是 assistant 就抛错。这不是洁癖,是瞬时错误重试的基础——provider 抽风时要能从合法断点续上,而不是重跑整轮。
Agent 是循环的有状态包装,维护 steeringQueue 和 followUpQueue 两条队列。模型跑了两分钟,你中途想补一句"顺便把测试也跑了",steering 插进当前轮次,follow-up 排到下一轮。区分这两者是产品判断,不是工程需要。
工具执行
默认并行,编排有点反直觉。
sequenceDiagram
participant L as AgentLoop
participant H as beforeToolCall
participant T as 工具
participant A as afterToolCall
L->>H: 逐个校验参数 + 串行执行前置钩子
H-->>L: 可返回 {block:true} 拦截
L->>T: Promise.all 并发启动未被拦截的
T-->>L: tool_execution_end 按完成顺序发事件
T->>A: 后置钩子(可改结果 / terminate)
A->>L: toolResult 按原始声明顺序落盘
这里有三种不同的顺序:校验和前置钩子是串行按声明顺序,执行是并发,事件按完成顺序,而最终写进消息历史的 toolResult 按原始声明顺序。
最后一条必须如此。模型看到的历史得是确定性的,否则同一次运行重放会得到不同上下文,缓存全废,调试无从谈起。但 UI 想要的是谁先跑完谁先显示。Pi 把这两个需求拆成了两条路径,事件流给 UI,消息历史给模型。这是我在这份代码里最喜欢的一处。
AgentHarness
循环最小,Harness 最重。它管会话持久化、运行时配置、资源解析、操作锁,以及扩展可见的修改语义。
它要解决的核心问题很具体:用户在模型跑到一半时改了配置,比如切模型或者开关工具,该怎么办。
graph TB
HC["Harness Config<br/>最新运行时配置<br/>setter 立即生效,作用于未来轮次"]
TS["Turn Snapshot<br/>createTurnState() 拍下的时点副本<br/>本轮只看它"]
SS["Session<br/>持久化消息树<br/>只含已提交条目"]
PW["Pending Writes<br/>运行中的写入排队"]
HC -->|每轮开始拍快照| TS
TS -->|轮次结束| PW
PW -->|Save Point| SS
答案是快照隔离:改的是 Harness Config,当前轮次用的是开始时拍下的 Turn Snapshot,本轮不受影响,下一轮生效。运行中产生的写入进 Pending Writes 排队,不直接动持久化的 Session 树。这是并发控制的经典做法,但在 agent 语境里被明确建模出来的不多。
结构性操作之间用显式 phase 互斥:
stateDiagram-v2
[*] --> idle
idle --> turn: executeTurn
turn --> idle: Save Point
idle --> compaction: 上下文超阈值
compaction --> idle
idle --> branch_summary: navigateTree
branch_summary --> idle
turn --> retry: provider 瞬时故障
retry --> turn
错误归一成 AgentHarnessError,带 busy / hook / session / compaction / branch_summary 几个码,原始错误留在 cause 上。
Save Point 发生在一个助手轮次及其全部工具结果都完成之后:刷 Pending Writes 到 SessionStorage,更新活动叶子 setLeafId(),回到 idle。
会话不是线性列表,是树,以 JSONL 追加存储。
graph TD
R["root: 初始提问"] --> A1["assistant + tools"]
A1 --> U2["追问 A"]
A1 --> U3["追问 B(同点分叉)"]
U2 --> A2["..."]
U3 --> A3["← 当前活动叶子"]
分叉带来的问题是切换分支时上下文怎么办。collectEntriesForBranchSummary 找当前叶子和目标之间的共同祖先,generateBranchSummary 用 LLM 生成一份结构化摘要(目标 / 进展 / 决策)保留下来。
上下文超过 CompactionSettings 的阈值时触发压缩:findCutPoint 定位切点,摘要旧历史,生成一条 CompactionEntry,keepRecentTokens 保证近期上下文仍是原始消息形态。近的留原文、远的压摘要是标准答案,Pi 的做法特别在于把它做成会话树里的正式条目类型,而不是运行时的临时处理。这意味着压缩可持久化、可回溯。
扩展点
这是理解 Pi 的关键。所有钩子放在一张图上:
graph TD
P["用户 prompt"] --> H1["before_agent_start<br/>注入消息 / 改配置"]
H1 --> TS["Turn Snapshot"]
TS --> H2["before_provider_request<br/>改 headers / metadata"]
H2 --> H3["before_provider_payload<br/>拦截发给 provider 的原始 JSON"]
H3 --> LLM["LLM"]
LLM --> H4["beforeToolCall<br/>可 block 拦截"]
H4 --> EXEC["执行"]
EXEC --> H5["afterToolCall<br/>改结果 / terminate"]
H5 --> SP["Save Point"]
SP --> TS
Harness 明确区分 Subscriber(只观察)和 Hook(可修改)。
before_provider_payload 值得单说,它让你在最后一刻动即将发出的原始 JSON。任何 Pi 没原生支持的 provider 特性都能从这里塞进去。有了它,"不 fork 就能扩展"才立得住。
扩展面不止代码钩子,一共四类。Extensions 是 TypeScript 模块,注册工具和命令、监听生命周期事件;Skills 是递归发现的 SKILL.md,注入系统提示词后按需调用;Prompt Templates 是带 YAML frontmatter 的 Markdown,做 slash 命令和参数占位;Themes 管配色。四类可以打包成 Pi Package,通过 npm 或 git 分发。名字冲突时项目本地的 .pi/ 覆盖全局的 ~/.pi/agent/。
安全上 Pi 的态度很直白:pi package 以完整系统权限运行,extension 执行任意代码,skill 能指示模型运行任何可执行文件,而且不含内置权限系统,默认继承启动进程的权限。官方给的边界方案是容器化——Gondolin 扩展把工具和 ! 命令路由进本地 Linux 微 VM 而 pi 本体和凭证留在宿主,或者纯 Docker,或者 OpenShell 策略沙箱。
AgentSession 在 coding-agent 包里编排用户、agent 框架、会话持久化三者,管消息队列并集成扩展系统。四种运行模式(interactive / print 或 JSON / RPC / SDK)共享它。RPC 模式让 Pi 能作为子进程组件嵌进别的系统,pi-chat 就是这么来的。
设计取舍
读完之后,我认为 Pi 的取舍集中在三处。
第一,热路径可读优先于热路径可配。循环没有被做成插件,它就在 agent-loop.ts 里,能一口气读完。代价是换不掉,收益是出问题时知道去哪看。DeepSeek 的 dsh 走的是相反方向,用 Cordis 内核把 loop 也做成插件,配置里就能换。两者都反对单体,但不是同一种哲学。
第二,省在功能,不省在状态管理。不做 sub agent、plan mode、权限系统,但会话树、快照隔离、compaction、branch summary 一个不少。作者显然认为前者能由用户自己长出来,后者不行。这个划分我认同。
第三,用扩展点密度换零 fork。五个钩子位置加四类扩展物加包分发,目标是永远不需要改内部。这是个高要求的承诺,一旦有人为了某个需求不得不 fork,承诺就输了一半。
我有保留的地方也在这:before_provider_payload 这种最后一刻改原始 JSON 的逃生口,既是灵活性来源,也是接口稳定性的敌人。生态里一旦有大量扩展依赖 payload 的具体形状,Pi 内部就很难再重构消息构造逻辑。所有留后门的框架都会遇到这个问题。
另外把权限完全外推给容器化,个人开发者场景没问题,团队和 CI 场景会变成实打实的部署负担。
如果你要读源码
别从 packages/coding-agent 开始,那是最大最杂的包。
先看 packages/agent/src/types.ts,AgentMessage、AgentContext、钩子签名都在这,读完就有了全局词汇表。然后 agent-loop.ts,信息密度最高、篇幅最短的关键文件。接着 agent.ts,看有状态包装怎么处理事件订阅和两条队列。
到 Harness 之前先读 packages/agent/docs/agent-harness.md,状态模型不看文档直接啃代码会很痛苦,读完再看 harness/agent-harness.ts 和 harness/session/。
最后才是 packages/coding-agent/src/core/extensions/,看前面这些东西怎么被暴露出去。
pi-tui 可以跳过,除非你对终端差分渲染本身感兴趣。
还有个偷懒办法:用 pi 读 pi。README 里就建议直接问 agent 让它解释自己,仓库带了 AGENTS.md。
总图
graph TB
U["用户 / 调用方"] --> MODES["interactive · print · RPC · SDK"]
MODES --> AS["AgentSession<br/>编排 + 消息队列 + 扩展集成"]
AS --> AH["AgentHarness<br/>持久化 · 配置快照 · phase 锁<br/>compaction · branch summary"]
AH --> AG["Agent<br/>状态 · 事件 · steering/followUp"]
AG --> AL["AgentLoop"]
AL --> AIP["pi-ai"]
AIP --> LLM["15+ Providers"]
AL --> TOOLS["read · write · edit · bash …"]
AH --> STORE["Session Storage<br/>JSONL 消息树"]
AS --> EXT["Extensions · Skills<br/>Prompt Templates · Themes"]
MODES --> TUIP["pi-tui"]