文章
合集Pi Agent 源码阅读第 1 / 9 篇

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"]