让大模型乖乖吐 JSON:Spring AI 2.0 的自纠错结构化输出

大模型结构化输出

先说清楚一个概念,什么叫大模型结构化输出。大模型本质上是个只会生成自然语言的系统,你给它一段文本,它返回一段文本。但很多时候,我们要的不是一段话,而是程序能直接处理的数据。所谓结构化输出,就是让大模型不再用自然语言作答,而是严格按照我们规定的格式(通常是一段 JSON)返回结果,好让后面的代码能直接解析、取值、继续处理。

快速上手结构化输出

大模型是个 text-in、text-out 的系统,它的接口就是自然语言。

自然语言对人是绝佳的沟通方式,对软件却是糟糕的输入。你的代码迟早要拿某个字段去路由、去入库、去做分支判断,这时候一段对话就必须先变成一条结构化的记录。开头那个工单系统能不能自动派单,完全取决于模型这一步输出的 JSON 是否可靠。

把对话变成记录这件事,Spring AI 从第一天起就支持。定义一个 record,描述你想要的形状:

1
record ActorsFilms(String actor, List<String> movies) {}

然后别用 .content() 收尾(那拿到的是原始文本),改用 .entity(...)

1
2
3
4
ActorsFilms films = chatClient.prompt()
.user("生成一位随机演员的电影作品集")
.call()
.entity(ActorsFilms.class);

仅此而已。背后 Spring AI 做了三件事:把你的 record 转成 JSON schema,把 schema 拼进系统提示词,再把模型返回的 JSON 交给类型转换器解析回 record。这套机制对它支持的所有模型都管用,不挑厂商。

但它有一个致命的短板:没有任何保证。

模型是「被请求」产出符合 schema 的 JSON,而非「被强制」。大多数时候它听话,偶尔就不听,多个字段、漏个必填、或者用一段说明文字把 JSON 裹起来。一旦这样,解析器当场抛异常。

第一道保险:validateSchema 让模型自己纠错

最朴素的思路是:检测到输出不对,就重试。Spring AI 2.0 把这件事做成了一个开关:

1
2
3
4
ActorsFilms films = chatClient.prompt()
.user("生成一位随机演员的电影作品集")
.call()
.entity(ActorsFilms.class, spec -> spec.validateSchema());

spec -> spec.validateSchema() 打开了一个会自纠错的重试循环,逻辑是这样的:

  1. 模型返回结果;
  2. Spring AI 拿 ActorsFilms 的 schema 去校验这段返回;
  3. 校验通过,你拿到类型对象,结束;
  4. 校验失败,就把具体的错误信息,比如「缺少必填字段 actor」「期望 array,实际是 string」,追加到提示词后面,重新发起请求,默认最多试 3 次。

关键在第四步。模型在每次重试时都能看到上一回到底错在哪。所以第二次不是盲目重试一遍,而是带着「我刚才哪儿错了」的信息去改。这跟 code review 时直接指出问题,远比只回一句「你再看看」要管用,是一个道理。

这套能力由 StructuredOutputValidationAdvisor 驱动,调用 validateSchema() 时自动注册,无需手动装配。要改重试次数,自己构建一个注册进去即可:

1
2
3
4
5
6
7
8
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
.outputType(ActorsFilms.class)
.maxRepeatAttempts(5)
.build();

ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(validationAdvisor)
.build();

第二道保险:useProviderStructuredOutput 从源头约束

validateSchema() 是响应侧的安全网,出了问题事后兜底再重试。还有一条互补的路子,从请求侧下手:直接在 API 层告诉模型厂商,这次响应必须符合 schema。

现在主流厂商基本都支持这个(OpenAI 的 Structured Outputs、Anthropic 的结构化输出扩展、Gemini 的 responseSchema、Mistral 的 response_format)。Spring AI 用同一个 spec 上的另一个开关把它们统一包了起来:

1
2
3
4
ActorsFilms films = chatClient.prompt()
.user("生成一位随机演员的电影作品集")
.call()
.entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());

打开后,底层发生三处变化:系统提示词里不再塞 JSON 格式说明(更干净、更省 token),schema 作为 API 字段直接发给厂商,由厂商的运行时强制约束,不合规的响应根本生成不出来。

好处很明显,但它默认是关的,原因是兼容性:老模型或不支持的模型会直接拒绝请求,而基于提示词的老方案在所有模型上都适用。另外几个坑也得提前知道:

  • JSON Schema 支持往往是残缺的。 就算厂商宣称支持,能接受的 schema 也各不相同。$ref、深层嵌套数组、allOf/anyOf/oneOf、正则、递归类型,都是常见的不支持项。
  • Ollama 上的推理模型会出问题。 像 qwen 这类带 thinking 的变体,可能把内部思考过程当成纯文本输出,而不是 JSON,直接导致解析失败。换个非推理模型,或者搭配 validateSchema() 兜底。
  • OpenAI 不接受顶层数组。 想原生返回一个 List,得先用一个容器 record 包一层。

所以最稳妥的做法,是两个开关一起用:

1
2
3
4
5
6
ActorsFilms films = chatClient.prompt()
.user("生成一位随机演员的电影作品集")
.call()
.entity(ActorsFilms.class, spec -> spec
.useProviderStructuredOutput()
.validateSchema());

前者从 API 层把出错概率压到最低,后者捞起剩下那些漏网之鱼(厂商的边界情况、上面提到的 Ollama 特殊行为)自动纠正。当下游代码一个字段错位就会污染状态、或者后面某处突然报错时,就该把两个都打开。

不用 Spring AI 怎么办:json-repair

上面这套方案很实用,但前提是你在用 Spring AI。要是手头是别的技术栈、甚至别的语言,模型返回一段残缺的 JSON,又该如何处理?

这里要分清两种思路。Spring AI 的自纠错,本质是重新请求大模型,它把错误信息回灌给模型,让模型自己改。这意味着多一次 API 调用,多一份 token 和延迟。但很多 JSON 异常其实不必再请求一次模型:少个右括号、多个逗号、缺对引号,这些纯粹是字符串层面的小问题,在应用层修复即可,没必要为此多付一次调用成本、等一次网络往返。

json-repair 走的就是后一条路。它是个 Apache 2.0 的 Java 库,专门在应用层修复大模型生成的异常 JSON。用法很直白,先引依赖:

1
2
3
4
5
<dependency>
<groupId>io.github.haibiiin</groupId>
<artifactId>json-repair</artifactId>
<version>0.4.0</version>
</dependency>

然后创建一个对象,调用 handle()

1
2
JSONRepair repair = new JSONRepair();
String correctJSON = repair.handle(mistakeJSON);

如果模型把 JSON 混在一大段文本里,你还得先把它提取出来,那就开启提取功能:

1
2
3
4
JSONRepairConfig config = new JSONRepairConfig();
config.enableExtractJSON();
JSONRepair repair = new JSONRepair(config);
String correctJSON = repair.handle(mistakeJSON);

0.4.0 版本目前能修的,大多是这类结构性破损:补缺失的右括号、右方括号、最外层括号,清掉数组里多余的逗号,给漏掉的值填上 null,给个别没加引号的字符串补引号,以及从文本里提取出符合 JSON 格式的片段并做有限修复。基准测试里单次修复基本在零点几毫秒级别,比起再调一次大模型,快了几个数量级。