读码笔记 · 2026 年 7 月
能住精装房,
为什么要住毛坯房?
我带着满脑子质疑,逐节精读了 Pi 这个极简 agent runtime——一个只有四个内置工具、却拿到 7.7 万 star 的东西。 读完发现,最大的收获不在源码里,在我那些质疑被逐个拆解的过程里。
先交代对象。Pi 是 Mario Zechner(badlogic,libGDX 的作者)2025 年 8 月放出来的终端编码 agent,
主打激进的极简主义:只带四个内置工具,其余全靠 TypeScript 扩展。后来被 Armin Ronacher(Flask 作者)联合创办的 Earendil 收编,MIT 协议,现在 77.7k star。
跨平台消息网关 OpenClaw 就是直接 embed 了它的 createAgentSession(),而不是去 shell 调一个 CLI 子进程。
我一开始的态度非常不友好。我的原话是:
我这么看下来,感觉这纯纯就是一个玩具啊。上生产环境还是得靠成熟产品,这玩意儿为什么能火?
然后我花了一整天,一节一节地读,一个一个地问。这篇就是那些质疑和它们的下场。
01 / 祛魅最小内核只有三样东西
我读的那篇源码分析是基于早期版本写的,说 Pi 的运行时是「5 个文件」:类型定义、agent loop、agent 类、proxy、入口。 读到一半我就卡住了,问了个我自己觉得挺笨的问题:
我怎么感觉 proxy 和 index 好像不是必备的东西啊?要完成一个最小化的 Agent,有 agent loop、state 管理和各种 schema 定义,好像就够了吧。
这个直觉是对的。proxy 是环境适配(浏览器里没法直接调 API 得有个中转),index 是模块边界(导出用的)——两个都是工程需要,不是运行必需。 真正不可再减的内核只有三样:
- Schema——数据契约,消息长什么样、工具怎么描述;
- State——当前状态,历史消息、正在跑什么;
- Loop——「模型 → 工具 → 结果 → 再给模型」直到没有工具可调。
剥到底,它就是十行 while:
while (true) {
const res = await llm.call(state.messages, tools)
state.messages.push(res)
if (!res.toolCalls?.length) break // 没工具可调 = 这一轮干完了
for (const call of res.toolCalls) {
const out = await tools[call.name].run(call.args)
state.messages.push(toolResult(call.id, out))
}
}
「Agent 很玄」这件事到这里就结束了。剩下所有的复杂度——中断、压缩、子任务、权限、预算——都是在这十行外面长出来的工程,不是魔法。
那篇文章说的「5 文件运行时」在我读的时候已经过时了——当前源码早不是那个结构。 我是对照真实仓库读的,所以一路上撞见好几处「文章这么说,源码不是这样」。 源码分析文章的保鲜期比想象中短得多,尤其是这种迭代飞快的项目。看的时候最好把仓库开在旁边。
02 / 消息的两副面孔应用存什么,和模型看什么
第一个让我坐直的设计在消息层。Pi 内部保存的不是标准的 LLM 消息,而是一层更宽的 AgentMessage[],
里面塞得下 bash 执行记录、通知、纯 UI 用的东西——这些玩意儿模型压根不该看见。
只有到了真正调模型的那一刻,才发生两步转换:
AgentMessage[] // 应用的完整记忆(含 UI 专属消息)
│ transformContext() // 裁剪 / 压缩 / 注入
▼
AgentMessage[] // 这一轮决定让模型看的
│ convertToLlm() // 过滤 UI 消息、转 provider 格式
▼
Message[] // 真正发出去的
这个结构眼熟吗?它就是编译器的 IR。
AgentMessage 是通用中间表示,在调用边界上才「编译」成某个 provider 的目标格式。
所以换模型不用重写会话历史——你只是换了个后端目标。
更实际的收益是:「应用存什么」和「模型这一轮看什么」被彻底解耦了。
上下文工程(裁剪、压缩、注入)从此有了一个明确的施工位置,就在 transformContext 这一层,不用去污染真正的历史记录。
顺带一提,原文在这儿写了句「彻底解决了 agent 状态的黑盒问题」,这属于营销话术。
流式状态之类的东西照样躺在 AgentState 里,并不进消息流。解耦是真的,「彻底」是假的。
03 / 打断Steering 是协作式中断,不是拔电源
Pi 有两条队列:steeringQueue(我要纠偏)和 followUpQueue(做完这个再干那个)。
实现朴素得让人安心——就是两个数组。
关键在注入时机。Steering 不会硬杀正在跑的工具,它等到当前这轮的工具批次执行完,在一个安全点注入。原因很实在:
- 文件写到一半被打断 = 半个文件;
- 发出去的
tool_call没有对应的tool_result= 下一轮请求直接违反协议; - 事务开了没提交 = 你懂的。
所以正确的口径不是「立刻打断」,而是在一致性边界上尽快纠偏。 (原文写的是「立即打断」,那是旧版行为,现在的源码已经改了。)
任何带副作用的执行单元,中断点必须落在一致性边界上。 「响应快」和「状态一致」冲突时,选一致——用户等半秒可以接受,收拾一个写坏的文件要十分钟。
04 / 最精彩的交锋都是 string,你怎么知道我想干嘛?
读到这儿我提了整场里我自己最得意的一个质疑:
不管是用户在纠正当前方向,还是在追加后续任务,它都是一串 string,根本就没有形式上的区别啊。harness 只是代码,它做不到区分这种语义信息啊。
这个逻辑是成立的。「先别写测试了,改用 pytest」和「写完之后顺便加个 README」——纯文本上,凭什么分得清哪个要插队?
答案是:Pi 根本不猜。
Enter 调 steer(),Alt+Enter 调 followUp()。语义不是从文本里推出来的,是用户按键的时候就已经声明了的。
一条消息被拆成两个正交的维度:内容是数据面,调度意图是控制面。
就像 HTTP 里同一个 URL 配不同的 method——GET /users/1 和 DELETE /users/1 的「内容」完全一样,
区别在那个没写进 body 的动词里。
我当时的收获不是「原来是快捷键啊」,而是这套优先级:
| 优先级 | 信号来源 | 例子 |
|---|---|---|
| 1 | 显式控制信号 | Enter / Alt+Enter、命令前缀、按钮 |
| 2 | 交互上下文 | 光标在哪、选中了什么、当前处于什么模式 |
| 3 | 确定性规则 | 正则、schema 校验、白名单 |
| 4 | 让 LLM 猜 | 兜底,且结果必须可撤销 |
不要让系统去解决一个可以在 UI 上消掉的问题。
我之前一直下意识觉得「agent 时代嘛,意图识别交给模型」。这一节把我掰过来了: 生产系统里,最终调度权不能交给一个概率模型。模型可以建议,但按哪个键是人说了算。
05 / 层数不是玄学为什么是两层循环,不是三层四层
另一个我追着问的问题:
为什么是双层状态机、双层 for 循环,而不是三层、四层?这个层数是怎么判定的?
答案干净得出乎意料:层数 = 你有几种不同的「完成」语义。
- 内层完成:这一轮任务没有工具要调了,可以交付了;
- 外层完成:连排队的 follow-up 都没有了,可以真正退出了。
两种完成,两层循环。想加第三层?先回答:它有没有独立的退出条件、独立的调度策略、独立的失败边界? 三个都答不上来,那你加的不是一层循环,是一个 if。
再往上,如果某个东西真的需要独立的生命周期和成败状态,那它就不该是循环层了——它该升格成一个独立的 Task 或 Sub-Agent。
说到底,这两层循环是把人类对话里的两种语义翻译成了两个优先级不同的队列:「等一下,改个方向」和「做完这个再做那个」。 架构没有凭空发明什么,它只是把已经存在的区别显式化了。
06 / 平淡即好Agent 类是一个 Redux
读到状态管理那节的时候,我的评价是:「感觉这不就是刚刚讨论过的东西,没啥特别的吗?」
后来我意识到,这恰恰是好架构的特征。它的分工是这样的:
agent-loop是无状态执行引擎,只管跑,只往外发事件;agent类持有状态,消费事件更新自己(Event → Reducer → State,标准 Redux 味儿), 对外暴露五个控制 API:prompt/continue/steer/followUp/abort。
Loop 是机制,Agent 类是封装。前者负责跑,后者负责让别人能控制它。
还有个我一开始完全没印象、回头去翻才找到的东西:三层事件生命周期(agent_start → turn → message,外加工具执行)。
它不是三层状态机——就是分层埋点 + 一条事件总线,给 UI、日志和会话回放用的。
我第一遍读的时候完全没注意到,因为它太不碍事了。好的埋点设计就是这样,你感觉不到它存在。
07 / 五个「不做」复杂度不会消失,只会换人背锅
这一节是全文最硬的。我当时一口气砸过去一串质疑:
不做 MCP 支持会不会太激进了?不做 sub agent 而是自我循环调用,会不会有递归风险?max step 也不限制,它会被无限循环。我怎么看下来感觉问题很多啊。
逐条来。
不做 Plan Mode
用一个 PLAN.md 文件代替。诚实地说,这是软约束——它挡不住模型真的伸手去改代码。
想要硬约束,得靠扩展在工具层直接禁掉写文件的能力。承认这一点,比假装 markdown 能约束模型要好。
不内置 MCP
走 CLI + --help 的渐进式披露:模型先看到一个命令名,需要细节时自己去问 help。
对比之下,MCP 那套是把几十个工具 schema 常驻在上下文里。
这是一笔明账:省下来的 context 是真的,失去的生态兼容也是真的。
不做 Sub-Agent
用 bash 递归调自己的子进程。天然隔离(进程级),但代价明确:没有并发调度、没有成本统计、取消信号传不下去。 递归风险确实存在,靠的是外层的预算和超时兜住,不是靠框架。
不设 maxSteps
这条最初最让我不安,但想通了:步数本来就是个粗糙指标。 一个步骤可能是读一行文件,也可能是跑一整套测试。用步数当熔断,等于用「翻了几页」来限制读书时间。 真正该兜底的是 token、费用、墙钟时间和重复行为检测——这四个都比步数更贴近你真正害怕的东西。
不做权限弹窗
交给容器和操作系统。这一条背后有个值得单独想的安全模型问题: 「我信任这个项目」不等于「这个项目里的所有文本都可信」——你 clone 下来的仓库里,一个 README 就能藏 prompt injection。 弹窗防不住这个,隔离能。
Pi 不是把复杂度消灭了,而是把复杂度从框架下放给了文件系统、CLI、进程和操作系统。
这句话是我这天最大的收获。以后再看到任何号称「极简」的框架,我的第一个问题都会是: 你删掉的那部分复杂度,现在归谁管了?
08 / 回到毛坯房它卖的不是功能,是控制权
绕了一整圈,回到我最初那个问题:能住精装房,为什么要住毛坯房?
我现在的答案是这个类比:
| Pi | 成熟编码 agent 产品 | |
|---|---|---|
| 定位 | Chromium 内核 | Chrome 成品 |
| 你得到 | 控制权 | 开箱即用 |
| 你付出 | 沙箱、预算、生态得自己补 | 接受它的设计取舍 |
| 该选它当 | 要解剖学习 / 嵌进自己产品 / 接私有模型 | 你只是想把活干完 |
所以「毛坯房」这个词其实用错了。毛坯房是没装修完的成品房,而 Pi 是发动机——你不该拿它跟整车比, 除非你正打算自己造一辆。
我最后跟自己确认了一件事:给 Pi 补齐沙箱、MCP 兼容和预算限制之后,恭喜你, 你亲手把毛坯房装修成了一个成熟产品。这不是笑话,这是判断标准—— 如果你补完之后得到的东西和现成产品长得一模一样,那你就不该用 Pi; 如果补完之后它长得不一样(因为你嵌进了自己的业务、接了自己的模型、改了调度策略),那 Pi 就是对的选择。
它火起来的链条也很清楚:极简 + 可嵌入 → OpenClaw 这类产品拿它当底座 → 有分量的人背书 → 被公司收编。 本质上,是「强模型 + 读写执行三件套 + 一个 loop」已经能覆盖编码 agent 八成的核心能力了。剩下的两成才是产品,而产品不是所有人都需要的那一份。
09 / 坐标系把这一切钉在一张图上
读完之后我自己画了个阶梯,用来给这类东西定位:
Prompt → Context → Tool → Workflow / Loop → Harness
│
Eval & Runtime ————— 纵向贯穿每一层 ———————┘
演化的本质是:模型变强之后,瓶颈从「怎么问」外移到了「系统怎么运转」。
这里最容易混的一对是 Workflow 和 Loop: Workflow 是你提前把路线画好,让模型填空;Loop 是模型在运行时自己定路线。 前者可控可测,后者上限更高——选哪个取决于你能不能承受它走错路。
而 Eval 和 Runtime 不是最后一个阶段,是纵向贯穿每一层的保障。 Pi 的每一节都能钉在这张图上:消息双层是 Context,双队列是 Loop,那五个「不做」全是 Harness 层的取舍。
最后给自己留了个作业,也推荐给同样在读这类源码的人:别停在「我读过源码」。 给它写个最小扩展——比如一个 Context Inspector,把每一轮实际发出去的 token 数和压缩触发时机打出来。
写完你会发现,你对「上下文工程」的理解从「知道有这么回事」变成了「我看见它在动」。 这个差别,在任何一场技术讨论里都藏不住。