我给模型加了一道格式约束,本来做好了它变慢的准备。结果每步快了将近 100 毫秒。
换一种约束写法,同一个模型、同一批题,200 次里有 60 次连一个完整的 JSON 都没写出来:写到 {"x": 608, 就开始吐空格,一直吐到 token 用完。
更好笑的是,如果你按大家最常看的 tok/s 去比,那个快了 98 毫秒的方案会被判成「慢了三分之一」。这一条在第 3 章,是全文最想让你带走的东西;想直接拿清单的看第 6 章。
1. 要模型干的事很小:看图,报一个点
上个月我在做让本地模型点屏幕的实验:给 Qwen3.6-35B-A3B(一个 35B 的混合专家模型,每次只激活 3B 参数干活)一张窗口截图,再给一句任务,比如「撤销上一步」,让它报出按钮在哪。场景是我用 Tk 画的,25 个窗口,每个 8 个按钮,一半有字一半是纯图标,所以每道题都有标准答案:点的位置落在哪个按钮框里,程序自己判。
模型原生的回答长这样:
```json
[
{"point_2d": [613, 607], "label": "target"}
]
```
坐标是 0 到 1000 的相对值。为了这两个数,它要写一个代码围栏、一个数组、一个 label,前后大约 33 个 token。偶尔它还会直接回一句 There are none.(找不到),我的正则就解析不出来。
这就是约束解码要解决的问题。约束解码说白了就是:生成每个 token 之前,推理服务先把不符合你给定格式的候选全部屏蔽掉,模型只能在合法的字里挑。你给一个正则或者一个 JSON Schema,它就只能写出符合那个形状的东西。
直觉上这一步要额外算一遍「哪些 token 合法」,应该更慢。我十月底先拿 40 道题试过一次,结果反过来了。今天用全部 200 道题重跑,四种方式轮流交错着调用(每道题四种方式的先后顺序轮换,避免谁总是排在缓存热了之后),各自先跑一次热身不计入。服务是 vLLM 0.26,temperature=0,不开思考,max_tokens=40。
2. 四种写法,200 题的结果
| 方式 | 中位延迟 | 平均延迟 | 平均输出 token | 解析失败 | 点对 |
|---|---|---|---|---|---|
| 自由输出(模型原生格式) | 382 ms | 406 ms | 32.4 | 5 | 183 |
提示里要 {"x","y"},不加约束 |
413 ms | 454 ms | 37.6 | 200 | 0 |
| 正则约束 | 284 ms | 337 ms | 17.9 | 0 | 188 |
| JSON Schema 约束 | 311 ms | 365 ms | 25.6 | 60 | 128 |
第二行是我专门加的对照。后两种约束用的是同一句提示:「输出 {"x": x, "y": y}」。如果只改提示不加约束,会怎么样?200 次全军覆没。模型压根不听,照样写它自己那套代码围栏加数组,只是把字段名改成了 x,还往 label 里塞了半句任务原文,71 次直接写到 40 个 token 被截断。所以第三行能成,功劳在约束上,提示词本身一点用没有。
正则长这样,空格写死,数字限定在 0 到 1000:
\{"x": (1000|[1-9]?[0-9]{1,2}), "y": (1000|[1-9]?[0-9]{1,2})\}
模型能写的只剩 {"x": 871, "y": 560} 这种东西,17 到 18 个 token,一个字都多不出来。
JSON Schema 那一行是这篇的第二个翻车现场。Schema 我写得很老实:一个对象,两个 0 到 1000 的整数,必填,不许多字段。可 JSON 的语法允许任意空白,约束器就照章办事,允许模型在逗号后面无限加空格。于是 60 次里模型写到这一步:
{"x": 608,
就开始一直吐空格,吐满 40 个 token 被截断。被截的那 60 次中位耗时 424 ms,比正常完成的还慢,等于白白烧掉。
那它是不是变笨了?看能解析的 140 次:Schema 点对 128 次,正则在同样这 140 题上点对 129 次。基本一样。模型认按钮的本事没变,是格式把它卡死了。我十月底那次 40 题小样本也是 8/40 卡死;今天按同样的抽样位置取 40 题,又是 8 次,一次不差。
还有个更阴的坑:vLLM 老版本的写法是在请求里放一个顶层的 guided_regex 字段。在 0.26 上它不报错,请求照样 200,返回的却是 40 个 token 的代码围栏,约束根本没生效。现在得写成 structured_outputs: {"regex": ...}。判断约束有没有真的生效,最省事的办法是看输出 token 数:生效的话,它应该稳定在一个很窄的范围里(我这儿是 17 到 18)。
3. 为什么约束反而更快,以及 tok/s 怎么把它算成了更慢
把 800 次调用的「输出 token 数」和「总耗时」放在一起做个直线拟合:
单次延迟 ≈ 固定开销 + 每 token 成本 × 输出 token 数
≈ 228 ms + 5.73 ms × n
固定开销主要是把那张截图连同提示(约 590 个 token)先算一遍,再加上来回网络;每多吐一个 token,多花 5.73 毫秒。代回去对账:
正则: 228 + 5.73 × 17.9 ≈ 331 ms 实测均值 337
自由: 228 + 5.73 × 32.4 ≈ 414 ms 实测均值 406
Schema:228 + 5.73 × 25.6 ≈ 375 ms 实测均值 365
三行都在 10 毫秒以内。意思很直白:约束器每步多做的那点屏蔽,在这个量级里看不出来;它省下的是 token。自由输出写的那 14 个多余 token(围栏、方括号、label),按每个 5.73 毫秒算,差不多就是 83 毫秒,实测均值差了 69 毫秒。
现在拿最常见的那个指标看一眼:
表面吞吐 = 输出 token 数 ÷ 总耗时
正则:17.9 ÷ 0.337 s ≈ 53 tok/s
自由:32.4 ÷ 0.406 s ≈ 80 tok/s
按 tok/s,正则版慢了三分之一。可用户等的时间,正则版短了 17%。两个结论方向完全相反,数据是同一份。
原因就在那 228 毫秒的固定开销上:输出越短,它在分母里占的比例越大,tok/s 就越难看。所以下次看到「开了结构化输出,速度掉了 X%」,先问一句:它报的是 tok/s 还是一次请求的总耗时?两边输出的 token 数一样吗?如果约束让输出变短了,tok/s 下降几乎是必然的,说明不了任何慢。
冷启动倒是真的有。我换了三条从没用过的正则各调两次:第一条第一次 2541 ms,第二次 246 ms;后两条第一次 388 ms 和 372 ms,第二次都在 240 ms 左右。新格式第一次要现编一个语法,之后就缓存了。只有三个样本,我只敢说:第一次会慢,量级从几百毫秒到两秒多,后面就没事了。如果你每次请求都拼一个不同的 Schema,这笔账每次都要付。
4. 多对的那 5 题,算不算数
正则 188 对自由输出 183,看着多对了 5 题。按上一篇里用过的方法,只看「一个对、一个错」的题:正则赢 6 题,输 1 题。
n 道分歧题里,有 k 道以上偏向同一边的双侧 p
= 2 × P(X ≥ k),X ~ 二项分布(n, 0.5)
7 道里 ≥ 6 道:2 × (7 + 1) / 128 = 0.125
0.125,不够说它更准。再细看那 6 题,有 4 题是自由输出回了 There are none.。自由输出一共说了 5 次「找不到」,正则不给它这个选项,硬逼它报点,这 5 题里对了 4 题、错了 1 题。多对的那几题,大半是这么逼出来的。
这正好是正则方案最大的毛病:它把「找不到」这个选项也一起删了。 我的场景里目标一定存在,所以逼一下反而占便宜。换到真实界面,目标可能真不在屏幕上,这时候它也会自信地报一个坐标,你的 agent 就会点到一个莫名其妙的地方去。要用就得在正则里给「找不到」留一条合法路径,比如允许输出 {"none": true},这个我还没测。
Schema 那边就没什么好犹豫的:正则赢 60 题,输 0 题,60 题全是被截断的那些。
5. 这组实验的毛病
- 只有一个模型、一个版本。 Qwen3.6-35B-A3B 的 AWQ 量化版,vLLM 0.26。别的模型会不会在 Schema 里狂吐空格、别的约束后端会不会默认收紧空白,我没测。
- 场景是合成的,目标永远存在。 这让「强制报点」占了便宜,第 4 章说过了。
max_tokens只给了 40。 给大了,Schema 那 60 次会吐得更久。按上面的公式推算,放到 512 的话,一次卡死大约是 228 + 5.73 × 512 ≈ 3.2 秒,这是推算,没实测。- 每题每种方式只跑了一遍,服务是空闲的、请求是串行的。并发压上去以后固定开销和每 token 成本都会变,那条直线不能直接搬过去。
- 约束组和自由组的提示词不一样。 第二行的对照只证明了「光改提示不行」,没能把「换格式」和「加约束」两件事彻底拆开。
6. 下次给模型加输出格式,按这个顺序
- 先数一下现在的输出里,有多少 token 是格式本身(围栏、label、字段名)。这部分就是约束能帮你省的。
- 能用正则就别用 JSON Schema,正则里把空格写死。Schema 默认放任空白,短输出最容易被它坑。
- 发出去之后看输出 token 数是不是收窄了。不收窄,就是参数名写错了,约束没生效。
- 在格式里给「做不到」留一条合法的出口,不然你会收到一堆格式完美、内容瞎编的答案。
- 比快慢只看一次请求的总耗时,别看 tok/s。顺手把第一次调用单独记,那是编语法的钱。