写在前面:可解析性是一种正确性属性,不是一种祈祷
过去两年里,几乎每一个把大模型接进生产系统的团队,都会在某个深夜遇到同一类事故:模型在应该返回 JSON 的地方返回了一段看起来非常像 JSON 的散文;或者在 tool_calls 的 arguments 字段里塞进一个带尾随逗号的、括号没闭合的、或者干脆用中文引号包起来的对象;又或者更隐蔽一些——JSON 完全合法,但 temperature 的值跑到字符串外面去了、枚举值大小写对不上、某个必填字段悄悄消失了,而你的校验函数在下游某个异步队列里才把它炸出来。
这类事故的共同点值得反复强调:它不是"模型不够聪明"的问题,而是"接口契约没有被强制执行"的问题。你在提示词里写"请只输出 JSON,不要有任何解释文字",这是一种请求,而不是一个机制。请求可以被忽略,机制不能。把可靠性寄托在模型的"听话程度"上,本质上是把一个确定性的工程问题,转化成了一个概率性的模型行为问题——而后者你既不能测试,也不能在半夜被告警时快速定位。
到了 2026 年,这个问题已经有了相当成熟的答案族。答案的核心思想只有一个:不要在采样完之后才去检查输出是否合法,而要在每一个采样步上,就把不合法的 token 从候选分布里物理性地抹掉。这就是约束解码(constrained decoding / grammar-constrained decoding)。它把"生成一段文本"变成了"在语言模型与某个形式语言求交的约束下做贪心采样",从此可解析性从一个概率问题变成了一个可以工程化的确定性问题。
但约束解码不是银弹,它有自己的明显代价,也覆盖不了全部场景。所以这篇文章要讲的是一套三层做法:
- 逐 token 掩码——在解码期强制语法,用 FSM 或语法(GBNF / 正则 / JSON Schema)把不合法 token 屏蔽掉,这是第一层,也是最可靠的一层;
- 采样后修正——parse-repair 加结构化校验加有限重试,在生成完之后抢救,这层覆盖语法约束管不到的语义问题;
- 降级解析——当两层都没救回来时,用宽松的容错解析器榨出最后一点可用信息,并且诚实地把置信度一起返回,让上游决定是重试还是降级。
三层不是三选一,而是从便宜到贵、从确定到不确定的降级链条:约束优先,校验兜底,降级解析。这篇的顺序就是这条链条的顺序:先说清楚为什么靠提示词不可靠,再往下一层一层走,最后把所有成本摊开算一笔账——每层分别要付多少时延、分别能救回多少比例的失败,以及在 2026 年的具体技术栈里它们各自该由谁来实现。
如果你正在做的是"模型输出必须能直接喂给下游"的场景——工具调用、结构化抽取、SQL 与代码生成、配置生成、Agent 的动作空间——这篇文章是给你的。如果你只是想"让 JSON 好看一点",那你多半不需要读到最后。
一、第一层失效现场:为什么"提示词要求输出 JSON"不可靠
先别急着上工具,我们得先把不靠工具会发生什么看清楚。不做这一步,选型讨论就是空中楼阁——因为很多团队在踩过一遍坑之后,结论是"约束解码太重了,加个 few-shot 就够",而另一些团队的结论是"必须上引擎侧约束"。这两种结论可能都对,取决于你可接受的失败率和失败时的代价。
1.1 四种典型失效形态
把线上真实见过的失败样本归类,大致是这四类。注意它们互相独立,也就是说你在提示词上做的每一条优化,通常只对其中一类有效,这也是为什么"多写几遍提示词"收益递减得很快。
| 失效形态 | 具体表现 | 提示词能否缓解 | 约束解码能否根治 |
|---|---|---|---|
| 包裹污染 | 输出前面带一句"好的,这是您要的 JSON:",或结尾跟一段解释;被 markdown 代码围栏包住 | 部分能,靠明确指令 + few-shot | 能,靠起始符与终止符约束 |
| 语法破损 | 尾随逗号、单引号、中文引号、字符串内换行未转义、括号不配对、生成中途被 max_tokens 截断 | 几乎不能,这是 tokenizer 与采样的概率行为 | 能,掩码直接屏蔽非法字符序列 |
| 模式漂移 | JSON 合法,但枚举值写成 "Search" 而 schema 要 "search";数字写成 "18" 字符串;日期格式不统一 | 部分能,靠在提示词里重复 schema | 只能覆盖被显式编译进约束的部分 |
| 内容幻觉 | JSON 合法且字段齐全,但值是编的:工具名不在工具列表里、参数个数对不上 | 不能 | 不能,这是模型知识/推理问题 |
第四行是整篇文章最需要建立的一个认知分界线:约束解码只治前三行里跟语法有关的部分,它对"内容是不是对的"完全无能为力。你可以用约束解码保证工具名一定出自你给的候选集合,但没法保证模型选了正确的那个工具。这是一个概率质量问题,属于重排序、工具描述优化、少样本对照或干脆换模型的范围。
1.2 为什么 temperature=0 也救不了你
一个很常见的误解是:既然模型不稳定,那我用贪心解码(temperature=0、top_p=1)就完全确定了。这个推理有两个漏洞。
第一个漏洞是贪心只消除了采样随机性,没有消除分布里的小概率路径。在模型认为"某个错误 token 有 0.3% 概率"的位置上,贪心每次都会选那个最大的 token——如果最大的恰好是错的那个,贪心就会确定地、稳定地产出错误。这就是为什么很多"偶尔解析失败"的线上问题,在离线评测里复现不了,而一旦复现就 100% 复现。约束解码在这里的价值就在于:它把 0.3% 的那条路径在分布层面删除,而不是靠运气不踩中它。
第二个漏洞更根本:贪心解码不改变"模型倾向于说什么",只改变"在倾向于说的话里怎么选"。当上下文里存在大量与目标格式矛盾的信号时——比如模型在 few-shot 示例之后已经不倾向于输出括号了——贪心解码只会更快地跑偏。再叠加两个现实因素:批处理会让数值 kernel 不完全确定(同一请求在不同 batch 组成下的 logit 可能有微小差异,进而在 top-1 接近时翻转),以及模型版本/量化档位的任何一次变动都可能改变胜负。这两点合起来意味着:任何"我们靠温度已经够稳了"的判断,在生产环境的长期尺度上都是不成立的。
1.3 提示词侧能做的,以及它的天花板在哪
公平地说,提示词工程在结构化输出上仍然有价值,只是它不能单独承担正确性保证。有效的做法通常包括:在系统提示里给出完整的 schema 而不是字段名列表;提供两到三个覆盖边界情况的 few-shot 示例(尤其是枚举值、数字、null 的写法);显式声明"不要输出 markdown 代码块";把需要抽取的原文用清晰的定界符包起来;以及在生成完之后紧跟一个"如果上一个回答不是合法 JSON,则先输出你的修复过程"的自检指令——最后这条在实践中出奇地有用,因为模型对自己的输出有很强的局部注意力,自检往往能在同一个上下文里把破损的 JSON 修回来。
但天花板也很清楚:这些手段全部作用在"意图"层面,而失效发生在"字符"层面。意图是连续的、可以用自然语言描述的、模型确实在学的;字符是离散的、组合爆炸的、需要外部机制来保证的。这就是为什么 2026 年的共识做法是把意图和机制分开——提示词负责让模型愿意按 schema 填内容,约束解码负责让输出一定能通过语法解析。两者不是竞争关系,是互补关系。
一个实用的判据:如果你无法写出一个"输出解析失败"的可复现的自动化测试用例,说明你还没有真正理解自己的失败模式,也就无从判断哪种方案解决了问题。约束解码之所以值得投入,很大程度上是因为它把失败模式从概率性的、难复现的变成了结构性不可能——而后者才可能写进 CI。
带着这个判据,我们往下一层走:既然要在解码期动手,那就得先搞清楚"在解码期到底能做什么"。
二、逐 token 掩码:把"合法"变成每一步都能计算的东西
约束解码的想法朴素得有点反直觉:既然模型只会在最后产出一整段文本,那我们就假装它是一个逐 token 输出的机器,在每一步都问一次"下一个字符,在当前上下文里是不是合法的"。只要这个判断足够快、足够准,非法输出就永远不会被拼出来。
2.1 从概率分布到允许集合
具体来说,普通采样是:拿到 logits 向量 l,过 softmax,按 temperature 和 top-p 采样。而约束采样是:拿到 l,先算出一个允许 token 集合 A,把所有不在 A 里的位置的 logit 压到 -inf,再照常采样。用伪代码写就是这个意思:
import math
NEG_INF = -math.inf
def constrained_sample(logits, allowed_token_ids, temperature=0.0, top_p=1.0):
# 1) 掩码:非法 token 直接物理性出局
masked = [logits[t] if t in allowed_token_ids else NEG_INF
for t in range(len(logits))]
# 2) 正常采样流程,只在剩下的合法 token 里做
if temperature <= 0:
return max(range(len(masked)), key=lambda i: masked[i])
scaled = [x / temperature if x != NEG_INF else NEG_INF
for x in masked]
probs = softmax(scaled)
return top_p_sample(probs, top_p)
这段代码的全部魔法只在第 1 步。剩下的采样逻辑跟平时一模一样——这意味着你依然可以采样、依然可以有温度、依然可以有多样性,只不过多样性被限制在"合法的那些输出"之内。很多人在第一次接触时会误以为约束解码会让输出变得死板,其实不然:它砍掉的是不可能性,不是可能性。真正会变得死板的是约束过紧的情况,这在 2.5 节和第三节会讲。
上面这个写法有个显然的性能问题:每个 decode step 都要在 Python 里遍历一遍整个词表。通常词表是几万到十几万量级,逐 step 的 Python 循环会成为瓶颈。所以真实实现要快得多。
2.2 加速:状态缓存、位掩码与向量化
把所有常见的优化手段摊开讲,因为这部分往往是"能不能上生产"的分水岭:
- 状态缓存。约束状态是离散的、有限的。相同的语法状态只需要算一次允许集合,之后直接查表。现代实现几乎都是"先遍历一遍 token 词表,给每个 token 的字符串形态打分,再用集合的位掩码(bitset)表示",这样"求交集"就退化成几个整数位运算。
- 把掩码做进 kernel。更好的做法是直接构造一个 logits_bias 稀疏张量(
序列长度 × 词表,值-inf),通过额外的输入张量喂进 attention kernel,让 GPU 一次性完成加法,完全不进 CPU 循环。SGLang 和 vLLM 的 guided decoding 走的都是这条路。-inf要转成很小的负数(如-1e9)才安全,防止在 softmax 里出 NaN。 - 确定性 vs 缓存。开缓存能显著降低 CPU 开销,代价是每个 step 的 CPU 工作量不再恒定,因此吞吐会有微小抖动。这一点对离线批量生成无所谓,但对有 SLA 的在线服务需要在压测时观察。
把这三条放在一起看,可以得出一个相当反直觉但很重要的结论:约束解码的主要开销不在算法复杂度,而在"掩码的构造与传递方式"。同样是 FSM 约束,一个在 Python 里逐 token 遍历的实现和一个用稀疏张量直喂 kernel 的实现,吞吐差距可能是十倍量级。选引擎、选实现方式,比选算法本身更重要。
2.3 FSM:状态即栈
最基础也最可控的实现是有限状态机。它的全部状态就是一个待闭合符号的栈,加上一个是否处于字符串内的标志位——两者加起来不到十个变量。推进函数逐字符消费输入:遇到开括号就把对应的闭合符号压栈,遇到闭合符号就弹栈并比对是否匹配,遇到引号就翻转字符串标志位并处理转义。而"当前允许哪些 token"这个问题,由这个状态直接回答:在栈非空时只允许出现与栈顶配对的闭合符、逗号、或者任何可以继续填充内容的起始符;栈为空时只允许出现一个完整 JSON 值的起始符。
这个状态机是语法正确性的完整保证。实现上有一处细节值得强调:判断 token 是否合法时,不能只看 token 的第一个字符,还要检查它的完整字符串形态。原因是词表里的一个 token 可能同时包含多个字符(比如某个 token 的形态是 0, 而不是 0 加 ,),只看首字符会把合法的 token 误杀,也会漏掉真正非法的 token。这一点是自研实现最常见的 bug 来源。
但请注意这个状态机完全不知道 schema。它允许你写 {"foo": {"bar": [1, 2, "baz", null]}},只要括号配对就行。要让它理解"这里必须是一个数组、里面每项必须是字符串",就得往上加一层——这正是第六章的 Schema 编译。而理解语义还有一个更麻烦的地方叫属性依赖:如果你写了 "type": "error",那就必须有 "message" 字段,而且不允许有 "value"。这是上下文相关语法,单靠"当前状态 + 栈"是表达不了的,得靠路径相关的约束表来解决。
2.4 语法引导与 GBNF:llama.cpp 的路线
FSM 只能表达正则语言和有限上下文语言,而 JSON Schema 里大量特性是上下文无关的(任意深度的嵌套对象和数组)。硬用 FSM 写会写得很痛苦,于是有了语法引导(grammar-constrained decoding)的通用框架:把一种上下文无关文法编译成"终止符 + 前缀树",然后在每一步用文法状态算出允许集合。
llama.cpp 选择的文法描述语言叫 GBNF(llama.cpp 扩展的 BNF),语法非常朴素,就是一堆产生式规则的组合:
# GBNF 示例:一个被约束死的工具调用
root ::= "{" ws "\"tool\":" ws tool "," ws "\"arguments\":" ws args ws "}"
tool ::= "\"search\"" | "\"calculator\"" | "\"weather\""
args ::= "{" ws "\"query\":" ws string ws "}"
string ::= "\"" char* "\""
char ::= [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F]{4})
digit ::= [0-9]
number ::= "-"? digit+ ("." digit+)?
ws ::= " "?
几个设计要点值得留意,因为它们直接影响到你能不能写出可用的语法:
- GBNF 本身没有可执行语义,只有产生式。所以它能表达"这里必须出现
"tool":",但表达不了"这里的字符串长度不得超过 200 个字符"这类需要计数或数值比较的条件。这类语义只能留给上层校验,或者用"引用 + 规则替换"的间接层硬拼。 - 递归深度。文法允许递归,但 grammar 的状态机必须能判定"还剩多少嵌套空间",否则一个合法的深嵌套 JSON 会把状态空间撑爆。实践中要么限制最大嵌套深度,要么在每 n 步重新生成掩码。
- 状态缓存的正确性。实现里最容易出的 bug 是:掩码必须按"当前状态"算,而不是按"下一个状态"算。搞错的后果非常隐蔽——约束看起来在生效,但偶尔会漏掉一个非法 token,且极难复现。
Outlines 走的是更彻底的路子:先把 Python 类型注解、正则、以及一批预定义结构编译成索引化的有限状态自动机(FSM),再用 DFA 的边表在每步查表。它的卖点在编译一次、缓存复用——同样的 schema 在多次请求间复用同一个 DFA,运行时几乎零额外开销。XGrammar 也是这个思路,并且补上了一个 Outlines 早期版本比较弱的地方:上下文感知的字节级约束,即正确处理一个 token 里包含多个字符(如 temperature 可能被 tokenizer 切成多个 token)的情况。
这里有一个非常容易低估的坑:约束的粒度是 token,语法的粒度是字符。当一个 token 的文本形态跨越了语法边界(比如一个 token 是0,而不是0加,),朴素实现要么漏约束(多输出了非法字符)要么过度约束(合法字符被误杀)。XGrammar 明确把这个叫做"字节级"或"字符级"对齐问题,并把它列为核心特性——如果你要自己写约束解码,这是绕不过去的一个工程点。
2.5 约束解码的代价:速度到底损失多少
约束解码是有成本的,而且成本高低高度依赖实现,不能一概而论。可以分几块看:
(1)掩码构造的 CPU 开销
每个 decode step,引擎需要产出当前步的允许 token 集合。如果实现是"每次重新遍历语法"(比如逐字符走 FSM 并逐个匹配),成本是 O(词表大小 × token 平均字符数);如果是"预编译 + 状态缓存 + 查表",成本接近 O(1)。前者在小词表模型上勉强可接受,在十万级词表上就会明显拖慢整体吞吐。好消息是:缓存未命中只发生在每个新生成的语法状态上,而同一个 schema 下,常见路径上的状态总数是有限的——这就解释了为什么一个复杂 schema 用久了反而"变快"(缓存热起来了)。
(2)logits 后处理进 kernel 的开销
掩码最终要变成一次 -inf 加法。如果是"生成稠密的 -inf 张量再相加",每次 step 都要写一遍十几万长度的显存——这在几十微秒的量级,通常相对单步 GPU 计算(几毫秒)可以忽略。但如果为了省这点开销而把约束逻辑塞进主 kernel,会严重复杂化代码。可行的折中是把掩码构造成 GPU 张量(用布尔 GPU 索引或者稠密 -inf 张量)从而避免 CPU 循环,但不要为此改动主 attention kernel。
(3)对输出质量的意外影响
这是最容易被忽略的一块。约束解码会改变模型的实际分布:被屏蔽的那些 token 如果概率很高,等价于把概率质量重新分配给合法 token 上,这会让输出比无约束时更"确定"。极端情况下,一个约束写得太紧的语法会导致模型在极少的可选路径里挣扎,输出质量(而非格式正确性)反而下降。所以"约束越严越好"是错的——约束的目标是消除不可能,不是指定唯一答案。过约束的经典症状是:输出格式完美,但内容变得干瘪、重复、或者总是选枚举里的第一项。
(4)多轮对话中的额外复杂度
流式对话比单轮生成复杂得多。约束解码和流式几乎是天敌:约束的允许集合依赖"到目前为止已经生成的完整前缀",而流式解码中,你在拿到第一个 token 时并不知道用户会等多久、后面接什么。这引出一个很实际的设计决策:在流式场景下,要么延迟到一定长度才开始施加约束,要么干脆不在流式阶段约束、只在最终拼好的字符串上做解析修正。这也是为什么很多 API 的"JSON mode + stream"其实只做了最轻量的括号平衡,完整约束走的是非流式路径。
把这四点合起来,约束解码的真实成本画像是:CPU 侧增加一次每步的掩码工作,GPU 侧增加一次张量加法,两者都在"单步几毫秒"的量级下被稀释;但它同时会带来 20% 到 60% 不等的吞吐下降(取决于实现的缓存命中率和约束复杂度),并且在与流式、投机解码(speculative decoding)等优化组合时需要额外适配。投机解码尤其麻烦——draft 模型不知道约束的存在,会提出非法 token,必须由 target 模型在验证阶段再过滤。
说完了"怎么强约束",下一节回答一个更具体的问题:你手里的约束不是一段手写语法,而是一份 JSON Schema,怎么把它变成解码器能用的形式。
三、约束解码到底在算什么
前面两章讲的是"怎么做",这一节补一块很多人跳过但迟早会遇到的认知:掩码掉那些 token 之后,模型的概率分布究竟发生了什么变化。不理解这一点,你就无法解释为什么约束解码有时会让输出变差,也无法判断自己的约束是不是写得太紧了。
3.1 它是一次条件化,不是一次过滤
最常见的直觉模型是"过滤":把不合法的 token 挑出来扔掉,剩下的按原概率采样。这个直觉在某些情况下是对的,但在数学上是错的,而且差别很关键。
准确的说法是:设模型在当前前缀 c 下的 token 分布为 P(t | c),约束定义的合法 token 集合为 A(c),那么约束解码实际采样自的是在合法集合上重新归一化后的条件分布:
# 无约束:P(t | c)
# 约束后:P'(t | c) = P(t | c) / Z(c) 当 t ∈ A(c),其中 Z(c) = Σ_{t' ∈ A(c)} P(t' | c)
# P'(t | c) = 0 当 t ∉ A(c)
# Z(c) 就是被剪掉的概率质量总和:所有非法 token 的概率加起来是多少
clipped_mass = 1.0 - Z(c)
这个 Z(c)(或者说 clipped_mass)是一个可以直接测量、并且非常有用的量:它告诉你模型"有多想"说一个非法的东西。如果 clipped_mass 长期接近零,说明模型本来就想说合法的,约束只是个摆设;如果它长期很高(比如 30% 以上),说明你的约束和模型的强倾向严重冲突,这时候约束不是在"避免错误",而是在"强迫模型说它不想说的话"。
后者是质量问题的根源。想象约束要求 temperature 必须是 0、1、2 三个值之一,而模型非常想输出 0.7——掩码强行把它推向 2,模型的内部状态其实一直在对抗这个约束。结果就是:字段值是合法的,但可能莫名其妙地总选最后一个,或者在别的字段上出现冗余、重复。这不是 bug,是条件化之后的必然。
把 clipped_mass 加进你的监控面板。它是唯一一个能在不解析输出的情况下、在线发现"约束过紧"的指标——而输出质量下降恰恰是最难在生产上自动检测的一类问题。
3.2 约束强度谱系
把"约束"这个词摊开,会发现它其实覆盖了差别极大的几类东西。搞清楚自己在哪一层,能省掉很多无效努力。
| 层级 | 约束的是什么 | 实现手段 | 可靠性 | 可扩展性 |
|---|---|---|---|---|
| 字面量约束 | 某个位置必须精确等于某个字符串 | 掩码到唯一 token;或直接模板拼接 | 最高 | 最好 |
| 词法约束 | 整数的数字形态、日期的字符集、标识符的字符集 | 字符集掩码 / 正则 | 高 | 好 |
| 语法约束 | 括号配对、键值交替、类型结构、枚举取值 | FSM / GBNF / JSON Schema 编译 | 高 | 中等,schema 复杂时退化 |
| 语义约束 | 跨字段一致性、属性依赖、取值范围、引用完整性 | 只能靠采样后校验 | 取决于校验器 | 差,规则会爆炸 |
| 意图约束 | 输出的内容是否真的回答了问题 | 提示词、少样本、推理、换模型 | 低 | 不可控 |
这张表有一个非常实用的推论:约束解码的性价比随着层级递减,而覆盖度随着层级递增。所以正确的投入顺序应该是——先把字面量约束用足(比如能用 const 就别用 string),再把能编译成语法的语义关键字全编译掉,剩下编不掉的统统交给校验器,最后才是在提示词上使劲。很多团队跳过了前两步直接卷提示词,等于用最贵的方法解决最容易的问题。
3.3 一个反直觉的结论:约束不总是让输出更"窄"
很多人第一次接触时会以为约束解码减少了输出多样性,所以应该用它来做确定性输出、用普通采样来做创造性任务。这个理解是反的,至少在两个意义上是反的。
第一,约束减少的是不可能路径,不是可能路径。如果你的 schema 允许六种取值,那么约束下的分布仍然覆盖全部六种,只是比例被重新分配了。真正决定多样性的是 schema 有多具体——一个字段有 200 个枚举值的 schema 远比一个自由字符串更"窄",而这跟用不用约束解码无关。
第二,在结构化生成任务上,加约束反而常常让内容更丰富。这是一个可复现的现象:当模型在自由生成时被"要输出合法 JSON"这个格式负担分散了注意力,它留给内容的预算就少了;一旦格式由解码器保证,模型可以把它全部注意力放在字段值上。表现出来就是:加了约束之后,必填字段被填得更完整、更少出现空字符串、更少提前结束。原因是"必须填完所有必填字段"从一个概率性诉求变成了结构性事实——模型没有"忘记某个字段"这个选项了。
推论是:对于抽取、分类、摘要成结构化字段这类任务,约束解码的主要收益往往不是"解析率",而是"字段完整率"。这个指标比解析率更值得盯,因为它才是真正影响下游质量的东西,而它在无约束时代是完全不可观测的。
四、约束解码与其它推理优化的交互
真实的生产环境里没有"只用约束解码"的引擎。大家都在同时用批处理、前缀缓存、投机解码这些优化,而它们和约束解码之间存在真实的耦合和冲突。这一节讲这些交互,因为踩坑的人通常是在压测阶段才发现的。
4.1 与连续批处理的耦合
连续批处理的前提是"同一个 step 里的所有请求形状可以不一样"。约束解码加入之后,多了一层约束:同一个 step 里的请求可能有完全不同的约束。实现上有两种选择:
- 按约束分组——把约束相同的请求放进同一个 batch,掩码算一次复用给整个 batch。代价是请求要在队列里等待同类伙伴,尾延迟变差;好处是掩码开销被摊薄。
- 逐请求掩码——每个请求带自己的掩码进 batch,实现上要求掩码能表达成批形状的张量(这就是第 2.2 节说的"稀疏张量喂进 kernel")。这是主流引擎的选择,因为它不牺牲批次的灵活性。
选择哪一种,取决于你的约束多样性:如果全公司只有一个固定 schema,第一种(甚至更激进地做 schema 级批处理)收益极大;如果每个用户的 schema 都不一样,第二种是唯一选择。这直接解释了为什么"约束解码在低并发时几乎不损失吞吐、在高并发时吃 CPU"——因为高并发下恰好是请求约束最分散的时候,缓存命中率最低。
4.2 与前缀缓存的耦合
前缀缓存要求多个请求共享完全相同的前缀 KV。约束解码不改变 prefill 阶段(约束只作用于生成阶段),所以前缀缓存的命中率理论上不受影响。但有一个间接影响:约束会让输出长度更可预测——因为语法决定了必填字段的长度区间。原本一个请求可能输出 50 个 token,也可能输出 500 个,现在它会稳定在某个范围内。这会让请求生命周期更整齐,间接减少批次的碎片化,对整体吞吐是正向影响。
但有一个坑要小心:如果 schema 每次请求都不一样,就不要指望前缀缓存。很多人把动态内容写进了 system prompt(比如"当前用户是 X,当前日期是 Y"),这本身就会破坏缓存,这与约束无关,但如果你在排查"为什么开了约束之后缓存命中率掉了",别把原因归错。
4.3 与投机解码的耦合(最容易出问题的一处)
投机解码用一个小模型快速提出若干候选 token,再由大模型批量验证。问题在于:draft 模型通常不知道约束的存在。它按自己的偏好提出 k 个 token,其中可能有不合法的那几个。验证阶段必须对每个位置应用对应的掩码,验证通过的最大长度取决于第一个出现非法 token 的位置。
这会导致一个反直觉的性能塌陷:如果 draft 模型对约束格式不敏感,它提出的 k 个 token 里平均在第 2 个就非法了,那么接受长度就永远是 1,投机解码退化成"每步只省一次 forward",而你还要额外付出 draft 和验证的两次开销——净亏损。
三种应对方式,按推荐程度排序:
- 让 draft 模型也带约束(同一个 grammar 传给 draft)。接受率最高,实现上要求 draft 和 target 用同一套约束引擎。
- 约束只在 target 验证时应用(主流做法)。接受率会掉,但因为 draft 通常很轻,总吞吐可能仍是正的。上线前必须实测接受长度分布,看 accepted_len 是否显著小于 k。
- 在长结构化输出上干脆关掉投机解码。当输出被约束到几乎确定时,draft 帮不上忙;但当 schema 很宽松(比如字段是自由文本)时,投机解码收益仍然存在。判断依据是 schema 的紧致程度,不是输出总长度。
4.4 与小模型的配合
一个容易被忽略的事实是:模型越弱,约束解码的收益越大。7B 模型的格式遵循率往往明显低于 70B 模型,在这种基线上,你用提示词能拿到的收益极小,而约束解码能把格式成功率从"看运气"直接拉到接近 100%。换句话说,约束解码让"用小模型"这个决策重新变得可行——如果没有约束,你会因为格式不稳定而被迫用大模型。
但小模型在工程上有自己的限制:实现层面的 guided decoding 支持往往先在大参数模型上落地;量化后的小模型在极窄的候选集合里偶尔会出现分布退化(尤其当 clipped_mass 很高时)。所以如果你的架构是小模型 + 约束解码,一定要监控那个"被剪掉的概率质量"——量化模型上这个值往往比预期高。
五、结构化编辑与补丁格式
前面讨论的都是"从零生成一个结构化对象"。但 2026 年约束解码最有价值的另一个战场,是结构化编辑——让模型以补丁(patch)的形式修改已有内容,而不是重写整个文件或整个对象。这个方向值得单独讲,因为它的收益比 JSON 生成大得多。
5.1 为什么编辑格式特别需要约束
考虑一个再常见不过的任务:让模型修一个文件里的 bug。如果让模型输出完整的修改后文件,会有三个问题——长(贵且慢)、容易破坏没打算改的地方(编辑精度差)、无法验证(你只能靠 diff 去猜它改了什么)。
所以更早的做法就是让模型输出一个结构化补丁。补丁格式的语法极其简单,通常就是"若干个搜索-替换对":
// search/replace 编辑格式:模型只需输出变更部分
<<<<<<< SEARCH
def calculate_total(items):
return sum(i.price for i in items)
=======
def calculate_total(items):
return sum(i.price * i.quantity for i in items)
>>>>>>> REPLACE
这个格式有三个特别好的性质,使得约束解码在这里的价值远大于 JSON 场景:
- 语法极简——只有 SEARCH、REPLACE 两个分隔标记,约束写起来比 JSON Schema 还简单,一个十几行的正则就够了,不需要编译任何东西。
- 可验证——搜索段必须逐字出现在原文中,这是可以在应用补丁之前精确校验的:如果搜索段不匹配,说明模型幻觉了一段不存在的代码,此时可以立刻失败或让模型重试,而不需要等用户去发现。
- 可数——输出长度与改动量成正比而不是与文件大小成正比,这对成本的改善是数量级的。
约束它的方式甚至比 JSON 更简单:整个格式只需要两个分隔标记,约束规则可以概括成"先输出 SEARCH 标记,再输出任意行,直到等号标记行;再输出任意行,直到 REPLACE 标记行"。用正则表达式写就是:标记行是字面量,标记之间的内容是"除换行外的任意字符"的重复。这条规则的正则复杂度几乎为零,编译开销可以忽略——这是编辑类任务最适合约束解码的原因之一。
注意这里体现的一个通用模式:语法约束负责"补丁长什么样",应用前校验负责"补丁打不打得上"。这两件事必须分开,因为语法约束在原理上无法表达"这段文本必须出现在文件里"——它需要看外部状态。
5.2 更激进的格式:受约束的 diff
如果直接约束标准 unified diff 格式,收益更大但实现更难,原因是它有一套复杂的行前缀语法(context、added、removed、hunk header,还有行号字段),而且要求 hunk 之间严格有序。这正是"上下文无关文法"能发挥价值的地方:把 hunk 语法写成产生式,掩码在生成过程中逐步收紧——一旦一个 hunk 结束,后续 hunk 的行号必须更大,这个条件可以在语法状态里表达。
不同工具的做法差异明显,值得知道各自的特点:
| 格式风格 | 代表做法 | 优点 | 难点 |
|---|---|---|---|
| 搜索-替换对 | 早期的文件编辑补丁 | 语法极简,约束成本低 | 上下文重复时无法唯一定位 |
| 受约束的 unified diff | 以 hunk 为单位的结构化 diff | 表达力强,天然可计算改动量 | 行号字段要严格约束,实现复杂 |
| 行级编辑指令 | 基于行号的新增/删除/替换指令 | 与结构化编辑模型(插入/删除/替换)天然契合 | 行号会随前面的编辑漂移,必须维护 |
| 锚点定位 | 用锚点字符串而非行号定位 | 对前面的编辑鲁棒 | 锚点要唯一,校验成本上升 |
选型上的一个实用判断:如果你的编辑目标经常在文件里多次出现(比如很多行长得一样),行号类方案必须额外维护偏移,锚点方案必须额外做唯一性校验——这两个额外成本都要算进去,而不是只对比格式本身的简洁度。
5.3 结构化编辑里的三重校验
编辑场景比生成场景需要更严格的校验,因为错误的补丁会破坏代码。三重校验是:格式(约束解码保证)、可应用性(搜索段必须命中、替换段必须唯一或明确标注意图)、语义(改完之后代码能不能跑、测试过不过)。前两重是结构性的、便宜的,第三重才需要真正执行。
这里有一个只有编辑场景才成立的判断:可应用性校验的失败率,比格式失败率更能说明问题。因为在约束解码之后,格式失败率应该已经接近零了;一旦你看到可应用性校验频繁失败,说明模型对代码的理解有问题(幻觉了一个不存在的片段),这时候加大重试次数是没用的,应该回到模型选择或上下文构造上。
六、把 JSON Schema 编译成解码约束
绝大多数团队的 schema 来源不是手写的 GBNF,而是已有的 JSON Schema 文档——可能来自 OpenAPI 定义、可能来自数据库结构、可能就是一个接口文档。这一节讲怎么把 Schema 编译成解码器能吃的语法,以及编译过程中会遇到哪些真实的坑。
6.1 编译到底在做什么
编译过程用一句话概括就是:把一个声明式的类型约束,翻译成一组上下文无关的产生式规则。整个流程是:
- 预处理——解析 Schema,处理 $ref、内联 $defs、展开嵌套引用。这一步决定了后面规则会不会无限递归。
- 遍历节点 → 生成规则——对每个节点,判断它是什么类型(对象/数组/字符串/数字/布尔/联合),为它生成一条语法规则,名字用路径的规范化形式,比如
root.properties.user.properties.age。跨节点引用的类型(比如"这个字段和上面那个字段是同一个类型")会生成独立规则而不是重复展开,这样语法更小、更快。 - 组合与去重——把子节点生成的规则挂到父节点上,删掉那些没有被任何规则引用到的孤立规则(deadcode elimination)。这一步对性能影响不小:语法里每多一条规则,编译时间和 DFA 状态数都会线性增长。
- 编译成执行结构——GBNF 是纯文本,最后还要转成 llama.cpp 的 grammar 结构;Outlines 里这一步是转成 DFA 的边表;XGrammar 里是转成一套压缩的字节级跳转表。
6.2 一个最小可用的编译器
下面这个实现省略了缓存、$ref 展开、循环检测等工程细节,但保留了完整的分派逻辑——这也正是编译器的核心难点:每个 Schema 关键字要么"能编译成语法规则",要么"编译不了必须丢弃",这个判断必须逐个关键字明确写出来。生产实现里这些"丢弃"通常会被收集成警告列表返回给调用方。
import re
from typing import Any, Dict, List
# 每种基础类型对应的 GBNF 片段(为可读性省略了空白容忍规则 ws?,
# 真实文法需在分隔符后插入它,否则模型无法在逗号后加空格)
PRIM = {
"boolean": '"true" | "false"',
"string": r'"(\\.|[^"\\])*"',
"integer": r'-?(0|[1-9][0-9]*)',
"number": r'-?(0|[1-9][0-9]*)(\.[0-9]+)?',
"null": '"null"',
}
class SchemaCompiler:
"""JSON Schema -> GBNF。核心是 _body 的关键字分派:
能编译的生成产生式,编不了的记入 discarded,交给上层校验兜底。"""
def __init__(self):
self.rules: List[str] = []
self.discarded: List[str] = []
def compile(self, schema: Dict[str, Any], name: str = "root") -> str:
self.rules.append(f"{name} ::= {self._body(schema, name)}")
return self._finish()
def _emit(self, name: str, body: str) -> str:
self.rules.append(f"{name} ::= {body}")
return name
def _body(self, s: Dict[str, Any], name: str) -> str:
# $ref:单独成规则以便跨节点复用,避免就地展开导致语法成倍膨胀
if "$ref" in s:
return s["$ref"].split("/")[-1]
# const / enum:字面量枚举,性价比最高的约束
if "const" in s:
return lit(s["const"])
if "enum" in s:
return " | ".join(lit(v) for v in s["enum"])
# anyOf / oneOf:编译成选择分支
for key in ("anyOf", "oneOf"):
if key in s:
alts = [self._emit(f"{name}-{key}-{i}",
self._body(sub, f"{name}-{key}-{i}"))
for i, sub in enumerate(s[key])]
return " | ".join(alts)
typ = s.get("type")
if typ == "object":
return self._object(s, name)
if typ == "array":
items = s.get("items", {})
r = self._emit(f"{name}-items", self._body(items, f"{name}-items"))
return f'[{r} ("," {r})*]'
if typ in PRIM:
return PRIM[typ]
# 编不了的关键字:显式记录,不要默默丢掉
for kw in ("pattern", "format", "minLength", "maxLength", "minimum",
"maximum", "multipleOf", "uniqueItems", "patternProperties",
"if", "then", "not", "dependentSchemas"):
if kw in s:
self.discarded.append(f"{name}.{kw}")
return self._any(name)
def _object(self, s: Dict[str, Any], name: str) -> str:
props: Dict[str, Any] = s.get("properties", {})
if not props:
return self._any(name)
required = set(s.get("required", []))
def member(pname: str) -> str:
sub = self._body(props[pname], f"{name}-{pname}")
return f'{lit(pname)} ":" {self._emit(f"{name}-{pname}", sub)}'
# 先必填后可选:JSON 键顺序无语义,强制这个顺序可让语法显著简单
ordered = [p for p in props if p in required] + \
[p for p in props if p not in required]
seq = ""
for i, p in enumerate(ordered):
frag = member(p)
# 第一个之后的所有成员都要以逗号开头;可选成员整体再套一层
frag = f'{", " if i else ""}{frag}'
seq += f' ({frag})?' if p not in required else frag
return '{' + seq + '}' # additionalProperties:false 编不出来,靠上层校验
def _any(self, name: str) -> str:
"""类型未指定时的放行规则:任何 JSON 值都接受。"""
return self._emit(f"{name}-any",
r'"(\\.|[^"\\])*" | -?[0-9]+ | true | false | null '
r'| \{[^{}]*\} | \[[^\[\]]*\]')
def _finish(self) -> str:
"""死代码消除:只保留从 root 可达的规则。"""
names = {r.split("::=")[0].strip() for r in self.rules}
reachable, frontier = set(), [self.rules[0].split("::=")[0].strip()]
while frontier:
n = frontier.pop()
if n in reachable:
continue
reachable.add(n)
body = next(r for r in self.rules
if r.split("::=")[0].strip() == n).split("::=", 1)[1]
for tok in re.findall(r"[A-Za-z][\w\-\.]*", body):
if tok in names and tok not in reachable:
reachable.add(tok)
frontier.append(tok)
return "\n".join(r for r in self.rules
if r.split("::=")[0].strip() in reachable)
def lit(v: Any) -> str:
"""Python 值 -> GBNF 字符串字面量。"""
if isinstance(v, bool):
return '"true"' if v else '"false"'
if v is None:
return '"null"'
return '"' + str(v).replace('\\', '\\\\').replace('"', '\\"') + '"'
这段代码里有几个值得单独讨论的设计决策,它们是从"能跑"走到"能用"的分界线:
(1)enum 与 const 是性价比最高的约束
因为它们直接生成字面量枚举,既能被解码器精确强制,也几乎没有状态爆炸风险。如果你的 schema 里大量字段是枚举(状态码、类别、动作类型),那么约束解码能把这部分做到近乎完美,成本却极低。
(2)$ref 必须跨节点复用,不能就地展开
一个典型反例是:地址 schema 里有 street、city、zip,用户 schema 里也有。如果每个引用都就地展开,生成的 GBNF 会成倍膨胀。正确的做法是给被引用的类型分配一个规范名字,只展开一次,其他地方直接引用名字。这样"地址"类型在语法里只有一份定义,编译时间也降下来了。
(3)递归 schema 是一个真正的难题
考虑 JSON Schema 里的经典递归定义:{"type":"object","properties":{"children":{"type":"array","items":{"$ref":"#"}}}},表示一棵任意深度的树。编译它需要生成规则 node ::= "{" ... "children"... "[" node* "]" ... "}"——GBNF 本身支持这种递归,llama.cpp 能处理。但注意:递归深度在语法层无法限制,一个病态的提示词可能诱导模型进入无限深度的嵌套。你要么在生成后用代码限制嵌套深度(读 JSON 时显式数一下),要么在 schema 编译阶段就把递归展开有限层——这是一个明确的取舍点,我倾向于前者(保留递归,但代码侧设硬上限)。
(4)additionalProperties: false 编不出来
这是最需要如实告诉你的一个限制。GBNF 可以穷举所有允许的键,但无法表达"除了这些键之外不允许有别的键"这种全称否定——你得用正则的负向先行断言或者在生成后校验。llama.cpp 的做法是加一个 (?!\s*[}\]]) 之类的守卫,但严格来说这只是启发式。真正可靠的 additionalProperties: false 语义只能靠上层校验。这一点在后面的三层架构里会再次出现。
6.3 编译期最该盯的三个数字
实现完成不等于能用。上线前至少测这三个量,它们决定了运行时会不会"编译一次、上传半小时":
- 编译耗时——schema 到 GBNF 的转换时间。深层嵌套或大 anyOf 会显著拉高它。如果编译慢到秒级,就应该做缓存(schema 字符串的哈希 → 语法文本)。
- 语法规则数与规则总长度——这两个直接决定 DFA 状态数和缓存冷启动时延。一个上千条规则的语法基本意味着首请求会有几百毫秒的冷启动成本。
- 掩码构造的 P99——不是平均值。约束解码的耗时分布通常极度长尾:常见状态走缓存,微秒级;新状态要遍历整个语法,可能几毫秒。压测时只看均值会严重低估问题。
不同引擎的 Schema 编译路径对比
同一家 XGrammar 提供了不同引擎路径,行为差异值得注意。下面这张表是我在实际测试中观察到的定性对比(版本差异会导致细节变化,这里只讲稳定的行为模式):
| 路径 | 约束生效时机 | Schema 支持度 | 多轮/流式 | 适用场景 |
|---|---|---|---|---|
| vLLM(后端可切换) | 引擎内,每 token 掩码 | 中上,取决于后端 | 支持 | 通用生产服务 |
| SGLang | 引擎内,每 token 掩码 | 较高 | 支持 | 高并发、需要结构化输出的服务 |
| llama.cpp(GBNF) | 采样层,逐 token | 取决于文法表达力 | 需特殊处理 | 边缘部署、CPU/GPU 推理 |
| Outlines | DFA 查表,编译一次 | 中高,类型注解友好 | 需包装 | Python 侧嵌入式、结构固定 |
| XGrammar | 字节级掩码,编译一次 | 高,覆盖关键字最多 | 支持 | 想要兼顾覆盖率与速度 |
| guidance-ai | token 级约束 + 反向解析 | 高(正则/选择/捕获) | 流式友好 | 复杂控制流、流式 |
| JSONFormer / lm-format-enforcer | 本地约束(token 级 / 树路径) | 视实现 | 有限 | 快速修补现有调用 |
关于这些项目的一条经验:不要按"谁支持的关键字多"来选,要按"你的 schema 长什么样、你的流量形态是什么"来选。如果你的 schema 就是简单的对象加几个枚举,任何一个方案都够用;如果你有深层递归和大 anyOf,那么"编译一次 + 死代码消除 + 跨节点复用"这三点做得好不好,比支持的关键字数量更重要,因为那些丢掉的关键字本来就只能靠上层校验。
到这里,"输出一定符合语法"这件事已经彻底解决了。但如果你的场景里还有"输出必须符合业务约束"——比如"当 status 是 rejected 时必须有 rejected_reason"——那还是得回到采样之后。文章后半部分讲的就是这条补救路径,以及如何用它兜住前面所有的漏网之鱼。
七、采样后修正:parse-repair、结构化校验与有限重试
即便有逐 token 约束,也别指望 100% 正确。原因有三类:
- 约束编不出来的语义约束——上一节说过
additionalProperties: false、dependentSchemas、pattern 这些关键字 GBNF 基本表达不了,模型就可能输出违反它们的内容。 - max_tokens 截断——如果 schema 允许一个长数组而你给了很小的 max_tokens,输出会在半途被砍断。这是一个非常容易被忽视的失败源:模型跑得好好的,是你的预算不够。
- 约束本身写错了——schema 和约束不同步(改了 schema 忘了重新生成语法),或语法里有 bug。这类问题编译期测不出来,只能靠端到端测试。
所以第三层"采样后修正"不是可有可无的补丁,它是兜底,是把"能解析"从 99.5% 拉到 100% 的那一层。
7.1 parse-repair:修复语法破损
最容易想到的手段是:把模型输出丢给一个容错 JSON 解析器,比如 json_repair(Python)或者 jsonrepair(JS)。它的做法分几步:
- 截取——找到第一个
{或[,砍掉它前面的一切(这就是"好的,这是您要的 JSON:"这种污染的标准解法);对称地,从最后一个闭合符开始砍掉后面的一切。 - 补全闭合符——按栈顺序补上缺失的
}/]。 - 修标点——去掉尾随逗号,把单引号/中文引号换成双引号,把 Python 风格的 True/False/None 换成 JSON 字面量。
- 补转义——字符串里的裸换行、未转义的引号、反斜杠,统统补上转义。
- 移除注释与多余文本——有些实现还会剥掉
//和/* */注释。
这套修复能救回相当高比例的破损输出,而且代价极低(纯 CPU、微秒到毫秒级)。但你必须清楚它的两个局限:
局限一:它可能改变语义。修好一个破损的 JSON 和"修对"是两件事。比如模型输出了 {"a": "he said "hi" to me"},修复器要么把内层引号转义(得到 a = 'he said "hi" to me',碰巧对了),要么砍掉后面的内容(得到 a = 'he said ',静默地错了)。静默错误比显式失败危险得多——所以修复后必须校验,而不是"修复即信任"。
局限二:它救不了截断的语义内容。如果输出因为 max_tokens 被砍在一个数组中间,修复器会给你一个看起来合法但数组短了的对象。它语法上是对的,业务上错的。这一点只能靠"检查必填字段和数组长度"来发现,也就是下面这层的职责。
7.2 结构化校验:Schema 之外的语义检查
校验分成两层,都要写。
第一层:JSON Schema 校验。这一步验证结构、类型、必填字段、枚举取值、数值范围。用 jsonschema(Python)、ajv(JS)或 zod(TS)都行。关键点在于:校验器必须用和约束编译同一份 schema。如果你手写了一份 GBNF 又维护了一份 Pydantic 模型,这两者必然会漂移,而漂移的方向通常是"校验比约束更严"——这没问题,说明约束保守;危险的方向是反过来。
注意 jsonschema 默认不做 format 校验(日期、邮箱、UUID),要显式打开 FormatChecker。另外 type: integer 在 JSON 里对 1.0 是通过的(它数值上是整数),如果你需要严格区分,得自己加检查。
第二层:业务语义校验。这一层 schema 表达不了,必须手写。它才是真正挡在业务系统前面的那道门:
- 跨字段一致性:
end_date > start_date、total == a + b。 - 属性依赖:
status == "rejected"时必须有 reason。 - 引用完整性:生成的 user_id 在数据库里真的存在吗?
- 业务枚举的时序合理性:状态机不能从 draft 直接跳到 shipped。
把校验结果组织成给模型看的错误信息,是这一层最有价值的设计。很多团队写完校验就直接抛出异常,白白浪费了下一次重试的机会——正确的做法是收集所有校验错误,取前若干条,每条写成"字段路径 + 失败原因"的形式,附加到下一轮的上下文里。比如金额不一致时,回灌的应该是"/total: 不等于各项之和"这样一条明确的信息,而不是一句"输出不合法"。
注意错误信息里带上 JSON Pointer(/reason、/end_date)这个小细节:模型对"哪个字段错了"的敏感度远高于"整体重写",只回灌错误路径而不回灌整个失败输出,通常能让重试的成功率明显提升,同时省下 prefill 的 token 成本。
7.3 有限重试:它是架构,不是循环
"重试到成功为止"是一个陷阱。它在生产环境里的失败模式非常明确:下游某个上游服务持续返回一个模型修不好的错误,你的服务就在那里无限重试,把 token 预算烧光、把延迟推到 SLA 之外。所以重试必须有硬边界,而且这个边界应该由调用方显式指定,而不是库内部偷偷决定。
三个必须显式设定的东西:
- 最大尝试次数——包含第一次。超过就抛错,让调用方决定。
- 总 token 预算——比次数更重要的硬约束。次数多但每次都生成长文本,照样能烧爆预算。
- 错误反馈的形态——重试时是否包含上一次的失败输出?包含则模型有对照(但 prompt 变长),不包含则 prompt 短(但模型不知道错在哪)。实践上,包含错误摘要 + 不包含完整失败输出 是一个很好的折中。
import json
import logging
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class GenPolicy:
max_attempts: int = 3
token_budget: int = 4000 # 跨所有尝试累计
backoff_base_s: float = 0.5 # 指数退避,缓解服务端瞬时过载
include_prev_error: bool = True
@dataclass
class Attempt:
ok: bool
obj: object = None
error: str = ""
used_tokens: int = 0
def generate_valid(generate_fn, schema, policy: GenPolicy, ctx: str = "") -> object:
"""
generate_fn(prompt: str, max_tokens: int) -> (raw_text: str, tokens_used: int)
抛出的所有异常都视为失败,进入下一轮重试。
"""
prev_error = ""
spent = 0
for attempt in range(1, policy.max_attempts + 1):
remaining = policy.token_budget - spent
if remaining <= 0:
raise RuntimeError(f"token budget exhausted after {attempt-1} attempts")
prompt = build_prompt(schema, ctx, prev_error if policy.include_prev_error else "")
try:
raw, used = generate_fn(prompt, max_tokens=min(1024, remaining))
spent += used
ok, obj, err = parse_with_repair(raw)
if ok:
ok2, err2 = validate_and_describe(obj, schema)
if ok2:
logger.info("valid on attempt %d, %d tokens", attempt, spent)
return obj
err = err2 # 结构合法但语义不合法
prev_error = err
logger.warning("attempt %d failed: %s", attempt, err[:200])
except Exception as e: # 网络、限流、解析异常都归到这里
prev_error = f"generation error: {e}"
logger.warning("attempt %d raised: %s", attempt, e)
if attempt < policy.max_attempts:
sleep(policy.backoff_base_s * (2 ** (attempt - 1)))
raise ValueError(f"failed after {policy.max_attempts} attempts, {spent} tokens: {prev_error[:300]}")
这里有两个容易被忽略的细节:重试的延迟是累加的(3 次重试加退避,在慢响应下很容易超过 10 秒),所以如果你对端到端延迟有 SLA,重试次数应该按延迟预算反推,而不是拍脑袋定 3;重试会放大下游负载,一个成功率 80% 的接口配 3 次重试,有效成功率到 99.2%,但下游收到的请求量增加了 25%——这个成本要提前和下游对齐。
经验法则:如果一次生成的成功率高于 95%,重试几乎总是划算的;如果低于 60%,三次重试也救不回来,更该做的是换约束、换提示词,或者直接把错误暴露给用户看——一个诚实的错误比一份看起来对、其实错的数据有用得多。
7.4 parse-repair 的一个"副作用"要小心
最后提醒一个真实踩过的坑:修复器会让你的错误率曲线变得难看。因为破损输出被修复后"成功了",你的监控上看 parse 失败率很低,但业务校验的失败率却上升了——那些被修复的输出里,语法对了、语义错了。如果只监控第一层,你会以为一切正常,实际上下游正在收到被静默篡改的数据。
所以埋点要分层:解析失败率(第一次就解析成功的比例)、修复率(需要 repair 的比例)、语义校验失败率——三个都要看。修复率突然升高,往往意味着约束层出问题了(比如 max_tokens 被调小了),这是一个很好的早期预警信号。
写到这里,单点技术基本讲完了。剩下的核心问题是:怎么把它们组织成一个可以上生产的架构——这是下一节的内容。
八、工具调用场景的特殊之处
工具调用(function calling / tool use)是约束解码最典型的落地场景,但它的约束形状和普通 JSON 生成有三点重要区别,值得单独讲。
8.1 外层是枚举,内层才是 schema
一次工具调用的输出形状大致是:外层一个对象,里面有工具名字段和参数字段,参数字段的值又是另一个结构。要把这样一层套一层、而且每一层的取值集合都不同的结构写成语法,规则会长成什么样是可想见的。
这意味着约束必须按工具名分派:先在 tool 上加一个枚举约束,当确定了是 search_docs 之后,arguments 的约束要切换成这个工具的参数 schema。这不是简单的"一个大语法覆盖所有工具",而是一个随前缀动态收窄的语法——好在逐 token 框架天然支持:等 "tool": "search_ 这几个 token 出来之后,掩码就可以只保留 docs 了。
这个特性比看上去更有价值:它让工具选择错误在解码期就被消除了。不用约束的话,模型有概率调用一个名字你早就下线的工具,或者把 search_docs 拼成 searchDoc;有了枚举约束,这些输出根本不存在。
8.2 多工具并行调用需要更严的约束
如果你的场景支持一次返回多个调用(tool_calls 数组),语法必须表达"数组里每个元素的 tool 字段各自受约束"。做法是把"名字 + 对应参数"整体展开成一个分支,而不是先选名字再选参数。
把"名字 + 对应参数"绑定成一个整体分支是对的做法,但代价是工具数量翻倍会导致语法规模线性膨胀——10 个工具就是 10 个分支,每个分支带着完整的参数 schema,编译时间和状态表大小都要重新算。工具超过二三十个时,这一步约束的性价比开始下降,更好的策略是改成"两阶段":第一阶段只约束工具名(一个小枚举语法,成本极低),第二阶段在确定工具后再对参数做约束(甚至用第二次独立请求)。
8.3 服务端 API 层已经帮你做了多少
到 2026 年,主流模型服务商基本都提供了服务端的结构化输出能力,你不需要自己接引擎就能拿到一部分约束解码的收益。实际情况是各家覆盖度差别很大,值得知道的是这个分层:
- JSON mode(最弱):只保证输出是一个 JSON 值,不保证符合任何 schema。通常实现方式是"找到一个
{之后开始约束、遇到平衡的}就结束",甚至只是改一下采样逻辑。它解决的是包裹污染和语法破损,解决不了模式漂移。 - Structured outputs / json_schema(较强):接受一份 schema 并在服务端做语法级约束。这是 OpenAI structured outputs 引入后的行业标准做法,也是最值得用的档位。它的已知限制通常是:只支持 schema 的一个子集(例如不支持 minLength/maximum/pattern 这类无法编译成语法的关键字,也不支持
additionalProperties: false的严格版本),需要提前在文档里确认。 - 严格函数调用:在 structured outputs 之上加一层工具名与参数 schema 的绑定,本质上就是 8.1 节描述的那个联动约束。
所以一个务实的选型顺序是:先用服务端能力(成本最低、运维最省),确认它不满足需求的部分(比如你需要的某个 schema 关键字它编不出来,或者你需要流式)再考虑自建。自建意味着你要承担引擎版本绑定、缓存预热、失败模式排查的长期成本,这不是小事。
8.4 一个容易被忽略的安全边界
约束解码在工具调用里还有一个容易被忽略但很重要的作用:它把"参数注入"的一部分风险消掉了。考虑一个恶意输入诱导模型调用 delete_user 的场景——枚举约束可以让 tool 永远只能取你给的值,但它挡不住模型在你的合法工具里填一个恶意的 path 参数。语法合法 ≠ 语义安全。
所以工具调用的最后一道闸永远是:参数白名单校验 + 权限检查 + 高危操作的人工确认或幂等键。约束解码让你从"需要解析每一个返回值"降级为"只需要校验语义",这是一个巨大的收益——但不要把"降级"误解成"可以不做"。
九、三层兜底的组合实践
现在把三层拼起来。这是全文最实用的部分,我给一个可以直接抄的骨架。
9.1 决策表:什么时候用哪一层
| 场景 | 第1层 约束 | 第2层 校验+重试 | 第3层 降级解析 | 理由 |
|---|---|---|---|---|
| 工具/函数调用 | 必须开 | 必须开(语义安全) | 只记录不返回 | 降级的参数可能造成危险副作用,绝不能静默执行 |
| SQL 生成 | 必须开(表名列名枚举 + 解析 AST) | 必须开(EXPLAIN 校验、只读检查) | 仅用于人工排查 | 错误的 SQL 有破坏性,且语法合法的危险 SQL 很多 |
| 结构化信息抽取 | 建议开 | 必须开 | 可返回(标 confidence,低置信度转人工) | 降级输出对业务无害,只需要标记可信度 |
| 流式 UI 渲染 | 难开(见 2.5) | 困难(输出未完成) | 主要靠它 | 流式与约束天然冲突,只能做前缀级的容错渲染 |
| JSON 模式的对话 | 可开可不开 | 可选 | 必须开 | 失败代价低,一个宽松解析器就够了 |
| 离线批量任务 | 必须开 | 必须开 | 可关(改为失败重跑) | 没有延迟压力,可以放心重试到成功 |
这张表的实际含义是:第 3 层不是"总是开启",而是由失败代价决定的。低代价场景开,高代价场景宁可失败也不要降级。这个判断比任何技术细节都重要。
9.2 完整骨架
import json, logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class StructuredResult:
value: object # 解析后的值
confidence: float # 0.0-1.0
layer_used: int # 1=约束直接通过 2=校验修复 3=降级
errors: list # 收集到的所有问题
degraded: bool # 调用方据此决定是否信任
def generate_structured(generate_fn, schema, constraints, *, max_attempts=2,
token_budget=4000, allow_degrade=True) -> StructuredResult:
"""
三层组合:约束解码 → 校验重试 → 降级解析
generate_fn(prompt, max_tokens, gbnf_grammar) -> (raw_text, tokens)
"""
gbnf = constraints.compile(schema) # 第0步:schema → 语法(应带缓存)
errors = []
for attempt in range(1, max_attempts + 1):
remaining = token_budget
if remaining <= 0:
break
# ---------- 第1层:约束解码 ----------
raw, used = generate_fn(
build_prompt(schema, ctx=errors if attempt > 1 else ""),
max_tokens=min(1024, remaining),
gbnf_grammar=gbnf, # ← 约束在这里生效
)
# ---------- 第2层:解析 + 修复 + 校验 ----------
ok, obj, err = parse_with_repair(raw)
if ok:
valid, err = validate_and_describe(obj, schema)
if valid:
return StructuredResult(obj, 1.0, 1, errors, False)
# 结构对但语义错 → 记录错误,进入重试
errors.append(err)
# ---------- 第3层:降级解析 ----------
if not allow_degrade:
raise ValueError(f"all {max_attempts} attempts failed: {errors}")
best = salvage(raw) # 从最后一次输出里榨出可用字段
if best is None:
raise ValueError(f"degraded parse also failed: {errors}")
return StructuredResult(best, 0.4, 3, errors, True)
def salvage(raw: str):
"""
降级解析:用宽松的提取器把能拿的字段拿出来。
例:正则抓 key/value 对,容忍括号不闭合。
永远要标 degraded=True,让上游决定是否信任。
"""
pairs = re.findall(r'"(\w+)"\s*:\s*"([^"]*)"', raw) # 先抓字符串字段
nums = re.findall(r'"(\w+)"\s*:\s*(-?[\d.]+)', raw) # 再抓数字字段
if not pairs and not nums:
return None
out = {}
for k, v in pairs:
out[k] = v
for k, v in nums:
try:
out[k] = float(v) if "." in v else int(v)
except ValueError:
pass
return out
几个设计要点值得单独强调,因为它们决定了这段代码在生产上是否可靠:
constraints.compile(schema)要在循环外——语法只编译一次,所有重试共享。这是把重试成本压下来的关键。- 约束 + 校验是互补而非重复的:约束保证"结构一定对",校验保证"结构对且内容合理"。只有前者,模型可能一直稳定输出一个缺必填字段的合法 JSON;只有后者,你要在每次失败上浪费一次完整生成。
- 降级结果必须带标记——
degraded=True和confidence=0.4是契约的一部分。如果调用方可以无视这个标记去用降级数据,那第三层就是有害的:它用一份"看起来能用"的数据掩盖了一次真实失败。 - 降级解析要保守——只提取确定能拿的字段(正则能匹配的 key-value),不要试图"猜"缺失的值。salvage 返回的对象永远不是原意的完整还原,它是一个 best-effort 的近似。
9.3 约束缓存:一个必做的小优化
如果你的 schema 是固定的(很常见,比如一个固定的工具调用协议),compile(schema) 的结果可以直接缓存起来。生产上更常见的是 schema 只有少数几个变体(比如按用户角色分三种),那就按 schema 哈希缓存,用 LRU 淘汰。缓存的收益是复合的:不仅省下编译时间,还让引擎侧的掩码状态缓存保持热(避免每次请求都走冷路径)。
一个实践中的教训:schema 里如果塞了动态内容(比如把用户 ID 写进 const,做 per-user 的字面量约束),缓存会 100% 命中率归零,编译开销全落到每个请求上。所以约束里的动态部分要尽量用"生成期注入"而不是"schema 期特化"——后者看起来很优雅,实际上是在每请求重编译。
十、时延代价:把这笔账算清楚
所有技术决策最后都要落到一个数字上:每层分别让 P99 延迟增加了多少。下面是一个典型结构的估算方法(具体数值强烈依赖于你的硬件、批量、模型规模,必须自己实测)。
10.1 成本分解
| 成本项 | 发生时机 | 量级参考 | 能否优化 |
|---|---|---|---|
| Schema → GBNF 编译 | 每次请求(或缓存未命中) | 小 schema 亚毫秒级;大 schema 几十毫秒 | 可以且应该缓存 |
| 掩码状态首次构造 | 每个新语法状态,冷启动 | 微秒到毫秒,长尾 | 缓存;看 P99 而非均值 |
| 每步掩码应用 | 每个 decode step | CPU 侧微秒级,GPU 侧加法 | 用 GPU 张量避免 CPU 循环 |
| 额外 prefill | 重试时 | 与错误回灌长度成正比 | 只回灌错误摘要 |
| 重试整体 | 失败时 | 倍数级(1 次 = N 倍) | 按延迟预算反推次数 |
| 校验(parse + schema + 语义) | 每次生成后 | 亚毫秒到几毫秒 | 通常不是瓶颈 |
| 降级解析 | 仅失败路径 | 微秒级 | 可忽略 |
真正会改变量级的是重试和mask 应用这两项,其余都是小数点后的噪声。这个认知很重要,因为它决定了优化优先级:与其花力气把校验从 2ms 优化到 0.5ms,不如把重试成功率从 80% 提到 95%——后者直接把 P99 从"两倍延迟"拉回"一倍"。
10.2 重试的延迟放大效应
设单次生成延迟为 T,成功率 p,最大尝试次数 N,退避总开销 B。那么:
# 期望尝试次数(直到成功或耗尽)
E[attempts] = (1 - p^N) / (1 - p) # p < 1
# 期望端到端延迟(含退避,不含限流等待)
E[latency] ≈ E[attempts] * T + B
# P99 延迟:取决于尾部,通常由最坏路径决定
# P99 ≈ N * T + B(当 p^N 不够高时)
举一个具体的数:T = 800ms、p = 0.92、N = 3。期望尝试次数 ≈ 2.62,期望延迟 ≈ 2.1 秒——而单次成功时只有 0.8 秒。也就是说,为了把失败率从 8% 压到 0.05%,你付出了 2.6 倍的平均延迟作为代价。
这个 2.6 倍是重试策略的固有成本,躲不掉。唯一的优化方向是提高 p(更好的约束、更长的 max_tokens、更清晰的提示词),而不是加大 N。把 N 从 3 提到 5,期望尝试次数只从 2.62 变成 2.75(几乎没变,因为失败很快就会收敛到"永远失败"),但 P99 延迟却涨了 67%。
给重试定预算的正确顺序是:先算可接受的 P99(比如 3 秒),反推N = (P99 - B) / T,得到N = 2或3;然后想办法把 p 从 0.85 提到 0.95(这通常靠约束和 max_tokens 的修正,能省下整整一次重试);最后才是考虑要不要加大 N。直接拍脑袋定N=5是最常见的错误决策。
10.3 吞吐视角:约束解码到底让引擎慢了多少
延迟是单请求视角,吞吐是多请求视角,两者结论可能不同。约束解码对吞吐的影响主要来自:
- 每步的 CPU 掩码工作——在高并发下,所有请求的掩码工作串行占用同一个(或少数几个)CPU 核心,容易成为瓶颈。这是约束解码最常见的扩展性瓶颈,而且只在压力下才暴露。
- 状态缓存的命中率——如果你的流量里 schema 很分散,缓存会被不断冲刷,命中率低,成本上升。这是约束解码唯一可能带来"流量越大越慢"现象的环节。
- KV cache 的复用——这是常被忽略的正面影响:强制结构化输出让输出更短更可预测,从而让请求之间的 KV 复用率更高,某些情况下吞吐反而上升。
压测时如果只看 QPS 曲线会得出误导性结论,建议分三档测量:低并发(验证 CPU 掩码开销)、中等并发(验证缓存命中率)、高并发(验证扩展性)。实践中常见的情况是:低并发时约束解码几乎没有吞吐损失(开销被 GPU 计算淹没),高并发时 CPU 侧开始吃紧——如果这时候你需要更多并发,第一选择是把掩码逻辑挪到 GPU 或做状态共享,而不是加卡。
10.4 流式场景的特殊账
流式约束解码的时延画像完全不同。TTFT(首 token 延迟)在流式下比非流式更慢,因为约束需要先算一遍初始状态的掩码;TPOT(每输出 token 延迟)则可能变快,因为 token 之间有网络和渲染的间隔,掩码的 CPU 开销被隐藏在间隔里。
所以对流式场景,判断标准应该是"用户感知的流畅度"而不是 TTFT:只要 TPOT 稳定在几十毫秒以内,用户就不会感知到约束的存在。这意味着流式下可以容忍更重的掩码实现(CPU 侧),而非流式下则必须上 GPU 张量。这是一个反直觉但很实用的结论。
十一、常见坑位清单
把这些坑单独列出来,因为它们大多不会报错,只会"安静地不工作":
- 掩码用了错误的状态。按"下一个状态"而非"当前状态"计算允许集合。症状是偶发非法输出,极难复现。修法:在
step()之前取允许集合,step()之后再取。 - tokenizer 跨字符边界。token 的文本形态可能跨越语法边界(一个 token 里同时有关键字的结尾和值的开头)。必须用 token 的完整字符串而不是只看首字符来判定,否则要么漏约束要么误杀。这是 XGrammar 强调"字节级对齐"的原因。
- schema 和约束不同步。改了 schema 忘了更新缓存的语法。修法:把 schema 哈希作为缓存 key 的一部分,并在 schema 变更时显式失效缓存。
- max_tokens 太小。schema 允许长数组但预算不够,输出永远被截断,然后你困惑于"为什么 repair 也修不好"。修法:根据 schema 估算一个下界(数一下最多能有多少个 token),至少给两倍余量。
- repair 之后不校验。这是最危险的一个。修好的 JSON 语法对但语义错,数据静默损坏。修法:repair 之后必须过 schema 校验 + 业务校验。
- 降级结果被当真。调用方无视 degraded 标记。修法:把降级数据放到独立的字段里,或者干脆不返回——让"用降级数据"变成一个显式的、需要调用方主动做的决定。
- 递归 schema 无深度上限。模型(或者恶意输入)诱导出深层嵌套,栈溢出或生成极长输出。修法:解析时设最大深度,超限丢弃。
- 没有分层监控。只监控"最终失败率",看不到 parse 失败、repair 成功、语义失败的分层结构,出问题时无法定位是哪一层坏了。
十二、怎么证明它真的有效:评测方法论
约束解码很容易陷入一个困境:上线之后感觉"好像更稳了",但没人能说出到底稳了多少。原因是这个改进的性质——它消除的是一类低频、偶发、难复现的失败,在没有度量的情况下,成功和没成功的差别会被日常的噪声淹没。这一节给出一套能真正量化的方法。
12.1 三个必须分开的指标
最关键的认知是:"输出对不对"是一个复合命题,必须拆成三层来看,而绝大多数团队只看第一层,导致后面的问题完全隐形。
| 指标 | 定义 | 约束解码的影响 | 为什么必须单独看 |
|---|---|---|---|
| 格式一次通过率 | 不修复直接能解析、且符合 schema 的比例 | 大幅提升,这是主要收益 | 唯一能反映第 1 层健康度的指标 |
| 修复后通过率 | 经 parse-repair 后能通过的比例 | 小幅提升 | 过高说明第 1 层失效,模型在被兜底 |
| 语义有效率 | 通过结构校验后,内容也满足业务约束的比例 | 通常小幅提升(字段更完整) | 这才是业务真正关心的指标 |
这三个指标一起看,才能读出一个完整的故事。比如:格式一次通过率 99%、修复后通过率 99.5%、语义有效率 70%——说明约束层工作正常,业务问题在别处。而如果是格式一次通过率 60%、修复后通过率 99%,那就是一个明确的告警:第 1 层几乎没起作用,你所有的"稳定"都是 repair 买来的,而 repair 买来的稳定里有一部分是静默的语义错误。
12.2 约束覆盖度测试
这是最容易被跳过、但价值最高的一项测试。它的思路很简单:把你 schema 里每一个关键字都写成一个"如果约束生效,模型绝对不可能违反"的测试用例,然后验证它确实不会被违反。
举例来说,你的 schema 里有 "age": {"type": "integer"}。对应的测试用例是:构造一个极强的诱导提示("请在 age 字段输出 25.5 岁"),检查输出。如果约束生效,模型不可能输出小数——它必须输出 26、或者输出字符串、或者干脆放弃这个字段,但绝不会是 25.5。跑了这条测试你才知道约束是不是真的接上了。
同一套思路的其他用例:
- 枚举穷举测试:schema 里有一个 5 值的 enum,构造 5 个分别强烈暗示第 6 个值(不在枚举里)的提示,检查输出是否被限制在 5 个值内。
- 必填测试:schema 有 10 个 required 字段,跑 20 次,检查是否每次 10 个字段都在。
- 递归深度测试:如果 schema 是递归的,用一个诱导深层嵌套的提示,检查输出深度是否受控。
这组测试的真正价值在于:它能在约束失效的那一刻就报警,而不是等到生产环境的解析失败率慢慢爬升时才被发现。把它做成 CI 的一部分,是这套方法里回报最高的一个工程动作。
12.3 反向验证:故意破坏约束看输出变不变
除了"约束生效时应该这样",还要测"约束不生效时应该那样"——这是一条更严格的检验,因为它能发现那些"看起来在工作、其实没在工作"的实现 bug。
做法是:拿一份 schema,故意把某个 enum 改少一个值(比如从 5 个改成 4 个),然后跑同一个诱导提示。
- 如果输出确实变了(原来能出的第 5 个值现在出不来了),说明掩码真的作用在这个位置上。
- 如果输出完全没变,说明约束根本没生效——这是个明确的 bug,而不是"约束比较宽松"。
这个测试能抓到的 bug 恰好是最隐蔽的那一类:掩码用了错误的状态(第二版的 8.1 节提到过这个坑)。这类 bug 的症状是"约束大部分时候工作,偶尔漏一个非法 token",在正常测试里几乎不可能被发现,但反向验证能稳定地把它逼出来。
12.4 回归集与快照
两样东西应该被固化成资产:
真实提示词回归集。从生产采样几百条真实请求(注意脱敏),固化成测试集。它测的是"在你的真实流量上"的效果,而不是在人造用例上的效果。真实流量里的 schema 分布、长度分布、诱导强度分布,往往和人造用例差得很远。
约束快照。把 schema → grammar 的编译结果做快照测试:schema 不变时 grammar 文本必须完全一致,schema 变时 grammar 必须按预期变化。这个测试的价值在于它会在编译逻辑被误改时立刻失败。因为编译器是个几百行的纯函数,最容易被一次"顺手重构"改坏,而这种破坏在功能测试里可能只是让某些边角 schema 悄悄失去了约束——快照能第一时间发现。
12.5 一个必须避免的评测错误
最后提醒一个看起来人畜无害实则有害的做法:用"格式通过率"作为唯一的成功指标去汇报收益。因为这个指标在基线上很低,加约束之后提升巨大,看起来是个压倒性的胜利;但它掩盖了两个事实——第一,你的重试机制可能一直在掩盖真实的格式失败;第二,业务侧的语义有效率可能毫无变化甚至下降(如果约束过紧)。
一个诚实的汇报应该同时给出三个指标,并明确说明语义有效率的提升来自哪里(通常是必填字段完整率的提升),而不是含糊地写"结构化输出质量显著提升"。
十三、生产运维:缓存、隔离、灰度与可观测性
约束解码一旦上线,它就变成了一个需要长期维护的组件。下面这几件事如果不在第一天做,后面会持续付出代价。
13.1 缓存键的设计:一个必须包含 tokenizer 版本
语法文本本身与 tokenizer 无关,但编译产物(DFA 表、掩码索引)与 tokenizer 强相关——因为掩码是按 token 索引计算的。绝大多数引擎的 guided decoding 后端正是按 token 索引查表。所以缓存键至少要包含:schema 内容的哈希、引擎版本、tokenizer 版本。
tokenizer 版本这一条特别值得强调:如果你的模型服务用了一个模型路由,同一个 schema 请求可能被路由到不同模型(不同 tokenizer),那么缓存必须在模型维度上隔离,否则就会出现"A 模型的掩码索引被用在 B 模型上"这种会导致输出彻底错乱的问题。这类 bug 表现为随机的、无法解释的格式错误,极难定位。做法很简单:把模型标识放进缓存键。
13.2 多租户隔离与缓存抖动
如果你的服务是多租户的,每个租户的 schema 各不相同,缓存会遇到一个现实问题:不同租户的请求交替到来,导致约束状态缓存不断被冲刷,命中率极低,掩码构造成本上升。前面讲过这就是"流量越大越慢"的唯一来源。
三种缓解方式,按侵入性从低到高:
- 扩大缓存容量——最简单,通常能解决大部分情况。观察命中率,涨容量直到命中率稳定。
- 按租户分池——大客户单独一套缓存。适合客户数量不多但流量分布集中的场景。
- 按 schema 频次分级——统计哪些 schema 高频,把它们的编译产物做成常驻(永不淘汰),低频的走 LRU。这是最有效的做法,因为缓存收益的分布本来就是极度倾斜的。
13.3 灰度与回滚
约束解码的灰度比普通提示词改动更微妙,因为它同时改变了格式和内容分布。一个合理的灰度设计是:
- 影子模式——线上请求同时跑"旧路径"和"新路径"(新路径只记录结果不返回),对比两个指标:格式成功率、字段完整率、以及下游任务的最终正确率。这一步能发现"格式成功率涨了但下游变差了"这种隐藏问题。
- 小流量切换——切 5% 流量,观察延迟 P99、错误率、以及上面三个质量指标。
- 全量——注意保留一键回退到"提示词 + repair"的开关,因为这条路径总是存在的,回退不需要重新上线。
影子模式这一步很多人会跳过,理由是"浪费一倍算力"。但它是唯一能在不影响用户的前提下发现"约束让内容变差"的手段,而这种问题恰好是生产上最难事后发现的——因为它表现为下游数据质量的缓慢劣化,通常要几周后才有人注意到。
13.4 可观测字段清单
把这些字段做成结构化日志或指标,是让这套系统可维护的基础:
| 字段 | 含义 | 健康区间 | 异常说明什么 |
|---|---|---|---|
| schema_compile_ms | 编译耗时 | 缓存命中时应接近 0 | 偏高说明缓存失效或 schema 过大 |
| grammar_cache_hit_rate | 语法缓存命中率 | 越高越好 | 下降说明流量 schema 变分散 |
| mask_p50 / mask_p99 | 掩码构造成本 | P99 远大于 P50 属正常 | 两者都高说明没有状态缓存 |
| clipped_mass_avg | 被剪掉的平均概率质量 | 越低越好 | 偏高说明约束过紧,输出质量会降 |
| format_first_pass | 格式一次通过率 | 应接近 1 | 下降说明约束层失效 |
| repair_rate | 需要修复的比例 | 越低越好 | 上升是早期预警信号 |
| semantic_pass_rate | 语义有效率 | 取决于业务 | 下降说明内容质量出问题 |
| attempts_histogram | 尝试次数分布 | 大量落在上限说明成功率不足 | 说明该调约束而不是加重试 |
这张表里最值得优先建立的是最后两个和 clipped_mass_avg。前两个直接对应第 1 层和第 2 层的健康度,而 clipped_mass_avg 是唯一能提前发现"约束过紧"的信号。一个只监控解析失败率的系统,恰恰会漏掉约束解码引入的最大风险——因为过紧的约束不会让解析失败,它只会让内容变差。
十四、把语义约束搬进解码期
前面反复提到一个边界:语义约束(跨字段一致性、属性依赖、取值范围、引用完整性)只能靠采样后校验,因为它们需要看上下文甚至外部状态。这个边界在 2026 年正在被突破,值得知道前沿在哪、以及该不该跟。
14.1 反向约束:让正则驱动生成
传统约束解码是"正向"的:给一份语法,然后往前推。而 guidance-ai 这类库展示了另一种思路:用一个正则和一组捕获组作为约束,然后反推出与之匹配的前文应该长什么样。比如你想让模型的回答里包含一个符合某个模式的邮箱,你不是去约束"JSON 里的 email 字段",而是把这个正则本身作为约束,让解码器在每一步判断"继续生成能不能匹配上这个正则"。
这个能力超出了上下文无关文法的表达范围,因为它能做的是基于后缀的前瞻判断——这是 FSM 和 GBNF 都做不到的(它们只看前缀,不看目标)。代价是实现复杂度高得多:每次要判断"当前后缀 + 任何续写是否仍可能匹配正则",这在正则含回溯时是可判定性问题,在一般情形下不可判定,所以实现必须做保守近似。
14.2 状态反馈型约束:把数据库候选注入掩码
第二个方向更贴近实际业务问题:把"这个字段的值必须来自数据库里这张表的当前取值"这件事搬进解码期。做法是——在进入 status 字段前,查一次数据库,把合法的状态值集合注入到该位置的允许 token 集合里。
这个做法看起来非常美,但在工程上有三个硬问题,每一个都足以让人停下:
- 外部延迟进入了 decode 循环。解码本来是纯计算的,现在每生成几个 token 就要等一次数据库。解决办法是预取(在 prefill 阶段或更早查好所有需要的候选),但这就要求你能预知"模型会走到哪个分支"——在有 anyOf 的 schema 里,这并不总是可行。
- 缓存一致性。候选集合在生成期间可能已经变了(有人改了配置)。要么接受这个窗口期,要么引入版本化 token——后者会显著增加复杂度。
- 多轮状态。对话场景下,上一轮约束过的字段,这一轮还约束吗?换 schema 了吗?状态机需要跨轮维护。
所以我的判断是:对"取值范围来自一个固定的小枚举"这类情况,用 schema 的 enum 就够了,不需要状态反馈;状态反馈型约束真正不可替代的场景是"取值空间大到无法穷举但可以从外部系统查",比如"这个用户 ID 必须存在于我们的租户列表里"——而这种情况下,采样后校验往往同样有效(查一次库比对 ID 是否存在,成本远低于把查询搬进 decode 循环)。
14.3 一个务实的取舍
判断要不要上状态反馈型约束,我用三个问题:候选集合是否真的无法穷举(如果是,就不值得做);采样后校验的成本是否不可接受(如果一次查询几毫秒,那完全可接受);约束错误的后果是否严重到不能靠校验兜底(工具调用里的高危参数算,其它大多不算)。
三个问题里有两个回答"否",就别做。这条判断准则的价值在于,它能让你 resist 住"把一切搬进解码期"这个听起来很高级的诱惑——约束解码最实用的形态,始终是那个朴素的、只管语法和字面量的版本,语义和状态留给校验层。
这其实也是整篇文章的一个缩影:技术选型的最优解,往往不是能力最强的那一层,而是"够用且代价最低"的那一层。约束解码之所以在 2026 年成了结构化生成的事实标准,不是因为它最强,而是因为它用最低的成本消除了最大的一类失败。理解了这一点,剩下的都是细节。
十五、为什么不用别的办法
在决定用约束解码之前,一个合格的方案应该先回答"为什么不用更便宜的办法"。这一节把主要替代路径摆开比较,因为它们不是互斥的,各自的适用边界其实非常清楚。
15.1 正则后处理:便宜,但会静默丢数据
最常见的替代方案是:让模型自由输出,然后用正则抽取——从一段文本里把 JSON 抠出来,抠不出来就用容错解析器,实在不行取几个关键字段。它成本极低,不需要任何引擎侧改造,所以很多团队的第一版就是这么做的。
这条路线的致命问题是它必然静默丢数据。正则只能处理你预想到的形态,当模型输出的结构和预期差异较大时,正则会"尽力"匹配出一个看起来合理但内容错误的结果——比如从一个被截断的 JSON 里抠出一个值域不完整的时间字段。而更危险的是:抽取失败和抽取成功但抽错了,在返回值上长得一样(都是一个字典),你没法在下游区分。
所以判断标准很明确:如果缺失或错误一个字段会导致业务损失,就不能用正则后处理;如果只是把一段文本变成几个标签用于人工浏览,正则完全够用。
15.2 少样本强约束:改善的是概率,不是保证
另一个常见做法是用大量示例把模型"教会"格式,比如给 20 个覆盖各种边界的 JSON 示例。这确实能显著改善格式遵循率,它的真实价值主要在两点:一是让模型知道怎么命名和填充(示例比 schema 描述更具体);二是让模型的输出分布和你的预期对齐。
但它做不到的两件事同样重要:保证一定合法(小样本下仍会失败,尤其是遇到没见过的组合)、保证一定完整(少样本很难覆盖"所有必填字段都出现"这个要求,除非示例里恰好都有)。
合理的分工是:少样本负责"内容怎么填",约束负责"结构一定对"。两者不是替代关系。当你看到团队试图用增加示例数量来解决解析失败率问题时,那是一个信号:结构问题被错误地当成了内容问题去解决。
15.3 微调:它解决的不是这个问题
微调确实能让模型更好地遵循格式,学术和工业上都有验证。但把它当作"保证输出合法"的手段,有几个结构性的问题:
- 它没有保证。微调后模型仍然会出错,只是错得更少。要把失败率从几个百分点压到接近零,还需要规则、重试这些工程手段——而这些工程手段本身就能做到接近零,不需要微调。
- 它有维护成本。schema 一改就要重新训练或至少重新做偏好对齐,这和约束解码"改一行 schema 就完事"形成了鲜明对比。
- 它有漂移成本。基础模型升级、指令跟随能力变化、训练数据的时效性,都会让格式遵循率产生不可预测的波动,而且你很难归因。
- 它和约束不冲突但不互补。约束解码保证结构,微调改善内容质量。如果你的瓶颈是结构,就别用微调;如果你的瓶颈是内容质量(比如分类理由写得不好),微调是对的投入。
一个常见的误判是"我们已经有微调了,为什么还要约束"——答案是因为微调解决的是大概率,约束解决的是必然。对于"输出要直接进数据库"的场景,只有"必然"够用。
15.4 方案对比
| 方案 | 结构保证 | 内容质量 | 改动成本 | 失败可见性 | 适合 |
|---|---|---|---|---|---|
| 纯提示词 | 无 | 一般 | 极低 | 差 | 原型、低代价场景 |
| 正则后处理 | 无(且静默) | 一般 | 低 | 差 | 标签提取、人工浏览 |
| 少样本 | 弱(改善概率) | 好 | 低 | 一般 | 与约束搭配使用 |
| 微调 | 弱(仍是概率) | 好 | 高 | 一般 | 内容质量是瓶颈时 |
| 约束解码 | 强(结构性) | 中上(字段更完整) | 中 | 好 | 需要硬保证的场景 |
| 约束 + 校验 + 重试 | 强 + 语义 | 好 | 中高 | 好 | 生产系统的默认选择 |
注意最后一列的措辞:"生产系统的默认选择"是组合,不是单点。约束解码单独用能保证结构,但接不住语义;校验单独用能接住语义,但浪费一次生成。生产上值得做的是把它们叠起来,并且用监控数据决定哪一层值得继续投入。
十六、五个场景逐个拆解
抽象的方法论容易让人误以为所有场景都一样。这一节挑五个典型场景,看同一套三层架构在不同约束下怎么落地,重点看哪里不同。
16.1 发票抽取
schema 高度固定(几个金额字段、日期、发票号),但输入是 OCR 出来的脏文本。这是最适合约束解码的场景,因为 schema 极其确定。
关键判断:金额字段有值域约束(不能是负数、不能超过合理范围),但这类约束语法编不出来——GBNF 可以约束"这是一个数字",无法约束"这个数字小于一百万"。所以校验层必须自己写业务规则:金额 > 0、合计 = 各分项之和、日期在合理区间。而且这些规则是高频失败点,重试时应该把具体错误("total 不等于 sum of items")回灌给模型。
另一个容易被忽略的点:OCR 文本里的数字可能是 O08.00(字母 O 冒充数字 0)。约束解码不会阻止模型把 O08.00 原样抄进一个字符串字段——因为 schema 说它是 string 就合法。这种情况只能靠上游的输入清洗,或者靠校验器拒绝并重试。
16.2 分类 + 理由
要求模型给出分类结果和一段理由。这是约束解码的一个特殊用法:约束用来保证结构,而推理过程本身不受约束。有两种做法:
- 先自由推理再约束输出——用一次生成产生"分析…最终分类是 X",再用约束解码产出结构化结果。质量更好,但要多一次生成或更长的输出。
- 单次生成、混合结构——用一个宽松的语法(key-value 对),把分析文本放进 reasoning 字段,分类放进 label 字段,label 用 enum 约束。成本更低,但需要确保模型先写 reasoning 再写 label——这靠字段顺序约束来实现(在语法里规定 reasoning 必须出现在 label 之前)。
第二种做法里"字段顺序约束"是一个实用技巧,很多人不知道:约束不只是限制取值,还能限制顺序,而顺序对模型的思考过程有实质影响。如果先写结论再写理由,理由往往会变成对结论的合理化;先写理由再写结论,理由就更可能是真实的推理。
16.3 SQL 生成
这是失败代价最高的场景,所以三层全开,而且第 3 层基本关闭(宁可不执行,不要执行一个静默错误的查询)。
约束的分工很有意思:表名和列名用枚举约束(这是最有价值的部分,能消除绝大部分幻觉表名)、关键字序列靠 grammar(SELECT ... FROM ... WHERE 的顺序)、而语义正确性完全交给校验(EXPLAIN 看执行计划、只读检查、结果行数上限)。
有个现实约束要提前想清楚:表名枚举可能非常长。一个有五百张表的库,把所有表名列进 grammar 会让语法膨胀到难以编译的程度。这时应该做两阶段——第一阶段用少量 token 让模型选表(可以先做检索缩小候选到十几个),第二阶段基于选中的表做完整约束。这个"先缩小候选再强约束"的模式在所有候选空间大的场景都适用。
16.4 配置文件与工作流生成
生成 YAML、工作流定义这类东西时,约束解码的价值有一个特殊来源:缩进和标点。YAML 的缩进层级是格式正确性的核心,而它极其容易出错——尤其是模型在嵌套结构深处时。让约束来保证缩进,比让模型"注意缩进"可靠得多。
另一个要点:这类输出通常会被人类阅读和编辑,所以除了合法性之外还要考虑可读性。约束解码在这里可能适得其反——过紧的约束(比如强制固定的键顺序、禁止注释、禁止行内空格)会让生成的配置变得又长又难读。所以约束的粒度要按"人会不会手改"来定:如果人会手改,就不要约束格式之外的表达自由度(键顺序、是否允许额外键),否则人类编辑和下次模型生成会互相打架。
16.5 多轮对话里的结构化状态
如果结构化输出不是终点,而是要进入下一轮上下文(比如助手把抽取出的结构化槽位写回对话历史),那有个额外要求:被约束的输出会长期留在上下文里,它必须对后续轮次友好。
这意味着顺序和可读性比合法性更重要——如果 JSON 是一行几千字符的压缩形式,它会占用大量 token 并且让模型难以引用其中的具体字段。做法是在约束允许的范围内优化输出:保持字段顺序稳定(和 schema 一致)、控制缩进(约束里允许换行而不是禁止)、避免冗余的可选字段。所以"约束解码 + 输出美观度"是一个真实存在的工程问题,值得在生成后加一层轻量的格式化。
十七、中文与多语言场景的特殊问题
中文站点上跑结构化生成的团队,会遇到一些英文资料里不会提到的具体问题,值得单独列一节。
17.1 中文引号是最常见的故障源
模型输出 JSON 时,把字符串里的值用中文引号包起来("名称":"北京"),是极其常见的失败模式——因为训练数据里中文语境下的引号本来就常是全角。这类破损无法被严格的解析器接受,但可以被容错解析器修复。
更进一步的做法是在约束层面根治:如果你的 schema 规定字符串用双引号,就在字符集约束里明确禁止全角引号。GBNF 的字符类支持这种精确排除,代价只是一个稍微复杂的字符集定义。如果你的 schema 允许字符串包含引号,可以考虑用 enum 把取值圈定——这同时解决了全角引号和枚举越界两个问题。
17.2 中文字段的键名问题
中文键名本身没有问题(JSON 完全支持),但有两个细节要注意:一是全角冒号(: 而不是 :)会让语法破裂,这是字符集约束能治的;二是键名里的空格——如果模型在生成后加了空格(" 用户名"),校验会失败,而约束可以把键名精确锁死为一个字面量,完全消除这个风险。
这里体现了一个普遍规律:键名是约束性价比最高的位置。因为键名是有限、可枚举、且完全确定的,用 const 锁死几乎没有成本,却消除了一整类故障(键名拼写、全半角、多余空格、顺序漂移)。
17.3 中文的长度预算
max_tokens 的估算在中文场景有个坑:同一个汉字在不同 tokenizer 下可能是 1 个 token 也可能是 2 个,而且中文文本的 token 密度通常低于同等信息量的英文(中文更压缩)。所以从"字数"推算 token 数会有系统性偏差。
可靠的做法只有一个:用真实 tokenizer 对真实样本做统计,取输出长度的 P99 再乘以安全系数。不要用"字数 × 某个系数"来估。前面说的"截断"故障在中文场景同样常见,而且因为中文信息密度高,截断往往发生在结构更靠后的位置,repair 救回来的概率反而更低。
17.4 输出语言约束
如果业务需要"字段值必须是中文",那这是一个语义约束而非语法约束——语法上 "北京" 和 "Beijing" 都是合法字符串。能做的是:
- 在提示词里明确语言要求(这是提示词真正有效的场景);
- 在校验层做语言检测,不符合就重试(简单有效);
- 用 GBNF 的字符类约束"只允许 CJK 字符"(最强,但会排除混排数字、英文专有名词,实际很少适用)。
我的建议是第二种。语言检测 + 重试的成本极低、覆盖率高,而字符类约束的实际可用性很差——因为真实的文本几乎总有数字、英文缩写、标点需要混排。约束在这里的收益远不如在结构层面。
十八、用约束解码合成数据
约束解码有一个常被忽略的用途:它是生成高质量训练/评测数据的最优工具,因为它能保证格式,让模型(或规则)专注于内容。
18.1 格式由约束保证,质量由采样控制
传统的数据合成流程是"让模型按格式输出 → 用脚本解析 → 丢弃解析失败的"。这里有个隐含的效率损失:大量算力花在了会被丢弃的样本上。用约束解码之后这个比例接近零,同样的算力能产出更多可用样本。这是它作为数据工具最直接的价值。
18.2 主动覆盖边界情况
约束解码让"按需生成边界样本"变得容易:想要一个"所有可选字段都缺失"的样本?把 schema 的可选字段临时改成 required 就行。想要一个"深层嵌套"的样本?生成一个深树结构。让模型自由生成,你很难定向地得到这些样本。
还有一个更有价值的用法:用 schema 的约束空间本身来定义评测集。遍历枚举的笛卡尔积,生成一批覆盖不同参数组合的样本,就能知道模型在哪些组合上容易出错——尤其是当两个字段的组合存在隐含逻辑关系时。
18.3 必须警惕的分布偏移
约束生成的文本有一个系统性缺陷:它的分布比真实输出窄。约束保证了格式,也就消除了模型在格式上的随机性,导致生成的样本在某些维度上过于规整(比如枚举字段的分布可能不均匀,因为约束下的条件化会改变各取值的概率)。
如果用这些数据做微调,模型会继承这个偏差。所以实践中的正确做法是:用约束生成的数据做"正样本"和边界样本,同时必须混入真实的无约束输出(哪怕是坏格式的),否则模型会丧失对脏输入的鲁棒性。这个道理和代码里"不能只用 happy path 测试"是一样的。
十九、常见反模式
最后汇总五个在真实项目里反复出现、且都指向架构问题的反模式。它们和第十一章的"坑位"不同——坑位是实现细节 bug,这五个是方案设计层面的错误选择。
- 用正则修 JSON 而不是加约束。症状是修补逻辑越来越复杂,正则越写越长。判据:如果你的正则超过二十行还经常改,那它是在替约束干活,换个地方修问题。
- 把完整 schema 塞进提示词却不加约束。症状是提示词越来越长(token 成本上升)但格式遵循率没有改善。schema 在提示词里的作用是告诉模型字段的含义,保证格式应该交给约束;两者混用会导致重复投入。
- 无上限重试。症状是"成功率 99.9%"的说法,但没人说得清 0.1% 失败时的延迟是多少。判据:重试次数必须能从延迟预算反推得出,不是拍脑袋定的。
- 在高危操作上开启降级解析。症状是"为了可用性"在 SQL、工具调用上做降级。判据:降级只用于降级失败本身代价低于失败代价的场景。参数填错就执行,比直接报错危险得多。
- 为了灵活把 schema 设计成一堆自由字符串。症状是"为了兼容各种情况"用了
type: string,于是约束退化成"必须是一个 JSON 字符串",几乎没起作用。这是约束收益的天花板由 schema 质量决定的最好例证——约束解码再强,也没法强制一个自由字符串里的内容。
这五条里最后一条值得展开:如果你的 schema 里 80% 的字段都是 string,那用不用约束解码对质量的影响微乎其微,因为你只约束了最外层的括号。真正的收益来自把 schema 设计得更具体——用 enum 而不是 string、用嵌套对象而不是塞进一个 JSON 字符串、用必填字段而不是让模型自己判断。约束解码的能力上限,等于你 schema 的信息量上限。
想清楚这一点,你就知道技术投入的正确顺序了:先把 schema 设计好(成本最低、收益最大),再上约束解码保证结构,最后用校验兜住语义。反过来的顺序——先上约束解码,然后发现 schema 太松导致约束没东西可约束——是最常见的时间浪费。
二十、约束系统的正确性验证
约束解码的实现很容易出现一种危险的状态:它看起来在工作,格式成功率也不错,但掩码在某些状态下是错的。这类 bug 不会让系统立刻崩掉,只会让失败率缓慢偏高,而且很难归因。这一节给出一套能主动发现这类问题的验证方法。
20.1 不变量测试:什么样的状态是"不可能"的
最有效的做法是定义不变量,然后在大规模随机生成上检查它。约束系统有几个很好用的不变量:
- 格式不变量:任何被允许的 token 序列,其完整拼接结果必须是合法 JSON(或符合目标语法)。这是最核心的一条,可以在测试里对每一次生成结果直接断言。
- 单调性不变量:一个已经通过校验的输出,在追加任何合法 token 之后,仍然应该处于"可完成"状态——即不会出现"无论后面怎么写都无法闭合"的死锁状态。
- 可达性不变量:语法里声明的每条规则都必须从 root 可达。如果某条规则永远不会被用到,说明 schema 和语法不同步(要么多生成了规则,要么漏引用了)。
第二条"死锁状态"特别值得单独说:约束语法可能允许进入一个无法完成的状态。比如一个对象定义了必填字段,但语法里某个分支写法使得进入这个分支之后没有任何合法续写。这种情况下模型会生成到一半,然后掩码把一切都禁掉,输出突然停止——表现是"输出莫名其妙地短"。这类 bug 靠人工测试极难发现,但可以用随机 fuzzing 抓到:随机生成几万个样本,检查每个样本是否都完整、是否都能正常终止。
20.2 属性测试:把 schema 当作断言源
更有价值的一类测试是直接从 schema 推导断言,而不是手写期望值。对每个生成结果,自动检查:
- 输出是合法 JSON;
- 输出通过 JSON Schema 校验(这一步把 schema 变成了测试断言的来源);
- 所有 required 字段都存在且非 null;
- 所有 enum 字段的取值都在枚举内;
- 所有以 _at 或 _date 结尾的字段都能被解析成日期。
第三、四条尤其有价值,因为它们可以在约束层完全正确的情况下失败——这正是它们的价值:它们测的是"schema 的语义是否真的被表达了",而不是"语法是否正确"。一个只做语法测试的测试套件,会漏掉"必填字段被约束层错误地变成了可选"这类问题。
20.3 差分测试:约束版 vs 自由版
第三个有用的是差分测试:同一批输入,分别用约束解码和自由生成(不施加约束,但保留提示词要求),对比两者的行为差异。
关注三类差异,每一类都有明确含义:
- 约束版非法、自由版合法——这是正常的(约束的作用),但比例过高说明约束与模型倾向严重冲突。
- 约束版合法但内容与自由版差异很大——值得人工抽样检查。如果差异普遍存在,说明约束正在实质性地改变内容选择(而不是只兜住格式),这可能正是你需要的效果,也可能意味着约束过紧。
- 字段完整率的差异——这是最该看的指标。如果约束版的必填字段完整率明显更高,那说明约束的核心价值正在兑现。
差分测试需要多花一份算力,但它给出了一个基线——没有基线,你无法回答"约束到底有没有用、值不值"。很多项目的约束解码是"上了之后感觉变好了",但没人做过这个对比。
二十一、编译产物的生命周期
编译产物(GBNF 文本、DFA 表、掩码索引)是有状态的、有内存占用的、还和引擎版本绑定的。这部分运维细节很少有人写,但它决定了一个多租户服务能不能长期稳定运行。
21.1 三种状态,各自有不同的生命周期
| 状态 | 内容 | 内存量级 | 失效条件 | 存放位置 |
|---|---|---|---|---|
| 语法文本 | GBNF 字符串 / JSON | 几十 KB | schema 变更 | 磁盘缓存 + 内存 LRU |
| 编译结构 | 规则表、DFA 状态表 | 几百 KB 到几 MB | schema 变更、引擎版本变更 | 引擎进程内存 |
| 掩码状态缓存 | 各语法状态对应的允许 token 集 | 可到几十 MB | 几乎不失效,只随 LRU 淘汰 | 引擎进程内存,可跨请求共享 |
把这三层分清楚很重要,因为它们的失效逻辑和内存特征完全不同。一个常见的事故是:schema 改了,但编译结构缓存的键没有包含 schema 版本,导致新旧语法混用——症状是输出里出现了新旧两套规则都允许的片段。这类问题靠重启服务能"解决",所以很容易被误判成环境不稳定。
21.2 预热:让第一个请求不至于很慢
因为编译结构是懒加载的,服务刚启动时的前若干个请求会明显慢(要现编译、现构造状态)。在流量高峰期这不影响什么,但在冷启动、低流量、或刚扩缩容的场景下,它就是用户可感知的延迟。
做法很简单:启动时对预定义的热门 schema 集合做一次预热——生成一个极短的输出,把常见语法路径上的状态全部走一遍,让缓存热起来。成本是启动时多几十毫秒,收益是彻底消除冷启动的长尾。判断要不要做:如果你的 P99 延迟曲线在服务刚启动后的几分钟内明显偏高,这就是证据。
21.3 多模型与多引擎并存
如果你的服务同时提供多个模型(不同 tokenizer),或同时接了多个推理后端,那么编译产物不能共享。前面第十三章讲过 tokenizer 版本必须进缓存键,这里补一个更细的点:不同引擎对同一个 schema 生成的语法可能不同(比如一个支持某种关键字另一个不支持),所以缓存键里必须包含"引擎 + 版本 + 是否开启了某个编译选项"。
一个实际会踩的坑:如果缓存键只用了 schema 哈希,而服务做灰度时一部分流量切到了新引擎,那么老引擎的语法文本可能被新引擎的掩码逻辑使用,导致行为异常。灰度发布时缓存必须按引擎实例隔离,最稳妥的做法是每个引擎实例有自己的缓存命名空间。
二十二、成本核算
约束解码是一次性投入加持续开销的组合,很多团队在决策时只算了其中一边。这一节把三块成本列清楚。
22.1 一次性成本
语法缓存的冷启动和预热是主要的一次性成本,量级通常在几十毫秒到几百毫秒,可以忽略。真正的一次性成本是schema 设计的投入——把一个松散的 schema 改写成能被有效约束的形式,往往是整个项目里工作量最大的一块,而且这部分投入永远不会被摊销成"免费"。
22.2 持续成本:延迟与吞吐
延迟侧的成本已经在第十章算清楚了。吞吐侧的成本是掩码构造占用的 CPU,这个成本在高并发下会以"CPU 成为瓶颈"的形式表现出来。缓解手段按性价比排序:
- 保证状态缓存命中——这是收益最大的,因为缓存命中时掩码几乎是零成本。
- 把掩码做成 GPU 张量——收益次之,取决于你的引擎是否支持。
- 加 CPU 核——最直接但最粗暴,如果掩码是纯 CPU 的,加核往往比改代码更快见效。
22.3 收益侧:不要只算解析率
约束解码的收益容易被低估,因为它的主要收益不是"少了几次重试",而是:
- 字段完整率提升带来的下游数据质量改善(这一点无法用重试次数衡量);
- 下游代码简化——下游不必再写容错解析逻辑、不必处理"可能损坏"的输入,测试用例也不必构造异常分支;
- 失败可观测性——失败从"偶发且难复现"变成"可计数、可告警、可归因",这个收益在团队协作层面比在性能层面更大。
做决策时最容易犯的错误,是把约束解码只当成"减少重试"来算 ROI。正确的算法是:把下游的容错代码维护成本、重试消耗的 token 成本、以及错误数据进入系统后的排查成本,都算进收益侧。这样算出来的账通常是正的,而且余量比想象的大。
反过来说,如果你的场景是下游本来就必须写完整的容错逻辑(因为格式本来就不重要),那约束解码的 ROI 确实很低——这是少数应该不上约束解码的正当理由。
二十三、与检索和长上下文的组合
结构化生成很少独立存在,它通常和检索、或者长文档处理一起出现。这两种组合里有几个值得注意的点。
23.1 从 schema 反向驱动检索
一个反向的思路很有价值:先定 schema,再用 schema 去指导该检索什么。如果 schema 里有"发票号"这个字段,那么检索时就该去找发票号;如果 schema 里有"违约条款"字段,检索时就该专门去找这部分内容。这比"先检索一大坨文本再让模型自己找"要准确得多,因为schema 本身就是一份"我们关心什么"的显式声明。
更进一步:如果你的 schema 来自数据库结构或接口定义,那它就是最权威的"需求清单",可以用来审计检索策略——检查每个字段是否有对应的检索来源,往往能发现"这个字段模型根本没法填"的 schema 设计缺陷。
23.2 长文档下的结构化抽取
从长文档抽取结构化信息时,约束解码有一个特别的作用:它让"抽取"和"归纳"可以分离。你可以先让模型用自由格式逐段做笔记(这一段不受约束,因为笔记格式自由有助于思考),最后再对笔记做一次强约束的抽取(格式严格、成本低)。
这比"一次调用强约束抽取整篇文档"更好,原因是强约束会在长生成过程中持续施加格式压力,而这个压力会挤占模型对内容的注意力。分两段做,第一段保持模型的思考质量,第二段保证输出可用。这在 token 成本上可能略高(多一次调用),但在抽取质量上通常更好——尤其是文档很长的时候。
23.3 检索结果本身要不要约束
如果你的 schema 里包含"引用来源"字段(比如要求每个抽取值附带原文出处),那么这个字段必须能被约束到一个真实存在的来源集合——也就是检索结果的 id 列表。这正是第十四章讲的"状态反馈型约束"的典型场景。
但我建议先不做状态反馈,用校验代替:让模型输出引用 id,然后用代码检查 id 是否在检索结果里。检查一次是 O(1) 的操作,而把检索结果注入解码掩码会带来第十四章说的全部复杂度和延迟问题。除非你发现"模型编造来源 id"的比例高到无法接受,否则不值得。
二十四、从零到一的上线路线图
如果你的团队现在还没有用约束解码,这一节给一个分阶段的路径。核心原则是:每一步都要能独立产生价值,不要为了最终形态而在中间态上过度投入。
阶段一:先上校验(第一天就能做)
不加任何约束,只做三件事:记录原始输出、跑 parse-repair、跑 schema 校验,把三个失败率埋点上报。这一步的产出是数据——你知道自己的真实格式成功率是多少、失败长什么样。
这一步经常被跳过,理由是"校验又不能修复问题"。但没有这组数据,后面所有决策都是盲的:不知道该不该上约束、不知道约束该多紧、不知道重试值不值得。埋点的成本是一天,不做的代价是几周的错误判断。
阶段二:修正 max_tokens 和 schema(第一周)
拿到失败样本后,你会发现相当一部分失败其实不是约束问题,而是预算和 schema 问题:max_tokens 太小导致截断、schema 要求了模型填不出来的字段(比如从一个模糊描述里要求精确数字)。
修正这两件事通常能把失败率降一大截,而且不花一分钱算力。这一步的产出是:一份真实的失败原因分布,它决定了后面该往哪个方向投资。
阶段三:上服务端结构化输出(第二到三周)
如果你的供应商支持 schema 级结构化输出,开它。这一步的成本最低(改几行参数),收益是格式成功率的大幅提升。同时把 schema 收紧——这是收益最大的一步,因为 schema 越具体,能被强制的内容越多。
这一步要重点验证的:服务端到底支持哪些 schema 关键字。把它当成一次调研(文档 + 实测),因为你需要知道哪些约束是靠后的、哪些是靠校验兜的。如果发现服务端不支持某个你需要的特性,这就是第四阶段的动机。
阶段四:自建约束(按需)
只有当下面至少一条成立时,才值得自建:(1)服务端不支持你需要的约束特性;(2)你需要控制候选集合(比如限定可选值来自实时查询);(3)你需要流式约束;(4)你需要控制约束实现以优化吞吐。
自建的方案选择在前几章已经讲过(引擎内置后端 vs XGrammar vs Outlines vs guidance)。选型的关键问题只有一个:你的 schema 长什么样、约束的紧致程度如何。宽松 schema 用什么都行,紧凑 schema 才有必要上专用引擎。
阶段五:补齐监控与回归(持续)
按第十二章和第十三章把三个失败率、编译耗时、缓存命中率、被剪掉的概率质量建起来,并约束覆盖度测试做成 CI。这一步没有新功能,但它是让前面所有投入持续有效的保障。
四个阶段的顺序里有一个反直觉的要点:阶段三(服务端结构化输出)的收益通常远大于阶段四(自建约束)。很多团队跳过服务端方案直接自建,结果付出了引擎集成的全部成本,却没有获得对应的收益。除非你有明确的、自建才能满足的需求,否则先用服务端的。
另一个要点是阶段二(修 max_tokens 和 schema)被普遍低估。它不需要任何新组件,却经常能解决大部分失败——因为很多"格式问题"其实是"你要求模型输出它没有信息能填的东西"。这一步做扎实,你对后续方案的收益预期才会是准确的。
二十五、复杂 Schema 的五种难点
前面几章讲的 schema 大多是扁平对象,真实项目里的 schema 往往要棘手得多。这一章挑五种最常见的难点,说明它们各自会在编译期还是运行期出问题,以及正确的处理方式。
25.1 判别字段与多态对象
最麻烦的一类结构是"同一个字段,按类型不同有不同的子结构",典型形态是带判别字段的联合:对象里有一个类型字段,取值可能是几种枚举之一,每种取值对应完全不同的属性集合。日志、事件、协议消息都属于这一类。
这类 schema 编译起来不难,难的是组合爆炸。如果三种类型各有十个子字段,编译器把每个分支的语法都展开,语法体积大约是单个分支的三倍;如果类型增加到十种,体积就是十倍。语法体积直接决定编译时间和状态缓存大小,所以在类型数量多的时候必须警惕。
三种处理方式。第一种是在语法层面共享公共前缀:把判别字段先写出来,再让后续字段按判别值分叉。这样公共部分的语法只写一份,能省下不少体积。第二种是限制同时可用的类型数量:如果业务上很少同时出现所有类型,可以按场景把 schema 拆成几个小版本,每次只用两三种类型,这是最实用的做法。第三种是放弃约束、只做校验:如果类型数量实在太多,把判别字段用枚举约束住(这部分收益最大、代价最小),其余字段交给校验器处理。
如果让我选,我会选第三种。因为判别字段的错误率远高于其它字段——模型倾向于选一个"看起来合理"的类型,而不会系统性地破坏某个分支的内部结构。把约束集中投入到判别字段上,用校验去覆盖剩下的部分,是性价比最高的安排。
25.2 条件字段:真正的难点不是语法而是语义
条件字段指的是"某个字段的存在或取值依赖于另一个字段"。最常见的两种:一个是存在性依赖——某个字段存在时必须同时存在另一个字段;另一个是取值依赖——某个字段取特定值时,其它字段的取值范围或存在性发生变化。
语法层面,条件字段可以部分表达:如果依赖关系是"选填字段二只在字段一等于某值时才允许出现",那可以在语法里把这个分支限定在特定上下文下。难点在于编译器需要维护路径相关的状态——"我现在在第几层、这个路径上的条件字段取值是什么",而这远超单纯的栈式状态机。
所以真正的实现里,条件字段通常走两条路之一:要么把条件编译成一组具体分支(相当于把条件展开成有限种情况,组合多时会爆炸),要么交给校验层。我的建议是后者,并且把条件写成显式的校验规则,因为它本来就是业务规则的直接翻译,写在代码里比翻译成语法更好维护。
这里有一个容易被忽略的收益:把条件写进校验器,等于把这部分业务规则从"散落在各个调用方"变成了"集中在一处"。很多团队的字段依赖规则散落在不同服务的不同地方,每次改规则都要全局搜索,而集中在一个校验模块之后,改动的影响范围一目了然。
25.3 联合类型与"任意一个"
联合类型(多个可能类型之一)在语法上很容易实现,就是把各分支的产生式用竖线连接。真正的问题是它让"校验什么"变得模糊:如果模型输出了字符串分支,而你的代码期望对象分支,应该报"类型错误"还是"字段缺失"?如果多种分支都合法,返回值的类型对下游来说就是不稳定的。
更麻烦的是下游的可用性:一个返回类型不稳定的接口,会把类型系统的负担转移给每一个调用方。约束解码能保证"输出符合某个分支",但不能保证"输出符合你想要的分支"。所以如果你的业务其实知道该用哪个分支,正确的做法是在 schema 里把它确定下来,而不是留一个联合让模型选。联合类型应该只用在"分支确实是运行时才能决定"的场合。
这是一条容易被忽视的设计原则:schema 里的每一个联合,都是把一个确定性问题变成了一个概率问题。模型选错分支的概率不为零,而你完全可以在 schema 层面消除这个可能性。能消除就不要留着。
25.4 递归结构的深度与宽度
递归结构(比如树形组织、嵌套评论、目录结构)在语法里很好表达,生成一条自引用的规则就行。问题出在实际生成时:模型不知道你的深度上限,而它可能生成一个非常深的嵌套。如果下游的解析器有递归深度限制(几乎所有语言都有),一个超过限制的输出会让下游直接抛异常。
处理办法有三个,从推荐到不推荐:其一是在生成后检查深度,超过阈值就判定失败重试或降级,这是最直接的;其二是在编译阶段把递归展开有限层,语法里只允许嵌套五层,代价是灵活性受限;其三是在提示词里说明深度限制,这条基本无效,因为它只是一个请求。
除了深度还有宽度问题:递归结构里的数组如果不限长,模型可能生成几千个元素的数组,这在解析和渲染时都是隐患。同样地,语法层面很难表达"最多一百个元素"这种约束(需要计数),所以更实际的做法是在校验层限制数量,或者在解析时截断并记录。
顺带一提,递归结构还有一个隐藏风险:它和语法状态缓存的交互。因为不同深度对应不同状态,深层嵌套会产生大量状态,缓存命中率会下降。如果你的业务经常处理深层结构,值得单独观察一下掩码构造的耗时分布。
25.5 巨大的枚举
最后一种难点是枚举本身很大——比如从数据库读来的几千个产品名、几百个标签。这种枚举用约束表达是"一个巨大的竖线分隔列表",语法体积会非常大,而且每增加一个取值就多一份状态。
更糟的是用户体验:当某个取值从未在训练数据里出现过时,强制它出现在候选集合里,模型可能根本不知道怎么写(它不认识这个值),结果是写出一个相近但错误的值——而这恰恰是约束本该解决的问题。
对巨大枚举,我的建议是换成两阶段:先用一次普通生成让模型表达意图(比如说出"这是一个婴幼儿配方奶粉产品"),然后用代码把这个意图映射到具体的合法取值上,最后再让模型在缩小的候选集里选。把"生成取值"和"从候选里选"这两个任务分开,比让模型一次性在几千个值里选更可靠。
这个模式的适用范围很广:标签选择、实体链接、分类打标,凡是"候选集大到无法穷举给模型看"的场景都适用。它的本质是用检索替代枚举。
二十六、结构化输出的流式路线
前面说过流式与约束天然冲突,这一章把可行的几条路线讲清楚,因为很多产品的结构化输出都是流式的,而这一块的最佳实践还相当不成熟。
26.1 三条基本路线
第一条是伪流式:服务端完整生成之后,再把结果切片分批推给前端。这是最容易实现、也最稳的方案,因为它本质上就是非流式生成加上一个分块推送。它的用户体验实际上不差——因为结构化结果的消费方通常是程序,前端拿到之后才渲染,分片推送的必要性本来就有限。
第二条是真流式加前缀修补:一边生成一边把已生成的部分推给前端,前端用容错解析器对不完整的前缀做修补,得到一个"尽力而为"的中间状态。它的收益是首屏更早出现内容,代价是前端的解析逻辑要能处理任意截断,而且中间状态会闪烁。
第三条是受约束的分片推送:一边用约束解码生成,一边在每个语法边界处(比如某个字段完整结束时)切出一个完整单元推送。这条路线最优雅但实现最复杂,它要求约束引擎能暴露"当前可以安全切分的位置",而这个能力多数引擎并不提供。
26.2 一个容易被低估的前端问题
如果你的结构化输出会在前端逐块渲染,那么字段顺序会影响渲染体验。约束可以强制字段顺序(前面讲过这是个实用技巧),而顺序应该按"用户最关心的先出现"来排,而不是按 schema 的声明顺序。这一个小改动就能显著改善流式场景下的感知性能。
另一个前端问题是缺失字段的占位。因为约束保证了必填字段一定出现,所以前端不需要处理"字段还没到"的分支——这是约束解码给前端带来的直接收益。反过来说,如果前端已经写了大量"字段可能缺失"的兼容代码,那约束解码的价值就不只是后端可靠性的提升,还包括前端代码可以显著简化。这一项收益在评估时经常被忽略,但它真实存在。
26.3 流式场景下的超时与降级
流式还有一个现实问题:约束让首 token 变慢了(要先算初始掩码),如果生成中途某个状态需要冷启动,输出会出现明显的停顿。用户看到的就是"卡住了"。
缓解手段有几个:预热常见语法状态(用一段极短的合成输入把热门路径走一遍);在流式场景放宽约束(只保证括号平衡和基本结构,不做完整 schema 约束,因为流式的前缀本来就无法完整校验);设置停顿告警(如果超过一定时间没有新 token,触发降级或告警)。
最后一条特别值得强调:流式场景下降级解析比非流式更有价值,因为用户已经看到了一部分内容,完全推倒重来体验很差。这时候把已收到的部分按容错方式解析出来展示,配合一个"正在生成"的提示,比直接报错要好得多。这也是第十一章里"流式 UI 渲染"那一行的判断依据。
二十七、多候选生成与选择
约束解码还有一个被低估的用法:它让"生成多个候选然后选最好的"变得可行且廉价。原因很简单——每个候选都保证是合法结构,所以你不需要花时间在"解析失败"上,可以把全部算力用在提高候选质量上。
27.1 三个可以叠加的机制
第一是采样多个候选。在约束下采样若干个不同输出,它们在结构上等价,差异体现在内容上。这一步和常规的多样性采样没区别,但收益在于你可以只用结构校验来筛选,而不用解析。
第二是用校验器和规则打分。有了多个合法候选之后,就可以用额外的规则排序:字段完整性、是否符合业务规则、甚至是否符合风格偏好。这本质上是一个"用规则做重排序"的轻量方案,不需要训练模型。
第三是自我一致性投票。如果候选之间的某个字段经常一致,那么这个字段大概就是"对的"。用多数投票来决定最终结果,在很多抽取任务上比单次生成更准。
27.2 什么时候值得开多候选
多候选不是免费的,它的成本是线性增长:生成三个候选的 token 成本是单次的三倍(约束和校验的开销也同步放大)。所以要不要开,取决于三个判断:
- 单次成功率已经很高了吗?如果单次就有九成以上的语义有效率,多候选带来的增益很小,因为剩下的错误多半是模型能力问题,不是随机性问题。
- 错误的代价大吗?如果是自动执行的链路,多花三倍成本换更高的正确率通常划算;如果是批量人工审核的链路,可能不划算。
- 有没有比多候选更便宜的手段?如果错误集中在一两个字段上,先重试一次往往就解决了,而多候选是全局放大。
一个常见的甜蜜点是两到三个候选:第一个候选加上少数几次重试,通常能覆盖大部分场景,成本也可控。超过五个候选之后,边际收益迅速下降,而延迟开始变得不可接受。
27.3 多候选与重试的区别
重试和多候选经常被混为一谈,但它们解决的是不同的问题。重试是在失败时触发的补救,它依赖失败信号(校验不通过),所以只在第一次真的失败时才产生额外成本,而且通常会把错误信息反馈给模型,效果更好。多候选是无条件生成的多个版本,即使第一次就对也要付三倍成本,收益是把"选"的机会显式化。
所以推荐的顺序是:先做好重试(它便宜、且有反馈信息),再考虑多候选(它贵、但可以排序)。很多团队反过来做,先上了多候选却没有校验器,等于白白多花了两倍成本。
27.4 约束让"选"这件事变得可度量
多候选方案能不能落地,取决于你能不能说清"哪个候选更好"。如果没有打分依据,多候选就只是浪费算力。而约束解码在这里有一个间接贡献:它把候选的差异限制在内容上,所以你可以用业务规则来比较它们,而不需要先做结构归一化或格式对齐。比较的实现因此变得简单——直接对字段值做规则判断即可。
这个好处在候选包含自由文本字段时会体现得更明显:没有约束时,两个候选可能一个用双引号一个用单引号、一个带空格一个不带,比较规则要先处理这些噪音;有约束时这些差异不存在。
二十八、从"提示词 + 修复"迁移到约束
假设你的系统现在是"提示词要求输出 JSON,失败就重试",这一章讲怎么迁移,以及迁移过程中最容易出的问题。完整的迁移通常分四步,每一步都可以独立验证、独立回退。
28.1 第一步:先补齐观测,再改行为
迁移的第一步不是加约束,而是把当前的失败率测出来。具体来说,你需要知道:格式一次通过率是多少、修复后通过率是多少、语义有效率是多少,以及这三者的关系。如果修复后通过率远高于一次通过率,说明你现在依赖的其实是修复器,那迁移的收益会更大。
这一步同时要收集失败样本,尤其是那些修复器也修不好的样本——它们通常指向两类问题:要么是 max_tokens 太小导致截断,要么是 schema 要求了模型无法提供的信息。这两类问题的修复成本远低于引入约束,而且应该先做。
28.2 第二步:收紧 schema
第二步是把 schema 从"描述性"改成"可执行"。具体来说:把所有能用枚举表达的字段从字符串改成枚举;把所有能确定长度限制的字符串加上限制;把散落的可选字段改成明确的必填或可选;把"一个字符串里塞多个概念"改成分开的字段。
这一步的收益是双重的:它既提高了约束能强制的比例,也直接提高了模型的输出质量(因为模型不用再猜该填什么)。很多团队跳过这一步直接上约束,结果发现约束能强制的部分很少,于是得出"约束解码没用"的错误结论。
28.3 第三步:接入约束并保留回退
第三步接入约束,但一定要保留旧的路径并加上开关。理由在第二十四章说过:新路径同时改变格式和内容分布,出现问题时需要能快速回退,而且要能对比两条路径的输出。
这一步最容易犯的错误是一次性改太多:同时改了 schema、换了模型、接了约束、又调整了提示词。结果是如果指标变化,你无法归因是哪一项起了作用。正确的做法是一次只改一个变量,每次观察足够长的时间。
28.4 第四步:把校验器补全
最后一步是补全校验器,因为约束只管结构,语义必须靠校验。这一步往往被推迟,但它是三层架构真正成立的标志:约束层保证结构,校验层保证语义,两层配合才能覆盖绝大部分失败。
补全校验器时的建议顺序:先把所有业务规则显式写成校验(哪怕先不生效,只是记录违规率),观察一段时间的违规率,然后对高频违规加自动拒绝和重试。先观测再拦截,这样能避免误伤——一个写得过于激进的校验器可能把大量本来可用的输出拒掉。
28.5 迁移期间最常见的三个问题
第一个是 schema 与约束不同步。改了 schema 之后忘记重新生成约束,导致约束还在按旧 schema 强制,表现为模型填了本该填的字段但被掩码挡住,或者反过来。防御手段是把 schema 哈希纳入缓存键,并在启动时打印当前使用的约束版本。
第二个是 max_tokens 没跟着调。收紧 schema 之后,输出长度通常会变化(新字段更多、枚举值更长)。如果沿用旧的预算,会出现新的截断。防御手段是把输出长度统计接进监控。
第三个是双路径的语义不一致。旧路径(提示词加修复)和新路径(约束)对同一个输入可能给出不同的内容。如果有下游依赖旧路径的具体行为(比如某个字段的措辞风格),切换时会有意外。防御手段是在灰度期做双跑对比,重点看内容差异而不只是格式通过率。
二十九、失败模式手册
这一章把全文涉及的问题整理成一份可查的手册,每条给出症状、原因和定位方式。建议把它当成排查手册,而不是阅读材料。
29.1 症状是"格式完全正确但内容明显不对"
这是最容易被误判的一类。约束解码保证结构,业务规则保证语义,如果只做了前者,就会出现格式完美但内容错误的输出。定位方法是看语义有效率这个指标——如果它明显低于一次通过率,说明约束层工作正常,问题在业务规则或者模型能力上。常见的真实原因是 max_tokens 太小(输出被截断但恰好在结构完整的位置截断)、schema 要求了模型拿不到的信息(比如从一个模糊描述里要求精确数值)、以及过紧的约束把模型逼到了不合理的取值上。
29.2 症状是"输出在某个位置突然结束"
这个症状几乎总是死锁状态:约束进入了无法完成的状态,掩码把后续所有 token 都禁掉了,输出被迫结束。原因是语法有缺陷,比如某个分支让某个必填字段无法被满足。定位方法是打印约束在输出终止时的状态栈,通常能直接看出缺了什么。修复方式是补上该分支的规则,或者在生成后检查必填字段是否齐全。
另一个可能的原因是模型决定停止——如果实现里支持"模型可以主动输出终止符",而语法允许在非完成状态下终止,那就会出现这种症状。所以终止符的合法性必须和当前状态绑定。
29.3 症状是"偶尔(不是每次)出现非法字符"
偶发的非法字符几乎总是掩码状态算错了,最常见的原因是掩码用了下一个状态而不是当前状态(或者反过来),或者状态在多个请求之间被错误共享。定位方法是加断言:在应用掩码之后,检查所有被允许的 token 的完整拼接是否仍然可完成——这是一个便宜而有效的在线检测。修复方式是把状态和请求绑定,确保状态机对象不会被复用。
另一个可能原因是 tokenizer 的跨字符边界问题:一个 token 的文本形态跨越了语法边界。定位方法是取真实的生成样本反向分词,检查是否存在跨界 token。
29.4 症状是"重启服务就好了"
重启能解决问题,通常指向缓存污染:约束语法、编译结构或者掩码状态的缓存里有过期条目。常见原因是缓存键不完整(缺少 schema 版本、引擎版本或者模型标识)。防御手段是把这些维度全部纳入缓存键,并且在发布时主动清空引擎内的进程级缓存。重启能修复的问题几乎都是这一类,所以它是一个很有用的诊断信号——如果重启有效,就不要去查代码逻辑,先查缓存。
29.5 症状是"高并发时吞吐反而下降"
这是约束解码唯一的负向扩展性问题,根因是掩码构造占用的 CPU 在高并发下成为瓶颈,或者多租户的 schema 分散导致状态缓存命中率下降。定位方法是分别压测低、中、高三档并发,观察吞吐曲线在哪个拐点开始下降,同时看缓存命中率。修复手段按性价比排序:先扩大缓存并观察命中率,如果命中率已经很高但 CPU 仍然吃紧,就把掩码改成 GPU 张量形式,最后才考虑加 CPU 核。
29.6 症状是"重试次数用满了还是失败"
这个症状说明重试策略掩盖了一个不可能通过重试解决的问题。如果三次重试都失败,那么问题几乎肯定不是随机的——重试之所以有用,是因为错误具有随机性(同一输入多次生成可能不同)。如果错误是系统性的(比如 schema 和模型能力不匹配),重试一万次也不会成功。修复方向是回到根因:检查是不是 max_tokens 不够、schema 有没有要求不可能的东西、或者约束是不是过紧。增加重试次数在这种场景下只是烧钱。
29.7 症状是"线上偶发格式错误,但复现不了"
如果失败率很低且难以复现,最有效的方法不是加日志,而是先降级为可观测:把失败的原始输出完整记录下来(注意脱敏),哪怕一天只出现几十次,两周也能积累出足够的样本用来定位。这一步必须先做,因为没有样本的话任何猜测都是盲的。积累样本时要注意记录上下文(模型版本、schema 版本、引擎版本),否则样本之间无法对比。
29.8 一条通用的排查顺序
遇到任何约束相关的问题,我建议按这个顺序排查:先确认约束是否真的生效(用反向验证法,故意放宽约束看输出变不变);再确认约束的作用范围是否正确(逐字段对比 schema 与生成的语法,看有没有哪个字段被漏掉了);然后确认状态管理是否正确(尤其是并发场景下状态有没有被错误共享);最后才怀疑模型本身。这个顺序背后的判断是:约束系统的问题大多是确定性的、可复现的、可通过代码审查发现的,而模型的问题才是概率性的、最难定位的,所以应该先排除便宜的可能性。
三十、约束与提示的职责边界
前面几章反复提到"提示词负责意图、约束负责结构、校验负责语义",但这个分工在具体决策时并不总是清晰。这一章把它变成一棵可以直接照着走的决策树,解决"这个东西到底该放在哪一层"的具体困惑。
30.1 先问:它的失败会造成什么后果
分层的第一个判断依据不是技术属性,而是失败后果。同样是"模型选错了值",如果后果只是界面上少显示一个标签,那它属于提示词的范畴,调提示词的成本最低;如果后果是执行了一个不该执行的数据库操作,那它属于约束加校验的范畴,必须在工程上强制。
这个判断之所以重要,是因为它能避免一种常见的错误投入:花大量时间调提示词,试图让模型"更准确地"选择某个值。但准确率问题的天花板往往不由提示词决定——如果模型的信息不足以做出正确选择,那么无论提示怎么写,它都只能猜。遇到这种情况应该问的是"它缺什么信息",而不是"该怎么措辞"。
30.2 第二问:它能不能被精确表达
第二个判断依据是可表达性。如果一个要求能被写成"取值必须属于这个有限集合"或者"这个字段必须存在",那它应该进约束,因为约束能百分之百保证它。如果一个要求只能被描述成"要写得专业一些"、"要简洁"、"要给出理由",那它只能进提示词,因为语法无法表达。
这个划分有一个重要的推论:当你发现自己在提示词里反复强调某个结构性要求时,应该立刻把它移到约束里。因为反复强调本身就说明它重要,而重要的结构性要求交给概率性的手段去保证是错配。提示词里每多一条结构性要求,都会挤占模型对内容要求的注意力。
30.3 第三问:违反它之后的补救成本
第三个判断依据是补救成本。同一个错误,如果能被自动检测并低成本补救(比如重试一次就好),它可以放在校验层;如果补救成本很高(比如已经产生了外部副作用),它就必须被约束提前挡住。
这个判断直接决定了降级策略的开关:补救成本低的失败才允许降级解析,补救成本高的一律要求重试或者直接失败。这也是第九章那张决策表的核心依据——它看起来是一张场景对照表,实际上是这张判断原则的具体化。
30.4 一个可以直接用的三问清单
把上面三个问题合起来,遇到"这个东西该放哪一层"的困惑时可以这样依次问:
- 它失败的后果有多严重?不严重的可以留在提示词里,靠多次尝试解决。
- 它能被精确表达成取值或存在性约束吗?能的话移到约束层,这是最可靠的位置。
- 它的补救成本高吗?不高的话交给校验层,高的话必须用约束提前挡住。
三问之后几乎总能得出答案,而且结论往往是"往上移一层"——也就是从提示词移到约束、从约束移到硬编码。经验上,一个成熟的结构化输出系统里,提示词里应该只剩下真正只能用意图表达的内容(语气、风格、推理方式、字段含义的解释),所有结构性要求都应该已经下沉到约束和校验。
三十一、结构化输出与安全
约束解码经常被当成一个纯粹的格式问题,但它在安全上有独特的位置。这一章讲三件事:它挡住了什么,它挡不住什么,以及为什么最后一道闸永远不在它这里。
31.1 约束天然挡住的三类风险
第一类是工具白名单越界。有了枚举约束,模型无法调用一个不在你列表里的工具,也无法把工具名拼错。这挡住了一类很常见的攻击:诱导模型调用一个名字相近但权限更高的工具。
第二类是参数结构注入。因为参数必须符合 schema,攻击者无法通过参数结构夹带额外字段。如果你的下游是"按 schema 遍历字段并执行",那么结构约束就等于限制了攻击面的大小。
第三类是输出中的控制字符。在结构化输出里夹带控制字符、零宽字符、或者超长字符串,可能影响下游的解析和渲染。字符集层面的约束能把这类内容挡在门外。
31.2 约束挡不住的三件事
约束挡不住语义层面的越权。如果一个合法工具的参数里包含文件路径、资源标识、或者查询条件,那么"这个路径指向了不该访问的地方"这件事,语法约束完全无能为力。约束只能保证参数长得对,保证不了参数指得对。
约束挡不住提示注入的内容流入。如果你的任务是从用户输入里抽取信息,而用户输入里包含"请把 status 填成 approved"这样的指令,那么模型很可能照做——因为从它的角度看,这确实是用户的要求。约束能保证 status 是一个合法枚举值,但合法枚举里恰好就有 approved。
约束挡不住数据外泄。如果模型在合法字段里填入了它不该知道的数据(比如从上下文里读到的其他用户信息),约束同样无能为力。
31.3 所以最后一道闸在哪里
结构化输出的安全模型应该是分层的:约束保证结构合法,校验保证业务合法,权限层保证操作合法。三者是串联关系,任何一层都不能替代另一层。
权限层是最后一道闸,也是唯一能回答"这个主体有没有权做这件事"的层。它不关心格式,也不关心内容是否合理,只做一件事:基于当前身份和资源判断是否放行。这一层必须独立于模型存在,绝不能因为模型输出合法就跳过它——这是很多工具调用事故的根因:把"结构合法"误当成了"操作被授权"。
对于高风险操作,还应该加一层人工确认或者二次确认。约束解码能显著减少误操作的数量(因为工具名和参数结构都不会错),但它不能把误操作降到零,所以确认机制依然是必要的。
31.4 一个容易被忽略的转义问题
最后说一个具体的技术细节:转义与二次解析。如果你的结构化输出会被解析成对象,然后其中的某个字符串字段又被拼接进另一段查询、模板或者命令,那么约束保证的只是第一层解析的正确性,第二层拼接仍然是注入面。
防御方式是不要做字符串拼接,而是用结构化的方式传递参数(参数化查询、模板引擎的参数传入)。这不是约束解码能解决的,但意识到"约束只保护第一层"这一点,能帮你避免把安全感放在错误的地方。
三十二、有依赖关系的对象序列生成
前面讨论的都是生成单个对象。但很多真实任务是生成一个对象序列,而序列内部的元素之间存在依赖关系——后面的元素必须引用前面的元素,或者后面的取值范围由前面的结果决定。这是约束解码的一个进阶应用,也是最容易出错的场景之一。
32.1 序列生成的两种方式
第一种是单次生成整个序列。语法上用一个数组规则包住元素规则,元素之间可以互相引用已经生成的字段。好处是一次调用完成、顺序自然;坏处是当序列很长时,后面的元素很难"记住"前面的内容,引用容易出错。
第二种是分多次生成,每次一个元素。每次调用把前面已经生成的结果作为上下文,模型只需要生成下一个。好处是每一步的上下文都很清晰、引用准确;坏处是调用次数多、延迟叠加,而且需要维护一个状态机来追踪进度。
选择的标准很简单:序列长度固定且短(比如不超过五个),用单次生成;长度不定或较长,用分次生成。分次生成的一个额外好处是每一步都可以独立校验和重试,失败的影响范围被限制在一步之内。
32.2 顺序约束:让依赖关系变得可执行
无论哪种方式,关键都是把依赖关系表达出来。有两种做法:一种是在语法层面强制顺序(比如语法规定引用字段只能出现在它被定义之后);另一种是在校验层面检查引用完整性。
语法层面的强制更适合"结构性顺序",比如"必须先声明再引用"这类语言级别的规则。而"引用的对象必须存在且类型匹配"这类语义约束,只能交给校验——因为语法无法知道数据库里有什么。
这里有一个容易被忽略的收益:强制顺序会显著提高模型输出的正确率。模型在生成时如果必须先产出被引用的对象,它就不会在引用还没定义时凭空编一个名字。反过来,如果语法允许乱序,模型就会倾向于先写引用、后补定义,而补的时候经常对不上。
32.3 分次生成的状态管理
如果选择分次生成,就必须认真设计状态管理。至少要处理三件事:进度记录(已经生成了哪些、下一步该生成什么)、一致性校验(新生成的元素是否与已有元素冲突)、失败恢复(某一步失败时是从头重来还是只重试这一步)。
失败恢复的设计特别重要。推荐的做法是保留已完成的部分,只重试当前步骤,并且重试时把上一次的失败原因带上。这相当于把第十章讲的"重试"思想应用到了序列生成的每一粒度上。如果整个序列从头重来,成本和延迟都会失控。
另一个实践建议是给序列设上限。模型可能生成超出预期的长序列(尤其是当任务描述含糊时),所以应该有一个明确的最大数量,超出就停止并记录,而不是任由它一直生成。
32.4 一个常见的失败模式
序列生成最常见的失败是重复:模型生成到第几个元素时开始重复前面已经生成过的内容,尤其是当元素的模板高度相似时(比如生成十个类似的配置项)。约束解码帮不上太多忙,因为重复的元素在语法上完全合法。
防御手段是在校验层做重复检测,发现重复就拒绝并重试。更好的做法是在生成前就打断这个倾向——比如在上下文里明确"下一个元素必须与前 N 个不同",或者在分次生成时把已有元素的摘要列出来。模型对"不要重复"这种明确指令的遵循度,比我们想象的要高。
三十三、多模态输入下的结构化抽取
前面所有讨论都假设输入是纯文本。当输入是图片、音频或者扫描件时,约束解码依然适用,但会出现一些新的问题,值得单独讨论。
33.1 抽取与识别:两个任务的混合
从图像里抽取结构化信息(比如从发票照片里抽出金额和日期),实际上是两个任务混合在一起:识别(图像里有什么)和抽取(把识别到的内容组织成字段)。约束解码只约束第二个任务。
这个区分很重要,因为它决定了失败归因。当抽取结果错误时,你需要判断是模型没看清图像(识别问题),还是看清了但填错了字段(抽取问题)。这两类问题的解法完全不同:前者要改善图像预处理或者换更强的视觉模型,后者要改善约束和校验。
一个实用的归因方法是先让模型自由描述它看到了什么,再做结构化抽取。如果自由描述里就没有那个字段,那是识别问题;如果自由描述里有但抽取丢了,那是抽取问题。这个两段式流程不仅能归因,还常常能提高抽取质量(第二段可以把描述作为上下文)。
33.2 图像里不可见的内容不能靠约束补
这里有一个必须讲清楚的限制:约束能保证字段存在,但不能保证字段有内容。从一张模糊的照片里抽取发票号,约束会强制模型填写这个字段,但模型可能填一个它"猜"出来的值——因为 schema 说它是必填的。
这会引入一种很隐蔽的失败:约束反而制造了幻觉。在没有约束时,模型可能会输出空值或者明确表示看不清,这其实是诚实的;加了必填约束之后,它必须填一个值,于是开始编造。
这是约束解码唯一一种会加剧错误的场景,而且它不是实现问题,是设计问题。解法是在 schema 里为"看不清"留一个出口——比如允许 null,或者提供一个专门的"不确定"枚举值,或者允许空字符串。约束不能消灭不确定性,但可以给不确定性一个合法的表达方式。这条原则在处理脏数据、缺失数据时同样适用。
33.3 音频与转写场景的特殊性
从音频转写并抽取结构化信息(比如从一段客服录音里抽出工单信息)时,情况类似但更极端:识别错误率更高,而且错误的类型往往是同音字错误——把人名、项目名、数字听错。
约束在这里的作用主要是数字格式:电话号码的长度、工单号的字符集、金额的小数位,这些都能被强制,而且确实能消除相当一部分错误。但人名和项目名这类内容,约束基本帮不上忙——枚举一个公司所有人的名字既不现实也不合理。
对这类内容,更有效的手段是实体链接:先生成描述,再从候选库里匹配,而不是让模型直接写名字。这和第二十五章里巨大枚举的处理方式是同一个思路——用检索替代枚举。
33.4 多模态场景的成本特点
最后一个实务提醒:多模态请求的输入成本远高于纯文本,而失败重试的代价被放大了。如果一次重试意味着重新处理一遍图像,那么第十章说的"期望尝试次数乘以单次成本"这个公式在多模态场景里会变得非常难看。
所以多模态场景的策略应该和纯文本场景不同:更倾向于用更重的约束和校验(因为单次成本高,不能靠重试解决),更少依赖重试。同时应该优先做那个"两段式"流程,因为第一段的结果可以被第二段复用,多个抽取任务也不必重复处理同一张图。
三十四、约束输出驱动状态机
智能体场景里,结构化输出常常不是终点,而是下一段逻辑的输入。这一章讨论一个具体的衔接问题:如何让被约束的输出安全地驱动一个状态机,以及这里的坑比纯抽取场景多在哪里。
34.1 状态转移的四种来源
智能体的下一状态通常来自四个地方,而它们的风险差异很大:模型输出的动作(风险最高,因为它由概率模型产生)、工具的执行结果(相对可控)、环境的被动事件(可信)、超时与降级(必须显式设计)。
约束解码能改善的是第一类:它保证模型输出的动作在结构上合法,比如必须调用一个真实存在的工具、参数必须符合 schema。但它不能保证动作是允许的——一个合法的动作可能违反业务规则(在没有权限时调用某个工具)。所以状态机里必须有一层权限判断,不能因为"结构合法"就直接执行。
34.2 状态机的保护性设计
把约束输出接进状态机时,有四条保护性的设计值得强调:
第一,默认拒绝。状态机遇到无法识别的动作时应该进入一个明确的错误状态,而不是保持原状或者走默认分支。因为"保持原状"在循环里会变成无限重试,而"默认分支"很容易让系统在无人察觉的情况下走向意外路径。
第二,转移必须显式枚举。不要用"任何未匹配的动作都走这条边"这种写法,因为它会把未来的新动作自动归到一条边上去。显式枚举意味着新加动作时必须显式处理,这是有意的摩擦。
第三,不可逆动作需要额外确认。约束保证动作合法,但不能保证动作的时机合适。所以状态机里应该有"待确认"这个中间状态,高风险动作先进这个状态,等外部确认后再真正执行。
第四,状态机的迁移要幂等。如果一个动作因为超时被重试,它不应该被执行两次。约束保证不了这一点,但状态机可以通过记录已执行的动作来保证。
34.3 循环检测
智能体场景下最常见的严重问题是循环:模型反复输出同一个动作,每次的结果都一样,于是无限循环。约束解码对这个问题几乎没有帮助,因为重复的动作在语法上完全合法。
防御手段有三个层次。最轻的是动作去重:检测到连续相同或近似相同的动作就中断。最重的是状态检测:跟踪状态轨迹,如果发现回到了之前访问过的状态,就判定为循环并中断。中间层是进度检测:要求每个动作必须带来某种可观测的进展(比如新增信息、状态变化),如果连续几步没有进展就中断。
其中状态检测最有效,因为它不需要你事先猜"什么样的重复是坏的"。实现上可以用已访问状态的集合,一旦当前状态在集合里就说明回到了旧状态,路径必然成环。
34.4 循环检测与约束的配合
值得注意的是,约束解码可以帮助缩小状态空间,从而让循环检测更容易。如果输出被约束在一个有限的枚举上,那么可能的状态轨迹数量是可控的,用"状态是否重复"来判断循环会非常准确。反之,如果输出是自由文本,状态空间几乎是无限的,任何基于状态的检测都会失效。
这是一个约束解码少被提及的间接收益:它把系统从"开环"变成了"有限状态"的可分析系统。很多在自由输出下只能靠提示词和祈祷来避免的问题,在强约束下变成了可以形式化检测的问题。
三十五、引擎与框架选型对照
第六章给过一个粗略的对照表,这一章把它细化,给出选型时更实用的判断维度。需要提醒的是,框架演进很快,具体的功能支持要以你所用版本的文档和实测为准,这里给的是判断框架而不是功能清单。
35.1 四类方案
第一类是模型服务商的接口能力。由服务端完成约束实现,你只传一份 schema。优势是零运维成本、与模型版本强绑定、延迟最优;劣势是约束细节不透明、支持的 schema 关键字有限、无法定制。绝大多数团队应该从这一类开始。
第二类是推理引擎内置的引导解码。由引擎在采样循环里实现,常见的后端有几种不同实现,覆盖度不同。优势是性能可控、能配合引擎的批量与缓存优化;劣势是需要自己选择和配置后端,版本升级时要注意兼容性。
第三类是独立的约束库。把约束编译和掩码生成封装成库,通过 logits 处理器之类的接口接入。优势是灵活、可复用、可以在不换引擎的情况下使用;劣势是需要自己处理与引擎的集成细节,性能通常不如引擎内置方案。
第四类是更激进的生成框架。它们不仅做约束,还做整个生成过程的控制流管理,支持流式、反向约束、以及把约束和生成逻辑混在一起编排。优势是能力强、能表达复杂的生成条件;劣势是学习成本高、和现有推理栈的集成不如前三类直接。
35.2 选型的六个判断维度
第一,schema 覆盖率。不是看它支持多少关键字,而是看你 schema 里实际用到的关键字有多少能被覆盖。做法很实际:把你的 schema 拿出来,逐个关键字核对。三种结果各有对策——能编译的交给约束;不能编译但重要的,考虑改写 schema 让它变成可编译的形式(比如用联合类型代替条件依赖);不能编译且无法改写的,交给校验。
第二,性能特征。关键不是平均速度而是长尾:约束构造的延迟分布、缓存命中率、以及在高并发下的扩展性。如果你的流量峰值很高,这一项的权重应该提高。
第三,与现有栈的集成成本。如果你已经在用某个推理引擎和某个服务框架,那"能不能只加一个组件"通常比"能力最强"更重要。一个需要重写整个生成流程的方案,即使能力更强也很难被采纳。
第四,可调试性。约束系统出问题时你能不能查清楚?这取决于它是否暴露状态、是否支持打印语法、是否有清晰的错误信息。一个黑盒的约束实现在生产上是危险的,因为它失败时你没有任何抓手。
第五,缓存与状态管理的可控性。能否控制缓存键、能否预热、能否查看命中率,这些在多租户场景里很关键。
第六,维护活跃度与版本稳定性。这个领域变化很快,一个不活跃的实现可能在半年后让你的引擎升级无法进行。选型时应该看看最近的提交频率和版本发布节奏。
35.3 一个务实的推荐路径
如果你不确定该选哪个,我的建议是按这个顺序试:先用服务商接口能力跑通完整链路,在这一步把 schema 收紧、把校验补全、把监控建起来。这一步的价值在于它几乎零成本,却能解决大部分问题。
做完之后看数据:如果格式相关的问题已经很少,而语义问题突出,那就不需要自建约束;如果格式问题仍然存在(通常因为服务商不支持你需要的某些 schema 特性),那就升级到引擎内置方案。
只有当你需要的东西在现有方案里确实做不到时,才考虑第三类和第四类。这个顺序的价值在于它避免了最常见的错误——因为"约束解码听起来很先进"而过早投入自建,最后发现服务商的接口已经够用了。
三十六、可验证生成与自检
前面所有内容都在解决"输出是否合法"。这一章讲一个更进一步的问题:怎么让输出的正确性可验证,而不只是可接受。这在金融、医疗、合规这类领域特别重要。
36.1 三种可验证性
第一种是语法可验证。输出能被确定性地解析,这由约束解码保证。它的特点是验证成本极低、结论确定,但保证的信息量有限。
第二种是约束可验证。输出满足一组显式的业务规则,这由校验器保证。它的特点是规则可以被审计(每条规则都能追溯到一条业务规定),但规则本身可能写错。
第三种是来源可验证。输出的每一个值都能追溯到它的依据,比如原文的某一段或者某个数据源。这需要额外的设计——通常是在 schema 里要求每个值附带一个引用字段,然后在应用阶段检查引用的有效性。
第三种在很多场景下是最有价值的,因为它把"相信模型"变成了"检查引用"。模型的错误率可能不低,但如果每个断言都附带依据,那么人工或者程序只需要抽查依据,审查成本会大幅下降。
36.2 让模型自检
还有一个成本很低但效果不错的做法:让模型在生成之后自己检查一遍。具体做法是在生成之后追加一次简短的检查,让模型对照 schema 判断自己的输出是否有问题,发现问题就修正。
这个方法的有效性依赖于一个前面提过的性质:模型对自己刚生成的内容有很强的局部注意力,所以自检往往能发现主生成时忽略的问题。它对"字段缺失"、"值不符合枚举"这类问题特别有效。
但要小心两个陷阱:一是模型可能过度自信地确认错误输出(说"看起来没问题"但其实有问题),所以自检不能替代校验器,只能作为补充;二是自检会显著增加延迟(相当于多一次生成),所以只有在错误代价足够高时才值得开。
36.3 用校验器替代自检
正因为自检不可靠,凡是能用确定性校验的地方,就不要用模型自检。这一条在实际项目里被反复违反,代价是延迟翻倍而可靠性并没有提升。
判断的标准很简单:如果你能为这个问题写出一条确定性的规则,那就写规则;如果写不出来(比如"这段摘要是否抓住了重点"),那可以考虑用模型判断。结构化输出领域里绝大多数约束都属于第一类,所以自检的适用范围其实很窄。
36.4 可验证性对架构的反向约束
最后有一个不那么明显的结论:想清楚要验证什么,会反向决定 schema 怎么设计。如果你希望输出的值可追溯,那 schema 里就应该有引用字段;如果你希望验证跨字段一致性,那相关的字段就应该放在一起而不是散落;如果你希望人工快速审查,那字段的顺序和分组就应该按审查的逻辑组织。
这意味着可验证性的要求应该在设计 schema 的时候就纳入,而不是等到出了问题再补。事后给结构化输出加验证通常很痛苦,因为你既拿不到原始依据,也无法让已经上线的输出补上缺失的字段。
三十七、结构化数据作为可观测信号
这一章讲一个常被忽略的用途:把结构化输出用作可观测性信号。它的价值不在于内容本身,而在于因为结构可靠,所以这些数据可以被稳定地采集、聚合和告警。
37.1 结构化日志的优势
传统的做法是从模型输出里用正则抽取关键字段,然后统计。这有两个问题:抽取会失败(正则匹配不上),而且抽出来的字段结构不稳定(有时有有时没有),导致聚合时大量数据被丢弃。
有了约束解码之后,你可以对日志内容本身也做结构化输出——让模型除了回答用户,还输出一个固定格式的分析结果,比如意图分类、是否涉及敏感话题、置信度。这样得到的日志可以直接入库、直接聚合,不需要任何后处理。
37.2 可以结构化的运行指标
如果日志是结构化的,你能统计出很多传统上只能靠人工判断的指标。比如通过意图分布的突变发现异常流量,通过某类输出的比例突然升高发现模型行为漂移,通过输出字段的平均长度变化发现输入分布变化。
这类指标的共同特点是:它们衡量的是"模型在想什么"而不是"模型说了什么",因此能在用户投诉之前很久就发出信号。而这恰恰是非结构化日志做不到的,因为自由文本无法稳定聚合。
37.3 让可观测性成为约束的驱动力
这里有一个很好的正反馈:因为你想采集这些指标,你需要一个稳定的结构;因为有了稳定的结构,采集变得容易,于是你会更倾向于把它做成强约束而不是可选输出。最终结果是可观测性和可靠性一起提升了。
反过来也成立:如果你的可观测性依赖可选的输出字段,那么在模型偷懒不输出的时候,指标会静默失真,而你会误以为"今天没有异常"。可观测性依赖的字段,必须是必填且强约束的,否则观测本身就会有盲区。
37.4 采集这些信号的成本
最后提一下成本。结构化日志意味着每次生成都要额外输出一部分内容,这会增加输出长度和延迟。判断标准是这个信号的采集成本是否值得:如果它能提前一周发现一次严重故障,那么每天多花的几毫秒是极其划算的;如果它只是一个"看起来有意思"但没人会看的指标,那就不值得。
务实的做法是从最有价值的一两个信号开始——通常是意图分布和错误类型分布——先确认它们确实能被消费(有对应的看板或者告警),再逐步扩展。把所有可能的字段都做成必填,是一种常见的浪费。
三十八、推理预算与格式约束的取舍
带推理能力的模型给结构化输出带来一个新的变量:模型会先花一部分预算思考,再生成输出。这两段预算之间的关系,直接影响约束解码的效果和成本,这一章专门讨论。
38.1 推理段与输出段的约束是分开的
关键的一点是:推理过程通常不受结构约束,只有最终输出受约束。这带来一个很好的性质——推理可以自由展开,把复杂的判断过程想清楚,而最终输出依然是一个严格的结构。
这比"强制整个输出都是 JSON"要好得多。后者会让模型在每一步都背着格式负担,思考质量下降。用推理加约束的组合,等于把"想清楚"和"说清楚"这两件事彻底分开,各自用最适合的方式处理。
38.2 推理预算被格式错误吃掉的情况
但也存在一种浪费:如果模型的推理预算固定,而它花了很多 token 去纠结格式(比如反复确认某个字段该不该填),那这些预算就没有用在真正的业务判断上。约束解码的一个间接收益就是把格式相关的思考彻底省掉,因为格式不再需要思考。
怎么验证这一点?做法是在有无约束两种情况下对比推理段的长度。如果有约束时推理明显变短,说明之前确实在纠结格式;如果长度差不多,说明模型本来就没怎么纠结格式,此时约束的主要收益是可靠性而不是省 token。
这个对比值得做,因为它能帮你在"提高可靠性"和"降低成本"之间做取舍决策。如果推理长度没有明显变化,那约束的成本就纯粹是掩码开销,很低;如果明显变短,那约束还额外降低了推理成本,收益更大。
38.3 推理结果与结构化字段的一致性
一个常见的问题是:推理里得出的结论和最终填进字段的值不一致。比如推理时说"应该是 3 厘米左右",但字段里填了 5。原因是两个阶段之间没有强制的对应关系。
解决办法有两个。一个是在推理里强制引用字段名,也就是让模型在思考时就明确说出每个字段应该取什么值,然后再填充;另一个是在校验层做一致性检查,但这很难做,因为推理段是自由文本,把它和字段做语义对齐本身就是一次模型调用。
第一个办法更实用。它的实现方式是调整提示结构,让推理阶段就按照最终字段的顺序逐个说明取值理由。这样做的一个额外好处是推理质量往往更高——因为模型被迫对每个字段都给出理由,那些没想清楚的字段会暴露出来。
三十九、模型选型时的约束支持度评估
模型换代很快,选型时如果只看上下文长度和通用能力,很容易漏掉一个对结构化输出至关重要的维度:它对约束的服从度有多高。这一章给一个可操作的评估方法。
39.1 用被剪掉的概率质量做横向比较
前面讲过,约束会把模型"想说的话"里不合法的部分剪掉,剪掉的比例就是被剪掉的概率质量。这个量在不同模型之间的差异很大,而且它直接反映了模型对格式的内在倾向。
评估方法很简单:同一份 schema,跑同一批提示,在每个模型上记录被剪掉的概率质量的平均值。数值越低,说明模型越"愿意"按格式来,约束带来的分布扰动越小,输出质量损失越小。数值高的模型在结构化任务上表现可能反而不如数值低的,因为它一直在和约束对抗。
这个指标比"格式遵循率"更有价值,因为格式遵循率可以在有约束和无约束两种情况下比较,而被剪掉的概率质量提供了一个不依赖成功率的、连续的质量信号。
39.2 必填字段的填充完整度
第二个评估维度是必填字段的填充完整度:在约束保证字段一定出现的前提下,统计其中有多少被填成了空字符串或者无意义的占位值。这个指标衡量的是模型"真的想填"还是"只是被迫留下位置"。
不同模型在这个指标上的差距往往比格式遵循率更大,因为格式遵循率在有约束时都接近满分,而这个指标能把差别暴露出来。一个实用经验是:如果某个模型的空值率明显偏高,说明它对某些字段没有真正的输入来源,此时换约束方案不如改 schema 或者补上下文。
39.3 稳定性:同输入多次生成的差异
第三个维度是稳定性。做法是固定输入和参数,重复生成若干次,统计结果的差异程度。如果同一个模型的两次输出差异很大,那么无论平均质量多好,它在生产里都会带来麻烦——因为你无法复现问题。
这个测试要特别控制温度变量,用较高的温度来测试更能暴露不稳定性。很多模型在低温下表现很好,但一旦生产环境因为多租户混合负载而无法保证稳定的批处理条件,结果就会变化。约束解码能提升结构稳定性,但不能提升内容稳定性,所以这个维度要单独测。
四十、团队协作与代码组织
约束解码最终是团队里多个人共同维护的系统。这一章讲一些组织层面的约定,它们的价值在于避免长期的混乱,而不是解决具体的技术问题。
40.1 schema 必须是单一事实来源
最常见的技术债是schema 有三个副本:一份用于生成提示、一份用于编译约束、一份用于运行时校验。如果这三份是人工同步的,迟早会漂移。
解决办法是让它们都从同一个源生成。这个源可以是接口定义文件,也可以是类型定义,但必须是一个源。生成的时候把三者一起产出,并且让它们在同一个提交里更新,这样 review 的时候能一眼看出约束和校验是否同步。
40.2 约束相关的约定要写下来
有几条约定值得明确写进团队的文档,因为它们都属于"不写下来每个人都会踩"的那一类:缓存键必须包含哪些维度(引擎版本、模型标识、schema 版本)、降级结果必须携带的标记、重试次数由谁决定(应该由调用方按延迟预算指定,而不是库内部默认)。
这些约定如果没有文档,很可能在人员变动后丢失,然后以 bug 的形式重新出现。写下来的成本是半小时,省下的是未来无数次的排查。
40.3 分层错误的处理方式
还有一个组织层面的建议:区分"格式失败"和"语义失败",并且让它们对应不同的处理路径。格式失败应该重试(因为约束应该让它不可能发生,如果发生了说明有 bug 要查),语义失败应该走业务降级或者交给人工。
把这两类混在一起处理(比如都抛同一个异常、都走同一个重试逻辑),会让问题定位变得困难——你分不清这次失败是约束层的 bug 还是业务逻辑的正常拒绝。这一条在排障时价值很高。
40.4 定期回顾被丢掉的 schema 关键字
最后建议建立一个小机制:定期检查编译过程中被丢弃的 schema 关键字(第六章提到过编译器会把编不了的关键字记录下来)。这个列表的变化能反映 schema 的演进方向,也能提醒你是否有新的业务规则可以被编译进约束。
这个机制的另一个用途是发现 schema 设计的问题:如果同一个关键字(比如取值范围限制)在被丢弃的列表里反复出现,说明它是一个重要的、被反复违反的规则,值得花力气想办法约束它——比如考虑换一种能表达的 schema 写法。
四十一、上线前的检查清单
按顺序过一遍,每一项都能在十分钟内验证:
- 能否离线复现失败?把你见过的失败样本存成测试集(哪怕只有 20 条)。没有这组数据,后面所有优化都是盲改。
- 约束是否真的生效?故意写一个错误的约束(比如把 enum 写少一个值),看模型能不能输出那个被漏掉的值。如果能,说明约束根本没起作用——这是最常见的"以为有约束其实没有"的情况。
- mask 应用是否在正确位置?在 mask 前打印一次、mask 后打印一次,确认所有非法 token 的 logit 确实是
-inf,且合法 token 没有被误杀。 - tokenizer 边界测试。取一批真实输出片段,反向 tokenize,看有没有 token 跨越语法边界。有的话,你的首字符判定法就是有 bug 的。
- max_tokens 合理性。生成 100 个合法样本,统计 token 长度分布,取 P99 再乘 2 作为预算下限。
- 三个失败率分别埋点。parse 失败率、repair 率、语义校验失败率,单独看,单独设告警。
- 重试次数按延迟预算反推。而不是拍脑袋。先定 P99 SLA,再算次数。
- 降级路径有开关。能一键关掉降级(返回错误而不是降级数据),方便在发现问题时快速止血。
- schema 编译有缓存。且缓存 key 包含 schema 内容哈希。监控编译耗时和缓存命中率。
- 压测分三档。低/中/高并发分别测吞吐与 P99,确认 CPU 掩码没有成为瓶颈。
四十二、总结
回到最开始的那个判据:让输出可解析,本质上是一个确定性的工程问题,应该用确定性的机制解决,而不是用概率性的提示词去祈祷。三层兜底的分工是这样的——
第 1 层(约束解码)负责"结构上不可能错"。它把可解析率从"95% 且不可复现"提到"100% 且有 CI 可守",代价是每步的掩码开销和对吞吐的 20%~60% 影响。它治不了语义,但语义本来也不是它该管的。
第 2 层(校验 + 有限重试)负责"内容必须合理"。它接住约束编不出来的那部分语义约束(依赖、范围、跨字段一致性),代价是失败时的倍数级延迟。它是最容易做过头的一层——重试次数应该由延迟预算反推,而不是越多越好。
第 3 层(降级解析)负责"失败时也别全丢"。它只在低代价场景开启,且必须带降级标记。它的价值在于把"硬失败"变成"有置信度的部分结果",但它的风险也最高——一旦调用方无视标记,失败就会被静默伪装成成功。
如果只记一条:先开第 1 层,把预算花在让成功率上去,而不是花在加大重试次数上。因为 p 从 0.85 提到 0.95 能省下一整次重试(延迟减半),而把 N 从 3 提到 5 只换来 0.01 的成功率提升(P99 却涨了 67%)。这是这一整套方法里最反直觉、也最值钱的一个结论。
最后提一句前沿:约束解码的下一步大概率是把语义约束也搬进解码期——比如让解码器在"取到这个字段的值"时,反向查询数据库,把合法值集合动态注入掩码。这类"带外部状态反馈的约束"已经在一些框架里有了雏形,但复杂度(外部延迟、缓存一致性、多轮状态)远高于纯语法约束。如果你正在做的是"模型输出必须严格落在业务允许的取值空间内",这个方向值得盯一眼。