让大模型稳定输出 JSON:提示词、Schema、程序兜底的三层防线
假设你的服务在用大模型做结构化抽取:从工单抽字段、从合同抽条款、把用户输入转成 API 参数。上线第一周你会发现,大部分请求返回干净的 JSON,但总有百分之几解析直接失败——有的包着 markdown 代码块围栏,有的开头多一句「好的,以下是提取结果」,有的截断在字符串中间。解析一挂,下游全挂。
这不怪模型。大模型的文本生成本质上没有任何「必须输出合法 JSON」的硬约束,它只是在按概率延续文本。想让输出稳定不能靠运气,要靠三层防线:提示词从源头约束,Schema 用示例定格式,程序在终态兜底。三层各管一段,缺一层,那百分之几的事故就会落到你头上。
第一层:提示词——把契约写死
正面说清要什么,再集中列出禁止项
写格式要求时,很多人喜欢堆一堆「不要这样、不要那样」。否定指令弱是有原理的:模型要执行「不要 X」,得先在内部把 X 表征出来,等于把错误样例又强化了一遍。更好的写法是两段式——
先正面定义契约:「只输出一个合法的 JSON 对象,以 { 开头、} 结尾」;再集中列出禁止项,用强措辞:「禁止:markdown 代码块、任何解释性文字、任何前后缀语句」。用「禁止」不只是语气更硬,它是把规则定位成硬约束,而不是建议。
另一个常被忽略的技巧是近因偏好:模型对离生成位置越近的指令权重越高。所以同一条格式契约,在 system 开头定义一次,在 user 消息末尾再强调一次,违规率会明显下降。
缺省规则写死,不给模型发挥空间
抽取类任务最常见的脏输出不是格式错,而是模型「好心补全」:原文里没有的字段它编一个,列表为空它硬凑一条。必须在提示词里写死缺省规则:
- 字段在原文中找不到 → 填 null
- 列表为空 → 填 []
- 不确定 → 填 null;禁止猜测、禁止编造、禁止用「可能是」的值填充
这三行看似朴素,却是后面「值溯源校验」的前提——模型只有被明确告知不猜不编,下游才有资格要求它的值可信。
格式规则放 system,不进 user
三个理由:
- 职责分离。system 是稳定契约层,user 只装数据。格式要求和数据混在一起,模型容易把用户内容里的字面格式当成指令,顺带也缩小了 prompt 注入的风险面。
- 缓存命中。system 不变,prompt cache 前缀就能命中,又便宜又快。这条在讲重试时还会变现。
- 稳定性。按请求把格式规则动态拼进 user,是很多脏输出事故的直接根源——规则本身在变,模型行为当然不稳。
temperature 设 0
这条值得单独讲原理。大模型每生成一个 token,本质是输出一个覆盖全词表的概率分布,temperature 是采样前对分布做缩放的系数:T 趋近 0 时分布被拉尖,永远选概率最高的 token(贪心解码);T 等于 1 时按原始分布抽样;T 大于 1 时分布被拉平,冷门 token 被放大。
为什么结构化输出要设 0:抽取不是创作,你要的是「最可能的下一个 token」,不是「多样的」。T 大于 0 时,模型在本该输出 } 的位置可能抽样出一段补充解释,在本该填 null 的字段抽样出一个编造值——低概率分支正是脏输出的主要来源。T 为 0 还有个工程红利:失败可复现,同一个输入挂了重跑还挂,排查不靠玄学。
两个脚注:其一,T 为 0 并非绝对确定,浮点误差和调度批次会带来微小抖动,但「接近确定」对工程已经够用;其二,别一刀切——需要创造力的生成任务设 0 会写得呆,这条规则只针对结构化输出场景。
第二层:Schema——模型是模仿者,不是阅读理解者
给模型一段抽象的类型描述(「name 是字符串,date 是日期」),不如给它一个填好的实例。对大模型来说,示例就是最强的格式约束。
具体做法:
- 骨架 + 完整实例。手写目标 JSON 的结构并填一份完整示例值。注意是「完整」——别偷懒写「其余字段省略」,模型会真的模仿出省略号。
- 字段顺序即输出顺序。模型会照抄示例里的 key 顺序,把最关心的字段放前面。
- 类型写死。日期写明 ISO 8601 并给出示例值;数字不加引号;枚举字段把全部合法值列出来,一个不漏。
- 示例必须与规则自洽。这是最容易翻车的一条:规则说「文本没有的填 null」,示例里就必须真实出现一个 null 字段和一个空列表 []。模型对示例的模仿权重远高于对文字规则的理解——两者矛盾时,它学示例、丢规则。
- 进阶技巧:在正确示例后面附一个错误输出示例,一句话点明它错在哪。对比例对围栏、解释文字这类格式错误很有效。注意顺序:正确示例在前,坏例只是对照。
第三层:程序兜底——五层漏斗
提示词做到位能消灭绝大多数脏输出,但「绝大多数」不等于「全部」。只要大模型还在按概率生成文本,就一定有残余失败率——程序兜底不是可选项,是成本最低的保险。
兜底之前先分清失败类型,不同类型走不同的处理层:
| 类型 | 典型表现 | 能否程序处理 |
|---|---|---|
| A 格式污染 | 合法 JSON 外包着代码块围栏、前后有说明文字 | 能,字符串清洗 |
| B 语法残缺 | 截断、尾逗号、单引号、未转义换行、True/None、中文标点 | 能,修复解析 |
| C 结构错误 | 缺字段、数字带引号、枚举外取值、多出字段 | 能,校验+重试 |
| D 语义幻觉 | 字段存在,值是编的 | 难,只能溯源校验 |
然后按成本从低到高走五层漏斗,永远先走便宜的:
- 清洗。剥掉代码块围栏,截取第一个 { 到最后一个 }(数组同理),再走严格解析。这一层零成本,能吃掉大部分失败。
- 修复解析。严格解析失败才启用宽容解析(社区有现成的 JSON 修复库,处理尾逗号、单引号、Python 风格的 True/None、截断的半截 JSON)。铁律:修复完必须重新过一遍完整校验,不许「修完就信」。
- Schema 校验。解析成功不等于能用。用类型声明框架(Python 系如 Pydantic,TS 系如 Zod)做类型检查、枚举检查、必填检查,并收集全部错误而不是遇到第一个就抛——因为这些错误要喂给下一层。截断要单独识别:返回结果带「因长度截断」标志时,别浪费重试,直接加大输出预算或简化 Schema。
- 带错误反馈的重试。把「你的上次输出 + 具体报错」追加回对话重调。两个关键:报错必须具体(「date 字段是字符串『3月』,需要 ISO 8601 日期」),笼统的「格式错了」会让重试成功率骤降;最多重试两次,temperature 保持 0——上下文变了,不需要升温。system 没变,prompt cache 照常命中,重试的额外成本比想象中低。
- 终态。fail closed:返回结构化错误,绝不把修复后的「可能错」数据伪装成正常数据往下游走;原始脏输出落日志,各层命中率进监控。
三条贯穿原则
兜底是安全网,不是垃圾场。 每一层兜底命中都要留痕迹:第 1 层命中率持续偏高,说明提示词该改了;第 4 层频繁触发,说明 Schema 示例有问题。遥测数据反哺提示词迭代,这才是闭环——兜底的意义不只是撑住,更是告诉你源头哪里没做好。
D 类幻觉兜不住,只能校验。 如果字段值必须来自原文(ID、金额、日期、专有名词),做值溯源校验:输出的值必须在原文中出现过,否则按校验失败走重试。这是「不猜不编」失灵时唯一的程序化手段,也是全文性价比最高的一个技巧。
分级兜底,避免两个极端。 一个极端是只做了「解析失败就放弃」,中间几层全缺;另一个极端是修复后不校验直接用,把垃圾数据洗白着送进下游。五层各自独立、可以单独验证,按失败类型各就各位。
结尾:三层是一个闭环
回看这三层:提示词消灭大部分格式偏差,Schema 把剩下的大头按住,程序兜底接住尾部并告诉你源头哪里该改。它们不是三个并列技巧,而是一条从源头到终态的流水线,遥测数据让这条线转起来。
最后留一个进阶钩子:主流模型 API 现在大多提供原生结构化输出——JSON mode、基于 Schema 的约束解码(在解码层直接禁止非法 token)、函数调用。这些机制把「格式正确」从提示词约定变成解码层硬约束,是三层防线之后的下一步。它们的适用边界、代价和坑,留到系列的下一篇。