下游代码要的是字段,模型给的是文本。真正的工程问题不是"怎么让模型输出 JSON",而是这条缝在哪一层被强制闭合,闭合不了的残余由谁兜。答不出这一点,解析失败就是随机事故。
1. 自由生成与受约束解码:两种物理过程
模型每次吐一个 token,候选全集是词表。自由生成时任何 token 都合法,"请输出 JSON"只改概率分布;受约束解码在采样前按已生成前缀,把不可能构成合法结构的 token 概率置零,再归一化采样。
| 强度 | 实现位置 | 保证等级 | 失效方式 |
|---|---|---|---|
| 提示层约束 | 文本 + few-shot 示例 | 概率性 | 加前言、改写键名、多段 JSON、注释 |
| tool schema / function calling | 服务端把 schema 转成受限文法 | 结构合法,语义不保证 | 值越出业务域;并行多调用 |
| 受约束解码(constrained decoding) | 采样器层(grammar / FSM / 掩码) | token 级硬约束 | 表达力受限(见 §3);长度截断 |
| 服务端 response_format | 厂商内置 JSON 模式 | 语法合法 | json_object 模式常只保证是 JSON,不管 schema |
常被忽略的事实:受约束解码是"只能这么写",不是"写得对"。它保证 type 合法,不管 "status": "cancelled" 是否真在你的状态机里。
2. 提示层约束的正确写法与必然的兜底
提示层提高成功率,不提供保证。写法要减自由度,不是加形容词。
PROMPT = """只输出一个 JSON 对象,不要代码块围栏、不要前后说明。
键集合固定为 uuid / amount / currency / status,不得增删。
status 只能取 pending | paid | refunded。
无法确定的字段填 null,不要猜,不要省略键。
"""
# 反面写法:"请务必严格输出符合 schema 的 JSON,不要出错" —— 无信息量
兜底解析必须假定围栏、前后缀、多余键都会出现。
def parse_loose(text: str) -> dict:
t = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip()
raw = t[t.find("{"):] # 丢弃前缀
for cut in range(len(raw), 0, -1): # 从右往左找可闭合点
try: return json.loads(raw[:cut])
except json.JSONDecodeError: continue
raise ValueError("unparsable")
这是"多段 JSON + 尾随说明"的通用解,代价是 O(n) 次尝试,只在失败路径跑。
3. JSON Schema 里哪些约束真能生效
能不能强制,取决于它能否在逐 token 生成时本地判定。需要往后看很远的约束,受约束解码就只能降级为"不约束"。
| 关键字 | 可否强制 | 说明 |
|---|---|---|
type |
可 | token 级可判定,最可靠的一类 |
enum |
可 | 值域有限;但仅当枚举是字面量而非动态生成 |
required |
可 | 键存在性可判;与 additionalProperties:false 一起才完整 |
嵌套 object/array |
可 | 递归文法,深度大时栈开销上升 |
pattern |
视实现 | 复杂正则常退化为"生成后校验",等价于没约束 |
minLength/maxLength |
弱 | 计数依赖已生成长度,部分实现支持,别指望 |
format(date-time/email) |
基本不可 | 多数实现不做语义校验 |
minimum/maximum |
弱 | 数值字面量边界部分可用,浮点表示易漏 |
oneOf / anyOf |
部分 | 首个区分点前无法唯一化,见 §4 |
实用结论:业务关键约束全下压到 enum / type / required / additionalProperties:false 四件上,其余交给生成后校验。
4. enum 与 oneOf 的失败模式
enum 的失败不是语法失败,是语义越界:输出格式合法但不在集合内的值,或大小写/连字符漂移(Paid vs paid)。修复是把枚举写进提示正文、schema 里 enum 双写、解析后做归一化映射,映射不中才重试。
oneOf 更隐蔽:分支要靠某个键出现才能区分,在此之前模型可自由生成任何分支的字段,于是产出"混合体"——同时带 card_* 和 iban_*,两分支各自合法,整体无解。
改法:先判别后填值。第一段只输出 payment_method 的 enum,再按分支渲染第二段 schema。一次复杂 oneOf 换成两次简单调用,成功率更高。
5. 解析失败的五类原因与逐类修复
| 现象 | 根因 | 修复 |
|---|---|---|
Unterminated string / 末尾 { |
截断:撞 max_tokens 或流式连接提前关闭 |
见 §7;schema 瘦身 + 提高上限 + 查 finish_reason |
| 一次响应里两个 JSON 对象 | 模型把"示例"和"答案"都写出来 | 提示里删示例、只留键清单;取最后一个完整对象 |
出现 // 或 /* */ |
模型的 JSON5 习惯,或提示里带注释 | 提示明确"JSON 不允许注释";解析前用状态机剥离 |
末尾 , 后接 } |
训练分布里尾逗号常见 | 正则清理尾逗号后再 json.loads |
键名漂移(user_id → userId) |
模型对命名风格做"善意纠正" | 键名用 snake_case 并在提示复述;解析时走别名映射表 |
顺序有讲究:先无损(去围栏/取子串),再可逆(剥注释、去尾逗号),最后才有损(截断补全)。上手就"正则修 JSON",会把字符串里的 ,} 一起吃掉。
6. 重试:把错误信息喂回去,而不是原样重发
原样重试只在温度非零时有意义,且是纯抽奖。带错误信息的重试是把校验器输出变成下一轮输入。
def call_with_repair(schema, user_input, max_attempts=2):
msgs = [{"role": "system", "content": PROMPT}, {"role": "user", "content": user_input}]
for i in range(max_attempts):
out = llm(msgs, response_format={"type": "json_schema", "json_schema": schema})
try:
data = json.loads(out)
validate(data, schema) # 生成后校验:enum/format/业务规则
return data
except (json.JSONDecodeError, SchemaError) as e:
if i == max_attempts - 1:
return downgrade(data) # 降级:部分字段 + 待人工确认标记
msgs.append({"role": "assistant", "content": out})
msgs.append({"role": "user", "content": f"校验失败:{e}。只修正该处后重新输出完整 JSON。"})
三条纪律:重试上限 2 次(第三次边际收益近零、成本线性上涨);失败样本落盘,否则分不清是提示问题还是版本问题;降级路径显式设计,返回 {"partial": true, "missing": [...]},比抛异常让上游 500 可控。
7. 截断:根因、检测、以及为什么"再试一次"没用
截断有三个来源:max_tokens 太小;schema 要求的总输出长度本身超预算(深层嵌套 + 长文本字段);流式输出被客户端超时或中断切断。检测靠响应元数据:
if resp.choices[0].finish_reason == "length":
log.warning("truncated %s", resp.usage.completion_tokens)
finish_reason == "length" 是硬证据;若为 stop 但 JSON 仍不完整,则是模型自行收尾(深层嵌套时提前闭合)。处理顺序:先按"字段数 × 单字段上限"估最坏输出长度,max_tokens 留 1.3 倍余量;再把长文本字段改成"分段生成 + 拼接"。输出上限按所用模型版本核对,版本间差异可达数倍。
8. 流式下的增量解析
流式只吐半截 JSON,不能等收完再 json.loads。做法是按括号深度增量提取,凑齐一个完整值就回调。
def feed(buf, on_event):
depth, in_str, esc, start = 0, False, False, 0
for i, ch in enumerate(buf):
if in_str:
if esc: esc = False
elif ch == "\\": esc = True
elif ch == '"': in_str = False
continue
if ch == '"': in_str = True
elif ch in "{[" and depth == 0: start = i; depth = 1
elif ch in "{[" : depth += 1
elif ch in "}]":
depth -= 1
if depth == 0: on_event(json.loads(buf[start:i+1]))
要点:字符串内的 {/} 必须跳过,否则引号内容破坏计数;深度归零才交付。字段级流式靠"最后一个逗号前的完整片段"试探。
9. 字段设计的可用性与版本幂等
| 反例 | 症状 | 改法 |
|---|---|---|
四层嵌套 data.result.items[].meta.tags[] |
深层最易缺键、最易被截断 | 扁平到两层,tags 提为顶层数组 |
| 可选字段不声明,靠"缺省即无" | 模型自行省略,上游 KeyError | 显式 type: ["string","null"] + 必填 |
value / data / info 多义键名 |
同一键在不同上下文含义不同 | 名词化:amount_cents、refund_reason |
| 时间用自然语言 | 歧义、时区丢失 | created_at 用 RFC 3339 |
版本与幂等要进请求,而不是只写在文档里:
{ "schema_version": "order.v3", "request_id": "req_7f3a", "items": [] }
schema_version 让解析器并存多套校验器,滚动发布期间老请求不走新规则;request_id 让重试可去重,第二次调用直接回缓存。缺幂等键时,"重试"就等于"重复下单"。
10. 排错
| 现象 | 先查 | 再查 |
|---|---|---|
| 偶发解析失败 | 样本里是否有围栏/前后缀 | 温度与 top_p 是否过高 |
| 特定字段总缺 | 该字段是否 required |
是否在提示里被"可选"措辞弱化 |
| 只在长输入时失败 | 是否撞 max_tokens |
是否把输入算进输出预算 |
| 换模型后集体失败 | 是否支持 json_schema 严格模式 |
是否只支持 json_object(无 schema 约束) |
| 校验总在枚举上失败 | 提示里的枚举是否与 schema 一致 | 是否需要映射表 |
11. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 输出是自由文档 | 不强制 JSON,用标记分段 + 后处理 | 强约束会显著压低内容质量 |
| 极低延迟的小模型 | 只做提示层 + 宽松解析 | 受约束解码有文法编译开销 |
结构极复杂(多层 oneOf) |
拆成多次调用,每次一个简单 schema | 成功率与可维护性都更好 |
| 需要严格审计 | 生成后校验 + 落原始响应 | 约束只管形状,审计要留原文 |
12. 自测
- 受约束解码保证了什么,又没保证什么?
- 为什么
pattern、format常常等于"没有约束"? oneOf会产出哪种"两个分支都合法、整体无解"的输出,怎么绕开?- 五类解析失败里,哪一类不能靠"再试一次"解决?
- 除了
finish_reason == "length",还有哪种迹象说明输出被截断? - 为什么
request_id要进请求体,而不是只放在日志里?