2708 字
约 9 分钟
14
S19-J05 · 结构化输出:JSON Schema 约束、解析失败与修复路径

下游代码要的是字段,模型给的是文本。真正的工程问题不是"怎么让模型输出 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. 自测

  1. 受约束解码保证了什么,又没保证什么?
  2. 为什么 pattern、format 常常等于"没有约束"?
  3. oneOf 会产出哪种"两个分支都合法、整体无解"的输出,怎么绕开?
  4. 五类解析失败里,哪一类不能靠"再试一次"解决?
  5. 除了 finish_reason == "length",还有哪种迹象说明输出被截断?
  6. 为什么 request_id 要进请求体,而不是只放在日志里?
S19-J05 · 结构化输出:JSON Schema 约束、解析失败与修复路径
https://www.clxhxhhr.top/posts/6047/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。