首页
/ llama.cpp 中 LLGuidance 实战指南:面向 JSON Schema 与 Lark 语法的结构化输出约束

llama.cpp 中 LLGuidance 实战指南:面向 JSON Schema 与 Lark 语法的结构化输出约束

2026-09-06 11:03:33作者:蔡丛锟

llama.cpp 除了内置的 GBNF 语法约束外,还可选集成 LLGuidance——一个用 Rust 实现的高性能约束解码(constrained decoding / 结构化输出)库。本文基于仓库中的 LLGuidance 支持文档,结合 common/llguidance.cppcommon/sampling.cpp 的源码与 tests/test-grammar-llguidance.cpp 测试,完整讲解如何启用该编译选项、%llguidance 语法的接口约定、JSON Schema 的语义差异、token mask 的运行时实现,以及为什么 LLGuidance 选择 Lark 语法而不是直接复用 GBNF。

一、LLGuidance 是什么

LLGuidance 是一个专为大语言模型约束采样设计的独立库,最初作为 Guidance 库的后端开发,也可以脱离 Guidance 单独使用。它的核心能力包括:

  • 支持 JSON Schema,覆盖面广、贴近规范语义;
  • 支持任意上下文无关文法(CFG),采用 Lark 语法的一种变体书写;
  • 性能非常高(原因见第四节),这是其词法器/解析器分离架构与一系列优化的结果。

代价是它由 Rust 编写,需要 Rust 工具链参与 llama.cpp 的构建过程。因此 llama.cpp 将其设计为一个默认关闭的可选编译开关,在 CMakeLists.txt 中定义:

option(LLAMA_LLGUIDANCE "llama-common: include LLGuidance library for structured output in common utils" OFF)

二、构建:启用 LLGuidance 支持

docs/llguidance.md 的说明,构建时打开 LLAMA_LLGUIDANCE 选项:

cmake -B build -DLLAMA_LLGUIDANCE=ON
make -C build -j

Windows 下将 make 替换为:

cmake --build build --config Release

前置条件是安装 Rust 编译器cargo 工具。

从源码构建流程看,common/CMakeLists.txtLLAMA_LLGUIDANCE 开启后会做三件事:

  1. 通过 CMake 的 ExternalProject_Add 拉取 llguidance 上游源码(当前固定到 v1.0.1 对应的提交 d795912),并用 cargo build --release --package llguidance 编译出静态库;
  2. 对 llama-common 目标定义编译宏 LLAMA_USE_LLGUIDANCE——这正是源码中所有 LLGuidance 分支的编译开关;
  3. 将静态库 llguidance 链接进 llama-common,并把 target/release 目录加入头文件搜索路径。Windows 下还会额外链接 ws2_32 userenv ntdll bcrypt 四个系统库。

也就是说,Rust 编译发生在 CMake 配置之后的构建阶段,产物是一个被 C++ 侧调用的 C ABI 静态库(接口头文件为 llguidance.h)。

三、接口设计:%llguidance 前缀与 -j 参数

LLGuidance 的接入不引入任何新的命令行参数,也不改动 common_params 结构(见 docs/llguidance.md "Interface" 一节)。它通过两条现有通道生效:

3.1 以 %llguidance 开头的文法字符串

当通过 --grammar-gf 文件方式同理)传入的文法内容以 %llguidance 开头时,llama.cpp 会把它交给 LLGuidance 处理,而不是走内置的 GBNF 解析器。分支逻辑位于 common/sampling.cpp

const std::string & grammar_str = common_grammar_value(params.grammar);
if (grammar_str.compare(0, 11, "%llguidance") == 0) {
#ifdef LLAMA_USE_LLGUIDANCE
    grmr = llama_sampler_init_llg(vocab, "lark", grammar_str.c_str());
#else
    GGML_ABORT("llguidance (cmake -DLLAMA_LLGUIDANCE=ON) is not enabled");
#endif
} else {
    // 原有 GBNF 路径:llama_sampler_init_grammar / lazy patterns
}

两个细节值得注意:

  • 传给 LLGuidance 的 grammar kind 固定为 "lark",即文法体按 Lark 变体语法解析;
  • 若使用 %llguidance 文法但编译时未启用该选项,程序会直接 abort 并提示 llguidance (cmake -DLLAMA_LLGUIDANCE=ON) is not enabled——在 common/llguidance.cpp 的降级实现中也有对应的警告输出。

因此你可以像使用 GBNF 一样使用 LLGuidance 文法,例如(示意):

llama-cli -m model.gguf -gf my_grammar.txt
# my_grammar.txt 内容以 %llguidance 开头,其后是 Lark 变体文法

对于已有的 GBNF 文法,可以用 LLGuidance 项目自带的 gbnf_to_lark.py 脚本将其转换为 Lark 风格,脚本通常还能自动处理终结符(大写)与非终结符(小写)的命名区分。

3.2 JSON Schema 请求(-j / -jf

llama-cli 等工具用 -j--json-schema)或 -jf--json-schema-file)传入 JSON Schema 时,参数解析器调用 json_schema_to_grammar 将其转成文法字符串,入口见 common/arg.cpp。该函数的实现在 common/json-schema-to-grammar.cpp 中根据是否启用 LLGuidance 走完全不同的路径:

std::string json_schema_to_grammar(const common_json & schema, bool force_gbnf) {
#ifdef LLAMA_USE_LLGUIDANCE
    if (!force_gbnf) {
        return "%llguidance {}\nstart: %json " + schema.dump();
    }
#else
    (void)force_gbnf;
#endif
    return build_grammar(...);  // 回退到内置的 GBNF 生成器
}

启用 LLGuidance 后,JSON Schema 会被原样 dump 拼进 %llguidance 文法里(start: %json <schema> 形式),由 LLGuidance 内部的 %json 规则解析,而不是先在 C++ 侧展开成 GBNF。这意味着:

  • 同一份 -j 参数,在未启用 LLGuidance 的构建上走内置 GBNF 生成器(功能子集),在启用后的构建上走 LLGuidance(更贴近规范);
  • common/chat.cpp 中 chat 接口的 inputs.json_schema 同样经过 json_schema_to_grammar,因此对话式调用也自动受益。

四、性能:token mask 计算成本

docs/llguidance.md 给出的实测数据(基于 JSON Schema Bench 基准):对于 128k 词表的 llama3 tokenizer,计算一次 "token mask"(即允许的 token 集合)平均消耗 50μs 单核 CPU 时间,p99 为 0.5ms,p100 为 20ms

这个数量级的成本主要来自架构设计:

  • 词法器(lexer)与解析器(parser)分离。JSON 等语言通常采用两阶段处理:先用正则词法器把字节流切成 lexeme,再由 CFG 解析器处理。词法器求值便宜得多,且 lexeme 数量比字节数少约 10 倍;
  • LLM 的 token 往往与 lexeme 天然对齐,因此解析器实际只在不到 0.5% 的 token 上被真正调用,其余时间由词法器处理。

对照源码可以印证 mask 的使用方式:common/llguidance.cpp 中,apply 阶段通过 llg_matcher_get_mask / llg_matcher_compute_mask 拿到位图,然后逐 token 检查:

for (size_t i = 0; i < cur_p->size; ++i) {
    auto token = cur_p->data[i].id;
    if ((mask[token / 32] & (1 << (token % 32))) == 0) {
        cur_p->data[i].logit = -INFINITY;   // 不在允许集合内 → 直接屏蔽
    }
}

被 mask 排除的 token 的 logit 被置为 -INFINITY,从而在后续 softmax/采样中概率为零。每次采样选定 token 后,llama_sampler_llg_accept_impl 调用 llg_matcher_consume_token 推进匹配器状态;reset 则调用 llg_matcher_reset 回到初始状态。

五、运行时实现:tokenizer 构建与采样器生命周期

深入 common/llguidance.cpp,LLGuidance 采样器(llama_sampler_llg)由四部分构成:词表指针 vocab、文法类型与文法文本、LlgTokenizer*LlgMatcher*

tokenizer 构建llama_sampler_llg_new_tokenizer)是理解性能与正确性的关键:

  1. 对词表中每一个 token id 调用 llama_detokenize 取其字节形式(普通 token 失败时以 special 标志重试;特殊 token 会在字节前加 \xff 前缀标记),并记录每个 token 的长度;
  2. llama_tokenize(封装为 llama_sampler_llg_tokenize_fn)作为反查函数交给 LLGuidance;
  3. EOS 取 llama_vocab_eot,若不存在则退回 llama_vocab_eos

该 tokenizer 会按词表做静态缓存(同一 vocab 只构建一次,克隆复用),避免每次创建采样器都全量 detokenize 一遍。llama_sampler_init_llg 还会做一次健全性断言:词表大小向上取整到 32 的倍数后乘以 4 字节,必须与 llg_matcher_get_mask_byte_size 返回的 mask 字节数一致——这保证了 mask 位图与词表一一对应。

其余生命周期操作都很直接:clone 通过 llg_clone_matcher / llg_clone_tokenizer 复制状态(支持并行采样链),free 释放 matcher 与 tokenizer。日志级别可通过环境变量 LLGUIDANCE_LOG_LEVEL 调整(见 common/llguidance.cpp,读取 cinit.log_stderr_level)。

六、为什么不直接复用 GBNF 格式

这是 docs/llguidance.md 单独设节的架构问题,答案的核心一句话是:GBNF 没有 lexer 的概念

  • 多数编程语言(含 JSON)都采用 lexer + CFG 解析器两阶段处理。lexer 基于正则、求值代价低;lexeme 数量比字节少约 10 倍,使得整体求值更快;
  • LLM token 常与 lexeme 对齐,解析器介入的频率不到 0.5%;
  • 代价是用户必须显式区分 lexeme(终结符)与 CFG 符号(非终结符)。Lark 的约定是:终结符名字大写,非终结符小写gbnf_to_lark.py 脚本在很多场景下能自动完成这一转换。

这与第三节 3.2 的实现选择一致:既然 GBNF 表达不了 lexer,-j 启用 LLGuidance 后干脆不再把 Schema 降级翻译成 GBNF,而是让 LLGuidance 的 %json 规则原生处理。

七、JSON Schema 语义:与内置 GBNF 生成器的关键差异

LLGuidance 严格贴合 JSON Schema 规范,文档列出了三点与 llama.cpp 现有文法生成器的行为差异,这些差异在 tests/test-grammar-llguidance.cpp 中有逐条对应的测试用例:

行为 内置 GBNF 生成器 LLGuidance 测试佐证
additionalProperties 默认按 false 处理 默认 true(需要收紧时显式写 "additionalProperties": false "object properties, additionalProperties: true" 用例验证附加属性合法
空白符 受限 任意空白均允许(如 "a": 1"a":1 均可) 多个用例在 enum 值前后带空格的字符串上通过
properties 定义顺序 required 属性一律排前 保持声明顺序,与 required 与否无关 "required + optional props each in original order" 用例:b 声明在 a 前,则 {"b": ..., "a": ...} 通过而反序失败
不支持的 Schema 关键字 可能静默忽略 直接报错,任何关键字都不会被静默忽略

测试文件 tests/test-grammar-llguidance.cpptest_json_schema() 覆盖的 Schema 语义相当全面,可作为能力清单参考:

  • 数值约束:minimum / maximum / exclusiveMinimum / exclusiveMaximum(含负数边界、前导零如 "01" 被拒绝);
  • 字符串约束:minLength / maxLength / pattern(含转义字符);
  • 类型与常量:type: string/integer/booleanconstenum(混合 string/null/number/array);
  • 对象语义:propertiesrequiredadditionalProperties 的 true/false 两种形态、属性顺序约束;
  • 数组语义:minItems / maxItemsitems 多态(如 type: ["array", "null"]);
  • 特殊 format:dateuuidtimedate-time

测试中还用 DISABLED_uniqueItems 标注了一个已知限制:uniqueItems 目前不支持(注释说明其实现代价过高,属于 TODO),使用时需要自行规避。

八、错误处理行为

文档明确说明当前策略:错误打印到 stderr,生成继续。源码可以确认这一语义——common/llguidance.cpp 中,compute_mask 返回非零时:

LOG_ERR("llg error: %s\n", llg_matcher_get_error(ctx->grammar));
llg_free_matcher(ctx->grammar);
ctx->grammar = nullptr;
return;

matcher 被释放后置空,之后的 apply 成为 no-op(约束解除,采样自由进行),因此约束失败不会中断推理,但输出将不再受约束保证。文档也提到未来可能会改进错误处理。文法创建阶段的错误(如语法不合法)则直接体现在 llg_matcher_get_error 上,llama_sampler_init_llg 返回 nullptr,上层 common_sampler_init 会抛出 "failed to parse grammar"。

九、测试与验证

该功能有专门的集成测试 tests/test-grammar-llguidance.cpp,且仅在选项开启时构建(tests/CMakeLists.txt):

if (LLAMA_LLGUIDANCE)
    llama_build_and_test(test-grammar-llguidance.cpp ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-llama-bpe.gguf)
endif ()

其测试方法(match_stringtests/test-grammar-llguidance.cpp)忠实模拟了真实采样循环:对输入逐 token 做 "apply → 检查期望 token 的 logit 非负 → accept",最后检查 EOS 是否被允许以判定文法接受。测试组包括简单/复杂算术文法、特殊字符(多字节 emoji 按单字符计数)、* + ? 量词与 {n} / {n,} / {0,n} 重复量词、全套 JSON Schema 用例,以及一个把 llama_sampler_init_llgllama_sampler_init_dist 串进 llama_sampler_chain 的集成用例,验证 LLGuidance 采样器在采样链中的组合行为。文法用例本身以 test_schema(内部拼成 %llguidance {}\nstart: %json <schema>,与 -j 的运行时形态一致)和 test_grammar(Lark 文法)两类组织。

小结

  • 启用cmake -B build -DLLAMA_LLGUIDANCE=ON + Rust 工具链;构建期自动以 cargo 编译上游静态库并链接进 llama-common;
  • 接口零侵入%llguidance 前缀文法与 -j JSON Schema 两条现有通道自动分流到 LLGuidance(kind 为 lark),未启用时前缀文法会明确 abort、-j 则回退内置 GBNF;
  • 语义更贴规范additionalProperties 默认 true、任意空白、属性声明顺序保留、不支持的 Schema 显式报错;已知限制如 uniqueItems 尚不支持;
  • 性能来源:lexer/parser 分离,128k 词表下 mask 计算平均约 50μs;
  • 失败不中断:运行期 mask 错误输出到 stderr 后解除约束继续生成;
  • 可验证test-grammar-llguidance 提供从词法到 JSON Schema 的完整回归测试,构建并运行该测试是确认环境配置正确的直接手段。

如果你想了解内置 GBNF 文法本身的语法细节,可参考 grammars/README.md;JSON Schema 到 GBNF 的内置转换逻辑则见 common/json-schema-to-grammar.cpp

登录后查看全文
热门项目推荐
相关项目推荐