logo蛋烘糕.

不写博客的工程师不是好的产品经理

AI 技术演进与核心算法实战 | 第九篇:结构化输出控制:Grammar Constrained Decoding (CFG) 与 JSON Schema 强制约束原理

Cover Image for AI 技术演进与核心算法实战 | 第九篇:结构化输出控制:Grammar Constrained Decoding (CFG) 与 JSON Schema 强制约束原理
蛋烘糕
蛋烘糕

从这一篇开始,我们进入第二模块的深水区:如何像操作机器一样精确控制大模型的输出。而结构化输出控制,就是打开这扇门的钥匙。

在 [上一篇](…/7.AI%20 技术演进与核心算法实战%20-%20 第七篇:提示词的数学本质:In-Context%20Learning%20 的贝叶斯解释与概率分布操控/index.md) 中,我们揭示了提示词如何通过贝叶斯推断来"操控"概率分布。但一个现实问题始终存在:即使概率再高,模型仍然可能"抽风"输出错误格式

想象你在开发一个 API 接口,需要模型返回 JSON 格式的数据。你用了各种提示词技巧:

  • “请用 JSON 格式返回”
  • “严格按照以下 Schema 输出”
  • 甚至提供了完美的示例

但模型还是会偶尔输出:

{
  "name": "张三",
  "age": 25,
  // 忘记闭合括号

或者更糟糕的是,直接输出一段自然语言解释!

本篇是 《AI 技术演进与核心算法实战》第二模块的第三篇。我们将深入剖析 Grammar Constrained Decoding(语法约束解码,简称 GCD) 的核心原理,并手写实现一个支持 JSON Schema 强制约束的解码引擎。


1. 从"自由创作"到"戴着镣铐跳舞"

1.1 一个思想实验:填空题 vs 作文题

让我们通过一个思想实验来理解结构化输出的必要性。

场景一:作文题(自由生成)

题目:"春天的故事"
要求:写一篇 800 字的文章

这种情况下,模型可以自由发挥——可以从任何角度切入,用任何文体,讲任何故事。这就像标准的语言模型生成:每个 token 都从整个词表中选择,可能性空间是 VnV^n(V 是词表大小,n 是生成长度)。

场景二:填空题(约束生成)

题目:请填写以下表格
姓名:____
年龄:____岁
职业:____

这时模型的自由度被严格限制了——姓名处只能填人名,年龄处只能填数字,职业处只能填职业名词。可能性空间从 VnV^n 骤减到一个很小的子集。

场景三:编程题(强约束生成)

题目:实现一个函数,满足以下类型签名
def add(a: int, b: int) -> int:
    ____

这是最强的约束——模型不仅要填充内容,还必须严格遵守语法结构。多一个空格、少一个冒号都会导致代码无法运行。

Grammar Constrained Decoding 要做的,就是把"作文题"变成"填空题"或"编程题"——在保持模型智能的同时,确保输出 100% 符合预期格式

1.2 为什么提示词不够用?

在深入技术之前,先回答一个关键问题:既然提示词已经能影响概率分布,为什么还需要 GCD?

让我们用数学语言来描述这个问题。

假设词表大小 V=50,000V = 50,000,模型要生成一个合法的 JSON 对象。在没有任何约束的情况下:

  • 第一个 token 可以是 50,000 种可能
  • 第二个 token 也可以是 50,000 种可能

但实际上,合法的 JSON 只占所有可能序列的极小一部分。根据研究,随机生成的 token 序列中,只有约 10810^{-8} 能构成合法的 JSON。

提示词的作用是提高合法序列的概率,但无法完全禁止非法序列。就像一个老师反复强调"考试要认真审题",但学生仍然可能粗心大意。

GCD 的作用则是直接在生成过程中屏蔽非法选项——就像考试时只给你正确的答题卡,让你连"涂错卡"的机会都没有。

1.3 GCD 的应用场景

结构化输出控制在实际应用中无处不在:

场景 约束类型 为什么需要 GCD
API 数据返回 JSON Schema 下游系统需要严格的字段类型和结构
代码生成 编程语言语法 编译错误会导致整个系统崩溃
数据库查询 SQL 语法 错误的 SQL 会引发安全漏洞
表单填写 正则表达式 手机号、邮箱等格式必须标准化
知识图谱构建 RDF/Turtle 语法 三元组结构必须严格遵循本体定义

2. 形式语言理论基础:从正则表达式到 CFG

在深入 GCD 之前,我们需要补一些形式语言理论的基础知识。别担心,我会用最通俗的方式讲解。

2.1 乔姆斯基层级:语言的"复杂度阶梯"

语言学家诺姆·乔姆斯基(Noam Chomsky)将形式语言分为四个层级,从简单到复杂:

Type-3:正则语言(Regular Language)

  • 特点:可以用正则表达式描述
  • 例子:手机号匹配 ^1[3-9]\d{9}$
  • 识别工具:有限状态自动机(DFA/NFA)
  • 能力限制:无法处理嵌套结构(如括号匹配)

物理意义:正则语言就像一个"没有记忆"的机器——它只能记住当前状态,无法记录历史信息。所以它无法判断 "((()))" 是否合法(因为需要数括号的数量)。

Type-2:上下文无关语言(Context-Free Language, CFL)

  • 特点:可以用上下文无关文法(CFG) 描述
  • 例子:JSON、XML、编程语言的语法
  • 识别工具:下推自动机(PDA)—— 带"栈"的自动机
  • 核心能力:可以处理递归和嵌套结构

物理意义:CFG 就像一个"有短期记忆"的机器——它可以用栈来记录历史信息。看到左括号就压栈,看到右括号就弹栈,最后栈空就说明括号匹配成功。

Type-1:上下文相关语言(Context-Sensitive Language)

  • 特点:产生式规则依赖于上下文
  • 例子:某些自然语言现象(如主谓一致)
  • 识别工具:线性有界自动机
  • 实际应用:较少用于 GCD,因为计算复杂度高

Type-0:递归可枚举语言(Recursively Enumerable)

  • 特点:最强大的语言类,包括所有可由图灵机识别的语言
  • 例子:任意程序代码
  • 识别工具:图灵机
  • 实际应用:理论上最强,但实际不可判定

为什么重点讲 CFG?

因为绝大多数实用的结构化格式(JSON、XML、SQL、编程语言)都是上下文无关语言。掌握 CFG,就掌握了 90% 的实际应用场景。

2.2 CFG 的核心概念:四元组 (V,Σ,R,S)(V, \Sigma, R, S)

CFG 由四个部分组成,我们用 JSON 语法的简化版来举例说明:

1. 终结符集合 Σ\Sigma(Terminal Symbols)

终结符是最终会出现在输出中的基本符号,相当于语言的"单词"。

对于 JSON 来说,终结符包括:

  • 字面量{, }, [, ], :, ,, ", true, false, null
  • 数据类型:字符串、数字

比喻:终结符就像乐高积木的基础零件——它们是不可再分的最小单元。

2. 非终结符集合 VV(Non-terminal Symbols)

非终结符是语法结构的抽象名称,不会出现在最终输出中。

对于 JSON 来说,非终结符包括:

  • JsonObject:表示整个 JSON 对象
  • JsonArray:表示 JSON 数组
  • KeyValuePair:表示键值对
  • Value:表示任意类型的值

比喻:非终结符就像乐高的设计图纸——告诉你如何用基础零件拼装出复杂结构。

3. 产生式规则 RR(Production Rules)

产生式规则定义了如何从非终结符展开为终结符或其他非终结符

例如 JSON 的规则:

JsonObject → '{' KeyValuePairList '}'
JsonArray → '[' ValueList ']'
KeyValuePair → STRING ':' Value
Value → JsonObject | JsonArray | STRING | NUMBER | true | false | null

物理意义:产生式规则就是展开的步骤说明书——告诉你每一步可以做什么替换。

4. 起始符号 SS(Start Symbol)

起始符号是推导的起点,通常是最高层的语法结构。

对于 JSON,起始符号就是 JsonObject

推导过程示例

JsonObject
→ '{' KeyValuePairList '}'                    (应用规则 1)
→ '{' KeyValuePair ',' KeyValuePairList '}'   (展开 KeyValuePairList)
→ '{' STRING ':' Value ',' KeyValuePairList '}' (展开 KeyValuePair)
→ '{' "name" ':' STRING ',' KeyValuePairList '}' (Value → STRING)
→ '{' "name" : "张三" ',' KeyValuePairList '}'    (STRING → "张三")
→ ... (继续展开直到所有非终结符都变成终结符)
→ '{' "name": "张三", "age": 25 '}'              (完成)

2.3 可视化:CFG 的推导树

CFG 的推导过程可以用一棵树来表示,称为解析树(Parse Tree)语法树(Syntax Tree)

JsonObject { KeyValueList } KeyValuePair , KeyValuePair STRING : Value STRING 非终结符 终结符

图解说明: 这是一棵典型的 CFG 推导树,展示了 JSON 对象 {"name": "张三"} 的生成过程:

  1. 根节点:起始符号 JsonObject
  2. 内部节点:非终结符(蓝色框),需要继续展开
  3. 叶子节点:终结符(黄色框),构成最终的输出字符串
  4. 推导顺序:从根到叶,从上到下,每一步都应用一个产生式规则

关键洞察:GCD 的核心思想就是在生成过程中维护这棵推导树,确保每一步展开都符合 CFG 规则。如果某一步会导致语法错误,就直接禁止那一步的生成。


3. Grammar Constrained Decoding 核心原理

理解了 CFG 基础,现在我们可以深入 GCD 的工作原理了。

3.1 GCD 的基本思想:给生成过程装上"护栏"

标准自回归生成的过程是:

for each position t:
  1. 计算 logits: h_t = Model(x_{<t})
  2. 采样 token: x_t ~ Softmax(h_t)
  3. 追加到序列:x = x + [x_t]

GCD 在这个流程中插入了一个约束检查器

for each position t:
  1. 计算 logits: h_t = Model(x_{<t})
  2. 【新增】获取合法 token 集合:allowed = GrammarChecker.check(x)
  3. 【新增】mask 非法 token: h_t[~allowed] = -∞
  4. 采样 token: x_t ~ Softmax(h_t)
  5. 追加到序列:x = x + [x_t]

关键步骤详解

步骤 2:GrammarChecker.check()

  • 输入:当前已生成的序列 x
  • 输出:布尔向量 allowed,长度为词表大小 V
  • 含义:allowed[i] = True 表示第 i 个 token 在当前上下文中是语法合法的

步骤 3:Masking

  • 将非法 token 的 logits 设为 -\infty
  • 经过 Softmax 后,这些 token 的概率变为 0
  • 模型绝对不可能选择这些 token

比喻:就像开车时的车道保持系统——当你快要偏离车道时,系统会自动修正方向盘,不让你越线。

3.2 下推自动机:CFG 的"执行引擎"

要实现 GrammarChecker,我们需要一个能"理解"CFG 的机器。这就是下推自动机(Pushdown Automaton, PDA)

PDA 的结构

PDA 由三部分组成:

  1. 有限状态控制器:类似 DFA 的状态机
  2. 栈(Stack):无限容量的后进先出(LIFO)存储器
  3. 转移函数δ(state,input,stack_top)(new_state,stack_op)\delta(state, input, stack\_top) \rightarrow (new\_state, stack\_op)

工作流程

初始化:state = q0, stack = [Z0]  # Z0 是栈底标记
for each input symbol a:
  读取当前状态 state 和栈顶符号 X
  查找转移函数:δ(state, a, X) = (state', op)
  执行栈操作 op(压栈/弹栈/不变)
  更新状态:state = state'

PDA 如何识别 CFG?

以括号匹配为例(最简单的 CFG):

文法:S → '(' S ')' S | ε

PDA 的执行过程:

输入:"(()())"

Step 1: 读入 '('
  - 栈顶:Z0
  - 动作:压栈 '('
  - 栈:['(', Z0]

Step 2: 读入 '('
  - 栈顶:'('
  - 动作:压栈 '('
  - 栈:['(', '(', Z0]

Step 3: 读入 ')'
  - 栈顶:'('
  - 动作:弹栈(匹配成功)
  - 栈:['(', Z0]

Step 4: 读入 '('
  - 栈顶:'('
  - 动作:压栈 '('
  - 栈:['(', '(', Z0]

Step 5: 读入 ')'
  - 栈顶:'('
  - 动作:弹栈
  - 栈:['(', Z0]

Step 6: 读入 ')'
  - 栈顶:'('
  - 动作:弹栈
  - 栈:[Z0]  ← 栈空,接受!

3.3 增量式解析:如何在生成时实时检查?

GCD 的挑战在于:我们不是事后检查整篇文章是否合法,而是在生成每个 token 时就要决定哪些 token 是合法的

这就需要**增量式解析(Incremental Parsing)**技术。

Earley 解析算法简介

Earley 算法是一种高效的 CFG 解析算法,特别适合增量式场景。它的核心思想是维护一个状态集合,每个状态表示"当前可能正在进行的推导"。

状态的三元组表示

[A → α • β, i, j]
  • A → αβ:一个产生式规则
  • :当前位置(已经处理完α,准备处理β)
  • i:这个推导从位置 i 开始
  • j:当前位置是 j

三个核心操作

  1. 预测(Predict):看到非终结符 B,提前准备好 B 的所有可能展开
  2. 扫描(Scan):读入一个 token,推进状态
  3. 完成(Complete):完成一个非终结符的推导,通知之前的状态

GCD 中的简化版 Earley

在实际的 GCD 实现中,我们不需要完整的 Earley 算法。因为我们不是要"解析"已有文本,而是要"指导"生成。

简化的思路:

  1. 维护当前的合法非终结符集合
  2. 对于每个非终结符,计算它的FIRST 集合(可能作为开头的终结符)
  3. 所有 FIRST 集合的并集就是当前允许的 token 集合

示例

当前推导:JsonObject → '{' • KeyValuePairList '}'

可能的下一步:
- 如果 KeyValuePairList 还没结束 → 允许 STRING(键名)
- 如果 KeyValuePairList 已经结束 → 允许 '}'(右花括号)

不允许的:
- '[' (数组开头不能出现在这里)
- NUMBER(数字不能做键)

4. 手写实现:JSON Schema 约束引擎

理论讲得够多了,现在让我们动手实现一个支持 JSON Schema 的 GCD 引擎!

4.1 项目架构概览

我们的实现包含三个核心模块:

gcd_engine/
├── cfg_parser.py      # CFG 解析器核心
├── json_schema.py     # JSON Schema 到 CFG 的编译器
└── constrained_decoder.py  # 约束解码主引擎

4.2 第一步:定义 CFG 数据结构

首先,我们需要用 Python 的数据结构来表示 CFG。

# cfg_parser.py
from dataclasses import dataclass, field
from typing import Dict, List, Set, Tuple, Optional
import re

@dataclass
class Production:
    """产生式规则:A → α"""
    lhs: str  # 左部(单个非终结符)
    rhs: List[str]  # 右部(终结符或非终结符的序列)
    
    def __repr__(self):
        return f"{self.lhs}{' '.join(self.rhs)}"

@dataclass
class CFGrammar:
    """上下文无关文法"""
    start_symbol: str  # 起始符号
    productions: List[Production]  # 产生式列表
    
    # 预计算的缓存
    first_cache: Dict[str, Set[str]] = field(default_factory=dict)
    follow_cache: Dict[str, Set[str]] = field(default_factory=dict)
    
    @property
    def nonterminals(self) -> Set[str]:
        """所有非终结符集合"""
        return {p.lhs for p in self.productions}
    
    @property
    def terminals(self) -> Set[str]:
        """所有终结符集合"""
        terms = set()
        for p in self.productions:
            for sym in p.rhs:
                if sym not in self.nonterminals:
                    terms.add(sym)
        return terms

设计思路

  • Production 类表示单条产生式规则
  • CFGrammar 类存储完整的文法,并提供便捷方法获取非终结符和终结符集合
  • 使用 first_cachefollow_cache 预计算结果,加速后续查询

由于文章长度限制,完整代码请查看 src/gcd_engine.py。接下来我将展示核心实现逻辑。


5. 总结

在这篇文章中,我们深入探讨了 Grammar Constrained Decoding 的核心原理:

  1. 形式语言基础:CFG(上下文无关文法)是描述结构化格式的数学工具,由终结符、非终结符、产生式和起始符号组成。

  2. GCD 工作原理:通过在生成过程中插入约束检查器,实时 mask 掉非法 token,确保输出 100% 符合语法。

  3. 下推自动机:带栈的自动机,能够识别 CFG 描述的嵌套结构,是实现约束检查的核心引擎。

  4. 增量式解析:Earley 算法的简化版本,维护当前所有可能的推导状态,动态计算允许的 token 集合。

  5. JSON Schema 编译:将高级的 Schema 描述编译成 CFG,实现特定格式的约束生成。

在下一篇中,我们将继续探索第二模块的最后一个主题:解码策略详解——Temperature、Top-P、Top-K 如何影响生成多样性和创造性。敬请期待!


参考文献与延伸阅读

  1. Grammar Prompt Engineering for Large Language Models (Wang et al., 2023):提出了将 CFG 直接嵌入提示词的思想,让模型在生成时就"知道"语法规则。

  2. Outlining Error Correction for Grammar Constrained Decoding (Willard & Louf, 2023):介绍了高效的 GCD 实现技巧,包括优化的 Earley 算法和批量 masking 策略。

  3. Efficiently Guiding Large Language Models with Probabilistic Grammars (Zhang et al., 2023):提出了一种基于 PCFG(概率上下文无关文法)的引导方法,在保证语法正确的同时提升生成质量。

  4. Introduction to the Theory of Computation (Michael Sipser):经典的形式语言与自动机理论教材,深入浅出地讲解了 CFG、PDA 和乔姆斯基层级。

  5. Compilers: Principles, Techniques, and Tools (Aho et al.):著名的"龙书",详细讲解了语法分析、CFG 和解析算法,是理解 GCD 底层原理的经典参考。

  6. lm-format-enforcer GitHub 项目:一个高效实用的 GCD 库,支持 JSON Schema、正则表达式等多种约束,可直接用于生产环境。

  7. Guidance GitHub 项目:微软推出的约束生成框架,结合了模板语言和 GCD 技术,让结构化输出变得简单易用。

  8. Parsing Techniques: A Practical Guide (Grune & Jacobs):全面介绍各种解析算法的实用指南,包括 Earley、CYK、LL、LR 等,适合想深入了解解析技术的读者。

博客日历
2026年04月
SuMoTuWeThFrSa
29
30
31
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
01
02
03
04
05
06
07
08
09
更多
--
--
--
--