1. 先把两套协议放在一起看:OpenAI 和 Anthropic 到底哪里不一样
我第一次接 Anthropic Messages API 的时候,顺手把 OpenAI Chat Completions 的请求体原样复制过去,心想“都是大模型,换一下 URL 不就行了”。结果一连串 4xx 直接把我打醒,连system该放哪、max_tokens该不该传、错误码长什么样,两边完全不是一回事。
过去半年,我同时维护了几套基于 GPT 和 Claude 的应用,OpenAI 这边还把 Codex CLI 这类命令行工具开放了出来,Anthropic 那边 Claude Code 也很活跃。表面上看大家都在卷 coding agent,但底层 API 协议依旧各玩各的。写这篇东西前我重新翻了两边官方文档,又把手头几个线上项目的请求日志扒了一遍,尽量把两套协议的核心差异和踩过的坑一次说清。
先说结论:OpenAI 和 Anthropic 的 API 协议,不是“换个模型名就能通用”的关系。它们一个是chat/completions/responses风格,一个是messages风格,字段命名、认证方式、流式事件、工具调用、usage 统计全都不一样。如果你只是调单家 API,照着官方示例写就行;但如果你要把应用做成“一键切换模型供应商”,或者在公司网关后面同时接两家,协议差异就是绕不开的工程问题。
下面这张表是我最常用来对照的速查表,后面每个点我都会展开讲:
| 对照项 | OpenAI | Anthropic |
|---|---|---|
| 主端点 | /v1/chat/completions(旧)//v1/responses(新) | /v1/messages |
| 认证方式 | Authorization: Bearer | x-api-key+anthropic-version |
| 系统提示 | messages里的system角色 | 顶层独立system字段 |
| 消息内容 | content可以是字符串或多模态数组 | content可以是字符串或内容块数组 |
| 输出 token 上限 | max_tokens/max_completion_tokens | max_tokens,必填,只计算输出 |
| 停止符 | stop | stop_sequences |
| 工具 schema | tools[].function.parameters | tools[].input_schema |
| 流式结束标志 | data: [DONE] | event: message_stop |
| usage 字段 | prompt_tokens/completion_tokens | input_tokens/output_tokens |
这篇不是官方文档的翻译,我按真实接入顺序来拆:先看请求结构,再看响应和错误码,然后专门讲流式输出和工具调用,最后给一份排查速查表。适合后端工程师、RAG 应用开发者、LLM 工具链维护者,尤其是要同时支持 Claude 和 GPT 的团队。
2. 请求体逐字段对照:同样一句话,两边怎么传
2.1 最小请求:Chat Completions 与 Messages
先看最简单的非流式请求。用curl最直观,一个调 OpenAI,一个调 Anthropic。
OpenAI Chat Completions:
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": "帮我出 3 道 Go 并发编程面试题"} ], "temperature": 0.7, "max_tokens": 500, "stream": false }'Anthropic Messages:
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", "system": "你是一名专业的面试官。", "messages": [ {"role": "user", "content": "帮我出 3 道 Go 并发编程面试题"} ], "temperature": 0.7, "max_tokens": 500 }'一眼看过去很像,但差别已经很明显了。OpenAI 把system当成messages数组里的一个角色;Anthropic 把system单独提到顶层。max_tokens在 OpenAI 那里是可选的,在 Anthropic 这里必填,而且语义更严格:它只约束输出 token 上限,不计输入。
我写例子用的是gpt-4o和claude-3-5-sonnet-20241022,不代表推荐,动手时以官方最新 model list 为准。
2.2 容易踩的字段:system、max_tokens、stop、response_format
先说system。OpenAI 从很早就支持messages里的system角色,后来还加了developer角色,用来承载开发者风格指令;而 Anthropic 是在请求体最上层单独放一个system字段。两者都会计入 context 长度,但你在适配层里做消息归一化时,必须先把system摘出来,再按目标协议重新放回去。
再说max_tokens。这是我踩得最狠的一个坑。Anthropic 的 Messages API 里max_tokens必填,而且只算输出 token;OpenAI 的 Chat Completions 里max_tokens可选,默认情况下会根据模型能力自己判断。OpenAI 的 o1/o3 系列又引入了max_completion_tokens,如果你传旧的max_tokens会被拒绝或忽略。也就是说,同一个“输出长度限制”,在 OpenAI 新旧模型之间、OpenAI 与 Anthropic 之间,字段名和必填性都在变。工程上最省心的做法是统一维护一份“模型参数模板”,每个模型对应一组默认值,而不是在代码里到处判断模型名。
还有stop和stop_sequences。OpenAI 的字段叫stop,可以传一个字符串或字符串数组;Anthropic 叫stop_sequences,只接受字符串数组。如果你把 OpenAI 的stop: "END"直接填到 Anthropic 请求里,不会报错,但行为不是你想的那样,因为 Anthropic 期望的是["END"]。
response_format也一样。OpenAI 有原生response_format,可以指定json_object或 JSON Schema,让模型严格输出结构化 JSON;Anthropic 目前没有完全等价的顶层字段,我通常用提示词约束,或者用工具调用(tool use)来拿结构化输出。把“OpenAI 能严格 JSON”这件事直接搬过去,是接 Anthropic 时最容易出现的预期差。
2.3 认证和公共 header 的坑
认证方式两套完全不一样。OpenAI 用Authorization: Bearer,Anthropic 用x-api-key,而且要带上anthropic-version这个请求头,比如2023-06-01。少了anthropic-version会直接报错,哪怕 API key 是对的也过不去。
很多从 OpenAI 迁过来的人会漏掉anthropic-version,因为 OpenAI 那边只要一个Authorization就完事了。Anthropic 还有一类anthropic-beta请求头,用于开启某些测试能力。这里我的建议是:不要把认证和业务模型混在一起,统一在 HTTP client 初始化的时候设置默认 header,这样业务层只关心messages和model。
说句题外话,如果你打开 OpenAI 最新文档,会发现/v1/responses这个新端点越来越重要,字段从messages变成了input,max_tokens变成了max_output_tokens。我在下文仍以 Chat Completions 为例做对照,因为存量代码和老例子最多;但新项目建议多看一眼 Responses API,避免刚写完适配层又要重构。
3. 响应结构与错误码:同一个 429,含义差多远
3.1 响应体:choices 和 content 不是一回事
请求长得像,响应长得更像,但取数方式完全不同。
OpenAI 的典型响应:
{ "id": "chatcmpl-xxx", "object": "chat.completion", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "好的,这是 3 道 Go 并发题..." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 25, "completion_tokens": 60, "total_tokens": 85 } }Anthropic 的典型响应:
{ "id": "msg_xxx", "type": "message", "role": "assistant", "content": [ { "type": "text", "text": "好的,这是 3 道 Go 并发题..." } ], "stop_reason": "end_turn", "usage": { "input_tokens": 25, "output_tokens": 60 } }核心差异有三点。第一,OpenAI 的文本在choices[0].message.content,Anthropic 的文本在content数组里,你要遍历出所有type == "text"的块再拼接。第二,OpenAI 的结束原因是finish_reason,Anthropic 是stop_reason,值也不一样,OpenAI 常见stop、length、tool_calls,Anthropic 常见end_turn、max_tokens、stop_sequence、tool_use。第三,OpenAI 的choices是个数组,同一个响应里可能有多个候选;Anthropic 默认只有一个 message 对象,没有choices这一层。
还需要注意:OpenAI 在工具调用时,message.content经常是null,真正的内容在message.tool_calls里。Anthropic 在工具调用时,content数组里会出现type == "tool_use"的块。如果你用“取 content 字符串”的习惯去兼容两边,工具场景下一定会拿到空字符串或者[object Object]。
3.2 usage 字段:计费口径不同
usage 字段是很多监控系统出 bug 的重灾区。OpenAI 用prompt_tokens和completion_tokens,Anthropic 用input_tokens和output_tokens。我在日志系统里最开始直接透传原始 usage,结果图表上指标对不上,后来才发现是字段名没映射。
字段映射并不复杂,但 stream 模式下要注意:OpenAI 默认流式响应不返回 usage,要额外传stream_options: {"include_usage": true};Anthropic 流式响应会在message_start事件里给input_tokens,在message_delta事件里给output_tokens的增量。如果你依赖“最后一次 chunk 里取总 usage”,两边行为完全不一样。
3.3 错误码和错误类型:别看到 4xx 就打印堆栈
两边的错误结构也不一样。OpenAI 错误体大致是:
{ "error": { "message": "Incorrect API key provided", "type": "invalid_request_error", "code": "invalid_api_key" } }Anthropic 错误体是:
{ "type": "error", "error": { "type": "authentication_error", "message": "invalid x-api-key" } }从语言表达上看,OpenAI 更依赖error.code,Anthropic 则把错误种类直接放在error.type里。我建议在统一错误处理层里做一次归一化:不管上游返回什么结构,最终对外抛出的异常对象都包含status_code、error_type、message、retryable四个字段,这样上层只判断retryable就好,不用关心是哪家 API。
特别说一下连接层的错误。很多人会遇到类似“unable to connect to anthropic services failed to connect to api.anthropic.com”的报错,这通常不是业务参数问题,而是网络出口不稳定、TLS 握手失败或服务端瞬时抖动。我的排查顺序是:先curl -I直连 API 域名,确认网络通不通;再看证书校验;最后确认是 5xx 还是 529。如果是服务端过载,指数退避重试就是正解,别把这类错误当 bug 去查请求体。
4. 流式输出与工具调用:最容易翻车的两个场景
4.1 流式事件:OpenAI 的 data 行和 Anthropic 的 event 行
流式输出是两套协议差异最明显的地方。OpenAI 的 SSE 流长这样,每行都是data:开头,结束标志是data: [DONE]:
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}]} data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}]} data: [DONE]Anthropic 的 SSE 流则同时有event:行和data:行:
event: message_start data: {"type":"message_start","message":{"role":"assistant","content":[],"usage":{"input_tokens":25,"output_tokens":1}}} 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"},"usage":{"output_tokens":60}} event: message_stop data: {"type":"message_stop"}如果你用同一个 SSE 解析库去处理两边,至少要注意三点。第一,OpenAI 只需要解析data:行,Anthropic 需要同时读取event:和data:,因为event决定了当前 data 的含义。第二,OpenAI 的增量内容在choices[0].delta.content,可能还有delta.tool_calls;Anthropic 的增量在content_block_delta.delta.text,工具参数增量在input_json_delta里。第三,结束判定不一样,OpenAI 是[DONE]哨兵,Anthropic 是message_stop事件,不要只按“流是否关闭”来判断完成。
我在实际项目里用状态机来处理流式解析,状态包括awaiting_message_start、streaming_content、awaiting_message_delta、done。每收到一个事件就喂给状态机,比散落一地的if判断可靠得多。
4.2 工具调用:schema 长得很像,回传方式完全不同
工具调用是现在 Agent 应用的核心,也是协议差距最大的一块。
OpenAI 的请求是这样定义工具的:
{ "model": "gpt-4o", "messages": [{"role": "user", "content": "深圳明天适合穿什么?"}], "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "查询城市天气", "parameters": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] } } } ], "tool_choice": "auto" }Anthropic 是这样:
{ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "深圳明天适合穿什么?"}], "tools": [ { "name": "get_weather", "description": "查询城市天气", "input_schema": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] } } ], "tool_choice": {"type": "auto"} }OpenAI 多包了一层type: "function",参数结构用parameters;Anthropic 直接用input_schema。两者都遵循 JSON Schema 规范,所以很多工具定义可以直接复用,但外层壳子必须转换。
更大的坑在后面。模型要调用工具时,OpenAI 会在响应里给tool_calls:
{ "choices": [ { "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"深圳\"}" } } ] } } ] }Anthropic 则给一个tool_use内容块:
{ "content": [ { "type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"city": "深圳"} } ] }注意,OpenAI 的工具参数arguments是字符串,需要JSON.parse;Anthropic 的input已经是对象。很多只接 OpenAI 的代码在 Anthropic 上会栽在这里。
回传工具结果时,OpenAI 要求你用role: "tool"的消息,里面带上tool_call_id:
{ "role": "tool", "tool_call_id": "call_abc", "content": "26度晴" }Anthropic 要求你把它包装成用户消息里的tool_result块,并且要放在原 assistanttool_use之后:
{ "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "toolu_abc", "content": "26度晴" } ] }如果你在适配层里没有正确处理这种结构,多轮工具对话一定会乱。我最开始给 Anthropic 传了一串 OpenAI 风格的toolrole 消息,结果一直 400,后来才意识到它根本不认识这个 role。
4.3 多轮工具链的坑
多轮工具调用比单轮更容易翻车。OpenAI 这边,tool消息后面必须跟着用户消息或新的 assistant 消息,而且要保证tool_call_id与之前的调用对应。Anthropic 那边,tool_result必须放在用户消息中,并且要与之前 assistant 消息里的tool_use_id对应。如果一次要并行调用多个工具,Anthropic 会把多个tool_use块放在同一个 assistantcontent数组里,回传时也要在同一个用户消息里放多个tool_result块。
还有一个容易忽略的点:OpenAI 的tool_choice支持"none"、"auto"、"required"或指定特定函数;Anthropic 的tool_choice则用{"type": "auto"}、{"type": "any"}、{"type": "tool", "name": "get_weather"}。很多迁移代码只改了auto,其实 Anthropic 没有"required"这个值,要强制走工具就得用any。我建议在适配层提供统一的方法:useTools("auto")、forceTool("get_weather")、noTools(),按语义映射到两边各自的tool_choice。
5. 常见报错速查表与排查思路
5.1 高频错误对照表
下面这张表是我根据生产环境日志归纳的,不能覆盖全部情况,但足够应对绝大多数日常问题:
| 场景 | OpenAI 常见错误 | Anthropic 常见错误 | 排查方向 |
|---|---|---|---|
| API key 错误 | 401,invalid_api_key | 401,authentication_error | 检查 key 是否过期、环境变量是否注入 |
| 权限不足 | 403 | 403,permission_error | 检查 key 绑定的组织和权限范围 |
| 模型不存在 | 404,model not found | 404,not_found_error | 检查 model 名称拼写和账号可用的模型列表 |
| 请求体不合法 | 400,invalid_request_error | 400,invalid_request_error | 对照官方文档检查必填字段和类型 |
| 上下文超长 | 400,提示 max context length | 400,invalid_request_error,提示 prompt too long | 截断历史消息或做摘要,看的是 prompt tokens |
| 限流 | 429,rate_limit_exceeded | 429,rate_limit_error | 检查并发配额,做排队和退避 |
| 余额/配额不足 | 429,insufficient_quota | 429,permission_error或rate_limit_error | 查账户余额、组织配额 |
| 服务过载 | 500 或 503 | 529,overloaded_error | 指数退避重试 |
| 内部错误 | 500 | 500,api_error | 保留请求 ID,提交工单 |
这里最值得强调的是上下文超长。很多人写代码时只检查max_tokens,其实超长通常发生在输入侧,也就是 prompt 太长。日志里那种“this model's maximum context length is 1048576 tokens”的报错,表面上是模型上下文 1M,实际上是你塞了太多内容。千万不要只把max_tokens调小,要看usage.input_tokens和总 context 的占比,然后对历史消息做截断、摘要或检索压缩。
5.2 我实测过的三个高发坑
第一个坑是 Anthropic 的max_tokens必填。如果你迁移 OpenAI 代码时忘了这个字段,会收到 400,而且报错信息里会明确说max_tokensis required。这类问题看起来小,但在批量切换模型时很容易被忽略,因为 OpenAI 不传也能跑。
第二个坑是内容结构。OpenAI 的content在文本模式下是字符串,Anthropic 的content在响应里一定是数组。如果你统一用message.content直接返回给前端,Claude 的场景你要先拼文本块。我见过一个线上事故,前端拿到的 Claude 回答变成[object Object],就是因为 content 是个数组。
第三个坑是工具参数格式。OpenAI 的arguments是 JSON 字符串,Anthropic 的input是对象。如果你在适配层不做转换,直接透传给业务函数,轻则类型报错,重则拿到undefined。这个坑我会在后面的适配层代码里重点处理。
5.3 网关接入时的模型名错误
很多团队会把 OpenAI 和 Anthropic 接到同一个 API 网关里统一管理,这时候最常见的一类报错是“doesn't look like an anthropic model: expected a gateway model route reference”。我看过很多同事卡在这里,本质上是请求里的 model 名没有正确映射到目标供应商,网关把claude-xxx当成了自己路由表里的一个 provider 名,但路由表里根本没有这个前缀。
解决办法不是猜报错,而是去检查网关里的模型路由配置:把传入的 model 名拆解成provider + model,比如openai/gpt-4o、anthropic/claude-3-5-sonnet-20241022,再根据路由表映射到真实的 API 地址。如果你是自己写适配层,建议在构造请求前先做一次 model 名归一化,统一存成{provider, model}结构,而不是直接拿用户传入的字符串去拼请求。
6. 双写适配层与工程建议
6.1 统一消息模型
如果只是单接一家,官方 SDK 即可。但你要同时支持 OpenAI 和 Anthropic,我强烈建议在业务层和 HTTP 层之间加一个适配层,所有请求都先转换成统一消息模型,再按目标协议渲染。这样业务代码就不会出现if provider == "openai"满天飞的情况。
下面是一个最小可用的 Python 数据结构:
from dataclasses import dataclass from typing import Any @dataclass class ChatMessage: role: str # system / user / assistant / tool content: Any = None tool_call_id: str | None = None tool_calls: list | None = None name: str | None = None适配 OpenAI 时,把role == "tool"的消息转成 OpenAI 的role: "tool",并带上tool_call_id;适配 Anthropic 时,把role == "tool"的消息转成role: "user",内容包成tool_result块,同时把system消息摘出来放到顶层。
要注意,Anthropic 要求消息尽量按 user/assistant 交替,连续多个 user 消息最好合并成一个。OpenAI 对连续消息没那么敏感,但 Anthropic 那边如果出现连续两个 user 角色,接口通常不会报错,模型对上下文的感知会受影响。我在适配层里做了一个 merge 函数:相邻同角色文本消息合并,tool_result块追加到当前 user 消息里。
6.2 错误分类归一
错误处理也建议归一。我常用的做法是先把两边的错误响应解析成统一结构:
@dataclass class LlmError(Exception): status_code: int error_type: str message: str retryable: bool429 和 529 都标记为可重试,400 需要告警但不要自动重试。连接层异常如超时、ConnectionError也标记为可重试,但要带上退避策略。这样上层调度器只需要看retryable,不需要知道具体是 OpenAI 还是 Anthropic。
流式解析同样可以归一。我建议把两边的 SSE 事件统一抽象成三个回调:on_delta(text)、on_tool_call(tool_name, args)、on_done(stop_reason)。OpenAI 的[DONE]和 Anthropic 的message_stop都映射到on_done;OpenAI 的delta.content和 Anthropic 的text_delta都映射到on_delta。做的时候会麻烦一点,但上层 UI 完全不用感知供应商差异。
6.3 我的工程建议
最后分享几个比较实际的建议。
如果你是老项目,先别急着重构整个调用链,可以用一个纯函数库先做协议转换,跑通后再逐步替换。工具定义大概率可以复用同一份 JSON Schema,先把这个基础打通,能省一半时间。新项目如果预算允许,优先考虑用官方 SDK 封装适配层,不要自己拼 HTTP,否则认证 header、流式解析、错误结构这些细节都会是你的维护成本。
我个人在实际操作中体会最深的一点是:不要把“供应商切换”做成“复制粘贴请求体”。两套 API 协议确实有相似之处,但差异都藏在细节里。与其每次切换模型都胆战心惊地看报错,不如花半天时间把请求构造、响应解析、错误分类、流式事件四层全做上适配。我自己的项目里,这套适配层已经跑过多次生产切换,GPT 和 Claude 来回换,业务代码一行都不用改,这种安逸感是用钱买不来的。