把大模型接进业务系统时,第一道真正的坎往往不是模型效果,而是结构化输出不稳定:同一个抽取任务,九次返回干净 JSON,第十次多带一段”好的,以下是结果”的寒暄,直接把下游 json.loads 打崩。本文用三层手段治这个病——提示工程收敛格式、JSON Schema 约束解码从根上限制字符生成、Pydantic 做最后一道校验,并给出商用 API 与本地模型两条落地路径,让 LLM 的输出能直接落库。
一、为什么”让模型输出 JSON”没那么简单
大模型本质是逐 token 采样的概率机器,它没有”必须闭合花括号”的硬约束。你在提示词里写十遍”只返回 JSON,不要多余文字”,也只是把违规概率从 10% 压到 2%,而不是压到 0。而在生产链路上,2% 的失败率意味着每天上万次调用里有两百次脏数据,如果没有兜底,脏数据会一路流进数据库。
更麻烦的是失败形态五花八门:Markdown 代码围栏包裹、字段名大小写漂移、数字被写成中文、枚举值自由发挥、必填字段静默缺失、长文本被截断导致 JSON 半截。这些错误单看都不致命,但组合起来会让上游服务的异常分支比主逻辑还长。所以正确姿势不是”祈祷模型听话”,而是把格式变成系统约束,而非请求。
值得强调的是,结构化输出和工具调用是两件不同的事。工具调用关心”模型该调哪个函数、传什么参数”,属于动作决策;结构化输出关心”模型返回的数据能否被程序直接消费”,属于数据契约。二者底层技术相通,但设计目标不同,之前介绍过的大模型工具调用 Function Calling 实战解决前者,本文解决后者。
二、三条约束路线的取舍
目前把输出压成固定结构,主流有三条路线:纯提示词约束、借工具调用的参数 schema、以及原生的约束解码(constrained decoding)。它们的可靠性与成本差异很大,选错会白白付出工程量。
| 方案 | 可靠性 | 实现成本 | 适用场景 |
|---|---|---|---|
| 提示词约束 + 后处理清洗 | 低(约 95%~98%) | 极低 | 原型验证、字段少、可人工复核 |
| Function Calling 参数 schema | 中高 | 中 | 已用工具调用链路,顺带复用 |
| JSON Schema 约束解码 | 高(接近 100% 合法) | 中 | 生产抽取、批量落库、无人值守 |
| 本地模型 GBNF / outlines 语法 | 高 | 较高 | 私有化部署、数据不出内网 |
结论很直接:能用约束解码就别只靠提示词。约束解码的原理是在采样阶段按 schema 生成状态机,把不合法的 token 概率直接掩掉——模型”物理上”写不出非法字符,因此不再需要靠正则去抠 JSON。提示词仍然重要,但它的职责从”保证格式”退回到”保证语义正确”,各司其职。
三、OpenAI 兼容接口:response_format 实战
绝大多数商用 API 与国内兼容网关都支持 response_format。用 json_schema 模式时要注意两个细节:一是 strict: true 才会真正启用严格约束;二是严格模式下通常要求所有字段都出现在 required 里,可选字段用联合类型(如 ["string", "null"])表达,而不是省略。
from openai import OpenAI
client = OpenAI()
schema = {
"name": "resume_extract",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"years": {"type": "integer", "minimum": 0, "maximum": 50},
"skills": {"type": "array", "items": {"type": "string"}},
"level": {"type": "string", "enum": ["junior", "mid", "senior"]},
"email": {"type": ["string", "null"]}
},
"required": ["name", "years", "skills", "level", "email"],
"additionalProperties": False
}
}
resp = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0,
response_format={"type": "json_schema", "json_schema": schema},
messages=[
{"role": "system", "content": "你是简历信息抽取器,只依据原文填写,缺失填 null,不要推测。"},
{"role": "user", "content": resume_text}
]
)
data = json.loads(resp.choices[0].message.content) # 可直接消费
三个容易踩的点值得单独记住。第一,additionalProperties: False 一定要写,否则模型会热心地多塞字段,导致落库时字段错位。第二,抽取类任务把 temperature 设为 0,减少同一输入的漂移,便于做回归测试。第三,枚举值一律用 enum 锁死,不要在提示词里用自然语言描述”请返回初级/中级/高级”,那等于把校验责任丢回给了自己。
四、用 Pydantic 把 Schema 和业务模型合一
手写 JSON Schema 很快就会失控:字段一多,schema 和业务实体两头维护,改一处忘一处。更好的做法是用 Pydantic 定义唯一契约,自动导出 schema,同时兼作运行时校验器——一份定义,既约束模型也保护数据库。
from typing import Literal, Optional, List
from pydantic import BaseModel, Field, ValidationError
class Resume(BaseModel):
name: str = Field(description="候选人姓名,原文出现的写法")
years: int = Field(ge=0, le=50, description="工作年限")
skills: List[str] = Field(default_factory=list, max_length=20)
level: Literal["junior", "mid", "senior"]
email: Optional[str] = None
# 1) 导出 schema 给模型做约束解码
json_schema = Resume.model_json_schema()
# 2) 拿到回复后再做一次运行时校验,双保险
try:
obj = Resume.model_validate_json(raw_text)
except ValidationError as e:
# 把错误摘要回灌给模型做一次自修复
repair_hint = e.json(include_url=False)
raise
Pydantic 的 description 会被写入导出的 schema,模型在生成时能看到字段语义,等于把一部分提示词沉到了契约里,比在 system prompt 里堆一大段字段说明更精准。这套写法在 LangChain 的结构化输出封装里也是同一思路,如果你的链路已经在用 Agent 编排,可以直接复用之前LangChain Agent 工具调用与自主编排里的模型客户端,只把输出解析层换成 Pydantic 契约。
五、本地模型怎么做:Ollama 与 vLLM 的约束
私有化场景数据不能出内网,同样能拿到强约束。Ollama 支持在请求里直接传 format 字段(可传 "json",新版本也支持完整 JSON Schema);vLLM 则通过 guided decoding 参数暴露同类能力,底层用的是 outlines 一类的语法约束引擎。
# Ollama:传入完整 schema 做约束
curl http://localhost:11434/api/chat -d '{
"model": "qwen2.5:7b",
"stream": false,
"options": {"temperature": 0},
"format": {
"type": "object",
"properties": {
"sentiment": {"type": "string", "enum": ["pos", "neg", "neutral"]},
"score": {"type": "number"}
},
"required": ["sentiment", "score"]
},
"messages": [{"role": "user", "content": "评价:物流很快但包装破损"}]
}'
# vLLM(OpenAI 兼容端点):guided_json 走约束解码
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen2.5-7B-Instruct",
"temperature": 0,
"messages": [{"role": "user", "content": "抽取工单要素"}],
"extra_body": {"guided_json": {"type": "object",
"properties": {"module": {"type": "string"}, "severity": {"type": "integer"}},
"required": ["module", "severity"]}}
}'
本地路线有个额外收益:约束解码会砍掉大量无效搜索空间,小参数模型在固定结构任务上的表现会明显好于放任自由生成,7B 级别模型做字段抽取常常够用,没必要上更大的模型。模型本地管理与量化调优可参考站内Ollama 模型管理:Modelfile 自定义与调优,高吞吐服务化部署则见vLLM 部署实战:高吞吐推理服务调优。
六、重试、自修复与降级三件套
即使有约束解码,也仍会遇到”格式合法但语义错误”的情况:日期写成去年、金额少一个零、把推测值当成原文事实。所以生产链路必须补上三层兜底,且严格区分”格式错误”与”语义错误”的处理方式。
import json, time
from pydantic import ValidationError
def extract_with_repair(text, max_retry=2):
messages = build_messages(text)
for attempt in range(max_retry + 1):
raw = call_llm(messages) # 已带 json_schema 约束
try:
return Resume.model_validate_json(raw)
except ValidationError as e:
if attempt == max_retry:
log_to_dlq(text, raw, str(e)) # 进死信队列,人工复核
return None
# 自修复:把上次错误输出 + 校验错误一起回灌
messages += [
{"role": "assistant", "content": raw},
{"role": "user",
"content": f"上次输出不符合契约,错误:{e.errors()};请仅修正错误字段后重新输出。"}
]
time.sleep(1.5 * (attempt + 1)) # 退避,避免打爆限流
关键设计有三点:一是把错误信息回灌而不是简单重跑,模型看到具体校验报错的修复成功率远高于盲目重试;二是重试上限要小(1~2 次),失败即入死信队列,别让重试风暴拖垮整条流水线;三是死信数据必须留原文与原始输出,这是后续优化提示词与 schema 的唯一素材。这套评测与回归的思路,和之前写过的向量检索评测实战:Recall@K 与 RAG 质量度量一脉相承——没有度量就没有优化。
七、生产避坑清单
| 坑点 | 症状 | 解法 |
|---|---|---|
| 只靠提示词约束格式 | 偶发 JSON 解析失败 | 启用 json_schema / guided_json 约束解码 |
| 未设 additionalProperties | 模型多返回字段,落库错位 | 显式设为 false |
| schema 与业务模型两头维护 | 字段改动不同步 | Pydantic 单一契约导出 schema |
| max_tokens 给太小 | 长结果被截断成半截 JSON | 按字段规模预估并留 30% 余量 |
| 无限重试 | 限流雪崩、成本失控 | 限重试 2 次 + 退避 + 死信队列 |
| 缺失字段用空串填充 | 脏数据污染统计 | 可选字段用 null,禁止模型推测 |
| temperature 默认值 | 同一输入结果漂移 | 抽取类任务固定为 0 |
另外提醒一点:schema 不是越复杂越好。嵌套超过三层、字段超过二十个的巨型 schema,会显著拉低小模型的准确率,也让约束解码的状态机开销上升。遇到复杂结构,优先拆成多次调用(先分类,再按类别抽取对应字段),准确率通常比一把梭更高,也更好定位问题环节。
八、小结
让大模型输出稳定的结构化数据,靠的不是更用力地写提示词,而是三层递进的工程约束:提示工程负责语义正确、JSON Schema 约束解码负责格式一定合法、Pydantic 校验加自修复负责最后兜底与可观测。把这三层搭起来后,LLM 的输出才算真正具备”可以直接进数据库”的资格,抽取类业务也就从演示 Demo 变成了能无人值守跑批的生产组件。落地时建议按本文顺序推进:先用 Pydantic 定义契约,再接约束解码,最后补重试与死信队列,每一步都能独立验证收益。




