摘要
Claude Code 这类 agent 不是把模型输出当成一段普通文本处理,而是把模型、工具执行器和用户消息组织成一条严格的执行日志。在 Anthropic Messages API 中,工具调用以 assistant 消息里的 tool_use content block 表示;工具执行结果必须作为下一条 user 消息里的 tool_result content block 回填,并通过 tool_use.id 与 tool_result.tool_use_id 配对。这个设计和 OpenAI/function calling 形态不同:OpenAI-compatible API 通常把工具请求放在 assistant message 的 tool_calls 字段,把工具结果放在独立的 tool role 消息里。
当 Claude Code 的工具调用协议要映射到 DeepSeek、Qwen 等非 Anthropic 模型或 OpenAI-compatible 网关时,最大的风险不是 JSON 字段名不同,而是状态机不同:消息角色、content block 顺序、工具 id 生命周期、错误语义、并行调用、停止原因和流式增量都必须一起转换。只做 input_schema 到 parameters 的 schema 改名,很容易在多轮工具调用中触发 400、丢失工具结果、重复执行工具,或让模型把工具错误当成普通用户文本继续推理。
本文聚焦最基础、最容易出错的一层:如何把 Anthropic 的 tool_use / tool_result 转成 OpenAI-compatible function calling 形态,并为国产模型兼容层保留足够的协议状态。
核心问题
跨模型 tool use 兼容层要回答四个问题:
1、工具定义如何转换:Anthropic 的 tools[].input_schema 如何变成 OpenAI-compatible 的 tools[].function.parameters。
2、工具调用如何转换:assistant content 中的 tool_use block 如何变成 assistant message 的 tool_calls。
3、工具结果如何回填:Anthropic 要求 tool_result 放在下一条 user message content 数组中;OpenAI-compatible 通常要求放在 role: "tool" 消息中。
4、状态如何闭合:每个 tool_use.id 必须精确匹配一个工具结果,错误结果必须保留机器可读状态,且下一轮模型请求必须能看见完整闭合后的历史。
这四点缺一不可。很多兼容层失败并不是因为模型不会调用工具,而是因为代理层把 Anthropic 的 content block 协议误当成普通聊天协议,导致工具调用链在历史中断裂。
Anthropic 与 OpenAI-compatible 形态差异
| 维度 | Anthropic Messages API | OpenAI-compatible function calling | 兼容层注意点 |
|---|---|---|---|
| 工具定义位置 | 请求级 tools 数组 |
请求级 tools 数组 |
都是请求级工具表,但字段层级不同 |
| 工具 schema 字段 | input_schema |
function.parameters |
可机械转换,但要保留 JSON Schema 约束 |
| 工具调用载体 | assistant message 的 content[] 中出现 type: "tool_use" block |
assistant message 的 tool_calls[],或 Responses API 的 function call item |
Anthropic 是 content block;OpenAI-compatible 多数是 message side channel |
| 工具名 | tool_use.name |
tool_calls[].function.name |
名称应稳定,不要在转换层改写 |
| 工具参数 | tool_use.input 为对象 |
tool_calls[].function.arguments 通常为 JSON 字符串 |
需要严格 JSON 序列化 / 反序列化 |
| 调用 id | tool_use.id |
tool_calls[].id / tool_call_id |
id 是配对主键,不是展示字段 |
| 工具结果载体 | 下一条 user message 的 content[] 中 type: "tool_result" |
role: "tool" 消息,带 tool_call_id |
角色和消息顺序完全不同 |
| 工具错误 | tool_result.is_error: true |
无统一标准错误字段 | 需要在结果内容中包装结构化错误 |
| 停止原因 | stop_reason: "tool_use" |
常见为 finish_reason: "tool_calls" 或输出 function call item |
停止原因要进入状态机,不能只看文本是否为空 |
| 并行工具 | 一个 assistant content 可包含多个 tool_use block |
一个 assistant message 可包含多个 tool_calls |
要等所有结果闭合后再进入下一轮模型调用 |
| block 顺序约束 | tool_result 必须紧跟对应工具调用后的下一条用户消息,并位于 content 数组前部 |
工具消息一般紧跟 assistant tool call message | 顺序校验要按源协议和目标协议分别做 |
从工程角度看,Anthropic 的工具协议更像“content block 状态机”:文本、工具调用和工具结果都在消息 content 数组里按 block 排列。OpenAI-compatible 则更像“消息角色状态机”:assistant 请求工具,随后若干 tool role 消息返回结果,再由 assistant 继续生成。
Anthropic content block 状态机
Anthropic 的关键约束可以抽象成以下状态机:
S0: assistant_generating
- 输出 text block: 仍在 S0
- 输出 tool_use block: 进入 S1
- stop_reason = end_turn: 本轮完成
S1: tool_use_pending
- 收集一个或多个 tool_use block
- stop_reason = tool_use
- 应用层执行所有工具
- 进入 S2
S2: awaiting_tool_result
- 下一条消息必须是 user
- user.content 开头必须包含对应 tool_result block
- 每个 tool_result.tool_use_id 必须匹配一个未闭合 tool_use.id
- 所有 pending tool_use 闭合后进入 S3
S3: continue_generation
- 把闭合后的历史发送给模型
- 模型基于工具结果继续 text 或再次 tool_use
这里有几个容易被忽略的细节。
第一,tool_use.id 不是可选调试信息,而是状态机主键。兼容层可以在目标模型不返回 id 时生成内部 id,但一旦生成,就必须贯穿 assistant tool call、工具执行记录、tool result 回填和后续历史。
第二,tool_result 的位置是协议语义的一部分。Anthropic 要求工具结果紧跟工具调用,并作为 user message content 数组中的 tool result block。把工具结果塞进普通文本,或者在 tool result 前插入一大段用户解释,都可能破坏模型对上一轮工具调用的绑定。
第三,stop_reason: "tool_use" 是“现在该执行工具”的控制信号,不是普通完成状态。兼容层如果忽略停止原因,只检查 assistant 文本,可能会把一个含有工具调用的响应当成空回复返回给上层。
字段映射
基础映射可以分三层:工具定义、工具调用、工具结果。
工具定义映射
Anthropic:
{
"name": "read_file",
"description": "Read a UTF-8 text file from the workspace.",
"input_schema": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
}
}
OpenAI-compatible:
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a UTF-8 text file from the workspace.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
}
}
}
这一步看似简单,但兼容层应当做三类校验:
• name 必须满足目标模型或网关的函数名限制,且保持稳定。
• input_schema 必须是 object schema;不要把自然语言参数说明拼到 description 后假装 schema。
• 如果目标模型对 strict schema、additionalProperties、枚举或数组嵌套支持不完整,应在工具注册阶段降级,而不是等模型生成非法参数后再补救。
工具调用映射
Anthropic assistant message:
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "我需要先读取配置文件。"
},
{
"type": "tool_use",
"id": "toolu_01A",
"name": "read_file",
"input": {
"path": "package.json"
}
}
],
"stop_reason": "tool_use"
}
OpenAI-compatible assistant message:
{
"role": "assistant",
"content": "我需要先读取配置文件。",
"tool_calls": [
{
"id": "toolu_01A",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\"package.json\"}"
}
}
]
}
注意 arguments 通常是 JSON 字符串,而 Anthropic 的 input 是已经解析好的对象。兼容层应当把参数解析和 schema 校验放在工具执行前,并记录原始参数字符串,方便排查流式截断、半个 JSON、重复 key、数字精度和编码问题。
工具结果映射
Anthropic 回填:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A",
"content": "{\"name\":\"demo\",\"scripts\":{\"test\":\"vitest\"}}"
}
]
}
OpenAI-compatible 回填:
{
"role": "tool",
"tool_call_id": "toolu_01A",
"content": "{\"name\":\"demo\",\"scripts\":{\"test\":\"vitest\"}}"
}
如果工具执行失败,Anthropic 可以显式设置 is_error: true:
{
"type": "tool_result",
"tool_use_id": "toolu_01A",
"is_error": true,
"content": "File not found: package.json"
}
OpenAI-compatible API 没有统一的 is_error 字段。不要只返回一段自然语言错误,因为模型很难稳定区分“工具失败”和“工具成功返回了一段错误文本”。更稳妥的做法是在 content 中包装结构化结果:
{
"ok": false,
"error": {
"type": "FileNotFound",
"message": "File not found: package.json",
"retryable": false
}
}
转换回 Anthropic 时,再把 ok: false 映射成 is_error: true。这样错误语义不会在模型切换时丢失。
兼容层设计
推荐把兼容层拆成五个边界清晰的模块。
1. ToolCallIR / ToolResultIR
不要让业务逻辑直接在 Anthropic block 和 OpenAI message 之间来回拼 JSON。先定义一个内部中间表示:
type ToolCall = {
id: string;
name: string;
input: unknown;
rawArguments?: string;
source: "anthropic" | "openai-compatible" | "xml" | "custom";
};
type ToolResult = {
toolUseId: string;
ok: boolean;
content: unknown;
error?: {
type: string;
message: string;
retryable?: boolean;
};
};
type AssistantTurn = {
text: string[];
toolCalls: ToolCall[];
stopReason: "end_turn" | "tool_use" | "max_tokens" | "unknown";
};
DeepSeek、Qwen 等后端或 OpenAI-compatible 网关的 API 表层可能相似,但细节并不完全一致:有的模型在 streaming 中分片输出 arguments,有的模型使用 XML 风格工具标签,有的网关对 tool role 消息顺序更严格。内部 IR 的作用是把这些差异限制在 adapter 内,不让 agent 主循环依赖某个厂商的偶然格式。其他后端如果纳入同一兼容层,也应通过能力探测建立自己的 profile,而不是直接套用 DeepSeek 或 Qwen 的证据。
2. Tool Registry Mapper
工具注册阶段只做确定性转换:
AnthropicTool.name -> OpenAITool.function.name
AnthropicTool.description -> OpenAITool.function.description
AnthropicTool.input_schema -> OpenAITool.function.parameters
同时生成工具能力表:
type ToolCapability = {
supportsParallelToolUse: boolean;
supportsStrictJsonSchema: boolean;
supportsToolRole: boolean;
requiresXmlParser: boolean;
requiresReasoningPassthrough: boolean;
};
这张能力表不应靠模型名称字符串散落在代码里判断,而应集中配置。比如同样是 Qwen,不同部署方式可能分别走 OpenAI-compatible JSON、chat template XML 或平台自定义 function calling。
3. Message Transformer
消息转换要按“轮次”处理,而不是按单条消息孤立处理。
Anthropic 到 OpenAI-compatible:
assistant(content: [text, tool_use A, tool_use B], stop_reason=tool_use)
user(content: [tool_result A, tool_result B, text?])
=>
assistant(content: text, tool_calls: [A, B])
tool(tool_call_id=A, content=result A)
tool(tool_call_id=B, content=result B)
user(content: text?) # 只有存在真实用户文本时才追加
OpenAI-compatible 到 Anthropic:
assistant(content: text, tool_calls: [A, B])
tool(tool_call_id=A, content=result A)
tool(tool_call_id=B, content=result B)
=>
assistant(content: [text, tool_use A, tool_use B], stop_reason=tool_use)
user(content: [tool_result A, tool_result B])
关键是不要把 tool role 消息逐条转换成多条 Anthropic user 消息。对于同一轮 assistant tool calls,应该聚合成下一条 user message 的 content 数组,并把 tool_result 放在数组前部。
4. Pending Tool Ledger
兼容层需要维护一个 pending ledger:
type PendingToolUse = {
id: string;
name: string;
inputHash: string;
createdAtTurn: number;
status: "pending" | "running" | "succeeded" | "failed";
};
每当模型输出工具调用,就登记 pending;每当工具结果回填,就按 id 闭合。下一轮模型请求前必须通过闭合检查:
• 不允许存在没有结果的 pending tool use。
• 不允许出现未知 tool_use_id / tool_call_id。
• 不允许同一个 id 被两个结果闭合。
• 并行工具调用必须全部闭合后才能继续生成。
• 如果目标后端不支持并行工具调用,应在请求侧禁用并行,或在兼容层串行调度并保留源协议顺序。
5. Error Normalizer
工具错误要同时满足两类消费者:模型需要可理解,上层系统需要可观测。建议所有 adapter 都使用统一错误 envelope:
{
"ok": false,
"error": {
"type": "CommandFailed",
"message": "npm test exited with code 1",
"retryable": true,
"metadata": {
"exit_code": 1
}
}
}
映射规则:
| 内部状态 | Anthropic | OpenAI-compatible |
|---|---|---|
| 成功 | tool_result.is_error 省略或为 false |
tool message content: {"ok":true,"data":...} |
| 失败 | tool_result.is_error: true |
tool message content: {"ok":false,"error":...} |
| 超时 | is_error: true,错误类型为 timeout |
ok:false,retryable 按工具语义决定 |
| 用户取消 | is_error: true,明确 cancelled |
ok:false,避免伪装成空结果 |
消息顺序:最小正确闭环
一个最小的 Anthropic 闭环如下:
1. user: "读取 package.json 并总结脚本"
2. assistant: text + tool_use(id=toolu_01, name=read_file, input={...}), stop_reason=tool_use
3. user: tool_result(tool_use_id=toolu_01, content=...)
4. assistant: "这个项目定义了 test / build ..."
对应 OpenAI-compatible 闭环如下:
1. user: "读取 package.json 并总结脚本"
2. assistant: content + tool_calls[id=toolu_01, function=read_file]
3. tool: tool_call_id=toolu_01, content=...
4. assistant: "这个项目定义了 test / build ..."
转换层必须保证第 2 步和第 3 步相邻,中间不能插入另一轮 assistant 推理。对于 Claude Code 这类 agent,如果工具执行器需要调用本地 shell、文件系统、浏览器或 MCP 服务,也应把这些执行细节记录在 agent 内部日志中,而不是插入模型消息破坏协议闭环。
面向 DeepSeek、Qwen 等后端的适配策略
这些模型或平台常见的接入方式是 OpenAI-compatible API,但“兼容”通常只意味着顶层 HTTP 路径和一部分字段相似,不意味着 tool calling 状态机完全一致。设计 adapter 时应按能力而不是按品牌假设:
| 能力问题 | 需要探测的行为 | 兼容策略 |
|---|---|---|
是否原生支持 tools / tool_calls |
模型是否返回结构化 tool call,而不是普通文本 | 支持则走 OpenAI-compatible adapter;不支持则走 XML / 文本 parser |
| 是否支持并行工具调用 | 一轮是否会返回多个 tool calls | 不支持时设置禁用并行,或串行调度 |
| streaming 参数是否稳定 | arguments 是否按合法 JSON 增量闭合 |
以 id 聚合 delta,结束后再 parse |
| tool role 是否严格 | role: "tool" 是否必须紧跟 assistant tool_calls |
历史构造时做顺序校验 |
| 错误语义是否保留 | 工具失败是否会被模型误读成普通结果 | 使用统一 ok/error envelope |
| 是否有额外 reasoning 字段 | thinking 模型是否要求回放 reasoning 状态 | adapter 单独保留并回传,不混入工具内容 |
| 是否使用 XML 工具格式 | Qwen 等模型在部分模板中可能输出 <tool_call> |
parser 输出统一 ToolCall IR |
因此,一个稳健的 Claude Code 兼容层不应写成:
if model.includes("deepseek") use openaiTransform()
if model.includes("qwen") use qwenTransform()
更好的结构是:
provider adapter
-> parse assistant output into ToolCall IR
-> validate pending ledger
-> execute tools
-> normalize ToolResult IR
-> render history into provider-specific messages
这样即使同一个模型在云 API、本地 vLLM、llama.cpp、LM Studio 或自定义网关下表现不同,也只需要替换 adapter 的 parse/render 层。
失败模式
| 失败模式 | 表现 | 根因 | 修复方式 |
|---|---|---|---|
| 工具调用生成了但没有执行 | assistant 文本为空,agent 直接返回 | 忽略 stop_reason: tool_use 或 tool_calls |
把停止原因纳入状态机 |
| API 返回缺少 tool_result | Anthropic 400 或下一轮拒绝 | tool_use.id 没有对应 tool_result.tool_use_id |
pending ledger 强制闭合检查 |
| 工具结果无法配对 | 模型重复调用或误读结果 | id 被重写、丢失或复用 | id 作为不可变主键保存 |
| Anthropic 历史格式非法 | tool_result 前插入普通文本 |
未遵守 content block 顺序 | 聚合同轮结果,并把 tool_result 放在 user content 前部 |
| OpenAI-compatible 历史格式非法 | tool 消息没有紧跟 assistant tool_calls | 逐条消息转换时乱序 | 按轮次转换,保持 assistant -> tool* -> assistant |
| 工具错误被当成成功 | 模型继续基于错误文本推理 | OpenAI-compatible 无 is_error 字段 |
使用 ok:false 错误 envelope |
| 并行工具只回填一部分 | 下一轮模型丢上下文或重复调用 | 未等待所有 tool_use 闭合 | 并行调用使用 barrier |
| streaming JSON 解析失败 | 参数半截、括号不闭合 | 边收边 parse arguments |
按 tool call id 聚合完整 delta 后 parse |
| XML 工具调用漏解析 | Qwen 类模型输出 <tool_call> 文本 |
只实现 OpenAI JSON parser | adapter 支持模板特定 parser |
| thinking 状态丢失 | DeepSeek thinking mode 后续 400 | 只转换 tool call,未保留 reasoning 字段 | reasoning passthrough 独立于 tool_result 管理 |
| 工具结果注入攻击 | 模型执行工具输出中的恶意指令 | 把 tool_result 当用户意图 | system 层明确工具输出不等于用户命令,并对高危工具加权限边界 |
验证清单
基础转换上线前,至少应覆盖以下用例:
• 单工具闭环:user -> assistant tool_use -> tool_result -> assistant text,确认 id、name、input、result 完整。
• 文本加工具混合输出:assistant 同时输出 text block 和 tool_use block,确认 text 不丢失,工具仍执行。
• 多工具并行:一轮返回两个以上 tool_use / tool_calls,确认所有结果聚合回填,顺序稳定。
• 工具错误:工具抛异常、超时、权限拒绝时,Anthropic 输出 is_error: true,OpenAI-compatible 输出 ok:false envelope。
• 未知 id:构造不存在的 tool_use_id / tool_call_id,兼容层应拒绝继续请求模型。
• 重复 id:同一个工具 id 回填两次,应在本地报错而不是发送给模型。
• 缺失结果:pending tool use 未闭合时,禁止进入下一轮 assistant 生成。
• 流式参数:arguments 分多片到达,结束后 JSON parse 和 schema validate 成功。
• 非法 JSON 参数:模型输出半截 JSON 或类型错误,兼容层返回结构化工具调用错误,而不是执行工具。
• XML parser:对使用 <tool_call> 或类似模板的模型,确认 parser 能生成同一套 ToolCall IR。
• 历史重放:把 20 轮工具调用历史从 Anthropic 转 OpenAI-compatible,再转回 Anthropic,确认状态机仍闭合。
• 注入防护:工具结果中包含“忽略系统提示”等文本时,模型不应把它当作用户新指令执行。
• provider 差异:DeepSeek、Qwen 以及其他被接入后端应各自跑一套 golden transcript,不只测单轮 happy path。
结论
Anthropic tool_use / tool_result 到 OpenAI-compatible function calling 的转换,本质是两个协议状态机之间的映射。字段改名只是最表层:input_schema 到 parameters、tool_use.input 到 function.arguments、tool_use.id 到 tool_call_id 都可以机械完成;真正决定稳定性的,是消息顺序、id 配对、错误语义、停止原因和 content block 生命周期。
对 Claude Code 迁移到 DeepSeek、Qwen 等非 Anthropic 模型来说,最稳的实现路径是引入内部 ToolCallIR / ToolResultIR,用 pending ledger 管住闭合状态,用 provider adapter 负责解析和渲染差异。只要状态机正确,模型差异可以局部适配;如果状态机错误,再强的模型也会表现成“不会用工具”。