摘要
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 | Done 或 Idle |
AwaitAssistant |
assistant 返回 stop_reason=tool_use |
解析所有 tool_use blocks,登记 pending ids |
DispatchingTools |
DispatchingTools |
后端支持并行 | 按依赖和权限并发执行工具 | CollectingResults |
DispatchingTools |
后端或代理不支持并行 | 进入串行调度,每次只执行一个逻辑调用 | CollectingResults |
CollectingResults |
工具成功 | 生成 tool_result |
CollectingResults |
CollectingResults |
工具失败 | 生成 tool_result 且 is_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=true 且 runtimeCanExecuteParallel=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_file、rg、list_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