目标

拿到一个自称「OpenAI 兼容」的网关(中转站、校园平台、自建代理),在不看服务端代码的前提下判断:它到底把请求原样转给了上游,还是做了删改。

这件事直接决定你能拿它干什么。删改过的网关聊天没问题,但做不了 Agent。

为什么不能只看它「能不能回话」

能回话 ≠ 透传。套壳网关的常见做法是:接下请求 → 自己挑几个字段转发 → 拿到响应 → 重新拼一个 JSON 返回。中间丢什么、改什么,从外面看不出来。

真正的判据在边界情况上:只有当你传一个「正常聊天用不到、但 Agent 必须用」的参数,才能试出它转不转。

核心手法:用 YES/NO 问出参数有没有到达模型

这是整篇最有用的一招。不要观察模型行为,直接问模型。

把要验证的参数放进去,然后让模型回答一个只有「参数真的到了」才会出现的答案。

以验证 tools 参数为例:

curl -s -X POST "$GATEWAY/v1/chat/completions" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "model": "your-model",
  "stream": false,
  "max_tokens": 200,
  "messages": [{
    "role": "user",
    "content": "I have registered a function named get_weather in this request's tools parameter. Look at your available tools. Is get_weather available to you? Answer exactly: YES or NO."
  }],
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_weather",
      "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
    }
  }]
}
JSON

读到 NO 就说明参数被网关吞了。 连问三次都是 NO,可以定性。

这个方法的好处:模型的回答是一个确定的布尔值,不依赖它怎么「演」。而观察行为会被误导 —— 见下。

反例:为什么不能看模型「有没有调工具」

被吞了 tools 的网关,表现极具迷惑性:

  • 有时模型拒绝:「我没有 get_weather 工具」
  • 有时模型编一段调用格式当普通文本吐出来

后者最容易骗人。以 DeepSeek 为例,它的原生工具调用格式用一对特殊标记包裹(形如 <全角竖线><全角竖线>DSML<全角竖线><全角竖线> 加 calls / invoke / parameter 标签)。

这段标记本该由服务端的 tool parser 读掉、翻译成 OpenAI 标准的 tool_calls 字段再返回。网关没开这个 parser 时,标记会原样漏进 content。

关键判断:漏出来的标记不是「工具调用成功」,恰恰是「工具调用失败」。 因为模型是在没有工具定义的情况下凭训练记忆猜了一个格式出来 —— 我实测时 prompt 里提到了函数名,模型就自己编了一段。

三者对照:

现象真实含义
tool_calls 字段有值真透传,工具调用可用
tool_calls 字段不存在,content 里有原始标记网关吞了 tools,模型在瞎猜
模型说「我没有这个工具」同上

补充:tool_calls 字段缺失 ≠ 值为 null

实测中容易搞混:网关返回的 JSON 里完全没有 tool_calls 这个键,而不是 "tool_calls": null。

用 Python 解析时 msg.get('tool_calls') 两种情况都返回 None,看不出区别。要判断真相得看原始 JSON 文本:

grep -c 'tool_calls' raw-response.json   # 0 表示字段根本不存在

配套验证项

同一个手法可以验其它参数。挑几个「正常聊天用不到、Agent 或高级用法才要」的:

验证项怎么试透传的表现
tools上面的 YES/NO 问法模型答 YES
tool_choice设为 {"type":"function","function":{"name":"x"}}(强制调用)返回 tool_calls,而不是自由文本
logprobs传 "logprobs": true响应里出现 logprobs 对象,不是 null
非法参数传 "temperature": 99报 400;静默接受也是被吞的信号
响应信封看顶层字段完整透传会带 system_fingerprint
模型注册表调上游的真名(如 deepseek-chat)透传能过;套壳网关会说「模型不存在」
stream 默认值不传 streamOpenAI 规范默认 false;默认 true 是自研实现的特征

判据总结:任何一项被吞,这个网关就不该拿来做 Agent。

一个容易误判的信号:token 数和内容长度对不上

有时会看到 usage.completion_tokens 远大于 content 的长度(例如 2600 tokens 但只有 31 个字符)。

别急着下「被吞了工具调用」的结论。 常见原因是推理模型的思维链(reasoning / thinking tokens)被网关丢弃,但 token 照算。

区分方法:去掉 tools 参数再测一次同样的问法。如果 token 数依然对不上,那就是思维链,与工具调用无关。

我实测时正是如此:去掉 tools 后,同样生成了 419 个 token 而只有 221 个字符的内容 —— 说明网关本来就在丢思考过程。

完整流程

  1. GET /v1/models 或 POST /v1/chat/completions 探活,确认鉴权方式
  2. 传一个不存在的模型名,看报错格式(能反推它是不是套壳)
  3. 跑 YES/NO 探针验 tools
  4. 逐个补验上表里的其它参数
  5. 看响应的顶层字段和 id 格式(自研 id 是套壳的强信号)

下次如何识别

  • 客户端报「模型不承认工具存在」「工具调用格式乱码」→ 先跑 YES/NO 探针,别先怀疑模型。
  • 看到 content 里出现原始特殊标记(DSML 之类)→ 网关的 tool parser 没开,在服务端修,客户端无解。
  • 只有 chat/completions 能用、completions(FIM 补全)404 → 这个网关只实现了 chat 一条路。
  • 想确认「是不是真上游」→ 看它吐不吐上游专有的东西。DeepSeek 的 DSML 标记就是强证据:套壳的模型不会吐别家的私有格式。

相关概念


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps