- Published on
Pi 源码解读(八):多个工具并发执行,异常如何处理?
- Authors

- Name
- Stone
本篇目录
前几课的任务都很顺利:模型提出请求,工具成功执行,结果交回模型。但真实情况可能是:两个工具同时完成、参数只生成了一半、服务临时出错,或者你突然按下停止。
这一课不重新讲循环,而是看 Pi 在这些地方作了什么具体选择。
NOTE
工具可以并行,结果不能对错号;请求不完整,不能冒险执行;任务失败,也不能不分原因地重试。
源码基准:Pi v0.84.1。例子为教学推演,不是实测记录;Codex 对照单独标明来源。
一、两个工具能不能一起执行?
假设模型为了检查前端项目,在同一条响应里提出两个请求:A 读取 package.json,B 读取 src/App.vue。我们先假设两次读取相互独立,也没有其他操作正在修改它们。
等待 A 读完再读 B,不一定有必要。Pi 的默认并行模式会先按顺序完成这批请求的执行前检查,再并发执行通过检查的工具。检查和执行不是随意混在一起的。1
但它也不是无条件并行。executeToolCalls 会检查全局配置与每个工具的执行模式:全局要求顺序执行,或者这批请求中有工具声明必须顺序执行,整批就走顺序分支。2
这里没有自动证明业务依赖的魔法。比如“先修改文件,再测试修改后的结果”,本来就有先后关系,不能因为两个操作都叫工具,就认为可以一起跑。
补充:Pi 是否完全不考虑同一个文件被同时修改?
不是。内置 edit 的执行会进入 withFileMutationQueue,在同一文件的修改过程中排队。扩展文档也要求自定义文件修改工具接入同样的队列,避免多个操作基于同一旧内容写回,互相覆盖。34
不过,这种文件级协调不等于自动识别所有业务依赖,更不等于管理所有外部进程的文件写入。第一遍记住“运行层并发与资源层协调是两件事”即可。
二、B 先完成,结果会不会被当成 A 的?
每个请求都有自己的 toolCallId,结果带着相同编号,所以不能只靠返回顺序识别它属于谁。
Pi 还把两种顺序分开处理:实时工具结束事件,可以按完成顺序出现;最终的工具结果消息,仍按原请求顺序组织。5
例如模型先提出 A、再提出 B,但 B 更快:界面可以先看到“B 已处理完”,不用假装 A 已完成;等结果收齐后,后续模型使用的记录仍按 A、B 的顺序组织。
源码中的 executeToolCallsParallel 就体现了这个安排:执行与结束通知可以交错,汇总后再按原顺序产生工具结果消息。稳定的记录顺序,不代表工具实际按这个顺序执行。6
默认并行路径可以压缩成下面这段伪代码:
收到带工具请求的模型响应:
若输出被长度限制截断:返回错误,不执行这些请求
否则,如果允许并行:
按顺序做执行前检查
并发执行通过检查的工具
哪个先处理完,先发哪个的结束事件
收齐后,按原请求顺序生成结果消息
否则:按顺序逐个检查、执行和记录
这里省略了取消与扩展结果修改,接下来解释第一条分支为什么重要。
三、参数能解析,为什么 Pi 仍然不执行?
假设模型正在生成一个修改请求,完整意图需要提供一段替换文本,但输出额度用完了,只传来了前半段。
危险点是:某些不完整输出经过尽力解析,仍可能得到一个结构上合法、内容却不完整的参数对象。 参数校验通过,不能证明模型把要写的内容全部传完了。
Pi 因此增加了一道判断:如果模型响应以 length 停止,而且包含工具请求,就调用 failToolCallsFromTruncatedMessage,将这条响应中的全部工具请求转为错误结果,不执行它们,并提示重新提出完整请求。哪怕某个请求看起来完整,也不会冒险单独放行。78
这是一个很具体的设计取舍:宁可多一次完整请求,也不根据可能缺失的参数修改环境。 后面是否继续调用模型,仍受运行中的停止策略控制。
别把这里与第五课混淆:这里是“模型输出被截断”;第五课讨论的则是“输入上下文太多”。处理位置和手段不同。
四、失败之后,先判断失败发生在哪一层
同样显示“失败”,后续动作不应该完全一样。
| 发生了什么 | Pi 中对应的主要处理 |
|---|---|
| 工具读取的路径不存在 | 正常工具异常路径会生成错误工具结果,让模型知道原因,而不是无条件重复读取。 |
| 模型服务暂时限流或出现可重试故障 | 会话层按配置决定是否延迟重试,并限制重试次数。 |
| 输入上下文超出模型容量 | 不归入普通瞬时错误重试,而是进入上下文压缩相关恢复判断。 |
工具错误的包装在 Agent Loop;模型请求的重试分类与预算在 AgentSession。_prepareRetry 会检查是否启用重试、是否超过次数,并按指数增加等待间隔;_isRetryableError 则明确将上下文溢出排除在普通重试之外。91011
所以,“模型收到错误后换个方案”与“程序按照策略重发一次模型请求”是两种机制。 不应该统称为“Agent 失败了会自动重试”,更不能理解成所有工具都会被自动再执行一遍。
五、按下停止,能否保证文件没有变化?
不能这样保证。Pi 的 Agent.abort() 会通过取消控制器发出信号,工具执行接口也能收到这个信号;能否及时停下,还取决于执行方是否检查并响应它。1213
edit.ts 里有一个很好的源码例子:它会在异步文件操作之后再次检查取消信号,而不是一听到取消就提前释放文件修改队列,因为已经发起的文件操作可能仍会完成。14
由这个执行顺序可以推导:如果写入已经完成,紧接着检测到取消,最后报告了错误,也不能据此认定文件一定没变。后续处理应先确认当前文件状态,不要盲目再执行一次修改。
WARNING
报错不等于没有副作用,取消不等于回滚。
运行时能控制后续流程,但不能让已经发生的操作自动倒退。
对照 Codex:它把哪些执行问题放到一起?
Codex 的公开源码 tools/orchestrator.rs 将工具审批、沙箱选择、执行尝试,以及部分沙箱拒绝后的后续尝试组织在一个协调流程中。具体是否允许继续,仍受审批和执行策略约束,不是任何失败都自动换更大权限重跑。15
这适合用来对比“工具执行周围还需要哪些控制”,但不能反过来声称 Pi 也采用这套沙箱协调代码。本段来源是 Codex 官方仓库 main,查阅于 2026-09-25;与 Pi v0.84.1 是两个独立的观察对象。
六、回到源码和面试,只挑几个关键分支
需要定位时,再展开源码入口
agent-loop.ts 的 executeToolCalls、executeToolCallsParallel:看执行模式如何选择,结果如何排序。
同一文件的 failToolCallsFromTruncatedMessage:看参数可能被截断时为什么不执行。
agent-session.ts 的 _isRetryableError、_prepareRetry:看哪些错误可重试,次数和等待怎么控制。
edit.ts 的执行函数:看取消检查与文件修改队列之间的关系。
面试时,可以这样讲
我在 Pi 源码里重点看了几个异常分支。工具可以并行完成,但最终结果消息仍按请求顺序整理;模型输出因长度限制被截断时,Pi 会拒绝执行这条响应里的工具请求,避免参数看着合法却缺内容。工具错误与模型服务错误也分层处理,后者才按会话层策略作有限重试。另外,取消只是发出停止信号,不能保证已经发生的文件修改被回滚。
一个不用写代码的小练习
假设 A 修改文件、B 测试项目,两者被允许并行。最终记录按 A、B 排好了,能否证明测试针对的是修改后的代码?
想过之后再看答案
不能。记录顺序不等于执行依赖。测试可能在修改完成前已经读取了旧文件。有先后关系的操作,需要明确安排串行,或者等修改结果返回后,再发起测试。
这一课记住:看源码时,别只看成功路径;更要看不完整、失败、并发和取消分别被怎样处理。
