ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenAI与Anthropic API协议差异全解析:从迁移踩坑到适配层设计

OpenAI与Anthropic API协议差异全解析:从迁移踩坑到适配层设计

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

1.1 从一次接口迁移说起

去年我把一个内部知识库问答工具从 OpenAI 切到 Anthropic 的时候,心里想的是"不就是换个 base_url 和 key 吗"。结果第一版代码跑起来,报错信息直接把我整懵了:messages: field required。我明明传了messages啊。翻文档才发现,问题不在字段名,而在一堆我压根没注意的细节——system 提示词的位置、content 的结构、max_tokens 是不是必填、多轮对话里 assistant 消息怎么摆。

这件事让我意识到,OpenAI 和 Anthropic 这两套 API 协议,表面上看都是"发一段对话、拿一段回复",但骨子里的设计哲学完全不同。OpenAI 走的是"消息数组 + 角色标签"的路线,Anthropic 走的是"系统提示独立 + 内容块"的路线。你如果只是把字段名硬替换,大概率会在某个边界场景翻车。

这篇内容就是把我踩过的坑、对照过的请求、以及最后总结出来的一套"迁移检查清单"完整讲清楚。适合两类人看:一是正在做多模型适配、想让同一套业务代码同时对接两家 API 的工程师;二是刚接触大模型 API、想搞明白"为什么同样是聊天接口,参数却对不上"的开发者。看完你应该能自己写出一层薄薄的适配层,把两边的差异吃掉。

1.2 核心分歧:消息模型 vs 内容块模型

先说最根本的差异。OpenAI 的请求体里,messages是一个数组,每个元素是{role, content},role 可以是system、user、assistant、tool。system 提示词就是数组里的第一条消息,和用户消息平级。这种设计很直观,像在排一条流水线,谁先谁后一目了然。

Anthropic 不一样。它的system是一个顶层字段,和messages并列,不放在消息数组里。而messages数组里只允许user和assistant两种 role,交替出现。这个设计背后的逻辑是:system 提示词在 Anthropic 看来不是"对话的一部分",而是"模型的全局设定",所以它被拎出来单独放。

这个差异带来的直接后果是:你从 OpenAI 迁移过来时,必须把 messages 数组里 role 为 system 的那条抽出来,放到顶层system字段。如果你忘了抽,Anthropic 会直接报错,因为它不认system这个 role。

再往深一层看,content 的结构也不同。OpenAI 的 content 可以是字符串,也可以是数组(多模态时用)。Anthropic 的 content必须是数组,哪怕你只发一句话,也得写成[{"type": "text", "text": "你好"}]。这个"必须数组"的设定,一开始我觉得很啰嗦,后来发现它其实是为了统一多模态、工具调用、图片等所有内容形态——所有东西都是"块",文本是块,图片是块,工具调用也是块。理解了这一点,后面很多设计就顺了。

1.3 一张对照表先建立全局印象

在展开细节之前,先给你一张我整理的对照表,把两套协议最常打交道的字段摆在一起。这张表是我迁移时的"救命稻草",建议你收藏。

维度OpenAIAnthropic
认证头Authorization: Bearer <key>x-api-key: <key>
版本头无强制anthropic-version: 2023-06-01必填
system 提示messages 数组内 role=system顶层system字段
messages rolesystem/user/assistant/tool仅 user/assistant
content 类型字符串或数组必须数组(内容块)
max_tokens可选必填
温度字段temperaturetemperature
停止词stopstop_sequences
流式开关stream: truestream: true
响应文本路径choices[0].message.contentcontent[0].text
结束原因finish_reasonstop_reason

这张表里每一行背后都有故事,下面逐个拆。

2. 认证与请求头:第一个容易翻车的关卡

2.1 认证方式的不同不是小事

OpenAI 用的是标准的Authorization: Bearer <key>,这是 HTTP 世界里最常见的令牌认证方式,几乎所有 SDK 和 HTTP 客户端都默认支持。你拿 Postman 或者 curl 发请求,只要在 header 里加上这一行就行。

Anthropic 用的是自定义头x-api-key。这个差异看起来只是换个字段名,但在实际工程里会引发连锁反应。比如你如果用某个网关或代理层做统一鉴权,网关默认可能只透传Authorization,那 Anthropic 的 key 就丢了。我遇到过最坑的一次,是本地调试时用了一个请求转发工具,它把Authorization头重写了,但没管x-api-key,结果 OpenAI 那边正常,Anthropic 一直 401。排查了半天才定位到是转发层的问题。

所以我的建议是:如果你要做多模型适配,鉴权头的构造一定要抽象成一个函数,根据 provider 类型返回不同的 header 字典,而不是在业务代码里硬编码。

2.2 anthropic-version 这个头千万别漏

Anthropic 要求每个请求都带上anthropic-version头,目前常用值是2023-06-01。这个头的作用是声明你使用的 API 版本,服务端据此决定行为。如果你漏了,会收到一个明确的报错,提示缺少版本头。

为什么 Anthropic 要这么设计?因为它的 API 在演进过程中有过不兼容的变更,通过版本头可以让老代码继续按老行为跑,新代码显式升级。这是一种"显式版本契约"的思路,比 OpenAI 那种"默认最新、变更靠公告"的方式更稳妥,但代价是你每次发请求都得多带一个头。

实操上,我习惯把这个版本号写进配置常量,而不是散落在各处。因为一旦哪天要升级版本,你只需要改一个地方。

# 一个简单的 header 构造示例 def build_headers(provider, api_key): if provider == "openai": return { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } elif provider == "anthropic": return { "x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", } raise ValueError(f"unknown provider: {provider}")

这段代码没什么技术含量,但它是适配层的第一块砖。把它写对,后面才谈得上跑通。

2.3 base_url 与端点路径的差异

OpenAI 的聊天补全端点是/v1/chat/completions,Anthropic 的是/v1/messages。注意,Anthropic 不叫 chat,叫 messages,这又一次呼应了它"消息模型"的设计取向。

这个路径差异在直接用官方 SDK 时是透明的,SDK 帮你拼好了。但如果你用裸 HTTP 请求,或者用某个通用 HTTP 客户端,就得自己拼。我见过有人把 OpenAI 的路径直接套到 Anthropic 上,结果 404,还以为是 key 的问题。

另外,base_url 的配置也要注意。OpenAI 的 base_url 通常配到https://api.openai.com/v1,然后 SDK 自动补/chat/completions。Anthropic 的 base_url 配到https://api.anthropic.com,SDK 补/v1/messages。如果你自己拼 URL,一定要看清楚 SDK 到底帮你补了哪一段,别重复也别漏。

3. 请求体结构:system、messages 与 content 的三重差异

3.1 system 提示词的搬家过程

这是迁移时第一个必须处理的点。OpenAI 里,system 提示词是 messages 数组的第一条:

{ "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是一个严谨的技术助手。"}, {"role": "user", "content": "解释一下什么是幂等。"} ] }

搬到 Anthropic,system 要拎出来:

{ "model": "claude-3-5-sonnet-20241022", "system": "你是一个严谨的技术助手。", "max_tokens": 1024, "messages": [ {"role": "user", "content": [{"type": "text", "text": "解释一下什么是幂等。"}]} ] }

注意这里有两个变化:system 搬家了,content 变成数组了,max_tokens 也加上了。这三个变化经常同时出现,所以我在写适配层时,把它们放在同一个转换函数里处理,避免遗漏。

有个细节值得说:Anthropic 的 system 字段可以是字符串,也可以是内容块数组。如果你需要给 system 加缓存标记(cache_control),就得用数组形式。这个后面讲缓存时再展开。

3.2 content 为什么必须是数组

前面提过,Anthropic 的 content 必须是数组。这个设计初看繁琐,但它是 Anthropic 整个"内容块"体系的基石。在 Anthropic 的世界里,一条消息的内容不是"一段文本",而是"一组块",每个块有自己的类型。

文本块长这样:

{"type": "text", "text": "你好"}

图片块长这样:

{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}

工具调用块和工具结果块也各有类型。这种统一性带来的好处是:多模态、工具调用、缓存这些高级能力,都能用同一套结构表达,不需要为每种能力单独设计字段。

OpenAI 这边,content 既可以是字符串,也可以是数组,数组里的元素用type区分,比如text、image_url。两边的思路其实在收敛,只是 Anthropic 更彻底,强制你一开始就用数组。

实操建议:写适配层时,统一把内部表示定义成"内容块数组",然后往 OpenAI 转换时,如果只有单个文本块,可以降级成字符串;往 Anthropic 转换时,直接输出数组。这样内部逻辑最干净。

3.3 max_tokens 必填带来的连锁反应

OpenAI 的max_tokens是可选的,不传就用模型默认值。Anthropic 的max_tokens是必填的,不传直接报错。

这个差异在迁移时特别容易漏,因为你在 OpenAI 那边跑得好好的代码,搬到 Anthropic 就报max_tokens: field required。我第一次遇到时还以为是 SDK 版本问题,折腾了一阵。

为什么 Anthropic 要强制?我的理解是,它希望调用方对输出长度有明确预期,避免因为默认值不合理导致意外的高消耗或截断。这是一种"显式优于隐式"的设计取向。

实操上,我建议在适配层里给 max_tokens 设一个合理的默认值,比如 1024 或 2048,然后在业务代码里按需覆盖。别指望它像 OpenAI 那样"不传也行"。

3.4 停止词与温度:字段名的小陷阱

停止词这块,OpenAI 叫stop,Anthropic 叫stop_sequences。功能一样,名字不同。如果你做字段映射时按名字硬匹配,这里就会漏。

温度字段两边都叫temperature,这点倒是省心。但要注意取值范围和默认值的差异。OpenAI 的 temperature 范围是 0 到 2,Anthropic 是 0 到 1。如果你从 OpenAI 那边传了个 1.5 过来,Anthropic 会报参数越界。这个坑我在做参数透传时踩过,后来在适配层加了个 clamp 才解决。

还有一个字段是top_p,两边都有,含义也基本一致,都是核采样。但一般不建议同时调 temperature 和 top_p,选一个调就行,这是模型调参的通用经验。

4. 响应结构:从 choices 到 content 的解析差异

4.1 响应文本的提取路径完全不同

OpenAI 的响应里,文本在choices[0].message.content。Anthropic 的响应里,文本在content[0].text。这两个路径没有任何相似之处,所以解析代码必须分开写。

OpenAI 响应示例(简化):

{ "choices": [ { "message": {"role": "assistant", "content": "幂等是指..."}, "finish_reason": "stop" } ] }

Anthropic 响应示例(简化):

{ "content": [ {"type": "text", "text": "幂等是指..."} ], "stop_reason": "end_turn" }

注意 Anthropic 的 content 是数组,可能包含多个块。如果模型同时输出了文本和工具调用,数组里就会有多个元素。所以解析时不能只取content[0],要遍历所有块,按 type 分别处理。这一点在只做纯文本对话时容易忽略,一旦上了工具调用就会暴露。

4.2 结束原因的枚举值不一样

OpenAI 的finish_reason常见值有stop、length、tool_calls、content_filter。Anthropic 的stop_reason常见值有end_turn、max_tokens、stop_sequence、tool_use。

你看,同样是"正常结束",OpenAI 叫stop,Anthropic 叫end_turn。同样是"达到长度上限",OpenAI 叫length,Anthropic 叫max_tokens。如果你在业务代码里用finish_reason == "stop"判断是否正常结束,迁移到 Anthropic 后这个判断永远为假,逻辑就错了。

我的做法是在适配层里做一层枚举映射,把两边的结束原因归一化成内部枚举,业务代码只认内部枚举。这样以后再加第三家、第四家模型,业务代码都不用改。

语义OpenAI finish_reasonAnthropic stop_reason
正常结束stopend_turn
达到长度上限lengthmax_tokens
命中停止词stopstop_sequence
触发工具调用tool_callstool_use

4.3 usage 字段的统计口径

两边的响应里都有 token 用量统计,但字段名和结构不同。OpenAI 在usage里有prompt_tokens、completion_tokens、total_tokens。Anthropic 在usage里有input_tokens、output_tokens。

注意 Anthropic 没有直接给 total,需要你自己加。而且 Anthropic 的 usage 里还有缓存相关的字段,比如cache_creation_input_tokens和cache_read_input_tokens,这些在算成本时不能忽略。

如果你要做成本监控,建议在适配层里把 usage 归一化成统一结构,把缓存命中的部分单独标记。因为缓存命中的 token 单价和普通 token 不一样,混在一起算会失真。

5. 流式响应:事件模型的分道扬镳

5.1 SSE 都是 SSE,但事件格式不同

两家都用 Server-Sent Events 做流式输出,但事件的组织方式不同。OpenAI 的流式响应里,每个 chunk 是一个完整的 JSON,结构类似非流式响应的简化版,choices[0].delta里带增量内容。最后会有一个data: [DONE]标记结束。

Anthropic 的流式响应是事件驱动的,每个 SSE 事件有event类型和data。事件类型包括message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop。文本增量在content_block_delta事件里,字段是delta.text。

这个差异意味着,你的流式解析代码没法复用。OpenAI 那边你循环读 chunk,取 delta.content 拼接就行。Anthropic 那边你得先判断事件类型,再决定从哪个字段取内容。

5.2 一个容易忽略的坑:content_block 的索引

Anthropic 的流式响应里,内容块是有索引的。当模型同时输出文本和工具调用时,会有多个 content block,每个 block 有自己的 index。你在拼接文本时,必须按 index 归位,不能简单地把所有 delta 拼在一起。

我踩过这个坑:一开始图省事,把所有content_block_delta的 text 直接拼成一个字符串,结果工具调用的参数也被拼进去了,输出乱成一团。后来改成按 index 维护一个字典,每个 block 单独累积,最后再按顺序组装,才正常。

OpenAI 这边相对简单,因为它的 delta 结构里通常只有一个 choice 在流式输出,工具调用的参数也是分片传的,但结构上更扁平。

5.3 流式下的错误处理

流式请求一旦开始,HTTP 状态码就已经返回 200 了,后续如果出错,错误信息会以事件的形式混在流里。OpenAI 会在流里发一个带 error 字段的 chunk,Anthropic 会发error类型的事件。

这意味着你不能只看 HTTP 状态码判断成功与否,必须在解析流的过程中持续检查错误事件。我见过有人的代码在流式模式下遇到限流,因为没检查流内错误,直接当成正常结束处理了,结果用户看到的是半截回答。

实操建议:在流式解析循环里,遇到错误事件就抛出异常,让上层统一处理。别试图在流里"恢复",因为流一旦出错,后续内容不可信。

6. 工具调用:两套协议差异最大的地方

6.1 工具定义的 schema 不同

OpenAI 的工具定义放在tools数组里,每个工具是{type: "function", function: {name, description, parameters}}。parameters 是 JSON Schema。

Anthropic 的工具定义放在顶层tools数组里,每个工具是{name, description, input_schema}。注意字段名是input_schema,不是parameters,而且没有type: "function"这层包裹。

这个差异在迁移时是硬性的,必须逐字段映射。我建议写一个转换函数,把内部的工具定义转成两边的格式,而不是在业务代码里维护两份。

6.2 工具调用的返回方式不同

OpenAI 里,模型决定调用工具时,响应里的finish_reason是tool_calls,message.tool_calls数组里每个元素有id、type、function.name、function.arguments(JSON 字符串)。你执行完工具后,要把结果作为一条 role 为tool的消息追加到 messages 里,带上tool_call_id。

Anthropic 里,模型决定调用工具时,响应的 content 数组里会出现type: "tool_use"的块,带id、name、input(已经是对象,不是字符串)。你执行完工具后,要把结果作为一条 role 为user的消息追加,content 里放type: "tool_result"的块,带tool_use_id和content。

注意这里的关键差异:OpenAI 用专门的toolrole,Anthropic 用userrole 加tool_result块。如果你按 OpenAI 的思路在 Anthropic 里发toolrole,会直接报错。

6.3 多轮工具调用的消息组织

当一轮对话里涉及多次工具调用时,消息的组织方式差异更明显。OpenAI 里,assistant 消息带 tool_calls,然后跟若干条 tool 消息,每条对应一个 tool_call_id。Anthropic 里,assistant 消息的 content 里混着 text 块和 tool_use 块,然后 user 消息的 content 里混着若干 tool_result 块。

这个结构差异导致你在做多轮工具调用循环时,消息历史的维护逻辑必须分开写。我的做法是在适配层里定义一套内部的消息表示,把工具调用和工具结果都归一化成内部块,然后在发送前转换成对应 provider 的格式。这样业务层的循环逻辑只写一遍。

提示:工具调用是多模型适配里最容易出错的部分,建议单独写单元测试,覆盖单工具、多工具、工具报错、工具结果为空等场景。

7. 那些文档里不写但一定会踩的坑

7.1 消息必须交替出现

Anthropic 要求 messages 数组里的 role 必须 user 和 assistant 交替出现。如果你连续发两条 user 消息,会报错。OpenAI 没这个限制,连续两条 user 消息是允许的。

这个限制在正常对话里不容易触发,但在一些特殊场景会踩到。比如你把系统事件、用户输入、工具结果都塞进 messages 时,很容易出现连续 user。解决办法是把连续的 user 内容合并成一个 content 数组,或者用 assistant 消息隔开。

7.2 空 content 的处理

Anthropic 不允许 content 为空数组。如果你某条消息的 content 是空的,会报错。OpenAI 相对宽松。这个坑在做消息裁剪、历史压缩时容易踩到——你把某条消息的内容清空了,但没删掉这条消息,就会触发。

7.3 图片和文件的上传方式

OpenAI 的图片可以用 URL 或 base64,放在 content 数组的image_url块里。Anthropic 的图片只支持 base64(至少早期是这样),放在image块的source里,需要指定media_type。

如果你从 OpenAI 迁移过来,且原来用的是图片 URL,那必须先把图片下载下来转成 base64,再发给 Anthropic。这个转换有性能开销,尤其是大图。我的建议是在适配层里加缓存,同一张图不要重复下载和编码。

7.4 缓存机制的不同

Anthropic 有显式的 prompt caching,你可以在 system 或 content 块上加cache_control: {"type": "ephemeral"}来标记缓存点。OpenAI 的缓存是自动的,不需要你标记,但命中的规则不透明。

这个差异意味着,如果你想让 Anthropic 的缓存生效,必须主动在合适的位置加标记。通常是在长 system 提示或长文档的末尾加。加对了能省不少钱,加错了没效果。我一般会在 system 提示的最后一个块上加,因为 system 通常是最稳定的部分。

7.5 错误响应的结构

OpenAI 的错误响应是{"error": {"message": ..., "type": ..., "code": ...}}。Anthropic 的错误响应是{"type": "error", "error": {"type": ..., "message": ...}}。结构不同,解析代码要分开写。

而且错误类型枚举也不同。OpenAI 有invalid_request_error、rate_limit_error等,Anthropic 有invalid_request_error、authentication_error、rate_limit_error、overloaded_error等。做重试逻辑时,要根据错误类型决定是否重试。比如overloaded_error适合退避重试,invalid_request_error重试也没用。

8. 写一层薄适配层的实操思路

8.1 内部统一表示的设计

讲了这么多差异,落地时最实用的方案是写一层适配层。核心思路是:定义一套内部的消息和工具表示,业务代码只跟内部表示打交道,发送前转换成对应 provider 的格式,收到响应后再转回内部表示。

内部消息可以设计成:

@dataclass class InternalMessage: role: str # system / user / assistant / tool content: list # 内容块列表 tool_calls: list = None tool_call_id: str = None

内容块用统一的 type 区分,比如text、image、tool_use、tool_result。这样业务层处理逻辑只写一遍。

8.2 转换函数的职责划分

转换函数分两个方向:to_provider和from_provider。to_provider负责把内部表示转成 OpenAI 或 Anthropic 的请求体,处理 system 搬家、content 数组化、max_tokens 补默认值、字段名映射等。from_provider负责把响应转回内部表示,处理文本提取、结束原因映射、usage 归一化等。

这两个函数是适配层的核心,也是最需要测试覆盖的地方。我一般会为每个 provider 写一组对照测试,输入相同的内部表示,断言输出的请求体符合预期。

8.3 流式适配的额外复杂度

流式适配比非流式复杂,因为你要处理事件流的差异。我的做法是在适配层里提供一个统一的流式迭代器,内部根据 provider 解析不同的事件格式,对外吐出统一的增量对象,比如{type: "text_delta", text: "..."}或{type: "tool_delta", ...}。

这样业务层的流式处理逻辑只写一遍,不用关心底层是 OpenAI 还是 Anthropic。代价是适配层要多写一些解析代码,但这是值得的,因为流式逻辑一旦分散到业务层,维护成本会指数级上升。

8.4 配置驱动的 provider 选择

最后,provider 的选择应该是配置驱动的,而不是硬编码。我一般会在配置里指定 provider 类型、模型名、api_key、base_url 等,适配层根据 provider 类型选择对应的转换函数和 header 构造。

这样切换 provider 只需要改配置,不用改代码。对于需要做 A/B 测试或多模型对比的场景,这一点特别有用。

9. 我个人的几条经验

第一,别指望"一套代码通吃"。两套协议的差异是设计层面的,不是字段名层面的。硬套只会让你在边界场景反复翻车。老老实实写适配层,前期多花一天,后期省一周。

第二,测试要覆盖边界。正常对话跑通不代表没问题,工具调用、流式、多轮、空内容、超长输入这些边界才是坑最多的地方。我一般会为每个 provider 准备一组边界用例,每次改适配层都跑一遍。

第三,日志要打全。适配层里把转换前后的请求体都打出来,出问题时能快速定位是转换错了还是 provider 行为不同。我踩过的坑里,有一半是靠对比转换前后的请求体定位的。

第四,关注成本口径。两家的 token 统计方式不同,缓存机制也不同。做成本监控时,一定要把缓存命中的部分单独算,否则数据会失真。

第五,版本头别偷懒。Anthropic 的anthropic-version一定要显式带上,别依赖 SDK 默认值。因为 SDK 升级时默认值可能变,显式指定能避免意外。

这套适配层我用了大半年,中间加过第三家模型,业务代码基本没动。这说明前期把抽象做对,后期的扩展成本会低很多。如果你也在做多模型适配,希望这些踩坑经验能帮你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进