一、背景:LLM 总在 JSON 上差一点
让 LLM 输出 JSON 做下游自动化,几乎人人都踩过坑:多一个尾逗号、字段名大小写错一个字母、多包一层 markdown 代码块、数字写成三千、枚举值自创一个。人工看没问题,json 解析直接炸,流水线全停。
解决思路不是在 prompt 里多喊两遍请输出合法 JSON,而是工程化三件套:Schema 约束(先定义好格式)加解析校验(用代码说了算)加失败重试(把错误喂回去让它改)。本文带你从 0 搭起这套机制。
二、原理:为什么必须有 Schema
- JSON Schema 与 Pydantic:先声明字段类型、必填项、枚举、数值范围,LLM 的输出不再是自由作文而是按表填空。
- 约束解码 vs 后校验:最高级的是推理时强制按 Schema 生成(结构化输出 API),退而求其次是生成后用代码校验,不合格就重试。后者通用、零依赖,本文主讲后者。
- 重试的本质:把报错信息(缺字段、类型错、JSON 解析错)拼进下一轮 prompt,模型看到具体错误,修正命中率远高于重新生成一次。
- 容错解析:先提取 markdown 代码块、修尾逗号、再做 JSON 解析,三板斧能救回一半差一点的输出。
三、环境准备
pip install pydantic jsonschema openai
本文示例兼容任何 OpenAI 兼容接口(OpenAI、本地 Ollama 与 LM Studio、国产大模型均可),把地址与模型名换成你的即可。建议先用 temperature 为 0 的确定性输出做结构化任务。
四、分步实战:从裸奔到可靠
第 1 步:反面教材——直接要 JSON
from openai import OpenAI
import json
client = OpenAI() # 需KEY,或换本地base_url
def naive_extract(text):
r = client.chat.completions.create(model="gpt-4o-mini", messages=[
{"role": "user", "content": f"从下面文本提取订单信息,输出JSON:{text}"}],
temperature=0)
return json.loads(r.choices[0].message.content) # 经常在这里炸
比如文本是张三买了 3 台显示器共 4500 元电话 138 开头,模型可能返回带代码块标记的内容,直接解析必炸。
第 2 步:用 Pydantic 定义 Schema
from pydantic import BaseModel, Field, ValidationError
from typing import Literal
class Order(BaseModel):
name: str = Field(description="客户姓名")
phone: str = Field(pattern=r"^1\d{10}$", description="11位手机号")
product: str
qty: int = Field(ge=1, le=1000)
total: float = Field(ge=0)
status: Literal["paid", "unpaid"] = "unpaid"
Schema 即契约:手机号正则、数量范围、状态枚举全部写死,模型猜不透就报错,报错就能修。
第 3 步:强约束 Prompt 加容错解析
SCHEMA_DESC = Order.model_json_schema()
SYSTEM = f"你只输出JSON,不要任何解释,不要markdown代码块。必须符合以下Schema:{json.dumps(SCHEMA_DESC, ensure_ascii=False)}"
def robust_loads(s: str):
s = s.strip()
if "```" in s: # 剥掉代码块
s = s.split("```")[1].replace("json", "", 1).strip()
s = s[s.find("{"):s.rfind("}") + 1] # 只取首尾大括号之间
s = s.replace(",\n}", "\n}").replace(",}", "}") # 修尾逗号
return json.loads(s)
第 4 步:校验加错误回抛重试(完整可跑)
def extract_order(text, max_retries=2):
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": text},
]
last_err = ""
for _ in range(max_retries + 1):
r = client.chat.completions.create(model="gpt-4o-mini",
messages=messages, temperature=0)
raw = r.choices[0].message.content
try:
data = robust_loads(raw)
order = Order(**data) # Pydantic校验
return order, raw
except (json.JSONDecodeError, ValidationError, ValueError) as e:
last_err = str(e)[:500]
messages.append({"role": "assistant", "content": raw})
messages.append({"role": "user",
"content": f"上次输出校验失败:{last_err}。请只输出修正后的JSON,不要解释。"})
raise RuntimeError(f"抽取失败:{last_err}")
order, raw = extract_order("张三买了3台显示器共4500元,电话13800001111,未付款")
print(order.model_dump_json(ensure_ascii=False, indent=2))
这个循环是全文核心:第一次成功率也许 85%,加一次错误回抛重试后通常冲到 97% 以上。
第 5 步:批量与计算字段防跑偏
from typing import List
class Item(BaseModel):
product: str
qty: int = Field(ge=1)
price: float = Field(ge=0)
class Receipt(BaseModel):
items: List[Item] = Field(min_length=1)
total: float
对计算型字段一定要在校验层二次验算(总额是否等于各项小计之和),LLM 算术不靠谱是常识。prompt 里追加总额必须等于各项数量乘单价之和,输出前自己验算一遍。
第 6 步:结构化输出 API(如果有就用)
OpenAI 与部分国产模型支持 response_format 为 json_object 或 json_schema 参数,能从解码层面保证合法 JSON。有就开,没有就用上面的后校验加重试,两者可叠加。
五、常见坑
- prompt 里贴 Schema 却不校验:模型看一眼就忘,必须用 Pydantic 或 jsonschema 在代码里卡死。
- temperature 太高:结构化任务一律 0 到 0.2,创意交给正文,格式必须稳定。
- 代码块没剥:一半的解析失败是因为三引号代码块包裹,解析函数先处理它。
- 枚举值自创:状态写成已付款而不是 paid。对策:Schema 用字面量锁死加 prompt 举例加校验失败回抛。
- 无限重试烧钱:max_retries 设 2 到 3 次,仍失败就记日志转人工,别死循环。
- 大 JSON 一次输出:字段超过 20 个就拆成多次抽取再合并,单次越长出错率越高。
- 数字带单位:数量返回 3 台。prompt 强调纯数字不要单位,或在校验层做清洗。
六、总结
可靠结构化输出等于好 Schema 加狠校验加聪明重试。先定义 Pydantic 模型,再写容错解析,最后把报错喂回去让模型自修,这套流程与模型无关,换任何 LLM 都适用。从今天起,别再裸调 JSON 解析了,给你的流水线加一道校验门,半夜报警会少一半。
七、深入:三种重试策略与生产级封装
重试不是无脑再跑一次,分三种,效果差很多。第一,同温重试:temperature 不变再生成一次,适合偶发格式抖动,成本最低,先试它。第二,降温重试:第一轮用 0.3 保证多样性,失败后降到 0 再跑,输出更保守,适合枚举值跑偏。第三,升级重试:小模型连续两次失败,转大模型或结构化输出 API 跑最后一击,适合重要工单,成本最高。实测组合是同温一次加降温一次,解决 95% 的问题,剩下转人工。
大 JSON 拆分原则:字段超过 20 个、或含数组嵌套,一次输出的出错率指数上升。拆成两次抽取:第一次抽主体字段,第二次抽明细数组,最后按订单号合并。每次 prompt 只放本次需要的 Schema 子集,模型负担小,准确率高。流式场景下先收齐再校验,不要边收边解析,半截 JSON 的报错没有意义。
生产级封装建议:抽取函数返回统一结构(成功、数据、原始文本、重试次数、错误),打日志入库;失败超阈值告警并转人工队列;prompt 模板放配置文件,换模型只改一处。完整封装如下:
import time, logging
from dataclasses import dataclass
@dataclass
class ExtractResult:
ok: bool
order: Order | None
raw: str
attempts: int
error: str = ""
def extract_production(text, temps=(0.3, 0.0), timeout=30):
t0 = time.time()
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": text},
]
last_err, raw, attempts = "", "", 0
for temp in temps:
if time.time() - t0 > timeout:
break
attempts += 1
r = client.chat.completions.create(model="gpt-4o-mini",
messages=messages, temperature=temp)
raw = r.choices[0].message.content
try:
return ExtractResult(True, Order(**robust_loads(raw)), raw, attempts)
except Exception as e:
last_err = str(e)[:300]
logging.warning("extract fail attempt=%d err=%s", attempts, last_err)
messages += [{"role": "assistant", "content": raw},
{"role": "user", "content": f"校验失败:{last_err}。只输出修正后的JSON。"}]
return ExtractResult(False, None, raw, attempts, last_err)
JSON Schema 手写还是 Pydantic 生成:推荐 Pydantic 为唯一源,用 model_json_schema 导出给 prompt 和文档,改一处全同步。手写 Schema 与代码校验两张皮,迟早不一致。版本变更时给 Schema 加 version 字段,日志里记下,方便回查某次失败用的是哪版格式。
八、三个真实脏文本案例与成本账
看三个生产级脏输入,体会校验门的价值。案例一:用户写138 0000 1111(带空格),模型照抄进 phone,正则拦截,重试后模型去掉空格通过。案例二:用户说显示器 3 台共 4500,未付款,模型 status 输出中文未付款,字面量校验拦截,重试后改 paid 还是 unpaid?这里 prompt 必须给映射规则(未付款对应 unpaid),否则模型连错三次。案例三:用户一次报三件商品,模型只抽两件,items 长度对但总额对不上,靠总额验算发现漏项,重试时要求逐项列出才补齐。三个案例说明:格式、枚举、完备性各需一道检查,缺一不可。
成本账也要算。一次抽取平均 800 token 输入加 200 输出,重试一次翻倍。按 GPT-4o-mini 价格,万次抽取成本几块钱,重试开到 2 次完全不心疼。但用 GPT-4 级模型做抽取,成本差 20 倍。正确姿势是小模型加严校验加重试,准确率追平大模型,成本省一个数量级。日志里记下每次的 attempts 分布:如果零重试成功率低于 80%,说明 prompt 或 Schema 要修,而不是加重试次数。
Schema 演进记住三条:只增字段不改旧字段含义,废弃字段标记 deprecated 而非删除,下游按 version 分流。否则线上跑着的抽取半夜全挂,查日志才发现是有人改了个字段名。
九、Prompt 模板库与上线验收标准
抽取 prompt 建议固定三段式:第一段讲身份和输出约束(只输出 JSON、无解释、无代码块);第二段贴 Schema(含字段说明、枚举映射、单位要求如纯数字不要单位);第三段给一正一反两个例子(正确输出长什么样、错误输出错在哪)。例子比规则管用,模型看例子学格式,看规则只记一半。枚举字段必须给中文到英文的映射表(如已付款对应 paid),不要考验模型的翻译运气。
上线验收三条硬指标:100 条真实文本跑一遍,零重试成功率过 80%,重试后总成功率过 97%,平均 attempts 低于 1.3。任何一条不达标都不要上线,先修 prompt 和 Schema。压测跑 500 次看 Pydantic 报错分布:最多的一类错误就是下期优化目标(比如全是 phone 格式,就加预清洗函数)。灰度期所有失败进人工队列并标原因,每周复盘一次,prompt 模板小步迭代。这套机制跑顺后,换模型只是换个名字重跑验收,半小时出结论。
十、多轮抽取与追问补全
真实场景里信息分多句给出:用户先说买了 3 台显示器,追问才报电话。抽取函数要支持多轮:维护一个已确认字段池,每轮只抽新增,合并后整体校验。缺必填字段不报错重试,而是生成追问(电话还缺,请用户补充),拿到补充再合。追问话术由模型生成,但追问哪些字段由代码决定(看哪些必填为空),分工不能反。会话状态存服务端,超时清理,避免串号。多轮加追问,信息抽取从单次赌博变成渐进收敛,客服和表单场景全靠它。
预清洗函数值得单独一节:电话去空格去横杠、金额去逗号和元字、枚举先映射后校验。放模型前面做,确定性规则不消耗 token,还能把零重试成功率抬 10 个点。记住分工:机器能洗的不要让模型猜,模型只处理真正的语义(未付款对应哪个枚举、商品简称对应哪个全称)。这条分工线划对了,成本和准确率双赢。
最后一条军规:校验逻辑必须和 prompt 同版本发布。见过最多的线上事故是 prompt 更新了、校验没跟上(或反过来),新格式被老校验打回,白白重试烧钱。做法是 Schema、prompt 模板、校验代码三者同仓库同版本,CI 里跑 50 条黄金用例,全过才允许合并。发布后灰度 10% 流量看 attempts 分布,平稳再全量。结构化抽取做得到这里,才算从 demo 毕业,真正扛得住业务。
附:首周行动清单。周一定义第一个 Pydantic 模型并跑通单条抽取;周二加上容错解析和重试循环;周三用 100 条真实文本验收三项指标;周四封装生产函数接入日志和人工队列;周五灰度 10% 流量观察 attempts 分布。五天下来,可靠结构化输出从技巧变成流水线,之后每接一个新实体都是重复这套动作,越接越快。
一句话收束:好 Schema 是契约,狠校验是门禁,聪明重试是保险,三者缺一不可。小模型加严校验打平大模型,多轮加追问收敛真实场景,同版本发布守住线上。每接一个新实体就重复首周清单,指标不达标不上线。做到这份上,LLM 的结构化输出就从玄学变成工程,半夜被告警叫醒的日子一去不返。 点击阅读原文
写在最后:今晚只干一件事——给你的业务定义第一个 Pydantic 模型,跑通一次带重试的抽取,亲眼看到报错被喂回去、第二次输出变对。看到这个循环转起来,你就掌握了可靠结构化的全部秘密。剩下的只是复制:更多字段、更多实体、更高指标,一套动作重复打。 点击阅读原文
参考资料:freeCodeCamp《How to Get Reliable Structured Data Out of an LLM》一文的方法论。 点击阅读原文