1. 两套协议到底差在哪:从一次真实迁移说起
去年底我把一个内部知识库问答服务从 OpenAI 的接口切到 Anthropic,本以为改个 base_url 和 model 名字就完事,结果整整折腾了一个下午。请求体结构、鉴权头、返回格式、流式事件、错误码,几乎没有一处能直接复用。那次之后我就养成了一个习惯:每接一家大模型 API,先把它的协议特征单独记一份笔记,而不是想当然地套用上一家的写法。
这篇内容就是那份笔记的整理版。核心讲清楚 OpenAI 和 Anthropic 两套 API 协议在请求结构、鉴权方式、消息组织、系统提示、流式响应、工具调用、错误处理这几个维度上的具体差异,并且给出可以直接对照的请求示例和我在迁移过程中踩过的坑。适合正在做多模型适配、准备从一家切到另一家、或者要同时维护两套调用的开发者参考。哪怕你只用过其中一家,看完也能明白另一家的设计逻辑为什么长这样。
需要先说明一点:两家的 API 都在持续演进,下面提到的字段和结构以我实际对接时的版本为准,具体以官方文档为准。但协议设计的底层思路差异是稳定的,理解了思路,字段微调不会影响你迁移。
2. 鉴权与请求入口:第一道分水岭
2.1 鉴权头的写法差异
OpenAI 用的是标准的Authorization: Bearer <API_KEY>,这是 HTTP 里最常见的令牌传递方式,几乎所有做过 REST 接口的人都不陌生。Anthropic 则用了自定义头x-api-key: <API_KEY>,同时要求带上anthropic-version头来声明协议版本。
# OpenAI 风格 curl https://api.openai.com/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" # 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"为什么 Anthropic 要单独搞一个版本头?我的理解是它把协议版本当成显式契约,方便在字段结构发生不兼容变更时做灰度。OpenAI 更多是靠路径里的/v1和模型名来区分行为。这个差异直接影响你的封装层设计:如果你写了一个统一的 HTTP 客户端,鉴权头不能写死,得按 provider 分支处理。
注意:Anthropic 的
anthropic-version是必填的,漏掉会直接返回 400,而不是给你一个默认版本。我第一次对接时就因为这个卡了十分钟,报错信息还不算特别直白。
2.2 端点路径与资源命名的思路
OpenAI 的主对话端点是/v1/chat/completions,从名字就能看出它把对话建模成"聊天补全"任务。Anthropic 用的是/v1/messages,强调输入输出都是"消息"。这个命名差异不是文字游戏,它反映了两家对同一件事的不同抽象:OpenAI 的底层心智模型是"给一段上下文,补全接下来的内容";Anthropic 的心智模型是"给一组消息,返回一条新消息"。
实际影响在于参数命名。OpenAI 里叫max_tokens(其实现在很多新模型已经改成max_completion_tokens),Anthropic 里叫max_tokens但它是必填的。对,你没看错,Anthropic 不传max_tokens会直接报错,而 OpenAI 在很多模型上可以省略由服务端给默认值。这个差异在批量迁移时特别容易漏,因为你的旧代码里可能压根没写这个字段。
3. 请求体结构逐字段对照
3.1 消息数组的组织方式
这是差异最大、也最容易踩坑的地方。OpenAI 的消息数组里,每条消息有role和content,role可以是system、user、assistant、tool。系统提示就是一条role: "system"的消息,直接放在数组开头。
Anthropic 把系统提示单独抽出来,作为顶层参数system,消息数组里只允许user和assistant两种角色。如果你把role: "system"塞进 Anthropic 的 messages 数组,会直接报错。
// OpenAI 请求体 { "model": "gpt-4o", "messages": [ {"role": "system", "content": "你是一个严谨的技术助手"}, {"role": "user", "content": "解释一下幂等性"} ], "temperature": 0.7 } // Anthropic 请求体 { "model": "claude-3-5-sonnet-latest", "system": "你是一个严谨的技术助手", "messages": [ {"role": "user", "content": "解释一下幂等性"} ], "max_tokens": 1024, "temperature": 0.7 }为什么 Anthropic 要这么设计?我个人的判断是它想把"系统级指令"和"对话内容"在语义上彻底分开,系统提示不参与对话轮次的计数,也不应该被当成一条普通消息。这个设计在实现 prompt 缓存时更清晰,但对习惯了 OpenAI 写法的人来说,迁移时第一件事就是把 system 消息从数组里拎出来。
3.2 content 字段:字符串还是数组
OpenAI 的content既可以是字符串,也可以是内容块数组(用于多模态)。Anthropic 的content在消息里必须是数组,哪怕只有一段纯文本,也要写成[{"type": "text", "text": "..."}]。
// Anthropic 纯文本消息的正确写法 { "role": "user", "content": [ {"type": "text", "text": "解释一下幂等性"} ] }这个差异导致的最常见错误是:你把 OpenAI 的字符串 content 直接搬过去,Anthropic 会返回类型错误。反过来,Anthropic 的数组结构搬到 OpenAI 虽然大多能兼容,但如果你用了 Anthropic 特有的块类型,OpenAI 是不认的。
3.3 关键参数对照表
我把常用参数整理成一张表,迁移时对着改基本不会漏:
| 功能 | OpenAI 字段 | Anthropic 字段 | 备注 |
|---|---|---|---|
| 模型名 | model | model | 命名规则完全不同 |
| 系统提示 | messages 里 role=system | 顶层system | 结构差异最大 |
| 最大输出 | max_completion_tokens | max_tokens | Anthropic 必填 |
| 温度 | temperature | temperature | 取值范围一致 |
| 采样 | top_p | top_p | 一致 |
| 停止词 | stop | stop_sequences | 字段名不同 |
| 流式 | stream: true | stream: true | 事件格式不同 |
| 工具 | tools+tool_choice | tools+tool_choice | 结构有差异 |
提示:
stop和stop_sequences这个差异特别隐蔽,因为功能一样、位置一样,只是名字不同。我在迁移时就是因为这个字段名没改,导致停止词完全没生效,模型一直输出到 max_tokens 才停。
4. 响应结构与流式事件解析
4.1 非流式响应的取数路径
OpenAI 的响应里,文本内容在choices[0].message.content,结束原因在choices[0].finish_reason,用量在顶层usage。
Anthropic 的响应里,内容在content数组里,通常第一个块是{"type": "text", "text": "..."},结束原因在stop_reason,用量也在usage但字段名是input_tokens和output_tokens。
// OpenAI 响应取数 { "choices": [ {"message": {"role": "assistant", "content": "幂等性是指..."}, "finish_reason": "stop"} ], "usage": {"prompt_tokens": 20, "completion_tokens": 80, "total_tokens": 100} } // Anthropic 响应取数 { "content": [{"type": "text", "text": "幂等性是指..."}], "stop_reason": "end_turn", "usage": {"input_tokens": 20, "output_tokens": 80} }注意finish_reason和stop_reason的取值也不一样。OpenAI 用stop、length、tool_calls;Anthropic 用end_turn、max_tokens、tool_use。如果你在业务代码里判断"是否正常结束",两套的枚举值不能混用。
4.2 流式事件的模型差异
这是我认为两家差异最本质的地方。OpenAI 的流式是增量补全模型:每个 chunk 里带choices[0].delta.content,你把这些 delta 拼起来就是完整文本。Anthropic 的流式是事件驱动模型:它发一系列带type的事件,比如message_start、content_block_start、content_block_delta、content_block_stop、message_delta、message_stop,文本增量藏在content_block_delta的delta.text里。
# OpenAI 流式解析(简化) for chunk in stream: delta = chunk.choices[0].delta if delta.content: buffer += delta.content # Anthropic 流式解析(简化) for event in stream: if event.type == "content_block_delta": buffer += event.delta.text elif event.type == "message_stop": break为什么 Anthropic 要搞这么复杂的事件流?因为它要支持内容块的概念——一次响应里可能同时有文本块和工具调用块,事件模型能精确表达"哪个块开始、哪个块在增量、哪个块结束"。OpenAI 的 delta 模型在多工具调用场景下就没那么清晰。代价是解析代码更啰嗦,你得写一个状态机而不是简单拼接。
注意:Anthropic 的流式里,
message_delta事件才带最终的stop_reason和usage,别在content_block_stop就以为结束了。我见过有人在这里提前关闭连接,导致拿不到用量统计。
5. 工具调用与多模态的协议差异
5.1 工具定义的结构对比
OpenAI 的工具定义是{"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}},参数用 JSON Schema。Anthropic 的工具定义是{"name": ..., "description": ..., "input_schema": {...}},注意它把 schema 字段叫input_schema而不是parameters,而且没有外面那层function包装。
// OpenAI 工具定义 { "type": "function", "function": { "name": "get_weather", "description": "查询天气", "parameters": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } } } // Anthropic 工具定义 { "name": "get_weather", "description": "查询天气", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } }5.2 工具调用结果的回传方式
OpenAI 里,模型返回tool_calls,你执行完后用role: "tool"的消息把结果塞回去,并且要带tool_call_id做关联。Anthropic 里,模型返回一个tool_use类型的内容块,你回传时用role: "user"的消息,内容块类型是tool_result,通过tool_use_id关联。
这个差异很反直觉:Anthropic 把工具结果当成 user 消息的一部分,而不是独立的 tool 角色。如果你按 OpenAI 的思路写,会一直报角色错误。
// Anthropic 回传工具结果 { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_xxx", "content": "北京今天晴,25度" } ] }5.3 多模态图片的传法
OpenAI 用{"type": "image_url", "image_url": {"url": "..."}},支持 URL 和 base64 data URI。Anthropic 用{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}},只接受 base64,不接受 URL 直接引用。
这意味着如果你原来用 OpenAI 传图片 URL,迁到 Anthropic 得先自己下载图片再转 base64。这个工作量不小,尤其是图片多的时候。我的做法是在适配层加一个图片预处理函数,按 provider 决定是直接透传 URL 还是下载转码。
6. 迁移实操:一份可复用的适配层设计
6.1 抽象出统一的内部消息模型
踩了上面这些坑之后,我的结论是:不要试图让业务代码直接对接某一家 SDK,而是定义一套自己的内部消息模型,然后在适配层做双向转换。内部模型我建议用 OpenAI 风格,因为它更简单、生态更广,转换到 Anthropic 的复杂度可控。
# 内部统一模型(简化示意) class Message: role: str # system / user / assistant / tool content: str tool_calls: list = None tool_call_id: str = None def to_openai(messages): return [{"role": m.role, "content": m.content} for m in messages] def to_anthropic(messages): system = "" converted = [] for m in messages: if m.role == "system": system += m.content + "\n" else: converted.append({ "role": m.role, "content": [{"type": "text", "text": m.content}] }) return system.strip(), converted6.2 参数映射与默认值补齐
适配层里最容易被忽略的是默认值。Anthropic 的max_tokens必填,所以你的适配层必须给一个兜底值,比如 4096。stop到stop_sequences的映射也要显式处理。我建议把这些映射写成一张配置表,而不是散落在 if-else 里。
| 内部字段 | OpenAI 输出 | Anthropic 输出 |
|---|---|---|
| max_tokens | max_completion_tokens | max_tokens(必填,兜底 4096) |
| stop | stop | stop_sequences |
| system | 数组首条 system 消息 | 顶层 system 字段 |
6.3 流式解析的统一封装
流式这块我建议在适配层就把两家的流都转成统一的"文本增量 + 结束事件"回调,业务层只关心文本和结束信号,不关心底层是 delta 还是 event。
def stream_openai(stream, on_text, on_end): for chunk in stream: d = chunk.choices[0].delta if d.content: on_text(d.content) if chunk.choices[0].finish_reason: on_end(chunk.choices[0].finish_reason) def stream_anthropic(stream, on_text, on_end): for event in stream: if event.type == "content_block_delta": on_text(event.delta.text) elif event.type == "message_delta": on_end(event.delta.stop_reason)这样业务层写一次,两家都能跑。代价是适配层代码量翻倍,但比起在每个业务点判断 provider,这个成本划算得多。
7. 常见报错与排查速查表
迁移过程中我遇到的报错基本集中在下面这几类,整理成表方便对照:
| 报错现象 | 大概率原因 | 解决方向 |
|---|---|---|
| 400 缺少 anthropic-version | 没带版本头 | 补anthropic-version |
| 400 max_tokens 必填 | 漏传该字段 | 适配层兜底默认值 |
| 400 role 非法 | system 塞进了 messages | 抽到顶层 system |
| 400 content 类型错误 | 传了字符串而非数组 | 包成 text 块数组 |
| 停止词不生效 | 用了 stop 而非 stop_sequences | 改字段名 |
| 流式拿不到用量 | 在 content_block_stop 就结束 | 等到 message_delta |
| 工具结果报角色错 | 用了 tool 角色 | 改成 user + tool_result |
| 图片报错 | 传了 URL | 下载转 base64 |
提示:Anthropic 的错误响应结构也和 OpenAI 不同,它的错误信息在
error.message里,类型在error.type。如果你有统一的错误处理中间件,记得把两家的错误结构都归一化,否则日志里会出现"取不到错误信息"的情况。
8. 我踩过的几个真实坑与经验
第一个坑是流式下的 token 统计。OpenAI 在流式最后一个 chunk 里带 usage(需要显式开启),Anthropic 在message_delta里带。我一开始在两家都按"最后一个 chunk"处理,结果 Anthropic 那边因为事件类型判断错了,统计一直是 0。后来改成按事件类型判断才正常。
第二个坑是工具调用的并行。OpenAI 支持一次返回多个 tool_calls,Anthropic 也支持多个 tool_use 块,但回传时 Anthropic 要求所有 tool_result 放在同一条 user 消息里,而不是分多条。我按 OpenAI 的习惯分条回传,模型行为变得很奇怪,后来合并成一条才恢复。
第三个坑是系统提示的拼接。OpenAI 允许多条 system 消息,Anthropic 只有一个 system 字符串。我在适配层简单拼接时忘了加分隔符,导致两条系统指令粘在一起语义变了。后来统一用换行加分隔线拼接,问题消失。
第四个坑是温度参数的边界。两家取值范围都是 0 到 1(OpenAI 部分模型支持到 2),但 Anthropic 对极端值的处理更敏感,温度设 0 时输出会非常死板。我在做确定性输出时踩过这个,最后改成 0.1 兼顾稳定和自然。
这些坑单看都不大,但凑在一起就是半天的调试时间。我的建议是:迁移前先把两家的请求体各写一个最小可运行示例,跑通再动业务代码,比直接改生产代码高效得多。
9. 多模型并存时的工程建议
如果你像我一样需要同时维护多家模型,有几个工程上的做法值得参考。第一,把 provider 的差异全部收敛到适配层,业务层只依赖内部模型,这样新增一家只需要加一个转换函数。第二,把模型名、端点、鉴权方式做成配置,而不是硬编码,切换时改配置不改代码。第三,给每家写一个冒烟测试,覆盖鉴权、非流式、流式、工具调用四条路径,迁移后先跑测试再上业务。
还有一点是关于超时和重试。两家的默认超时和限流策略不同,Anthropic 对并发更敏感,OpenAI 在高并发下也会返回 429。我的做法是在适配层统一加指数退避重试,并且把 429 和 5xx 分开处理,429 退避时间长一点,5xx 短一点快速重试。
最后分享一个我自己的小习惯:每次对接新 API,我都会先写一个"协议特征卡",把鉴权头、端点、必填字段、响应取数路径、流式事件类型这五项记下来。下次迁移时翻出卡片对照,基本不会再犯结构性错误。这个习惯帮我省下的调试时间,远比写卡片花的时间多。