8.0
热点
AI SCORE
技术实践2026-08-02 01:33
LLM JSON 输出:Prompt 不如解码约束可靠
dev.to · AI#LLM提示工程#JSON解析#实验验证
Editor brief · 编辑速览
通过实验对比 Prompt 和 Constrained Decoding 对 JSON 输出的影响,揭示模型知道有效 JSON 格式但未必生成符合解析器期望的输出。
一个模型即使知道合法 JSON 长什么样,仍然可能生成一段会被解析器拒绝的响应。
例如,下面每个响应都包含一个看起来合法的 JSON 对象。
Here is the result:
```json
{"answer": "72"}
{"answer": "72"}
The calculation is 48 + 24.
{"answer": "72"} {"answer": "72"}
人可以立刻识别出答案。
但一个期望输入恰好为单个 JSON 值的程序却做不到。
这正是我想进一步弄清楚的区别:
Prompt 改变的是哪些输出更有可能出现。约束解码改变的是哪些输出被允许出现。
我围绕 Qwen2.5-0.5B-Instruct 搭建了一个小型本地实验室,从 tokenizer、生成、验证和 logit 处理几个层面观察这种区别。
仓库中包含实验脚本、原始生成结果、观察记录以及 JSONL 格式的评估日志:
github.com/Vaibhav701161/constrained-decoding-lab
我针对三道简单的小学数学题,对比了两种生成条件。
自由形式的 prompt 要求模型展示简短的推理过程,并以一个数字答案结尾。
结构化 prompt 则要求模型只返回一个 JSON 对象:
{ "reasoning": "your reasoning here", "answer": "final numeric answer here" }
生成过程是确定性的,使用了:
do_sample=False
对于每个示例,评估脚本都会记录:
- prompt 和原始输出
- 解析后的表示
- 精确匹配的正确性
- prompt token 数和生成 token 数
- 解析或验证错误
最初的冒烟测试结果是:
这个结果不应被视为基准测试。
三个手工挑选的示例远远不够,而且提取逻辑有意保持得非常简单。
不过,其中暴露出的失败模式仍然很有价值。
使用 JSON prompt 生成的输出包括:
- 看起来合法的 JSON 后面跟着普通文本
- 一个响应中包含多个 JSON 对象
- 一个完整对象生成结束后,模型仍继续生成内容
在一些案例中,答案明明清晰可见。
但完整响应仍然无法通过 JSON 解析。
这正是仅依赖以下指令的实际局限:
Return ONLY valid JSON. No markdown. No extra text.
这些指令可以提高模型生成合规输出的概率。
但它们并不会从模型可能选择的解码路径中移除不合规输出。
## 验证发生在错误之后
JSON 解析器可以在生成完成后告诉我们输出无效。
Schema 验证器可以告诉我们,解析后的对象包含错误的 key 或错误的值类型。
恢复逻辑或许能够从一段更长的响应中提取出第一个完整对象。
这些技术都很有用,但它们解决的是另一个问题。
生成后验证问的是:
模型生成的内容可以接受吗?
约束解码问的是:
模型接下来可以合法生成哪些 token?
前者检测失败。
后者试图防止结构性失败。
这并不意味着约束解码会自动让答案变得正确。
它只意味着生成的序列被强制限制在某种定义好的语言之内。
一个完全合法的 JSON 对象,仍然可能包含错误答案。
结构有效性与语义正确性是两个独立的评估维度。
## 接触解码循环
为了理解其底层机制,我实现了一个小型 Hugging Face `LogitsProcessor`。
这个处理器会扫描 tokenizer 的词表,记录那些单独解码后的文本中包含数字的 token ID:
for token_id in range(len(tokenizer)): piece = tokenizer.decode( [token_id], skip_special_tokens=False, )
if any(character.isdigit() for character in piece):
self.banned_token_ids.append(token_id)
在每一个生成步骤中,它都会修改这些 token 的分数:
scores[:, self.banned_token_ids] = -float("inf")
经过 softmax 后,这些 token 的概率会变为零。
解码器无法选择它们。
这不是一个受语法约束的解码器。
它只是一个静态 token mask,用来直接观察解码阶段的基本操作。
实验结果并不只是简单的“模型不再生成数字”,而是更有意思。
模型避开了普通的 ASCII 数字,却生成了带圈数字等类似数字的 Unicode 字符。
格式和输出质量也随之下降。
这暴露出了两个重要问题。
## 约束执行的是它的实现,而不是它的意图
“阻止模型输出数字”是一种非形式化的意图。
“将单独解码后的字符串中包含数字的 token,其 logits 设置为负无穷”则是一种具体实现。
二者并不等价。
数字还可以通过以下方式表示:
- 其他 Unicode 数字字符
- 在视觉上类似数字的符号
- 被简单词表扫描遗漏的 token 组合
解码器只能保证实际编码进系统的规则得到执行。
当人们声称约束解码可以保证输出有效时,这一点尤其重要。
这种保证取决于:
- 实际执行的语法
- tokenizer 的表示方式
- byte 和 Unicode 的处理方式
- 对接受状态的定义
一个范围过窄或存在错误的形式化定义,可能会为错误的语言提供非常强的保证。
## 硬约束可能损害输出质量
原始模型的概率分布可能会将大部分概率质量集中在少数几个自然的续写选项上。
当这些续写被 mask 后,解码器只能从剩余选项中选择。
剩下的 token 也许在结构上合法,但可能很别扭、概率很低,或者语义质量很差。
因此,只通过有效率来评估约束解码是不完整的。
一次有价值的对比还应该衡量:
- 生成的 token 数
- 被移除的概率质量
- 在不同 schema 和 prompt 风格下的行为
一个系统可以生成 100% 语法合法的 JSON,但在实际任务上的表现仍然更差。
## 真正的难点:语法作用于 byte,模型输出 token
将选定的 logits 设置为负无穷并不困难。
真正困难的是判断应该 mask 哪些 logits。
语法通常定义在字符或 byte 之上。
语言模型输出的则是 token。
一个 token 可能同时包含:
- 空白和标点
- 一个多 byte 字符的一部分
- 行为取决于前后 token 的片段
假设解析器当前允许一个以 `0` 开头的数字。
合法词表中可能包含表示以下内容的 token:
0 0. 0.4 0.46 0} 0,
每个 token 是否合法,取决于语法和解析器的当前状态。
仅仅判断一个 token 是否等于下一个合法字符是不够的。
解码器必须模拟消费这个 token 的完整 byte 序列。
current parser state ↓ candidate token bytes ↓ simulate every parser transition ↓ keep the token or mask it
只有当一个 token 的完整 byte 序列能够被消费,且不会导致语法失效时,它才应该继续保持可用。
生成一个 token 后,解析器的状态就会发生变化。
因此,必须重新计算合法词表集合。
这让真正的约束具备了状态依赖性。
静态数字禁令会在每个生成步骤中使用相同的 mask。
而 JSON 语法在以下内容之后需要使用不同的 mask:
{
{"answer":
{"answer":"72"
合法的后续内容取决于解析器当前所处的位置。
## 为什么逐个检查所有 token 的朴素做法代价高昂
现代 tokenizer 可能包含数万乃至数十万个 token。
如果在每个生成步骤中都用解析器测试所有 token,就会引入巨大的额外开销。
这正是 token trie 和缓存解析器状态转换等技术可以发挥作用的地方。
token trie 会按照共享的 byte 前缀对词表中的 token 进行分组。
解码器不需要将每个 token 分别送入解析器重放,而是可以只遍历一次共享前缀,并在某个前缀失效时立即剪掉整个分支。
高层循环会变成:
1. 读取解析器的当前状态。
2. 遍历可能的 token byte 前缀。
3. 剪掉违反语法的分支。
4. 收集在可行分支上结束的 token ID。
5. mask 词表中的其余 token。
6. 推进解析器状态。
7. 重复上述过程,直到到达接受状态。
当涉及递归结构、字符串、转义、数字、UTF-8 和存在歧义的解析器状态时,实现会变得更加复杂。
但其核心映射关系始终不变:
解析器状态 → 合法 token ID
## 这个实验不能证明什么
当前实验设置存在一些局限。
三个示例足够用于冒烟测试,但不足以比较不同的解码策略。
### JSON 提取器有意设计得严格且简单
它会截取第一个左花括号与最后一个右花括号之间的内容。
因此,多个合法对象会被合并成一个无效候选结果。
更完善的评估应该分别报告以下指标:
- 完整响应的有效性
- 第一个对象的可恢复性
### 自由形式提取器并不可靠
它会选择输出中的最后一个数字。
如果答案后面又出现了额外的数字文本,就可能产生错误评分。
### 模型没有通过自己的 chat template 接收 prompt
原始 prompt 被直接传给了一个经过 instruction tuning 的模型。
有必要将这种方式与 tokenizer 预期的 chat template 进行比较。
### token mask 并非 byte 级精确
单独解码词表中的每个 token 对观察分析很有用。
生产级实现则应该仔细处理底层 token byte 和 UTF-8 行为。
### 约束是静态的
数字处理器演示的是 logit masking,而不是由解析器驱动的约束解码。
这些局限并不是无关紧要的旁枝末节。
它们决定了我们可以从实验中得出哪些结论,又不能得出哪些结论。
## 下一步实现
下一个有价值的里程碑,是为一种受限输出语言实现一个小型、状态依赖的解码器,例如:
{"answer":"<digits>"}
这个实现应该:
- 将该语言定义为自动机或解析器
- 将 tokenizer ID 映射为 byte 序列
- 跟踪解析器的当前状态
- 模拟候选 token 的 byte
- 只允许能够维持有效性的 token
- 仅在接受状态中停止
- 在每一步记录 mask 大小和延迟
实现之后,就可以对比:
- regex 或有限状态约束
- JSON Schema 约束
- 不同的 token 过滤策略
在对比过程中,应尽可能控制模型、数据集、prompt 内容和语义评分方法保持一致。
## 真正有用的区别
“只返回合法 JSON”仍然是一条有用的指令。
它可以减少失败、提升可读性,并让模型预期的输出形式更加清晰。
但它并不是解码约束。
Prompting 是要求模型遵循某种结构。
验证是检查模型是否做到了。
约束解码则会移除违反该结构的续写选项。
这些方法可以相互配合,但它们提供的保证并不相同。
该实验的代码、原始输出和笔记可以在这里找到:
Constrained Decoding Lab
如果要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。