摘要

Claude Code / Anthropic Messages API 的 tool use 不是“模型吐出一个函数名,应用执行一下”这么简单。模型在一次 assistant 响应中可能返回多个 tool_use content block,并以 stop_reason: "tool_use" 暂停;应用需要执行这些工具,再用下一条 user message 中的 tool_result block 按 tool_use_id 回填结果。Anthropic 原生协议支持并行工具调用,也提供 disable_parallel_tool_use=true 来限制模型一次只发起一个工具调用。

问题出现在跨模型和代理层。许多 OpenAI-compatible 模型、本地模型、网关代理或 agent runtime 只实现了单工具调用假设:一次 assistant turn 只认一个 tool_call,或只会把第一个调用交给工具执行器。此时如果上游仍按 Anthropic 语义生成多个 tool_use blocks,轻则部分工具被丢弃,重则后续历史缺少对应 tool_result,导致协议 400、状态回放失败或 agent 行为不可复现。

因此,并行工具调用降级应被设计成一个明确的调度层:能在 Anthropic 后端上保留并行,在不支持并行的模型或代理层上降级为串行执行,同时保证 id 配对、顺序保持、失败隔离和回放一致性。

问题边界:并行不是性能优化,而是协议能力

在 Anthropic tool use 协议中,assistant message 的 content 是一个数组。数组中可以同时出现文本块和一个或多个 tool_use block:

{
  "role": "assistant",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_01A",
      "name": "read_file",
      "input": { "path": "src/router.ts" }
    },
    {
      "type": "tool_use",
      "id": "toolu_01B",
      "name": "grep",
      "input": { "query": "tool_result" }
    }
  ],
  "stop_reason": "tool_use"
}

应用必须把每个 tool_use.id 映射到一个 tool_result.tool_use_id。这些结果应当作为下一条 user message 的 content blocks 返回,并且在 Anthropic 文档要求的布局下,tool_result blocks 需要紧跟对应的 tool use 轮次,放在 user message content 数组前部:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01A",
      "content": "..."
    },
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01B",
      "content": "..."
    }
  ]
}

这说明并行工具调用首先是协议语义,其次才是执行优化。兼容层不能简单地把多个调用合并成一个字符串,也不能只执行第一个调用后继续对话。只要历史里出现了 N 个 tool_use ids,后续就必须有 N 个可配对的 tool_result blocks,哪怕某个工具执行失败,也应以 is_error: true 的工具结果闭合该 id。

并行状态机

一个稳健的 tool use runtime 可以抽象成如下状态机:

状态 输入事件 动作 下一个状态
Idle 用户请求进入模型 构造 messages、tools、模型参数 AwaitAssistant
AwaitAssistant assistant 返回普通文本 追加 assistant message DoneIdle
AwaitAssistant assistant 返回 stop_reason=tool_use 解析所有 tool_use blocks,登记 pending ids DispatchingTools
DispatchingTools 后端支持并行 按依赖和权限并发执行工具 CollectingResults
DispatchingTools 后端或代理不支持并行 进入串行调度,每次只执行一个逻辑调用 CollectingResults
CollectingResults 工具成功 生成 tool_result CollectingResults
CollectingResults 工具失败 生成 tool_resultis_error=true CollectingResults
CollectingResults 所有 pending ids 已闭合 按稳定顺序组装 user message AwaitAssistant
CollectingResults 存在不可恢复调度错误 生成可回放错误事件并停止 Failed

这里最重要的约束有三条。

第一,pending set 以 tool_use.id 为主键,而不是以工具名或数组下标为主键。数组下标只能用于顺序恢复,不能用于配对。

第二,执行顺序和返回顺序可以不同,但回填顺序应稳定。这是兼容层的确定性治理策略,不是 Anthropic 官方协议额外规定。并行执行时 grep 可能先返回,read_file 后返回;回传给模型时建议按 assistant content 中的 tool_use 出现顺序排列,避免模型在后续推理中看到非确定性的结果序列。

第三,状态机必须区分“工具业务失败”和“协议调度失败”。业务失败可以通过 is_error: true 闭合工具调用,让模型决定下一步;协议调度失败,例如丢失 id、重复 id、结果无法序列化,则应在兼容层阻断并记录审计日志。

降级策略

1. 能关并行时优先关并行

如果目标仍是 Anthropic 后端,且当前 agent runtime 或下游工具执行器无法处理多个并发调用,最直接的办法是在请求中设置:

{
  "disable_parallel_tool_use": true
}

这会要求模型尽量一次只使用一个工具。优点是协议形态最简单:每轮最多一个 tool_use,旧式单调用执行器也更容易兼容。缺点是它依赖 Anthropic 参数;迁移到非 Anthropic 模型、OpenAI-compatible 网关、本地推理服务时,这个参数可能被忽略、报错,或没有等价能力。

因此,disable_parallel_tool_use 应当是能力协商的一部分,而不是唯一防线:

type ToolUseCapabilities = {
  modelSupportsParallelToolUse: boolean;
  providerSupportsDisableParallel: boolean;
  runtimeCanExecuteParallel: boolean;
  runtimeCanReturnBatchResults: boolean;
};

providerSupportsDisableParallel=trueruntimeCanExecuteParallel=false 时,开启 disable_parallel_tool_use。当 provider 不支持该参数时,兼容层必须自己做调度降级。

2. 串行调度:把一个并行 turn 拆成多个执行步骤

对不支持并行的执行器,降级层可以仍然接受 assistant 的多个 tool_use blocks,但内部按队列逐个执行:

type PendingToolUse = {
  index: number;
  id: string;
  name: string;
  input: unknown;
};

async function runSerial(pending: PendingToolUse[]) {
  const results = new Map<string, ToolResult>();

  for (const call of pending) {
    try {
      const output = await executeTool(call.name, call.input);
      results.set(call.id, {
        type: "tool_result",
        tool_use_id: call.id,
        content: serializeToolOutput(output)
      });
    } catch (error) {
      results.set(call.id, {
        type: "tool_result",
        tool_use_id: call.id,
        is_error: true,
        content: formatToolError(error)
      });
    }
  }

  return pending.map((call) => results.get(call.id)!);
}

这种策略保留了 Anthropic 历史形态:assistant turn 里仍有多个 tool_use,下一条 user message 里一次性返回多个 tool_result。它的关键价值是兼容“工具执行不能并发”的运行时,而不是兼容“模型 API 不能接收批量 tool_result”的后端。

3. 批量工具结果配对:不要逐条把结果发回模型

有些降级实现会执行完第一个工具就立刻把一个 tool_result 发回模型,然后再执行第二个工具。这个做法很危险:原始 assistant turn 中已经声明了多个 tool_use ids,下一条 user message 却只回了其中一个 id,剩余 ids 暂时悬空。对严格实现 Anthropic 协议的后端,这可能直接触发“缺少对应 tool_result”的错误;对宽松代理,也会让历史状态变得不可预测。

更稳妥的规则是:只要 assistant turn 中出现了多个 tool_use blocks,就把它们视为一个批次。执行可以串行,但回传必须批量闭合:

assistant turn T:
  tool_use A
  tool_use B
  tool_use C

runtime:
  execute A
  execute B
  execute C

next user turn T+1:
  tool_result A
  tool_result B
  tool_result C

这也是回放一致性的基础。审计日志可以记录每个工具实际开始和结束时间,但模型历史应看到稳定的批次边界。

4. 模型级不支持多个 tool calls 时:在提示和适配器层双保险

如果目标模型本身不可靠地产生结构化并行调用,兼容层需要同时做两件事。

第一,在提示和 tool schema 策略上引导模型一次只调用一个工具。例如系统提示中明确“每次只发起一个工具调用;等待工具结果后再继续”。这不等同于协议保证,只是减少异常概率。

第二,在 parser / adapter 层容忍模型仍然输出多个调用。社区案例中,多类 agent 框架都报告过“模型生成了看似正确的工具调用,但兼容层没有执行、没有正确解析,或没有回放必要状态”的问题。更稳妥的适配器不应假设模型永远遵守单调用提示;它应当能检测多个调用,进入批次处理,或在无法处理时生成明确错误,而不是静默丢弃。

id 配对:兼容层的不可变账本

tool_use.id 是 tool use 状态机的账本主键。跨模型适配时,常见字段映射如下:

Anthropic OpenAI-compatible 常见字段 兼容层注意点
content[].type="tool_use" message.tool_calls[] Anthropic 是 content block;OpenAI 通常是 message 顶层数组
tool_use.id tool_calls[].id 必须稳定保存,不能重新生成后丢失映射
tool_use.name function.name 工具名可能需要 schema 名称规范化
tool_use.input function.arguments OpenAI 常是 JSON 字符串;Anthropic 是结构化对象
tool_result.tool_use_id role="tool"tool_call_id 回填必须指向原始调用 id

推荐在兼容层维护一份不可变 ledger:

type ToolUseLedgerEntry = {
  conversationId: string;
  assistantTurnId: string;
  batchId: string;
  ordinal: number;
  provider: "anthropic" | "openai-compatible" | "local";
  originalToolUseId: string;
  normalizedToolUseId: string;
  toolName: string;
  inputHash: string;
  status: "pending" | "running" | "succeeded" | "failed" | "returned";
  resultHash?: string;
  errorClass?: string;
};

有了 ledger,降级层可以做到三件事:

1、检查每个 assistant turn 的 tool_use 是否全部闭合。
2、在不同 provider id 格式之间做稳定映射。
3、在重试、回放、日志清理后仍能证明“哪个结果对应哪个调用”。

注意不要用工具名配对。同一轮里模型可能调用两次同一个工具,例如读取两个文件;也不要用 JSON input 配对,因为两个调用可能输入相同但语义上仍是两个不同事件。

顺序保持与依赖判断

并行 tool use 默认表达的是“模型认为这些工具可以在同一轮发起”,但这不等于它们绝对无依赖。兼容层至少要做两层判断。

第一层是保守依赖规则。只读工具如 read_filerglist_dir 通常可以并发;写文件、执行 shell、数据库 mutation、浏览器点击等有副作用的工具建议默认串行。这同样是安全工程策略,而不是协议硬性要求;只有当工具声明了资源锁、幂等性或事务边界时,调度器才应放宽并发。

第二层是返回顺序稳定。无论实际执行是否并行,回传给模型的 tool_result 顺序都应按 assistant content 中的 ordinal 排列:

const orderedResults = pending
  .toSorted((a, b) => a.ordinal - b.ordinal)
  .map((call) => resultById.get(call.id));

这会牺牲一点“谁先完成谁先返回”的实时性,但换来确定性。对于 coding agent,这种确定性通常更重要:同一个历史回放两次,应当生成同样的消息结构和相同的工具结果顺序。

错误处理:失败隔离,而不是批次崩溃

并行批次中某个工具失败,不应默认让整个批次失败。更好的策略是失败隔离:

{
  "type": "tool_result",
  "tool_use_id": "toolu_01B",
  "is_error": true,
  "content": "grep failed: permission denied"
}

这样可以保证 toolu_01B 被协议闭合,同时把错误交还给模型判断。模型可能换一个路径搜索,也可能向用户说明权限问题。

需要特别区分以下错误类型:

错误类型 是否生成 tool_result 处理方式
工具业务错误,如文件不存在、命令退出码非零 is_error=true,content 写明可操作错误
工具输出过大 截断或摘要,content 标注 raw ref / hash
工具执行超时 is_error=true,标注 timeout 和是否已取消
工具进程部分成功 返回结构化摘要,标注 partial
找不到工具实现 通常是 生成错误 result,让模型知道工具不可用
tool_use.id 缺失或重复 协议错误,阻断请求并记录兼容层错误
结果无法绑定到任何 pending id 调度错误,阻断并进入审计

失败隔离还有一个实际好处:防止串行降级时“第一个工具失败导致后续工具完全不执行”。如果同一批次中的工具互不依赖,后续工具仍应执行并回填结果。若工具有副作用或显式依赖,调度器可以把后续工具标记为 skipped,并为对应 id 生成 is_error=true 的结果,说明跳过原因。

回放一致性:让降级后的历史仍像原生历史

跨模型 agent 最容易出问题的地方不是当前轮,而是下一轮、重试轮和压缩后的历史回放。并行降级层需要保证以下不变量:

1、批次边界不变:一个 assistant turn 产生的多个 tool_use,由下一条 user message 的多个 tool_result 共同闭合。
2、id 不变:原始 tool_use.id 与回填 tool_use_id 一一对应;如果 provider id 被转换,ledger 中必须可追溯。
3、顺序不变:模型历史中的结果顺序按原始 tool use ordinal,而不是按完成时间。
4、错误可见:工具失败被编码成 tool_result,而不是从历史中消失。
5、重试幂等:同一个 tool use 批次重放时,要么复用已记录结果,要么显式标注重新执行;不能静默混用旧结果和新结果。
6、清理不破坏配对:后续做工具历史清理或摘要时,不能删除仍处于协议窗口内的 tool_use / tool_result 对。

一个简单的回放校验器可以在每次发请求前运行:

function validateToolUseHistory(messages: Message[]) {
  const open = new Map<string, { turn: number; ordinal: number }>();

  for (const [turn, message] of messages.entries()) {
    for (const [ordinal, block] of message.content.entries()) {
      if (message.role === "assistant" && block.type === "tool_use") {
        if (open.has(block.id)) throw new Error(`duplicate tool_use id: ${block.id}`);
        open.set(block.id, { turn, ordinal });
      }

      if (message.role === "user" && block.type === "tool_result") {
        if (!open.has(block.tool_use_id)) {
          throw new Error(`orphan tool_result: ${block.tool_use_id}`);
        }
        open.delete(block.tool_use_id);
      }
    }
  }

  if (open.size > 0) {
    throw new Error(`unclosed tool_use ids: ${Array.from(open.keys()).join(", ")}`);
  }
}

真实实现还需要检查 Anthropic 对消息相邻关系和 content block 位置的要求:tool_result 不应被普通 user 文本或其他无关 block 随意插入到前面。

测试矩阵

并行降级不能只靠单元测试 parser。它需要覆盖模型响应、工具执行、消息组装、历史回放和 provider 转换。

场景 输入 期望
Anthropic 原生并行 assistant 返回 3 个 tool_use 并发执行,下一条 user message 返回 3 个按稳定顺序排列的 tool_result
Anthropic 禁用并行 设置 disable_parallel_tool_use=true 模型每轮最多产生一个工具调用;若仍出现多个,兼容层进入批次处理或报明确错误
Runtime 不支持并发 assistant 返回多个 tool_use,执行器只能串行 串行执行,批量回填,历史形态不变
OpenAI-compatible 单调用模型 模型返回单个 tool_call 映射为单个 Anthropic-style pending entry,id 正确回填
OpenAI-compatible 多调用模型 tool_calls[] 有多个元素 转成同一批次,执行后按 tool_calls[] 顺序回填
重复工具名 同一轮两次调用 read_file 用 id 区分,不能按工具名覆盖结果
工具失败 第二个工具抛错 只将第二个结果标记 is_error=true,其他结果正常返回
工具超时 一个工具超过 deadline 生成 timeout error result,批次仍闭合
输出过大 工具输出超过 token 上限 返回摘要或截断结果,附 hash / raw ref
缺失 result 人为删除一个 tool_result 发送前校验失败,阻断请求
orphan result 出现未对应任何 pending id 的结果 校验失败,记录调度错误
顺序抖动 并行执行完成顺序随机 最终 user message 顺序稳定
历史清理后回放 清理旧工具结果摘要 不破坏仍需配对的 tool use 批次
社区兼容案例回归 模拟“模型生成工具调用但代理未执行” parser 能检测调用,执行器或错误路径可观测,不静默吞掉

建议把这些测试拆成四层:

1、Parser tests:验证 Anthropic content blocks、OpenAI tool_calls[]、本地模型 XML / 文本工具调用格式能被规范化成统一 pending entries。
2、Scheduler tests:验证并行、串行、资源锁、超时、取消和失败隔离。
3、Message assembly tests:验证 tool_result 批量回填、顺序、id 配对和 Anthropic 布局约束。
4、Replay tests:把完整 messages 重新喂给校验器,确认没有 unclosed / orphan / duplicate ids。

实现建议

工程上,不要把并行降级散落在 provider adapter、工具执行器和消息组装器里。推荐拆成三层:

职责 不应承担的职责
Provider Adapter 把不同模型响应规范化为 PendingToolUse[],把结果组装回目标 provider 格式 不直接执行工具
Tool Scheduler 根据能力和资源锁执行工具,生成 ToolResult[] 不改写模型历史
History Assembler 按协议生成下一条 user message,做 id 和顺序校验 不决定工具业务逻辑

核心数据流如下:

model response
  -> Provider Adapter
  -> normalized PendingToolUse batch
  -> Tool Scheduler
  -> ordered ToolResult batch
  -> History Assembler
  -> next model request

这样设计后,disable_parallel_tool_use 只是 Provider Adapter 的一个能力开关;串行执行只是 Tool Scheduler 的一种策略;批量 tool_result 配对则由 History Assembler 统一保证。

结论

并行工具调用降级的目标不是简单地“把并行改成串行”,而是在不同模型和代理能力之间保持同一个 tool use 状态机。Anthropic 默认可能出现多个 tool_use blocks;兼容层如果不支持并行,就要优先使用 disable_parallel_tool_use,并在参数不可用或被忽略时自行完成串行调度、批量结果回填、id ledger、顺序稳定和失败隔离。

真正可靠的降级实现有一个判断标准:无论工具实际是并发执行、串行执行,还是部分失败,模型看到的历史都应是闭合、可配对、可校验、可回放的。只要这个不变量成立,Claude Code 类 agent 才能在 Anthropic、OpenAI-compatible、本地模型和社区代理之间迁移时保持行为稳定。

参考链接

以下参考中,Anthropic 官方文档直接支撑并行 tool use、tool_result 配对和错误回传;DeepSeek / Qwen 社区案例用于说明跨模型兼容常见的状态回放与解析问题,不作为并行语义本身的直接证据。

Anthropic Tool use overview
Anthropic Handle tool calls
Anthropic Parallel tool use
Anthropic Tool Runner SDK
AWS Bedrock Anthropic Claude tool use
Anthropic Engineering: Advanced tool use
Anthropic Engineering Postmortem
Vercel AI issue: DeepSeek thinking mode tool calling
OpenCode issue: DeepSeek V4 thinking mode + tool call
NousResearch Hermes Agent issue: DeepSeek V4 thinking mode + tool call
OpenClaw issue: reasoning content replay
Qwen3-Coder issue: function calling / tool call format
llama.cpp issue: Qwen3-Coder XML tool call parser