Spring AI 结构化输出实战:让模型返回 JSON,而非自由文本
在前三篇文章中,我们解决了 Spring AI 三层架构、提示词工程和对话记忆。但还有一个困扰每个开发者的现实问题:模型返回的是自然语言文本,而我们的程序需要的是结构化数据。
例如,你让模型从用户评论中提取"电影名称、导演、年份",模型可能回复:
"根据您的描述,这部电影是《盗梦空间》,导演是克里斯托弗·诺兰,上映于2010年。"
如果走字符串解析,你需要写正则、处理各种变体,简直是一场噩梦。而且模型偶尔还会多说一句话,导致解析崩溃。
本文将从原理到实践,详细讲解如何让模型稳定地返回 JSON,并一键绑定为 Java 对象。
一、为什么模型爱说"废话"?
大语言模型本质是"下一个 token 预测器"。它并不知道你的程序想要 JSON,它只知道接下来最自然的文本是什么。如果不加约束,它倾向于输出完整的自然语言句子,因为历史上它见过的绝大多数文本都是自然语言。
要让模型输出 JSON,核心手段只有两类:
- 提示词约束:在 System/User 消息中明确要求"只输出 JSON,不要任何解释"。
- 解码约束:在模型层面开启 JSON Mode / Structured Output,从算法上保证输出是合法 JSON。
Spring AI 将这两者封装为便捷 API,下面逐一展开。
二、方案一:用 Prompt 指令"求"模型输出 JSON
最朴素的方式是手动拼接提示词。例如:
typescript
@RestController
public class JsonPromptController {
private final ChatClient chatClient;
public JsonPromptController(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel).build();
}
@GetMapping("/json/prompt")
public String rawJson(@RequestParam String review) {
String prompt = """
请从以下用户影评中提取信息,并严格按如下JSON格式返回,不要输出任何其他文字:
{
"title": "电影名称",
"director": "导演",
"year": 上映年份
}
影评:%s
""".formatted(review);
return chatClient.prompt()
.user(prompt)
.call()
.content();
}
}
如果模型听话,返回可能是:
json
{
"title": "盗梦空间",
"director": "克里斯托弗·诺兰",
"year": 2010
}
但实际往往会出现:
- 输出前后带
json 和代码块标记; - 输出里包含"以下是提取结果:"这样的前缀;
- 字段名变成
"电影名称"而不是title; - 甚至直接拒绝输出 JSON,长篇大论解释。
这种方式的优点是零依赖,缺点也极其明显:不稳定、不可靠。
三、方案二:模型层 JSON Mode
为了从解码层保证 JSON,OpenAI、Anthropic 等模型提供了 JSON Mode 或 Response Format。Spring AI 对部分厂商支持这种模式的配置。
以 OpenAI 为例,可以在请求选项中设置 responseFormat:
typescript
@Configuration
public class OpenAiConfig {
@Bean
public OpenAiChatOptions chatOptions() {
return OpenAiChatOptions.builder()
.model("gpt-4o-mini")
.responseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT))
.build();
}
}
或者在创建 ChatClient 时指定:
scss
this.chatClient = ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.responseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT))
.build())
.build();
启用 JSON Mode 后,模型会尽可能输出合法 JSON。但需要注意的是:
- 还需要在 Prompt 中指明 JSON 的结构;
- JSON Mode 不保证 JSON 和提示词中的 schema 完全匹配;
- 某些模型(如 Ollama 上的 Llama)可能不支持。
因此,Spring AI 官方推荐使用更高级的结构化输出方案。
四、方案三:Spring AI 结构化输出(OutputConverter)
Spring AI 提供了一组 OutputConverter实现,它们同时完成了"提示词约束"和"响应解析" ,让开发者只需定义 Java 类型,模型输出自动变成对象。
4.1 核心类
| 实现类 | 作用 |
|---|---|
BeanOutputConverter<T> |
将模型输出映射为任意 Java Bean / Record |
MapOutputConverter |
映射为 Map<String, Object> |
ListOutputConverter |
映射为 List<T> |
TextOutputConverter |
输出纯文本(默认行为) |
4.2 BeanOutputConverter 用法
首先定义一个 Java 类型。在 Spring AI 中,可以使用 Record(推荐):
arduino
public record Movie(
String title,
String director,
int year,
List<String> genres
) {}
然后创建转换器:
ini
BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
调用 converter.getFormat()会生成一段用于约束模型的指令,内容大致是:
javascript
你的响应必须遵守如下JSON Schema格式:
{"type":"object","properties":{"title":{"type":"string"},...}}
不要输出JSON以外的任何文本...
将这个字符串拼接到 System Prompt 中:
ini
String formatInstruction = converter.getFormat();
Movie movie = chatClient.prompt()
.system("你是信息抽取助手,请从用户输入中提取电影信息。\n" + formatInstruction)
.user(review)
.call()
.entity(converter);
ChatClient.call().entity(converter)会完成两步:
- 获取模型的文本响应;
- 用 Jackson 将 JSON 反序列化为
Movie对象。
如果模型返回的内容不是合法 JSON,Spring AI 的转换器会抛出异常。我们可以捕获并进行重试或降级。
4.3 更简洁的写法:直接传 Class
如果不需要自定义 System Prompt,可以简化为:
scss
Movie movie = chatClient.prompt()
.user(review)
.call()
.entity(Movie.class);
Spring AI 会内部自动创建 BeanOutputConverter<Movie>并注入格式指令。但这种方法对提示词的控制较少,推荐在复杂生产场景中显式使用 converter。
4.4 MapOutputConverter
当你不想定义 POJO,或者 JSON 结构动态变化时,可以使用 MapOutputConverter:
scss
MapOutputConverter converter = new MapOutputConverter();
Map<String, Object> result = chatClient.prompt()
.system("提取用户的姓名、年龄、职业,以JSON返回。" + converter.getFormat())
.user("我叫张三,今年25岁,是一名程序员")
.call()
.entity(converter);
得到:
json
{"name":"张三","age":25,"job":"程序员"}
4.5 ListOutputConverter
如果模型需要返回一组字符串(如关键词列表),可以直接用:
scss
ListOutputConverter converter = new ListOutputConverter();
List<String> keywords = chatClient.prompt()
.system("从用户输入中提取5个关键词,以JSON数组返回。" + converter.getFormat())
.user("Spring AI 是构建企业级AI应用的最佳实践框架")
.call()
.entity(converter);
ListOutputConverter要求模型返回类似 ["java","spring","ai"]的 JSON 数组。
五、格式化器的底层原理
BeanOutputConverter.getFormat()到底做了什么?
它利用 Jackson 2 的 JsonSchema生成器,将 Java 类型转换为 JSON Schema,然后包装成指令:
python
public String getFormat() {
String schema = JsonSchemaGenerator.generateJsonSchema(type);
return """
您的响应必须仅使用JSON格式,并且必须符合以下JSON Schema:
%s
不要输出任何JSON模式之外的文本。
""".formatted(schema);
}
当模型看到这个 Schema 时,会尽力生成符合要求的 JSON。Spring AI 拿到响应后,再用 ObjectMapper 反序列化到目标类型。
因此,为了反序列化成功,Java 类型必须有可用的构造机制:
- Record 可通过构造器自动完成;
- POJO 需要有默认构造器和 setter;
- 也可以使用
@JsonCreator注解。
如果字段缺失,可能导致对象为 null 或默认值,需要根据业务决定是否校验。
六、流式调用(SSE)中的 JSON 输出
我们之前讲过流式调用。如果需要在流式场景下获取 JSON,有两条路:
6.1 路径一:流式返回纯文本,前端自行解析
适合前端需要即时显示 JSON 内容的场景:
less
@GetMapping(value = "/json/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> jsonStream(@RequestParam String text) {
BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
return chatClient.prompt()
.system("请提取电影信息。" + converter.getFormat())
.user(text)
.stream()
.content();
}
前端收到的可能是分片 JSON,需要自行拼接后解析。
6.2 路径二:流结束后返回完整对象
如果后端只需最终得到一个 Movie对象,可以收集所有流片段后统一转换:
less
@GetMapping("/json/entity")
public Mono<Movie> jsonEntity(@RequestParam String text) {
BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
return chatClient.prompt()
.system("提取电影信息。" + converter.getFormat())
.user(text)
.stream()
.content()
.collectList()
.map(chunks -> String.join("", chunks))
.map(json -> converter.convert(json))
.onErrorResume(e -> Mono.just(new Movie("解析失败", "未知", 0, List.of())));
}
注意:entity(converter)在流式模式下也可直接使用,但 Spring AI 会先收集全部内容再解析,因此与第二种方法本质上相同。流式的好处是用户在等待解析前能感知到输出过程,但实现上增加了复杂度。如果目标是获得结构化对象,建议直接使用同步调用 call() 。
七、错误处理与降级策略
无论使用哪种方案,模型都不保证 100% 输出合法 JSON。生产环境必须做好防御。
7.1 捕获转换异常
scss
try {
Movie movie = chatClient.prompt()
.system("提取电影信息。" + converter.getFormat())
.user(review)
.call()
.entity(converter);
return movie;
} catch (Exception e) {
// 转换失败
log.error("JSON解析失败: {}", e.getMessage());
// 降级:直接返回原始文本,或调用其他模型
String raw = chatClient.prompt().user(review).call().content();
return parseWithFallback(raw);
}
7.2 设置较低 temperature
温度越高,模型越"发散",越容易偏离格式。结构化输出建议将 temperature 设置为 0 或接近 0:
scss
ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.temperature(0.0)
.build())
.build();
7.3 两次重试机制
如果第一次解析失败,可以带着错误信息让模型自己修正:
ini
public Movie extractWithRetry(String review, int maxRetries) {
BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
String prompt = "提取电影信息。\n" + converter.getFormat() + "\n影评:" + review;
for (int i = 0; i < maxRetries; i++) {
String response = chatClient.prompt().user(prompt).call().content();
try {
return converter.convert(response);
} catch (Exception e) {
log.warn("第{}次解析失败,让模型修正", i + 1);
prompt = "你上次输出无法解析为JSON:`" + response + "`,请重新输出符合格式的JSON。\n" +
converter.getFormat() + "\n原影评:" + review;
}
}
throw new RuntimeException("重试次数用尽");
}
7.4 对字段做非空校验
即使 JSON 合法,字段也可能缺失。使用 Bean Validation 或手动检查:
scss
if (movie.title() == null || movie.title().isBlank()) {
throw new IllegalArgumentException("缺少电影标题");
}
八、实践建议总结
| 实践点 | 推荐做法 |
|---|---|
| 首选方案 | 使用 BeanOutputConverter / ChatClient.entity() |
| 提示词统一 | 将 converter.getFormat() 放入 System Prompt,不要放在 User Prompt 中 |
| 模型参数 | 设置 temperature=0,开启 JSON Mode(如支持) |
| 类型定义 | 优先使用 Record,字段名用驼峰,可加 Jackson 注解 |
| 流式场景 | 如果最终需要对象,建议同步调用;如果为了体验,前端拼接 |
| 错误处理 | 必须捕获异常,提供重试或降级 |
| 日志与审计 | 记录模型原始输出,便于排查解析问题 |
| 兼容性 | 不同模型对 JSON Schema 遵从度不同,需回归测试 |
一个完整的 Controller 示例:
less
@RestController
@RequestMapping("/api/extract")
public class ExtractController {
private final ChatClient chatClient;
public ExtractController(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.temperature(0.0)
.build())
.build();
}
@PostMapping("/movie")
public ResponseEntity<Movie> extractMovie(@RequestBody ReviewRequest request) {
BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
try {
Movie movie = chatClient.prompt()
.system("从影评中提取电影信息。" + converter.getFormat())
.user(request.review())
.call()
.entity(converter);
return ResponseEntity.ok(movie);
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(new Movie("未知", "未知", 0, List.of()));
}
}
record ReviewRequest(String review) {}
}
九、总结
让模型返回 JSON,本质上是在"自然语言生成"和"程序数据契约"之间搭一座桥。
- Prompt 指令是桥上的路标,但会飘摇不定;
- JSON Mode 是桥的护栏,让模型难以跑偏;
- Spring AI 的 OutputConverter 则是铺好的沥青路面,将 JSON Schema 生成、响应解析、类型转换全部自动化。
在实际项目中,强烈推荐你:
- 始终使用
BeanOutputConverter或.entity(Class)代替手写 JSON 解析; - 将格式约束放在 System 消息中;
- 设置低 temperature,开启 JSON Mode;
- 做好异常捕获和重试。