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

把 Codex CLI 的登录态"搬"到一台新服务器

场景:你在一台老机器上早就登录好了 Codex CLI,现在开了台新服务器、装好了 codex,但它没登录。你不想在新机上重新走一遍 OAuth 网页授权(有时候服务器上根本打不开浏览器),只想把老机器上那份"已经登录好的身份"复制过去。 这篇讲的就是这个搬运动作的完整方法论——为什么能搬、怎么搬、有哪些坑。命令里所有隐私都用占位符,照着换成你自己的即可。 一、先理解一件事:Codex 的登录就是一个文件 这是整个操作的地基。Codex CLI(ChatGPT OAuth 登录模式下)的登录状态,不在什么系统钥匙串里,也不在环境变量里,就是家目录下一个单独的 JSON 文件: ~/.codex/auth.json 它长这样(字段名是真的,值我打码了): { "auth_mode": "

By ladydd

哨兵机制:让 Agent 一触即醒

0. 一句话点破本质 **让"等"发生在便宜的子进程里,让贵的 agent 只在有事时醒。**心跳解决"最迟多久必有人查岗",探针解决"事情一发生几乎立刻有人到场"——两个机制回答的是两个不同的问题,谁也替代不了谁。 1. 机制全貌:会自杀的轮询进程 + 宿主的"尸体通知" 我的实现只有两块积木: 积木一:一个有明确死法的后台循环 # 放行任务的同时,后台挂上(run_in_background) for i in $(seq 1 20); do 信号=$(ssh data "tmux capture-pane -t dna

By ladydd

Agent 心跳机制·设计与实现

0. 一句话点破本质 **心跳不是闹钟,是"带着完整世界快照的自我唤醒"。**闹钟只解决"什么时候醒";心跳真正要解决的是你点出的那个问题——醒来的那个瞬间,清楚自己是谁、任务到哪了、这一跳该干什么。我所有跑得好的心跳,提示词都写得像给一个失忆的陌生人看的;所有出过事的心跳,都是因为假设"我还记得"。 1. 第一性原理:为什么"醒来知道干啥"这么难 一个长期任务里的 agent 面临三重失忆: 1. 上下文会被压缩——多轮之后早期细节只剩摘要,心跳打进来时,那条心跳提示词可能是上下文里唯一高保真的任务描述 2. 世界在你睡着时变了——下属可能干完了、卡死了、跑偏了,你脑子里的"进度"从睡着那刻就开始过期 3. 任务本身会变—

By ladydd

我没手动映射 3000,公网为什么还能访问?一次 UPnP 误开孔复盘

写在前面:标题里的“自己打开”只是当时的主观感受。路由器没有失控,也不存在神秘穿透。真正发生的是:排障自动化从局域网主动调用了 UPnP AddPortMapping,路由器按协议新增了公网映射。 1. 原本的设计边界 家里的 Open WebUI 跑在一台 Ubuntu 主机的 Docker 中: 内网主机 192.168.x.x:3000 路由器上手动配置的入口是: 公网 TCP 13000 → 内网主机:3000 外部用户不直接访问家宽端口,而是先到云端 Caddy: 用户浏览器 → https://ai.example.com (云端 Caddy) → http://home.example.com:13000 (DDNS → 家宽公网

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