开源 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 的逻辑非常清晰,像一段优雅的爵士乐:
发 agent_start/turn_start;调 streamFn跑一个 turn;在流里持续产出 message_update(text delta、thinking delta、tool call delta);turn 结束时拿到完整 AssistantMessage; 如果有 tool calls → 走 beforeToolCall钩子 → 执行工具 → 走afterToolCall钩子 → 追加 ToolResultMessage → 回到第 2 步;没有 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 只做到上面这些,它充其量是个设计精良的内部玩具。
真正让它"飞起来"的是扩展系统。
扩展能做的事情远超你的想象:
pi.on('agent_start', …) | |
pi.registerTool({ … }) | |
pi.registerCommand('name', …) | |
pi.registerFlag('name', …) | |
pi.registerKeybinding({ … }) | |
pi.registerMessageRenderer(…) | |
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 长期方向