跳到主要内容

LLM 结构化输出实战:从 Prompt Engineering 到 Constrained Decoding

· 阅读需 9 分钟

这是 Function Calling 系列的第三篇。前两篇讲了 Function Calling 诞生的背景和角色、字段与报文处理。本文聚焦一个更底层的问题:怎么让 LLM 输出结构化的、可解析的、格式正确的数据?

问题:为什么 LLM 输出 JSON 这么难?​

先看一个真实场景。你想让 LLM 从一段文本里提取实体:

prompt = """
从以下文本中提取人名、地点和事件,以 JSON 格式返回:
"张三和李四昨天在北京奥林匹克公园参加了马拉松比赛。"

输出格式:
{
"people": ["..."],
"location": "...",
"event": "..."
}
"""

理想输出:

{"people": ["张三", "李四"], "location": "北京奥林匹克公园", "event": "马拉松比赛"}

实际你可能会收到:

好的,以下是从文本中提取的信息:

```json
{
"people": ["张三", "李四"],
"location": "北京奥林匹克公园",
"event": "马拉松比赛"
}
```

希望这对你有帮助!

—— Markdown 代码块包裹,前后有废话,json.loads() 直接报错。

这只是最温和的情况。更糟的:

问题示例
尾随逗号{"name": "张三",}
注释{"name": "张三" // 用户名}
引号不匹配{"name": '张三'}
遗漏字段{"people": ["张三"]} — 丢了李四
幻觉字段{"weather": "晴"} — 你根本没要求这个
NaN/Infinity{"score": NaN} — JSON 不合法

根本原因:LLM 的输出是 token-by-token 自回归生成的,不是"先想好结构再一次性输出"。每个 token 都基于之前的 token 预测,没有全局校验机制。

解决方案演进:从松到严​

业界解决这个问题的技术栈,按"约束力度"从弱到强:

方案 约束方式 可靠性 性能影响
─────────────────────────────────────────────────────
1. Prompt 引导 自然语言描述格式 ★☆☆☆☆ 无
2. JSON Mode 模型内置开关 ★★☆☆☆ 极小
3. FSM/Regex 有限状态机约束 Token ★★★★☆ 中等
4. Grammar-based 上下文无关文法约束 ★★★★★ 较大

下面逐一拆解。

方案一:Prompt Engineering —— 最低成本,最不可靠​

基础版:在 prompt 里描述格式​

prompt = """
请以严格 JSON 格式返回,不要有任何额外文字。
输出必须能被 Python json.loads() 直接解析。
格式:{"name": "...", "age": int 类型, "skills": ["..."]}
"""

进阶版:Few-shot + 反例​

prompt = """
返回纯 JSON,不要包裹在 markdown 代码块中,不要有任何解释。

正确示例:
{"name": "张三", "age": 30, "skills": ["Python", "Go"]}

错误示例(不要这样):
```json
{"name": "张三"}
```
"""

终极版:用 Pydantic 生成格式说明​

from pydantic import BaseModel

class Person(BaseModel):
name: str
age: int
skills: list[str]

schema = Person.model_json_schema()

prompt = f"""
只返回符合以下 JSON Schema 的有效 JSON:
{schema}

不要有任何其他文字。不要用 markdown 代码块包裹。
"""

效果评估​

场景准确率
简单结构(2-3 个字段)~80-90%
嵌套对象~60-70%
数组 + 枚举~50-60%
复杂 Schema~30-40%

结论:原型验证够用,生产环境不可靠,尤其是 Schema 复杂、温度 > 0、或者长输出时。

方案二:JSON Mode —— 模型原生支持​

OpenAI 在 2023 年 DevDay 推出了 JSON Mode,通过在 API 请求里设置 response_format:

response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "提取实体"}],
response_format={"type": "json_object"}
)

JSON Mode 做了什么?​

  1. 禁用文本生成:强制模型只输出 JSON token
  2. 括号/引号配对:保证 { 和 } 数量匹配
  3. 不保证 Schema 正确:只保证"是 JSON",不保证"是你想要的 JSON"

关键限制​

# ❌ 这样不能保证输出有任何特定字段
response_format={"type": "json_object"}
# → 输出是合法 JSON,但可能不包含你需要的字段

# ✅ OpenAI 后来新增了 structured outputs
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"schema": Person.model_json_schema(),
"strict": True
}
}
# → 保证输出符合 Schema 的所有约束

实测数据​

模型JSON Mode 误格式率Strict Mode 误格式率
GPT-4o~3%低于 0.1%
GPT-4o-mini~8%低于 1%
Claude 3.5 Sonnet~2%不支持 strict mode
DeepSeek V3~5%不支持

结论:JSON Mode 解决了"是 JSON"的问题,没解决"是你想要的 JSON"的问题。OpenAI 的 Strict Mode 做到了,但其他模型还不支持。

方案三:Regex/FSM 约束 —— 有限状态机控制采样​

这是真正开始"硬约束"的第一层:在生成每个 token 之前,用有限状态机过滤掉不合法的 token。

原理​

词表: ["{", "}", '"', 'name', ':', '张三', ',', 'age', '3', '0', '}', ...]

当前已生成: {"name": "

下一步合法 token:
✅ 任何字符串字符
❌ "}" (在字符串中间不能闭合括号)
❌ "{" (字符串内不能嵌套对象)

→ 把不合法的 token logit 设为 -inf
→ 模型只能从合法 token 中采样

实际框架​

Guidance(Microsoft)

import guidance

program = guidance("""
{
"name": "{{name}}",
"age": {{age}},
"skills": {{#each skills}}"{{this}}"{{#unless @last}}, {{/unless}}{{/each}}
}
""")

result = program(
name='...', # 模型会填充
age='...',
skills=['...']
)

LMQL

import lmql

@lmql.query
def extract_person():
"""
argmax
"输出 JSON:\n"
"{ \"name\": \"[NAME]\", \"age\": [AGE], \"city\": \"[CITY]\" }"
from
"openai/gpt-4o"
where
len(NAME) < 20 and
INT(AGE) and
CITY in ["北京", "上海", "深圳"]
"""

优缺点​

优点缺点
100% 保证格式只能约束能表达为正则的格式
不需要模型支持嵌套结构复杂时正则很冗长
推理框架无关对输出质量有负面影响

关键发现:FSM 约束会移除一些 token 选项,在某些情况下会降低生成质量。比如一个位置原本模型想输出 "张" 但因为约束只能输出数字,结果被迫选了个不太好的数字。

结论:适合简单、扁平的输出格式。复杂嵌套结构(如 API 响应、配置文件)不适用。

方案四:Grammar-Based Constrained Decoding —— 终极方案​

用上下文无关文法(CFG) 或 JSON Schema 直接驱动采样过程。这是目前最严格、最可靠的方式。

核心原理​

JSON Schema → 编译成 → 字符级状态机 → 控制每一步采样的合法字符集

每一步生成时:
1. 拿到模型的原始 logits
2. 根据当前状态,mask 掉不合法的 token
3. Softmax → 采样 → 推进状态机

结果:输出一定符合 Schema,不需要后处理校验。

主流框架对比​

Outlines(推荐)​

from outlines import models, generate
from pydantic import BaseModel

class Entity(BaseModel):
people: list[str]
location: str
event: str

model = models.transformers("Qwen/Qwen2.5-7B-Instruct")
generator = generate.json(model, Entity)

result = generator("从文本中提取实体:张三和李四在北京参加马拉松")
# → Entity(people=["张三","李四"], location="北京", event="马拉松")
# 保证 100% 符合 Pydantic 模型

Outlines 的索引机制:不是暴力 mask,而是预编译一个"状态 → 合法 token 集合"的映射表(通过 tokenizer 的 trie 结构),推理时 O(1) 查表。

# Outlines 内部简化逻辑
mask_table = {
(state_0, "{"): 允许,
(state_0, " "): 允许,
(state_0, "张三"): 禁止, # 不在合法字符集中
...
}

llama.cpp Grammar​

grammar = r"""
root ::= object
object ::= "{" ws pair ("," ws pair)* "}" ws
pair ::= string ws ":" ws value
value ::= string | number | array
array ::= "[" ws value ("," ws value)* "]" ws
string ::= "\"" [^"]* "\""
number ::= [0-9]+
ws ::= [ \t\n]*
"""

result = llama.cpp.create_completion(
prompt=prompt,
grammar=grammar
)

vLLM Guided Decoding​

from vllm import LLM, SamplingParams

llm = LLM("Qwen/Qwen2.5-7B-Instruct")

sampling_params = SamplingParams(
temperature=0,
guided_decoding=GuidedDecodingParams(
json=Person.model_json_schema()
)
)

outputs = llm.generate(prompts, sampling_params)

性能开销​

方案吞吐下降延迟增加原因
JSON Mode~0%~0ms模型内部优化
Regex~5-10%+50msToken-level mask
Grammar (vLLM)~10-20%+100msSchema 编译 + XPU mask
Grammar (Outlines)~15-30%+200msPython 层开销

vLLM 的 guided decoding 把 mask 计算放在了 CUDA kernel 旁边,比纯 Python 的方案快很多。

选型建议​

你的需求 推荐方案
────────────────────────────────────────────
快速原型,格式简单 → Prompt Engineering
调用 OpenAI,需要 JSON → response_format + strict
用开源模型,输出扁平结构 → Regex / FSM
用开源模型,复杂嵌套 Schema → Outlines / vLLM grammar
生产环境,低延迟要求 → OpenAI Strict / vLLM guided
生产环境,100% 可靠 → Outlines / llama.cpp grammar

几个实测结论​

  1. Prompt Engineering 的格式化指令会互相干扰。你写了"返回 JSON",模型可能记住训练数据里"JSON 要包在 markdown 里",反而增加代码块包裹概率。不如用 JSON Mode 或 Outlines。

  2. 温度必须为 0。结构化输出和创造性是矛盾的——温度 > 0 时格式错误率呈指数增长。几乎所有约束解码框架都建议 temperature=0。

  3. Few-shot 对格式的正确性没有帮助。Few-shot 帮助模型理解内容(提取什么实体),但对格式(括号匹配、引号正确)几乎没影响。格式问题要走约束解码,不是 prompt 技巧。

  4. Outlines 的 Pydantic 集成是杀手锏。你的业务代码里用 Pydantic 定义数据模型,直接用同一个模型当 JSON Schema 传给 Outlines——不需要维护两套定义。

实战:构建一个结构化提取流水线​

结合前面四篇文章的知识,完整流水线长这样:

from pydantic import BaseModel
from outlines import models, generate

# 1. 定义 Schema(就是你业务代码用的 Pydantic 模型)
class ExtractionResult(BaseModel):
summary: str
entities: list[str]
sentiment: str # "positive" | "negative" | "neutral"
confidence: float

# 2. 加载模型 + 约束生成器
model = models.transformers(
"Qwen/Qwen2.5-7B-Instruct",
device="cuda"
)
generator = generate.json(model, ExtractionResult)

# 3. 调用
prompt = f"""
分析以下用户反馈,提取关键信息:
"这个产品功能很强大,但价格太贵了,希望能降价。客服响应很快。"
"""

result = generator(prompt)
# → ExtractionResult(
# summary="用户认可功能但抱怨价格",
# entities=["功能", "价格", "客服"],
# sentiment="neutral",
# confidence=0.92
# )

# 4. 不需要 json.loads(),不需要 try-except,不需要格式校验
print(result.model_dump_json(indent=2))

如果不用 Outlines,用 vLLM 的话:

from vllm import LLM, SamplingParams

llm = LLM(
"Qwen/Qwen2.5-7B-Instruct",
guided_decoding_backend="outlines" # 或 "xgrammar"
)

params = SamplingParams(
temperature=0,
max_tokens=256,
guided_decoding=GuidedDecodingParams(
json=ExtractionResult.model_json_schema()
)
)

outputs = llm.generate([prompt], params)
result = ExtractionResult.model_validate_json(outputs[0].outputs[0].text)

总结​

方案可靠性灵活度性能适用场景
Prompt Engineering★☆☆☆☆★★★★★★★★★★原型/探索
JSON Mode★★☆☆☆★★★★☆★★★★★简单 JSON,有 OpenAI
OpenAI Strict★★★★★★★★★☆★★★★★复杂 Schema,有 OpenAI
Regex/FSM★★★★☆★★★☆☆★★★★☆扁平结构,开源模型
Grammar-based★★★★★★★★★★★★★☆☆复杂嵌套,开源模型

一句话总结:低风险场景拼 prompt,生产环境上约束解码。Outlines + Pydantic 是目前开源方案里最丝滑的组合。


系列文章: