让大模型返回 JSON,看着是最简单的需求,实际却是最容易翻车的一环。你要的是结构化输出,模型给你的可能是一段被 “`json 包裹的字符串、一个少了两层嵌套的对象、一段被 max_tokens 砍断的半截 JSON,或者字段名明明是 user_id 它偏要写成 userId。JSON Schema 能把这类事故的概率从“经常”压到“偶尔”,但真要上生产,还得在 Schema 外面再叠一层容错解析和校验。本文给出可直接照抄的代码,把这条链路从调用、约束、兜底到落库一次打通。
一、先搞清楚:JSON 为什么总是“差一点”
大模型的输出本质是逐 token 采样,语法并不是硬约束,而是靠训练分布“习惯性”遵守。理解失败来源,才知道该在哪一层加钩子。
| 失败形态 | 典型表现 | 根因 |
|---|---|---|
| 包裹污染 | “`json … “` 代码块、前言“好的,这是结果:” | 模型沿用了对话习惯 |
| 字段漂移 | user_id → userId、缺 required 字段、多出解释字段 | 缺少 schema 级强约束 |
| 截断 | 结尾缺 } 或 ],JSON 解析报 Unexpected EOF | 输出超过 max_tokens |
| 类型混淆 | 数字写成字符串、布尔写成 “true” | 未声明 type 或未开 strict |
| 全角污染 | 中文引号 “ ”,中文逗号,尾随逗号 | 中文语料导致的标点混用 |
| 枚举越界 | priority 返回 “urgent” 而你要的是 high/medium/low | 未用 enum 限值 |
这六种里,只有“截断”属于长度问题,其余五种都能靠 Schema 的 strict 模式挡掉大半。但请注意:strict 只保证结构符合定义,不保证语义正确。字段齐了,值仍可能胡说,这是后面校验层要解决的。
二、三档约束手段,强弱差别很大
很多人以为“在提示词里写一句只输出 JSON”就等于结构化,其实那只是最弱的一档。按保证强度从低到高排:
| 手段 | 保证强度 | 代价 | 适用场景 |
|---|---|---|---|
| 提示词约束 | 弱,纯靠概率 | 零成本,偶发失败 | 一次性脚本、原型验证 |
| response_format=json_object | 中,只保证是合法 JSON | 需 prompt 中出现 json 字样 | 简单 KV 抽取 |
| response_format=json_schema(strict) | 强,结构+类型+枚举全约束 | 首次调用有 schema 编译开销 | 生产入库、下游强依赖字段 |
| Function Calling | 强,等价于 schema 通道 | 多一次工具选择逻辑 | 需要模型决定“调不调”的场景 |
如果你的服务要接多家模型,建议在网关层统一收口这些差异——字段名、strict 支持度、json_schema 与 tools 的兼容性各家不一,用一层网关包住能省掉大量适配代码,具体做法见LiteLLM 实战:用统一网关接入多家大模型与成本管控。
三、把输出契约写成 JSON Schema
Schema 不是越复杂越好,能表达业务约束即可。以下是提取“工单信息”的实战样例,注意四个关键点:required 列全、additionalProperties:false 封死扩展、枚举字段用 enum、数值给上下界。
{
"type": "object",
"additionalProperties": false,
"required": ["ticket_id", "title", "priority", "tags", "confidence"],
"properties": {
"ticket_id": { "type": "string", "pattern": "^T-[0-9]{6}$" },
"title": { "type": "string", "minLength": 4, "maxLength": 60 },
"priority": { "type": "string", "enum": ["high", "medium", "low"] },
"tags": { "type": "array", "items": { "type": "string" }, "maxItems": 5 },
"confidence":{ "type": "number", "minimum": 0, "maximum": 1 },
"due_date": { "type": ["string", "null"], "format": "date" }
}
}
坑点提醒:format: date 在 strict 模式下属于“软约束”,模型仍可能给出 2026-13-45,所以日期字段最好保留 null 分支,把非法值交给校验层降级,而不是指望 Schema 堵住。另外 pattern、minLength 这类关键字在部分厂商的 strict 实现里会被忽略,上线前务必用真实接口跑一轮试探。
四、用 json_schema 发起调用
下面是 OpenAI 兼容接口的最小可运行示例。注意 strict:true 必须放在每个 schema 节点里,而不是只放顶层——漏掉内层是最常见的“strict 看似开了但没生效”的原因。
import json, requests
resp = requests.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={
"model": "gpt-4o-mini",
"temperature": 0,
"messages": [
{"role": "system", "content": "你是工单解析器,只输出符合 schema 的 JSON。"},
{"role": "user", "content": "客户说:系统登录一直转圈,很急,今天必须好"},
],
"response_format": {
"type": "json_schema",
"json_schema": {"name": "ticket", "strict": True, "schema": TICKET_SCHEMA},
},
},
timeout=60,
)
data = json.loads(resp.json()["choices"][0]["message"]["content"])
print(data["priority"], data["confidence"])
两个工程细节:第一,同一个 schema 第一次请求会被编译,延迟可能比后续高出一截,所以服务启动时应该发一次“预热请求”,别让首个真实用户承担这笔开销;第二,schema 一旦变更就等于换了契约,务必带版本号,并在日志里记录版本,否则出问题时你无法判断是模型变了还是契约变了。
五、Function Calling:兼容性更好的第二条路
并非所有厂商都完整支持 json_schema,但绝大多数已经支持 tools。把“函数参数”当成输出 schema 用,配合 tool_choice 强制选择,效果几乎等价,而且在国内模型上踩坑更少。
tools = [{
"type": "function",
"function": {
"name": "save_ticket",
"description": "保存解析后的工单信息",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"priority": {"type": "string", "enum": ["high", "medium", "low"]},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "priority", "tags"],
},
},
}]
resp = client.chat.completions.create(
model="glm-4.7-flash",
messages=[{"role": "user", "content": user_text}],
tools=tools,
tool_choice={"type": "function", "function": {"name": "save_ticket"}},
)
args = json.loads(resp.choices[0].message.tool_calls[0].function.arguments)
这条路和智能体的工具调用是同一套机制,区别只在“谁来触发”——这里你强制模型调用,智能体场景下则交给模型自己判断。关于多工具编排的完整链路,可参考AI 智能体 2026:从工具调用到多智能体协作。
六、四道容错防线:把“偶尔失败”变成“几乎不失败”
即使开了 strict,线上依然会遇到截断、超时降级、模型切换导致的格式回退。我的做法是四道防线串联,每道都便宜、每道都能独立生效。
第一道:截断修复
先判断是否只是尾部不完整,是就按括号栈补齐——比直接重试便宜得多。
def repair_truncated(s: str) -> str:
stack, in_str, esc = [], False, False
for ch in s:
if esc: esc = False; continue
if ch == "\\": esc = True; continue
if ch == '"': in_str = not in_str; continue
if in_str: continue
if ch in "{[":
stack.append("}" if ch == "{" else "]")
elif ch in "}]" and stack:
stack.pop()
if in_str:
s += '"'
return s + "".join(reversed(stack))
第二道:提取与清洗
正则抓最外层大括号,顺手清掉全角引号、中文逗号和尾随逗号。这三类污染在中文语料上出现频率高得离谱。
import re
def extract_json(text: str) -> str:
text = text.strip()
text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.M).strip()
start, end = text.find("{"), text.rfind("}")
if start == -1 or end == -1:
raise ValueError("no json object found")
s = text[start:end + 1]
s = s.replace("“", '"').replace("”", '"').replace("’", "'")
s = s.replace(",", ",").replace(":", ":")
s = re.sub(r",(\s*[}\]])", r"\1", s) # 去尾随逗号
return repair_truncated(s)
第三道:Schema 校验
用 jsonschema 或 Pydantic 做真正校验,重点是把错误信息结构化——它要能直接回灌给模型当修复提示。
from jsonschema import Draft202012Validator
def validate(obj, schema):
v = Draft202012Validator(schema)
errs = sorted(v.iter_errors(obj), key=lambda e: list(e.path))
if not errs:
return obj, []
msgs = [f"{'/'.join(map(str, e.path)) or '$'}: {e.message}" for e in errs[:5]]
return None, msgs
第四道:带错误回灌的重试
重试不是原样再问一遍,而是把校验错误原样贴回去,并把温度压到 0。两次仍失败就落库到“待人工”表,绝不让脏数据流进主表。
def structured_call(user_text, schema, max_retry=2):
messages = [
{"role": "system", "content": "只输出 JSON,不要解释。"},
{"role": "user", "content": user_text},
]
for i in range(max_retry + 1):
raw = chat(messages, temperature=0)
try:
obj = json.loads(extract_json(raw))
except Exception as e:
messages += [{"role": "assistant", "content": raw},
{"role": "user", "content": f"JSON 解析失败:{e},请只返回合法 JSON"}]
continue
obj, errs = validate(obj, schema)
if obj is not None:
return obj, "ok"
messages += [{"role": "assistant", "content": raw},
{"role": "user", "content": "字段校验失败:" + "; ".join(errs) + ",请修正后重发"}]
return None, "manual_review"
七、流式输出下别急着校验
开了 stream=True 之后,你拿到的是碎片,任何时刻的累积结果都可能是半截 JSON,此时跑 Schema 校验几乎必然报错。实践里分两种处理:前端展示用流式,落库用非流式再确认一次;或者引入增量 JSON 解析器,只在遇到完整的对象边界时才推给下游。前一种更简单,也更省钱,因为二次确认可以只带已解析字段,不必重发全文。
同样的思路在检索类应用里更重要:当结构化输出要拼接向量召回结果时,字段契约一旦漂移,检索结果就无法映射回原文,可先看RAG 从零搭建实战:用向量检索增强大模型回答,以及Embeddings 向量嵌入实战:从原理到语义检索落地中的元数据设计部分。
八、上线前对照这份清单
| 检查项 | 建议做法 | 为什么 |
|---|---|---|
| Schema 版本 | 纳入 Git,版本号写进日志 | 出现脏数据能定位是契约变更还是模型漂移 |
| strict 覆盖 | 每个嵌套节点都带 strict:true | 顶层 strict 不会向下继承 |
| 自由文本字段 | 尽量换 enum,实在要自由则限 maxLength | 自由文本是幻觉与越界的主要来源 |
| 数值字段 | 必须给 minimum/maximum | 越界值会直接污染统计与排序 |
| 失败率监控 | 记录 extract/validate 失败次数,超 1% 告警 | 失败率突增往往先于业务异常出现 |
| 降级路径 | 失败落 manual_review 表,不进主表 | 宁可少一条,不可脏一片 |
| 大对象 | 拆成多次调用,单次 schema 不超过 20 个字段 | 字段越多首字准确率越低 |
九、小结
结构化输出这件事,本质是把“请求模型自觉”改成“工程上强制”。三层就够用:能用 json_schema 就用,不能就退到 Function Calling;外面固定包一层 extract + validate + 带错重试;最后所有落库动作都过校验,失败即拦截。照这套下来,我手上的抽取任务失败率从 8% 左右降到了千分之几,剩下的基本是输入本身有歧义,属于人工该介入的部分。




