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

Mac 上 Skill CLI 无法执行的坑:最后其实一条命令就够了

我在做 Amazon skills 的过程中,逐步把本地 CLI 从 Python 脚本切到 Go 二进制。这样做的好处很明显:用户不用装 Python、不用配依赖,解压 skill 后直接运行。但在 macOS 上,我们反复遇到一个看起来很玄的问题:同一个二进制,在 Linux/Windows 上正常,在 Mac 上就是执行不了。 当时遇到的现象 常见报错大概有几类: * 双击或 agent 调用 CLI 时,系统提示文件来自未知开发者,无法打开。 * 终端里执行时提示 Permission denied。 * 已经 chmod +x 了,仍然被 macOS 拦截。 * Apple

By ladydd

当我把全世界人群的基因 PCA 跑出来后,看见了一个倒 L 型

最近我把之前学的一些分子人类学知识,终于真正落地了。 不是停留在看论文、看别人画图、看别人解释“人群结构”这些概念,而是自己把数据处理完,自己跑 PCA,自己把全世界不同人群放到一张图上。 然后那一刻,我真的被击中了。 图上出现了一个非常漂亮的倒 L 型。 一端是非洲,另一端逐渐拉向东亚,中间有中东、欧洲、南亚、欧亚大陆上的各种过渡人群。它不是那种随机散点图,而是有方向、有骨架、有历史感的结构。 我第一眼看到的时候,脑子里直接冒出一句话: 这不像是一张普通统计图,这像是人类迁徙史在二维空间里留下的影子。 当然,后来我也提醒自己,PCA 不能被过度浪漫化。它不是地图,也不是时间轴,更不是“谁从哪里走到哪里”的直接证据。PCA 本质上是把高维基因差异压缩到几个主成分上,用最大方差方向把样本摊开。它可以帮助我们观察人群结构、相似性、分化和混合,但不能单独承担全部历史解释。PCA 在群体遗传学里常用于观察 population structure

By ladydd

吞吐与延迟:一个厨房比喻讲透性能压测

写于 2026-06-26。背景: MCP 服务 跑在 3 台 ClickHouse(每台 16 核 / 64G,1 分片 3 副本)上。 我们花了一整轮做公网压测,把这套系统的极限、天花板和杠杆全摸清了。这篇把"吞吐 / 延迟 / 排队"这三个最容易混的概念讲透,配我们自己的真实实测数据。 一句话结论 我们这套系统的吞吐天花板 ≈ 76 req/s。 往里塞再多并发(100、200、300、500),每秒"做完"的还是大约 76 个,多出来的全在排队。系统不会崩,只会让每个人等得更久。 天花板能不能抬?

By ladydd

四卡 3090 本地模型部署复盘:Ollama 跑通 35B,以及 GPU0 掉卡问题

这次做的是一轮真实的本地模型部署摸底。 目标不是搭一个临时 Demo,而是把一台四卡 3090 GPU 机器接进自己的日常 AI 使用环境:本机跑 Open WebUI,负责账号、会话和前端配置;GPU 机器只负责模型推理。这样以后换模型、换推理框架、重启服务,都尽量不影响本机的使用入口。 最后结论比较清楚:qwen3.5:35b 的 GGUF Q4_K_M 量化版已经通过 Ollama 跑通,本机 Open WebUI 可以接入,热加载后的聊天速度也能用;但 GPU0 存在明显稳定性问题,重启后能短暂恢复,跑过负载后又会掉到 NVML 异常状态。 状态快照 当前能用的部分: * 本机 Open WebUI 已部署,

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