ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenAI与Anthropic API协议差异及统一适配层实践

OpenAI与Anthropic API协议差异及统一适配层实践 做 LLM 应用接入的时候最常被问到的不是“用哪个模型”而是“OpenAI 和 Anthropic 的接口到底能不能一套代码全兼容”。我近期在一个多模型接入项目里同时对接了两家的 API从鉴权、请求体、流式回包到错误处理踩了一圈坑才把适配层稳定下来。这篇文章把 OpenAI 和 Anthropic 两套 API 协议的差异一次讲清附上完整的请求对照和几段真实的报错排查记录给正在做模型聚合层、工具调用或者模型迁移的同学做个参考。两家的接口看起来都是 REST SSE结构上也很像真上手才发现细节差异特别多Anthropic 的 max_tokens 必须传OpenAI 可以不传OpenAI 的 system 是 messages 里的角色Anthropic 的 system 是独立字段同样是工具调用OpenAI 返回 tool_callsAnthropic 返回 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 TokenHeaderAuthorization: Bearer sk-xxx可选OpenAI-Beta使用 beta 接口时Anthropic 有两套鉴权官方要求的是自定义头Headerx-api-key: sk-ant-xxxHeaderanthropic-version: 2023-06-01如果走 Anthropic 的 OAuth 通道也可以使用Authorization: Bearer但常规 API key 场景下我建议直接用x-api-key少踩兼容性坑。请求头对照表如下含义OpenAIAnthropic鉴权Authorization: Bearerx-api-key协议版本无靠模型名和 URLanthropic-versionsystem 位置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_tokensAnthropic 返回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_penaltyAnthropic 只支持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 版对照。OpenAIcurl 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 }Anthropiccurl 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 supportedsystem 放错位置。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.contentAnthropic 的正文是content数组里所有typetext块的拼接。finish_reason和stop_reason是两种枚举。OpenAI 常见stop、length、tool_callsAnthropic 常见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.textcontent_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: # 过滤掉 systemAnthropic 的 system 走顶层参数 return [m for m in messages if m[role] ! system] def build_body(self, system, messages, max_tokens1024): 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_tokens1024): if self.provider openai: url https://api.openai.com/v1/chat/completions headers {Authorization: fBearer {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(funknown provider: {self.provider}) body self.build_body(system, messages, max_tokens) resp requests.post(url, headersheaders, jsonbody, timeout60) 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_errorAnthropic 常见invalid_request_error、authentication_error、permission_error、not_found_error、rate_limit_error、api_error、overloaded_error。做统一错误类型时至少要把“限流”、“鉴权失败”、“服务端错误”三个大类映射到内部枚举。重试策略也要分级别。HTTP 429 和 5xx 可以重试但如果状态码是 429Anthropic 会在响应头里给retry-afterOpenAI 也会给retry-after-ms之类的头。不要用固定 sleep 3 秒要根据头去动态等待。而像 400 错误比如max_tokens漏填、模型名不对重试也是白费应该直接报给上层。我在项目里统一这么处理连接异常/超时最多重试 2 次间隔 1s、2s。429读取响应头中的重试时间最多重试 1 次。5xx最多重试 3 次指数退避。状态码和错误类型的对照可以整理成表方便排查状态码OpenAI 类型Anthropic 类型处理建议401invalid_request_error / authentication_errorauthentication_error检查 API key400invalid_request_errorinvalid_request_error检查请求体字段404model_not_foundnot_found_error检查模型名和 URL429rate_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 模型名报错doesnt look like an anthropic model这个报错我一开始完全摸不着头脑doesnt 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 models maximum context length is 1048576 tokens这个报错虽然不一定来自 OpenAI 或 Anthropic但同样值得提醒不同模型的上下文限制差异极大有的模型号称 1M token实际上请求里的 system 历史 工具定义都会计入上下文。出现这个 400 时不要盲目重试而是先检查你是否把大量工具定义和长文档塞进了请求。处理上下文超长的三板斧裁剪历史消息保留最近 N 轮降低单条内容长度做摘要如果模型本身上下文不够升级到长上下文模型OpenAI 的 gpt-4o 系列支持 128KAnthropic 的 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 响应里有idAnthropic 的message.id也可以透传这样排查线上问题时能拿着原始 ID 去查两家日志比什么日志都好使。还有一个细节Claude 的 system 字段有时候会用到多模态 block 数组而不仅仅是字符串如果你的业务里既要传 text system 还要传图片示例就要单独处理这个结构。这些都是在接完一整套之后才意识到的坑希望这篇文章能帮你少走一段弯路。
返回列表