拿一个枚举,不值得让模型逐字写 JSON
路由、分类、排序这类活,答案集在推理前就已经定好了。生成一句话再解析,和直接给候选打分,差的不是优雅,是延迟、token,还有一条能拿来设阈值的概率分布。
一条客服工单进来:"I was charged twice for the same subscription."
系统要做的事只有一件,把它分到 billing、technical support、account access 三个队里的一个。
大多数 LLM 调用在这里是这么干的。模型完整跑一遍解码,逐 token 生成,吐出一句 "Based on the customer's query, this ticket should go to billing.",应用再拿正则把 billing 抠出来。
规范一点的做法是结构化输出。schema 把非法 token 挡在外面,模型说不出废话,但底下流程没变,它还在一个 token 一个 token 地生成:
{"team": "billing"}
Avi Chawla 在 Daily Dose of Data Science 的文章里拎出了第三种。应用在推理前把候选答案交出去,模型不写句子也不写 JSON,只给候选打分:
billing 0.90
technical support 0.10
account access 0.00
应用直接拿到标签和概率分布。
三种方式的分工其实很清楚。
普通解码适合需要新写文本的场景。它也能返回一个标签,但那个标签是像其它任何回答一样生成出来、再解析出来的。
结构化输出适合值事先无法枚举、但响应形状必须固定的场景。解码器挡住非法结构,模型仍然逐 token 生成每个字段名和值。
候选打分适合合法答案已经确定的场景。它写不了说明,也产生不了未知字段值,它只做一件事:比较供给它的候选项,返回概率,不解码任何句子或对象。
机制不复杂。prompt 里给每个候选一个短标签,结尾停在 Label:,模型在这个位置产生的那个词表大小的向量里,有 A、B、C 的 logit。取出这三个位置,忽略其它所有值,在这三个数上做 softmax,分布就出来了。
为什么用 A/B/C 而不直接给 billing、technical support 打分。一个可见的词不一定是单个 token。"technical support" 必然跨多个位置,比较这类短语就得做序列打分,模型得先给第一个 token 打分、追加、再打下一个,长度也会混进比较里。单 token 标签把这个麻烦避开了,语义仍然留在 prompt 里:
A = billing questions and payment problems
B = product errors and technical failures
C = login, password, and account access problems
标签是不是单 token 得自己验。分词器常把前导空格编进 token,A 和 A 的 id 不一样。chat template 还可能在答案位置前塞空白或控制 token。做法是用模型的 chat template 渲染完整 prompt,确定答案位置上的确切续写,送给 /tokenize,只要不是单个 token 就拒掉。
还有一件事必须做,加逃生选项。一个 security incident 落进只有 billing、technical support、account access 的路由器,restricted softmax 仍然会把它全部的概率质量压在这三个错误答案上。加一个 OTHER 或者 ESCALATE。
概率不是准确率。0.91 的意思是,在排除其它所有回答之后,billing 拿到了这三个候选之间 91% 的概率质量。它不证明模型 91% 的时候是对的,要测那个得用标注样本。
生产上真正扎人的是两件事。
John Rood 在回复里点了一个,label-set drift。加一个合法答案,所有对着旧候选集校准出来的阈值都得重新怀疑。Avi 的回答更直接,这些概率是条件于候选集的,模型和 prompt 都不动,加一个标签,其它每个概率都会变。真正要一起打版本的是模型、prompt、tokenizer、标签集和阈值这一整套。
Naman Jain 补了另一条,拿打乱顺序的选项测一遍。next-token scoring 对顺序敏感,这一点在训练过的模型上不明显,本地手搓的版本经常在这悄悄翻车。
Avi 把这套在本地复现了一遍,用的是 SGLang 的 /v1/score 和 Qwen2.5-0.5B。prompt 结尾是 Label:,先 tokenize 确认 A、B、C 分别对应 32、33、34,各自一个 token,然后发一次打分请求,拿到 0.678、0.311、0.011。顺带说明了一件常被忽略的事,0.68 这个结果,如果策略要求 0.70 才自动路由,这张工单就该转人工,而那个阈值应该来自标注样本上的评估。
有人已经把它做成了开源项目。jevmlx 跑在 Apple Silicon 上,schema 里放布尔、枚举、多选,加一段 context,一次 batched forward pass,每个字段从允许选项的 logits 里打分,JSON 由程序自己组装,天然合法。
它的本地榜给了一组可比的数字。同样 45 个公开用例,Qwen2.5-7B 走生成再解析是 67.7%,走 labels 打分器是 72.8%,单例耗时从 1.5s 降到 0.6s。Qwen3-8B 是 69.3% 到 75.0%。作为参照,官方引用的 Jev 是 67.8%,GPT-5.6 Terra 67.9%,Claude Opus 5 73.1%,单例 37.8 秒、$0.176。这个对照并不公平,官方数字跑在 TypeSafe 的完整私有评测上,本地是 45 个公开样例,只能看方向。
Kira 在回复里问了个值得认真对待的问题,这不就是几年前大家玩过的 logits 技术吗。是。下一个 token 的 logits 一直都在,取出来在候选上做归一化也不是新东西。变的是有人把它包装成产品,顺手把校准和评测也做了。
RawMean 追问价格优势。本地打分没有那个优势,它省的是延迟和 token,不是账单上的那个数字。Kizuno18 把省延迟的原因列得比原文更清楚。解码受显存带宽限制,每生成一个 token 都要把权重完整读一遍,候选打分只有一次前向;生成要分配 paged KV cache 块、跨 worker 同步,打分跑完就丢,共享前缀缓存一点不脏;打分给的是归一化后的对数似然,阈值路由比如最高概率低于 0.85 就升级到更强的模型,是确定性的,不带采样噪声。
怎么选,看推理前你知不知道输出是什么。输出内容未知,生成。输出集合已知且只需要一次选择,打分。结构化输出管的是响应语法,它仍然逐 token 写字段名和值,值无法枚举时才该用它。
发布时间: 2026-10-28 04:25