正文
:
Outlines
是目前广泛使用的开源框架,它支持多种约束方式,包括正则表达式、JSON Schema 以及 CFG。对于 CFG,它通常依赖如
Lark
这样的外部解析库,理论上可以支持 LALR(1) 等解析算法。然而,在实践中,为了处理 LLM tokenization 和增量生成带来的复杂性,其由社区贡献的CFG 实现可能面临性能挑战,尤其是在处理复杂或大规模语法时,就算目前的实现是仅使用第一个有效token,其性能也无法接受。同时,官方也强调了这只是一个实验性功能,见pull#
1067[3]
(在我实验中测试,sql的cfg解析时间达到了平均
0.12s/token
)
XGrammar 的核心原理与优化:
XGrammar 选择
下推自动机
(Pushdown Automata, PDA)
作为其执行 CFG 的核心机制。PDA 本质上是一个带栈的有限状态机,非常适合处理 CFG 定义的递归和嵌套结构(这在 SQL 中很常见)。
图 4: 简化的下推自动机 (PDA) 工作原理示意
为什么 PDA 对 CFG 高效?
因为它通过栈来管理规则的嵌套调用,避免了为无限可能的中间状态预计算所有情况。
让我们用一个简单的例子来说明:检查括号匹配,(如果大家平时有刷过leetcode,可以发现这是一个非常经典的栈使用的例子),比如
((()))
。CFG 规则可以是
S ::= "(" S ")" | ""
。
1.遇到
(
:它不改变状态来“记住”数量,而是在
栈顶
放一个标记(比如“需要右括号”)。栈记录了当前的嵌套深度。
2.遇到
)
:它检查栈顶是否有标记。
3.结束时:如果栈是空的,说明所有括号完美匹配。
图 4.1: PDA 使用栈处理
((()))
PDA 通过
动态地使用栈
来跟踪嵌套层级(规则的递归调用
S ::= "(" S ")"
),而不是为每种可能的嵌套深度(无限的中间状态)创建单独的状态。这就是它高效处理 CFG 的关键:用有限的状态 + 一个动态的栈,优雅地解决了无限状态空间的问题。
XGrammar 相较于 Outlines 等框架的关键优化点:
1.
智能区分 Token (Context-independent/dependent Separation)
:
XGrammar 能聪明地识别出:哪些 token 的合法性只跟当前的语法位置有关(绝大多数情况),哪些则需要回头看整个“调用栈”历史才能确定(少数复杂情况)。这避免了对所有 token 都进行复杂的检查。
2.
预先计算与缓存 (Adaptive Token Mask Cache)
:
对于那些只跟当前位置有关的 token,XGrammar 在“编译”语法时就预先算好它们在什么位置是合法的,并把结果存起来。运行时,对于这些 token,直接查表就行,速度极快!
3.
高效管理多条路径 (Persistent Execution Stack)
:
当语法存在多种可能性时(比如
WHERE
后面可以跟
a=1
或
b=2
),PDA 需要同时探索这些路径。XGrammar 使用一种聪明的树状数据结构来高效地管理这些并行的“探索路径”(栈),避免了低效的复制和回溯操作。
4.
PDA 结构优化
:
像编译器优化代码一样,XGrammar 会优化 PDA 的内部结构,比如合并等价的状态,让整个“语法检查机器”更小、更快。
5.
并行编译
:
利用多核 CPU 并行处理 EBNF 的编译过程,缩短准备时间。
6.
计算重叠
:
将 CPU 上的语法检查计算与 GPU 上的 LLM 推理计算安排得像流水线一样,尽量让它们同时进行,从而隐藏 CPU 的开销。
这些优化使得 XGrammar 在处理通用 CFG 时,相比传统实现能达到显著的性能提升(如官方报告中高达 10 倍以上的加速),目标是实现“零开销”的约束生成。
尽管如此,XGrammar 自身也存在挑战,例如对左递归的处理、非终结符过多可能导致的性能下降等问题(见 issue
127[6]
、
#203
[7]
),这也是我们后续需要关注和改进的地方。在我们的初步测试中,XGrammar 表现出潜力,但在测试过程中,对于复杂的CFG,也遇到了性能瓶颈(
1.83s/token
),目前我的解决方式是在CFG中尽可能避免递归表达,改为平铺的方式。
2. 动态 EBNF 生成:灵活性之源
静态的 SQL 语法无法满足我们“可干预”的需求。例如,用户 A 可能只想查询
table1
,而可能用户 B 需要查询
table2
并强制带有
WHERE date > '2024-01-01'
的条件。
我们使用
Jinja 模板引擎
来解决这个问题。基础的 SQL EBNF 语法(如
sql_ebnf_simple.jinja
文件所示)被编写为模板,其中包含占位符和条件逻辑。
示例 (简化版):
假设
sql_ebnf_simple.jinja
中有如下片段:
{% if tables and tables|length > 0 -%}
table_name ::= {% for table in tables %}{% ifnot loop.first %} | {% endif %}"{{ table.name }}"{% endfor %}
{% else -%}
table_name ::= identifier
{% endif %}
{% if required_filters and required_filters|length > 0 -%}
where_clause ::= "WHERE" ws required_conditions (ws "AND" ws user_conditions)?
required_conditions ::= {% for filter in required_filters %}{% ifnot loop.first %} ws "AND" ws {% endif %}{{ filter.column }} {{ filter.operator }} {{ filter.value }}{% endfor %}
user_conditions ::= expression
{% else -%}
where_clause ::= ("WHERE" ws expression)?
{% endif %}
当用户请求传入配置
{ "tables": ["dm_ai.table_X"], "required_filters": [{"column": "
fstatus
", "operator": "=", "value": "1"}] }
时,Jinja 会渲染出如下特定的 EBNF:
# Table name
table_name ::= "dm_ai.table_X"
# WHERE clause
where_clause ::= "WHERE" ws required_conditions (ws "AND" ws user_conditions)?
required_conditions ::= fstatus = 1
user_conditions ::= expression
这样,生成的 EBNF 就精确地编码了用户的约束,后续的约束解码将强制模型遵守这些规则。
3. EBNF 设计考量:为何强制某些规则?
在
sql_ebnf_simple.jinja
中,我们不仅定义了基础 SQL 语法,还嵌入了一些看似“强制”的规则,这背后是实际业务
需求和工
程考量:
图 5: EBNF 中动态处理必需过滤条件的逻辑