
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 Completionshttps://api.openai.com/v1/chat/completionsOpenAI Responseshttps://api.openai.com/v1/responsesAnthropic Messageshttps://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 keyAnthropic 要求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 里的 rolesystem 吗实际上 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_urlAnthropic 用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 CompletionsAnthropic Messages必填项modelmodel、max_tokensmax_tokens可选老参数部分模型建议用 max_completion_tokens必填不填直接 400temperature0 ~ 2默认 10 ~ 1文档范围默认 1top_p支持支持top_k不支持支持stop字符串或字符串数组stop_sequences字符串数组frequency_penalty支持不支持presence_penalty支持不支持stream支持支持tools嵌套 function 对象扁平 name/input_schema 对象响应字段choices[0].messagecontent[0].text从这张表能读出几个工程上的问题第一如果业务层在 OpenAI 那边用了frequency_penalty或presence_penalty转 Anthropic 时这些参数没有对应项要么忽略要么用 temperature/top_p 近似替代第二Anthropic 有top_kOpenAI 没有从 Anthropic 迁回 OpenAI 时也不能直接透传这个参数。3. 输出控制与流式返回max_tokens 和 SSE 事件千万别硬编码如果说前面那些差异还只是“改改字段名”那输出控制和流式协议就是真正容易踩坑的地方。尤其当你用统一的流式解析层时两款 API 返回的事件完全不是一回事。3.1 max_tokens 必填与非必填造成的 400Anthropic 的 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 models 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.contentOpenAI 的流式工具调用增量在choices[0].delta.tool_calls[0].function.argumentsAnthropic 则在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_schemaOpenAI 的工具定义是这样的{ 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:{...}}参数名是parametersAnthropic 工具定义是扁平的参数名是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 里工具结果是单独的toolroleAnthropic 里工具结果必须是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的显式字段你只能通过保持前缀稳定来提高缓存命中率。这个差异对统一接入层影响很大。第一如果你把所有请求都加上一个动态的时间戳或者随机 userIdAnthropic 的缓存命中率会非常差第二你在 OpenAI 那边没法主动要求缓存但 Anthropic 那边需要显式插入cache_control所以适配层要能识别“哪些内容块需要打缓存标记”。另外还要注意只有支持缓存的位置才能加cache_control不是任何 content 块都能加具体要以下发的模型能力为准。4.4 usage 字段也分两套既然说到缓存顺便把 usage 对照列一下含义OpenAIAnthropic输入 tokensusage.prompt_tokensusage.input_tokens输出 tokensusage.completion_tokensusage.output_tokens总 tokensusage.total_tokensusage.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” 这类路由错误另一个比较有代表性的报错是doesnt 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 上下文超限先算账再改代码再就是这种 400400 This models maximum context length is 1048576 tokens. However, you requested 1048600 tokens (1048576 in the messages, 24 in the completion).很多人的第一反应是把max_tokens调小但这通常解决不了本质问题。你看这个报错messages 已经占了 1048576completion 还要 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 了才回来看协议细节。