大模型结构化输出
先说清楚一个概念,什么叫大模型结构化输出。大模型本质上是个只会生成自然语言的系统,你给它一段文本,它返回一段文本。但很多时候,我们要的不是一段话,而是程序能直接处理的数据。所谓结构化输出,就是让大模型不再用自然语言作答,而是严格按照我们规定的格式(通常是一段 JSON)返回结果,好让后面的代码能直接解析、取值、继续处理。
快速上手结构化输出
大模型是个 text-in、text-out 的系统,它的接口就是自然语言。
自然语言对人是绝佳的沟通方式,对软件却是糟糕的输入。你的代码迟早要拿某个字段去路由、去入库、去做分支判断,这时候一段对话就必须先变成一条结构化的记录。开头那个工单系统能不能自动派单,完全取决于模型这一步输出的 JSON 是否可靠。
把对话变成记录这件事,Spring AI 从第一天起就支持。定义一个 record,描述你想要的形状:
1 | record ActorsFilms(String actor, List<String> movies) {} |
然后别用 .content() 收尾(那拿到的是原始文本),改用 .entity(...):
1 | ActorsFilms films = chatClient.prompt() |
仅此而已。背后 Spring AI 做了三件事:把你的 record 转成 JSON schema,把 schema 拼进系统提示词,再把模型返回的 JSON 交给类型转换器解析回 record。这套机制对它支持的所有模型都管用,不挑厂商。
但它有一个致命的短板:没有任何保证。
模型是「被请求」产出符合 schema 的 JSON,而非「被强制」。大多数时候它听话,偶尔就不听,多个字段、漏个必填、或者用一段说明文字把 JSON 裹起来。一旦这样,解析器当场抛异常。
第一道保险:validateSchema 让模型自己纠错
最朴素的思路是:检测到输出不对,就重试。Spring AI 2.0 把这件事做成了一个开关:
1 | ActorsFilms films = chatClient.prompt() |
spec -> spec.validateSchema() 打开了一个会自纠错的重试循环,逻辑是这样的:
- 模型返回结果;
- Spring AI 拿
ActorsFilms的 schema 去校验这段返回; - 校验通过,你拿到类型对象,结束;
- 校验失败,就把具体的错误信息,比如「缺少必填字段 actor」「期望 array,实际是 string」,追加到提示词后面,重新发起请求,默认最多试 3 次。
关键在第四步。模型在每次重试时都能看到上一回到底错在哪。所以第二次不是盲目重试一遍,而是带着「我刚才哪儿错了」的信息去改。这跟 code review 时直接指出问题,远比只回一句「你再看看」要管用,是一个道理。
这套能力由 StructuredOutputValidationAdvisor 驱动,调用 validateSchema() 时自动注册,无需手动装配。要改重试次数,自己构建一个注册进去即可:
1 | var validationAdvisor = StructuredOutputValidationAdvisor.builder() |
第二道保险:useProviderStructuredOutput 从源头约束
validateSchema() 是响应侧的安全网,出了问题事后兜底再重试。还有一条互补的路子,从请求侧下手:直接在 API 层告诉模型厂商,这次响应必须符合 schema。
现在主流厂商基本都支持这个(OpenAI 的 Structured Outputs、Anthropic 的结构化输出扩展、Gemini 的 responseSchema、Mistral 的 response_format)。Spring AI 用同一个 spec 上的另一个开关把它们统一包了起来:
1 | ActorsFilms films = chatClient.prompt() |
打开后,底层发生三处变化:系统提示词里不再塞 JSON 格式说明(更干净、更省 token),schema 作为 API 字段直接发给厂商,由厂商的运行时强制约束,不合规的响应根本生成不出来。
好处很明显,但它默认是关的,原因是兼容性:老模型或不支持的模型会直接拒绝请求,而基于提示词的老方案在所有模型上都适用。另外几个坑也得提前知道:
- JSON Schema 支持往往是残缺的。 就算厂商宣称支持,能接受的 schema 也各不相同。
$ref、深层嵌套数组、allOf/anyOf/oneOf、正则、递归类型,都是常见的不支持项。 - Ollama 上的推理模型会出问题。 像 qwen 这类带 thinking 的变体,可能把内部思考过程当成纯文本输出,而不是 JSON,直接导致解析失败。换个非推理模型,或者搭配
validateSchema()兜底。 - OpenAI 不接受顶层数组。 想原生返回一个 List,得先用一个容器 record 包一层。
所以最稳妥的做法,是两个开关一起用:
1 | ActorsFilms films = chatClient.prompt() |
前者从 API 层把出错概率压到最低,后者捞起剩下那些漏网之鱼(厂商的边界情况、上面提到的 Ollama 特殊行为)自动纠正。当下游代码一个字段错位就会污染状态、或者后面某处突然报错时,就该把两个都打开。
不用 Spring AI 怎么办:json-repair
上面这套方案很实用,但前提是你在用 Spring AI。要是手头是别的技术栈、甚至别的语言,模型返回一段残缺的 JSON,又该如何处理?
这里要分清两种思路。Spring AI 的自纠错,本质是重新请求大模型,它把错误信息回灌给模型,让模型自己改。这意味着多一次 API 调用,多一份 token 和延迟。但很多 JSON 异常其实不必再请求一次模型:少个右括号、多个逗号、缺对引号,这些纯粹是字符串层面的小问题,在应用层修复即可,没必要为此多付一次调用成本、等一次网络往返。
json-repair 走的就是后一条路。它是个 Apache 2.0 的 Java 库,专门在应用层修复大模型生成的异常 JSON。用法很直白,先引依赖:
1 | <dependency> |
然后创建一个对象,调用 handle():
1 | JSONRepair repair = new JSONRepair(); |
如果模型把 JSON 混在一大段文本里,你还得先把它提取出来,那就开启提取功能:
1 | JSONRepairConfig config = new JSONRepairConfig(); |
0.4.0 版本目前能修的,大多是这类结构性破损:补缺失的右括号、右方括号、最外层括号,清掉数组里多余的逗号,给漏掉的值填上 null,给个别没加引号的字符串补引号,以及从文本里提取出符合 JSON 格式的片段并做有限修复。基准测试里单次修复基本在零点几毫秒级别,比起再调一次大模型,快了几个数量级。