我们在做一套用 Claude Code 自动解 code 题的系统:在一个容器里跑 cc,使用一个外层框架驱动 cc 一轮轮干活。我们想让它除了 Claude 官方模型,也能用第三方托管或自部署的开源模型(比如 GLM、Qwen),结果第一步就卡住了:Claude Code 这个客户端只支持 Anthropic 的 API 协议,而那些模型对外提供的几乎都是"OpenAI 兼容"端点,两边格式对不上,请求没法直接发。
对于这个问题,有标准的解决办法:在 cc 和模型之间加一个 sidecar 代理(跟主进程跑在同一台机器上、专门做协议翻译的小服务),把 Anthropic 格式的请求翻成 OpenAI 格式再转发。但要把这层翻译接稳,得先搞清楚两套协议到底差在哪。协议只是 HTTP 接口的约定,比如请求打哪个路径、API key 放在哪个请求头里、返回的 JSON 字段叫什么。请求头就是 HTTP 请求里携带元信息的字段,身份凭证、内容格式这类信息都放在这里;这些跟模型本身无关,同一个模型可以同时挂在两套协议下对外。
这篇文章是一份(经过我们实测的)OpenAI 兼容模式 vs. Anthropic 协议的差异清单,以及相关踩坑记录。
1 两套协议是什么
- Anthropic Messages API :Anthropic 官方协议,请求路径
POST /v1/messages。 - OpenAI Chat Completions API (即"OpenAI 兼容模式"):请求路径
POST /v1/chat/completions。sglang、vLLM 这类推理框架,以及各家的模型托管服务,部署后大多提供这套端点。叫"OpenAI 兼容"是因为它模仿了 OpenAI 的请求/响应格式,跟 OpenAI 自家的模型没有关系。 - 另外还有较新的第三套 OpenAI Responses API (
POST /v1/responses),能直接回传思考内容;本文只在相关的地方顺带提到它。
2 两套协议的 diff
逐项列差异之前,先看两个最小的请求实例。同一个任务(带一句 system prompt、一句用户输入),两套协议分别长这样:
Anthropic 协议,请求打 POST /v1/messages:
json
{
"model": "claude-opus-4-6",
"system": "你是一个编程助手",
"max_tokens": 64000,
"messages": [
{"role": "user", "content": [{"type": "text", "text": "你好"}]}
]
}
OpenAI 兼容,请求打 POST /v1/chat/completions:
json
{
"model": "glm",
"messages": [
{"role": "system", "content": "你是一个编程助手"},
{"role": "user", "content": "你好"}
]
}
对照看,能直接看出三个区别:
- system prompt 在 Anthropic 侧是顶层的
system字段,在 OpenAI 侧只是messages里role:"system"的一条消息; - user 消息的正文(
content)在 Anthropic 侧是一个"内容块"的数组,在 OpenAI 侧就是一串文本;max_tokens在 Anthropic 侧按规范必填,在 OpenAI 侧可以不写。 - 另外 API key 放的位置也不同(在请求头里,不在 JSON 体里)。
逐项差异见下表,踩过坑的几行,会在后面展开:
| 维度 | Anthropic (/v1/messages) |
OpenAI 兼容 (/v1/chat/completions) |
|---|---|---|
| API key 放在哪个请求头 | x-api-key(或 Authorization),另带 anthropic-version,可带 anthropic-beta |
Authorization: Bearer <key> |
| system prompt 放哪 | 顶层独立字段 system,不进 messages |
作为 messages 里 role:"system" 的一条 |
| 输出上限 max_tokens | 官方规范必填(实际网关不一定强制,见坑二) | 可选(新版名【】这里的新版是什么意思?max_completion_tokens) |
| 消息正文长什么样 | "内容块"的数组:text/image/tool_use/tool_result/thinking 各是一种块 |
一整串文本,多模态时才是 [{type,...}] |
| 模型要调工具 | 回一个 tool_use 内容块 |
回 message.tool_calls 数组 |
| 工具结果回传 | 塞进 user 消息里的 tool_result 块 |
单独一条 role:"tool" 消息 |
| 这轮为什么停了 | stop_reason:end_turn/tool_use/max_tokens |
finish_reason:stop/tool_calls/length |
| token 用量字段 | input_tokens/output_tokens |
prompt_tokens/completion_tokens |
| 流式输出怎么分段 | 细粒度:message_start→content_block_delta→...→message_stop 一串事件 |
粗粒度:只有一种 choices[].delta 片段,末尾 data: [DONE] 收尾 |
| 思考过程怎么表示 | thinking 块(带签名);用 budget_tokens 设思考预算 |
reasoning_content 流式片段;或用 reasoning_effort 调档位 |
| 前缀缓存 | 显式打 cache_control 断点【】这个是什么意思? |
自动前缀缓存,无标记 |
表里有三个词需要解释。
- 内容块 :Anthropic 协议里一条消息的正文不是一整串文本,而是一个数组,每个元素(块)带一个
type,文字、图片、工具调用、工具结果、思考各是一种块,所以一条消息里可以既有文字又有工具调用。 - 流式 :模型边生成,边把输出切成小片段陆续推送,客户端收到一段就能显示一段,底层走 SSE(服务端往同一个 HTTP 连接里持续推
data: ...行);两套协议都支持流式,只是切事件的粒度不同(见表)。 - 思考内容 :Anthropic 用独立的
thinking内容块装模型的推理过程,块上带服务端签发的签名,多轮对话时要原样回传校验;OpenAI 兼容协议里没有思考块,推理混在流式输出的reasoning_content字段里分段返回。注意reasoning_content不是 OpenAI 原生的:OpenAI 官方的思考型模型只让你用reasoning_effort调思考力度,思考原文不回传,官方文档的原话是 "reasoning tokens are not visible via the API"。这个字段是 DeepSeek 先定义的,DeepSeek-R1 的 API 用它回传思考原文,Qwen 等国产厂商随后沿用了同一个字段名。一个旁证:开源推理框架 vLLM 里解析这类思考输出的解析器就叫deepseek_r1,连 QwQ 都复用它。
3 踩坑记录
坑一:system prompt 放错位置,直接 400
前面的请求示例里已经能看到,两套协议给 system prompt 安排的位置不一样:Anthropic 放在请求体顶层,OpenAI 混在 messages 里。这个位置差异是我们踩过的 400 里最常见的一个。
当时的现象是:同一个请求,打我们的网关好好的,打自部署的 sglang 就直接 400。查下来发现,Claude Code 有时会把一条 system 指令放进 messages 数组,宽容的实现能收,但 sglang 的 /v1/messages 严格要求 messages 只含 user/assistant,多一个 role 就拒。
所以我们在翻译层加了一步规范化:把混在 messages 里的 system 抽出来,合并进顶层 system 字段。注意这一步只对 /v1/messages 做,OpenAI 协议里 system 混在消息里本来就合法,不需要动。
总结:OpenAI 侧 system 混在消息里合法;Anthropic 侧必须单独放顶层,混在消息里的严格实现会 400。
坑二:任务不报错,却卡死不动
有一次我们用 opencode(一种开源的 scaffold,即包在模型外面、驱动它一轮轮干活的程序)跑题,发现任务既不报错也不结束,就停在那里不动。
翻代理日志逐条取证才发现:几十次请求的收尾原因几乎全是"输出长度触顶",其中 16 次的输出 token 数精确顶在 4096 这个整数上。被截断的是半截工具调用 JSON,客户端解析不了也不报错,就一直干等。等我们发现时,任务已经这样静默挂了 7 小时 53 分。问题是 opencode 自己根本不填 max_tokens(输出上限),那这个 4096 是谁填的?
一开始我们以为是上游强制的,因为 Anthropic 官方规范里 max_tokens 确实是必填字段。但拿 curl 直接打网关实测(每种写法各试 3 次),结果正相反:完全不带 max_tokens 也能正常返回 200,带 4096、带 64000 都收得下。真正填上这个值的是中间的翻译层 litellm(一个开源的多后端代理):它看到请求没带 max_tokens,就按自己的默认实现补了个 4096。
总结:规范说必填,不代表网关真强制;输出莫名被截断时,先查中间层是不是替你填了个小默认值。
坑三:报"模型不存在",多半是打错了入口
预检时我们发现一个现象:同一个模型名 claude-sonnet-4-6,打 /protocol/openai/v1/chat/completions 正常返回(试了 3 次都是 200),打同一个网关的 /protocol/openai/v1/responses 却报 NoAvailableModels(3 次都是 400)。
原因很简单,但容易忽略:网关上的模型是按协议入口注册的,同一个模型往往只挂在某一个入口下。我们用的托管版 GLM 也一样,只在 compatible-mode 入口注册,打 anthropic 入口就是"不存在"。所以报"模型不存在"时,先别怀疑模型下架了,先核对入口打没打对。
顺着多说两句。Claude Code 选"1M 上下文变体"靠在模型名末尾拼 [1m] 后缀,这只是 Anthropic 侧的客户端约定,OpenAI 兼容路没有这个概念,把带后缀的名字发过去同样会报"未注册"。另外,不同翻译层对陌生名字的容忍度也不同:litellm 严格校验,模板里忘了替换的占位名会被它当场拒掉;我们 anthropic 侧的代理有默认档兜底,同一个占位名反而能正常通过,排查时这种"一路通一路不通"的现象就是这么来的。
排查时还要分清两类报错:400 / NoAvailableModels 这类是名字或入口不对;401 是 key 不对,服务端认不出你(我们踩过的实际原因:容器里的 claude 拿到的是占位用的假 key,真 key 没传进去)。
总结:"模型不存在"先查协议入口,再查模型名;400 和 401 别混。
坑四:思考型模型把输出额度吃光,整轮作废
先说两套协议怎么表示"模型思考"(给正式答案前的推理过程):
- Anthropic 侧 用独立的
thinking内容块装思考,块里带一个signature(服务端签发的校验串);多轮工具调用时,带签名的思考块要原样回传、供服务端校验。想控制思考多长,用budget_tokens参数给思考单独设预算,花完就停止思考、转入正式作答。 - OpenAI 兼容侧 没有独立的思考块,思考内容混在流式输出里,放在每个片段的
delta.reasoning_content字段里吐出来。 - 这里有个容易混淆的点:
reasoning_content不是 OpenAI 官方协议原生的字段 。OpenAI 官方的思考型模型(o 系列)走reasoning_effort参数,选 low/medium/high 档位,而思考过程本身不回传原文;reasoning_content这个"把思考原文塞进流式片段"的做法是国内厂商的扩展(DeepSeek 首创、Qwen 等跟进),是加在 OpenAI 兼容格式之上的。
问题:接 GLM 这类"思考型"模型(也就是在给正式输出前先输出一大段推理)时,我们发现思考 + 正式输出的长度,远大于单轮 64K 的输出 length 上限,所以导致输出被截断,解题过程作废。
问题出在额度分配上。前面提到,这类模型的思考内容也是输出的一部分,和正文共享同一个输出上限。它的推理又特别重,思考长度很长,会导致整轮输出触顶被截断。更麻烦的是,Claude Code 的非交互模式会把"因为触顶而停" stop_reason=max_tokens 判成致命错误,整题直接作废。
解法是在代理端把思考的额度和正文分开管:总输出上限 64K 不变,单独给思考封 24K 的预算,思考花完自己的额度就必须转入正文。改完之后正文再没被挤掉过。
总结:接思考型模型,一定要给思考单独设预算(Anthropic 侧有现成的 budget_tokens 参数),别让它和正文抢同一个输出上限。
坑五:模型思考过久、迟迟不返回,客户端 300 秒就放弃了
思考型模型还有个更隐蔽的问题:上下文一大,它可能要思考好几分钟才开始返回内容。我们实测最久的一次,等了 943.7 秒才收到第一个数据块。而 Claude Code 等第一个数据块最多只等 300 秒,超时就把这一轮判废了。
解法是代理在等数据的空档往流式连接里插"心跳"(SSE 注释行),让客户端一直能收到东西,不至于判超时。但心跳的位置有讲究:SSE 流由一个个事件组成,每个事件是一条以 data: 开头、以空行结尾的文本块,块里装着一个完整的 JSON。心跳必须插在两个事件之间的空档处,不能插进事件内部。我们最早没注意这点,心跳落进事件内部、把里面的 JSON 劈成两半,客户端解析直接报错,中招率约 20%。
总结:慢模型要配心跳保活,且心跳必须插在两段 SSE 事件之间的空档处。
坑六:客户端带了网关不认识的协议头
Anthropic 协议有一个 anthropic-beta 请求头,用来声明要启用哪些测试中的新特性,网关只认它支持的那些值。Claude Code 内置的一个安全扫描工具会往这个头里塞一个较新的特性名,我们的网关不认识,直接 400。
解法很简单:客户端官方提供了关闭开关,让它别发这个头就行。这类"客户端比网关新"的冲突只会出现在 Anthropic 侧,OpenAI 兼容协议没有这种头。
总结:网关 400 且报错提到 anthropic-beta,就是客户端声明了网关不认的特性,关掉对应的客户端功能即可。
4 举例,sidecar 可能会怎么接
完整的请求链路长这样(以托管服务上的 GLM 为例):
容器内 claude ──(Anthropic /v1/messages)──▶ localhost 上的 sidecar
│ 翻译 Anthropic → OpenAI
▼
内部模型网关的 compatible-mode 入口 ──按模型名前缀转发──▶ 托管服务后端
我们的 sidecar 按协议分两个:cc-proxy 收 Anthropic 协议(/v1/messages),是 Claude Code 走的那个;litellm 收 OpenAI 协议(/chat/completions 或 /responses),opencode、codex 这些 scaffold 的 openai 档走它。选哪个由一个 protocol 开关决定。
这里有个反直觉的坑:我们的接法是故意不给 claude 设 ANTHROPIC_BASE_URL ,让它回落到 localhost 上的 sidecar 地址。一旦注入了这个变量,claude 会绕过 sidecar 直连你填的地址。托管服务的端点只认 OpenAI 兼容协议,直连必失败。只有一种情况该直连:后端本身就是 Anthropic 协议端点(比如自部署 sglang 暴露了 /v1/messages),这时才显式注入 ANTHROPIC_BASE_URL 指过去,不经翻译。
最后是一张协议对照表:
| 后端是什么 | 走哪套协议 | 怎么接 |
|---|---|---|
| Claude 官方模型 | Anthropic | claude 直连,或经 cc-proxy |
| 托管的开源模型(GLM、Qwen 等) | OpenAI 兼容 | 必须走 sidecar 翻译 |
| 自部署 sglang(暴露 /v1/messages) | Anthropic | 可直连 |
| 自部署只暴露 /chat/completions | OpenAI 兼容 | 走 litellm sidecar |
5 两个 debug 建议
1. 接之前先 curl 预检。 我们的环境里起一个任务要等十几分钟才分到机器,接错很浪费,所以先用一条 curl 确认"模型名 + key + 协议入口"三者对得上:
bash
API_KEY="<你的 key>" # 网关颁发的 key
curl -sS -m 60 \
"https://<网关地址>/protocol/openai/compatible-mode/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"model":"<模型名>","messages":[{"role":"user","content":"say pong"}],"max_tokens":20}'
通了会在响应里看到后端的真实模型名;回 NoAvailableModels 就是模型名或协议入口对不上,比如坑三的情况。
2. 接完后,需要确认真的走了 sidecar。 两个信号:任务产物里 proxy 的日志目录有一堆请求记录文件,说明确实经过了代理(直连模式没有这个目录);最后一条结果记录里的 stop_reason 正常应是 end_turn,如果是 max_tokens,就是撞了坑四的截断。另外结果里显示的模型名可能被客户端规整成占位名,别被它骗了,要以网关响应里的真实模型名为准。