让 AI 返回结构化数据:JSON Bean 一把梭
上一篇文章我们实现了打字机效果的流式对话,但 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 版本编写。结构化输出功能依赖模型的指令遵循能力,不同模型的表现可能存在差异,建议在实际项目中针对所选模型进行充分测试。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)