把模型输出直接 json.loads 是这条链路上最常见的崩溃点。上游只要多输出一句"好的,以下是结果:",解析就整体失败;而失败发生在业务代码里,日志里只有一句 JSONDecodeError,看不出模型到底给了什么。
结构化输出要解决的不是"让模型输出 JSON",而是在不可靠的生成之上,构造一条可靠的解析与校验流水线。
1. 为什么需要结构化输出
| 下游动作 | 需要什么 | 自由文本的问题 |
|---|---|---|
| 入库 | 固定字段、类型 | 字段名漂移,类型不确定 |
| 路由/分支 | 枚举值 | "大概属于退款类"无法 switch |
| 调接口 | 参数齐全、类型正确 | 缺字段只能靠正则捞 |
| 展示 | 稳定 schema | 结构随生成变化,前端无法渲染 |
| 审计 | 可校验、可比对 | 无法做 schema diff |
判据:下游是不是代码。只要下游是代码而不是人,就必须有 schema。
2. 三条实现路线
| 路线 | 做法 | 可靠性 | 成本 | 适用 |
|---|---|---|---|---|
| 提示词约束 + 解析 | 在 prompt 里写"只输出 JSON" | 低 | 最低 | 原型、内部工具 |
| JSON mode | 请求参数强制合法 JSON | 中(合法≠符合 schema) | 低 | 简单扁平结构 |
| 工具/函数调用 + 严格 schema | 由 schema 约束生成 | 高 | 中 | 生产、复杂嵌套 |
JSON mode 只保证"语法合法",不保证字段对得上;工具调用才把 schema 变成生成约束。两者差别在字段级,不在语法级。
# 提示词约束路线:必须自己兜底,模型随时可能加前后缀
PROMPT = """只输出 JSON,不要任何解释文字。字段:intent(枚举: refund|logistics|other), order_id(string)"""
3. schema 设计原则
| 原则 | 反例 | 正例 |
|---|---|---|
| 字段少而明确 | 15 个字段一次填 | 拆成两次调用 |
| 枚举优于自由文本 | category: string |
enum: ["退款","物流","其他"] |
| 必填只留真必需 | 所有字段 required | 只 required 关键字段 |
| 嵌套层数受控 | 4 层嵌套对象 | ≤2 层,深层拆引用 |
| 命名无歧义 | date(哪个日期?) |
refund_apply_date |
{
"type": "object",
"properties": {
"intent": {"type": "string", "enum": ["refund", "logistics", "other"]},
"order_id": {"type": "string", "pattern": "^SO[0-9]{8,}$"},
"urgency": {"type": "string", "enum": ["low", "normal", "high"], "default": "normal"}
},
"required": ["intent"],
"additionalProperties": false
}
additionalProperties: false 很关键:不写它,模型会热情地补充它认为有用的字段,你的反序列化器要么报错要么静默丢弃。
嵌套是最容易低估的成本。层数一多,模型漏填深层字段的概率显著上升;把深层对象改成"id 引用 + 第二次调用填充",可靠性通常更高。
4. 解析失败的四种原因与容错顺序
| 原因 | 表现 | 处理 |
|---|---|---|
| 多余文本 | Here is the JSON: {...} |
剥离前后缀,提取首个 { 到末个 } |
| 语法错误 | 尾逗号、单引号、未闭合 | 尝试修复,失败则重试 |
| 字段缺失 | 必填字段不存在 | 回灌缺失字段名重生成 |
| 类型不符 | "3" 而非 3 |
宽松转换,转换失败再重试 |
import json, re
def parse_robust(text: str):
# 1) 剥离:取第一个 { 到最后一个 },容忍 markdown 围栏
s = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip()
i, j = s.find("{"), s.rfind("}")
if i >= 0 and j > i:
s = s[i:j + 1]
# 2) 解析
try:
return json.loads(s), None
except json.JSONDecodeError as e:
return None, {"stage": "syntax", "error": str(e), "raw": text[:500]}
# 3) 修复/重试/降级由上层编排
容错顺序固定为:剥离 → 解析 → 修复 → 带错误信息重试 → 降级。顺序颠倒(先重试再剥离)会白烧一次调用,因为模型多半还是会加那句话。
5. 校验的两层与回灌
| 层 | 校验什么 | 失败后 |
|---|---|---|
| 语法层 | JSON 合法、schema 匹配、类型正确 | 修复或重试 |
| 语义层 | 业务规则(金额>0、日期在未来、枚举组合合法) | 回灌具体违反项,让模型修正 |
from pydantic import BaseModel, Field, ValidationError
class Refund(BaseModel):
order_id: str = Field(pattern=r"^SO[0-9]{8,}$")
amount: float = Field(gt=0, le=100000)
reason: str = Field(min_length=4, max_length=200)
def validate_two_layers(raw: dict):
try:
obj = Refund(**raw) # 语法层:类型/范围/正则
except ValidationError as e:
return None, {"layer": "schema", "errors": e.errors()}
if obj.amount > order_total(obj.order_id):
return None, {"layer": "business", "error": "退款金额超过订单金额"}
return obj, None
校验必须在解析成功之后、入库之前,这个顺序没有争议。真正容易搞反的是另一处:把 schema 校验放在"调用下游"之后——那时脏数据已经写进库了。
回灌要带"可执行的信息":不要只说"校验失败",要说"amount 必须大于 0,你给了 -5"。模型能修正的前提是知道错在哪一字段、错在什么规则。
6. 函数调用的多轮与并行
user: 帮我查 SO20261109001 的退款状态
assistant(tool_calls): refund_query({"order_id": "SO20261109001"})
tool: {"status": "处理中", "eta_days": 3}
assistant: 你的退款正在处理中,预计 3 天内到账。
四步固定:模型要求调用 → 你执行 → 把结果作为 tool 角色消息回传 → 模型继续。最容易错的是回传格式:结果必须是模型当初给的那个 tool_call_id 对应的消息,ID 对不上,模型会当成新信息重新规划。
# 并行调用:一次返回多个 tool_calls,逐个执行后全部回传
for call in resp.tool_calls:
result = dispatch(call.function.name, json.loads(call.function.arguments))
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False)})
并行调用要注意两点:有依赖的调用不能并行(第二个的参数依赖第一个的结果);回传顺序不必与调用顺序一致,但 ID 必须一一对应。
7. 流式输出下的结构化解析
流式返回的是增量 token,JSON 在传输过程中始终是不完整的。直接 json.loads 半截字符串必然抛异常。
| 方案 | 做法 | 代价 |
|---|---|---|
| 全缓冲 | 收齐再解析 | 失去流式体验 |
| 增量解析 | 边收边尝试补全括号 | 实现复杂,需容错 |
| 分块协议 | 每块是一个完整小 JSON(NDJSON) | 需约定协议 |
| 双通道 | 文本流给人看,结构化结果最后单独给 | 需两次输出或工具调用 |
buf = ""
for chunk in stream:
buf += chunk
# 只在括号配平后才尝试解析,避免对半截字符串报错
if buf.count("{") == buf.count("}") and buf.strip().endswith("}"):
try:
obj = json.loads(buf)
yield obj # 完整对象,可用于增量渲染
buf = ""
except json.JSONDecodeError:
continue # 配平但语法仍错,等更多数据或走修复
对 UI 更友好的做法是 NDJSON:模型每行输出一个完整对象,前端逐行解析。代价是模型得遵守"一行一个 JSON",可靠性不如工具调用。
8. 版本与向后兼容
| 变更 | 兼容性 | 做法 |
|---|---|---|
| 加可选字段 | 兼容 | 直接加,消费端忽略未知字段 |
| 加必填字段 | 不兼容 | 先加可选并给默认值,再收紧 |
| 改字段名 | 不兼容 | 新旧双写一段时间 |
| 改字段类型 | 不兼容 | 新增字段,旧字段只读 |
| 改枚举值 | 视情况 | 加值兼容,删值不兼容 |
class RefundV2(BaseModel):
model_config = {"extra": "ignore"} # 消费端忽略未知字段,是兼容的前提
order_id: str
amount: float
currency: str = "CNY" # 新增带默认值 = 兼容
兼容的主动权在消费端:只有消费端忽略未知字段、对缺失字段有默认值,生产端才敢加字段。反过来,生产端单方面加必填字段,等于让所有旧消费端同时崩。
9. 常见坑
| 坑 | 后果 | 改法 |
|---|---|---|
| schema 太复杂 | 模型漏填深层字段 | 拆调用、减嵌套、缩字段数 |
| 校验放在解析之后、入库之后 | 脏数据已落库 | 校验前移到入库前 |
| 重试不带错误信息 | 模型重复同样的错 | 回灌字段名 + 违反的规则 |
流式里直接 json.loads |
半截字符串必崩 | 配平检测或 NDJSON |
忽略 tool_call_id |
模型把结果当新输入 | 严格按 ID 回传 |
| 靠正则抽字段 | 格式一变全崩 | 用 schema 约束生成 |
10. 排错
| 现象 | 先查 | 处理 |
|---|---|---|
| 偶发解析失败 | 原始响应里有没有前后缀 | 加剥离层,日志存原文 |
| 字段总是缺一个 | 该字段是否 required | 改 required 或给默认值 |
| 类型来回错 | 提示里有没有给类型示例 | 用 schema 约束,别靠描述 |
| 重试三次仍失败 | 错误信息是否回灌 | 带上具体字段与规则 |
| 流式首包就崩 | 是否在增量上解析 | 改配平检测 |
| 换模型后格式全乱 | 新模型是否支持该 tool 协议 | 降级到 JSON mode + 校验 |
11. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 只要一段自然语言 | 不做结构化 | 增加约束反而降低表达质量 |
| 输出极长(数千字段) | 分批调用 + 合并 | 单次生成填不满 |
| 需要严格数值计算 | 让模型出表达式,代码算 | 模型算术不可靠 |
| 极低延迟场景 | 预定义模板 + 填空 | 结构化生成有固定开销 |
| 一次性离线任务 | 提示词约束 + 人工抽检 | 不值得建全套流水线 |
12. 自测
- JSON mode 与工具调用在可靠性上的差别,具体差在哪一层?
- 为什么
additionalProperties: false对反序列化很重要? - 解析失败的容错顺序为什么必须"先剥离再重试"?
- 语法层与语义层校验,哪一层该放在入库之前,两层都放行不行?
- 流式输出下为什么不能直接对增量做
json.loads? - schema 加字段与改字段名,哪种兼容、哪种不兼容,消费端要配合什么?