在这里插入图片描述

摘要

在构建 AI Agent 框架时,我们经常会遇到一个工程问题:不同大模型厂商虽然都支持 Function Calling / Tool Use,但它们的消息格式并不一致。

例如 OpenAI、OpenRouter、vLLM 等生态通常采用一种接近 OpenAI Chat Completions 的工具调用格式,我们可以称之为 OpenAI-wire;而 Claude / Anthropic 则采用另一套基于 content blocks 的工具调用格式,我们可以称之为 Anthropic-wire

这两种格式的核心差异包括:

  • 工具调用放置位置不同;
  • 工具参数格式不同;
  • 工具执行结果的消息 role 不同;
  • 工具调用与工具结果的关联字段不同;
  • 多工具并行调用的表达方式不同;
  • 消息 content 的组织方式不同。

对于 Agent 框架来说,一个非常重要的设计原则是:不要让核心 Agent 循环直接依赖某一个模型厂商的协议格式

以 Hermes 这类 Agent 框架为例,它通常会在内部统一使用一种格式,比如 OpenAI-wire,然后在调用 Anthropic API 时,在适配层将 OpenAI-wire 转换成 Anthropic-wire。这样做可以让 Agent 主循环只维护一套逻辑,避免 provider 差异扩散到整个系统。

本文将详细介绍 OpenAI-wire 与 Anthropic-wire 的差异,并结合 Agent 框架的设计,讲清楚为什么需要做统一中间表示,以及如何实现格式转换。


1. 什么是 wire format?

在讨论 OpenAI-wire 和 Anthropic-wire 之前,首先要理解一个概念:wire format

所谓 wire format,可以理解为:

LLM API 在网络传输时使用的 JSON 消息格式。

也就是说,当我们的程序调用大模型时,最终都会把上下文消息、工具定义、工具调用结果等内容组织成一个 JSON 请求体,然后通过 HTTP API 发送给模型服务。

例如:

Agent 代码
   ↓
内部统一消息结构
   ↓
转换成不同模型厂商要求的 JSON
   ↓
发送给 OpenAI / Anthropic / OpenRouter / vLLM
   ↓
解析模型返回结果
   ↓
继续执行工具或返回最终答案

不同模型厂商虽然都支持“让模型调用工具”,但它们对消息格式的要求不同。

同样是让模型调用一个 read_file 工具:

  • OpenAI 风格会把工具调用放到 assistant.tool_calls 字段中;
  • Anthropic 风格会把工具调用放到 assistant.content 数组中的 tool_use block 里。

所以,Agent 框架要解决的不是简单的“工具怎么执行”,而是:

如何让同一个 Agent 工具调用循环,兼容不同 LLM provider 的通信协议。


2. Function Calling 在 Agent 中的基本流程

在 Agent 系统中,Function Calling / Tool Use 的基本流程通常是这样的:

用户提出任务
   ↓
模型判断是否需要调用工具
   ↓
模型返回工具调用请求
   ↓
程序解析工具名和参数
   ↓
程序执行本地工具 / 外部 API
   ↓
将工具结果返回给模型
   ↓
模型基于工具结果继续推理
   ↓
返回最终答案,或者继续调用工具

例如用户说:

读取 README.md 文件

模型可能不会直接回答,而是输出一个工具调用:

调用 read_file 工具,参数为 {"path": "README.md"}

程序收到之后执行:

read_file(path="README.md")

然后把文件内容再发回给模型,让模型继续总结或回答。

这个过程听起来简单,但一旦接入多个模型厂商,就会遇到消息格式差异。


3. OpenAI-wire 的工具调用格式

OpenAI-wire 是目前很多 Agent 框架和开源模型服务采用的格式。

OpenAI、OpenRouter,以及很多 OpenAI-compatible API,包括部分 vLLM 服务,都会采用类似结构。

一个典型请求如下:

{
  "model": "gpt-4",
  "messages": [
    {
      "role": "user",
      "content": "读取 README.md"
    },
    {
      "role": "assistant",
      "content": null,
      "tool_calls": [
        {
          "id": "call_1",
          "type": "function",
          "function": {
            "name": "read_file",
            "arguments": "{\"path\": \"README.md\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_1",
      "content": "# Hermes Agent..."
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read_file",
        "description": "Read a file",
        "parameters": {
          "type": "object",
          "properties": {
            "path": {
              "type": "string"
            }
          }
        }
      }
    }
  ]
}

3.1 OpenAI-wire 的核心特点

OpenAI-wire 有几个非常重要的特点。

第一,工具调用放在 assistant.tool_calls

模型如果想调用工具,会返回一条 assistant 消息:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_1",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\": \"README.md\"}"
      }
    }
  ]
}

也就是说,工具调用不是放在 content 文本里,而是放在结构化字段 tool_calls 中。


第二,tool_calls 是数组

因为 tool_calls 是数组,所以模型一次可以返回多个工具调用。

例如:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_1",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\": \"README.md\"}"
      }
    },
    {
      "id": "call_2",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\": \"package.json\"}"
      }
    }
  ]
}

这意味着模型可以并行请求多个工具调用。

Agent 框架在处理时,需要遍历 tool_calls

for tool_call in message["tool_calls"]:
    name = tool_call["function"]["name"]
    args = json.loads(tool_call["function"]["arguments"])
    result = execute_tool(name, args)

第三,arguments 是 JSON 字符串

这是 OpenAI-wire 中一个非常容易踩坑的地方。

工具参数不是对象,而是一个 JSON 字符串:

"arguments": "{\"path\": \"README.md\"}"

所以程序执行工具前,需要先做反序列化:

import json

arguments_str = tool_call["function"]["arguments"]
arguments = json.loads(arguments_str)

也就是说:

OpenAI-wire:
arguments 是字符串,需要 json.loads

而不是:

arguments = tool_call["function"]["arguments"]

如果直接把字符串当对象使用,就会报错。


第四,工具执行结果使用独立的 tool role

OpenAI-wire 中,工具执行结果会以一条独立消息追加回上下文:

{
  "role": "tool",
  "tool_call_id": "call_1",
  "content": "# Hermes Agent..."
}

其中 tool_call_id 非常重要,它用于关联:

哪个工具调用请求
对应
哪个工具执行结果

完整链路如下:

user:
读取 README.md

assistant:
tool_calls = [
  read_file({"path": "README.md"})
]

程序执行工具:
read_file("README.md")

tool:
tool_call_id = call_1
content = "# Hermes Agent..."

assistant:
根据 README.md 的内容继续回答

4. Anthropic-wire 的工具调用格式

Anthropic-wire 是 Claude 使用的工具调用格式。

它和 OpenAI-wire 的最大不同在于:Claude 的消息内容采用 content blocks 结构。

Claude 不会把工具调用放到 assistant.tool_calls 字段,而是放在 assistant.content 数组中。

一个典型请求如下:

{
  "model": "claude-xxx",
  "messages": [
    {
      "role": "user",
      "content": "读取 README.md"
    },
    {
      "role": "assistant",
      "content": [
        {
          "type": "tool_use",
          "id": "toolu_1",
          "name": "read_file",
          "input": {
            "path": "README.md"
          }
        }
      ]
    },
    {
      "role": "user",
      "content": [
        {
          "type": "tool_result",
          "tool_use_id": "toolu_1",
          "content": "# Hermes Agent..."
        }
      ]
    }
  ],
  "tools": [
    {
      "name": "read_file",
      "description": "Read a file",
      "input_schema": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string"
          }
        }
      }
    }
  ]
}

4.1 Anthropic-wire 的核心特点

第一,工具调用放在 assistant.content 数组中

Claude 的 assistant 消息可能是这样的:

{
  "role": "assistant",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_1",
      "name": "read_file",
      "input": {
        "path": "README.md"
      }
    }
  ]
}

这里的工具调用是一个 tool_use block。

结构如下:

{
  "type": "tool_use",
  "id": "toolu_1",
  "name": "read_file",
  "input": {
    "path": "README.md"
  }
}

第二,input 是对象,不是 JSON 字符串

这是 Anthropic-wire 和 OpenAI-wire 的关键区别之一。

Claude 工具调用参数是:

"input": {
  "path": "README.md"
}

而不是:

"arguments": "{\"path\": \"README.md\"}"

因此,解析 Claude 的工具参数时不需要 json.loads

args = block["input"]

对比一下:

# OpenAI-wire
args = json.loads(tool_call["function"]["arguments"])

# Anthropic-wire
args = block["input"]

第三,工具执行结果放在 user role 中

这也是一个容易让人疑惑的地方。

在 OpenAI-wire 中,工具结果是:

{
  "role": "tool",
  "tool_call_id": "call_1",
  "content": "工具执行结果"
}

但是在 Anthropic-wire 中,工具结果是:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_1",
      "content": "工具执行结果"
    }
  ]
}

也就是说:

OpenAI-wire: 工具结果使用 role = tool
Anthropic-wire: 工具结果使用 role = user

这并不是说工具结果真的是用户输入,而是 Anthropic 的消息协议把“客户端提供给模型的工具结果”归在 user 消息中。


第四,content 可以混合文本和工具调用

Claude 的 content 是一个数组,里面可以同时包含文本和工具调用。

例如:

{
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "我先读取 README 文件。"
    },
    {
      "type": "tool_use",
      "id": "toolu_1",
      "name": "read_file",
      "input": {
        "path": "README.md"
      }
    }
  ]
}

所以解析 Claude 返回结果时,不能简单认为 content[0] 一定是工具调用。

更稳妥的写法是:

for block in response["content"]:
    if block["type"] == "text":
        collect_text(block["text"])

    elif block["type"] == "tool_use":
        collect_tool_call(block)

5. OpenAI-wire 与 Anthropic-wire 对比表

下面用一张表总结两种格式的差异:

对比项 OpenAI-wire Anthropic-wire
工具定义字段 tools[].type = function,工具信息放在 function 直接使用 tools[].namedescriptioninput_schema
工具参数 Schema function.parameters input_schema
工具调用位置 assistant.tool_calls assistant.content[] 中的 tool_use block
工具调用参数 function.arguments input
参数格式 JSON 字符串 JSON 对象
是否需要 parse 需要 json.loads(arguments) 不需要
工具结果 role role: "tool" role: "user"
工具结果关联字段 tool_call_id tool_use_id
多工具调用 tool_calls 数组 多个 tool_use block
文本和工具调用关系 contenttool_calls 分开 content 数组可混合 texttool_use
解析重点 解析 tool_calls 遍历 content blocks

6. 为什么 Agent 框架需要统一内部格式?

如果我们只接入一个模型厂商,其实可以直接按照该厂商的协议写代码。

但是 Agent 框架一般不会只支持一个模型。

常见的接入对象可能包括:

OpenAI
Anthropic Claude
OpenRouter
vLLM
Ollama
Gemini
Qwen
DeepSeek
本地私有化模型服务

如果每个 provider 的格式都直接写进 Agent 主循环,代码会变得非常混乱。

例如:

if provider == "openai":
    # 解析 assistant.tool_calls
    # 工具结果用 role=tool
elif provider == "anthropic":
    # 遍历 assistant.content
    # 工具结果用 role=user + tool_result
elif provider == "gemini":
    # 又是另一套格式
elif provider == "qwen":
    # 可能还有特殊格式

这样的问题是:

  1. Agent 主循环越来越复杂;
  2. 每新增一个模型 provider,都要修改核心逻辑;
  3. 工具执行逻辑和 provider 协议耦合;
  4. 多工具调用、错误处理、消息历史管理变得难维护;
  5. 后期调试困难。

所以更好的设计是:

Agent Core 内部统一使用一种标准格式,Provider Adapter 负责做格式转换。


7. Hermes 的适配思路

以 Hermes 这类 Agent 框架为例,它的设计思路可以理解为:

内部统一使用 OpenAI-wire
调用 OpenAI / OpenRouter / vLLM 时:基本原样发送
调用 Anthropic / Claude 时:转换为 Anthropic-wire
拿到 Anthropic 返回后:再转换回 OpenAI-wire

也就是说:

Agent Core 只认识 OpenAI-wire
Provider Adapter 负责翻译不同模型厂商的格式

架构可以抽象成:

┌──────────────────────────────┐
│ Agent Core                    │
│                              │
│ - 维护 messages               │
│ - 判断 tool_calls             │
│ - 执行工具                    │
│ - 回填工具结果                │
│                              │
│ 内部统一使用 OpenAI-wire       │
└───────────────┬──────────────┘
                │
                ▼
┌──────────────────────────────┐
│ Provider Adapter              │
│                              │
│ - OpenAI: 原样发送             │
│ - Anthropic: 转换成 Claude 格式│
│ - Gemini: 转换成 Gemini 格式   │
│ - 其他模型: 转换成对应格式     │
└───────────────┬──────────────┘
                │
                ▼
┌──────────────────────────────┐
│ LLM Provider                  │
│                              │
│ OpenAI / Claude / OpenRouter  │
│ vLLM / Ollama / Gemini 等      │
└──────────────────────────────┘

这本质上是一个典型的 Adapter Pattern,适配器模式


8. Agent Core 的核心循环

Agent 的主循环大致可以写成这样:

def run_agent(user_input, tools):
    messages = [
        {
            "role": "user",
            "content": user_input
        }
    ]

    while True:
        response = call_model(messages, tools)

        # 如果模型没有请求工具调用,说明可以直接返回最终答案
        if not has_tool_calls(response):
            return response["content"]

        # 如果模型请求了工具调用,则执行工具
        for tool_call in response["tool_calls"]:
            tool_call_id = tool_call["id"]
            tool_name = tool_call["function"]["name"]
            tool_args = json.loads(tool_call["function"]["arguments"])

            result = execute_tool(tool_name, tool_args)

            messages.append({
                "role": "tool",
                "tool_call_id": tool_call_id,
                "content": result
            })

注意,这段主循环假设所有模型返回的都是 OpenAI-wire:

response["tool_calls"]
tool_call["function"]["name"]
tool_call["function"]["arguments"]
role = "tool"
tool_call_id = xxx

如果底层是 OpenAI-compatible provider,那么可以直接处理。

如果底层是 Anthropic,则需要在 _call_anthropic() 里做格式转换,让 Agent Core 仍然看到 OpenAI-wire。


9. OpenAI-wire 转 Anthropic-wire

下面看一下核心转换逻辑。

9.1 转换 assistant tool_calls

Hermes 内部的 OpenAI-wire 消息可能是:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_1",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\":\"README.md\"}"
      }
    }
  ]
}

发送给 Claude 前,需要转换成:

{
  "role": "assistant",
  "content": [
    {
      "type": "tool_use",
      "id": "call_1",
      "name": "read_file",
      "input": {
        "path": "README.md"
      }
    }
  ]
}

对应转换代码:

import json

def openai_assistant_to_anthropic(message):
    content = []

    # 如果原 assistant 消息里有普通文本,也需要保留
    if message.get("content"):
        content.append({
            "type": "text",
            "text": message["content"]
        })

    for tool_call in message.get("tool_calls", []):
        content.append({
            "type": "tool_use",
            "id": tool_call["id"],
            "name": tool_call["function"]["name"],
            "input": json.loads(tool_call["function"]["arguments"])
        })

    return {
        "role": "assistant",
        "content": content
    }

核心映射关系:

OpenAI tool_calls[].id
    → Anthropic content[].id

OpenAI tool_calls[].function.name
    → Anthropic content[].name

OpenAI tool_calls[].function.arguments
    → json.loads(...)
    → Anthropic content[].input

9.2 转换 tool result

Hermes 内部的 OpenAI-wire 工具结果是:

{
  "role": "tool",
  "tool_call_id": "call_1",
  "content": "# Hermes Agent..."
}

发送给 Claude 前,需要转成:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "call_1",
      "content": "# Hermes Agent..."
    }
  ]
}

对应转换代码:

def openai_tool_result_to_anthropic(message):
    return {
        "role": "user",
        "content": [
            {
                "type": "tool_result",
                "tool_use_id": message["tool_call_id"],
                "content": message["content"]
            }
        ]
    }

核心映射关系:

OpenAI role = tool
    → Anthropic role = user

OpenAI tool_call_id
    → Anthropic tool_use_id

OpenAI content
    → Anthropic tool_result.content

9.3 转换 tools 定义

OpenAI-wire 的工具定义是:

{
  "type": "function",
  "function": {
    "name": "read_file",
    "description": "Read a file",
    "parameters": {
      "type": "object",
      "properties": {
        "path": {
          "type": "string"
        }
      }
    }
  }
}

Anthropic-wire 的工具定义是:

{
  "name": "read_file",
  "description": "Read a file",
  "input_schema": {
    "type": "object",
    "properties": {
      "path": {
        "type": "string"
      }
    }
  }
}

转换代码:

def openai_tool_to_anthropic_tool(tool):
    function = tool["function"]

    return {
        "name": function["name"],
        "description": function.get("description", ""),
        "input_schema": function["parameters"]
    }

映射关系:

OpenAI function.name
    → Anthropic name

OpenAI function.description
    → Anthropic description

OpenAI function.parameters
    → Anthropic input_schema

10. Anthropic-wire 转 OpenAI-wire

当 Claude 返回工具调用时,Hermes 还需要把 Anthropic-wire 转回内部 OpenAI-wire。

Claude 可能返回:

{
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "我需要先读取 README 文件。"
    },
    {
      "type": "tool_use",
      "id": "toolu_1",
      "name": "read_file",
      "input": {
        "path": "README.md"
      }
    }
  ]
}

Hermes 内部希望看到:

{
  "role": "assistant",
  "content": "我需要先读取 README 文件。",
  "tool_calls": [
    {
      "id": "toolu_1",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\":\"README.md\"}"
      }
    }
  ]
}

转换代码如下:

import json

def anthropic_assistant_to_openai(message):
    texts = []
    tool_calls = []

    for block in message.get("content", []):
        if block["type"] == "text":
            texts.append(block["text"])

        elif block["type"] == "tool_use":
            tool_calls.append({
                "id": block["id"],
                "type": "function",
                "function": {
                    "name": block["name"],
                    "arguments": json.dumps(block["input"], ensure_ascii=False)
                }
            })

    openai_message = {
        "role": "assistant",
        "content": "\n".join(texts) if texts else None
    }

    if tool_calls:
        openai_message["tool_calls"] = tool_calls

    return openai_message

这里的关键点是:

Anthropic input 对象
    → json.dumps(...)
    → OpenAI arguments 字符串

11. 一个完整的 Provider Adapter 示例

下面给出一个简化版的适配器设计。

class ProviderAdapter:
    def call(self, messages, tools):
        raise NotImplementedError


class OpenAIAdapter(ProviderAdapter):
    def call(self, messages, tools):
        # OpenAI-wire 内部格式可以基本原样发送
        return openai_client.chat.completions.create(
            model="gpt-4",
            messages=messages,
            tools=tools
        )


class AnthropicAdapter(ProviderAdapter):
    def call(self, messages, tools):
        anthropic_messages = self.convert_messages_to_anthropic(messages)
        anthropic_tools = self.convert_tools_to_anthropic(tools)

        response = anthropic_client.messages.create(
            model="claude-xxx",
            messages=anthropic_messages,
            tools=anthropic_tools
        )

        return self.convert_response_to_openai(response)

    def convert_messages_to_anthropic(self, messages):
        result = []

        for message in messages:
            role = message["role"]

            if role == "tool":
                result.append({
                    "role": "user",
                    "content": [
                        {
                            "type": "tool_result",
                            "tool_use_id": message["tool_call_id"],
                            "content": message["content"]
                        }
                    ]
                })

            elif role == "assistant" and message.get("tool_calls"):
                content = []

                if message.get("content"):
                    content.append({
                        "type": "text",
                        "text": message["content"]
                    })

                for tool_call in message["tool_calls"]:
                    content.append({
                        "type": "tool_use",
                        "id": tool_call["id"],
                        "name": tool_call["function"]["name"],
                        "input": json.loads(tool_call["function"]["arguments"])
                    })

                result.append({
                    "role": "assistant",
                    "content": content
                })

            else:
                result.append({
                    "role": role,
                    "content": message["content"]
                })

        return result

    def convert_tools_to_anthropic(self, tools):
        result = []

        for tool in tools:
            function = tool["function"]
            result.append({
                "name": function["name"],
                "description": function.get("description", ""),
                "input_schema": function["parameters"]
            })

        return result

    def convert_response_to_openai(self, response):
        texts = []
        tool_calls = []

        for block in response.content:
            if block.type == "text":
                texts.append(block.text)

            elif block.type == "tool_use":
                tool_calls.append({
                    "id": block.id,
                    "type": "function",
                    "function": {
                        "name": block.name,
                        "arguments": json.dumps(block.input, ensure_ascii=False)
                    }
                })

        message = {
            "role": "assistant",
            "content": "\n".join(texts) if texts else None
        }

        if tool_calls:
            message["tool_calls"] = tool_calls

        return message

这个例子只是用于说明思路,真实项目中还需要处理:

  • streaming;
  • error handling;
  • tool result 顺序;
  • 多工具并行调用;
  • content block 中的图片、文件等多模态内容;
  • token usage;
  • stop reason;
  • provider-specific 参数;
  • 工具调用失败后的错误消息;
  • JSON 参数解析失败的 fallback 策略。

12. 为什么选择 OpenAI-wire 作为内部统一格式?

Hermes 选择 OpenAI-wire 作为内部统一格式,通常有几个原因。

12.1 生态兼容性更强

很多模型服务都提供 OpenAI-compatible API,例如:

OpenRouter
vLLM
Ollama 的部分 OpenAI-compatible 接口
DeepSeek API
Qwen API 的兼容接口
一些企业内部私有化模型网关

因此,如果内部使用 OpenAI-wire,接入大量模型服务会更方便。


12.2 Agent 主循环更简单

Agent Core 只需要处理一种格式:

assistant_message["tool_calls"]
tool_call["function"]["name"]
tool_call["function"]["arguments"]
role = "tool"
tool_call_id = xxx

不用在主循环里写大量 provider 分支。


12.3 后续扩展更容易

如果将来要支持 Gemini,只需要增加:

OpenAI-wire ↔ Gemini-wire

如果要支持 Qwen 原生格式,只需要增加:

OpenAI-wire ↔ Qwen-wire

如果要支持其他私有模型网关,只需要增加:

OpenAI-wire ↔ PrivateModel-wire

Agent Core 不需要改。

这就是统一中间表示的价值。


13. 典型踩坑点

13.1 OpenAI 的 arguments 是字符串

错误写法:

args = tool_call["function"]["arguments"]
execute_tool(name, args)

这样拿到的是字符串:

"{\"path\": \"README.md\"}"

正确写法:

args = json.loads(tool_call["function"]["arguments"])
execute_tool(name, args)

13.2 Anthropic 的工具结果不是 role=tool

很多人第一次接 Claude tool use 的时候,会下意识这么写:

{
  "role": "tool",
  "tool_call_id": "toolu_1",
  "content": "工具结果"
}

这是 OpenAI-wire 的写法,不适用于 Anthropic-wire。

Anthropic-wire 应该写成:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_1",
      "content": "工具结果"
    }
  ]
}

13.3 Claude 的 content 可能同时包含 text 和 tool_use

不能只取第一个 content block:

block = response["content"][0]

因为第一个 block 可能是文本:

{
  "type": "text",
  "text": "我需要先读取文件。"
}

正确方式是遍历:

for block in response["content"]:
    if block["type"] == "text":
        handle_text(block)

    elif block["type"] == "tool_use":
        handle_tool_use(block)

13.4 多工具调用必须用 id 关联结果

不要用工具名关联工具结果。

错误思路:

read_file → 工具结果

因为同一个工具可能被调用多次:

read_file("README.md")
read_file("package.json")
read_file("src/main.py")

必须使用 id:

OpenAI: tool_call_id
Anthropic: tool_use_id

例如:

call_1 → read_file("README.md")
call_2 → read_file("package.json")
call_3 → read_file("src/main.py")

工具结果必须分别对应:

tool_call_id = call_1
tool_call_id = call_2
tool_call_id = call_3

13.5 Anthropic 的 tool_result 顺序要注意

Claude 对工具结果的顺序比较敏感。

一般来说,模型返回 tool_use 后,下一条用户消息中应该尽快提供对应的 tool_result

推荐结构:

{
  "role": "assistant",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_1",
      "name": "read_file",
      "input": {
        "path": "README.md"
      }
    }
  ]
},
{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_1",
      "content": "文件内容"
    }
  ]
}

不要在工具调用和工具结果之间插入无关消息。


14. 对 Agent 框架设计的启发

OpenAI-wire 和 Anthropic-wire 的差异,本质上反映了一个 Agent 框架设计问题:

框架内部到底应该直接使用某个 provider 的格式,还是定义自己的统一中间格式?

对于一个长期维护的 Agent 框架来说,推荐做法是:

内部统一格式
外部适配转换
核心逻辑不依赖 provider

也就是:

Agent Core
    ↓
Canonical Message Format
    ↓
Provider Adapter
    ↓
LLM Provider

这种设计的好处是:

  1. 主循环简单;
  2. 工具执行逻辑统一;
  3. 消息历史管理统一;
  4. provider 扩展方便;
  5. 测试更容易;
  6. 不同模型之间切换成本低;
  7. 适合构建企业级 Agent 平台。

15. 总结

OpenAI-wire 和 Anthropic-wire 都是在表达同一件事:

模型需要调用工具,程序执行工具,然后把工具结果返回给模型。

但是它们的表达方式不同。

OpenAI-wire 的特点是:

assistant.tool_calls
function.arguments 是 JSON 字符串
工具结果使用 role=tool
通过 tool_call_id 关联工具结果

Anthropic-wire 的特点是:

assistant.content 中包含 tool_use block
input 是 JSON 对象
工具结果使用 role=user
通过 tool_use_id 关联工具结果
content 可以混合 text 和 tool_use

对于 Hermes 这类 Agent 框架来说,最合理的设计是:

内部统一使用 OpenAI-wire
调用 Anthropic 时转换成 Anthropic-wire
拿到 Anthropic 返回后再转换回 OpenAI-wire

这样 Agent Core 只需要维护一套逻辑。

最终可以把整个设计总结成一句话:

OpenAI-wire 是内部统一语言,Anthropic-wire 是 Claude 的外部方言,Provider Adapter 是两者之间的翻译器。

这个设计看起来只是消息格式转换,但对 Agent 框架的可维护性、可扩展性和多模型兼容能力非常关键。对于需要支持多模型、多工具、多轮工具调用的企业级 Agent 系统来说,统一 wire format 几乎是必须要考虑的基础架构设计。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐