同一套「OpenAI 兼容」标签下两家实现可能表现迥异——流式 tool call 分块、retry 逻辑错误体等边界情况会导致生产环境事故。
两个端点都对外宣称"OpenAI 兼容"。你把应用指向第一个,一切正常。指向第二个,前几天也一切正常——然后某天,一个流式返回的 tool call,其参数以你未曾预料的方式分散在多个 chunk 中,JSON 解析器在重试循环里抛出异常,重试循环因为错误响应体里没有你的 backoff 代码所读取的字段而对端点发起狂轰滥炸。
没有任何人说谎。"OpenAI 兼容"从来就不是布尔值。它是一片表面区域,每个实现都覆盖了不同的子集。
我维护着一个网关,所以读了很多兼容性 bug 报告。声明一下:我在 daoxe 工作,这是一个多模型网关,除其他协议外也支持 OpenAI 协议。下面的检查清单特意写成你可以用它来测试我们、也可以测试任何其他实现的样子,末尾的脚本不知道也不关心你指向哪个端点。如果它在某个检查项上让我们显得很差,那是正确的输出。
流式 delta。参考行为有具体规范:第一个 chat.completion.chunk 携带 choices[0].delta.role = "assistant",通常 content 为空;中间 chunk 携带 delta.content 片段;最后一个带内容的 chunk 携带 finish_reason;流以字面的 data: [DONE] 行终止。实现在这四个点上都会分化。有些从不发送 role chunk,这会破坏用它来开启消息的客户端。有些把 finish_reason 放在一个 trailing chunk 上且 delta 为空。有些完全省略 [DONE],直接关闭连接——这对读 EOF 的客户端没问题,对阻塞等待 sentinel 的客户端则是致命的。
Tool / function calling。这里有两个陷阱。首先,function.arguments 是一个 JSON 编码的字符串,不是对象——那些"好心"返回已解析对象的端点会破坏每一个对它调用 json.loads 的客户端。其次,在流式模式下,tool calls 以片段形式到达,你需要通过 index 字段来重新组装,id 和 function.name 通常只出现在第一个片段上。如果某个端点在每个片段上都重新发送 id,或者在只有一个 call 时省略了 index,它会与你的朴素累加器配合工作,但一旦模型发出两个并行的 calls 就挂了。还要检查 finish_reason 是 tool_calls 而不是 stop——agent 循环是根据这个值来分支的。
response_format。三个层级,而且经常被混为一谈:不支持、{"type": "json_object"}(有效 JSON,任意形状)、和 {"type": "json_schema", "json_schema": {...,"strict": true}}(根据你的 schema 做约束解码)。危险的中间情况是接受参数但忽略它的端点。你得到的是带代码围栏的纯文本,每五十个请求有一个解析器失败,看起来像是模型质量问题。
logprobs / top_logprobs。通常是代理层第一个丢弃的东西,因为几乎没人注意到。但如果你是通过比较 token 概率来做分类,或者用 logprobs 做置信度门控,这就是关键依赖,你应该显式测试它。
temperature 和采样参数。推理风格模型在某些上游会直接拒绝 temperature,有些接受并忽略,还有些会正确处理。三种都合理;但不知道你面对的是哪一种就不合理了。top_p、presence_penalty、以及 max_tokens vs max_completion_tokens 同理。
Stop sequences。端点是否支持 stop 字符串数组?stop sequence 是包含在返回内容中还是排除在外(参考实现是排除的)?finish_reason 是否返回 "stop"?大量 shim 实现是通过在后面对完整补全进行截断来做到 stop 的,这意味着你为你从未看到的 token 付费——usage 数字会暴露这一点。
Usage 计量。非流式响应应携带 usage.prompt_tokens、completion_tokens、total_tokens。流式响应只在传入 stream_options: {"include_usage": true} 时才包含 usage,然后它以一个空的 choices 数组的最终 chunk 形式到达——这种形状会崩溃那些假设 choices[0] 始终存在的客户端。如果你做按请求的成本归属,还要检查 cached-prompt 和 reasoning-token 的细分是否能保留下来。
Error-body 形状。你写过的每一个重试层都在解析这个,但没人测试它。参考格式是 {"error": {"message": ..., "type": ..., "param": ..., "code": ...}},HTTP 状态码与语义匹配。现实世界的变体:200 状态码但 body 里有个 error 对象(对重试逻辑是致命的——你会愉快地把 error 字符串返回给用户)、中间代理返回的 HTML 错误页、或者本应是 400 但给了 500 的情况,这会把一个永久性的客户端错误变成无限重试风暴。
/v1/models 保真度。它存在吗?返回的是 {"object": "list", "data": [{"id": ...}]} 吗?——重要的是,它列出的 ID 是你的 key 实际可以调用的吗?一个返回 vendor 销售的所有模型而不是你的 key 被授权的所有模型的目录端点,比没有目录还糟糕,因为你会在它上面构建 CI 检查。
仅用标准库,Python 3.8+,无需安装。它运行 11 项检查并打印判决表。
#!/usr/bin/env python3
"""compat_probe.py — how OpenAI-compatible is this endpoint, really?
export LLM_BASE_URL=https://api.example.com/v1
export LLM_API_KEY=sk-...
python3 compat_probe.py --model <exact-model-id>
"""
import argparse, json, os, sys, urllib.error, urllib.request
BASE = os.environ.get("LLM_BASE_URL", "").rstrip("/")
KEY = os.environ.get("LLM_API_KEY", "")
OUT = []
def _open(path, payload=None, method="GET", timeout=90):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + KEY)
if data is not None:
req.add_header("Content-Type", "application/json")
try:
return urllib.request.urlopen(req, timeout=timeout)
except urllib.error.HTTPError as exc: # still a readable file object
return exc
def call(path, payload=None, method="GET"):
resp = _open(path, payload, method)
raw = resp.read().decode("utf-8", "replace")
try:
return resp.getcode(), json.loads(raw)
except ValueError:
return resp.getcode(), raw
def stream(payload):
payload = dict(payload, stream=True)
resp = _open("/chat/completions", payload, "POST")
chunks, saw_done = [], False
for line in resp:
line = line.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
body = line[5:].strip()
if body == "[DONE]":
saw_done = True
break
try:
chunks.append(json.loads(body))
except ValueError:
pass
return chunks, saw_done
def record(name, ok, detail):
OUT.append((name, "PASS" if ok is True else "FAIL" if ok is False else "PARTIAL", detail))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
model = ap.parse_args().model
ask = {"model": model, "messages": [{"role": "user", "content": "Say hi."}],
"max_tokens": 24}
# 1 — catalogue
code, body = call("/models")
ids = [m.get("id") for m in body.get("data", [])] if isinstance(body, dict) else []
record("models_endpoint", code == 200 and bool(ids),
f"HTTP {code}, {len(ids)} ids, target listed: {model in ids}")
# 2 — basic completion + model echo + usage
code, body = call("/chat/completions", ask, "POST")
msg = (body.get("choices") or [{}])[0].get("message", {}) if isinstance(body, dict) else {}
usage = body.get("usage") if isinstance(body, dict) else None
record("basic_chat", bool(msg.get("content")), f"HTTP {code}")
record("model_echo", isinstance(body, dict) and body.get("model") == model,
f"asked {model!r}, got {body.get('model')!r}" if isinstance(body, dict) else "n/a")
record("usage_fields", bool(usage and usage.get("total_tokens") is not None), str(usage))
# 3 — streaming shape
chunks, done = stream(ask)
role = any((c.get("choices") or [{}])[0].get("delta", {}).get("role") for c in chunks)
fin = any((c.get("choices") or [{}])[0].get("finish_reason") for c in chunks)
record("stream_shape", bool(chunks) and role and fin and done,
f"{len(chunks)} chunks, role_chunk={role}, finish_reason={fin}, [DONE]={done}")
# 4 — usage on stream
chunks, _ = stream(dict(ask, stream_options={"include_usage": True}))
record("stream_usage", any(c.get("usage") for c in chunks),
"final usage chunk present" if any(c.get("usage") for c in chunks) else "absent")
# 5 — tool calling
tool = {"type": "function", "function": {"name": "get_weather", "parameters": {
"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}
code, body = call("/chat/completions", dict(
ask, messages=[{"role": "user", "content": "Weather in Osaka? Use the tool."}],
tools=[tool], tool_choice="auto"), "POST")
choice = (body.get("choices") or [{}])[0] if i
运行脚本时有两点注意。它会消耗少量的小型补全,所以先用便宜模型跑一遍,确认你的 base URL 和 key 是对的。而且要用你计划上线的精确 model ID 来跑,因为同一个端点上不同模型的兼容度是不同的——推理模型尤其会拒绝其非推理同类接受的参数。
basic_chat 或 error_shape 挂 FAIL 是真正的问题。logprobs 挂 FAIL 只有在你使用 logprobs 时才是问题。表格的意义不是打分;而是让你有一份书面记录,记录下你可以依赖哪些表面区域,你可以在任何provider 变更后重新运行并 diff。
由此产生的三个习惯:
把它加入 CI。每天跑一次,输出作为 artifact 提交。兼容性回归本质上是静默的——没人会发 changelog 说"我们停止转发 logprobs 了"。
在生产环境中断言 model echo,而不只是探针里。如果你请求了一个 ID 但响应的 model 字段说是另一个,你希望这件事发生在你注意到质量下降的那一天,而不是一周后。
把 PARTIAL 当作设计输入。如果 stream_usage 不存在,你的成本归属需要一条非流式路径或本地 tokenizer 估算。这是现在花两小时做决定,还是月底对账时花一个月查的差别。
它告诉不了你什么
探针测试的是协议,不是背后的模型。一个端点可以通过全部 11 项检查,但仍然在把你路由到更小的模型、量化版本、或被截断的上下文窗口——线格式都同样是完美的。那是另一个调查,用不同的工具,诚实的说法是行为探针给你的是信号而不是证明:provider 之外没人能看到是哪个权重在服务你的请求。我另外写了一篇关于目录端那部分问题的文章——固定 model ID、检测 drift、以及当 model 字段与你所请求的不再匹配时应该记录什么——标题是《Model IDs are a dependency. Pin them like one.》。
但协议一致性是你可以用一个不到一屏的脚本在一下午就解决的部分,也是如果你跳过它会在凌晨 3 点叫醒你的部分。
这篇文章是一个持续进行的"验证你的端点"系列的一部分。关于 model-ID drift 的配套文章是《Model IDs are a dependency. Pin them like one.》,如果你在第一次生产调用之前评估第三方端点,《Ten questions to answer before you route production traffic through someone else's LLM endpoint》是买方检查清单。