当前位置:首页 > 逆向工程 > 正文

同一句 OpenAI 兼容,四家的错误体是三种格式

约 5578 字 · 预计阅读 14 分钟
一句话总结:我对着四家 AI 服务各发了一次无鉴权请求,只读响应头和错误体。结果发现「OpenAI 兼容」在错误处理上根本不兼容:DeepSeek 返回纯文本,Moonshot 返回标准 JSON 但漏了一个内部灰度标记,智谱返回中文 JSON 且字段结构不同。顺带从响应头里看出了三家的 CDN 供应商。

写调用代码的时候,我习惯把错误处理写成这样:

try:
    resp = client.chat.completions.create(...)
except Exception as e:
    print(e)

能用,但不知道错在哪。上周想看看到底是什么错,就把几个服务的错误响应抓下来对比了一遍。

结果比预想的有意思。

一、方法:只发一次无鉴权请求,看响应头

做法很简单,对每个服务的 /v1/models 发一次不带 token 的请求:

curl -s -D - -o body.txt "https://api.deepseek.com/v1/models"

-D - 把响应头打到标准输出,-o 把响应体存文件。然后看两样东西:响应头里的服务器标识,和错误体的结构。

每个端点只发一次,不做任何扫描。这个量级的请求和正常用户误操作没区别。

四个样本:DeepSeek、Moonshot、智谱、OpenRouter。

二、四家的错误体放在一起看

同一句 OpenAI 兼容,四家的错误体是三种格式

图 1:只有 Moonshot 的字段结构和 OpenAI 对得上

服务 Content-Type message type code
DeepSeektext/plain无(纯文本)——
Moonshotapplication/json有有—
智谱application/json有(中文)—有
OpenRouter200,没触发错误———

OpenAI 的错误体长这样:

{"error": {"message": "...", "type": "...", "param": null, "code": "..."}}

四个字段。四家里没有一个完全按这个返回。

  • Moonshot 有 message 和 type,缺 param 和 code
  • 智谱有 message 和 code,缺 type 和 param,而且 code 是字符串 "1001" 不是数字
  • DeepSeek 干脆不是 JSON

三、DeepSeek 的错误体根本不是 JSON

它的完整响应体是这样:

Authentication Fails (governor)

31 字节,纯文本,Content-Type: text/plain。

「governor」这个词值得看一眼。 它不该出现在面向用户的错误信息里——这是内部服务名,大概是负责鉴权和限流的网关组件,被直接写进日志文案然后原样返回了。

对调用方的实际影响是:任何按 OpenAI 约定写 response.json()["error"]["message"] 的代码,遇到 DeepSeek 的 401 会直接抛 JSONDecodeError。

这个异常和「鉴权失败」是两回事。你的日志会记录「JSON 解析错误」,而真实原因是「token 写错了」。排查方向完全跑偏。

我猜大部分 SDK 都会在这一步栽一次。有些 SDK 做了兼容(先判断 content-type 再解析),有些没做。

四、Moonshot 漏了一个内部标记

它的错误体是最规范的:

{"error": {"message": "Incorrect API key provided", "type": "incorrect_api_key_error"}}

结构和 OpenAI 一致(少两个字段),type 的值「incorrect_api_key_error」也是 OpenAI 风格的命名。

但响应头里有个东西不该在:

x-internal-adhoc-canary: 984034

我连发三次,值是 984034、3716743850、2611629965——每次都不一样。

x-internal-adhoc-canary 的翻译就是「临时金丝雀」,是灰度发布时用来标记「这个请求打到了哪个实验实例」的内部头。它出现在公网响应里,说明中间某一层没把它剥掉。

单看这个头没危害。但它透露了一件事:内部流量标记体系直接暴露在外。如果哪天这个头变成有语义的版本号或分片标识,等于把内部发布结构公开。

同一批响应头里还有:

Set-Cookie: aliyungf_tc=a0967d0f...
Msh-Request-Id: d4943694-...
Msh-Trace-Mode: on

aliyungf_tc 是阿里云 CDN 的 cookie。说明 Moonshot 前面挂的是阿里云。

五、响应头暴露的技术栈

把四家的头放在一起,能看出它们各自的技术选型:

服务 关键响应头 推断
DeepSeekServer: openresty、EO-LOG-UUID、EO-Cache-StatusOpenResty + 腾讯 EdgeOne
Moonshotaliyungf_tc cookie、Msh-Trace-Mode阿里云 CDN + 自建追踪
OpenRouterServer: cloudflare、CF-RAY: ...-SEACloudflare,节点在西雅图
智谱X-LOG-ID自有网关

四家用三家不同的云。 这个分布挺能说明问题:没有一家用「最优解」,都是按自己的合规、成本、历史包袱选的。

DeepSeek 那组特别干净地暴露了链路:openresty 是源站,EO-* 是腾讯 EdgeOne 的边缘节点在回源。看到 EO-Cache-Status: MISS 就知道这次请求穿透到了源站。

OpenRouter 那边更有意思。 它的 Vary 头写着:

Vary: Host, User-Agent, Originator, Anthropic-Version

Anthropic-Version 是 Anthropic API 的版本头。它出现在 Vary 里,说明 OpenRouter 会根据这个头返回不同内容——也就是它支持 Anthropic 的协议格式,不只是 OpenAI 的。

这个信息在它的文档里可能也有,但从响应头推出来更直接。顺带它还暴露了:

Access-Control-Expose-Headers: X-Generation-Id, x-openrouter-error-origin, x-openrouter-error-tool, ...

x-openrouter-error-origin 和 x-openrouter-error-tool 这两个头名字说明:它内部区分「错误是上游 provider 造成的,还是自己造成的」,以及「是哪个工具调用出的错」。这是它做聚合层必须解决的问题,从头名就能反推出来。

这种「从暴露出来的痕迹反推内部结构」的做法,模型 ID 那篇用的是同一个路子:那次是从 rc-ada-* 这种命名反推发布阶段,这次是从响应头名反推系统分工。共同点是:对方没打算告诉你,但也没打算藏。

六、错误体不统一,调用方要写四套解析

回到实用层面。这件事的代价是什么?

如果你只调一家,代价是零。 写死就行。

如果你调多家(或者用 OpenRouter 这种聚合层),代价是每个服务一套错误处理。

举个具体的。你想做一个「token 失效就提醒用户重新配置」的功能:

def is_auth_error(resp):
    if resp.status_code != 401:
        return False
    try:
        data = resp.json()
        return "auth" in str(data).lower() or "key" in str(data).lower()
    except ValueError:
        # DeepSeek 这种情况:纯文本
        return "Authentication" in resp.text

这个函数丑,但必须这么写。任何「统一错误处理」的抽象,在遇到纯文本响应的那一刻都会破。

反过来,这也解释了为什么统一的推理端点要做好不容易——它不只是把路径转发一下,还要把六种引擎各自的错误格式归一化成一种。归一化过程中丢掉的信息,就是你在排查时最想要的那部分。

七、坑清单

1. 别假设错误体一定是 JSON。

先看 Content-Type,或者干脆用 try: resp.json() except 包一层。我见过不止一个 SDK 在这里崩。

2. 401 不等于「token 错了」。

也可能是「token 过期」「额度用尽」「这个 key 没有该模型的权限」。DeepSeek 那句 Authentication Fails 后面还带个 (governor),说明是网关层拦的,不是模型层。网关拦的错误和业务层的错误,处理方式不一样。

3. 响应头里的 trace id 要存。

DeepSeek 的 x-ds-trace-id、Moonshot 的 Msh-Request-Id、智谱的 X-LOG-ID——这些是提工单时对方唯一认的东西。日志里不带它,你说「我报错了」,对方没法查。

4. 别依赖响应头推断的「内部信息」做判断。

我上面推的 CDN 供应商、内部服务名,都是观察不是事实,可能明天就变。技术上可以参考,业务逻辑里绝对不能依赖。

5. Vary 头会改变缓存行为。

OpenRouter 的 Vary 里有 User-Agent 和 Anthropic-Version,意味着同一个 URL 换个头就是另一份缓存。你写爬虫或者缓存层的时候要按这个来,否则会拿到错的内容。

八、结语

「OpenAI 兼容」这四个字,实际含义是「路径和主要字段对得上」,不包括错误处理、不包括流式细节、不包括工具调用的边界情况。

这不是谁的错。兼容一个协议,本来就只需要兼容到「能跑通」这一层。 剩下的部分各家按自己的情况做,很正常。

但对调用方来说,这意味着:「换个 base_url 就能切换」这句话,只在你不用错误处理的时候成立。

我这次的收获是两个具体的:知道 DeepSeek 会返回纯文本,所以代码里加了 try/except;知道四家的 trace id 头名不一样,所以日志里统一记了三个字段。

这种东西文档里不写,得自己撞一次。

参考资料

目录导航
  • 一、方法:只发一次无鉴权请求,看响应头
  • 二、四家的错误体放在一起看
  • 三、DeepSeek 的错误体根本不是 JSON
  • 四、Moonshot 漏了一个内部标记
  • 五、响应头暴露的技术栈
  • 六、错误体不统一,调用方要写四套解析
  • 七、坑清单
  • 八、结语
    • 参考资料
  • 相关文章:

  • Atomic 本地推理服务器:一个端点接六个引擎,坑在兼容层
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。