大模型工程项目里,最让人心里没底的一环往往不是模型“聪明不聪明”,而是它“听话不听话”。你要它输出一段 JSON,它给你夹带几句解释;你要它严格按枚举值来,它随手编了个新颜色;线上跑得好好的,突然某个字段类型变了,下游直接 500。这个问题有个正式名字:结构化输出稳定性。
我处理过不少这类问题,一条比较通用的治理路径已经成型,核心就是三层防线:第一层用 JSON Schema 做约束,让模型的第一版输出尽量合法;第二层靠重试和修复,让不合法输出有机会被校正;第三层是线上的异常兜底,保证即使前两层失败,业务也不会裸奔。这篇文章把这三层展开聊聊,重点讲每层的技术选型、落地细节和实际踩过的坑。
1. 为什么结构化输出是刚需,稳定住它更难
先说个基础认知。大模型本身是概率模型,输出天然不稳定。你给它同样的 Prompt,两次结果可能不一样,这个大家都能接受。但问题在于,工程系统不欢迎“不稳定”。下游要的是字段名固定、类型固定、枚举范围固定,数据才能正常入库、渲染、触发流程。
举个例子,某业务需要模型从用户留言里抽取“意向等级”,你定义了三个档:high / middle / low。模型第一次输出{"level": "high"},第二次可能输出{"level": "High"},第三次干脆输出{"level": "高级"}。每种写法人眼都能看懂,但程序只看你对不上,校验一跑就挂。
这个场景非常典型,所以大家开始引入约束。约束的思路有两个方向:一是从生成侧限制,代表方案是结构化生成,直接约束解码空间;二是从校验侧把关,拿到文本之后按规则检查,不合格就处理。两者各有用武之地,我这个治理体系里两者都用了,但架构上把“校验”作为主线,原因是通用性和可观测性更好。
为什么建议把校验作为主线?因为解码侧约束工具一般绑定具体模型服务或框架,换模型、换供应商时容易被锁死。而 JSON Schema 校验是标准化的,不关心文本从哪来。先把“结果必须合法”这个底限守住,再谈怎么提升第一版合法率,治理会稳妥很多。
再补一句,稳定性治理的目标不是“100% 不失败”。只要你的系统有网络、有权重浮动、有并发,就一定有失败概率。做治理的核心是量化失败率、把失败控制在可接受范围、失败发生时系统不崩、事后能复盘改进。带着这个目标去看三层防线,功能就非常清晰了:约束负责降概率,重试修复负责纠偏差,兜底负责守住底线。
2. 约束层:JSON Schema 是第一道闸门
2.1 Schema 怎么设计才算合格
很多团队刚开始做结构化输出,Schema 写得很随性。最常见的问题是把所有字段全塞进一个平铺结构里,字段名随便叫,类型也不严谨。这种 Schema 约束效果很差,因为模型看到的指令不够明确,会自由发挥。
拿抽取意向等级举例,一份合格 Schema 大概长下面这样。
{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "level": { "type": "string", "enum": ["high", "middle", "low"] }, "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, "reason": { "type": "string", "maxLength": 200 } }, "required": ["level", "confidence"], "additionalProperties": false }这里有几个设计细节值得展开。
字段名的英文命名要贴近业务直觉。level比intention_level好,比level_1好得多。模型对自然语言指令的理解是概率性的,语义越直白,出错率越低。
枚举值应该小写且统一格式。你定了["high", "middle", "low"],模型输出"High"的概率会明显降低,但不可能为零。所以枚举之外还必须有后手,比如注册一个多值映射的解析器,把同义表达归一到合法值,后文会详细讲。
additionalProperties: false这个设置建议打开。不打开的话,模型会出于“好心”给你额外加字段,比如"level_note": "用户情绪积极"。这些字段不一定是坏事,但会破坏下游强类型解析的一致性。关闭附加属性后,模型会收敛到给定字段,输出整体稳定很多。
required 字段宁少勿多,但业务上不可缺的必须写上。如果某个字段允许缺失,就不要放进 required;反过来说,既然业务非用不可,那就一定要让模型知道它“必须给”,不能靠它自觉。
2.2 提示词嵌入 Schema 的正确姿势
光有 Schema 不够,还得把 Schema 传给模型。常规做法是两种:一是放进系统提示词,二是用结构化解码框架从外部注入。两条路都要走。
我在系统提示词里通常会写这么一段:
你是一名数据处理引擎。你必须严格按照 JSON Schema 的要求输出,不要输出任何解释、前言或多余字符。 Schema: {schema_json} 要求: - 只输出 JSON 对象,不要包裹 Markdown 代码块。 - 字段名不得改动,枚举值必须原样输出。 - 如果输入缺少必要信息,用 null 填充,不要臆造。 - 所有输出必须能被标准 JSON 解析器解析。这段提示词里有几个值得注意的点。
“只输出 JSON 对象,不要包裹 Markdown 代码块”这句话很重要。很多模型默认把 JSON 放代码块里,表面看着没毛病,但如果你直接拿原始文本做JSON.parse,就会被代码块标记给卡住。备选的兜底方案是拿到文本先剥代码块再解析,后文会说到。
“用 null 填充,不要臆造”也非常关键。模型有“补全”倾向,信息不足时它倾向于编造一个合理值,对于抽取类任务这是大忌。明确授权输出 null 反而能抑制幻觉,提升置信度字段的真实性。
出去聊经验的时候,很多朋友觉得 Schema 写完后就不用管了,这是误解。约束层必须配合“样本级验证”:上线前拿几十条真实输入跑一遍,统计首过合法率,不达标就继续调 Schema 和提示词。没有这个步骤,Schema 写得再漂亮也只是纸面规范。
2.3 约束层不能解决的事
说完 Schema 的能力,必须泼一盆冷水。约束层的法律效力只存在于“模型愿意遵守”的时候。几个典型漏网之鱼是:
- 字段值格式合法但语义不对,比如
level输出middle但理由字段说的却是高意向; - 类型合法但取值范围异常,比如 confidence 负值(如果你忘了设 minimum);
- 整个输出合法但答案根本不在上下文里,属于事实性幻觉;
- 输出合法但是被截断了,JSON 语法都可能不完整。
所以如果你以为“有 Schema 就万事大吉”,迟早会被线上告警教育。稳定的输出治理体系,约束只是最前置的一步,真正的形态是把校验当成核心决策点,驱动后面的修复和兜底动作。
3. 修复层:靠重试把不合法输出拉回来
3.1 重试之前,先做规范化解析
遇到不合法输出,脑子里不能只有“重来一次”这一个动作。真正高效的修复流程是先尝试低成本规范化,再做针对性重试,最后才考虑降级兜底。
规范化解析解决的是格式瑕疵问题。举个例子,模型输出了 Markdown 代码块:
```json {"level": "high", "confidence": 0.9} ```直接JSON.parse肯定失败。但这段文本信息是完整的,丢掉代码块标记就能解析。我习惯把这类情况叫“可修复格式错误”,处理优先级最高。
规范化解析器的设计不复杂,核心几步:
- 去掉首尾不可见字符;
- 识别并剥掉 Markdown 代码块前后缀;
- 尝试从原文本中截取第一个
{到最后一个}之间的内容; - 对截取后的内容做 JSON 解析。
这套方法加起来代码也就几十行,但能修复的高频问题占到了不合法输出的 40% 以上。网上不少人对“JSON 修复”有执念,动不动引入重型库,其实先做规范化,很多问题已经消失在你眼前。
经过规范化之后仍然解析失败的,才轮到“正则修复”。常见问题包括:值带了多余引号、多层括号嵌套错乱、少了逗号。这些可以写一些小规模规则去修,但我不建议修得太深。规则一多,你会陷入“每个错误都定制规则”的泥潭,后期维护成本很高。我对正则修复的定位是:只修有把握的、低风险的、高频的少量错误,其余交给模型自修复。
3.2 让模型自己修:把错误信息喂回去
规范化解析无效时,重试不是单纯地再问一次原问题。正确姿势是“错误反馈式重试”:把上一轮输出和具体失败原因一起塞给模型,让它针对错误修正。
简单打个比方,你让实习生填一份表格,他填错了几项,你要做的是把表格和批注一起还给他,而不是什么也不说让他重新填一次。
Prompt 大概这样组织:
你上次的 JSON 解析失败,原因如下: {validation_error} 你上次的输出是: {raw_output} 请重新生成。注意: - 严格依据原始 Schema 输出。 - 不要修改字段名和枚举值。 - 直接输出修订后的 JSON,不要额外解释。这个方案的底层逻辑是大模型有很强的“对话式修正”能力,给它具体错误信息,它通常能自己发现格式问题。实际跑下来,合法率提升非常明显。
这里有两个细节:
第一,必须把原始输出原文贴回去,不然模型看不到自己犯了什么错。有的实现只贴校验报错不贴原文,模型经常一脸雾水。
第二,反馈信息不能太笼统。“JSON 解析失败”和“第 3 个字段错误,期望枚举类型,实际值不在允许列表内”,后者的修复率明显更高,因为模型获得了真正的线索。
3.3 别把重试做成无底洞
重试虽好,但必须有节制。不然一次请求变成五连问,响应时长拉爆,费 token 不说,用户早就失去耐心。
我的工程标准是:规范化解析 + 模型自修复,最多再补一次不带错误上下文的基础重试,总共不超过三轮。再多基本就是浪费资源,因为失败往往不是偶然波动,而是模型能力、输入质量、Schema 复杂度的综合问题。
重试之间要加指数退避。第一轮失败后停 200ms 再重试,第二轮停 500ms,第三轮停 1s。这个等待不是随便定的,是为了避免短时间内反复触发同一模型的相似输入,造成重复错误。也顺便避开下游瞬时抖动。
还有一点,重试的触发条件不要只盯“解析失败”。“校验失败”也要纳入。比如 JSON 解析成功但字段缺失、枚举不合法,这些都属于可重试范围。很多工程踩过这个坑:解析器过了,但业务校验发现字段不对,这时如果把数据直接入库,等于变相容忍了脏数据。校验不过就应该走重试逻辑,不要让不合规的数据往下流。
3.4 重试工厂:把修复策略代码化
重试逻辑一旦写明白,就要固化成可复用的代码模块。我的习惯是写一个轻量级的重试编排器,对调用方暴露统一接口,内部处理规范化、重试、终止条件、耗时统计。
伪代码思路大概是:
def get_structured_output(query, schema): result = llm.query(query, schema=schema) parsed, err = normalize_parse(result) if parsed: return parsed for attempt in range(MAX_RETRIES): feedback = build_feedback(parsed_raw_output, err) result = llm.query(query, schema=schema, extra=feedback) parsed, err = normalize_parse(result) if parsed: return parsed raise StructuredOutputError(last_raw_output=result, last_error=err)实现的时候重点注意两点:
一是每一次重试都要把完整链路日志记录下来,包括第几轮、错误类型、模型回复内容。没有这些信息,后面优化完全靠猜。
二是要把重试循环放在“独立的服务方法”里,不要散落在业务大流程各处。这样每个调用点都是同一套策略,测试也好写,后面要调整重试次数只需改一处。
这套重试修复体系上线后,整体合法率大概能提升到 95% 以上。也就是说一百次结构化请求里,大约只有几次需要真正走兜底流程。但别急,这剩下的不到 5%,才是线上稳定性最关键的部分。
4. 兜底层:即使全失败了也不能让线上裸奔
4.1 失败分类是兜底的前提
很多团队做兜底,一上来就是“返回空值”。这当然简单,但丢失了太多可利用的信息。对失败场景做分类,才能做出更有质量的处理决策。
我把结构化输出失败大致分为四类:
一是解析失败。文本不是合法 JSON,或者结构不对,整体无法读取。
二是校验失败。JSON 能解析,但字段缺失、枚举不合法、类型错误、范围越界。
三是语义失败。字段合法,但内容明显不合业务逻辑。比如level是high,理由字段却写“用户没有意向”。这类最隐蔽,单靠 Schema 很难发现,需要结合业务规则或额外模型判断。
四是超时或上游异常。模型 API 超时、限流、服务端错误,这类不属于模型输出质量问题,但兜底逻辑必须覆盖。
每类失败的处理策略不同:解析和校验失败可以进修复重试;语义失败优先人工介入,或降级为默认值;超时类要看具体错误码决定重试还是立即降级。
4.2 兜底返回值的艺术
设计兜底返回值时,最忌讳的是“填个空字符串凑合”。空值会让下游逻辑产生连锁反应,到时候线上问题从“解析失败”变成“空指针”或者“页面空白”,排查成本反而更高。
好的兜底设计,要保证“形状不变、语义可用”。比如抽取意图等级,兜底值设置成:
{ "level": "unknown", "confidence": 0, "reason": "model_output_unavailable" }这个设计妙在:level枚举里加入了unknown作为业务上合法的兜底档位;confidence置 0 表达“不可信”;reason提供了错误标识,方便后续检索。下游收到这样的结果,不会崩溃,但能明确感知到数据质量异常。
这个思路的核心是:把兜底值当成正常枚举的一部分,而不是边界外的野值。提前跟产品经理敲定“unknown 是合法值”,比线上出了问题再解释强得多。
4.3 降级、熔断与上下文保留
高并发场景下,批量请求一起失败会形成连锁效应。比如某运营接口每五分钟调用一次模型抽取热门话题,某天模型服务抖动,连续五分钟每分钟 200 次调用全部失败,这时候疯狂重试只会加剧上游压力,把一个小抖动放大成一个大故障。
所以兜底层一定要有降级和熔断机制。
降级的常见做法是:最近的失败如果达到一定阈值,直接从“调用模型”降级为“读取缓存”。只要上一次成功结果还在有效期内,就能兜住一阵子。例如话题抽取结果缓存半小时,模型抖动期间用户看到的仍是半小时内的话题列表,体验上基本无感。
熔断的做法是:统计连续失败或错误率,超过阈值就打开开关,短时间内停止调用模型,让系统“喘口气”。开关上通常配滑动窗口,窗口长度我倾向用 60 秒,阈值为窗口内失败率 50% 或连续失败 10 次,具体数值需要根据压力测试调整。
这里上下文保留是很容易被忽略的点。熔断开关打开后,有几件事要立即做:
- 记录熔断开始时间;
- 缓存当前生效的降级数据版本;
- 启动一个后台探测任务,每隔一段时间试一次“最小代价请求”,验证上游是否恢复;
- 探测成功后自动关闭熔断,恢复主链路。
有的团队熔断期间直接不调模型,但没有任何恢复探测,结果上游早恢复了,业务却降级了几个小时,白白损失输出质量。探测机制虽然写起来多几十行代码,但它是自动恢复的关键。
4.4 监控、追踪与告警:兜底不是终点
兜底跑得好不好,不能靠感觉。必须把链路埋点做好,形成一张能回答“哪些业务在降级、失败原因是什么、趋势如何”的监控面板。
我习惯在每个结构化输出调用入口埋下指标:
- 总数(total)
- 解析失败数(parse_error)
- 校验失败数(validation_error)
- 语义失败数(semantic_error)
- 重试次数分布
- 兜底触发次数
- 模型响应延迟
- 整体耗时
这些指标要按业务维度打点,每个业务一个biz_id。这样当某条曲线突然上升,可以立刻定位到具体业务,而不是整个大池子一起看。
告警阈值设计上,我的经验是别等失败率飙到 30% 才报警,那就太晚了。更合理的做法是对环比趋势做告警:当五分钟失败率和前五个相同周期相比增长超过三倍时触发 P2 告警。这种相对告警能捕获早期异常,同时减少周五晚高峰的无聊噪声。
链路追踪推荐用标准 trace 体系。每个请求贯穿“模型调用 → 规范化解析 → 重试 → 兜底”全过程,构建唯一请求 ID。兜底触发时,全链路日志能把原始输出、错误信息、最终返回值串成一条线,排查效率提高一个量级。没追踪体系的排查,等于在黑暗里摸钥匙。
5. 全链路治理的整体编排与实测效果
三层防线讲完了,但关键在于怎么编排。完整的流程一般是这样的:
首先接收请求,进行格式化和 Schema 注入。这一步看似简单,但要注意通过模板引擎把 Schema 转成字符串时,有可能引入多余的转义符,所以建议用静态 JSON 字符串传递,不要手拼。
然后发起模型调用,得到原始文本。紧接着进入“快速评审”阶段:先规范化解析,成功则校验字段;校验失败则进入重试循环;重试无果则走失败分类和兜底。任何一步成功,就把结构化结果返回上游,同时记录打点。
我落地过一套这个体系,上线两周内的实测数据是:规范化解析直接把合法率从约 82% 拉到约 89%,重试修复再拉高到约 96%,兜底流程覆盖剩余。也就是说,最终用户感知不到模型输出异常的占比超过了 99%。
但这组数字不具备绝对参考性,因为具体合法率跟模型型号、任务难度、Schema 复杂度强相关。如果你拿到的数字比这低很多,先别急着怀疑架构。排查顺序我一般这样走:
先看 Schema 是不是太复杂。嵌套超过三四层,字段数量超过二十个,首过合法率会显著下降。解决办法是多用枚举、少用自由文本、嵌套结构用扁平化替代。
再看提示词是否闭环。有没有明确禁止模型输出解释语句?有没有告诉它缺失信息填 null?很多项目是提示词漏了约束,模型才“自由发挥”。
最后看模型能力边界。如果就是任务超过模型能力,Schema 再优化也白搭。这时候该拆分任务就拆分,该换模型就换,别硬撑。
6. 踩过的一些坑,提前帮你避开
最后分享几个实操里反复遇到的坑,每一条都是真金白银的线上教训。
第一个坑是additionalProperties: false没开。早期某个项目没开这个选项,模型抽着抽着给你加了个"summary": "..."字段,下游 Java 强类型反序列化直接报错。开了之后,字段收敛,这个报错消失。所以这个选项我建议默认打开。
第二个坑是重试时不区分错误类型。有段时间我们统一对“校验失败”重试三次,但有些语义错误重试再多也修不好,白白浪费 token 和延迟。后来给错误类型打了权重:解析错误重试收益最高,枚举不合法次之,语义错误不重试直接走兜底或人工,效果改善非常明显。
第三个坑是格式错误的重试 Prompt 里没有放原始错误输出。早期某版本只在重试时告诉模型“你错了请改正”,结果模型思考一会儿后交出一份内容更差的答案。把原始文本放回去之后,修复成功率才明显上来。
第四个坑是兜底值没有加入业务枚举。有一次兜底值直接返回空字符串,下游展示页渲染了个空白卡片,用户反馈量飙升。后来把unknown直接写进业务枚举,同时加了一段前台默认文案,体验才算兜住。
第五个坑是缺少熔断探测。某次模型供应商上游故障恢复后,我们的系统还继续降级读取缓存,白白用旧数据跑了几个小时。补上每分钟一次的探测后,这个场景自动恢复,才算是真正闭环。
7. 最后再补充几句
做结构化输出稳定性治理,时间越久越觉得它不是单一技术问题,而是“提示词 + Schema + 工程策略 + 监控体系”的组合工程。约束层负责压低失败概率,修复层负责校正失败结果,兜底层负责吸收残余失败,每一层的目标都不是做到完美,而是把失败的影响控制在业务可接受范围内。
我个人建议从最简单的规范化解析开始,收益立竿见影,然后再慢慢把 Schema 约束、重试、兜底几个环节逐个补齐。别一开始就追求大而全,工程是迭代出来的,不是设计出来的。等你把这条链路跑顺了,再回头看当初那个“模型又输出不合法 JSON”的线上告警,你会觉得它其实没那么可怕。