ARTICLE · 1317698
读 Pi 源码(一):一个 Coding Agent 为什么需要 Harness?
一、为什么开始研究 Pi?
最近在构思一个可控的 Coding Agent 时,我一直在思考一个问题:一个面向日常编程任务的 Agent,其内核到底应该承担多少职责?
在很多常见框架的设计里,平台往往倾向于把任务规划(Plan Mode)、子智能体集群(Sub-agents)、复杂的反思重试以及权限拦截全都封装在底层框架里。这在演示 Demo 时效果很惊艳,但一旦接入实际业务,框架内部的黑盒逻辑往往让上下文剪裁、工具分发和中断控制变得极难定制。
直到我开始看 Pi(https://github.com/earendil-works/pi)的源码。它的定位非常克制:一个 minimal、
它不默认内置 Plan Mode,不内置复杂的 Sub-agent 编排,甚至明确说明不包含内核级的沙箱权限隔离。它只专注于做好一件事情:为大模型提供一个可预测、可干预、支持分支回溯的单任务执行基座。
这种“内核做减法,能力交由扩展层(Extensions/Skills)组装”的设计取舍,让我决定系统性地读一遍它的实现。
二、几十行的 Agent Loop 够不够?
如果只追求让模型调用本地工具,核心逻辑确实几十行就能写完:
1. 组装上下文(Prompt + 历史 Messages)发给模型;2. 接收流式响应,如果包含 tool_call,则在本地执行对应的函数;3. 将执行结果作为 tool_result 追加回 messages;4. 递归或循环上述过程,直到模型输出纯文本,停止运行。很多初学者(包括我最初)会认为:Coding Agent 的核心技术点不就是这个 while (hasToolCall) 吗?
但在真实编码场景中,这个简单循环会立刻面临三个典型挑战:
用户中途插话怎么办? 模型正在连续读写 4 个文件,第 2 个文件写完时,用户在终端发现提问有偏差,敲入了一行纠偏指令。此时是粗暴 SIGINT丢弃所有执行进度,还是强行把消息排到几个文件全改完之后的末尾?上下文如何回退与分叉? 如果模型尝试了两个方案都报错,用户想回到最开始的方案重新探讨,一个不断向后 的扁平push messages[]数组如何干净地表达这种历史分支?输出截断导致的破坏性执行:如果模型在输出工具调用的 JSON 参数时碰到了 max_tokens截断,流式解析器拼出了一个“半成品”参数,你的执行器敢直接拿去跑rm或写入文件吗?
解决这些循环外围的工程问题,正是 Harness 存在的理由。
三、深入源码:Pi 的 Agent Loop 到底在调度什么?
打开 pi/packages/agent/src/agent-loop.ts,核心执行入口是 runLoop 函数。精读这段实现会发现,它并不是一个单纯的 while,而是精巧的双层状态机:

在这套调度机制中,有两个非常值得借鉴的真实代码细节:
1. 区分“实时转向(Steering )”与“后续跟进(Follow-up)”
在 pi/packages/agent/src/agent-loop.ts,Pi 设计了两个队列读取点:
Steering Queue(转向队列):通过 config.getSteeringMessages?.()获取。当模型一轮输出结束,只要内层循环还有待调用的工具或收到了转向指令,它会立刻作为系统/用户消息插在下一次模型请求的最前面。这意味着用户可以实时扭转模型的动作,而无需重置对话。Follow-up Queue(排队队列):通过 config.getFollowUpMessages?.()获取。只有当模型认为任务已彻底完成、内层循环自然终止时,才会检查是否还有排队的后续指令。
2. 防御性处理 Token 截断的工具调用
在常规开发中,若大模型生成的参数过长触发了上下文上限(stopReason === "length"),流式 JSON 解析器往往会尽力挽救(Salvage)出一个合法的闭合 JSON。 如果直接执行这个残缺参数,可能会产生灾难性的文件覆写。
看 Pi 在 pi/packages/agent/src/agent-loop.ts 的处理方式:
const executedToolBatch = message.stopReason === "length" ? await failToolCallsFromTruncatedMessage(toolCalls, emit) : await executeToolCalls(currentContext, message, config, signal, emit);当检测到 stopReason === "length" 时,它直接拒绝执行该批次的所有工具调用,并在 failToolCallsFromTruncatedMessage 中构造显式的错误返回给模型:“参数因达到 Token 上限已被截断,请重新发起完整的工具调用”。这种对边界状态的克制与敬畏,就是 Harness 的价值所在。
四、状态与边界:SessionTree 能做什么,不能做什么?
除了调度循环,Pi 在 pi/packages/coding-agent/src/core/session-manager.ts 中实现了一套基于 JSONL 的树状持久化系统。
每个 Session Entry 都包含 id 与 parentId。通过指定不同的叶子节点(leafId),SessionManager 可以从树状结构中沿着父链回溯,重组出当前分支的活跃上下文(Active Branch)。
但这里有两个在技术上必须厘清的边界:
会话树(Session Tree)不等于代码版本控制(Git): Session Tree 记录的是大模型的对话与推理轨迹。切回旧节点,意味着下一次向大模型发送的历史记录回到了过去; 但工作区里已经被修改的文件并不会自动还原。如果你让模型删除了一个文件,即便在会话里回退到该步骤之前,磁盘上的文件依然是删除状态。文件级别的撤销必须依赖 Git 或外部工作区快照。 Schema 校验不等于权限沙箱: Pi 引入了纯闭包编译的 pi/packages/schema,在工具执行前做入参合规校验,防止格式畸变的输入击穿代码。 但官方 README 明确指出:Pi 默认以宿主启动用户的权限运行,内部不包含针对文件系统、系统进程或网络的权限拦截。如果需要严格的安全防护,官方推荐的做法是在外部通过 Docker、Linux Micro-VM(Gondolin)进行容器化隔离,而不是在 Agent 应用层伪造沙箱。
五、执行事件的落盘时机
在阅读持久化逻辑时,我原本以为它是在一次 Turn 结束或 Agent 退出时批量将事件写入磁盘。
但查看 pi/packages/coding-agent/src/core/session-manager.ts 的 _appendEntry 实现:
private _appendEntry(entry: SessionEntry): void {const previousLeafId = this.leafId;this.fileEntries.push(entry);this.byId.set(entry.id, entry);this.leafId = entry.id;try {this._persist(entry); // 内部直接 appendFileSync 写入 JSONL } catch (error) {// 磁盘写入失败时回滚内存状态,保证内存状态与文件严格一致this.fileEntries.pop();this.byId.delete(entry.id);this.leafId = previousLeafId;throw error; }}每个事件(无论是用户消息、模型思考增量、工具执行结果还是 Token 消耗)都是即时同步追加(Append-only)写入磁盘的。如果写入异常,立即回滚内存指针。这种设计保证了即使终端被意外强制关闭,已执行的步骤也绝对不会丢失。
六、执行时序:一条工程指令的生命周期追踪
以下是用户发起一个代码重构任务时,Harness 内部真实的调用时序:

在这个过程中,Harness 提供了三大确定性保障:
输入解析隔离:Tool 入参在落地前由 pi-schema 完成类型断言,若大模型参数缺失或格式异常,在内核层即刻构造错误上下文并重新触发推理,绝不让脏数据击穿到底层执行器。 状态非破坏性回滚:如果开发者中途发现方向偏差,随时可以通过 Session Tree 回退到第 3 步之前的分支节点重新发起会话,旧的探索分支完好保留在 JSONL 文件中。 因果队列调度:如果第 7 步执行期间开发者追发了补充说明,该输入将被放入 Steering Queue,并在当前 Assistant 轮次结束后立刻作为最高优先级注入下一轮请求,无需重启整个 Agent。
七、Pi 的取舍总结
通过第一篇的源码通读,可以清晰看到 Pi 的核心取舍:
| 任务规划 | ||
| 执行权限 | ||
| 会话历史 | ||
| 输入干预 |
八、小结与下一篇
Coding Agent 的开发,难点往往不在于如何接入大模型,而在于如何处理非理想情况下的状态流转:输入截断、用户插话、分支回溯以及长上下文的压缩。
Pi 给出的解法是:用极小但坚固的 Harness 管控好单步生命周期,把上层策略空间彻底留给开发者。
在下一篇文章中,我们将剖析整个基础设施的最底层:pi/packages/ai 。看它是如何用一套精简的 TypeScript 抽象,同时抹平 OpenAI、Anthropic、Gemini 等各大厂商流式 Chunk、Token 计数与 Tool Call 差异的。














































































