大模型工程化实战(七):JSON Schema 强约束——模型吐的 json 不合规?schema 校验不通过,重试、兜底、降级一条龙

目录

  • 前言
  • [一、问题定义:prompt 里写 return JSON 为什么不保险](#一、问题定义:prompt 里写 return JSON 为什么不保险)
  • 二、核心方案:四条路线,收敛成一条判断链
    • [设计①:schema 校验 + 重试------最朴素,任何链路都能挂](#设计①:schema 校验 + 重试——最朴素,任何链路都能挂)
    • [设计②:厂商 structured output------生成端帮你锁,但不是免死金牌](#设计②:厂商 structured output——生成端帮你锁,但不是免死金牌)
    • 设计③:语法约束采样------采样层锁死,但绑后端绑自托管
    • [设计④:json_repair 类兜底------救急神器,但治标不治本](#设计④:json_repair 类兜底——救急神器,但治标不治本)
    • [一条判断链:链路用厂商 API,还是自托管?](#一条判断链:链路用厂商 API,还是自托管?)
    • [厂商 structured output 都这么强了,出口校验闸还挂吗?](#厂商 structured output 都这么强了,出口校验闸还挂吗?)
    • 降级兜底长什么样
  • [三、代码实战:一个可离线跑的 SchemaGate 最小实现](#三、代码实战:一个可离线跑的 SchemaGate 最小实现)
  • 四、踩坑记录:这三个坑每个都真付过费
    • [4.1 盲重烧钱:schema 不过就原样重跑](#4.1 盲重烧钱:schema 不过就原样重跑)
    • [4.2 把 JSON mode 当 structured output](#4.2 把 JSON mode 当 structured output)
    • [4.3 repair 修语法不修语义 + 兜底返 500](#4.3 repair 修语法不修语义 + 兜底返 500)
  • [五、选型对比:四条路线一张表 + 各厂商 structured output 支持度](#五、选型对比:四条路线一张表 + 各厂商 structured output 支持度)
  • [六、总结 + 下一篇预告](#六、总结 + 下一篇预告)
  • 附录:重试成本估算(推导放这,正文只留结论)

前言

客服 AI 念身份证的事过去一周,脱敏闸挂上了,泄露是不泄露了。可上线第四周,数据组来拍桌子:模型把"金额"吐成字符串 "¥1,234"(schema 要的是 number),"是否退款"整个字段丢了,还自作主张加了个 schema 里根本没有的"运费"。下游 json.loads 直接崩,pandas 读出来一片 NaN。

上篇说"输出侧先脱敏、再锁格式"------脱敏闸挂上了,格式闸没挂。这篇把输出侧最后一道闸挂上:JSON Schema 输出结构校验,不合规就重试、兜底、降级一条龙。

一、问题定义:prompt 里写 return JSON 为什么不保险

让模型"按这个 schema 输出",和上篇"别泄露"是一回事------都是措辞。prompt 里写"请返回 JSON,结构是 {...}",模型大概率吐得七七八八,但概率不是保证:它按 token 概率吐,不按 JSON 语法吐。键名拼错、字段少一个、类型不对,它自己不知道,也没人拦。我们把线上一个月的输出拉出来,烂 json 就这五类:

失败类 表现 真实事故例
字段缺失 required 里的键没吐出来 "是否退款"整个丢了
类型错 键在、值类型不对 "金额"吐成字符串 "¥1,234",要 number 给 string
多余字段(幻觉) 多出 schema 没定义的键 多出个"运费",schema 里根本没有
嵌套错 嵌套对象内部不合规 收货地址的"城市"吐成数字
值不在枚举内 吐了个允许范围外的值 状态字段给个枚举外的字符串

烂 json 进了下游,三个后果,一个比一个贵:

  • json.loads 直接崩。 fence 包裹、尾逗号、单引号,解析当场抛异常,调用方 500。
  • pandas 一片 NaN。 字段缺失、类型错,读出来全是空值,报表静默错------比崩还阴,你看见报表数字不对才知道出事了。
  • Agent 工具调用传错参。 这个最危险:工具调用了,动作是对的,参数是错的。把字符串 "¥1,234" 当 number 传给扣款工具,扣款流程走完了,金额是个垃圾值。

结论先放这儿:格式对不对和泄露不泄露一样,是代码闸的事,不是措辞的事。 上篇说"安全不是写给模型看的,是挂在链路两端的代码",这篇把它复制一遍,把"格式"填进去。

二、核心方案:四条路线,收敛成一条判断链

管住模型输出结构,市面上就四条路。先记住一条判断链,看完你会选,不是只记住四个名词。

复制代码
model 生成
   │
   ▼
OutputGuard.mask(第 6 篇:脱敏闸------先脱敏)
   │
   ▼
SchemaGate.guard(本篇:锁格式闸------再锁格式)
   │   ├─ 校验通过 → 放行给下游
   │   ├─ repair 救急 → 放行
   │   ├─ 带反馈重试(budget 内)→ 放行
   │   └─ 重试耗尽 → 结构化 error object(降级,不是 500)
   ▼
下游(json.loads / pandas / Agent 工具调用)

设计①:schema 校验 + 重试------最朴素,任何链路都能挂

模型吐什么算什么,出口端用 JSON Schema 校验,不过就把错误列表喂回模型重试。纯代码闸,不绑厂商不绑后端。代价两个:重试烧 token(重试 1 次长 json = 2 倍输出 token,附录有推导),且不保证收敛------重试 N 次还不过呢?所以必须有 budget 上限 + 降级兜底,这就是开篇《四大支柱》支柱二 2.3 说的"避免无限重试推高成本"。

设计②:厂商 structured output------生成端帮你锁,但不是免死金牌

OpenAI / Anthropic / Bedrock 到 2026 年都把这套做成了正式能力:让模型按 schema 生成,厂商在服务端尽量锁(soft guarantee,不是硬保证)。但这里有个大坑,我们踩过的:JSON mode 不等于 structured output。OpenAI 的 JSON mode 只保证"吐出来是合法 JSON",不保证"符合你的 schema"------字段照样能少、类型照样能错;strict 模式才保证。

老模型还不支持,schema 也有一堆限制(OpenAI strict:全部字段 required、additionalProperties 强制 false、根级不能有 oneOf、嵌套深度 ≤5)。各家支持度见第五节表格,照着选。

设计③:语法约束采样------采样层锁死,但绑后端绑自托管

outlines / guidance / llama.cpp GBNF / SGLang+XGrammar:采样层把非法 token 的 logits 置为 -inf,模型想吐错都吐不出来,物理层级的强约束。代价:绑模型绑后端、要自托管、工程重(XGrammar 编译期集成),还有 8%~28% 的解码开销(附录给数字出处)。

设计④:json_repair 类兜底------救急神器,但治标不治本

修 markdown fence、尾逗号、单引号、截断补全。救急好使,但修语法不修语义:"¥1,234"修完还是字符串,不会自动变 number。

一条判断链:链路用厂商 API,还是自托管?

先过一道前置过滤:数据出不出得去? 出不去(合规/隐私),厂商 API 直接出局,只剩自托管。过了这关,才轮到下面这个问题:

  • 厂商 API → 优先 ② structured output + ① 出口校验兜底(含 ④ repair 救急旁路 + 重试 + 降级)
  • 自托管 → ③ grammar-constrained(outlines / SGLang / llama.cpp)+ ① 出口校验兜底(含 ④ repair 救急旁路)
  • 不管哪条,SchemaGate 都要挂。

厂商 structured output 都这么强了,出口校验闸还挂吗?

要,而且必须。五条理由,全是真碰到的:

  1. JSON mode ≠ strict。 JSON mode 只保证合法、不保证合规。response_format 选错模式,上线第一周就被"字段对不上"打脸(踩坑②)。
  2. 老模型不支持。 模型降级、灰度切到旧型号,structured output 参数不认或静默忽略,输出照样乱。
  3. schema 限制逼你改业务 schema。 OpenAI strict 要全部 required、根级无 oneOf、深度 ≤5------业务 schema 为了迁就厂商限制而放宽,放宽了就不够硬。
  4. 流式截断。 SSE 流式输出中途断流,厂商的"保证"断在半路,下游拿到半个 json。
  5. 模型降级 / 路由切换。 网关把请求路由到另一家厂商(第 5 篇《LLM 网关选型》的活),那家的 structured output 能力不一样。

一句话:structured output 是在"生成端"提高合规概率,不是保证;出口校验是在"交付端"物理拦截。这层闸删了,就只能赌模型不出错。

降级兜底长什么样

重试耗尽仍不合规 → 返回结构化 error object(error.code + error.detail + 原文本裁剪),不是 500。下游接得住、能告警、能进 Trace------呼应第 5 篇的错误透传,也是给第 8 篇《OTel + LangFuse 全链路 Trace》埋的观测点。

长上下文、小模型结构化能力弱这两类场景,还有一个补充轨:分段输出、分段校验(开篇支柱二 2.3)。这篇不放 demo,有那个场景再回头翻。

三、代码实战:一个可离线跑的 SchemaGate 最小实现

先交代清楚再上代码:demo 是离线决策演示,重试"烧 token"的代价由 max_retries 预算和 ModelMock 模拟出来,不真调 API、不真花钱。生产里重试必须带 budget 上限------重试一次长 json 就是 2 倍输出 token,这个认知别丢。json_repair 在 demo 里是自写的迷你 repair(demo 坚持纯标准库,引不进真实 json_repair 库),真实 json_repair 库 0.63.x 放选型表。

再说自包含方案:磁盘上没有 guardrails.py(第 6 篇的 OutputGuard 嵌在那篇文章里),所以这份 json_schema_gate.py 不 import 任何不存在的文件。demo 内联一个最小 MiniOutputGuard 桩,只替换身份证号,用来演示"先脱敏、再锁格式"的调用顺序------生产换成第 6 篇《OWASP LLM 安全护栏》完整版,SchemaGate 一行不改。

这里有个串篇的坑要提前点破:第 6 篇的 OutputGuard 会把"金额"这类敏感字段打成 [金额已脱敏] 占位串,而本篇 schema 要求金额是 number------脱敏在前、锁格式在后,金额先被脱敏闸打成字符串,SchemaGate 再一查就是类型错。

生产上二选一:金额这类"既要脱敏又要当输出"的字段,要么豁免脱敏,要么 schema 对脱敏占位符开白名单。demo 的 MiniOutputGuard 只脱敏身份证、不碰金额,就是先把"先脱敏、再锁格式"的顺序演清楚,不把两个闸的边界搅进来。

python 复制代码
"""
输出侧最后一道物理闸:JSON Schema 强约束。模型吐的 json 不合规,schema 校验不通过,
重试、兜底、降级一条龙。纯标准库(json/re/dataclasses),无第三方依赖、无 API 密钥,
模型生成藏在 ModelMock 接口后,离线可跑,重试"烧 token"由 max_retries 预算模拟。

依赖安装:无(Python >= 3.10 即可)|运行:python json_schema_gate.py / python test_json_schema_gate.py
(Windows 控制台打印中文若报 GBK 编码错,命令前加 PYTHONUTF8=1)

五条设计为什么:
1. 校验收集全部错误:烂 json 常同时缺字段、错类型、多幻觉字段,一次报全,重试一次拿全信息。
2. 重试带校验反馈:错误列表喂回 prompt 再重跑,不是原样盲重;max_retries 是 budget 上限,超限走降级。
3. repair 是救急旁路:修完必须再过校验------repair 只修语法不修语义,"¥1,234" 不会变 number。
4. 降级是结构化 error object 不是 500:下游接得住、能告警、能进 Trace(第 8 篇打点钩子)。
5. 挂载顺序先脱敏、再锁格式:挂第 6 篇 OutputGuard 之后、同一出口端,入参是已脱敏文本。
"""
import json
import re
from dataclasses import dataclass


ORDER_SCHEMA = {
    "type": "object",
    "required": ["订单号", "金额", "是否退款", "收货地址"],
    "properties": {"订单号": {"type": "string"}, "金额": {"type": "number"},
                   "是否退款": {"type": "boolean"},
                   "收货地址": {"type": "object", "required": ["城市"],
                                "properties": {"城市": {"type": "string"}, "街道": {"type": "string"}},
                                "additionalProperties": False}},
    "additionalProperties": False,
}


def _type_matches(value, expected):
    """bool 是 int 的子类,先排除,别把 true 误判成 number。"""
    if expected == "boolean":
        return isinstance(value, bool)
    if expected in ("number", "integer"):
        is_int = isinstance(value, int) and not isinstance(value, bool)
        return is_int or expected == "number" and isinstance(value, float)
    if expected == "null":
        return value is None
    return isinstance(value, {"object": dict, "array": list, "string": str}[expected])


class MiniValidator:
    """最小 JSON Schema 子集:type / required / properties / additionalProperties / enum / 嵌套 object。
    validate() 返回全部错误列表、不 raise:烂 json 常同时缺字段、错类型、多幻觉字段,
    一次报全,下游和重试循环一次拿全信息,别修一个蹦一个。"""

    def validate(self, instance, schema, path="$"):
        errors = []
        self._walk(instance, schema, path, errors)
        return errors

    def _walk(self, value, schema, path, errors):
        # 先查 type:类型都不对,底下子字段检查没意义,直接返回
        if "type" in schema and not _type_matches(value, schema["type"]):
            errors.append(f"{path}: 期望 {schema['type']},实际 {type(value).__name__}")
            return
        if "enum" in schema and value not in schema["enum"]:
            # 注:错误信息带原始值,生产 schema 若含自由文本字段,要对值截断/脱敏,别把 PII 写进日志
            errors.append(f"{path}: 不在枚举内,值 {value!r}")
        if schema.get("type") == "object" and isinstance(value, dict):
            for key in schema.get("required", []):
                if key not in value:
                    errors.append(f"{path}.{key}: 缺失必填字段")
            props = schema.get("properties", {})
            for key, sub_schema in props.items():
                if key in value:
                    self._walk(value[key], sub_schema, f"{path}.{key}", errors)
            # additionalProperties=False:schema 没定义的键一律算多余/幻觉字段
            if schema.get("additionalProperties") is False:
                errors += [f"{path}.{k}: 多余字段(additionalProperties=False)"
                           for k in value if k not in props]


def repair_json_text(text):
    """迷你 json_repair:剥 fence / 补尾逗号 / 单引号转双引号 / Python True/None 转 JSON。
    修不动返回 None 走重试。为什么自写:纯标准库引不进真实 json_repair 库(0.63.x 放选型表)。
    为什么 repair 后必须再过校验:repair 只修语法不修语义,"¥1,234" 不会自动变 number。"""
    s = text.strip()
    try:
        json.loads(s)                              # 输入本身已是合法 JSON:不做正则修补,否则会把字符串里的 ",}" / "True" / "None" 一起改坏
        return None                                # 合法但校验没过 = 语义错不是语法错,交给重试,别在 repair 这儿越修越坏
    except json.JSONDecodeError:
        pass
    if s.startswith("```"):                        # 剥 markdown fence:```json ... ```
        s = "\n".join(ln for ln in s.splitlines() if not ln.strip().startswith("```")).strip()
    s = re.sub(r",\s*([}\]])", r"\1", s)           # 补尾逗号:末位元素后多出的逗号
    if '"' not in s:                               # 整串没双引号才做单引号替换,避免引号混用修坏结构
        s = s.replace("'", '"')
    s = re.sub(r"\bTrue\b", "true", s)             # Python 字面量 -> JSON 字面量
    s = re.sub(r"\bFalse\b", "false", s)
    s = re.sub(r"\bNone\b", "null", s)
    try:
        return json.dumps(json.loads(s), ensure_ascii=False)  # 重新序列化,归一化缩进/换行
    except json.JSONDecodeError:
        return None


def _reject_constant(value):
    """json.loads 默认把 NaN/Infinity 解析成 float('nan')/float('inf'),这些会骗过 number 类型检查、
    直通下游变成 pandas 的 NaN。在解析层就把它们挡掉,不放进对象。"""
    raise ValueError(f"非法 JSON 常量 {value}(NaN/Infinity 不允许)")


@dataclass(frozen=True)
class Report:
    """一次校验报告。ok = 没有任何错误;json.loads 解析失败也算一个错误。"""
    text: str
    obj: object
    errors: list

    @property
    def ok(self) -> bool:
        return not self.errors


@dataclass(frozen=True)
class GateResult:
    """guard() 的裁决:放行 / repair 救急放行 / 重试后放行 / 降级。
    降级也包成对象不抛异常:下游接得住、能告警、能进 Trace(第 8 篇打点钩子)。"""
    ok: bool
    degraded: bool
    repaired: bool
    text: str
    errors: list
    retries: int
    error: dict = None


class SchemaGate:
    """输出侧最后一道物理闸:解析 -> 校验 -> repair 救急 -> 带反馈重试 -> 降级。
    挂在第 6 篇 OutputGuard 之后、同一出口端------入参是"已脱敏文本",先脱敏、再锁格式。"""

    def __init__(self, validator=None):
        self.validator = validator or MiniValidator()

    def validate_text(self, text, schema) -> Report:
        """json.loads 解析 + MiniValidator 校验。解析失败也算一个错误:
        repair/重试需要知道"到底坏在哪",解析都不过也是一种坏法(fence 包裹最常见)。"""
        try:
            obj = json.loads(text, parse_constant=_reject_constant)
        except json.JSONDecodeError as exc:
            return Report(text=text, obj=None,
                          errors=[f"json_parse_error: 第 {exc.lineno} 行第 {exc.colno} 列:{exc.msg}"])
        except ValueError as exc:  # NaN/Infinity 被 _reject_constant 拒掉,别让它们直通下游变 NaN
            return Report(text=text, obj=None, errors=[f"json_parse_error: {exc}"])
        return Report(text=text, obj=obj, errors=self.validator.validate(obj, schema))

    def _retry_with_feedback(self, errors, model) -> str:
        """错误列表喂回 prompt 再调模型------带校验反馈的决定式重试,不是原样盲重(踩坑①)。"""
        feedback = "\n".join(f"- {err}" for err in errors[:5])
        prompt = ("你是订单抽取器,只输出 JSON,结构必须严格符合:"
                  '{"订单号": string, "金额": number, "是否退款": boolean, '
                  '"收货地址": {"城市": string, "街道": string}}\n'
                  f"你上次输出有以下问题:\n{feedback}\n请修正后重新输出完整 JSON。")
        return model.complete(prompt)

    def guard(self, masked_text, schema, model, max_retries=2) -> GateResult:
        """编排:解析 -> 校验 -> 不过则 repair -> 仍不过则带反馈重试 -> 耗尽降级。
        max_retries 是 budget 上限,超限停手,绝不无限重试(无限重试 = 无限烧 token)。"""
        text = masked_text
        last_errors = []
        retries = 0
        for attempt in range(max_retries + 1):
            report = self.validate_text(text, schema)
            if report.ok:
                return GateResult(ok=True, degraded=False, repaired=False,
                                  text=text, errors=[], retries=retries)
            last_errors = report.errors
            # 救急旁路:repair 当前文本,修完必须再过一遍校验(repair 只修语法不修语义)
            repaired = repair_json_text(text)
            if repaired is not None and repaired != text:
                r2 = self.validate_text(repaired, schema)
                if r2.ok:
                    return GateResult(ok=True, degraded=False, repaired=True,
                                      text=repaired, errors=[], retries=retries)
            if attempt >= max_retries:
                break
            text = self._retry_with_feedback(last_errors, model)
            retries += 1
        detail = ";".join(last_errors[:5])
        return GateResult(ok=False, degraded=True, repaired=False, text=text,
                          errors=last_errors, retries=retries, error={
                              "code": "schema_validation_exhausted",
                              "detail": f"带反馈重试 {retries} 次仍不合规:{detail}",
                              "sample": text[:200]})  # 裁剪原文本,下游/告警/Trace 有据可查


class ModelMock:
    """可注入失败模式的模型桩。生产换真实 SDK + structured output 调用时 SchemaGate 一行不改;
    bad_mode 注入缺字段/类型/多余/嵌套四类失败(或 all 三合一),fail_until 控制"第几次开始变好",CI 把"重试收敛/不收敛"两条路都压一遍。"""

    _GOOD = {"订单号": "D20260901-001", "金额": 1234.0, "是否退款": False,
             "收货地址": {"城市": "北京", "街道": "朝阳路 88 号"}}

    def __init__(self, bad_mode="ok", fail_until=None):
        self.bad_mode = bad_mode          # ok / missing / type / extra / nested / all
        self.fail_until = fail_until      # 第 N 次调用起变好;None = 永远坏(测不收敛降级)
        self.call_count = 0

    def complete(self, prompt) -> str:
        self.call_count += 1
        if self.fail_until is not None and self.call_count > self.fail_until:
            return json.dumps(self._GOOD, ensure_ascii=False)      # 过了收敛点:这次给合法的
        return json.dumps(self._bad(), ensure_ascii=False)

    def _bad(self):
        bad = dict(self._GOOD)
        if self.bad_mode == "missing":
            bad.pop("是否退款")                       # 字段缺失
        elif self.bad_mode == "type":
            bad["金额"] = "¥1,234"                    # 类型错:schema 要 number
        elif self.bad_mode == "extra":
            bad["运费"] = 88                          # 幻觉出 schema 外字段
        elif self.bad_mode == "nested":
            bad["收货地址"] = {"城市": 123}           # 嵌套错:城市应为 string
        elif self.bad_mode == "all":                 # 三合一:类型错 + 字段缺失 + 多余字段,演示"一次报全"
            bad["金额"] = "¥1,234"
            bad.pop("是否退款")
            bad["运费"] = 88
        return bad


class MiniOutputGuard:
    """最小脱敏桩:只替换身份证号。生产换第 6 篇《OWASP LLM 安全护栏》完整版(5 类正则+姓名启发式)。
    为什么给桩:磁盘上没有 guardrails.py,本篇要独立可跑,用桩演示"先脱敏、再锁格式"的顺序。"""
    _ID_RE = re.compile(r"(?<!\d)(?:\d{17}[\dXx]|\d{15})(?!\d)")

    def mask(self, text: str) -> str:
        return self._ID_RE.sub("[身份证已脱敏]", text)


def build_demo() -> None:
    gate, masker = SchemaGate(), MiniOutputGuard()

    print("== 1) 合法通过:model -> OutputGuard.mask -> SchemaGate.guard -> 下游 ==")
    model = ModelMock(bad_mode="ok")
    result = gate.guard(masker.mask(model.complete("x")), ORDER_SCHEMA, model, max_retries=2)
    print(f"  放行 ok={result.ok} | 金额类型={type(json.loads(result.text)['金额']).__name__}")
    print("\n== 2) 不合规拦截:校验一次报全(类型错 + 字段缺失 + 多余字段)==")
    report = gate.validate_text(ModelMock(bad_mode="all").complete("x"), ORDER_SCHEMA)
    print(f"  错误 {len(report.errors)} 条:")
    for err in report.errors:
        print(f"    - {err}")
    print("\n== 3) repair 救急:markdown fence 剥掉再过校验 ==")
    fenced = "```json\n" + ModelMock(bad_mode="ok").complete("x") + "\n```"
    result = gate.guard(masker.mask(fenced), ORDER_SCHEMA, ModelMock(bad_mode="ok"), max_retries=2)
    print(f"  repaired={result.repaired} ok={result.ok}")
    print("\n== 4) 重试收敛:第一次坏、第二次好,带反馈重试 1 次放行 ==")
    model = ModelMock(bad_mode="type", fail_until=1)
    result = gate.guard(masker.mask(model.complete("x")), ORDER_SCHEMA, model, max_retries=2)
    print(f"  ok={result.ok} retries={result.retries} 模型被调 {model.call_count} 次")
    print("\n== 5) 重试不收敛降级:结构化 error object,不是 500 ==")
    model = ModelMock(bad_mode="type", fail_until=None)
    result = gate.guard(masker.mask(model.complete("x")), ORDER_SCHEMA, model, max_retries=2)
    print(f"  degraded={result.degraded} 重试 {result.retries} 次耗尽")
    print(f"  error.code={result.error['code']} detail={result.error['detail']}")


if __name__ == "__main__":
    build_demo()

python json_schema_gate.py,五个小节各演一件事。① 合法通过:出口链是 model → OutputGuard.mask → SchemaGate.guard → 下游,金额是 number;② 不合规拦截:校验一次报全(3 条错);③ repair 救急:fence 剥掉回校放行;④ 重试收敛:第一次坏、第二次好,带反馈重试 1 次放行;⑤ 重试不收敛降级:结构化 error object,不是 500。(第 5 类失败"值不在枚举内"校验器也支持------schema 的 enum 关键字,只是 ORDER_SCHEMA 没有 enum 字段,demo 没演它。)

test_json_schema_gate.py------5 个断言,一条锁一道取舍:

python 复制代码
"""
5 个断言锁死 JSON Schema 强约束:合规放行 / 一次报全 / repair 救急 / 重试收敛 / 重试不收敛降级。
纯标准库 + assert,直接跑:
    python test_json_schema_gate.py
    # 或 pytest test_json_schema_gate.py
"""
import json
from json_schema_gate import ModelMock, ORDER_SCHEMA, SchemaGate, repair_json_text


def test_happy_path_valid_json_passes():
    """断言①:合规放行------模型返回合法 json,校验通过,下游拿得到(happy path)。"""
    gate, model = SchemaGate(), ModelMock(bad_mode="ok")
    raw = model.complete("抽取订单")
    result = gate.guard(raw, ORDER_SCHEMA, model, max_retries=2)
    assert result.ok and not result.degraded
    # 下游 json.loads 不崩,金额是 number 不是字符串
    assert json.loads(result.text)["金额"] == 1234.0


def test_invalid_json_reports_all_errors_at_once():
    """断言②:不合规拦截且一次报全------类型错(金额是字符串)+ 字段缺失(是否退款丢了)+
    多余字段(运费)→ validate() 一次返回 3 条错误,guard() 判不通过。"""
    bad = '{"订单号": "D1", "金额": "¥1,234", "运费": 88, "收货地址": {"城市": "北京"}}'
    report = SchemaGate().validate_text(bad, ORDER_SCHEMA)
    assert not report.ok
    assert len(report.errors) == 3            # 一次报全,不是修一个蹦一个
    assert any("金额" in e and "number" in e for e in report.errors)
    assert any("是否退款" in e and "缺失" in e for e in report.errors)
    assert any("运费" in e and "多余" in e for e in report.errors)


def test_repair_strips_fence_and_revalidates():
    """断言③:repair 救急------模型返回 ```json ... ```包裹的合法 json,
    repair 剥掉 fence 后必须再过一遍校验,不是直接给下游。"""
    fenced = ('```json\n'
              '{"订单号": "D1", "金额": 99.0, "是否退款": false, "收货地址": {"城市": "北京"}}\n'
              '```')
    repaired = repair_json_text(fenced)
    assert repaired is not None
    assert SchemaGate().validate_text(repaired, ORDER_SCHEMA).ok    # repair 后回校才放行
    result = SchemaGate().guard(fenced, ORDER_SCHEMA, ModelMock(bad_mode="ok"), max_retries=2)
    assert result.ok and result.repaired
    # repair 只救语法不救语义:类型错的字段,repair 修完照样过不了校验
    bad_but_parseable = '{"订单号": "D1", "金额": "¥1,234", "是否退款": false, "收货地址": {"城市": "北京"}}'
    assert not SchemaGate().validate_text(bad_but_parseable, ORDER_SCHEMA).ok


def test_retry_with_feedback_converges():
    """断言④:重试收敛------ModelMock(fail_until=1) 第一次坏、第二次好,
    guard() 带反馈重试 1 次后通过,不是盲重原样重跑。"""
    gate, model = SchemaGate(), ModelMock(bad_mode="type", fail_until=1)
    raw = model.complete("抽取订单")          # call #1:坏(金额是字符串)
    result = gate.guard(raw, ORDER_SCHEMA, model, max_retries=2)
    assert result.ok and not result.degraded
    assert result.retries == 1
    assert model.call_count == 2              # 1 次原始 + 1 次带反馈重试


def test_retry_exhausted_degrades_with_error_object():
    """断言⑤:重试不收敛降级------模型永远坏,重试耗尽后 degraded=True,
    返回结构化 error object(code + detail + sample),不抛 500、错误透传(给第 8 篇打点)。"""
    gate, model = SchemaGate(), ModelMock(bad_mode="type", fail_until=None)
    raw = model.complete("抽取订单")
    result = gate.guard(raw, ORDER_SCHEMA, model, max_retries=2)
    assert not result.ok and result.degraded
    assert result.retries == 2                # budget 耗尽,停手
    assert result.error["code"] == "schema_validation_exhausted"
    assert "金额" in result.error["detail"]   # 错误透传:下游/告警/Trace 看得见坏在哪
    assert isinstance(result.error["sample"], str)


if __name__ == "__main__":
    test_happy_path_valid_json_passes()
    test_invalid_json_reports_all_errors_at_once()
    test_repair_strips_fence_and_revalidates()
    test_retry_with_feedback_converges()
    test_retry_exhausted_degrades_with_error_object()
    print("全部 5 个断言通过:合规放行 / 一次报全 / repair 救急 / 重试收敛 / 重试不收敛降级")

python test_json_schema_gate.py,全绿。把 MiniValidator 改成报第一个错就停,断言②当场挂(3 条错只报 1 条);把 repair 后不回过校验直接给下游,断言③挂;把重试改成原样盲重(错误列表不喂回 prompt),断言④的 retries 语义就乱了;把降级改成抛异常,断言⑤就炸了。这就是闸在代码里的样子:每条取舍都有一根断言钉着。

生产里 ModelMock 换成真实 SDK------厂商 API 优先 response_format={"type": "json_schema", "json_schema": {"name": "...", "schema": {...}, "strict": true}}(注意 strict 在嵌套的 json_schema 对象里,不在顶层) / Anthropic output_config.format,自托管用 outlines,或 SGLang 的 sampling_params={"json_schema": schema}换哪条路,SchemaGate 出口校验一行不改

四、踩坑记录:这三个坑每个都真付过费

4.1 盲重烧钱:schema 不过就原样重跑

  • 症状:schema 校验不通过就原样重跑,一个长 json 重试 3 次,token 花了 4 倍,还没收敛。
  • 排查:翻日志发现每次重试的 prompt 一模一样,模型不知道错在哪,同样的错再犯一遍。
  • 根因:盲重------不带校验反馈。
  • 修复:带校验反馈重试(把错误列表喂回 prompt,demo 的 _retry_with_feedback 就是干这个),并设 budget 上限,超限走降级。长 json 重试成本:重试 1 次 = 2 倍输出 token,重试 3 次 = 4 倍,推导在附录。

4.2 把 JSON mode 当 structured output

  • 症状:上线第一周,response_format 配了 json,字段还是对不上------少字段、类型错照旧。
  • 排查:OpenAI JSON mode 只保证"合法 JSON",不保证"符合 schema"。
  • 根因:把"合法"当"合规",以为厂商帮你锁了结构。
  • 修复:改 strict 模式(确认模型支持),出口校验兜底双保险------再强的 structured output,出口这道闸也得留着。

4.3 repair 修语法不修语义 + 兜底返 500

  • 症状:repair 把 "¥1,234" 修成合法字符串,类型还是错的,下游照样崩;重试耗尽直接返 500,下游比不返还更糟------照样崩。
  • 排查:repair 只做语法修补,不做语义修正。
  • 根因:把 repair 当主路径、把降级当错误处理。
  • 修复:repair 后必须再过一遍校验(demo 的 repaired 分支就是修完回校);重试耗尽返结构化 error object 而不是 500------下游接得住、能告警、能进 Trace。

五、选型对比:四条路线一张表 + 各厂商 structured output 支持度

四条路线,维度对齐"锁在哪一层 / 厂商依赖 / 解码开销 / 维护成本 / 适用",直接抄作业:

路线 锁在哪一层 厂商依赖 解码开销 维护成本 适用
① schema 校验 + 重试(本篇) 出口端,纯代码闸 无(本地字符串处理) 任何链路兜底,必须带 budget
② 厂商 structured output 生成端,厂商服务 强绑定厂商 厂商 API 链路首选
③ 语法约束采样(outlines / guidance / llama.cpp GBNF / SGLang+XGrammar) 采样层,解码时 绑定后端,自托管 8%~28%(附录给出处) 自托管 + 要硬保证
④ json_repair 类兜底(真实库 0.63.x) 出口端,语法修补 救急:fence / 尾逗号 / 截断

各厂商 structured output 支持度(2026 年现状,版本号都可核实):

厂商 能力名 支持型号 / GA 关键限制
OpenAI Structured Outputs(strict 模式) GPT-5.x / GPT-4o 系;老 GPT-3.5 系只有 JSON mode strict 下 schema 全部 required、additionalProperties 强制 false、根级无 oneOf、嵌套深度 ≤5;JSON mode 只保证合法、不保证合规
Anthropic Structured Outputs(output_config.format 2026-01 GA;Sonnet 4.5 / Opus 4.5 / Haiku 4.5 refusal / max_tokens 截断仍可能返回不合规输出,出口闸不可省
Amazon Bedrock Structured Outputs(Converse) 2026 年 GA 各家模型能力差异大
国产(通义 / DeepSeek) 多走 function calling 或自研 grammar 后端 以各家文档为准 能力、字段覆盖差异大,别拿 OpenAI 的文档当圣经

可核实的版本号(2026 年 8 月):outlines 1.3.3 (2026-08-06 发布,dottxt-ai,Apache-2.0);guidance 0.3.1 (2026-02-03,guidance-ai,约 2.2 万 star,2026-05 仍在维护);json_repair 0.63.x (2026-08 仍在发版,约 5 千 star);jsonschema 4.26 (无 $schema 时默认按最新草案 Draft 2020-12 校验,生产显式用 Draft202012Validator 最稳)。XGrammar 是 vLLM、TensorRT-LLM 的默认语法后端;SGLang 起服务需显式 --grammar-backend xgrammar

选型 checklist,5 个"是/否"答完落到方案:

  1. 数据出不出得去? 出不去(合规/隐私),厂商 API 直接出局,只能自托管 → ③ 或 ①。
  2. 是否自托管模型? 是,grammar-constrained 才有得选;否,走厂商 structured output。
  3. 要不要硬保证? 要,structured output 各厂商也有限制(见上表),硬保证靠出口校验闸,不是厂商承诺。
  4. 团队养不养得起采样层工程? XGrammar 编译期集成、后端绑定、8%~28% 解码开销,小团队掂量一下。
  5. 有没有现成厂商 structured output 可用? 查上表型号/GA 支持度,别假设模型都支持。

六、总结 + 下一篇预告

到这儿,输出侧两道闸齐了:第 6 篇的脱敏闸管不泄露,本篇的 SchemaGate 管格式对不对。回到开头数据组那个工单------如果 SchemaGate 在,"金额"变字符串、"是否退款"丢失、多出"运费",这三条在出口就被校验拦下:要么 repair、要么带反馈重试、要么结构化降级,烂 json 根本出不了出口。

但"校验不通过"这件事本身必须看得见:重试了几次、哪个字段错的、走没走兜底,都要进 Trace。下一篇《OTel + LangFuse 全链路 Trace》:把第 5 篇网关、第 6 篇护栏、本篇 SchemaGate 全部打点串起来,出问题一眼定位到环节。

评论区聊聊:你们输出结构现在靠什么锁?是 prompt 里写一句 return JSON 碰运气,还是已经挂上物理闸了?

附录:重试成本估算(推导放这,正文只留结论)

正文说"重试 1 次长 json = 2 倍输出 token",这里补推导,结论数字在正文直接用。

  • 一次生成的输出 token 记 O。带反馈重试 1 次:原始输出 O + 重试输出 O = 2O,即 2 倍;重试 2 次 = 3O;重试 3 次 = 4O。这就是踩坑①那个"4 倍"的来路。
  • 反馈 prompt 本身还要吃输入 token:I_反馈 = 错误列表长度 × 单条错误 token 数。错误多的时候,这部分也按输入价计费,量级比输出 token 小一档,但别忽略。
  • 订单抽取这种长 json,输出 O 很容易上千 token。假设单次输出 1000 token、重试 3 次,输出侧就是 4000 token,乘上你家的模型单价,一算就是钱。
  • 所以结论三条:带反馈重试(让每次重试有价值)、设 budget 上限(max_retries 就是预算)、重试不收敛走结构化降级(0 次额外调用,代价可控)。

语法约束采样的 8%~28% 解码开销出处:XGrammar 团队论文与 outlines 官方 benchmark 的公开数据,量级随 schema 复杂度浮动------schema 嵌套越深、枚举越大,开销越靠近 28%。这个数字影响的是首 token 延迟和吞吐,自托管要做容量规划时用得上。


🎯 更多专栏系列文章可以查看博客主页📑 👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏

相关推荐
七牛云行业应用28 分钟前
DSH Desktop 桌面版实测:Win/macOS 一键安装,“桌面也是插件“怎么理解,和 npx 起 Web UI 差在哪
人工智能·agent·ai编程
KeepSeek1 小时前
大模型推理优化面试题
大模型
狂师1 小时前
AI 测试提效 | 别搞万能 Skill,推荐5 个 Agent Skill 串起 UI 自动化执行到报告生成全流程
人工智能·agent·测试
deepseek231 小时前
Claude Fable 5.1 深度拆解:推理强度、缓存降价,Agent 工作流成本如何省 45%
大模型·claude·ai agent
张彦峰ZYF2 小时前
从对话记忆到状态控制平面:长程 Agent 的状态治理工程
人工智能·llm·agent·loopengineering·langgroup
Steve__evetS2 小时前
我的开源项目:python依赖安全修复助手
python·安全·agent
七夜zippoe2 小时前
我用 Qwen3.8-Max 搭了一个 AI 作业辅导助手,拍照讲题 + 错题本诊断一次搞定
人工智能·ai·大模型·qwen3.8-max·build with qwen
腾视科技-AI2 小时前
腾视科技AI大模型应用:提效、破局与落地,重塑智能新生态
大数据·人工智能·科技·大模型·ai大模型·腾视科技·ai算力盒
Steve__evetS2 小时前
Claude Code记忆机制
agent·记忆·claude code