# Agent Note: 按单次调用安全性并行执行工具调用

Status: implemented

[English](2026-07-10-parallel-tool-call-execution.md) | 中文

## 问题

一条 assistant 消息可以包含多个并列的 `tool-call` 块。尽管模型已经同时请求了这些调用，串行执行仍会叠加各个独立读取和 Web 请求的延迟。

并发属于宿主调度范畴，不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下，判断哪些调用可以重叠执行。

会话日志仍是权威记录：每个已启动的调用都有审计事件，都会获得结果；无论完成顺序如何，模型历史都按原始调用顺序观察结果。

## 决策

每个工具都可以提供可选的 `isConcurrencySafe(args)` 分类器。该分类器必须是同步纯函数：它只检查当前调用已解析的参数，不执行 I/O 或任何变更。只有显式返回 `true` 才表示选择并行；分类器缺失、参数无效、分类器抛错或返回任何其他值，都会使该调用按独占方式执行。规范类型契约见[工具数据结构](../../../../docs/core-data-structures/tools.md)。

分类器有意设计为一元函数。返回 `true` 表示工具承诺：此调用可以与任何同样返回 `true` 的并列调用重叠执行。调度器不会比较调用，也不会证明它们的资源访问相容。

这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行，将变更操作分类为独占。该接口无法表达「仅当路径不同时，这些写入才安全」之类的关系规则，因此，安全性依赖并列调用的调用仍按独占方式执行。

`defineTool()` 先验证参数，再调用类型化分类器。无效参数会被归为独占，且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义，并返回带标签的 `parallel` 或 `exclusive` 模式；未知工具将以安全方式退化为独占。

使用带标签的模式，而不是公开布尔型调度器 API，使得以后可以表达感知资源的变体，无需改变分类器契约。

## 调度与顺序

循环会等待完整的 assistant 消息，对每个调用只解析一次，为每个调用创建独立的 `ToolExecution`，再按模型顺序扫描。连续的并行调用组成一组；每个独占调用单独组成一组，并构成顺序屏障。各组按顺序执行。分类采用惰性方式：每经过一个屏障，调度器都会解析下一个调用；补充并行池之前，还会重新分类每个后续调用。如果注册表变更使该调用变为独占，当前池会先完全排空，然后该调用才作为下一个屏障启动。

例如：

```text
[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)]

→ [read(A), read(B)]
→ [write(A)]
→ [read(C)]
```

`read(A)` 和 `read(B)` 可以重叠执行。`write(A)` 要等两者都完成后才启动，`read(C)` 则要等写入完成后才启动。

每组都使用一个由 `maxParallelToolCalls` 限制上限的滚动池：循环先按模型顺序启动调用，直到达到上限；每有一个调用结算，就再启动一个。独占组是容量为 1 的池。将上限设为 `1` 可保持串行执行。

只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行，因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会环绕并发派发运行，因此必须能在不同执行之间重入。

每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位；提交游标只有在下一个槽位就绪时，才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用，但结果和工具执行后的上下文仍按模型顺序排列。

如果在一组启动前中止，系统不会记录该组的任何调用。如果在一组执行期间中止，系统会停止补充池，等待已启动的调用，按顺序提交其结果，在这些结果之后排空已接受的批次上下文，然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。

Code Mode 仍不使用此调度器，因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行；`mode: 'both'` 中的原生并列调用使用常规调度器。

## 安全契约

工具返回 `true` 即承诺：其主体可以与其他并行调用同时运行。它不得直接变更父会话或其他由父级拥有的状态；它将输出返回给循环，由循环按模型顺序提交。

执行期间触及的任何共享状态都必须支持并发。这也包括工具包装层和提供方：它们可以在内部串行化，也可以实施自身容量限制，但必须在并发派发时不破坏状态。

## 配置与声明

`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限，由工厂创建的所有 agent（智能体）共享。默认值为 `10`；`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。

当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源，而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器，因此仍按独占方式执行。

文件系统读取依赖一个范围很窄的记录器例外：其同步观察更新可以不按顺序结算，但写入和编辑在变更前会重新检查已观察的版本，因此陈旧状态只会导致 `FS_STALE_VERSION`。

## 验证

单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文，以及中止排空。第一方测试固定每项并行声明。

快照覆盖固定了可见的多调用 transcript（文本记录）：待处理调用可以重叠执行，已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为，因此无需依赖提供方的 e2e 测试。

## 备选方案

**保持串行执行。** 这可以避免新的顺序和中止情形，但会保留独立并列调用所产生的不必要延迟。

**使用一个工具级布尔值。** 固定的 `supportsParallelToolCalls` 标志更小，但无法区分同一工具的只读操作和变更操作。感知参数的分类器保留了这项区分。

**使用有状态的分类。** 向分类器提供实时 agent、注册表或 I/O 访问，会使决策依赖分类器的运行时机，并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。

**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用，或让每个调用声明资源读写要求。这样可以并行化不冲突的写入，却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性，并在安全性取决于关系时安全退化。

**并行执行完整的工具流水线。** 这样可以让循环继续使用公开的单调用 API，但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态，因此只允许派发重叠。

**公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件，会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图，而 `executionMode(exec)` 为策略 seam 保留了插入点。

**在模型流式输出时启动调用。** 这可能进一步降低延迟，但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。

**使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用，一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。

**向模型暴露并发元数据。** 模型已可以发出并列调用。宿主调度元数据会扩大请求，却无助于工具选择。

## 影响

该设计以安全退化为原则，对工具作者而言也很简单，但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行，可能暴露潜在的共享状态竞态。

在某些情形下，并行调用会先行启动，而串行执行原本会在轮到这些调用之前中止。因此，调度器只记录已启动的调用，在中止时将其排空，且取消后绝不启动替换调用。

有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序，同时实时界面仍可显示待处理进度。

并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制；循环上限只限制一个 agent 步骤中的调用数量。

工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类，因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
