PostgreSQL码农集散地

开源 Pi, 挑战 Claude Code CLI

如果把大模型塞进终端,它会变成什么样?pi 给出的答案是:把它做成一座分层严谨、可以自我扩展的"操作系统"。

https://github.com/earendil-works/pi


一、从一个极简内核说起

打开 pi 的仓库,你会发现一件反直觉的事——这个号称"LLM 编码代理"的 CLI,内核其实轻得吓人。

它只做四件事:拼装命令行、装载扩展、调度 Agent 循环、渲染终端界面。所有真正复杂的能力——文件系统读写、Shell 执行、grep/find 搜索、LLM Provider 适配——都被设计成可插拔的扩展。

这不是偷懒,而是一道刻意的护城河。

pi 的作者在 CONTRIBUTING.md 的开头写得很直白:

pi's core is minimal. If your feature does not belong in the core, it should be an extension.

翻译过来就是:你的功能如果不属于核心,它就应该是个扩展。

特别喜欢这个风格, 就像 PostgreSQL 的理念, 核心安全稳定, 模块化扩展设计, 周边生态极为丰富. 

听起来像口号,但在 pi 里,这是一条被 npm run check 和 Git 钩子强制执行的铁律。

二、九层楼,每层都不抬头看上层

如果你只扫一眼 pi 的仓库布局,会觉得它有点"过度工程化":

telemetry → tui → ai → agent
 → session-backends/sqlite-node
                → protocol → client / server
                → coding-agent (顶层)

九个包,依赖链条一字排开,看着像某种强迫症作品。

但当你真正去看它每一层的入口文件,会发现一件惊人的事:下层对上层一无所知。

  • telemetry 只暴露 TelemetrySpan 这种 span 契约,根本不知道有 LLM、有 UI、有 agent。
  • ai 包封装了 40 多家 Provider,但它不认识 TUI,也不知道 AgentState 长什么样。
  • agent 包里的 reducer 跑得飞起,但它不知道你用的是 Claude 还是 GPT。

所有箭头都向下指。上层可以引用下层,下层永远不抬头看上层。

这种"分层严谨"的代价是前期设计成本很高,收益是:任何一层都可以独立替换、独立测试、独立发版。Session 后端想从 SQLite 换成 Postgres?没问题,重写一个 session-backends 包就够了,上层零改动。

三、AgentState:一个"不可变的现在"

pi 的 Agent 运行时有个非常优雅的设计:内部统一用 AgentMessage,只在真正发 HTTP 请求的时候才转成 Provider 特定的 Message[] 。

翻译一下:

上层 reducer、session、tools、UI 全都用同一组类型;只有当代码真正要"出门"——向 Anthropic、OpenAI、Google 发请求的那一刻——才做协议转换。

这意味着 reducer 处理消息时,根本不用关心消息最终会变成 OpenAI Completions 还是 Anthropic Messages。AgentState 是一个不可变的快照,每个 turn 结束后生成新版本。

type AgentState = {
    systemPrompt: string;
    model: Model<any>;
    thinkingLevel: ThinkingLevel;
    tools: AgentTool[];
    messages: AgentMessage[];
    isStreaming: boolean;
    streamingMessage?: AgentMessage;
    pendingToolCalls: Set<string>;
    errorMessage?: string;
};

这种"快照 + reducer"的模式,让 messages 历史可以直接序列化进 session,UI 可以做差异渲染,任意时刻的 Agent都可以被无副作用地读取。

想一下:这不就是 Redux / Elm 的思路吗?只不过 pi 把它用在了 LLM Agent 上。

四、AgentLoop:turn-by-turn 的小步快跑

AgentLoop 的逻辑非常清晰,像一段优雅的爵士乐:

  1. 发 agent_start / turn_start;
  2. 调 streamFn 跑一个 turn;
  3. 在流里持续产出 message_update(text delta、thinking delta、tool call delta);
  4. turn 结束时拿到完整 AssistantMessage;
  5. 如果有 tool calls → 走 beforeToolCall 钩子 → 执行工具 → 走 afterToolCall 钩子 → 追加 ToolResultMessage → 回到第 2 步;
  6. 没有 tool calls → 发 turn_end / agent_end,收工。

钩子机制是关键。它意味着 LLM 看到的工具行为是可观察、可重写的——你想在某个危险命令前加个人工确认?想给 file write 自动加日志?改两个钩子就完事了,工具本身一行都不用动。

再往上一层是 AgentHarness,把 Agent 装进一个支持多 lane 并发的容器。run / compaction / navigation 三种操作各有自己的 Outcome 联合,互不干扰。

五、AI 包的三大抽象

pi-ai 这个包的设计干净到可以当教科书用。三大核心抽象:

Provider:暴露 streamSimple(model, context, options) 这个高层函数,所有 Provider 都遵循同一签名。

Model:模型目录条目,包含 id、api 协议类型、contextWindow、cost 等元数据。

Context:发给模型的内容,{ systemPrompt, messages }。

更妙的是它把 api/* 和 providers/* 切开:

  • Provider 层只关心凭证和 OAuth 刷新;
  • API 层只关心 wire format。

同一种协议可以被多个 Provider 复用。比如 openai-completions 这个 API 既给 OpenAI 用,也给 OpenRouter、Together、xAI 这些用,你只需要写一次 wire format。

模型目录不是手写的——packages/ai/scripts/generate-models.ts 抓取各家公开目录自动产出 models.generated.ts。npm run check 在 drift 时会失败,所以你永远不能手编这个文件。这条规则很硬,但杜绝了一类典型 bug:模型目录和实际可调用的 Provider 不一致。

还有一点彩蛋:providers/faux.ts 是一个不发任何网络请求的伪 Provider。所有 coding-agent 的回归测试都基于它构建——所以测试套件不需要真实 API key,也不需要联网。这对一个商业项目来说是巨大的工程红利。

六、扩展 Host:把你的奇思妙想插进去

如果 pi 只做到上面这些,它充其量是个设计精良的内部玩具。

真正让它"飞起来"的是扩展系统。

扩展能做的事情远超你的想象:

能力
API
监听 agent 事件
pi.on('agent_start', …)
注册工具
pi.registerTool({ … })
注册 slash 命令
pi.registerCommand('name', …)
注册 CLI flag
pi.registerFlag('name', …)
注册键位
pi.registerKeybinding({ … })
注册消息渲染器
pi.registerMessageRenderer(…)
与 UI 交互
pi.getUIContext()
发起子任务
pi.sendMessage(…)

举几个真实场景:

  • 想把工具调用路由到一个 Linux micro-VM 里增强隔离?写个扩展,packages/coding-agent/examples/extensions/gondolin/ 给出了完整范例。
  • 想接入一家私有 LLM(比如 GitLab Duo)?写个 custom-provider-gitlab-duo/ 扩展就行。
  • 想在 VSCode 里跑 pi 但不想再起一个进程?通过 RPC 协议接入,rpc-extension-ui.ts 演示了怎么弹出 TUI overlay。

这套机制让 pi 不再是一个孤立的 CLI,而是一个可以长出新器官的平台。

七、TUI 与差分渲染:把终端当 GPU 用

pi 的 TUI 走的是差分渲染路线,而不是 blessed/ink 的"全量重绘"。

原理不复杂:

Component.render(width)  ──▶  string[]
                                          │
                                          ▼
TUI/TuiMainScreen  ──▶  缓存上一次结果 ──▶  diff 算法  ──▶  ANSI 输出

组件先渲染成字符串数组,TUI 比对前后两次结果,只把变化的格子写到终端。

对终端这种"重绘一次闪一下"的设备来说,这种细粒度更新能把闪烁降到最低,也让大量组件能高效响应 resize、focus、输入事件。

更值得说的是键盘模型。所有键位都必须注册到 DEFAULT_EDITOR_KEYBINDINGS 或 DEFAULT_APP_KEYBINDINGS——AGENTS.md 明确禁止硬编码键位判断。这意味着 Vim党、Emacs 党、自定义党都能找到自己的归属。

八、发布:锁步版本,没有 major

pi 的发布走的是 lockstep 版本控制:

  • patch = 修复 + 新增;
  • minor = 破坏性变更;
  • 没有 major。

所有九个包共用一个版本号。原因很朴素:上层依赖下层,下层变化上层一定要跟着改,版本错位只会制造混乱。

CI 通过 trusted publishing 自动发布 npm,Bun 单文件二进制作为 GitHub Release artifact一起分发。整个流程被 npm run check 和 Git 钩子强制执行——锁文件改动会被 pre-commit 拦截,除非显式设置 PI_ALLOW_LOCKFILE_CHANGE=1。

这种"约束即文档"的思路贯穿了整个仓库。

九、一句话总结

pi 是一座分层严谨的 LLM 操作系统:底层 telemetry 提供观测契约;tui 提供终端渲染;ai 提供 LLM 适配;agent 把消息、工具、状态机装进 AgentLoop + AgentHarness;protocol/client/server 把这套能力外置为远程服务;coding-agent 在最上层做 CLI / TUI / print / rpc 四种入口,并把所有非核心能力收敛到扩展里。

如果你想真正理解一个现代 AI Agent 工程应该长什么样,不要只读 README,去读它的分层架构和扩展机制。这两块决定了它的天花板。


延伸阅读

https://github.com/earendil-works/pi

  • 仓库:packages/agent/src/agent-loop.ts + agent.ts —— 看懂 Agent 怎么跑
  • 仓库:packages/ai/src/types.ts + providers/faux.ts —— 看懂 LLM 接口长什么样
  • 仓库:packages/coding-agent/examples/extensions/ —— 看懂扩展的极限
  • 文档:tui-plan.md —— 看懂 TUI 长期方向