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.cpp | GBNF | 自定义 BNF 方言,支持正则 |
| Outlines | 内部 regex/FSM | Python 侧编译 |
| XGrammar | EBNF 子集 | 为 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 延迟 | 备注 |
|---|---|---|---|---|
| 裸 Prompt | 82% | 79% | 1.9s | 需重试 |
| Function Calling | 100% | 94% | 2.0s | 服务端约束 |
| XGrammar JSON | 100% | 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 解析失败而中断,重试成本极高——因为要重放整个对话历史。
实践上遵循两条原则:
- 工具调用一律走原生 Function Calling 或服务端 guided decoding,绝不手写 Prompt 解析。
- 工具参数 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 字段级准确率
对每个字段单独统计,定位是哪个字段在漂移。
| 字段 | 准确率 | 常见错误 |
|---|---|---|
| name | 98% | 把称谓也抽进去(“张三先生”) |
| age | 91% | 把"28 岁"整体当字符串 |
| skills | 95% | 漏掉最后一个技能 |
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 收敛值域、全字段必填、关闭额外属性,并记住受限解码只保证结构合法,语义正确仍需评估与护栏兜底。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。