base_url 迁移后失效的 5 个功能实测
只改 base_url 就能迁移?我们把 function calling、流式输出、多模态、结构化输出和 prompt 缓存逐项对照,列出 5 个会静默降级的功能、自检脚本与绕过方案,附真实挂单价。
「把 base_url 换成中转站地址就行」这句话在八成场景下成立。剩下两成不会报错,只会悄悄降级:HTTP 200,返回结构合法,日志干净,但工具调用少了一半、流式尾部没有 usage、图片里的字识别不出来。这类问题最难排查,因为你没有任何一条错误日志可以抓。
下面这 5 项是迁移后最常被削掉的能力。每一项给出症状、根因、一段 10 行以内的自检代码。全部跑完大约 10 分钟,花费不到 1 美分。
先看失效矩阵
| 功能 | 兼容层的典型行为 | 你看到的现象 | 检测成本 |
|---|---|---|---|
| function calling | 并行调用合并为串行;strict 字段被丢弃 | tool_calls 只有 1 个;arguments 偶尔不是合法 JSON | 一次请求 |
| 流式输出 | 不透传 stream_options;网关 60s 截断 | 尾块没有 usage;长回答断在半句 | 一次 curl -N |
| 多模态 | 只接受 base64,不回源下载 URL;忽略 detail | 公网图报 400;小字 OCR 精度骤降 | 两图对比 |
| 结构化输出 | json_schema 降级为 json_object | 字段缺失、多余字段、包了 markdown 围栏 | 一次请求 |
| prompt 缓存 | 不实现 cache_control | 长 system 重复计费,成本比预期高 3–5 倍 | 看 usage 字段 |
矩阵之外还有两项容易漏:Anthropic 的 /v1/messages 原生端点和 OpenAI 的 /v1/responses。这两个端点不是「兼容」的对象,而是各自厂商的私有协议,能不能用取决于中转站有没有单独实现。协议层的区别在 OpenAI 兼容协议详解 里讲过,这里只讲怎么测。
一、function calling:并行退化与非法 JSON
兼容层最常做的一件事是把工具定义「翻译」成目标模型的原生格式。翻译过程中有两样东西最容易丢:parallel_tool_calls 和 strict。
症状很具体:
- 你要求「同时查天气和时间」,回来只有一个
tool_calls,另一半被模型写进了content文本里,你的分发逻辑直接跳过 arguments是字符串,但偶尔带 ``json 围栏、尾逗号,或者被max_tokens截断,json.loads抛JSONDecodeError`- 声明了
"strict": true的 schema,模型仍然给出{"city": null}这种不符合required的结果
自检代码,重点是「同时」两个字,逼模型并行:
import json, os, requests
BASE = os.environ.get("ZAIMOD_BASE", "https://api.zaimod.com/v1")
KEY = os.environ["ZAIMOD_API_KEY"]
tools = [
{"type": "function", "function": {
"name": "get_weather", "description": "查询城市天气",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}},
{"type": "function", "function": {
"name": "get_time", "description": "查询城市当前时间",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}},
]
r = requests.post(f"{BASE}/chat/completions",
headers={"Authorization": f"Bearer {KEY}"},
json={"model": "gpt-4o-mini", "temperature": 0,
"messages": [{"role": "user", "content": "同时查杭州的天气和当前时间,两个工具都必须调用。"}],
"tools": tools, "tool_choice": "auto"},
timeout=60).json()
calls = r["choices"][0]["message"].get("tool_calls") or []
print("tool_calls =", len(calls), "(期望 2)")
for c in calls:
try:
json.loads(c["function"]["arguments"])
except json.JSONDecodeError:
print("非法 JSON:", c["function"]["arguments"][:80])拿到 tool_calls = 1 不代表不能用,只代表你要把分发逻辑改成「循环处理所有 tool_calls,并检查 content 里是否有未执行的意图」。工程上的稳妥做法是三层防御:解析失败先做一次修复(补引号、去围栏),修复失败把原文回灌给模型要求重写,仍失败则降级为纯文本回答。strict 不可信,就用 pydantic 在服务端再校验一遍。
二、流式:尾块没有 usage
官方接口在 stream: true 时会忽略 usage,必须显式加 stream_options: {"include_usage": true} 才在最后一个 chunk 返回 token 统计。兼容层经常把这个参数吃掉,于是出现一个很隐蔽的后果:你的账单模块永远记 0,等到月底对不上账才发现。
另一种更痛的情况是网关超时。反向代理默认 60 秒空闲断连,模型输出长文时连接被腰斩,前端表现为「回答说到一半停了」,而后端没有任何异常——因为 TCP 是正常关闭的。
一条命令看两个问题:
curl -sN https://api.zaimod.com/v1/chat/completions \
-H "Authorization: Bearer $ZAIMOD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","stream":true,
"stream_options":{"include_usage":true},
"messages":[{"role":"user","content":"用 300 字解释 SSE 的工作方式"}]}' | tail -n 4最后 4 行应该能看到 "usage":{"prompt_tokens":...},以及 data: [DONE]。看不到 usage 就在客户端本地估算(中文约 1 字 ≈ 1.5 token,英文约 1 词 ≈ 1.3 token)作为兜底;没有 [DONE] 而连接直接关闭,说明被截断了,长任务要改成分段生成或者换非流式。
推理模型的流式还有第三个坑:思考内容字段名不统一,有 reasoning_content、reasoning、thinking 三种写法。读取时三个 key 都试一遍,比猜一个更省事。
三、多模态:URL 不回源,detail 被忽略
image_url 有两种写法:公网 URL 和 data:image/png;base64,...。官方会自己下载 URL,很多兼容层不会——它没有出网能力,或者干脆没实现,于是返回 400 或者把 URL 当纯文本喂给模型,模型开始「根据文件名猜测图片内容」。这种回答看起来通顺,实际全是编的,比报错危险得多。
detail: "high" 是第二个陷阱。官方会用高分辨率切片,兼容层忽略这个参数后按固定尺寸降采样,图片里的小字(发票号、报错堆栈、表格数字)直接糊掉。
验证方法不需要写代码:找一张含小字的截图,同一张图分别问「读出图里第三行的数字」,把 detail 设为 high 和 low 各跑一次。两次结果一样,说明参数被忽略了;两次都读错,说明分辨率已经不够。
工程上的建议是把不确定性去掉:一律 base64 内联,长边压到 1568px 以内,detail 显式声明。这样不依赖中转站是否回源,也不依赖它是否尊重参数。文件类输入(PDF、音频)支持差异更大,各端点的能力范围在 多模态文档 里有对照表,接之前先确认,别等上线才发现 PDF 走不通。
四、结构化输出:json_schema 悄悄降级
response_format: {"type": "json_schema", "json_schema": {...}} 是强约束,{"type": "json_object"} 只是「请输出 JSON」。兼容层如果只实现了后者,你会遇到三种情况:少字段、多字段、以及最经典的——模型热心地包了一层 ```json 围栏。
区别在于失败是静默的。json.loads 可能成功,但拿到的是 {"result": {...}} 而不是你要的顶层字段,下游 KeyError 报在很远的地方。
处理方式很朴素:不要相信 json_schema 一定生效。解析后立刻用 pydantic 校验,缺字段就把「哪个字段缺了」写进重试提示,最多重试一次。这一步加上去,线上 500 能少一大截。计费和错误码的判定逻辑见 计费与错误码。
五、prompt 缓存:看不见的那部分钱
长 system prompt 的应用(客服机器人、代码审查、文档问答)对缓存极其敏感。官方口径是缓存命中的输入按 0.1 倍计价,但兼容层很可能压根没实现 cache_control,于是每次请求都按全价算输入。你不会收到任何警告,只会在账单上看到输入费用是估算值的 3–5 倍。
判断方法只有一个:看返回的 usage 里有没有缓存字段。
u = resp["usage"]
print(u.get("prompt_tokens_details", {}).get("cached_tokens")) # OpenAI 形状
print(u.get("cache_read_input_tokens"), u.get("cache_creation_input_tokens")) # Anthropic 形状三个值全是 None 或 0,就当作没有缓存来算预算。还有一条容易被忽略:Anthropic 的缓存有最小长度门槛(Sonnet / Opus 约 1024 tokens),system 太短时 cache_creation_input_tokens 恒为 0——不是没生效,是没到门槛。
一次跑完 5 项的自检脚本
把上面五段合并成一个探针脚本,迁移前后各跑一次,diff 输出就知道丢了什么:
import json, os, requests
B, K = os.environ.get("ZAIMOD_BASE", "https://api.zaimod.com/v1"), os.environ["ZAIMOD_API_KEY"]
H = {"Authorization": f"Bearer {K}"}
def post(body, stream=False):
return requests.post(f"{B}/chat/completions", headers=H, json=body,
timeout=90, stream=stream)
ok = True
# 1) 工具调用
r = post({"model": "gpt-4o-mini", "messages": [{"role": "user",
"content": "同时查杭州天气和当前时间,两个工具都必须调用。"}],
"tools": TOOLS, "temperature": 0}).json()
n = len(r["choices"][0]["message"].get("tool_calls") or [])
print("tool_calls", n, "PASS" if n == 2 else "DEGRADED"); ok &= n == 2
# 2) 流式 usage
buf = [c for c in post({"model": "gpt-4o-mini", "stream": True,
"stream_options": {"include_usage": True},
"messages": [{"role": "user", "content": "数到 20"}]}, stream=True).iter_lines()]
has_usage = any(b'"usage"' in x and b"prompt_tokens" in x for x in buf if x)
print("stream usage", "PASS" if has_usage else "MISSING"); ok &= has_usage
print("ALL PASS" if ok else "有降级项,按上文绕过")多模态和结构化输出两项依赖图片与 schema,建议手动各跑一次,不要塞进自动脚本——它们的失败往往是「结果不对」而不是「字段缺失」,机器判断不了。
跑一遍要花多少钱
自检 5 项,每项按 600 输入 + 120 输出 token 估算,合计 3000 输入 + 600 输出。按平台挂单价(美元 / 百万 tokens,default 分组):
| 模型 | 输入 | 输出 | 跑完 5 项 |
|---|---|---|---|
| gpt-4o-mini | $0.156 | $0.624 | $0.0008 |
| qwen-flash | $0.32 | $2.00 | $0.0022 |
| gemini-2.5-flash | $0.30 | $2.50 | $0.0024 |
| deepseek-v4-flash | $3.00 | $9.00 | $0.0144 |
| claude-sonnet-5 | $4.00 | $20.00 | $0.0240 |
也就是说,用最便宜的模型跑一次全项自检不到一厘钱,却能在迁移当天暴露掉后面三天才查出来的问题。真正贵的是线上跑了一周才发现缓存没生效。实时价格看 价格页,这张表只用来判断数量级。
结论
迁移的正确顺序不是「改 base_url → 上线 → 出事再查」,而是「改 base_url → 跑自检 → 记录降级项 → 针对性绕过」。绝大多数降级都有低成本的绕过方案:base64 代替 URL、pydantic 代替 strict、本地估算代替 usage、显式分段代替长流。真正无法绕过的只有厂商私有端点(/v1/messages 的 thinking block、/v1/responses 的 previous_response_id),那部分要单独判断是否需要。
选服务商时除了价格,还要看它是否愿意把能力边界写清楚——中转站跑路的 5 个信号 里提到的「文档是否随模型更新」就是同一件事的两面。
注册后有测试额度,自检脚本可以直接跑;确认哪些功能可用之后再决定主力模型,按量计费,余额不过期。