OpenAI Tools vs Anthropic Tools:两种Function Call协议的差异
OpenAI Tools vs Anthropic Tools:两种Function Call协议的差异
适读人群:需要同时对接多个LLM厂商的Java后端工程师 | 阅读时长:约16分钟
开篇故事
我们项目最初用的是OpenAI,后来老板说要接入国内的模型,同时也想试试Claude(因为某些任务Claude表现更好)。这就涉及到了多模型适配问题。
最开始我以为Function Call的协议是统一的,直接换个API Key就行了。结果发现OpenAI和Anthropic的接口格式差异相当大:工具定义的JSON结构不同,工具调用结果的传递方式不同,甚至消息的角色(role)定义也有差异。
花了好几天,把两套协议彻底搞清楚,然后抽象出了一个适配层,才实现了业务代码零修改切换不同LLM。
今天把这两套协议的差异和适配方案完整讲清楚。
一、协议对比总览
二、协议详细差异
2.1 工具定义的差异
OpenAI格式:
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取城市天气",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名"}
},
"required": ["location"]
}
}
}
],
"tool_choice": "auto"
}Anthropic格式:
{
"tools": [
{
"name": "get_weather",
"description": "获取城市天气",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名"}
},
"required": ["location"]
}
}
]
}关键差异:
- OpenAI:
tools[].function.name,Anthropic:tools[].name(少一层function包装) - OpenAI:
parameters,Anthropic:input_schema(字段名不同)
2.2 工具调用响应的差异
OpenAI响应:
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"北京\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
}Anthropic响应:
{
"content": [
{
"type": "text",
"text": "我来帮你查询天气。"
},
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9f",
"name": "get_weather",
"input": {
"location": "北京"
}
}
],
"stop_reason": "tool_use"
}关键差异:
- OpenAI:
tool_calls数组在message字段,arguments是字符串 - Anthropic:
tool_use块在content数组中,input是已解析的JSON对象 - OpenAI:
finish_reason = "tool_calls",Anthropic:stop_reason = "tool_use"
2.3 工具结果传递的差异
OpenAI方式(新增一条role=tool的消息):
{
"messages": [
{"role": "user", "content": "北京天气"},
{
"role": "assistant",
"content": null,
"tool_calls": [{"id": "call_abc123", ...}]
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temperature\": 22, \"description\": \"晴\"}"
}
]
}Anthropic方式(在user消息的content数组中添加tool_result块):
{
"messages": [
{"role": "user", "content": "北京天气"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "我来查询..."},
{"type": "tool_use", "id": "toolu_xxx", "name": "get_weather", ...}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_xxx",
"content": "{\"temperature\": 22, \"description\": \"晴\"}"
}
]
}
]
}三、完整代码示例:统一适配层实现
3.1 抽象工具调用接口
// 统一的工具定义
public record ToolDefinition(
String name,
String description,
JsonNode inputSchema
) {}
// 统一的工具调用请求
public record ToolCallRequest(
String callId,
String toolName,
JsonNode arguments // 已解析的JSON对象
) {}
// 统一的LLM响应
public record LLMResponse(
String textContent, // 文本内容
List<ToolCallRequest> toolCalls, // 工具调用请求(可能为空)
boolean isFinished // 是否结束对话
) {}3.2 OpenAI适配器
@Component
public class OpenAiAdapter implements LLMAdapter {
@Autowired
private OpenAIClient openAIClient;
@Autowired
private ObjectMapper objectMapper;
@Override
public LLMResponse chat(ConversationContext context) {
// 构建messages
List<ChatCompletionMessageParam> messages = buildMessages(context);
// 构建tools
List<ChatCompletionTool> tools = context.getTools().stream()
.map(this::toOpenAiTool)
.collect(toList());
// 发送请求
ChatCompletion response = openAIClient.chat().completions().create(
ChatCompletionCreateParams.builder()
.model("gpt-4o")
.messages(messages)
.tools(tools.isEmpty() ? null : tools)
.build()
);
ChatCompletionMessage message = response.choices().get(0).message();
FinishReason finishReason = response.choices().get(0).finishReason();
if (finishReason == FinishReason.TOOL_CALLS) {
// 解析tool_calls
List<ToolCallRequest> toolCalls = message.toolCalls()
.orElse(Collections.emptyList())
.stream()
.map(tc -> {
try {
return new ToolCallRequest(
tc.id(),
tc.function().name(),
objectMapper.readTree(tc.function().arguments())
);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
})
.collect(toList());
return new LLMResponse(null, toolCalls, false);
}
return new LLMResponse(
message.content().orElse(""),
Collections.emptyList(),
true
);
}
private ChatCompletionTool toOpenAiTool(ToolDefinition def) {
return ChatCompletionTool.builder()
.type(ChatCompletionToolType.FUNCTION)
.function(FunctionDefinition.builder()
.name(def.name())
.description(def.description())
.parameters(def.inputSchema())
.build())
.build();
}
}
// Anthropic适配器
@Component
public class AnthropicAdapter implements LLMAdapter {
@Autowired
private AnthropicClient anthropicClient;
@Autowired
private ObjectMapper objectMapper;
@Override
public LLMResponse chat(ConversationContext context) {
// 构建Anthropic格式的messages
List<MessageParam> messages = buildAnthropicMessages(context);
// 构建Anthropic格式的tools
List<Tool> tools = context.getTools().stream()
.map(def -> Tool.builder()
.name(def.name())
.description(def.description())
.inputSchema(Tool.InputSchema.builder()
.properties(def.inputSchema().get("properties"))
.required(parseRequired(def.inputSchema()))
.build())
.build())
.collect(toList());
// 发送请求
Message response = anthropicClient.messages().create(
MessageCreateParams.builder()
.model("claude-3-5-sonnet-20241022")
.maxTokens(4096)
.messages(messages)
.tools(tools.isEmpty() ? null : tools)
.build()
);
if (response.stopReason() == StopReason.TOOL_USE) {
// 从content数组中提取tool_use块
List<ToolCallRequest> toolCalls = response.content().stream()
.filter(block -> block instanceof ToolUseBlock)
.map(block -> {
ToolUseBlock toolUse = (ToolUseBlock) block;
return new ToolCallRequest(
toolUse.id(),
toolUse.name(),
(JsonNode) toolUse.input() // Anthropic的input已是JsonNode
);
})
.collect(toList());
return new LLMResponse(null, toolCalls, false);
}
// 提取文本内容
String text = response.content().stream()
.filter(block -> block instanceof TextBlock)
.map(block -> ((TextBlock) block).text())
.collect(Collectors.joining("\n"));
return new LLMResponse(text, Collections.emptyList(), true);
}
}3.3 统一的对话管理器
@Service
public class MultiModelChatService {
private final Map<String, LLMAdapter> adapters;
@Autowired
private FunctionExecutor functionExecutor;
public MultiModelChatService(List<LLMAdapter> adapterList) {
this.adapters = adapterList.stream()
.collect(toMap(a -> a.getModelFamily(), a -> a));
}
public String chat(String model, String userMessage, List<ToolDefinition> tools) {
// 选择适配器(openai/anthropic)
String family = detectModelFamily(model);
LLMAdapter adapter = adapters.get(family);
ConversationContext context = new ConversationContext(model, tools);
context.addUserMessage(userMessage);
// 最多循环10次工具调用
for (int i = 0; i < 10; i++) {
LLMResponse response = adapter.chat(context);
if (response.isFinished()) {
return response.textContent();
}
// 执行工具调用
context.addAssistantMessage(response);
List<ToolResult> results = response.toolCalls().stream()
.map(tc -> functionExecutor.execute(tc))
.collect(toList());
context.addToolResults(results);
}
throw new RuntimeException("Too many tool call rounds");
}
private String detectModelFamily(String model) {
if (model.startsWith("gpt-") || model.startsWith("o1")) return "openai";
if (model.startsWith("claude-")) return "anthropic";
throw new IllegalArgumentException("Unknown model: " + model);
}
}四、踩坑实录
坑1:Anthropic的input已解析,OpenAI的arguments是字符串
这是最常见的坑。同样的字段,Anthropic的input是已解析的JSON对象,OpenAI的arguments是JSON字符串,需要再次objectMapper.readTree()。适配层必须统一成JsonNode。
坑2:Anthropic支持多内容块,文本和工具调用可以同时出现
OpenAI的tool_calls场景中,content通常是null或纯文本。但Anthropic在stop_reason=tool_use时,content数组里可能同时包含text块和tool_use块(模型先解释再调用)。
处理时要分别提取两种块,不能只看tool_use:
String textPart = response.content().stream()
.filter(b -> b instanceof TextBlock)
.map(b -> ((TextBlock) b).text())
.collect(Collectors.joining());
List<ToolCallRequest> toolPart = response.content().stream()
.filter(b -> b instanceof ToolUseBlock)
.map(b -> convertToToolCall((ToolUseBlock) b))
.collect(toList());坑3:两家的并行工具调用行为不同
OpenAI在一次响应中可以返回多个tool_calls,天然支持并行;Anthropic也支持多个tool_use块,但处理时要确保把所有tool_result都放在同一个user消息里,否则会报错。
坑4:token计费方式不同导致成本估算偏差
OpenAI按token计费,工具定义的JSON也算在输入token里。Anthropic的工具定义有额外的处理费用。在估算成本时需要分别查阅各家的计费说明。
五、总结与延伸
两套协议的核心差异对比:
| 特性 | OpenAI | Anthropic |
|---|---|---|
| 工具定义层级 | tools[].function.name | tools[].name |
| 参数Schema字段 | parameters | input_schema |
| 工具调用位置 | message.tool_calls | content[].type=tool_use |
| 参数格式 | arguments(字符串) | input(已解析JSON对象) |
| 工具结果角色 | role=tool | role=user中的tool_result块 |
如果你在做多模型适配,建议用适配器模式+统一接口,把协议差异封装在适配层里。业务代码只和统一接口打交道,切换模型只需换适配器。
Spring AI已经帮你做了大部分这些适配工作,如果你在用Spring AI,可以直接受益。
下一篇聊Function Call的安全问题,这是生产环境必须考虑的。
