摘要

Claude Code 这类 agent 不是把模型输出当成一段普通文本处理,而是把模型、工具执行器和用户消息组织成一条严格的执行日志。在 Anthropic Messages API 中,工具调用以 assistant 消息里的 tool_use content block 表示;工具执行结果必须作为下一条 user 消息里的 tool_result content block 回填,并通过 tool_use.idtool_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_schemaparameters 的 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:falseretryable 按工具语义决定
用户取消 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_usetool_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_schemaparameterstool_use.inputfunction.argumentstool_use.idtool_call_id 都可以机械完成;真正决定稳定性的,是消息顺序、id 配对、错误语义、停止原因和 content block 生命周期。

对 Claude Code 迁移到 DeepSeek、Qwen 等非 Anthropic 模型来说,最稳的实现路径是引入内部 ToolCallIR / ToolResultIR,用 pending ledger 管住闭合状态,用 provider adapter 负责解析和渲染差异。只要状态机正确,模型差异可以局部适配;如果状态机错误,再强的模型也会表现成“不会用工具”。