深度解析塑造未来的技术文章。

约束解码:把LLM变成快速决策模型

约束解码让LLM变身快速分类器。详解logit掩码、校准与温度缩放,如何让决策模型真正可靠。

一束光穿过闸门分裂成五道彩色光线,象征受约束的token输出。
对词表做掩码,相当于让模型的整个输出分布只能通过少数几道允许的“闸门”。

有个不起眼但很实用的小技巧:如果你只关心LLM输出的第一个token,就可以把它当成选择题来问,一次前向传播就能拿到答案。约束解码——把词表里除了你允许的少数几个token之外全部屏蔽掉——能让生成式模型看起来很像一个分类器。不用解析JSON,不用重试循环,也不用为了输出十一个字符跑十一步自回归。一次前向、一次softmax,就得到每个选项对应的概率。

这个思路一直以“决策模型”或“系统一”推理之类的名字流传。最近有篇教程在Hacker News上火了,它展示了如何用1.7B参数的Qwen模型、约四十行Python代码搭出一个这样的模型。评论区的机器学习老手们自然是怒火中烧:这不就是分类器吗,感知机时代就有了。他们说得对,但也有点没抓住重点。真正新的不是这个概念,而是你无需任何训练,就能从一个通用语言模型里得到一个零样本分类器。值得琢磨的工程问题是:什么时候这种约束式做法优于让模型自由发挥,什么时候它会用过度自信的概率悄悄骗你。

从LLM获取答案的两种方式

与语言模型交互的默认方式是生成。你提问,模型一个token一个token地往外吐,最后再在下游解析输出内容。如果需要结构化输出,就再加一层约束:JSON模式、语法约束采样、outlines式有限状态机。这些方法都管用,但模型仍然要逐token走完整个回复。一个简单的选择题答案可能需要十一步解码,而每一步都要对数十亿参数做一次完整前向计算。

另一种方式是根本不让它一步步走。提示词处理完之后,你只看最后一个位置上整个词表的logits,丢掉除选项对应token ID之外的所有值(比如“A”“B”“C”“D”“E”),只在这几个值上做softmax。argmax就是预测结果,softmax值就是分数。总成本是一次前向传播,和你本来就要付出的prefill开销一样。核心代码大致如下,改编自最近流传很广的基于Qwen的做法:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_name = "Qwen/Qwen3-1.7B"
options = ["A", "B", "C", "D", "E"]
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype="auto", device_map="auto"
)
# First token the model would emit for each option
option_token_ids = [
tokenizer.encode(opt, add_special_tokens=False)[0] for opt in options
]
prompt = (
"What color is the sky?\n"
"A. Red\nB. Blue\nC. Green\nD. Purple\nE. I don't know\nAnswer:"
)
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
enable_thinking=False
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
logits = model(**inputs).logits[0, -1]
# Constrained decoding: softmax over only the option tokens
probs = torch.softmax(logits[option_token_ids].float(), dim=-1)
print(options[probs.argmax().item()])  # -> B

核心逻辑就这些。词表有十五万多个token,而我们把决策压缩成了五个数字。生成时不需要玩采样温度的花样,没有会出错的解析器,模型也没法编出一个不在列表里的选项。输出空间是靠构造本身就封闭的。

它就是分类器,这没什么不好

先公正地看看怀疑派的意见。我们做出来的东西,本质上是一个基于固定标签集的判别式分类器,它可以追溯到1950年代Rosenblatt的感知机,经过逻辑回归,再到深度学习时代每个带softmax头的神经网络。仔细看,约束解码不过是对最终隐藏状态做的一次线性读出,而这正是分类头一直以来的做法。那位多年来一直求团队别再费劲、直接训练分类器的机器学习工程师,看到这东西被重新包装成“决策模型”,有点抓狂也是情有可原的。

但历史也说明了新版本为什么重要。老式分类器很局限:你要收集带标签的数据、训练,最后得到的模型只会一件事,别的什么都不懂。基于LLM的系统之所以不断蚕食那些“本该”用专门分类器的任务,靠的是零样本能力——基础模型已经吸收了足够多的世界知识,一条提示词就相当于训练数据。我拿这样的方案在CommonsenseQA的留出集上测试,1.7B模型在零微调的情况下准确率大约59%,在训练集上快速微调一下后升到约62%。这谈不上最先进,但只花了一下午,也不需要自己的标注数据。专门训练的分类器可能会更好,但它需要一整套流水线、一个数据集,而且每次标签变化都要重新训练。

这里可以借用生成式与判别式之争的老框架。生成式分类器对整个分布建模,浪费但灵活;判别式分类器直接对决策边界建模,高效但僵硬。约束解码是一种有趣的混合体:推理时,把生成式模型征用来做判别式的工作。你既得到了判别式读出的效率,也保留了生成式预训练的广度。这种组合以前确实不存在,即便其中每一块都是老技术。

约束解码的优势所在

  • 延迟。一次前向传播,代替N步自回归。对小模型来说,这就是“足够快,能放进请求链路”和“得排队等”之间的差别。有人在浏览器里跑纯决策模型,报告响应时间低于200毫秒,你试试用生成式JSON做到?
  • 结构正确性。模型根本无法输出允许集合之外的内容。没有格式错误的JSON,没有“答案可能是B,因为……”这类絮叨,也不需要额外的护栏层去拦截格式问题。
  • 吞吐量。因为每个请求都是形状相同的一次前向,批处理既简单又可预测。而生成长度不定的答案,会严重拖累批处理效率。
  • 每个选项一个分数。你拿到的是完整分布,而不只是赢家。这为弃权逻辑打开了大门:如果最高概率低于阈值,就转给人工或更大的模型。

弃权这一点值得强调。生成式回答是一个你要么信任、要么不信任的整体产物。而选项上的概率分布让你能做路由:高置信度的案例自动放行,低置信度的升级处理。很多生产环境的分诊系统都是这个模式,比如工单分派、内容审核预筛、意图识别,这正是这项技术的价值所在。如果你的任务天然可以拆解成“从K个标签中选一个,并告诉我有多确定”,那约束解码几乎肯定是合适的工具。

生成式输出的优势所在

再看另一面。一旦任务不再适合固定标签集,约束解码就站不住了。如果答案是自由形式的实体、一个数字、一段代码,或者任何需要组合的东西,你就需要生成——可能还要加上结构化输出约束,但终归还是生成。还有个更微妙的损失:推理能力。当模型先生成思维链再作答时,在难题上往往表现明显更好。单次前向的决策头没有草稿纸。你要的是系统一式的快速直觉思维,确实得到了它,包括它典型的失误。

还有措辞敏感性的问题。基于“A/B/C/D/E”的约束分类器,实际上衡量的是模型对该位置上每个选项文本的偏好。稍微改写选项C,调换列表顺序,或者把“Answer:”改成“The best answer is”,分数都可能跟着变。带推理的生成式回答通常对表面扰动更稳健,因为模型必须对内容做出承诺,而不只是对一个token做出选择。如果你在评估任何一种方法,不妨改动呈现方式看看会出什么问题——这是一个廉价的稳健性测试,但结果可能令人不太舒服。

穿越原野的两条路,一条笔直,一条蜿蜒,象征快速推理与审慎推理之间的区别。
约束解码是一条直达的路;带推理的生成则是那条蜿蜒的长路,有时能通向更高的地方。

校准问题:你的置信度分数在说谎

这是每个做这类东西的人都会踩的坑。你从softmax里得到了概率,那它们就一定是概率,对吧?并不是。它们表示的是模型认为某个token接下来出现的置信度,这是关于语言的陈述,而不是关于正确性的陈述。问模型“你最可能在哪里找到蝙蝠?”,选项包括“洞穴”和“棒球比赛”,它可能会给“洞穴”打出类似0.998的分数——一个本身有歧义、没有站得住脚的确定答案的问题,却得到了几乎百分之百的确信。

在真实评测上按置信度分箱,情况会更糟。在某次CommonsenseQA运行中,0.9–1.0这一区间的预测只有大约70%是对的,0.8–0.9区间勉强达到40%。一个校准良好的模型,在它说0.9时应该大约有90%是对的。这个模型系统性地过度自信——仔细想想,这与一个老观察相吻合:现代深度网络普遍过度自信。Guo等人在2017年就指出,相比1990年代的浅层网络,普通ResNet的softmax输出校准得很差。旧问题换了个样子又回来了,只不过我们现在是在17亿参数的规模上把它重新发明了一遍。

好在解决办法同样古老,而且简单得近乎尴尬:温度缩放。在softmax之前,用一个学到的标量T去除logits:

def scaled_probs(logits, option_ids, temperature):
selected = logits[option_ids].float() / temperature
return torch.softmax(selected, dim=-1)
# Fit T on a validation set by minimizing negative log-likelihood
# of the correct option. For the CommonsenseQA run above, T ~= 3.8
temperature = 3.7973
probs = scaled_probs(logits, option_token_ids, temperature)

T大于1会让分布变平,T小于1则让分布变尖。用一个留出集拟合这一个数,就能把原来那张糟糕的校准表变得靠谱:0.9–1.0区间现在大约对应95%的准确率,0.5–0.6区间大约对应55%。准确率本身一点没变——argmax对单调缩放是不变的——但分数现在的含义终于和你以为的一致了。如果你打算根据这些概率做路由,要么先校准,要么就别费劲去收集它们。

在基准上调温度算作弊吗?

讨论中有人提出一个合理的问题:为了让模型在基准上看起来校准良好而去拟合T,是不是有点数据钓鱼?如果你在测试集上拟合,那确实是。正确的做法是:在验证集上拟合T,在留出的测试集上报告校准情况——这只是一个单参数回归,而且文献里正是这样处理的标准做法。关键在于ML里处处通用的那条纪律:保持数据划分干净,对任何用了参与拟合的数据来测量校准的说法都保持怀疑。如果你的部署领域偏离了评测领域,你的T也会跟着漂移,所以重新校准应该和其他维护工作一起纳入你的维护流程。

这其实是一个更普遍问题的体现:系统被规定的含义与它实际计算的东西之间存在差距。我之前论证过,这种差距正是bug的藏身之处。“置信度”被规定为正确性的概率,而实现给你的却是下一个token的合理程度。温度缩放是对这道鸿沟的补丁,而不是真正的弥合。每当你想把softmax输出直接接入告警阈值时,都请记住这个区别。

一个实用的决策流程

现在我在两种模式之间做选择时,会过一遍这份简短的清单:

  1. 输出是否是固定的标签集?如果是,约束解码就可以考虑。如果不是,就用生成。
  2. 要达到可接受的准确率,是否需要推理?两种都做个原型。如果单次前向的头落后于思维链的程度超出你的容忍范围,那不管延迟多高,生成都会胜出。
  3. 是否需要每个选项的分数来做路由或弃权?如果需要,约束解码加温度缩放几乎是白送的,而生成式方案无法提供可比的东西。
  4. 标签集有多大?五个选项很简单,五千个就属于检索的地盘了。标签空间很大时,先把标签向量化,用向量搜索做出候选短名单,再让模型在入围者中挑选——这种分类器扩展技巧从推荐系统时代起就是标准做法。
  5. 标签会频繁变化吗?零样本能力正是它的核心价值。如果标签每周都在变,重新训练的分类器就是维护负担;而改一下提示词则不是。

模型的softmax表达的是关于下一个出现什么token的主张,而不是关于什么是真的。要么校准它,要么别信它。

还有一点:模型规模会影响这个选择。小模型的单次前向头足够便宜,可以在每个请求上运行,甚至放在客户端;已经有人在浏览器里运行决策模型,响应低于200毫秒。级联设计——用小型约束模型处理简单的80%,用大型生成模型处理困难的长尾——在成本和准确率上往往都优于任何一个极端。这和推测解码背后的思路是一样的,只不过是在系统层面而不是token层面应用。

我的建议

我的立场是:如果你的任务确实是固定选项的决策——路由、分诊、意图识别、选择题评测——那就构建约束决策头,不必回头。仅延迟上的收益就足以说服你,结构性保证消除了一整类解析失败,而每个选项的分数则提供了生成式方案无法匹敌的路由逻辑。但要把原始softmax当作未校准的仪器来对待。如果能凑出哪怕是一个不大的数据集,就针对你的实际任务做微调;在干净的验证集上拟合一个温度;并在拟合过程从未见过的数据上验证校准情况。

对于那些真正需要组合能力、或者能从可见推理中受益的任务,再使用生成式输出——需要的话加上结构化输出约束。也别有人把“决策模型”当成什么新奇的品类卖给你:它不过是披着LLM外衣的分类器,师承六十年的判别式建模,而只有当你足够尊重这一谱系,去做老前辈们一直坚持的校准工作时,它才最有用。工具是新的,但纪律并不新。