☰
OpenAI与Anthropic API协议差异及统一适配层实践
2026/10/7 4:37:13 网站建设 项目流程

做 LLM 应用接入的时候,最常被问到的不是“用哪个模型”,而是“OpenAI 和 Anthropic 的接口到底能不能一套代码全兼容”。我近期在一个多模型接入项目里同时对接了两家的 API,从鉴权、请求体、流式回包到错误处理,踩了一圈坑才把适配层稳定下来。这篇文章把 OpenAI 和 Anthropic 两套 API 协议的差异一次讲清,附上完整的请求对照和几段真实的报错排查记录,给正在做模型聚合层、工具调用或者模型迁移的同学做个参考。

两家的接口看起来都是 REST + SSE,结构上也很像,真上手才发现细节差异特别多:Anthropic 的 max_tokens 必须传,OpenAI 可以不传;OpenAI 的 system 是 messages 里的角色,Anthropic 的 system 是独立字段;同样是工具调用,OpenAI 返回 tool_calls,Anthropic 返回 tool_use 块。这些不是手工“改个字段名”就完事,而是会影响你整个请求构造和响应解析的设计。下面按我实际接入的顺序一点一点拆。

1. 两套协议为什么会不一样

1.1 同源但不同路的设计背景

OpenAI API 早期靠 chat/completions 建立起事实标准,后来新增了 Responses API,但大多数生态和工具链还是围绕 chat/completions 转。Anthropic 从 Claude 2 时代开始就一直走 Messages API,并没有照抄 OpenAI 的 messages 结构。这就导致很多从 OpenAI 迁移过来的开发者,第一反应是“把 model 换掉、key 换掉就行了”,结果连请求都发不出去。

Anthropic 在设计上更强调请求的可追溯性和版本兼容。它要求每个请求都带anthropic-version头,就是为了保证不同的客户端版本不会被服务端升级悄悄破坏。OpenAI 的鉴权更简单,一个Authorization头走天下。两种设计没有优劣,但在做统一接入层时,差异会直接体现在代码分支里。

1.2 统一接入时首先要看清的“协议边界”

很多团队希望一套调用层同时接多家的模型,这个方向没问题,但不要天真地以为可以用同一个 JSON 请求体直接转发。我见过有同事把 OpenAI 的 messages 原封不动发给 Anthropic,结果 400 报错说messages[0].role不支持system。原因就是 Anthropic 的 messages 数组里根本没有system这个角色。

所以统一接入不是“写一个通用 client 就完事”,而是要先定义一套自己的内部消息抽象,比如 role 只保留system/user/assistant/tool,再在适配层转换成各家的格式。这个思路会贯穿下面的所有对比。

2. 鉴权、请求头与请求体的核心差异

2.1 鉴权方式与请求头对照

OpenAI 的鉴权是标准的 Bearer Token:

  • Header:Authorization: Bearer sk-xxx
  • 可选:OpenAI-Beta(使用 beta 接口时)

Anthropic 有两套鉴权,官方要求的是自定义头:

  • Header:x-api-key: sk-ant-xxx
  • Header:anthropic-version: 2023-06-01
  • 如果走 Anthropic 的 OAuth 通道,也可以使用Authorization: Bearer,但常规 API key 场景下,我建议直接用x-api-key,少踩兼容性坑。

请求头对照表如下:

含义OpenAIAnthropic
鉴权Authorization: Bearerx-api-key
协议版本无(靠模型名和 URL)anthropic-version
system 位置messages 内的 role顶层 system 字段
必填参数model, messagesmodel, messages, max_tokens
流式结束标识data: [DONE]message_stop 事件

注意:Anthropic 的 messages 中虽然没有 system role,但允许 assistant 消息里包含tool_use和tool_result块,这个会在工具调用部分展开。

实际排查时,headers 错了最常见的表现是 401authentication_error,而且 Anthropic 的 401 消息会比 OpenAI 更直接一些。不要凭感觉猜,先curl -i看响应头。

2.2 messages 结构与 system 字段的不同

OpenAI 的 messages 数组是统一承载所有角色的,system 就是其中的一个 role:

{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是助手"}, {"role": "user", "content": "你好"} ] }

Anthropic 的 system 被单独抽到了顶层,messages 里只能有 user 和 assistant:

{ "model": "claude-3-5-sonnet-20241022", "max_tokens": 1024, "system": "你是助手", "messages": [ {"role": "user", "content": "你好"} ] }

这里有个容易踩的细节:Anthropic 校验 messages 时,要求第一条必须是 user 消息(system 不算在 messages 里),而且不能出现两个连续的 user 消息。如果要从 OpenAI 的对话记录直接迁移,需要过滤掉原来的 system 消息,并把连续的用户消息合并,否则会 400。

2.3 必填参数和 token 计算方式差异

OpenAI chat/completions 里model和messages必填,max_tokens早期模型可选,后来 o1 系列要求用max_completion_tokens,这本身就是一个“同厂商不同模型协议不同”的例子。Anthropic 则无论哪个模型,max_tokens都是必填项,漏掉直接报missing required field: max_tokens。

token 计费字段也不一样:

  • OpenAI 返回usage.prompt_tokens,usage.completion_tokens,usage.total_tokens
  • Anthropic 返回usage.input_tokens,usage.output_tokens,如果开了提示缓存,还会多出cache_creation_input_tokens和cache_read_input_tokens

如果你统一上报 usage 到监控系统,字段名必须做映射,不然报表里的 token 消耗会串数据。这块我在第 4 节会给出一个映射示例。

2.4 采样参数和控制字段的映射差异

除了必填项,采样参数也存在“同形不同义”的问题。OpenAI 用temperature、top_p、presence_penalty、frequency_penalty;Anthropic 只支持temperature和top_p,没有 penalty 类参数。如果你在业务层直接把 OpenAI 的presence_penalty透传给 Anthropic,会被服务端忽略,而不是报错。

停止符的命名也不同。OpenAI 是stop数组,Anthropic 是stop_sequences数组。比如要模型生成到“结束”两个字停下,OpenAI 传"stop": ["结束"],Anthropic 传"stop_sequences": ["结束"]。适配层最好统一成stop_sequences,转 OpenAI 时再映射为stop。

这一层映射不复杂,但容易被忽略。因为很多业务同学只关注对话能不能通,忽略了这些控制参数对生成结果的影响。我的经验是:适配层里把参数白名单列死,不认识的参数直接报错,比静默丢弃更好排查问题。

3. 请求对照:同一需求在两家的真实报文字段

3.1 一个普通对话请求的完整对照

我用同一个“你好”请求,分别给出 curl 版对照。

OpenAI:

curl https://api.openai.com/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是一个简洁的助手"}, {"role": "user", "content": "你好"} ], "temperature": 0.7 }'

Anthropic:

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": 1024, "system": "你是一个简洁的助手", "messages": [ {"role": "user", "content": "你好"} ], "temperature": 0.7 }'

一眼看过去,变化不大。但如果你把 OpenAI 的 JSON 直接改成 url 和 key 就发出去,会收到 400。常见报错有:

  • messages[0].role: "system" is not supported:system 放错位置。
  • max_tokens: field required:漏了必填参数。
  • model: "gpt-4o" does not exist:模型名没改成 Claude 系列。

所以迁移时,不要只改 header,要改 body 结构。

3.2 响应结构和 stop_reason 的差异

请求结束之后,两家的响应结构也完全不同。OpenAI 的普通响应是:

{ "id": "chatcmpl-xxx", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "你好!有什么可以帮你?" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30 } }

Anthropic 的普通响应是:

{ "id": "msg_01xxx", "type": "message", "role": "assistant", "content": [ { "type": "text", "text": "你好!有什么可以帮你?" } ], "stop_reason": "end_turn", "usage": { "input_tokens": 15, "output_tokens": 10 } }

这里有两个点要注意:

  • OpenAI 的正文在choices[0].message.content,Anthropic 的正文是content数组里所有type="text"块的拼接。
  • finish_reason和stop_reason是两种枚举。OpenAI 常见stop、length、tool_calls;Anthropic 常见end_turn、max_tokens、tool_use、stop_sequence。

在统一解析层,一定不能直接读取finish_reason == "stop"判断正常结束,因为 Anthropic 的end_turn就是正常结束。我一般会先映射成内部枚举NORMAL / MAX_TOKENS / TOOL_CALL / ERROR,业务只认内部枚举。

3.3 流式返回的 SSE 事件差异

普通非流式响应都比较直观,一旦开 stream,两家的差异立刻放大。

OpenAI 流式返回一个接一个的data: {...},最后用data: [DONE]结束。每个 chunk 里的choices[0].delta.content就是需要拼接的增量文本。

Anthropic 流式返回不是单纯的一堆 data,而是分阶段的多个事件:

  • message_start:头部元信息
  • content_block_start:开始某个内容块
  • content_block_delta:增量文本,存放在delta.text
  • content_block_stop:内容块结束
  • message_delta:累计 token 等统计信息
  • message_stop:整个消息结束

如果你沿用 OpenAI 的“找一个[DONE]就结束解析”的逻辑,在 Anthropic 上会一直等不到结束标识。正确做法是识别到message_stop再认为流结束,同时把多个content_block_delta的delta.text拼起来。

我用 Python 做过一个简易流式解析,两个协议用同一个回调,核心是维护一个event_name状态,遇到 Anthropic 的message_stop时触发finish。如果只接一家,不用做这么复杂;但做多模型聚合,这个事件模型必须统一。

3.4 工具调用的响应结构与适配思路

这里是最容易写错的地方。OpenAI 的工具调用是 assistant 消息里带tool_calls数组:

{ "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } } ] }

然后客户端把工具结果以role: tool、tool_call_id对应起来放回 messages。

Anthropic 的工具调用是content数组里的一个tool_use块:

{ "role": "assistant", "content": [ { "type": "text", "text": "我来查询北京的天气" }, { "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "北京"} } ] }

工具名称从function.name变成了name;参数从字符串arguments变成了对象input。注意 OpenAI 的参数是 JSON 字符串,Anthropic 的参数是直接 JSON 对象,适配层不能简单复制,要做一次深解析。

工具结果回填的格式也不同,Anthropic 是user消息里包含tool_result块:

{ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_01", "content": "晴,28摄氏度" } ] }

我的建议是内部统一采用“函数名 + JSON 参数字典”的抽象,转 OpenAI 时把参数字典序列化成字符串,转 Anthropic 时直接传字典。这个适配逻辑在 4.1 里会有代码。

4. 实操中的适配层设计与实现

4.1 设计一个最小兼容层

下面是我在项目里用的简化版适配层示例,只保留核心路径。内部先用统一的ChatRequest结构,再转换成各家格式。

import json import requests class LLMClient: def __init__(self, provider, api_key, model): self.provider = provider self.api_key = api_key self.model = model def build_messages(self, system, messages): if self.provider == "openai": return [{"role": "system", "content": system}] + messages elif self.provider == "anthropic": # 过滤掉 system,Anthropic 的 system 走顶层参数 return [m for m in messages if m["role"] != "system"] def build_body(self, system, messages, max_tokens=1024): base = { "model": self.model, "messages": self.build_messages(system, messages), "temperature": 0.7, } if self.provider == "openai": base["max_completion_tokens"] = max_tokens elif self.provider == "anthropic": base["max_tokens"] = max_tokens base["system"] = system return base def send(self, system, messages, max_tokens=1024): if self.provider == "openai": url = "https://api.openai.com/v1/chat/completions" headers = {"Authorization": f"Bearer {self.api_key}"} elif self.provider == "anthropic": url = "https://api.anthropic.com/v1/messages" headers = { "x-api-key": self.api_key, "anthropic-version": "2023-06-01", } else: raise ValueError(f"unknown provider: {self.provider}") body = self.build_body(system, messages, max_tokens) resp = requests.post(url, headers=headers, json=body, timeout=60) return self._parse(resp.json()) def _parse(self, data): if self.provider == "openai": return { "content": data["choices"][0]["message"]["content"], "finish_reason": self._map_stop_reason(data["choices"][0].get("finish_reason")), "usage": { "input_tokens": data["usage"].get("prompt_tokens"), "output_tokens": data["usage"].get("completion_tokens"), }, } elif self.provider == "anthropic": content_text = "".join( block.get("text", "") for block in data["content"] if block.get("type") == "text" ) return { "content": content_text, "finish_reason": self._map_stop_reason(data.get("stop_reason")), "usage": { "input_tokens": data["usage"].get("input_tokens"), "output_tokens": data["usage"].get("output_tokens"), }, } def _map_stop_reason(self, raw): mapping = { "stop": "NORMAL", "end_turn": "NORMAL", "length": "MAX_TOKENS", "max_tokens": "MAX_TOKENS", "tool_calls": "TOOL_CALL", "tool_use": "TOOL_CALL", } return mapping.get(raw, "UNKNOWN")

这段代码在真实项目里不够健壮,但足够作为骨架。核心逻辑是先定义内部消息格式,再按 provider 拆分。把 system 独立出来一层,避免两种 messages 的 role 规则打架。

4.2 错误映射与重试策略

两家的错误结构完全不一样。OpenAI 的错误响应大致是:

{ "error": { "message": "...", "type": "invalid_request_error", "code": "model_not_found" } }

Anthropic 的错误响应是:

{ "type": "error", "error": { "type": "invalid_request_error", "message": "..." } }

虽然都叫error.type,但取值集合不同。OpenAI 常见invalid_request_error、rate_limit_exceeded、server_error;Anthropic 常见invalid_request_error、authentication_error、permission_error、not_found_error、rate_limit_error、api_error、overloaded_error。做统一错误类型时,至少要把“限流”、“鉴权失败”、“服务端错误”三个大类映射到内部枚举。

重试策略也要分级别。HTTP 429 和 5xx 可以重试,但如果状态码是 429,Anthropic 会在响应头里给retry-after,OpenAI 也会给retry-after-ms之类的头。不要用固定 sleep 3 秒,要根据头去动态等待。而像 400 错误,比如max_tokens漏填、模型名不对,重试也是白费,应该直接报给上层。

我在项目里统一这么处理:

  • 连接异常/超时:最多重试 2 次,间隔 1s、2s。
  • 429:读取响应头中的重试时间,最多重试 1 次。
  • 5xx:最多重试 3 次,指数退避。

状态码和错误类型的对照可以整理成表,方便排查:

状态码OpenAI 类型Anthropic 类型处理建议
401invalid_request_error / authentication_errorauthentication_error检查 API key
400invalid_request_errorinvalid_request_error检查请求体字段
404model_not_foundnot_found_error检查模型名和 URL
429rate_limit_exceededrate_limit_error / overloaded_error按 retry-after 重试
5xxserver_errorapi_error / overloaded_error指数退避重试

4.3 超时与连接问题的处理

两家的接口时延差异比想象中明显。OpenAI 的响应速度和 Anthropic 在相同模型档位下响应体感不同,但不是稳定规律,所以超时设置不能一刀切。我建议把连接超时控制在 10 秒以内,读取超时给到 60 秒以上,模型思考时间长的任务甚至可以放宽到 120 秒。

另外要留意网络访问限制的问题。如果部署环境到api.anthropic.com或api.openai.com之间连不上,通常表现是请求阶段直接 timeout,或者 TLS 握手报错。排查时先curl -v看卡在哪一步,再检查 DNS、防火墙、安全组。不要一上来就加重试,很多时候是网络路径问题,重试只会放大请求失败的影响。

5. 常见报错与排查技巧实录

5.1 请求一直连不上 api.anthropic.com 怎么办

有段时间我这边服务突然反馈failed to connect to api.anthropic.com连接超时。第一反应不是改代码,而是先手动执行:

curl -v https://api.anthropic.com/v1/messages -d '{}'

如果 curl 都卡在 TCP 连接阶段,说明是网络路径问题,和服务代码无关。检查了 DNS 解析、出口防火墙和服务器地域之后,发现是某条链路不稳定,换了机房网络后恢复。应用层要做的是把这些错误归到ConnectionError,不要直接抛 500。

5.2 模型名报错:doesn't look like an anthropic model

这个报错我一开始完全摸不着头脑:

doesn't look like an anthropic model: expected a gateway model route reference

后来查文档才明白,Anthropic 的 Messages API 在转发到某些 gateway 路由时,模型名必须是claude-3-5-sonnet-20241022这种包含版本的完整形态,或者是你所在接入平台配置好的路由名。如果你在中间层做了模型映射,比如把用户的claude-sonnet简写直接透传,就会触发这个错误。解决方法是维护一个模型名映射表,把简写映射到完整版本号,或者让用户传完整 model ID。

5.3 上下文超长:400 this model's maximum context length is 1048576 tokens

这个报错虽然不一定来自 OpenAI 或 Anthropic,但同样值得提醒:不同模型的上下文限制差异极大,有的模型号称 1M token,实际上请求里的 system + 历史 + 工具定义都会计入上下文。出现这个 400 时,不要盲目重试,而是先检查你是否把大量工具定义和长文档塞进了请求。

处理上下文超长的三板斧:

  • 裁剪历史消息,保留最近 N 轮
  • 降低单条内容长度,做摘要
  • 如果模型本身上下文不够,升级到长上下文模型

OpenAI 的 gpt-4o 系列支持 128K,Anthropic 的 Claude 3.5 Sonnet 支持 200K,超长时选用合适模型再优化 prompt 才是正解。

5.4 本地 CLI 依赖缺失和 key 配置混乱

开发过程中还遇到过这类报错:

missing optional dependency @openai/codex-win32-x64. reinstall codex: npm...

这是本地 OpenAI Codex CLI 装在不支持平台或依赖缺失导致的。对于本地工具,强烈建议严格按照官方安装命令在干净环境重装,不要跨平台复制 node_modules。另一个常见问题是多 provider 配置混乱,比如llm-deepseek: no api key for provider route "deepseek-official",其实是环境变量没有配全。多个模型接入时,我习惯把 key 放到独立环境变量文件,并写一个启动前校验脚本,凡是引用了 provider 但没设置 key 的直接 fail fast,省得运行时才报错。

千万注意:API key 是敏感凭证,不要在日志、代码仓库或分享出去的示例里明文贴出,也不要使用网上流传的“共享 key”。一是不安全,二是服务商随时会风控。

6. 我所用下来的取舍与建议

实际写完这层适配后,我的体会是:不要追求“一套代码零分支兼容两家”,那会让代码里全是 if provider,维护成本很高。更好的做法是把内部消息结构固定下来,例如统一成system + user/assistant 列表 + 工具定义,然后把两家的协议差异全部收敛到适配层。这样新接一家模型,只需要写新的 adapter,不碰业务代码。

如果只是在做一个 demo,直接按官方示例硬编码完全没问题。但如果要长期维护,尽早从“copy 官方 curl”切到“统一抽象 + adapter”,后面会省非常多事。

另外一个小技巧:在适配层里给每个请求加上请求 ID 字段(OpenAI 响应里有id,Anthropic 的message.id也可以透传),这样排查线上问题时,能拿着原始 ID 去查两家日志,比什么日志都好使。还有一个细节:Claude 的 system 字段有时候会用到多模态 block 数组,而不仅仅是字符串,如果你的业务里既要传 text system 还要传图片示例,就要单独处理这个结构。这些都是在接完一整套之后才意识到的坑,希望这篇文章能帮你少走一段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询