返回博客

2026.11.01

同一个 35B 点 200 次屏幕:套上正则约束每步快了 98 毫秒,换成 JSON Schema 却有 60 次没写完——约束解码到底该怎么用?

让本地 35B 看截图报一个点击坐标,同样 200 道题跑四种输出方式。用正则把格式钉死,中位延迟从 382 毫秒降到 284 毫秒,解析失败从 5 次降到 0;换成 JSON Schema,60 次卡在逗号后面一直吐空格直到被截断。顺手还发现:按 tok/s 看,正则版「慢了三分之一」。三条能自己算的账:延迟怎么拆成固定开销加每 token 成本、tok/s 为什么会把更快的方案算成更慢、多赢几题到底算不算数。

LLMAgent评测方法论

我给模型加了一道格式约束,本来做好了它变慢的准备。结果每步快了将近 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. 下次给模型加输出格式,按这个顺序

  1. 先数一下现在的输出里,有多少 token 是格式本身(围栏、label、字段名)。这部分就是约束能帮你省的。
  2. 能用正则就别用 JSON Schema,正则里把空格写死。Schema 默认放任空白,短输出最容易被它坑。
  3. 发出去之后看输出 token 数是不是收窄了。不收窄,就是参数名写错了,约束没生效。
  4. 在格式里给「做不到」留一条合法的出口,不然你会收到一堆格式完美、内容瞎编的答案。
  5. 比快慢只看一次请求的总耗时,别看 tok/s。顺手把第一次调用单独记,那是编语法的钱。