从工程角度看,Claude Code这类Agent的核心并非普通文本生成,而是围绕工具调用构建的严格状态机。在Anthropic Messages API中,工具调用以tool_usecontent block嵌入assistant消息,工具执行结果必须作为下一条user消息的tool_resultblock回填,通过tool_use.id与tool_result.tool_use_id配对。这与OpenAI-compatible function calling的设计截然不同:后者将工具请求放在assistant消息的tool_calls字段,工具结果则分配给独立role: "tool"消息。
当需要将Claude Code的工具调用协议映射到DeepSeek、Qwen等非Anthropic模型或兼容网关时,最大风险并非JSON字段名差异,而是状态机不对齐。消息角色、content block顺序、工具ID生命周期、错误语义、并行调用、停止原因和流式增量都必须整体转换。仅做input_schema到parameters的字段重命名,很容易在多轮工具调用中触发400错误、丢失工具结果、重复执行工具,或让模型把工具错误当成普通用户文本继续推理。
核心挑战:四个必须回答的问题
任何跨模型Tool Use兼容层都需要解决以下关键议题:
- 工具定义如何转换:Anthropic的
tools[].input_schema如何映射为OpenAI-compatible的tools[].function.parameters。 - 工具调用如何表达:assistant content中的
tool_useblock如何转换为assistant消息的tool_calls。 - 工具结果如何回填:Anthropic要求
tool_result放在下一条user message content数组中;OpenAI-compatible通常要求放在role: "tool"消息内。 - 状态如何闭合:每个
tool_use.id必须精确匹配一个工具结果,错误结果需保留机器可读状态,且下一轮模型请求必须能识别完整闭合后的历史。
这四个环节缺一不可。许多兼容层失败,不是因为模型不会调用工具,而是因为代理层将Anthropic的content block协议误判为普通聊天协议,导致工具调用链在历史中断裂。
协议差异的本质:从content block到消息角色
| 对比维度 | Anthropic Messages API | OpenAI-compatible function calling | 兼容层关键关注点 |
|---|---|---|---|
| 工具定义位置 | 请求级tools数组 | 请求级tools数组 | 字段层级不同,但都位于请求级别 |
| Schema字段名称 | input_schema | function.parameters | 可机械映射,需保留JSON Schema约束 |
| 工具调用载体 | assistant消息的content[]中出现type:"tool_use"block | assistant消息的tool_calls[] | Anthropic为内嵌block;OpenAI-compatible多为消息侧信道 |
| 工具名称 | tool_use.name | tool_calls[].function.name | 名称应保持稳定,避免转换层改动 |
| 工具参数 | tool_use.input为对象 | tool_calls[].function.arguments通常为JSON字符串 | 需要严格的序列化/反序列化 |
| 调用ID | tool_use.id | tool_calls[].id/tool_call_id | ID是配对主键,非展示字段 |
| 工具结果载体 | 下一条user消息的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_useblock | 一个assistant message可包含多个tool_calls | 等待所有结果闭合后再进入下一轮模型调用 |
| 顺序约束 | tool_result必须紧跟对应工具调用后的下一条用户消息,位于content数组前部 | 工具消息一般紧跟assistant tool call消息 | 顺序校验需按源协议和目标协议分别进行 |
从实现角度看,Anthropic更像“content block状态机”:文本、工具调用和工具结果都在消息content数组里按block排列。OpenAI-compatible则更像是“消息角色状态机”:assistant请求工具,随后若干tool role消息返回结果,再由assistant继续生成。
Anthropic的状态机模型
Anthropic的核心约束可抽象为以下状态:
状态 S0: assistant生成中 - 输出 text block → 停留在 S0 - 输出 tool_use block → 进入 S1 - stop_reason=end_turn → 本轮结束 状态 S1: 等待工具执行 - 收集一个或多个 tool_use block - stop_reason=tool_use - 应用层执行所有工具 - 进入 S2 状态 S2: 等待工具结果回填 - 下一条消息必须是 user - user.content 开头必须包含对应 tool_result block - 每个 tool_result.tool_use_id 必须匹配一个未闭合 tool_use.id - 所有 pending tool_use 闭合后进入 S3 状态 S3: 继续生成 - 将闭合后的历史发送给模型 - 模型基于结果继续 text 或再次 tool_use三个容易被忽略的细节:
tool_use.id是状态机主键,而非可选调试信息。如果目标模型不返回 id,兼容层需要生成内部 id,且该 id 必须贯穿 assistant tool call、工具执行记录、tool result 回填和后续历史。tool_result的位置本身就是协议语义。工具结果必须紧跟对应工具调用,并作为 user message content 数组中的 tool result block。将结果塞入普通文本,或在 tool result 前插入用户解释,都可能破坏模型对上一轮工具调用的绑定。stop_reason:"tool_use"是控制信号,表示“现在该执行工具”,不是普通完成状态。忽略该信号而只检查 assistant 文本,可能会把包含工具调用的响应当作空回复。
字段映射三层拆解
1. 工具定义映射
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、枚举或数组嵌套支持不完整,应在注册阶段降级,而非等模型生成非法参数后再补救。
2. 工具调用映射
Anthropic assistant 消息:
{"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 形式:
{"role":"assistant","content":"我需要先读取配置文件。","tool_calls":[{"id":"toolu_01A","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"package.json\"}"}}]}注意arguments通常是 JSON 字符串,而 Anthropic 的input是已解析对象。兼容层应在工具执行前解析参数并做 schema 校验,同时记录原始字符串以排查流式截断、半个 JSON、重复键、数字精度和编码问题。
3. 工具结果映射
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。先定义内部接口:
ToolCall={id, name, input, rawArguments,source}ToolResult={toolUseId, ok, content, error}AssistantTurn={text[], toolCalls[], stopReason}DeepSeek、Qwen 等后端或网关的 API 表层可能相似,但细节各异:有的在 streaming 中分片输出arguments,有的用 XML 标签,有的对 tool role 顺序更严格。内部 IR 将差异限制在 adapter 内,避免主逻辑依赖厂商格式。
2. 工具注册映射器
注册阶段只做确定性转换:input_schema→parameters,同时生成工具能力表:
ToolCapability={supportsParallelToolUse, supportsStrictJsonSchema, supportsToolRole, requiresXmlParser, requiresReasoningPassthrough}能力表不应靠模型名称字符串散落在代码中判断,而应集中配置。例如同样是 Qwen,不同部署方式可能分别走 OpenAI-compatible JSON、chat template XML 或平台自定义 function calling。
3. 消息转换器
消息转换需按“轮次”处理,而非逐条孤立转换。
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
维护一个待办账本:
PendingToolUse={id, name, inputHash, createdAtTurn, status}模型输出工具调用时登记 pending;工具结果回填时按 id 闭合。下一轮模型请求前必须通过检查:
- 不允许存在无结果的 pending tool use。
- 不允许出现未知
tool_use_id/tool_call_id。 - 不允许同一个 id 被两个结果闭合。
- 并行调用必须全部闭合后才能继续生成。
- 若目标后端不支持并行,应在请求侧禁用并行,或在兼容层串行调度并保留源协议顺序。
5. 错误标准化器
所有 adapter 使用统一错误信封:
{"ok":false,"error":{"type":"CommandFailed","message":"npm test exited with code 1","retryable":true,"metadata":{"exit_code":1}}}映射规则:
| 内部状态 | Anthropic | OpenAI-compatible |
|---|---|---|
| 成功 | is_error省略或 false | {"ok":true,"data":...} |
| 失败 | is_error: true | {"ok":false,"error":...} |
| 超时 | is_error: true,类型 timeout | ok:false,retryable依语义决定 |
| 用户取消 | is_error: true,明确 cancelled | ok:false,避免伪装成空结果 |
最小正确闭环示例
Anthropic 闭环:
对应 OpenAI-compatible 闭环:
转换层必须保证第 2 步和第 3 步相邻,中间不能插入另一轮 assistant 推理。Claude Code 如果调用本地 shell、文件系统、浏览器或 MCP 服务,这些执行细节应记录在 agent 内部日志中,而非插入模型消息破坏协议闭环。
面向 DeepSeek、Qwen 等后端的适配策略
这些模型常见的接入方式是 OpenAI-compatible API,但“兼容”通常只意味着顶层 HTTP 路径和部分字段相似,不意味着工具调用状态机完全一致。设计 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/errorenvelope |
| 是否有额外 reasoning 字段 | thinking 模型要求回放 reasoning 状态 | adapter 单独保留并回传 |
| 是否使用 XML 工具格式 | Qwen 等模型可能输出<tool_call> | parser 输出统一 ToolCall IR |
因此,稳健的兼容层不应写成:
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错误信封 |
| 并行工具只回填一部分,模型丢上下文或重复调用 | 未等待所有 tool_use 闭合 | 并行调用使用 barrier |
| streaming JSON 解析失败,参数半截括号不闭合 | 边收边 parsearguments | 按 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:falseenvelope。 - 未知 id:构造不存在的
tool_use_id/tool_call_id,兼容层应拒绝继续请求模型。 - 重复 id:同一个 id 回填两次,应在本地报错而非发送给模型。
- 缺失结果:pending tool use 未闭合时,禁止进入下一轮生成。
- 流式参数:
arguments分多片到达,结束后 JSON parse 和 schema validate 成功。 - 非法 JSON 参数:模型输出半截 JSON 或类型错误,兼容层返回结构化工具调用错误,不执行工具。
- XML parser:对使用
<tool_call>模板的模型,确认 parser 能生成同一套 ToolCall IR。 - 历史重放:20 轮工具调用历史从 Anthropic 转 OpenAI-compatible 再转回,状态机仍闭合。
- 注入防护:工具结果含“忽略系统提示”等文本时,模型不应当作新指令执行。
- provider 差异:DeepSeek、Qwen 及其他后端各自运行 golden transcript,不只测单轮 happy path。
总结
Anthropictool_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 负责解析和渲染差异。只要状态机正确,模型差异可以局部适配;如果状态机错误,再强的模型也会表现成“不会用工具”。