目录
- 前言
- [一、问题定义: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 都这么强了,出口校验闸还挂吗?
要,而且必须。五条理由,全是真碰到的:
- JSON mode ≠ strict。 JSON mode 只保证合法、不保证合规。response_format 选错模式,上线第一周就被"字段对不上"打脸(踩坑②)。
- 老模型不支持。 模型降级、灰度切到旧型号,structured output 参数不认或静默忽略,输出照样乱。
- schema 限制逼你改业务 schema。 OpenAI strict 要全部 required、根级无 oneOf、深度 ≤5------业务 schema 为了迁就厂商限制而放宽,放宽了就不够硬。
- 流式截断。 SSE 流式输出中途断流,厂商的"保证"断在半路,下游拿到半个 json。
- 模型降级 / 路由切换。 网关把请求路由到另一家厂商(第 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 个"是/否"答完落到方案:
- 数据出不出得去? 出不去(合规/隐私),厂商 API 直接出局,只能自托管 → ③ 或 ①。
- 是否自托管模型? 是,grammar-constrained 才有得选;否,走厂商 structured output。
- 要不要硬保证? 要,structured output 各厂商也有限制(见上表),硬保证靠出口校验闸,不是厂商承诺。
- 团队养不养得起采样层工程? XGrammar 编译期集成、后端绑定、8%~28% 解码开销,小团队掂量一下。
- 有没有现成厂商 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 延迟和吞吐,自托管要做容量规划时用得上。

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