一次看起来像“模型不兼容”,其实是 `max_tokens` 把输出截断了

最近在给这套商品图提示词服务接入新的 OpenAI-compatible 供应商时,我们踩了一个很典型、也很容易误判的坑:

  • 同样是 /chat/completions
  • 同样是 OpenAI 兼容请求格式
  • 旧供应商 qwen3-vl-plus 跑得很正常
  • 新供应商 gemini-3-flash-preview 却频繁只返回半句话

最开始看现象,直觉很容易往这几个方向怀疑:

  • 这个供应商的 OpenAI 兼容实现不完整
  • 多模态图片传递格式不对
  • 响应解析逻辑没兼容到位
  • 模型本身不稳定

但最后复盘下来,真正的主因比想象中直接很多:我们显式传了 max_tokens,而这个第三方 OpenAI-compatible 供应商上的 gemini-3-flash-preview 组合,很可能把图片理解和推理消耗也算进了输出预算里,导致最终文本阶段只剩下一小截。

这篇文章就把整个问题、排查过程和最终处理方法完整记录一下。

背景

我们的服务很简单:

  1. 前端提交商品标题、图片 URL、模型配置
  2. 后端调用 OpenAI-compatible 接口做两轮生成
  3. 第一轮生成 image_prompt
  4. 第二轮基于第一轮结果继续生成 video_prompt

原来在 qwen3-vl-plus 上,整体链路是稳定的。

后来换到一个新的第三方中转商,模型名也是 gemini-3-flash-preview,接口地址仍然是 OpenAI 兼容风格:

POST /v1/chat/completions

代码层面,请求格式本身也没有问题:

  • model
  • messages
  • 多模态 image_url
  • temperature
  • max_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/completions
  • messages 里带文本和图片 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

具体做了两处调整:

  1. 第一轮 image_prompt 请求不再默认传 max_tokens
  2. 第二轮 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
  • 放松本地输出校验

所以这是一次兼容性修复,不是业务主流程重构。

经验总结

这次问题很值得记下来,因为它太像“供应商不兼容”,但真正的坑却是在我们自己这边的默认参数选择。

最后总结几条经验:

  1. OpenAI-compatible 只代表协议像,不代表 token 预算行为完全一致。
  2. 多模态模型的 max_tokens 比纯文本模型更容易踩坑。
  3. 第三方兼容供应商上的同名模型,不一定和官方实现有完全一致的 token 预算行为。
  4. 不同模型对 reasoning token 的消耗差异很大,不能拿一个固定值硬套所有供应商。
  5. 对只求生成文本结果的接口,max_tokens 不一定要默认传。
  6. 系统提示词可以严格,但本地字符串校验不要比业务需求更严格。

当前结论

这次问题我们最终是这样处理的:

  • 保持 OpenAI-compatible 请求格式不变
  • 去掉 max_tokens
  • 让模型自己决定输出预算
  • 本地只保留最小必要校验

这样之后,新供应商已经能正常跑通,旧逻辑的核心流程也没有被破坏。

如果以后还要继续接更多供应商,这次经验可以直接复用:

先统一协议,再减少不必要的本地限制。

Read more

传统 SaaS 转向 AI 时代,我目前的一点理解:先把数据能力变成 Agent 可调用的基础设施

最近我一直在思考一个问题:传统 SaaS 到底应该怎么转向 AI? 一开始很容易想到的方向是:给原来的系统加一个 AI 助手。 比如在页面右下角放一个聊天框,让用户可以问数据、生成报告、总结内容、解释指标。这个当然有价值,但我现在越来越觉得,这只是比较表层的一种转型。 真正的变化,可能不是“在 SaaS 里面加 AI”,而是 SaaS 本身的能力形态发生变化。 过去的 SaaS,核心是给人使用。 人登录系统,看页面、点按钮、筛选数据、导出报表、判断问题,然后再去做决策。数据库是给 Web 页面供数的,后端 API 是给前端页面服务的,整个产品的中心是“人如何操作软件”。 但 AI 时代,尤其是 Agent 逐渐发展之后,

By ladydd

对 Python 应用场景的一次重新思考:FastAPI、协程、线程、数据库与任务系统边界

最近在重新设计一个任务系统时,我顺便把自己对 Python,尤其是 CPython 应用场景的理解重新梳理了一遍。 这次讨论的背景是一个典型的异步任务服务: 上游提交任务 API 立即返回 task_id 后台 worker 慢慢执行 用户通过 task_id 查询任务状态 任务主要是 LLM 调用、图片下载、外部 HTTP 请求这类 I/O 型工作。 一开始关注的是队列、Redis、PostgreSQL、worker 并发控制这些问题。但聊到后面,其实更核心的问题变成了: Python 到底应该放在什么位置? 哪些并发适合 Python? 哪些并发不要硬塞给 Python? FastAPI、协程、线程、数据库之间应该怎么分工? 这篇文章就是这次思考的整理。 一、我不想抛弃 Python,

By ladydd

Go 和 Python 的并发模型对比:进程、线程、协程、并发和并行到底怎么理解?

最近我在写 worker 任务系统的时候,重新理解了一遍 Python 和 Go 的并发差异。 以前写 Python,多 worker 经常要考虑: 多进程怎么管理? 日志会不会串? 一个 worker 崩了怎么办? 怎么吃满多核心? 后来换成 Go,发现一个进程里开多个 goroutine worker 就很自然: go worker(1) go worker(2) go worker(3) go worker(4) 日志也好管,状态也好管,而且单进程还能利用多个 CPU 核心。 一开始很容易误会成: Python 不行,Go 行 但更准确的理解应该是: Python 和

By ladydd

Python 进程和 Go 进程的区别:为什么 Go 单进程多 worker 用起来更爽?

最近我在做 worker 任务系统的时候,突然意识到一个很关键的问题: 以前写 Python,多 worker 的时候经常要小心日志串、文件切割乱、时间不好管理。 但是换成 Go 以后,一个进程里开多个 goroutine worker,反而可以比较自然地写到同一个日志文件里。 一开始我以为这是“Python 和 Go 写日志能力不一样”,后来想明白了,核心不是日志本身,而是: Python 常见 worker 模型:多进程 Go 常见 worker 模型:单进程 + 多 goroutine 这背后其实是两个语言在并发模型上的巨大差异。 一、进程、线程、goroutine 先分清楚 先把几个概念捋一下。 进程:操作系统分配资源的单位 线程:CPU 调度执行的基本单位

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号