1. 两套协议的请求入口与身份认证:从 URL 到 Header 的差异
最近在做一个统一接入层,需要同时兼容 OpenAI 和 Anthropic 两套 API 协议。乍一看,两边都是 POST 一个 JSON 过去、SSE 流式返回,好像差别不大。真正逐个字段对齐之后才发现,从请求入口、认证头,到消息结构、工具调用、缓存控制、流式事件,几乎每一层都有各自的约定。这篇文章我一次讲清这些差异,附上可以直接对照的 HTTP 请求示例,并把我踩过的坑一并记录下来,给正在做多模型接入、API 网关适配或者准备从 OpenAI 迁移到 Anthropic 的朋友参考。
1.1 三个 API 入口:不要把 Chat Completions 和 Responses 混为一谈
先说端点。OpenAI 这边有两个常见的对话接口,一个是大家最熟悉的POST /v1/chat/completions,另一个是新一代的POST /v1/responses。Anthropic 这边对应的对话接口是POST /v1/messages。
- OpenAI Chat Completions:
https://api.openai.com/v1/chat/completions - OpenAI Responses:
https://api.openai.com/v1/responses - Anthropic Messages:
https://api.anthropic.com/v1/messages
如果你只是做简单的文本对话,OpenAI 的 Chat Completions 和 Anthropic 的 Messages 在语义上最接近,都是把messages数组传过去。Responses API 则换了一套输入结构,它不再接受messages参数,而是用input来传会话内容,返回结果也从choices[0].message.content变成了output数组。很多从 OpenAI 老代码迁移过来的同学,第一次切到 Responses 时会对不上字段,这个不是今天重点,但你要知道两套入口不能混用。
我这里说的 OpenAI 协议差异,默认指 Chat Completions,因为目前大多数现有系统、开源 SDK、中间件兼容的都是这个入口。Anthropic 协议相对统一,就是 Messages API。
这里还要注意一个容易疏忽的点:OpenAI 的 Chat Completions 是可以用curl直接测的,但 Anthropic 的 Messages API 有一些额外 Header 要求,光有 URL 和 Key 不够,下面这段就专门讲 Header。
1.2 认证头、版本头,以及两个容易忽略的 Header
身份认证是两套协议第一个明显的分水岭。
OpenAI 的认证很简单,只有一个Authorization头:
Authorization: Bearer sk-xxxxxxxx Content-Type: application/jsonAnthropic 的认证则是这样:
x-api-key: sk-ant-xxxxxxxx anthropic-version: 2023-06-01 Content-Type: application/jsonAnthropic 比 OpenAI 多了一个anthropic-version版本头。这个头不是可选的,虽然有些时候不传也能通过,但官方 SDK 一定会带。很多人在自写 HTTP 客户端时只记得加x-api-key,忘了anthropic-version,结果请求直接失败,返回的消息要么是缺头,要么是协议版本不识别。
第二个容易忽略的点是 Header 名称。OpenAI 要求Authorization: Bearer <key>,Anthropic 要求x-api-key: <key>。如果你在网关层做统一转发,最常见的问题就是只透传Authorization,把x-api-key剥掉了;或者反向代理两侧 Header 命名不一致,导致 Anthropic 收到请求时根本没有 API Key。线上出现过不少 401 都是这个原因,根本不是 Key 本身失效。
还有一个 Key 前缀上的差异:OpenAI 的 Key 通常是sk-开头,Anthropic 的 Key 通常是sk-ant-开头。不过现在很多企业是走第三方网关或者模型聚合平台拿 Key,二次分发的 Key 可能统一都是sk-svcac...这种自定义前缀,所以你千万不要在代码里写死“开头是 sk-ant 就是 Anthropic Key”这种判定逻辑。识别厂商应该靠配置项,而不是 Key 前缀。
1.3 401 报错文案对照,以及一个常见误判
两套协议在认证失败时的 JSON 结构也完全不同。
OpenAI 认证失败返回:
{ "error": { "message": "Incorrect API key provided: sk-***. You can find your API key at ...", "type": "invalid_request_error", "param": null, "code": "invalid_api_key" } }Anthropic 认证失败返回:
{ "type": "error", "error": { "type": "authentication_error", "message": "invalid x-api-key header" } }注意,如果你是直接在官方 API 上调,错误结构基本就是上面两种。但如果你用的是聚合网关或某个封装 SDK,经常会把错误统一包装成一个异常字符串,比如:
unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****这种文案一出来,很多人第一反应是去后台重新生成 Key。其实这时候要先确认,401 到底是官方 API 返回的,还是网关在前面拦截后返回的。我在接入层排查过好几次,发现 Key 没问题,是网关配置里把 Key 环境变量覆盖成了旧值。遇到 401 的正确排查顺序应该是:先看请求实际到了哪个端点,再看 Header 里的 Key 到底是什么,最后才考虑重新生成 Key。
2. 消息体结构差异:system 的位置比你想的更讲究
请求 Header 看完了,真正写代码时工作量最大的还是请求体。两套协议对“系统提示词、多轮消息、内容块”的建模方式不同,直接互转很容易出错。
2.1 system 不是放哪里都行
OpenAI 的 Chat Completions 里,系统提示词是放在messages数组里面的:
{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是资深后端工程师,回答要简洁。"}, {"role": "user", "content": "帮我解释一下 HTTP 长连接。"} ] }也就是说,OpenAI 把 system 当成一种特殊的 message role。你还可以在同一轮里穿插 system、developer、user、assistant 消息。
Anthropic 则把 system 单独提出来,放在请求体顶层:
{ "model": "claude-3-5-sonnet-20241022", "max_tokens": 1024, "system": "你是资深后端工程师,回答要简洁。", "messages": [ {"role": "user", "content": "帮我解释一下 HTTP 长连接。"} ] }这就带来了几个工程上的影响。如果你做一个统一协议层,假设上游业务传的是“system 字符串”,转 OpenAI 时要把它塞进 messages 第一条,转 Anthropic 时要把它放在顶层。如果你偷懒,统一都塞进 messages,在 OpenAI 那边没问题,但 Anthropic 的 Messages API 是直接忽略 messages 里的 role=system 吗?实际上 Anthropic 要求 system 必须作为顶层字段,如果你在 messages 里塞role: "system",接口会报错或者要求你调整结构。所以这块一定要在适配层处理,不能直接透传。
另外,Anthropic 的 system 字段还支持数组形式,比如要使用缓存控制时,system 会变成:
{ "system": [ { "type": "text", "text": "很长的一段系统提示词", "cache_control": {"type": "ephemeral"} } ] }这种写法在 OpenAI 的 Chat Completions 里是没有对应结构的,需要转换成普通的 system message。
2.2 content 字段:字符串还是内容块数组
第二处差异是content的形态。OpenAI 的 Chat Completions 允许 content 是纯字符串,也允许是数组,数组里可以放文本块、图片 URL、文件等。一个多模态内容示例:
{ "role": "user", "content": [ {"type": "text", "text": "这张图里有什么?"}, {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}} ] }Anthropic 的 Messages API 也支持 content 为字符串或数组,但里面的内容块类型名字不一样。图片块的常见写法是:
{ "role": "user", "content": [ {"type": "text", "text": "这张图里有什么?"}, { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "base64字符串" } } ] }对比一下就知道,同样是“图片”,OpenAI 用image_url,Anthropic 用image加source。如果你做统一封装,必须对内容块做一层翻译,否则请求体里会出现对方协议不认识的多模态字段。
2.3 多轮对话里没有 content 的 assistant 消息
这个坑我印象很深。OpenAI 的模型在返回工具调用时,assistant 消息可能是这样的:
{ "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" } } ] }这里content可以是null。如果你把这段消息直接丢给 Anthropic 的 Messages API,大概率会挂,因为 Anthropic 的消息模型里,assistant 消息的 content 通常至少是一个数组,而且表达工具调用的方式不是tool_calls,而是一个tool_use内容块:
{ "role": "assistant", "content": [ { "type": "text", "text": "我来查一下北京的天气。" }, { "type": "tool_use", "id": "toolu_01ABC", "name": "get_weather", "input": {"city": "北京"} } ] }所以协议适配层处理多轮对话时,不能简单地按 role 映射。遇到 OpenAI 的tool_calls,要转成 Anthropic 的tool_use块;遇到 Anthropic 的tool_use块,转成 OpenAI 时又得抽成tool_calls字段。如果只考虑文本消息,你会觉得两套协议差不多;一旦加入工具调用,差异就非常明显。这部分的完整对照我在第 4 节单独展开。
2.4 顶层参数:从左到右各有各的脾气
两套协议除了消息结构,顶层生成参数也有不少差异。我直接整理成一张表,方便对照:
| 参数 | OpenAI Chat Completions | Anthropic Messages |
|---|---|---|
| 必填项 | model | model、max_tokens |
| max_tokens | 可选,老参数,部分模型建议用 max_completion_tokens | 必填,不填直接 400 |
| temperature | 0 ~ 2,默认 1 | 0 ~ 1(文档范围),默认 1 |
| top_p | 支持 | 支持 |
| top_k | 不支持 | 支持 |
| stop | 字符串或字符串数组 | stop_sequences,字符串数组 |
| frequency_penalty | 支持 | 不支持 |
| presence_penalty | 支持 | 不支持 |
| stream | 支持 | 支持 |
| tools | 嵌套 function 对象 | 扁平 name/input_schema 对象 |
| 响应字段 | choices[0].message | content[0].text |
从这张表能读出几个工程上的问题:第一,如果业务层在 OpenAI 那边用了frequency_penalty或presence_penalty,转 Anthropic 时这些参数没有对应项,要么忽略,要么用 temperature/top_p 近似替代;第二,Anthropic 有top_k,OpenAI 没有,从 Anthropic 迁回 OpenAI 时也不能直接透传这个参数。
3. 输出控制与流式返回:max_tokens 和 SSE 事件千万别硬编码
如果说前面那些差异还只是“改改字段名”,那输出控制和流式协议就是真正容易踩坑的地方。尤其当你用统一的流式解析层时,两款 API 返回的事件完全不是一回事。
3.1 max_tokens 必填与非必填造成的 400
Anthropic 的 Messages API 里,max_tokens是必填参数。你在请求体里不写,接口会直接返回类似:
{ "type": "error", "error": { "type": "invalid_request_error", "message": "max_tokens: field required" } }OpenAI 的 Chat Completions 里,max_tokens是可选的。早期所有 OpenAI 模型都用max_tokens,但从某个版本开始官方更推荐max_completion_tokens。如果你一直在 OpenAI 那边写max_tokens,大多数情况下也能跑,只是 OpenAI 会提示这个参数在新模型上可能不被支持或语义有变化。需要留意的是,从 Anthropic 迁到 OpenAI 的老代码,可能习惯把所有请求都带上max_tokens,这个不算大问题;反向迁移时,忘写max_tokens就会立刻 400。
还有一个容易混淆的点是“上下文长度超限”。错误信息经常会这样出现:
400 This model's maximum context length is 1048576 tokens. However, you requested 1048600 tokens (1048576 in the messages, 24 in the completion). Please reduce the length of the messages or completion.这种错误不是参数必填问题,而是你的输入 tokens 加上输出 tokens 超过了模型上限。页面里看到的最大值可能来自某个支持 1M 上下文的模型或网关配置。遇到这种情况,首先看请求里 messages 是不是被重复拼接了;其次看是不是把历史上所有对话都无脑塞进上下文,没有做裁剪或摘要。
我的建议是:在接入层里给 Anthropic 请求强制补max_tokens,默认值可以从模型配置表里读,不要写死;给 OpenAI 请求则优先用max_completion_tokens,并且做一层参数白名单过滤,避免把 Anthropic 特有参数透传过去。
3.2 SSE 流式事件:一个叫 delta,一个叫 content_block_delta
流式返回是两套协议差异最大的地方之一。
OpenAI Chat Completions 的流式返回是逐行data:,每个 chunk 长这样:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}你要做的就是把data:后面的 JSON 解析出来,读取choices[0].delta.content,拼在一起。流结束时会有一个[DONE]:
data: [DONE]Anthropic Messages 的流式返回则是事件驱动的 SSE,每一行有event:和data:两部分。典型流程是这样的:
event: message_start data: {"type":"message_start","message":{"id":"msg_xxx","usage":{"input_tokens":15,"output_tokens":1}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你"}} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":32}} event: message_stop data: {"type":"message_stop"}注意几个关键点。OpenAI 判断流结束的标准是解析到[DONE];Anthropic 判断流结束的标准是收到message_stop事件。如果你把 OpenAI 的解析器硬套在 Anthropic 上,会因为读不到[DONE]而一直傻等。我在接入层里最先做的就是一个事件类型适配器,把 Anthropic 的message_stop转成内部统一的finish信号。
另外,Anthropic 的流式文本增量在delta.text,不是choices[0].delta.content;OpenAI 的流式工具调用增量在choices[0].delta.tool_calls[0].function.arguments,Anthropic 则在content_block_delta里的delta.partial_json。这些都要分开处理。
3.3 流式结束后的 usage 在哪
流式场景下,很多人会关心 token 用量。OpenAI 的流式返回里,usage 通常在最后一个 chunk 里出现:
data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":28,"total_tokens":43}}不过也有模型不返回 usage。Anthropic 的流式返回里,usage 在message_delta事件里给你output_tokens,在最早的message_start里给你input_tokens。如果你在做计费系统,要把两边的 usage 字段分别收集好,再映射到你自己的计量模型里,而不是统一读usage.prompt_tokens。
4. 工具调用与缓存:看起来都是 function calling,底层设计完全不同
现在的大模型应用里,工具调用几乎是标配。OpenAI 和 Anthropic 都支持类似“function calling”的能力,但协议设计思路差得很远。
4.1 工具定义:parameters 还是 input_schema
OpenAI 的工具定义是这样的:
{ "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "查询某个城市的天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名"} }, "required": ["city"] } } } ] }Anthropic 的工具定义是这样的:
{ "tools": [ { "name": "get_weather", "description": "查询某个城市的天气", "input_schema": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名"} }, "required": ["city"] } } ] }注意两个关键区别:OpenAI 的工具定义外层多包了一层{"type":"function","function":{...}},参数名是parameters;Anthropic 工具定义是扁平的,参数名是input_schema。如果你做统一适配,工具定义需要做一次深度转换,不能只改字段名,因为嵌套层级都不一样。
4.2 工具结果的回传方式:tool_calls 对比 tool_use + tool_result
这是最容易写错的地方。OpenAI 的工具调用回传流程是:
- 模型返回 assistant 消息,里面带
tool_calls,每个 call 有id、function.name、function.arguments。 - 你执行完工具后,把结果放在一条新的
role: "tool"消息里,用tool_call_id关联回之前的 call id:
{ "role": "tool", "tool_call_id": "call_abc123", "content": "{\"temperature\": 25}" }Anthropic 的流程则是:
- 模型返回 assistant 消息,content 数组里包含
tool_use块,每个块有id、name、input。 - 你执行完工具后,把结果放在下一条
role: "user"消息的content数组里,用tool_result块关联:
{ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_01ABC", "content": "{\"temperature\": 25}" } ] }注意,OpenAI 里工具结果是单独的toolrole;Anthropic 里工具结果必须是user消息里的tool_result内容块。这个差异导致很多现成框架在迁移时出错,尤其是日志回放、会话持久化的时候,如果存的是 OpenAI 格式,恢复 Anthropic 会话时要把 role 和 content 重新组装。
4.3 上下文缓存:Anthropic 显式控制,OpenAI 自动但不可配
在长提示词场景下,Anthropic 有一个非常有用的缓存机制。你可以在 system 或者某些内容块上打上cache_control标记,比如:
{ "type": "text", "text": "这里是非常长的系统提示词……", "cache_control": {"type": "ephemeral"} }第一次请求会产生cache_creation_input_tokens,后续相同前缀的请求会命中缓存并产生cache_read_input_tokens。OpenAI 那边也有 prompt caching,但它对客户端来说是自动的,请求体里没有类似cache_control的显式字段,你只能通过保持前缀稳定来提高缓存命中率。
这个差异对统一接入层影响很大。第一,如果你把所有请求都加上一个动态的时间戳或者随机 userId,Anthropic 的缓存命中率会非常差;第二,你在 OpenAI 那边没法主动要求缓存,但 Anthropic 那边需要显式插入cache_control,所以适配层要能识别“哪些内容块需要打缓存标记”。另外还要注意,只有支持缓存的位置才能加cache_control,不是任何 content 块都能加,具体要以下发的模型能力为准。
4.4 usage 字段也分两套
既然说到缓存,顺便把 usage 对照列一下:
| 含义 | OpenAI | Anthropic |
|---|---|---|
| 输入 tokens | usage.prompt_tokens | usage.input_tokens |
| 输出 tokens | usage.completion_tokens | usage.output_tokens |
| 总 tokens | usage.total_tokens | usage.input_tokens + usage.output_tokens |
| 缓存创建 | 无公开字段 | usage.cache_creation_input_tokens |
| 缓存命中 | 无公开字段 | usage.cache_read_input_tokens |
如果你的日志系统统一打印prompt_tokens和completion_tokens,接 Anthropic 后这些字段会全是空,因为 Anthropic 的字段名完全不一样。别小看这个,很多团队上线后看调用量报表对不上,就是 usage 字段映射没做。
5. 实际踩坑记录:从 401 到路由错误再到上下文超限
最后这部分,我把接入过程中真正踩到的坑串一遍。每一个都不是什么高端问题,但线上遇到时非常迷惑。
5.1 401 的排查链路:别急着换 Key
有一次线上集体报错,异常信息是:
unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****第一反应是 Key 过期了,但换了新的 Key 依然报错。后来仔细查,发现所有请求都走了网关,而网关里配置的 OpenAI/Anthropic Key 是写死在环境变量里的,配置中心更新后网关没有重新加载,导致它一直用旧 Key 去请求上游。所以你只要看到“incorrect api key provided”这种是来自统一封装层的字符串,先跳到网关去看它实际用的是哪一把 Key。如果是直连官方 API,才需要去后台检查 Key。
另外,Anthropic 的认证头大小写也很敏感,x-api-key这个 header 在 HTTP/2 里会被标准化为小写,但如果你的自建服务端用了大小写匹配,很容易产生 401。排查时可以用一个最简单的curl先验证:
curl https://api.anthropic.com/v1/messages \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}'如果 curl 能通,说明协议没写错;curl 不通,再看 Header 和 Key。
5.2 “doesn’t look like an anthropic model” 这类路由错误
另一个比较有代表性的报错是:
doesn't look like an anthropic model: expected a gateway model route reference这个错误通常出现在你用了 Anthropic 官方 SDK,但baseURL指向的是一个聚合网关的时候。Anthropic SDK 会把model直接带给网关,而网关一般只认自己配置的路由名。比如你在代码里写claude-3-5-sonnet-20241022,但网关那边的模型路由名可能是claude-sonnet-3-5,或者是某个内部别名,于是网关认为你传的不是一个有效的 Anthropic 模型路由,直接拒绝。
遇到这种问题,不要反复改模型名,应该先去网关提供商的模型列表里查它支持的路由别名,然后把它填到配置里。很多团队在这个错误上卡了一两天,原因就是“官方模型名”和“网关路由名”是两套命名体系。
5.3 上下文超限:先算账,再改代码
再就是这种 400:
400 This model's maximum context length is 1048576 tokens. However, you requested 1048600 tokens (1048576 in the messages, 24 in the completion).很多人的第一反应是把max_tokens调小,但这通常解决不了本质问题。你看这个报错,messages 已经占了 1048576,completion 还要 24,总和超出模型上限。哪来的 1048576 tokens?大概率是接入层把对话历史、系统提示词、工具定义、文档片段全拼进去了,没有做截断。
应对办法分三层:第一层,发送前做 token 预算检查,估算 messages 总长度;第二层,对多轮历史做摘要或裁剪,只保留最近的 N 轮;第三层,把很长的工具定义或者 system 提示词做压缩,或者利用 Anthropic 的缓存机制把静态部分放到前缀中,减少每次重复计费。注意:缓存能省钱,但不能减少上下文占用的“长度”,超限问题还是要靠裁剪解决。
5.4 统一接入层应该怎么做
最后说说我自己现在的做法。我不追求“一套请求体同时兼容两家”,而是定义一套内部的“会话消息模型”,在往上传的时候分别翻译成 OpenAI 格式或 Anthropic 格式。核心只有三条:
- 分层设计:业务层使用统一的
SystemMessage、UserMessage、AssistantMessage、ToolMessage;适配层负责翻译成各家协议。 - 参数白名单:不能把 OpenAI 的
frequency_penalty透传给 Anthropic,也不能把 Anthropic 的top_k透传给 OpenAI。每个厂商维护一份支持参数列表。 - 错误归一化:把 401、400、429、超限、路由错误统一转成内部错误码,原始错误按厂商维度记录到日志里,方便排查。
翻译层里最实用的一个函数是把内部消息转成 Anthropic 消息。核心逻辑大概是这样:
def to_anthropic_messages(messages): converted = [] for msg in messages: if msg.role == "system": # Anthropic 的 system 放在顶层,由外层单独处理 continue if msg.role == "tool": converted.append({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": msg.tool_call_id, "content": msg.content, }] }) elif msg.role == "assistant" and msg.tool_calls: content = [] if msg.content: content.append({"type": "text", "text": msg.content}) for tc in msg.tool_calls: import json content.append({ "type": "tool_use", "id": tc.id, "name": tc.function.name, "input": json.loads(tc.function.arguments), }) converted.append({"role": "assistant", "content": content}) else: converted.append({"role": msg.role, "content": msg.content}) return converted这段代码只是一个最小示例,真实项目里还要处理多模态内容块、流式事件、usage 映射等。但思路是对的:不要在业务代码里到处写if provider == "anthropic",而是把协议差异收口在适配层。
我在接入层里最终把 OpenAI 和 Anthropic 拆成了两个独立的 adapter,只在最上层共享同一个业务会话格式。虽然刚起步时多一些 adapter 代码,但后面接新模型、调缓存、看账单都清爽很多。如果你正在做同样的整合,建议也先把两套协议的差异列成表放在文档里,别等到线上 401 了才回来看协议细节。