上一篇文章我们实现了打字机效果的流式对话,但 AI 返回的一直是自由格式的文本。今天来解决一个更实际的痛点:如何让 AI 把答案直接装进你定义好的 Java 对象里。

在真实业务中,你经常需要的不是一个聊天段落,而是一个可以写入数据库、传给前端组件、参与计算的结构化结果。比如:

  • 从用户输入中提取姓名、年龄、地区,返回一个 UserProfile 对象;
  • 分析一段商品评论,输出 SentimentResult(情感倾向、关键词、评分);
  • 生成一份日报摘要,映射为 DailyReport 实体。

如果靠正则或者字符串截取来解析 AI 的自由文本,那可就太痛苦了。Spring AI 提供了一种优雅的方式:你定义好 Java Bean,剩下的映射工作框架帮你完成。

一、痛点场景:自由文本解析的噩梦

假设你做了一个智能简历解析功能,用户输入一段话:

我叫张三,今年28岁,是一名Java开发工程师,base在北京。

你希望得到一个这样的对象:

{
  "name": "张三",
  "age": 28,
  "job": "Java开发工程师",
  "city": "北京"
}

如果让 AI 返回普通文本:“好的,解析结果:姓名张三,年龄28……”,然后你自己写正则去抠数据,不仅代码丑,而且 AI 稍微换个表述你的正则可能就失效了。你真正想要的是:AI 直接输出结构化的 JSON,并且框架自动把 JSON 反序列化成你的 Java Bean。

Spring AI 的结构化输出能力,就是为此而生。

二、核心概念快览

2.1 结构化输出

结构化输出 是指让大语言模型返回符合预定义格式的数据(通常是 JSON),而不是自由文本。Spring AI 通过 BeanOutputConverter 实现了一套机制:将你的 Java 类转换为 JSON Schema 描述,随请求一起发给模型,让模型按要求输出 JSON,然后框架自动将返回的 JSON 反序列化为对应的 Bean。

2.2 BeanOutputConverter

Spring AI 内置了一个输出转换器 BeanOutputConverter,它可以:

  • 根据给定的 Java 类生成对应的 JSON Schema;
  • 将模型返回的文本解析为指定类型的对象;
  • 自动处理一些常见的格式问题(如 JSON 被包裹在 ```json 代码块中)。

在日常使用中,你通常不需要直接操作 BeanOutputConverter,因为 ChatClient 已经提供了更简便的 .entity(Class) 方法。

2.3 调用方式对比

// 返回纯文本
String text = chatClient.prompt().user(...).call().content();

// 返回结构化对象
MyBean bean = chatClient.prompt().user(...).call().entity(MyBean.class);

entity() 方法内部就使用了 BeanOutputConverter。它会隐式地在系统提示中加入“请返回一个 JSON 对象,格式如下……”的指令,所以你甚至不需要自己写复杂的 prompt。

三、环境准备

不需要引入任何新依赖。我们之前用到的 spring-ai-starter-model-openai 已经包含了结构化输出所需的一切。如果想对映射成功的 Bean 做额外的字段校验,可以引入 Spring Boot 的 spring-boot-starter-validation,但这一步是可选的,今天我们先不依赖它也能演示。

配置文件继续沿用之前的设置即可,以 DeepSeek 为例:

spring:
  ai:
    openai:
      api-key: ${DEEPSEEK_API_KEY}
      base-url: https://api.deepseek.com
      chat:
        options:
          model: deepseek-chat
          temperature: 0.3   # 低温度有助于生成更稳定的 JSON

注意:结构化输出时,建议将 temperature 设得较低(如 0.1-0.3),降低模型“发挥”空间,减少输出格式不正确的概率。

四、代码实战

4.1 定义目标 Bean

我们以简历解析场景为例,创建一个 PersonProfile 类:

package com.example.springaihelloworld.model;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
 * 人员信息实体
 * 属性使用 @JsonProperty 来明确 JSON 字段名
 */
public class PersonProfile {

    @JsonProperty("name")
    private String name;

    @JsonProperty("age")
    private Integer age;

    @JsonProperty("job")
    private String job;

    @JsonProperty("city")
    private String city;

    // 无参构造器(反序列化必需)
    public PersonProfile() {}

    public PersonProfile(String name, Integer age, String job, String city) {
        this.name = name;
        this.age = age;
        this.job = job;
        this.city = city;
    }

    // getter / setter
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }

    public String getJob() { return job; }
    public void setJob(String job) { this.job = job; }

    public String getCity() { return city; }
    public void setCity(String city) { this.city = city; }

    @Override
    public String toString() {
        return "PersonProfile{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", job='" + job + '\'' +
                ", city='" + city + '\'' +
                '}';
    }
}

注意:

  • 必须有无参构造器,否则 Jackson 无法反序列化。
  • 使用 @JsonProperty 明确指定字段名,避免 Java 属性名和 JSON key 不一致时出问题。

4.2 Service 层:结构化调用

AIChatService 中新增方法,演示通过 .entity() 直接映射:

package com.example.springaihelloworld.service;

import com.example.springaihelloworld.model.PersonProfile;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.stereotype.Service;

@Service
public class AIChatService {

    private final ChatClient chatClient;

    public AIChatService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    /**
     * 结构化输出:从用户输入中解析出 PersonProfile
     */
    public PersonProfile extractPersonProfile(String userInput) {
        return chatClient
                .prompt()
                .user(userInput)             // 用户输入的原始文本
                .call()
                .entity(PersonProfile.class); // 直接映射为实体
    }

    /**
     * 更精细的控制:手工构造提示词并映射
     * 当模型能力较弱时,可以显式说明输出格式
     */
    public PersonProfile extractPersonProfileWithHint(String userInput) {
        String systemHint = "从用户输入中提取人员信息,只返回一个 JSON 对象,字段包括:name, age, job, city。年龄必须为整数。";
        return chatClient
                .prompt()
                .system(systemHint)
                .user(userInput)
                .call()
                .entity(PersonProfile.class);
    }
}

关键点

  • .entity(PersonProfile.class) 会自动要求模型返回 JSON,并将 JSON 反序列化为 PersonProfile 实例。
  • 如果模型返回的文本不是合法 JSON(例如被包裹在 markdown 代码块中),BeanOutputConverter 会尽力清洗,但如果实在无法解析,会抛出异常。
  • 你还可以通过 .system() 给出更明确的指令,提高成功率。

4.3 Controller 层:暴露接口

创建或扩展 ChatStructureController

package com.example.springaihelloworld.controller;

import com.example.springaihelloworld.model.PersonProfile;
import com.example.springaihelloworld.service.AIChatService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatStructureController {

    private final AIChatService chatService;

    public ChatStructureController(AIChatService chatService) {
        this.chatService = chatService;
    }

    /**
     * 结构化解析接口
     * 示例:/chat/profile?input=我叫张三,28岁,Java开发,在北京
     */
    @GetMapping("/chat/profile")
    public PersonProfile getProfile(@RequestParam String input) {
        return chatService.extractPersonProfile(input);
    }

    /**
     * 带提示词的结构化解析接口
     */
    @GetMapping("/chat/profile-v2")
    public PersonProfile getProfileV2(@RequestParam String input) {
        return chatService.extractPersonProfileWithHint(input);
    }
}

现在,调用 /chat/profile?input=... 就会直接返回一个 JSON 格式的 PersonProfile 对象(因为 @RestController 自动将返回的 Bean 序列化为 JSON)。

4.4 进阶:返回 Optional 进行校验

有时我们想对返回的 Bean 做空值检查或自定义校验,可以结合 Optional 和 Spring 的 Validator。先在 AIChatService 中添加一个返回 Optional<PersonProfile> 的方法:

import java.util.Optional;

public Optional<PersonProfile> extractPersonProfileOptional(String userInput) {
    try {
        PersonProfile profile = chatClient
                .prompt()
                .user(userInput)
                .call()
                .entity(PersonProfile.class);
        // 可以在这里进行额外的业务校验
        if (profile.getName() == null || profile.getName().isEmpty()) {
            return Optional.empty();
        }
        return Optional.of(profile);
    } catch (Exception e) {
        // 解析失败时返回空
        return Optional.empty();
    }
}

在 Controller 中就可以据此返回不同的响应:

@GetMapping("/chat/profile-safe")
public ResponseEntity<?> getProfileSafe(@RequestParam String input) {
    Optional<PersonProfile> opt = chatService.extractPersonProfileOptional(input);
    return opt.<ResponseEntity<?>>map(ResponseEntity::ok)
              .orElseGet(() -> ResponseEntity.badRequest()
                        .body("无法从输入中解析出人员信息,请确认格式。"));
}

4.5 手动使用 BeanOutputConverter(了解即可)

如果你需要脱离 ChatClient 的默认行为,自己处理转换流程,可以这样:

import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;

public PersonProfile extractManually(String input) {
    var converter = new BeanOutputConverter<>(PersonProfile.class);

    // 获取 JSON Schema 格式的描述(可以嵌入到系统提示中)
    String format = converter.getFormat();

    Prompt prompt = new Prompt(
        new UserMessage(input),
        OpenAiChatOptions.builder()
            .withTemperature(0.3)
            .build()
    );

    // 使用 ChatModel 获取原始文本
    String content = chatModel.call(prompt).getResult().getOutput().getContent();
    // 手动转换
    return converter.convert(content);
}

但这比较底层,日常开发直接使用 ChatClient.entity() 就足够了。

五、运行与演示

5.1 启动应用

确保 DEEPSEEK_API_KEY 环境变量已设,启动 Spring Boot。

5.2 测试接口

打开浏览器或使用 curl:

curl "http://localhost:8080/chat/profile?input=我叫张三,今年28岁,是一名Java开发工程师,base在北京"

返回结果可能是:

{
  "name": "张三",
  "age": 28,
  "job": "Java开发工程师",
  "city": "北京"
}

5.3 测试带提示词的接口

curl "http://localhost:8080/chat/profile-v2?input=李四,35岁,产品经理,住在上海"

同样可以得到结构化的 JSON。

5.4 测试一个异常输入

curl "http://localhost:8080/chat/profile-safe?input=今天天气真好"

因为输入中不包含人员信息,服务端会返回 400 错误,并给出提示消息。这比直接崩溃要好得多。

六、常见问题与避坑提示

问题一:模型返回的不是合法 JSON

有时候模型会在 JSON 外面包裹 json ... 代码块标记,或者添加额外的说明文字。Spring AI 的 BeanOutputConverter 内置了清洗逻辑,会尝试提取其中的 JSON 部分。但若结构过于混乱,仍可能解析失败,抛出 ConversionException

解决方案

  • .system() 消息中明确要求:“只返回纯 JSON 对象,不要任何额外说明”。
  • 使用较低的温度值(0.1-0.3)。
  • 若模型支持原生 response_format(如 OpenAI),可以在选项中设置。但目前的 DeepSeek 等兼容模型不一定原生支持,需要通过 prompt 引导。

问题二:某些字段缺失或类型不匹配

例如期望 age 是整数,模型返回了字符串 "28",Jackson 可能无法自动转换并抛异常。

解决方案

  • 在 Java Bean 中使用宽松的类型定义,如 Object,然后手动处理;或者使用 @JsonFormat 自定义反序列化器。
  • 在 system prompt 中强调字段类型:“age 字段必须是一个整数,不要加引号。”
  • 如果允许字段缺失,可以在 Bean 中使用 Optional 或包装类型。

问题三:模型不支持结构化输出

Spring AI 的结构化输出机制基于 prompt 引导,适用于任何能够理解“请输出 JSON”的模型。但极少数轻量级模型可能根本不按格式回复。对于这类模型,结构化输出的成功率会显著下降。

解决方案

  • 选用能力较强的对话模型(如 DeepSeek、GPT-4.1、通义千问 plus)。
  • 如果必须用小模型,可以考虑通过多次重试或辅助正则来补丁。

问题四:实体类中的字段名与 JSON key 不匹配

如果 JSON 中是 "first_name",你的 Java 属性是 firstName,但没有加 @JsonProperty("first_name"),字段会为 null。

解决方案

  • 严格使用 @JsonProperty 显式映射。
  • 或者在 application.yml 中配置 Jackson 的命名策略(但建议不要,显式映射更清晰)。
spring:
  jackson:
    property-naming-strategy: SNAKE_CASE

但这种方式是全局的,不够灵活。

七、小结与下一步预告

本篇回顾

  • 理解了结构化输出的价值:直接拿到可编程的 Java 对象,告别手写正则。
  • 学会了通过 ChatClient.entity(Class) 一行代码实现 AI 回复到 Bean 的映射。
  • 了解了 BeanOutputConverter 的工作原理和清洗机制。
  • 掌握了添加系统提示词提升解析成功率、使用 Optional 处理解析失败的防御性编程思路。

一个简单的对比

传统方式 结构化输出方式
解析自由文本,正则匹配 直接拿到 Java 对象
AI 换个说法,代码就崩 字段明确,容错性强
调试靠 print 字符串 类型安全,IDE 自动提示

现在,你的 Spring AI 工具箱里已经有了同步、异步、流式、角色预设、结构化输出这些核心武器。下一站,我们要离开云端,把模型部署到本地——使用 Ollama 在你自己电脑上跑大模型,然后用 Spring AI 接入。全程离线,数据不出本机,而且免费。

下一篇《本地部署大模型:Ollama 安装与 Spring AI 接入》见。


本系列博客基于 Spring AI 1.1.6 版本编写。结构化输出功能依赖模型的指令遵循能力,不同模型的表现可能存在差异,建议在实际项目中针对所选模型进行充分测试。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐