一次看起来像“模型不兼容”,其实是 `max_tokens` 把输出截断了
最近在给这套商品图提示词服务接入新的 OpenAI-compatible 供应商时,我们踩了一个很典型、也很容易误判的坑:
- 同样是
/chat/completions - 同样是 OpenAI 兼容请求格式
- 旧供应商
qwen3-vl-plus跑得很正常 - 新供应商
gemini-3-flash-preview却频繁只返回半句话
最开始看现象,直觉很容易往这几个方向怀疑:
- 这个供应商的 OpenAI 兼容实现不完整
- 多模态图片传递格式不对
- 响应解析逻辑没兼容到位
- 模型本身不稳定
但最后复盘下来,真正的主因比想象中直接很多:我们显式传了 max_tokens,而这个第三方 OpenAI-compatible 供应商上的 gemini-3-flash-preview 组合,很可能把图片理解和推理消耗也算进了输出预算里,导致最终文本阶段只剩下一小截。
这篇文章就把整个问题、排查过程和最终处理方法完整记录一下。
背景
我们的服务很简单:
- 前端提交商品标题、图片 URL、模型配置
- 后端调用 OpenAI-compatible 接口做两轮生成
- 第一轮生成
image_prompt - 第二轮基于第一轮结果继续生成
video_prompt
原来在 qwen3-vl-plus 上,整体链路是稳定的。
后来换到一个新的第三方中转商,模型名也是 gemini-3-flash-preview,接口地址仍然是 OpenAI 兼容风格:
POST /v1/chat/completions
代码层面,请求格式本身也没有问题:
modelmessages- 多模态
image_url temperaturemax_tokens
所以最开始大家自然会想:既然协议看起来都一样,那问题应该不大。
线上现象
问题非常明显:模型会返回一段看起来像正常英文 prompt 的开头,但只到半句就结束了。
例如日志里曾经出现过这种输出:
A 2x2 grid layout. Top-left: worn on child's feet on
还有这种:
Children's pink mermaid-scale jelly sandals. The camera
这类输出最麻烦的地方在于:
- 它不是空
- 它不是 HTTP 报错
- 它也不是完全胡说八道
- 它只是“像一个正常结果的开头”
于是就会让人误以为:
- 是不是响应解析错了
- 是不是日志被截断了
- 是不是前端显示裁掉了
但从日志链路往回看,能确认一件事:代码当时真正拿到的内容就只有这么短。
换句话说,这不是展示层问题,而是模型/网关在这次调用里实际只给出了这一小段文本。
第一次误判:怀疑是 OpenAI-compatible 不够兼容
一开始这个怀疑很合理。
因为很多供应商虽然对外宣称兼容 OpenAI 接口,但“兼容”往往只保证:
- 路径像
- 字段名像
- 基础调用能通
但并不等于:
- 停止原因语义完全一致
- usage 统计完全一致
- 多模态预算计算方式一致
- 输出行为一致
我们也一度怀疑是不是:
message.content里只取到了第一段- 完整文本被放到了别的扩展字段里
- 或者这个 Gemini 兼容层有特殊结构
这个方向不能说完全没意义,但后来用户提供了一份单独的测试脚本返回结果,直接帮助我们把范围大幅缩小了。
关键证据:单独测试脚本可以正常返回完整内容
用户在同一个供应商上跑了一个简化版测试脚本,请求仍然是:
gemini-3-flash-preview/v1/chat/completionsmessages里带文本和图片 URL
脚本返回是正常的,content 完整,finish_reason 也是 stop。
更关键的是,它的 usage 里出现了类似这样的数据:
"completion_tokens": 861,
"completion_tokens_details": {
"reasoning_tokens": 715
}
这一条线索几乎把问题点直接指向了 max_tokens。
这里要先强调一句,避免误解:
这次问题指向的是某个第三方 OpenAI-compatible 供应商上的行为表现,不代表 Google 官方 Gemini API 一定也是这样。
我们这次的结论只覆盖下面这个组合:
- 第三方中转商
- OpenAI-compatible
/chat/completions gemini-3-flash-preview- 我们这套多模态商品图 prompt 生成请求
也就是说,这篇复盘想说明的是:在这个第三方兼容层里,显式传 max_tokens 会带来明显风险。
真正的问题:max_tokens 把模型输出预算锁死了
我们原来的代码里,两轮请求都显式传了 max_tokens:
- 第一轮图片 prompt:
600 - 第二轮视频 prompt:
300
在 qwen3-vl-plus 上,这个值大体是够用的。
但这个第三方 Gemini 兼容组合表现出一个明显特征:它可能会把图片理解、推理甚至 reasoning token 一并算进这次 completion 预算。
这就意味着:
- 我们看到的可见文本明明不长
- 但模型内部已经消耗了大量 token 在“理解图片”和“组织推理”上
- 真正轮到输出正文时,剩余预算已经很少了
- 于是最终只吐出半句话
这也解释了为什么旧模型正常、新模型却频繁半截:
qwen的生成风格更直接- 这个第三方
gemini兼容链路更可能消耗大量 reasoning token - 同样的
max_tokens,不同模型实际效果差非常大
我们最初的理解,哪些对,哪些要修正
用户当时的理解是:
qwen 那边是按照文本记录的,不考虑图片;新的第三方中转商似乎把图片先计入了 token,导致输出一直截断。
这个判断方向是对的,但更准确地说,不一定只是“图片被计入 token”这么简单,更可能是“这个第三方模型/供应商组合把多模态理解和推理消耗一起算进了 completion 预算”。
所以更完整的说法应该是:
- 旧模型组合下,显式
max_tokens看起来够用 - 新模型组合下,
max_tokens对真实输出预算限制过强 - 尤其在图片输入、长 system prompt、严格输出要求同时存在时,问题更明显
最终解决方案
我们没有去继续猜各家供应商内部到底怎么核算 token,而是做了一个更稳妥的工程决策:
默认不再传 max_tokens。
具体做了两处调整:
- 第一轮
image_prompt请求不再默认传max_tokens - 第二轮
video_prompt请求也不再传max_tokens
这样做的好处很直接:
- 请求结构仍然是 OpenAI-compatible
- 图片传递方式完全不变
- 两轮主流程完全不变
- 只是把“我们自己人为加的输出上限”去掉了
也就是说,我们没有改核心业务逻辑,只是去掉了一个对新模型不友好的限制条件。
为什么这是更合适的默认策略
如果一个服务要兼容多个 OpenAI-compatible 供应商,最稳妥的原则通常不是“把所有供应商都压进完全一致的参数模板”,而是:
- 必要字段统一
- 可选限制尽量少
- 把模型生成空间交给模型自己决定
尤其是 max_tokens 这种参数,在不同供应商、不同模型、不同计费/推理实现下,行为差异可能非常大。
在只需要“生成一段文本结果”的场景里,不传往往比传一个保守上限更稳。
第二个连带问题:我们自己的本地严格校验也放大了问题
除了半截输出,我们还踩到了另一个相关坑。
有些时候,Gemini 实际上已经返回了一条完整、语义可用的 image_prompt,但因为我们本地代码做了很严格的模板校验,例如强制要求:
- 必须出现
Top-left: - 必须出现
Top-right: - 必须出现
appears in all four panels. - 顺序必须匹配某个固定模式
结果模型只是把一句话写成了:
appear in all four panels.
而不是:
appears in all four panels.
就被判定失败。
这个问题和 max_tokens 不完全相同,但思路是一致的:
为了兼容更多供应商和模型,系统提示词可以继续尽量明确,但本地代码不要把输出格式卡得太死。
因此后来我们也把图片 prompt 的本地校验改成了轻校验,只保留最基础的兜底规则。
这次改动没有改变什么
这里也值得强调一下,免得后续排查时误会范围过大。
这次改动没有动这些东西:
- API 入参格式
- 图片 URL 传递方式
- OpenAI-compatible 请求结构
- 两轮生成流程
- Redis 任务状态流转
- 前端调用方式
改动的只是:
- 去掉
max_tokens - 放松本地输出校验
所以这是一次兼容性修复,不是业务主流程重构。
经验总结
这次问题很值得记下来,因为它太像“供应商不兼容”,但真正的坑却是在我们自己这边的默认参数选择。
最后总结几条经验:
- OpenAI-compatible 只代表协议像,不代表 token 预算行为完全一致。
- 多模态模型的
max_tokens比纯文本模型更容易踩坑。 - 第三方兼容供应商上的同名模型,不一定和官方实现有完全一致的 token 预算行为。
- 不同模型对 reasoning token 的消耗差异很大,不能拿一个固定值硬套所有供应商。
- 对只求生成文本结果的接口,
max_tokens不一定要默认传。 - 系统提示词可以严格,但本地字符串校验不要比业务需求更严格。
当前结论
这次问题我们最终是这样处理的:
- 保持 OpenAI-compatible 请求格式不变
- 去掉
max_tokens - 让模型自己决定输出预算
- 本地只保留最小必要校验
这样之后,新供应商已经能正常跑通,旧逻辑的核心流程也没有被破坏。
如果以后还要继续接更多供应商,这次经验可以直接复用:
先统一协议,再减少不必要的本地限制。
陕公网安备61011302002223号