LLM 结构化输出全解:从 Prompt 约束到 Schema 硬保证,三层实现怎么选
LLM 系列又一篇。Function Calling 篇讲过「模型输出结构化请求」的协议细节------但那个循环里有个前提没展开:模型输出本来就是不可靠的散文,怎么让它变成代码能直接用的数据? 这篇讲透结构化输出。
一、为什么结构化输出是刚需
Agent 的每次 LLM 调用,下游接的都是代码:分类结果要 if-else、抽取结果要入库、工具参数要执行。而模型的本能是写散文------「分类结果:这可能是一个 Bug,因为......」------下游代码直接崩。
工程化的 LLM 应用,90% 的输出都应该是结构化的。 结构化输出的可靠度分三层,从软到硬,失败率天差地别:
| 层 | 手段 | 合法 JSON 率 | 字段符合 Schema |
|---|---|---|---|
| 层 1 | Prompt 约束 | ~95% | ~85% |
| 层 2 | JSON mode | ~100% | ~90% |
| 层 3 | Schema 约束生成 | ~100% | ~100% |
(数字是量级示意,具体看模型------但顺序是稳定的:约束越靠底层,保证越强。)
二、层 1:Prompt 约束(软约束)
在 System Prompt 里定义格式 + few-shot 示范------零 API 依赖,所有模型都支持:
python
messages = [
{"role": "system", "content":
"分析用户反馈,只返回 JSON,不要任何其他文字。\n"
'格式:{"category": "Bug|需求|咨询", "severity": 1-5, "summary": "一句话"}\n'
'示例输入「登录按钮点不动」→ 输出 {"category": "Bug", "severity": 3, "summary": "登录按钮失效"}'},
{"role": "user", "content": feedback},
]
问题:模型偶尔会「加戏」------前后包 ```json 代码块、加一句解释、字段名擅自改。解析失败率几个百分点,量大会很难受。
什么时候用:原型、低频调用、下游有人工兜底。生产高频场景别停在这一层。
三、层 2:JSON mode(API 层保证合法 JSON)
主流 API 都有 JSON 模式:保证返回合法 JSON(不会包代码块、不会夹散文),但不保证字段符合你的 Schema:
python
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
response_format={"type": "json_object"}, # 合法 JSON 硬保证
)
data = json.loads(resp.choices[0].message.content) # 不会解析失败
JSON mode 的实现原理是约束采样:解码时只允许产生合法 JSON 的 token------这是解码层的硬保证,不是 prompt 恳求。
残留问题 :category 可能返回一个 Schema 里没有的值(「问题反馈」?),severity 可能是字符串不是数字。合法 JSON ≠ 符合 Schema。
四、层 3:Schema 约束生成(硬保证)
把完整 Schema 交给 API,解码层直接禁止生成 Schema 之外的 token:
python
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {
"name": "feedback_analysis",
"strict": True, # 硬保证:字段、类型、枚举全锁定
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["Bug", "需求", "咨询"]},
"severity": {"type": "integer", "minimum": 1, "maximum": 5},
"summary": {"type": "string"},
},
"required": ["category", "severity", "summary"],
"additionalProperties": False,
},
},
},
)
data = resp.choices[0].message.content # 100% 符合 Schema,直接用
strict 模式下枚举就是真枚举、类型就是真类型 ------「category 返回了 Schema 外的值」这种失败模式直接消失。Function Calling 篇的工具参数(input_schema)就是同一套机制:工具参数是最重要的结构化输出场景。
注意代价:strict Schema 会限制模型自由度,Schema 写太死(枚举太少、字段太碎)会挤歪模型的判断空间------Schema 设计本身是提示词工程的一部分。
五、兜底回路:Pydantic 校验 + 失败重试
即使层 3,也要留一道校验兜底(语义校验是 Schema 管不了的:severity=3 但 summary 说「很严重」,语法对语义错):
python
from pydantic import BaseModel, field_validator
class Feedback(BaseModel):
category: str
severity: int
summary: str
@field_validator("category")
@classmethod
def check_category(cls, v):
if v not in {"Bug", "需求", "咨询"}:
raise ValueError(f"非法类别: {v}")
return v
def extract(feedback: str, max_retries: int = 2) -> Feedback:
last_err = None
for _ in range(max_retries + 1):
resp = call_llm(feedback) # 任意层的结构化输出
try:
return Feedback.model_validate_json(resp)
except Exception as e:
last_err = e # 把校验错误回喂模型,让它自我修正
raise RuntimeError(f"结构化输出失败: {last_err}")
校验失败把错误信息回喂重试------和 Function Calling 篇的「错误当 tool_result 回填」同一个原理:模型拿到错误描述会自我修正,两轮重试后失败率趋近于零。
六、三层怎么选
| 场景 | 用哪层 | 理由 |
|---|---|---|
| Agent 工具调用 | FC 的 Schema(层 3) | 参数直接执行,必须硬保证 |
| 抽取入库、分类打标 | 层 3 或 层 2 + Pydantic | 量大,人工兜不起 |
| 原型验证、低频任务 | 层 1 | 零依赖,先跑通 |
| 模型不支持 JSON mode | 层 1 + Pydantic 重试 | 软约束 + 兜底回路 |
一句话:能上硬保证就上硬保证,上了硬保证也要留 Pydantic 兜底------语义错误是任何解码层约束都拦不住的。
七、踩坑提醒
- 别在 prompt 里写「请返回 JSON」就完事:软约束的失败率是按百分比算的,生产量级下每天都会崩几次------至少上 JSON mode
- Schema 别设计太碎:二十个嵌套字段会把模型挤得顾此失彼------扁平、少字段、枚举适度
- 温度对结构化输出仍然有效:分类任务低温更稳,别因为「反正有 Schema 兜底」就放任高温
- 校验错误信息要具体:回喂「校验失败」模型修不好,回喂「category 收到「问题反馈」,只允许 Bug/需求/咨询」才能自我修正
总结
| 层 | 手段 | 一句话 |
|---|---|---|
| 层 1 | Prompt 约束 | 零依赖软保证,原型用 |
| 层 2 | JSON mode | 合法 JSON 硬保证,字段仍可能歪 |
| 层 3 | Schema 约束生成 | 字段类型枚举全锁定,生产首选 |
| 兜底 | Pydantic + 错误回喂重试 | 语义错误最后防线,两轮重试归零 |
结合系列:Function Calling 的工具参数、Jev 的 Choice/Score、评测篇的判分------全是结构化输出的应用场景。Agent 工程化的一半工作,就是把模型的散文变成代码能信的数据。觉得有用点个关注。