Part II · Kernel — Tool Execution

04 工具执行 「并行」不是你以为的那个并行

已发布 主文件 agent-loop.ts L411–L791 内建工具 7 个
§1 Mental Model 一句话心智模型

先记住这一句

pi 的「并行工具执行」只并行 execute() 本身。参数校验、beforeToolCall 拦截、事件发出的第一帧,全部按 assistant 给出的顺序串行走完,一个都不跳。

这句话是理解本章所有行为的钥匙。一次 tool batch 被拆成三个阶段,只有中间那段是并发的:

由此得到一条实用推论:事件顺序和消息顺序是两套顺序。tool_execution_end 按完成先后到达(UI 据此实时更新),而进入会话历史的 toolResult 永远按模型写下的顺序排列(模型据此理解因果)。把这两者当成一回事,是接 UI 时最常见的错。

§2 Evidence 代码实证

模式选择:一票就能把整批拖回串行

并行是默认,但有两个降级条件,任一命中则整批串行执行——不是只把那一个工具串行。agent-loop.ts:419–425

packages/agent/src/agent-loop.ts原文 L419–L425
const hasSequentialToolCall = toolCalls.some(
    (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
    return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
}
return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);
一个标了 executionMode: "sequential" 的工具,会让同批次的其它工具也失去并发。把 edit 标成串行,同批的 read 也跟着串行。

并行分支里,真正并发的只有一小段

executeToolCallsParallel 的循环体本身是 await 串行的:它逐个 emit tool_execution_start、逐个跑 prepareToolCall,只把「执行 + finalize」包成闭包推入数组而不立即 await。agent-loop.ts:489 agent-loop.ts:522

并发发生在循环之后的那一行,随后立即按数组原序生成消息:agent-loop.ts:540–548

packages/agent/src/agent-loop.ts改写自 L540–L549
const orderedFinalizedCalls = await Promise.all(          # 唯一的并发点
    finalizedCalls.map((e) => typeof e === "function" ? e() : Promise.resolve(e)),
);
const messages: ToolResultMessage[] = [];
for (const finalized of orderedFinalizedCalls) {          # 回到 assistant 源顺序
    const toolResultMessage = createToolResultMessage(finalized);
    await emitToolResultMessage(toolResultMessage, emit);
    messages.push(toolResultMessage);
}
这是改写:省略了变量的完整类型标注与箭头函数体的换行。Promise.all 保序,所以 orderedFinalizedCalls 的下标仍是源顺序——「按完成顺序发事件、按源顺序存消息」就是靠这一点实现的。

preflight 的四道关

prepareToolCall 顺序执行四步,任一步失败都返回 kind: "immediate"——不进执行阶段,但仍然会产出一条 toolResult,模型总能看到发生了什么。agent-loop.ts:600

步骤失败时位置
① 按名字找工具Tool {name} not foundagent-loop.ts:607
② prepareArguments 规整参数(可选钩子)抛错即成为错误结果agent-loop.ts:586
③ schema 校验(typebox)校验信息回给模型agent-loop.ts:618
④ beforeToolCall 可 block用 reason 作为结果,可带 terminateagent-loop.ts:636

注意③在④之前:beforeToolCall 拿到的是已经过 schema 校验的参数,不是模型的原始输出。想做「参数级安全策略」的扩展因此不必自己重做解析,但也看不到那些没通过校验的调用——它们在④之前就被挡掉了。

工具抛错不是异常,是一种正常结果

executePreparedToolCall 把 execute() 的异常整个吞掉,转成 isError: true 的结果。agent-loop.ts:670 循环不会因为工具失败而中断——这是 agent 能自我纠错的前提。

同一函数里有一处容易忽略的细节:工具通过 onUpdate 推送的进度事件被收集进 updateEvents 数组,并用 acceptingUpdates 标志在执行结束的瞬间关闸,然后 await Promise.all(updateEvents) 才返回。agent-loop.ts:683–699 效果是:迟到的 tool_execution_update 永远不会排在该工具的 tool_execution_end 之后,UI 不会出现「已完成又冒出进度条」。

afterToolCall 只能改结果的四个字段

后置钩子的返回值被逐字段合并,未提供的字段保留原值;钩子自己抛错则整条结果被替换为错误。agent-loop.ts:724–751 可改的是 content / details / usage / terminate(外加 isError)——改不了 toolName、toolCallId,也无法凭空追加一条结果。

最后 createToolResultMessage 里有一行注释值得记住:JS 扩展写的无类型工具可能返回没有 content 的结果,此处兜底成空数组,避免 null 流进会话历史和 provider 请求体。agent-loop.ts:782–784 这是「核心必须容忍扩展写得不严谨」的具体体现,参见 第 12 章。

§3 Design 设计动机与权衡

为什么 preflight 不并行

把校验也并行,吞吐会更好一点。不并行换来的是一个确定的拦截顺序:beforeToolCall 按源顺序依次看到每个调用,因此可以实现有状态的策略(「这一批里已经批准过一次写操作了,第二次要问」)。如果 preflight 并发,钩子就得自己处理竞态,而钩子是扩展作者写的。

这个取舍与 第 03 章看到的一致:pi 倾向于把复杂度留在核心、把简单契约给扩展,代价是核心的控制流不可配置。

「一个串行工具拖垮整批」是特性还是缺陷

从实现看这是刻意的:判断写在批次级别而非调用级别。agent-loop.ts:422 理由推得出来但未见公开论述:标 sequential 的工具通常是有副作用且顺序敏感的(改文件、跑命令),如果只把它自己串行、其它照并发,它与并发工具之间的交错顺序仍然不确定——那等于没解决问题。要真正确定,只能整批串行。

代价是可观的:模型常在一条消息里发出「读三个文件 + 编辑一个文件」,只要 edit 是串行的,三次读也失去并发。pi 的内建 edit/write 用的是另一条路——file-mutation-queue.ts 这样的串行化队列,把顺序保证做在工具内部而不是抬到批次级别。

错误一律回灌给模型,而不是抛给用户

找不到工具、参数不合法、执行抛错、被 beforeToolCall 拦下——四种情况产出的都是一条正常的 toolResult。这让「模型自己发现错了再改」成为默认路径,而不是需要额外设计的功能。

边界在于:这也意味着没有任何一类工具错误会让 run 停下来。一个每次都失败的工具,模型可以反复调用它直到上下文耗尽。停机责任完全在模型和上层(见 第 03 章 §4)。

上游公开谈过工具——谈的是「选择」那一半

Earendil《What is a Harness?》(2026-08-20)——发布于本书锚定 SHA 之后 13 天——给出一条工具通则:「Critically, the harness usually does not dictate when and how the AI model should use the tool.」harness 通常不规定模型何时、如何使用工具,只把它们提供出来、描述清楚,交给模型自己判断。

这条通则在它自己的抽象层级上是准确的,pi 的代码也印证了它的核心:preflight 第一步查表时,键就是模型写下的那个 name。agent-loop.ts:607 工具集由 harness 给定,选哪一个由模型的输出决定。本章的贡献是指出它下面还有一层——按 pi 的实现收窄,更准确的说法是:选择权归模型,执行权几乎全归 harness。模型写下一批 tool call 之后,对接下来发生的事几乎没有发言权:

这个区分决定了工具层的工作量估算落在哪里。照通则去理解,工具层像一件文档工作:schema 写对、描述写清楚,模型自会用好。而本章读到的代码说明,工具层真正的复杂度是在模型做完决定之后才开始的——顺序保证、拦截时机、错误回灌、事件与消息的两套顺序,这几件事没有一件能靠把工具描述写得更清楚来解决。

这不是疏漏,而是抽象层级的选择:科普不是规格说明。也正因如此,本书只把它当作上游意图的证据,不当作代码行为的证据。本章前面对批次级串行判定所标的「未见公开论述」不受影响——文章没有触及执行侧的任何决定。

§4 Limits 边界与失效条件

什么时候这套机制会咬你

检查过但未发现问题:串行分支与并行分支的错误处理路径一致(都经 prepareToolCall → executePreparedToolCall → finalizeExecutedToolCall),没有出现「只在某一种模式下才有的行为」。两个分支的差别只在于何时 await。

§5 Takeaway 拿走什么

如果你在设计工具执行层

§ Self-check 自测

给场景,判断 pi 会怎么做

场景模型一条消息里发了 read(a)、read(b)、edit(c)。全局 toolExecution: "parallel",但 edit 的 executionMode 是 "sequential"。三个工具怎么跑?

看答案

B。hasSequentialToolCall 是批次级判断——只要批里有任何一个工具标了 sequential,整批走 executeToolCallsSequential。agent-loop.ts:422

场景并行模式下三个工具,完成顺序是 3 → 1 → 2。写进会话 JSONL 的 toolResult 顺序是什么?

看答案

C。Promise.all 保序,随后按数组下标顺序生成消息。tool_execution_end 事件按完成顺序(3、1、2),但消息按源顺序。agent-loop.ts:540

场景你写了个 beforeToolCall 想审计模型传的原始参数,包括那些格式非法的。你能看到它们吗?

看答案

A。顺序是 prepareArguments → validateToolArguments → beforeToolCall。钩子收到的 args 是校验后的值;校验失败的调用在 L618 抛错,直接变成 immediate 错误结果,永远到不了钩子。agent-loop.ts:618

基于 earendil-works/pi @ ac4ac9e(2026-08-07 快照)。
仓库 commit 速度约 428/月,行号引用仅对该 SHA 有效。