Spring AI 结构化输出实战:让模型返回 JSON,而非自由文本

Spring AI 结构化输出实战:让模型返回 JSON,而非自由文本

在前三篇文章中,我们解决了 Spring AI 三层架构、提示词工程和对话记忆。但还有一个困扰每个开发者的现实问题:模型返回的是自然语言文本,而我们的程序需要的是结构化数据

例如,你让模型从用户评论中提取"电影名称、导演、年份",模型可能回复:

"根据您的描述,这部电影是《盗梦空间》,导演是克里斯托弗·诺兰,上映于2010年。"

如果走字符串解析,你需要写正则、处理各种变体,简直是一场噩梦。而且模型偶尔还会多说一句话,导致解析崩溃。

本文将从原理到实践,详细讲解如何让模型稳定地返回 JSON,并一键绑定为 Java 对象。


一、为什么模型爱说"废话"?

大语言模型本质是"下一个 token 预测器"。它并不知道你的程序想要 JSON,它只知道接下来最自然的文本是什么。如果不加约束,它倾向于输出完整的自然语言句子,因为历史上它见过的绝大多数文本都是自然语言。

要让模型输出 JSON,核心手段只有两类:

  1. 提示词约束:在 System/User 消息中明确要求"只输出 JSON,不要任何解释"。
  2. 解码约束:在模型层面开启 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)会完成两步:

  1. 获取模型的文本响应;
  2. 用 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 生成、响应解析、类型转换全部自动化。

在实际项目中,强烈推荐你:

  1. 始终使用 BeanOutputConverter.entity(Class)代替手写 JSON 解析;
  2. 将格式约束放在 System 消息中;
  3. 设置低 temperature,开启 JSON Mode;
  4. 做好异常捕获和重试。
相关推荐
用户8356290780511 小时前
使用 Python 为 PDF 添加和管理超链接
后端·python
抠脚小弟1 小时前
Spring Task 定时任务详解:从入门到实战
java·后端·spring
n8n2 小时前
Spring AI 对话记忆深度实践:无状态本质、ChatMemory 抽象、上下文管理与会话隔离
后端
ShuiShenHuoLe2 小时前
golang-jwt v5 入门
开发语言·后端·golang
码事漫谈2 小时前
DeepSeek V4.1 Flash:一次把自家旗舰送走的发布
后端
LXMXHJ2 小时前
springboot中的线程操作
java·spring boot·后端·线程
Sinclair2 小时前
MCP 功能详解:内置 108 个工具,让 AI 直接帮你管理网站
后端·mcp
用户233376852182 小时前
接口卡死排查实录-缺失return的UB死循环
前端·后端
用户489148799763 小时前
Go 系统服务开发实战:systemd+sdnotify + 看门狗-agent与系统服务
后端