一次看起来像“模型不兼容”,其实是 `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

三台机器部署 ClickHouse 高可用集群实战记录

本文是一份可发布版部署记录。真实 IP、域名、账号、密码、下载链接、业务目录名、机器唯一标识等敏感信息已经替换为占位符。命令中的 <...> 需要按自己的环境替换。 目标与拓扑 这次目标是用三台数据节点部署一套 ClickHouse 高可用集群,拓扑采用: 1 shard x 3 replicas 含义是:集群只有一个逻辑分片,三台机器都保存同一份数据的完整副本。任意一台数据节点宕机时,只要 ClickHouse Keeper 仍然有多数派,剩余节点仍可继续提供读写服务。 规划节点如下: 主机名示例地址角色ch-01<ch-01-ip>ClickHouse Server + ClickHouse Keeperch-02<ch-02-ip>ClickHouse Server + ClickHouse Keeperch-03<ch-03-ip&

By ladydd

折腾记(二):接入火山引擎实时语音 API,家庭语音助手体验直接拉满

接上篇 上一篇用全开源组件(Whisper + Hermes + Edge-TTS)搭了个语音助手,能跑,但体验就是"能用"二字: * 中文识别只有 70 分,方言基本歇菜 * 英文唤醒词"Alexa"喊着别扭 * 说完到回复要等 4-8 秒 * 它说话的时候你插不了嘴 这些问题靠堆开源组件很难根治。于是我去试了火山引擎(字节跳动)的语音服务,结果直接换了条路。 这篇分两段:先讲怎么用火山引擎的 ASR/TTS 替换掉开源组件(小改),再讲怎么上端到端实时语音模型(大改)。 第一段:先把 ASR 和 TTS 换成火山引擎 为什么换 我用豆包输入法的时候发现它语音识别准得离谱。一查,豆包用的就是字节自家的火山引擎 Seed-ASR。开通后有免费额度(

By ladydd

折腾记(一):用全开源组件给家里搭一个语音助手,对接自己的 Hermes Agent

起因 事情是从一块 ESP32-S3 开发板开始的。 我手上有一块 Seeed Studio XIAO ESP32-S3 Sense,带摄像头和麦克风。最初的想法很美好:用这块板子做一个无线语音终端,对着它说话,连到我服务器上跑的 Hermes Agent(一个自托管的 AI agent),让它回答我。 但折腾到一半我突然意识到一件事:我的麦克风、音响、服务器全在家里,为什么要绕一圈用 ESP32?直接把麦克风和音响插到服务器上不就行了? ESP32 那条路(做无线拾音终端)当然也有价值,但那是"为了学嵌入式而学",不是解决问题的最短路径。于是这个项目就从"嵌入式项目"变成了"在服务器上拼一个语音助手"。这篇就记录后者。 教训零:先想清楚你要解决的是什么问题。很多时候最优解比你最初设想的简单得多。 目标

By ladydd

Kiro 的三种代理设置方法:本地、服务端、Remote

作为kiro的骨灰级用户,这篇是我自己折腾 Kiro / Kiro Remote / Ubuntu Server 代理问题后的复盘。 核心不是“怎么配一个代理”,而是先判断:到底是谁在访问外网? 谁访问外网,代理就要配给谁。 0. 先说结论 Kiro 相关代理大概分三类: 场景真正访问外网的进程在哪里代理应该配在哪里本地 KiroWindows / Mac 本机本机 Clash / Proxifier / 系统代理服务端 Kiro / CLIUbuntu Server 上的 shell、CLI、node、kiro 进程Ubuntu 的环境变量,比如 HTTP_PROXY / HTTPS_PROXYKiro Remote远程 Ubuntu 上的 ~/.kiro-server 和 extensionHost远程 Ubuntu 的 Kiro Server

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