这段时间我一直想把 Coding Agent 拆到足够小:模型到底在哪一层决定调用工具,工具结果怎么回到下一轮对话,所谓的“权限控制”又究竟拦在什么地方。Pi 很适合拿来做这件事。它不是把所有能力藏在一个黑盒命令里,而是把 Agent Loop、工具、扩展、会话都摆在仓库里。我最后没有只停在读源码,还给它写了一个路径保护扩展和一个审计扩展。真正让我看明白 Pi 的,不是第一遍读代码,而是审计第一次少了一条记录。
Coding Agent 其实挺简单的
刚开始看 Pi,我以为重点会是 prompt、模型选择,或者某个很复杂的规划器。沿着 agent-loop.ts 往下读,才发现它的骨架其实很朴素:模型先返回一条 assistant message;如果里面有 tool call,就找对应工具、处理参数、校验参数、执行工具;工具结果作为一条 toolResult message 放回上下文,再交给模型继续回答。
大致就是这样:
LLM 返回 tool call
-> 找到工具定义
-> 参数处理与校验
-> 策略检查
-> 工具执行
-> tool result 写回消息上下文
-> 下一轮 LLM
这条链路里我最在意的是最后一步。工具不是在终端里跑完就算结束,它必须变成模型能理解的消息。否则模型根本不知道文件有没有写进去、命令有没有报错,也没法决定下一步该补什么。Pi 把这个结果统一写成 toolResult,所以读文件、写文件、执行命令这些看起来完全不同的动作,在下一轮模型看来都是同一种“观察结果”。
这也是我后来做 mini agent 时最想保留下来的部分:先把一轮循环走通,再谈规划、多 Agent 或者花哨的 UI。没有可靠的结果回填,Agent 就只是会调用工具的聊天框。
工具定义和运行时工具,中间隔着一层
Pi 的 Coding Agent 里,内置工具和扩展工具都不是直接塞进循环。tool-definition-wrapper.ts 会把完整的 ToolDefinition 包成 agent runtime 真正使用的 AgentTool。
前者包含给模型看的描述、参数 schema、执行函数和终端渲染方式;后者只保留循环执行所需的名称、参数和 execute。扩展上下文会在真正执行时注入进去。
一开始我觉得这层包装有点绕,后来反而觉得它很必要。模型不需要知道终端怎么高亮 diff,循环也不应该知道扩展 API 的全部细节。把“模型看见什么”“循环怎么调度”“用户界面怎么展示”拆开后,工具才能既被 CLI 用,也被 SDK 或别的界面复用。
Pi 的扩展注册也是顺着这个边界走的。扩展调用 pi.registerTool 后,工具表会刷新;如果名字和内置工具相同,扩展工具会覆盖内置工具。这个设计很灵活,但也意味着工具名不是随手起的字符串,而是一段会改变运行时行为的接口。
我没有只读权限代码,而是让它真的拦一次
只看 tool_call 事件时,“这里可以做策略检查”很容易理解。我还是想知道它在真实一次模型调用里到底长什么样,于是写了一个很小的 protected-paths 扩展:
write和edit不能写出工作区;.env、.env.*、.git、node_modules被保护;- 其他工具不做假装全面的限制。
然后我直接让 Pi 尝试用 write 工具写 .env。模型确实发起了写入,但工具结果变成了 Write target is protected,工作区里也没有产生 .env 文件。再把目标换成普通的 playground/policy-allowed.txt,写入就成功了。
这次实验把一个很容易混淆的边界拉开了:这是一层 Agent 策略,不是操作系统沙箱。它能在工具调用前拒绝不该做的事,但不能自动解决 bash、符号链接、进程权限这些更底层的问题。把扩展里的 if 判断叫成“安全沙箱”,反而会让自己放松警惕。
审计第一次失败时,我才看清事件的真实边界
为了记录这次策略决策,我又加了一个 tool-audit 扩展。它把工具调用追加到 JSONL:记录工具名、路径、参数字段名、写入内容长度和结果统计,但不记录 write.content 或 bash.command 的原文。
我原本监听的是 tool_call 和 tool_result,预期会得到一对记录:
tool_call -> tool_result(error)
实际跑完后,日志里只有 tool_call。.env 的确没有被写入,模型也确实拿到了阻断错误;只是我期待的结果事件没有出现。
这个现象比“代码一次写对”更有价值。我回到 agent-loop.ts 才发现:beforeToolCall 返回 block 时,Pi 会立刻构造一个错误结果,这在内部叫 immediate outcome。它没有进入真正的工具执行和 afterToolCall;而 Coding Agent 扩展的 tool_result 正是从后者桥接出来的。所以对策略阻断来说,tool_result 并不是一个完整的终态事件。
但循环仍会发出 tool_execution_end。把审计扩展改为监听它之后,第二次测试终于得到了同一个 toolCallId 对应的两条记录:
tool_call -> tool_finalized(error)
同时审计文件里没有出现我让模型尝试写入的原文,只有 contentLength。这次小插曲让我对“可观测性”有了更具体的理解:不是加一个日志文件就结束了,先要说清楚每个事件到底覆盖了哪一段生命周期。一个名字叫 result 的事件,也可能不包含策略拒绝产生的结果。
会话不是聊天记录,审计也不该只看上下文
Pi 的 SessionManager 用 JSONL 保存一棵以 parentId 和 leafId 连接起来的消息树。分支不是复制整个对话,而是让当前叶子换到另一条路径;压缩上下文时,也不是把老记录直接删掉,而是写入摘要和保留尾部消息,让后续模型少吃一些 token。
这让我区分开两件以前容易混在一起的事:模型上下文需要变短,审计历史不能因此消失。模型只需要足够的摘要和最近状态继续工作;但如果某次写入出错、某条策略被绕过,回头排查时还得能沿着原始工具调用找到发生了什么。
Pi 把这两条线分开了。它不保证替我做完所有治理,但至少没有把“喂给模型的历史”和“应该保留的操作事实”当成同一种数据。
并发不是开关,而是资源问题
最后一个让我改观的地方是多工具调用。
Pi 在并行模式下,仍然会按模型调用顺序做工具查找、参数校验和策略检查;通过检查的工具才并发执行。运行时的 tool_execution_end 按谁先完成谁先出现,但最终写回模型上下文的 tool result 还是回到模型原始调用顺序。
这两个顺序看起来有点别扭,其实各自服务不同目标:审计想看到真实发生的先后,模型则更需要稳定的、与自己调用顺序一致的上下文。
我还以为 Pi 会把 write 和 edit 一律设成串行,结果它做得更细。两个工具都经过 file-mutation-queue:同一真实路径上的修改排队,不同文件仍然可以并发。也就是说,它不是笼统地说“写操作危险,所以别并发”,而是把竞争约束在同一个资源上。
这也提醒我,第一版 mini agent 没必要急着复刻这套优化。先让所有副作用工具串行,能把策略、审批、审计跑对;等确实有并发需求,再根据文件路径或其他资源做锁。Pi 这段代码给我的不是“立刻抄过来”,而是一个很明确的演进顺序。
Pi 和其他 Coding Agent,到底差在哪
读完以后,我不太想把 Pi 简单排成“比 Claude Code 强”或者“比 Aider 弱”。它们更像是站在不同的位置上。
Claude Code 更像已经打磨好的工作台:权限提示、终端交互、项目理解和日常开发流程更完整;Aider 则非常聚焦代码编辑、仓库上下文和 Git 工作流。Pi 吸引我的地方是,它把工作台底下的零件露了出来:工具如何进循环、扩展怎么截获调用、会话如何落盘、并发怎么避免同文件互相覆盖。
代价也很直接。用 Pi 做自己的 Agent,策略、审批、审计格式、权限边界都要自己定义;它给的是可组合的部件,不是一套替你兜底的企业级治理方案。对日常写代码,我未必会为了“可学习”放弃成熟产品;但为了理解 Coding Agent 并做一个能在求职时讲清楚设计取舍的小项目,Pi 是很好的参考实现。
最后
这次读 Pi 没有让我找到一个万能的 Agent 架构。反而是几个很具体的细节留下来了:工具结果必须回到模型上下文;策略拦截和工具执行是两段不同的生命周期;审计要覆盖拒绝路径;并发最终要落到同一个资源会不会竞争。
这些事情写在介绍页里都很轻,真正跑一次、让一条审计记录缺席、再顺着调用链找回去之后,才会变成自己的理解。接下来我会带着这些边界去做 mini coding agent,而不是从一个空的 while 循环开始猜它应该长什么样。
作者:T | 汕头大学光电信息科学与工程 | AI Agent 方向 GitHub: github.com/iuyup