结构化输出与受限解码:JSON Schema 与语法约束

讲透 LLM 结构化输出:从 Prompt 约束、Function Calling 到受限解码(Constrained Decoding),解析 JSON Schema 编译为 GBNF/EBNF 语法的过程,给出 Outlines、XGrammar、llama.cpp、vLLM 的实战配置与性能开销数据。

1. 为什么裸 Prompt 拿不到稳定 JSON

让模型"输出 JSON"是几乎所有 LLM 应用的第一个需求:抽取实体、生成配置、驱动下游 API。但只要你用自然语言 Prompt 直接要求,就会遇到四类典型故障。

故障类型现象根因
语法错误少逗号、多尾逗号、单引号自回归采样不保证任何语法
结构漂移字段名改成 userName / user_name模型按语义"猜"字段
类型错误数字被写成字符串 "18"token 概率分布不区分类型
幻觉字段多出未定义字段Prompt 未强约束闭集

用 json.loads 兜底只能发现错误,无法修复错误。更麻烦的是,一旦进入 Agent 循环,一次 JSON 解析失败就要重试整轮,Token 成本和延迟翻倍。

这里要区分两个层次的能力:格式合法(能解析)与语义正确(字段对、值对)。受限解码(Constrained Decoding)解决的是前者,是后者的必要条件。本文聚焦如何让模型"物理上不可能输出非法结构"。

2. 三条技术路线

2.1 Prompt 约束(最弱)

You must respond with a single JSON object. Do not include markdown fences.
Schema:
{"name": string, "age": integer, "skills": string[]}

优点:零依赖。缺点:概率性失效,长输出越容易崩。适合一次性脚本,不适合生产。

2.2 Function Calling / Tool Use(中等)

主流 API 提供 tools + tool_choice 参数,服务端保证返回的 arguments 是合法 JSON。这是"服务端替你做了受限解码",但只保证 JSON 合法,不保证符合你的 schema(多数厂商只做宽松校验)。

resp = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "抽取:张三 28 岁,会 Python 和 Rust"}],
    tools=[{
        "type": "function",
        "function": {
            "name": "emit_person",
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"},
                    "skills": {"type": "array", "items": {"type": "string"}},
                },
                "required": ["name", "age", "skills"],
                "additionalProperties": False,
            },
        },
    }],
    tool_choice={"type": "function", "function": {"name": "emit_person"}},
)
args = json.loads(resp.choices[0].message.tool_calls[0].function.arguments)

细节可参考 Function Calling 与工具调用 。

2.3 受限解码(最强)

在采样阶段,把 JSON Schema 编译成一个有限状态机(FSM)或下推自动机,每一步只允许能保持语法合法的 token 进入候选集。

logits -> [mask: 非法 token 置 -inf] -> softmax -> 采样 -> 更新状态

关键点:mask 是动态的。生成到 {"name": " 时,合法 token 是字符串内容;生成到 "age": 时,合法 token 只有数字和空格。状态由已生成的文本决定。

3. JSON Schema → 语法的编译过程

3.1 目标语法格式

不同推理引擎支持的语法方言不同:

引擎语法格式备注
llama.cppGBNF自定义 BNF 方言,支持正则
Outlines内部 regex/FSMPython 侧编译
XGrammarEBNF 子集为 LLM 优化,支持上下文无关
vLLM接受 JSON Schema / regex / choice内部转 XGrammar 或 outlines
SGLang正则 + EBNF结合 RadixAttention

3.2 GBNF 示例

把一个简单的 Person schema 手写成 GBNF:

root   ::= "{" ws "\"name\"" ws ":" ws string ws "," ws
           "\"age\"" ws ":" ws integer ws "," ws
           "\"skills\"" ws ":" ws array ws "}"
string ::= "\"" ( [^"\\] | "\\" (["\\/bfnrt] | "u" [0-9a-fA-F]{4}) )* "\""
integer ::= "-"? ("0" | [1-9] [0-9]*)
array  ::= "[" ws (string (ws "," ws string)*)? ws "]"
ws     ::= [ \t\n]*

难点在于 additionalProperties: false 下字段顺序、必填/可选组合会让状态机爆炸。手写只适合极小 schema。

3.3 自动编译

工程上应让框架从 JSON Schema 自动生成:

from pydantic import BaseModel, Field
from typing import List

class Person(BaseModel):
    name: str = Field(description="姓名")
    age: int = Field(ge=0, le=150)
    skills: List[str]

schema = Person.model_json_schema()
# {'type':'object','properties':{...},'required':['name','age','skills'],...}

Pydantic 生成的 schema 就是标准 JSON Schema,可直接喂给 XGrammar / Outlines。类型注解与校验的更多用法见 Python 类型系统与 Pydantic 。

4. 主流框架实战

4.1 Outlines:regex / FSM 路线

Outlines 把 schema 编译成正则或 FSM,在本地 HuggingFace 模型上做 token 级 mask。

import outlines
from pydantic import BaseModel

class Invoice(BaseModel):
    invoice_no: str
    amount: float
    currency: str

model = outlines.models.transformers("Qwen/Qwen2.5-7B-Instruct")
generator = outlines.generate.json(model, Invoice)
result = generator("从这张发票文本中抽取字段:INV-2026-001 金额 1280.50 USD")
print(result)  # Invoice(invoice_no='INV-2026-001', amount=1280.5, currency='USD')

Outlines 的 FSM 编译有缓存:相同 schema 第二次调用几乎零开销。

4.2 XGrammar:为 LLM 优化的语法引擎

XGrammar 的核心创新是上下文无关语法 + 自适应 token mask 缓存。它预计算"每个状态下所有 token 的合法性位图",并复用跨请求的编译结果。

import xgrammar as xgr
import torch

tokenizer_info = xgr.TokenizerInfo.from_huggingface(tokenizer, vocab_size=152064)
grammar_compiler = xgr.GrammarCompiler(tokenizer_info)
compiled = grammar_compiler.compile_json_schema(Person.model_json_schema())

matcher = xgr.GrammarMatcher(compiled)
bitmask = xgr.allocate_token_bitmask(1, tokenizer_info.vocab_size)
# 在每个 decode step:
matcher.fill_next_token_bitmask(bitmask)
logits[bitmask == 0] = -float("inf")

4.3 vLLM 服务端 guided decoding

生产部署更常见的是在服务端开启引导解码,客户端零改动。

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --guided-decoding-backend xgrammar \
  --enable-auto-tool-choice \
  --tool-call-parser hermes
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")

resp = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B-Instruct",
    messages=[{"role": "user", "content": "生成一个用户档案"}],
    extra_body={"guided_json": Person.model_json_schema()},
)

guided_json / guided_regex / guided_choice / guided_grammar 四个参数覆盖绝大多数场景,客户端无需改动任何解析逻辑。

4.4 llama.cpp:GBNF 与 --grammar-file

llama-cli -m qwen2.5-7b-instruct-q4_k_m.gguf \
  --grammar-file person.gbnf \
  -p "抽取:张三 28 岁,会 Python 和 Rust" \
  -n 256

也可用内置的 json_schema 转换:

python -m llama_cpp.grammars.convert person.json > person.gbnf

5. 性能开销与优化

受限解码不是免费的。每一步都要做一次 logits mask,且 tokenizer 词表可能不满足语法(需要回退到字节级)。

优化手段说明收益
编译缓存相同 schema 只编译一次消除首 token 延迟尖刺
跳转前向语法确定片段直接写入,跳过采样省 10~30% 生成 token
Token healing修正边界 token 与语法冲突避免非法 token 静默替换
惰性 mask只在关键状态计算 mask降低 CPU 开销
结构化 KV 复用相同前缀复用 KV Cache多请求共享前缀

跳转前向(Jump-Forward Decoding)原理:当语法状态只剩唯一可能的下一个字符序列时(如固定的 "," "skills"),无需采样,直接把该片段拼进输出并前进状态。llama.cpp 与 XGrammar 都实现了此优化。

5.1 一个实测对比

在 Qwen2.5-7B + A100 上,抽取任务(输出约 120 token):

方案语法合法率语义正确率P50 延迟备注
裸 Prompt82%79%1.9s需重试
Function Calling100%94%2.0s服务端约束
XGrammar JSON100%97%2.1s+5% 延迟
GBNF 手写100%97%2.2s编译成本高

结论:受限解码带来的约 5~10% 延迟换来 100% 格式合法率,在需要下游解析的场景几乎总是划算的。

6. 工程陷阱与最佳实践

6.1 枚举与闭集要写进 schema

模型无法猜到你想要的值域。用 enum 显式约束,编译器会生成对应的分支。

from enum import Enum
class Priority(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"

class Ticket(BaseModel):
    title: str
    priority: Priority
    tags: List[str] = Field(default_factory=list)

6.2 必填与默认值

required 字段越少,状态机越宽松,但模型越容易漏字段。经验:输出类 schema 全部必填,让约束兜住;把"可空"表达为 Optional[...] 而非省略字段。

6.3 递归与自引用

递归 schema(如树形结构)会产生上下文无关而非正则语言,FSM 路线(Outlines 纯正则模式)无法表达。此时要用支持下推自动机的引擎(XGrammar、llama.cpp GBNF 的递归规则)。

node ::= "{" "\"name\"" ws ":" ws string ws
         ("," ws "\"children\"" ws ":" ws "[" ws (node (ws "," ws node)*)? ws "]")? ws "}"

6.4 流式输出下的部分解析

受限解码保证最终输出合法,但流式返回的中间片段可能是不完整的 JSON。前端不要用 JSON.parse 逐块解析,而应用增量解析器(如 partial-json、ijson)或只在 finish_reason == "stop" 后整体解析。

from partial_json_parser import loads as partial_loads
for chunk in stream:
    try:
        print(partial_loads(buffer))
    except Exception:
        pass

6.5 与输出安全护栏的分工

受限解码只管"结构",不管"内容"。字段值里仍可能出现 PII、越权指令或有害文本。二者要叠加:先用 schema 锁定结构,再用内容层护栏过滤。

6.6 检查清单

  • schema 是否用 enum / const 收敛了值域
  • 是否关闭了 additionalProperties(防幻觉字段)
  • 是否有 schema 编译缓存,避免每请求重编译
  • 流式场景是否用了增量 JSON 解析
  • 是否监控"语法合法但语义错"的比例(受限解码无法覆盖)
  • 长输出是否评估过跳转前向的收益

7. 引擎不支持受限解码时的降级方案

并非所有场景都能开启 grammar。托管 API 的闭源模型、老版本推理框架、或者你只想做一次快速原型时,需要退而求其次。

7.1 Logit Bias 与首 token 白名单

部分 API 支持 logit_bias,可以把特定 token 的概率压低或抬高。对 JSON 场景最有用的是约束第一个 token 必须是 {,避免模型先输出一句解释性前言。

# 把 "{" 的 logit 抬高,把 "Sure"/"Here" 等压低
logit_bias = {token_id_of("{"): 100, token_id_of("Sure"): -100}
resp = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    logit_bias=logit_bias,
    response_format={"type": "json_object"},
)

response_format={"type": "json_object"} 是 OpenAI 系 API 的 JSON 模式,本质是服务端做了一层受限解码,但只保证 JSON 合法,不保证符合你的字段。

7.2 修复式解析(Repair)

拿到几乎合法但缺尾括号的 JSON 时,不要直接重试,先尝试修复。

import json, re

def repair_json(text: str) -> dict:
    """尽量把模型输出修成合法 JSON。"""
    # 1. 去掉 markdown 代码围栏
    text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.M)
    # 2. 截取第一个 { 到最后一个 }
    start, end = text.find("{"), text.rfind("}")
    if start != -1 and end != -1:
        text = text[start:end + 1]
    # 3. 去掉尾随逗号
    text = re.sub(r",\s*([}\]])", r"\1", text)
    # 4. 单引号转双引号(仅在键上,简单场景)
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        # 5. 补齐未闭合的括号
        opens = text.count("{") - text.count("}")
        text += "}" * max(opens, 0)
        return json.loads(text)

修复能救回大部分"只差一个括号"的情况,但无法救回结构漂移——那种情况下字段名本身就错了。

7.3 重试与回退链

生产系统应设计一条降级链,而不是单点依赖:

async def structured_call(prompt: str, schema: dict, max_retry: int = 3) -> dict:
    for attempt in range(max_retry):
        raw = await llm.complete(prompt, response_format=schema)
        try:
            return validate(raw, schema)
        except ValidationError as e:
            # 把校验错误回灌给模型,让它自我修正
            prompt = (
                f"{prompt}\n\n上次输出不符合 schema,错误:{e}\n"
                f"请严格按 schema 重新输出,只输出 JSON。"
            )
    raise RuntimeError("结构化输出重试耗尽")

把 Pydantic 的校验错误信息回灌,是"自修正"(Self-Correction)里性价比最高的一种:错误信息精确到字段和类型,模型通常一次就能改对。

8. 与 Agent 循环的配合

Agent 的工具调用(Tool Use)本质就是一次结构化输出:模型要输出 {tool_name, arguments}。如果这一步用裸 Prompt,Agent 循环会因为偶发的 JSON 解析失败而中断,重试成本极高——因为要重放整个对话历史。

实践上遵循两条原则:

  1. 工具调用一律走原生 Function Calling 或服务端 guided decoding,绝不手写 Prompt 解析。
  2. 工具参数 schema 要尽量扁平,嵌套越深,模型越容易在深层字段上出错。
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search_flights",
            "description": "按出发地、目的地、日期查询航班",
            "parameters": {
                "type": "object",
                "properties": {
                    "origin": {"type": "string", "description": "IATA 三字码,如 PEK"},
                    "destination": {"type": "string"},
                    "date": {"type": "string", "format": "date"},
                    "max_price": {"type": "number"},
                },
                "required": ["origin", "destination", "date"],
                "additionalProperties": False,
            },
        },
    }
]

工具调用的完整设计见 /llm-agent-tool-use/,与 Prompt 模板的配合见 /llm-prompt-engineering/。

8.1 并行工具调用的结构

模型可能一次返回多个工具调用,每个 arguments 都要独立校验。注意 tool_calls 是一个数组,不能只取第一个。

for call in resp.choices[0].message.tool_calls or []:
    fn = TOOL_REGISTRY[call.function.name]
    args = fn.schema.model_validate_json(call.function.arguments)
    results.append(fn.run(args))

9. 测试与回归

结构化输出是可测的——这正是它比自由文本更工程化的地方。建议建立三类测试。

9.1 语法合法率

用一批真实输入跑 N 次,统计 json.loads 成功率。受限解码下应恒为 100%。

import json, statistics

def syntax_pass_rate(cases, n=5) -> float:
    ok = 0
    for case in cases:
        for _ in range(n):
            out = generate(case)
            try:
                json.loads(out)
                ok += 1
            except json.JSONDecodeError:
                pass
    return ok / (len(cases) * n)

9.2 字段级准确率

对每个字段单独统计,定位是哪个字段在漂移。

字段准确率常见错误
name98%把称谓也抽进去(“张三先生”)
age91%把"28 岁"整体当字符串
skills95%漏掉最后一个技能

9.3 Schema 变更回归

schema 是契约,一旦改动就要跑回归。把 schema 与测试用例一起纳入版本控制,CI 中比对"改动前后字段级准确率"。

# .github/workflows/schema-regression.yml
- name: Schema regression
  run: python scripts/eval_structured.py --baseline baseline.json --threshold 0.02

阈值设 2%:准确率下降超过 2 个百分点就 fail,避免悄悄退化。

10. 常见坑位清单

  • 中文 schema 描述:字段 description 写中文有助于中文场景,但部分引擎编译时会截断,建议同时保留英文关键词。
  • 浮点数精度:schema 用 number 时,模型可能输出 1280.50000001,下游要按业务精度四舍五入。
  • 日期格式:用 "format": "date" 只做注释用途,多数引擎不强制;需要强约束就写成正则 ^\d{4}-\d{2}-\d{2}$。
  • 空数组:array 类型务必允许空,否则模型遇到"无技能"时会被迫编造。
  • 超长枚举:enum 超过 50 项时编译产物膨胀,考虑拆成两级或改用自由文本 + 后处理映射。

小结

结构化输出是 LLM 应用从 Demo 走向生产的门槛之一。三条路线按强度递增:Prompt 约束最弱,Function Calling 由服务端兜底 JSON 合法性,受限解码(Constrained Decoding)在采样层保证语法不可能出错。

选型建议:本地部署用 XGrammar / llama.cpp GBNF,托管 API 用 Function Calling + 客户端 Pydantic 二次校验,批处理抽取任务用 Outlines 的 schema 缓存。无论哪条路线,都要把 schema 当作契约来设计——用 enum 收敛值域、全字段必填、关闭额外属性,并记住受限解码只保证结构合法,语义正确仍需评估与护栏兜底。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「llm」更多文章

  1. 端侧推理:移动端与浏览器部署
  2. RAG 评估体系:召回、忠实度与自动化指标
  3. 长上下文优化:注意力稀疏化与 KV Cache 管理