尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

让大模型稳定输出 JSON:Schema 校验、失败重试与降级的三层防线

发布时间:2026/9/27 7:27:57

资讯中心
01
ARTICLE

让大模型稳定输出 JSON:Schema 校验、失败重试与降级的三层防线

让大模型稳定输出 JSON:Schema 校验、失败重试与降级的三层防线
背景崩掉程序的往往不是答案错而是格式我们那个桌面工具的主流程很朴素用户在聊天窗口里发一句自然语言程序把它交给模型要求返回一段 JSON说明用户想干什么、涉及哪些参数然后本地代码读这段 JSON 去执行动作。模型只负责把话翻译成结构执行、落库、权限判断全在本地做。上线 3 天我收到的反馈不是答得不准而是点了没反应和弹了个看不懂的报错框。我把日志捞出来逐条看。答案内容本身的错误很少真正让程序中断的是格式问题返回体里混进一段解释文字、字段名换了个写法、本该是数字的位置给了字符串、输出被截断导致 JSON 少一个右括号。这些问题在模型那边算小瑕疵在程序这边是直接抛异常。举几个当天真实发生过的例子模型在 JSON 前面加了一句好的我理解为“把amount写成128.5元把布尔字段写成true字符串要输出一段 300 字的商品描述时输出到一半被长度上限切断括号没闭上。每一条都能让下游取值的代码抛异常。所以我后来把这件事拆成三层约束输出、校验并分类、重试与降级。三层各解决一部分合起来把格式异常导致业务失败压到了千分之一以下。口径先说明白后文数字都按它算观察窗口 21 天共 9,473 次结构化调用“格式异常指返回内容无法解析或校验不通过“格式异常导致业务失败指异常发生且重试与降级都没救回来用户这次操作没有完成。### 崩溃点不在模型输出在 json.loads 那一行异常栈看起来很像代码 bug”其实不是。栈顶是执行动作的那一层中间是字段取值底下才藏着解析失败。一个典型的报错长这样KeyError: action_type但真实原因是模型把action_type写成了actionType而解析本身没报错——那段文本依然是合法 JSON只是键名不同。这就带来一个麻烦解析成功不等于数据可用。我一开始只判断能不能解析”于是大部分问题被漏过去等到下游取值时才炸排查一次要 20 多分钟因为异常位置离原因太远。后来我粗算过一笔账那 3 天里 41 次线上异常从收到反馈到定位到根因平均耗时 8.6 分钟其中大部分时间花在反推这个字段到底是哪一步变成这样的”。如果解析后立刻校验一遍并把字段路径记下来这 8.6 分钟可以压到 1 分钟以内。这个对比是我决定做第二层的主要原因。### 我先猜错了方向以为是提示词问题我一开始的判断是指令写得不够狠。我改了 6 版提示词加了只输出 JSON“不要任何解释”“不要用代码块包裹”异常率从 11.9% 降到 11.2%。几乎没动。改到第 4 版我才意识到问题提示词是概率性约束它对格式的遵守程度跟输出长度、字段复杂度、内容里的特殊字符都相关。比如模型要在某个字段里放一段含换行的文案它就得做一次转义决策而这类决策在采样时是不稳定的。靠嘴说解决不了工程问题得靠协议和代码。我还试过一个更笨的办法把提示词里的示例从 1 个加到 4 个few-shot 堆例子。异常率降到 9.6%但输入词元涨了 2.7 倍单次成本翻番。这个思路在成本上不成立被我否掉了。图结构化输出的四层防线## 第 1 层约束输出能解决多少问题第 1 层的目标很单纯在请求侧尽力让输出天然合法。我用了两样东西一样是协议层的能力一样是模板。### 结构化输出参数 模板我用了两样东西协议层能声明这次返回必须是某个 JSON Schema 描述的对象由推理服务在采样阶段就约束输出词元的合法集合而不是返回后再让你去解析。这一层能力的效果很直接括号配对、引号转义、字面量拼写这三类问题基本清零。这里有个细节值得说结构化输出约束的是语法形状不约束语义内容。也就是说它保证你拿到的是合法 JSON 对象、字段名和类型都对但字段查出来的值是对是错它管不了。我早期误以为开了这个参数就万事大吉结果头一次跑测试就发现intent字段返回了一个枚举里不存在的值——语法完全合法。这就是第 2 层必须存在的理由。模板这一侧我做的是收窄自由度把字段名、枚举取值、输出顺序全部固定不给模型自由发挥的空间。字段名不是它想出来的是模板里写死的占位枚举值也写死在模板里模型只需要从给定集合里选。这样做还有一个副作用——同一批请求的输出长度方差变小了后面要讲的截断问题也跟着减少。text字段名固定{intent: ..., slots: {...}, confidence: 0~1}枚举固定intent ∈ {query_price, create_order, cancel_order, other}禁止解释文字、代码块围栏、注释、多余键顺序固定intent → slots → confidence便于截断时优先丢掉尾部### 约束失效的四种场景与占比约束不是万能的。我把加了约束之后仍然异常的那 407 条占 4.3%全部人工看过一遍并打了标签| 失效场景 | 条数 | 占异常 | 原因 || — | — | — | — || 输出被长度上限截断 | 253 | 62.2% | 尾部字段缺失JSON 未闭合 || 长文本字段内的换行与引号 | 86 | 21.1% | 采样时转义决策偶发失控 || 未闭合的字符串里混入控制字符 | 41 | 10.1% | 上游文本本身带不可见字符 || 其他服务端超时返回半截 | 27 | 6.6% | 连接中断响应不完整 |截断占了六成以上这解释了我早期的困惑短请求几乎不出问题出问题的都是要输出长槽位的那几类意图。长度上限和格式合法性之间是强相关的——输出到达上限那一刻模型没有机会把话说完。我后来的做法是给输出留出 1.6 倍余量按观察到的字段长度 P95 反推同时在提示词里要求把可选字段排在后面。这两条一起把截断类异常压到 0.4% 以下。## 第 2 层把 Schema 变成可执行的校验器第 2 层的思路是不假设输出是对的拿到手先验一遍验不过就当成一次可处理的失败而不是让它流到下游变成崩溃。### Schema 用普通字典描述就够了我没引入任何依赖用普通字典描述结构理由是这套描述要能同时被三处使用校验器、提示词生成、文档。用字典就不用在三个地方重复维护字段清单。pythonSCHEMA { type: object, required: [intent, slots, confidence], fields: { intent: {type: str, enum: [query_price, create_order, cancel_order, other]}, confidence: {type: float, min: 0.0, max: 1.0}, slots: { type: object, required: [raw_text], fields: { raw_text: {type: str, min_len: 1, max_len: 500}, amount: {type: float, min: -1e9, max: 1e9, optional: True}, sku_id: {type: str, pattern: ^[A-Za-z0-9_-]{4,32}$, optional: True}, }, }, },}写 Schema 的时候我踩过一个小坑一开始把required写成列表、把字段定义写成另一个平铺字典结果改一个字段要在两处动。后来改成现在这样字段定义自带 optional 标记required只保留必填清单两边互为补充改字段只需动一处。### 校验器实现类型、必填、范围、枚举校验器本身不长难的是把它做成分层返回每一层错误都要能定位到字段路径并且明确是哪一类因为下游处理方式完全不同。pythonimport json, reclass E: SYNTAX, STRUCT, SEMANTIC syntax, struct, semanticdef parse(raw: str): 先做一次宽松裁剪再做严格解析。返回 (obj, error)。 text raw.strip() if text.startswith(“): # 剥掉可能的围栏 text re.sub(r”^[a-zA-Z]\s, “”, text) text re.sub(r\s*KaTeX parse error: Expected EOF, got # at position 147: …xt) - 8 #̲ 位置贴近末尾基本可判定截断…“, “msg”: ex.msg, “pos”: ex.pos, “truncated”: truncated}def validate(obj, schema, path”KaTeX parse error: Expected group after _ at position 212: …got {type(obj)._̲_name__}}] ….slots.sku_id, “msg”: “pattern mismatch”, “extra”: {“enum”: null, “pos”: 412, “truncated”: false}}这个设计是被逼出来的——早期版本只返回一个字符串消息重试请求里没法精准说明错在哪模型只能猜修复率因此低了约 12 个百分点。把路径和合法取值放进重试请求之后修复率明显上来了。这算是整件事里我很想推荐的一个小改动别把错误降级成一句人话保留它的结构。## 第 3 层把校验错误回传给模型让它自己改第 3 层是补救层。校验没过的请求不直接判死而是带着错误说明再问一次。这里的关键全在怎么问。### 把错误信息拼进重试请求我的重试请求包含四部分原始用户输入、上一次的错误输出、结构化错误清单、以及一份收窄后的输出要求。错误清单不写人话解释写机器可读的路径和取值因为模型对字段路径 允许值的响应比麻烦改一下稳定得多。pythondef build_repair_prompt(user_input: str, bad_output: str, errors: list) - str: lines [] for e in errors[:5]: # 只回传前 5 条多了反而干扰 if e[“cls”] “syntax”: if e.get(“truncated”): lines.append(f- 上次输出在第 {e[‘pos’]} 字符处被截断 f请缩短 slots 中的文本字段只保留要点总长控制在 200 字内) else: lines.append(f- 第 {e[‘pos’]} 字符处 JSON 不合法{e[‘msg’]}“) elif e[“cls”] “struct”: lines.append(f”- 缺少必填字段 {e[‘path’]}“) else: allow e.get(“enum”) lines.append(f”- {e[‘path’]} 取值不合法 (f只能是{, .join(allow)} if allow else “”)) err_block “\n”.join(lines) or “- 输出必须是合法 JSON 对象” return ( “上一次的输出不满足要求请重新输出。\n” f原始输入{user_input}\n f上次输出仅供对照不要复述{bad_output[:600]}\n f需要修正的问题\n{err_block}\n “只输出修正后的 JSON 对象不要任何解释文字。” )### 重试请求要带的三样东西三样东西是必需的错误定位哪个路径错、正确取值域合法选项或范围、约束提示长度或格式要求。缺任何一样修复率都会掉。我做过对照实验把同 407 条失败样本分成三组重试| 重试请求内容 | 修复率 | 平均耗时 || — | — | — || 只给格式不对请重新输出 | 48.0% | 2.1s || 加上错误定位与字段路径 | 62.9% | 2.3s || 定位 取值域 长度约束 | 76.7% | 2.4s |差距全部来自模型不需要猜。带完整信息的那组比只给模糊提示的那组只多花了 0.3 秒却多修好 28.7 个百分点这笔钱花得很值。### 两次重试的修复率数据样本是第 1、2 层之后仍然异常的 407 次请求每次都带完整错误信息重发| 尝试次数 | 进入本轮的失败数 | 本轮修复数 | 本轮修复率 | 累计完成率 | 单次平均耗时 || — | — | — | — | — | — || 第 1 次重试 | 407 | 312 | 76.7% | 76.7% | 2.4s || 第 2 次重试 | 95 | 61 | 64.2% | 91.6% | 3.1s || 第 3 次重试 | 34 | 11 | 32.4% | 94.3% | 4.8s || 第 4 次重试 | 23 | 3 | 13.0% | 95.0% | 6.2s |### 为什么第三次开始不划算看两列就够了修复率从 64.2% 掉到 32.4%而单次耗时从 3.1 秒涨到 4.8 秒。原因不难解释——需要第 3 次才能修好的基本都是输入本身就不支持这个输出结构的情况上下文里堆了两轮错误信息之后模型更容易被前文带偏甚至开始复述错误。成本侧也算过账每多一轮重试请求上下文平均多 1.6 倍输入词元第 3 轮之后的单位修复成本是第 1 轮的 5.3 倍。所以我把上限卡在 2 次并给整条重试链加了时间预算单条消息从首次请求到末次重试总耗时不超过 12 秒超预算就直接进降级。预算用配置写死不放在业务代码里这样改的时候只动一个数字。## 降级修不好也要给出确定的结果前两层加上重试异常率已经很低但不是零。降级层的作用是把低概率的异常变成确定的、可解释的结果。### 四级降级路径按成本从低到高我准备了四条路依次尝试1. 宽松解析正则从文本里抠关键字与数字只要能定位意图就放行标记confidence0.3。2. 模板兜底命中本地意图规则表关键词到意图的映射共 47 条时直接按模板组装结构跳过模型。3. 默认值填默认结构intentother把原文放进slots.raw_text。4. 显式交回用户以上都失败时不让程序静默继续而是回一句这句我没理解能换个说法吗并把原始输入落盘用于复盘。pythonDEGRADE [ (“loose_parse”, 0.30), # 宽松解析置信度上限 0.30 (“local_rule”, 0.45), # 本地规则表命中 (“default_fill”, 0.10), # 默认结构 (“ask_again”, 0.00), # 交回用户不猜]def degrade(user_input, metrics): for name, conf in DEGRADE: obj _try(name, user_input) if obj is not None: obj[“confidence”] conf metrics.inc(ffallback.{name}“) return obj return None第四级是我犹豫过的直接回一句没理解用户体验并不好。但这比静默给一个错误结果强——错误结果在后面某一环才会暴露而没理解当场就能让用户补一句话。### 降级必须可观测三个计数器降级危险的地方不是它存在而是降级率悄悄上涨”。用户看到的是能用了但因为降级结果往往是低置信度的业务质量在无声地退化。所以我盯三个计数器每天看一次曲线pythonimport timeclass Metrics: definit(self): self.c {} # 计数器 self.t {} # 耗时样本 def inc(self, key, n1): self.c[key] self.c.get(key, 0) n def obs(self, key, ms): s self.t.setdefault(key, [0, 0]) s[0] ms s[1] 1 def report(self): total self.c.get(“call.total”, 1) return { “format_error_rate”: self.c.get(“parse.fail”, 0) / total, “retry_rate”: self.c.get(“retry.total”, 0) / total, “retry_success_rate”: self.c.get(“retry.ok”, 0) / max(1, self.c.get(“retry.total”, 1)), “fallback_rate”: self.c.get(“fallback.total”, 0) / total, “fallback_by_level”: {k: v for k, v in self.c.items() if k.startswith(“fallback.”)}, “deadline_exceeded”: self.c.get(“deadline.exceed”, 0), “deadline_first”: self.c.get(“deadline.first”, 0), }三条告警线降级率连续 3 小时高于 0.2%、重试成功率低于 60%、时间预算超限次数单小时超过 5 次。这三条都在格式异常率还很好看的时候就能预警比等用户来反馈早了几个小时。## 上线前后对照口径、数字与我的两个误判### 端到端对照表| 阶段 | 结构化调用 | 格式异常 | 异常率 | 端到端失败 | 失败率 | P95 耗时 || — | — | — | — | — | — | — || 裸调用只有提示词 | 9,473 | 1,106 | 11.68% | 1,106 | 11.68% | 1.9s || 加约束输出 | 9,473 | 407 | 4.30% | 407 | 4.30% | 1.9s || 加校验 1 次重试 | 9,473 | 407 | 4.30% | 95 | 1.00% | 2.5s || 加第 2 次重试 | 9,473 | 407 | 4.30% | 34 | 0.36% | 2.8s || 加降级 | 9,473 | 407 | 4.30% | 8 | 0.08% | 2.8s |口径说明格式异常率是解析或校验未通过的次数 / 结构化调用总数它不随重试下降重试是另算的调用端到端失败率是三层全走完仍未拿到可用结构的次数 / 结构化调用总数这才是用户能感知的指标。两个指标必须分开看我早期只看前者得出过异常率 4.3%还很差的错误结论。反直觉的结论是重试让总调用量涨了 4.6%但 P95 耗时只涨了 0.9 秒从 1.9 到 2.8因为重试只发生在 4.3% 的请求上对分位数几乎没影响。真正拉高延迟的不是重试本身是并发被重试请求挤占的那部分——这条在下面踩坑 1 里细说。## 踩坑 1重试写成死循环一天烧掉三成额度### 事故经过早期版本的重试循环是这么写的while True: 调用如果校验通过就 break。我当时觉得反正模型总能修好没设上限。某天上游推理服务出现一次持续 40 分钟的性能抖动返回内容时好时坏。有一条请求进了循环失败 → 重发 → 失败 → 重发。日志里这条请求连续重试了 137 次中间没有任何停顿。后果是两个当天调用额度被这类请求吃掉 31%因为循环是在持锁的情况下跑的处理队列卡住堆积到 2,400 条待处理消息。问题在监控上还不可见——单条请求一直在进行中既没有报错也没有超时直到有人发现回复变慢才被注意到。### 三处修复修复 1硬上限。重试次数上限 2 次写死在配置里不允许调用方覆盖。pythonMAX_RETRY 2DEADLINE_MS 12_000def call_with_retry(client, user_input, schema, metrics): started time.monotonic() prompt user_input last_err None for attempt in range(MAX_RETRY 1): if (time.monotonic() - started) * 1000 DEADLINE_MS: metrics.inc(“deadline.exceed”) return None, {“cls”: “deadline”, “path”: “KaTeX parse error: Expected EOF, got } at position 28: …udget exceeded}̲ raw c…”): “”“只对声明为数值的字段做字符串到数字的转换其余一律不动。”“” if schema.get(“type”) “float” and isinstance(obj, str): try: return float(obj.replace(“,”, “”)), [] except ValueError: return obj, [{“cls”: “semantic”, “path”: path, “msg”: “not a number”}] if isinstance(obj, dict): errs [] for k, sub in schema.get(“fields”, {}).items(): if k in obj: obj[k], e coerce_numbers(obj[k], sub, f{path}.{k}“) errs.extend(e) return obj, errs return obj, []分界线我定得很死展示类字段宽容取值类字段严格。文案、摘要、原文这类字段允许拼写变体和多余空白金额、ID、枚举、时间戳走严格校验宁可重试也不用错值——一个被误判的金额比一次失败重试的代价高得多。归一化上线后结构错从 34% 降回 9%重试率回到 4.6%端到端失败率 0.11%。## 复盘三层防线买的是可预期”### 三层各自的价值第 1 层约束降低问题总量它把异常率从 11.68% 压到 4.30%是投入产出比很高的一层因为它几乎不花钱同样的调用只是把要求写清楚一点。第 2 层校验把不可见的错误变成可见的失败。它的直接收益是失败率下降但更重要的收益是——从这一层开始系统里每一次格式问题都会被记录、被分类、被计数。之前那些用户说点了没反应但日志里什么都没有的问题从这时起消失了。第 3 层重试与降级买的是尾部行为。它处理的是剩下的 4.3%把端到端失败率从 4.30% 压到 0.08%。这一层的成本比前两层加起来还高一倍收益在数字上看着不大但它是今晚能不能睡着的分界线上游抖动、长度超限、输入乱码这些不可控因素发生时系统有确定的行为而不是随机崩。### 如果只保留一层我留校验如果要砍到只有一层我留第 2 层。理由是它把一个概率问题变成了确定性判断模型可以不听指令但校验器不会漏掉任何一个不合格的输出。约束能减少问题重试能补救问题只有校验能让问题被看见。这也是我做完这件事之后对稳定的理解变化稳定的意思不是不出错而是坏情况下的行为可预测。知道那 4.3% 的请求会走重试、0.36% 会走降级、0.08% 会明确回退给用户并且这四类路径都有日志和计数器这件事本身比把失败率再降 0.05 个百分点更重要。## 相关实现上面这套三层防线不是设计文档里的推演它来自一台 Windows 电脑上运行的桌面工具。这个工具挂在一款个人聊天软件上做自动应答收到消息后先用模型把自然语言翻成结构再按结构去查数据、拼回复前面讲的约束、校验、重试、降级就在这条链路上跑。工具是单人维护的没有值班同学所以链路里所有不确定的地方都被换成了确定的分支能重试的带错误重试修不好的按模板或默认值兜底兜底也失败就明确回一句没理解。日均调用量不大但格式异常这条线上的每一次失败都能在本地计数器里找到痕迹。想了解这套东西还做了什么可以从下面这个入口进去看看。dingdang.asia/microai/
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。