两个模型,同一批真实 prompt,8 条里有 7 条撞满了输出上限被截断。两个模型都是 7/8。
我当时差点在笔记里写下:「这两个模型不适合做结构化输出。」
修复是一个启动参数。修完以后截断变成 0/8,其中一个模型总耗时直接砍掉一半。
这还只是那天的第一次。同一天里我对这两个模型下了四次结论,四次都得改:两次测的是我自己的配置,一次测错了维度,一次测的是我自己出的题。想直接拿判据的看第 4、5 章,都是一个除法或一次对比;想看那 8 道题里怎么藏了 3 道坏题,看第 4 章。
1. 7/8 截断,答案只占 2.4%
背景很简单。我在自己的机器上跑一个多 agent 模拟世界,里面每一轮都要模型吐一段严格的 JSON。我想在两个模型里挑一个:
- Qwen3.8-27B,dense,所有参数每次都参与计算,FP8 权重
- Qwen3.6-35B-A3B,MoE,内部分成很多「专家」,每次只叫醒其中约 3B 参数干活,4bit AWQ 权重
两个各占一张 64GB 的卡,都用 vLLM 起服务。我从模拟世界的存档里拿了 8 条真实 prompt 打过去。
结果两个模型都是 8 条里 7 条 finish=length,意思是生成撞到了 max_tokens 上限、被硬切断。看起来就是「模型一碰长约束就收不住」。
我把它缩到最小再试:只问一句「只输出一个 JSON 对象 {"ok":true}」。
content: "Here's a thinking process:\n\n1. **Analyze User Input:**..." 251 tok
reasoning_content: None
它在想。这一代 Qwen 的聊天模板会在每个助手回合开头先放一个思考标记,模型先写一段思维链再给答案。vLLM 这边要靠 --reasoning-parser qwen3 把思维链拆到单独的 reasoning_content 字段里。我漏了这个参数,于是整段思维链全落进了正文。
vLLM 官方配方里对这个参数的原话是 "not optional in practice"。我是被人提醒了一句「是不是聊天模板没配对」才回去翻的。
确诊签名就一句:reasoning_content 是 None,正文里却有思维链。 看到大量 finish=length,先查这个字段,再怀疑模型能力。
加上参数,再顺手用 --default-chat-template-kwargs '{"enable_thinking":false}' 把思考关掉,同一句话:
content: '{"ok":true}' 6 tok
这里有一个能自己算的数:
答案占比 = 答案本身需要的 token ÷ 模型实际生成的 token
= 6 ÷ 251 ≈ 2.4%
同一个答案只要 6 个 token,修复前它先写了 251 个,还没写到答案。你给 max_tokens 留的预算,几乎全被一段你根本没打算要的独白吃掉了。预算一紧,截断就是必然的。这跟模型能力无关,就是一个除法。
2. 只修了一边,就去做对照
第一个坑我是修了的,但只修了 27B。
修完 27B 我就拿还没修的 35B 当对照,得出一句「35B 截断率高」。这句话测的是两边的启动参数不一样,跟模型一点关系都没有。回过神来,我才把 35B 也补上。
两边都修完,同样 8 条真实 prompt:
| 模型 | 修复前 | 修复后 |
|---|---|---|
| Qwen3.8-27B | 604.4 秒,截断 7/8 | 366.8 秒,截断 0/8 |
| Qwen3.6-35B-A3B | 163.0 秒,截断 7/8 | 84.4 秒,截断 0/8 |
35B 那一行快了将近一倍(163.0 ÷ 84.4 ≈ 1.93 倍)。之前那些时间,大半花在生成没人要的思维链上。
这条说白了就是对照组的基本功:实验组动过的每一个配置,对照组都要动一遍。 道理谁都懂,我照样只修了一边,因为修完第一个我就急着看结果了。
3. 46/46 对 43/46,我测的是听话程度
两边配置拉平之后,我跑了一套更正式的:从模拟世界里抽 46 个用例,覆盖 12 类 JSON 输出契约,一共二百多个检查项。
| 模型 | 用例全过 | 检查项 | 耗时 |
|---|---|---|---|
| Qwen3.6-35B-A3B | 46/46 | 276/276 | 5.6 分钟 |
| Qwen3.8-27B | 43/46 | 271/274 | 27.8 分钟 |
27B 挂掉的 3 条全在同一类上。打开一看,它输出的是一段英文分析,带 Markdown 标题,像在写报告,finish=stop。也就是说它认为自己答完了。三条耗时 123、113、33 秒,是那一类里最慢的。
思维链已经被拆走了,这是模型自己的行为:碰到长约束推理的任务,它倾向于展开写分析,不老老实实填表。
于是我写下了第三个结论:35B 更好。
然后被问了一句:「3.8 的 27B 不可能不如 3.6 的 35B 吧?」
我去查了第三方的数字。Artificial Analysis 的综合智能指数,27B 是 52,35B 是 32,差了 20 分。这个指数我没复现过,只是拿来当一个独立来源。
回头看我那套 46 个用例,测的是「在很长的 prompt 下能不能乖乖吐出合法 JSON」。这是格式服从,衡量的是听不听话。我拿它下了一个「谁更强」的结论,维度就错了。
4. 自己出的 8 道题,3 道是坏题
为了测「谁更聪明」,我当场写了 8 道题:每道都要多步推理,答案唯一,能用程序判对错,不需要再请一个模型当裁判。
第一轮结果:27B 4/8,35B 5/8。35B 又赢了。
这个结果跟第三方指数正好反着,我不太信。于是拿 Python 的 fractions 和暴力枚举把 8 道题的标准答案全部验算了一遍:
- 题 1,标准答案写错了。 袋子里 5 红 3 蓝 2 绿,不放回取 3 个,恰好 2 红 1 蓝的概率。我写的是 5/12。正确是 C(5,2)×C(3,1)÷C(10,3) = 10×3÷120 = 1/4。两个模型都答了 1/4,都被我判错。
- 题 3,无解。 「甲乙丙恰有一人说谎。甲说乙在说谎,乙说丙在说谎,丙说甲和乙都在说谎。」三种情况挨个试:甲说谎则乙诚实,丙就说谎了,两个人说谎;乙说谎则丙诚实,可甲也诚实,丙的话就是假的,矛盾;丙说谎则甲诚实,乙就说谎了,又是两个人。没有一个解满足「恰有一人」。这题我自己出烂了。
- 题 7,有歧义。 「4×4 的网格从左上走到右下,只能向右或向下,几条路径。」按 4×4 个格子算是 C(8,4) = 70,按 4×4 个点算是 C(6,3) = 20。我标的是 20,27B 答了 70。它没错,是我题目没说清。
删掉无解的题 3,改对题 1,题 7 两种答案都算对,剩 7 道:
修正前 27B 4/8 = 50% 35B 5/8 = 62%
修正后 27B 7/7 = 100% 35B 6/7 = 86%
排名翻过来了。35B 错的那道是 (1+1/2)(1+1/3)…(1+1/99),每一项是 (k+1)/k,相邻项一路约掉,剩 100/2 = 50。它答了 100。
这里有第二个能自己算的式子,我觉得它比这一节任何一个分数都值钱:
坏题率 = 坏题数 ÷ 总题数 = 3 ÷ 8 = 37.5%
分差 = 两个模型差了几道题 ÷ 总题数 = 1 ÷ 8 = 12.5%
坏题率 ≥ 分差 → 这个排名不能信
我的卷子坏了 37.5%,两个模型只差 12.5%。坏题的量是分差的三倍,排名跟抛硬币差不多,碰巧还抛反了。拿没验算过的题去评模型,测的是我自己的算术。
事后看,题 1 其实早给过信号:两个模型给出同一个答案,又同时被判错。两个独立的模型犯同一个错,概率比我写错标准答案低得多。所以这是一个零成本的筛子:两个模型答案一致、又同时判错,先去验标准答案。
5. 想开思考,参数被静默吞掉
推理题那一轮,我还想看看 27B 开了思考会不会更强。服务端启动时带着 --default-chat-template-kwargs '{"enable_thinking":false}',我就在请求里传 chat_template_kwargs: {"reasoning_effort":"xhigh"},想按请求打开。
跑完一对比,「关思考」和「最高档思考」两轮的输出逐字节相同:
关思考 题1 12.4s 511tok 题2 15.9s 673tok 题8 8.0s 317tok
最高档思考 题1 12.8s 511tok 题2 16.6s 673tok 题8 7.8s 317tok
耗时有零点几秒的抖动,token 数一个不差,reasoning 字段全是空的。在我这个部署里,服务端这个参数是硬覆盖,请求级的同名参数改不动它,而且不报错。
所以那一轮「开思考」的数据等于白跑。想换思考档位只能重启服务,重启后我又专门查了一次 reasoning 字段,有 156 个字符,才算确认真开了。上面 27B 的 7/7 是关着思考拿的。
这是我觉得全文最通用的一条判据:
两组配置跑同一批题,逐题的 completion_tokens 全部相同 → 你改的那个变量没生效
温度为 0 的时候,两个真正不同的配置几乎不可能在每道题上都生成一模一样长的回答。完全相同就说明它们根本是同一个配置。这个检查不需要读任何源码,把两份结果的 token 数并排一放就行。
6. 速度那个 4.3 倍,以及怎么拆别人的「X 比 Y 强」
修完所有问题,那 8 条真实 prompt 上:27B 用了 366.8 秒,35B 用了 84.4 秒,35B 快 4.3 倍。
能不能自己估一下?解码阶段每生成一个 token,大致要把参与计算的权重从显存里读一遍:
每个 token 要读的权重 ≈ 激活参数量 × 每个参数的字节数
27B dense,FP8:27B × 1 字节 ≈ 27 GB
35B-A3B,4bit:3B × 0.5 字节 ≈ 1.5 GB
纸面上差 18 倍,实测是 4.3 倍。这个缺口我没拆,里面至少混着三样东西:prefill(先把整段 prompt 读进去的那一步)不只看激活参数;注意力和缓存有各自的开销;两个模型在这 8 条上写出来的答案长度不一样,所以 4.3 倍是总墙钟时间之比,不是每 token 速度之比。估算那两行只能看量级。
不过选型时这条能直接用:MoE 的速度看激活参数量,看总参数量会看反。 35B 的名字比 27B 大,实际快 4 倍多。
现在回到这一天最后的结论表:
| 维度 | Qwen3.8-27B | Qwen3.6-35B-A3B |
|---|---|---|
| 第三方智能指数 | 52 | 32 |
| 我的推理题(7 道) | 7/7 | 6/7 |
| JSON 契约(46 个用例) | 43/46 | 46/46 |
| 8 条真实 prompt 总耗时 | 366.8 秒 | 84.4 秒 |
同一对模型,看第一、二行是 27B 赢,看后两行是 35B 赢。要严格 JSON 和吞吐,我最后还是选了 35B;要对话和推理,选 27B。
这张表也是我拆别人「X 比 Y 强」时用的模板。看到任何一个模型排名,问三句:
- 它测的是哪个维度?能力、格式服从、速度,三者经常互相打架。只给一个维度就下通用结论的,默认不信。
- 两边的部署参数一样吗?尤其是思考开关和推理解析器。一边漏了,结论测的就是配置。
- 题目的标准答案验算过吗?没说的,拿它的坏题率去跟它的分差比一比。
7. 我没做到的
- 推理题只有 7 道。 6/7 和 7/7 差一道题,换一套题完全可能打平。我只敢说「35B 在能力上没赢」,不敢说「27B 显著更强」,那句话靠的是第三方指数。
- 第三方指数我没复现。 52 和 32 是直接引用的。
- 题 7 两种答案都算对,是我事后放宽的。 这对答 70 的 27B 有利。按严格口径只认 20 的话,27B 就只剩 6/7。35B 那道题答的是多少我没留记录,所以严格口径下谁领先我说不准。
- 4.3 倍没拆。 输出长度、prefill 占比我都没单独量,所以它只能当「这两个模型在我这批 prompt 上」的数字用。
- 思考开到最高档的 27B 成绩,我没测完整一轮。 确认思考真的打开以后,就只验了那 156 个字符。
- 硬覆盖这件事只在我这一个 vLLM 版本上确认过。 别的版本或别的引擎,行为可能不同,你得自己用第 5 章那个 token 对比测一遍。
8. 下次给本地模型做选型,按这个顺序查
- 看到大量
finish=length,先查reasoning_content是不是空的、正文里有没有思维链。算一下答案占比,低得离谱就是配置问题。 - 实验组改过的每一个启动参数,对照组照抄一遍,再跑。
- 自己出的题,先用程序验算标准答案、枚举一遍有没有解、读一遍有没有两种理解。
- 算坏题率,跟两个模型的分差比。坏题率不比分差小,就别排名。
- 两个模型答了同一个答案又同时判错,先查标准答案。
- 改了一个开关,先把两组结果的逐题 token 数并排放一下。全一样,就是开关没生效。
- 结论表至少列能力、格式服从、速度三行,按任务选,别报一个总冠军。