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 的结果

自检代码,重点是「同时」两个字,逼模型并行:

Python
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 是正常关闭的。

一条命令看两个问题:

Shell
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 里有没有缓存字段。

Python
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 输出就知道丢了什么:

Python
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 个信号 里提到的「文档是否随模型更新」就是同一件事的两面。

注册后有测试额度,自检脚本可以直接跑;确认哪些功能可用之后再决定主力模型,按量计费,余额不过期。

延伸阅读

#base_url 迁移#function calling 失效#流式 usage#多模态 detail#prompt 缓存
免费创建 API Key

相关阅读

流式输出 SSE 原理与调试:从 curl 到前端

大模型 API 的流式输出本质是一条 SSE 长连接。本文拆解 text/event-stream 报文格式、stream:true 的分片结构、curl 与前端 ReadableStream 的调试方法,以及代理缓冲、[DONE]、usage 丢失等高频坑位。

DeepSeek 免费档能干什么:5 个生产级用法

平台上有一个按次计费 0 元的 deepseek-v4-flash-free 免费档,每天限流。本文给出 5 个真正能上生产的用法:意图分类、内容打标签、结构化抽取、会话摘要、批量预处理,并附用量测算与降级策略。