一、前言
前四篇我写的所有接口,返回的都是字符串。AI 说什么,我就原样返回什么。
但在真实项目中,我们很少直接把 AI 的回复扔给前端。更常见的需求是:
- 从回复中提取某个字段做业务路由
- 把回复持久化到数据库
- 根据结果做分支判断
这就需要把 AI 返回的文本转换成类型化的 Java 对象。Spring AI 提供了entity()方法来实现这个能力。
在幕后,Spring AI 做了三件事:模式生成器将您的WeatherInfo记录转换为 JSON 模式,该模式被附加到提示的系统上下文中,模型的 JSON 答案被传递给类型转换器,该转换器将其解析回您的记录。
这篇博客通过五个接口,逐个拆解结构化输出的五种用法。
二、准备工作
沿用第一篇的项目结构和 POM 配置。
2.1 配置文件
spring: ai: openai: base-url: https://api.deepseek.com/v1 api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat三、定义返回类型
在com.yoyo.demo.dto包下创建两个 POJO 类。
3.1 WeatherInfo:天气信息
package com.yoyo.demo.dto; /** 城市天气信息 */ public class WeatherInfo { private String city; private String date; private double temperature; private String condition; private int humidity; // 无参构造器(必须,Jackson 反序列化需要) public WeatherInfo() {} public String getCity() { return city; } public void setCity(String city) { this.city = city; } public String getDate() { return date; } public void setDate(String date) { this.date = date; } public double getTemperature() { return temperature; } public void setTemperature(double temperature) { this.temperature = temperature; } public String getCondition() { return condition; } public void setCondition(String condition) { this.condition = condition; } public int getHumidity() { return humidity; } public void setHumidity(int humidity) { this.humidity = humidity; } }3.2 AttractionInfo:景点信息
package com.yoyo.demo.dto; import java.util.List; /** 旅游景点推荐 */ public class AttractionInfo { private String name; private String city; private String description; private double rating; private List<String> tips; // 无参构造器(必须) public AttractionInfo() {} public String getName() { return name; } public void setName(String name) { this.name = name; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public double getRating() { return rating; } public void setRating(double rating) { this.rating = rating; } public List<String> getTips() { return tips; } public void setTips(List<String> tips) { this.tips = tips; } }四、五种结构化输出用法详解
4.1 用法一:基础结构化输出——返回单个 Java 对象
4.1.1 代码
/** * 用法一:基础结构化输出 * 返回单个 Java 对象 * * 场景:查询某个城市的天气预报 * 接口:GET /structured/weather?city=北京 */ @GetMapping("/weather") public WeatherInfo getWeather(@RequestParam(defaultValue = "北京") String city) { return chatClient.prompt() .user("请生成" + city + "今天的天气预报。返回城市名称、日期、温度、天气状况和湿度。") .call() .entity(WeatherInfo.class); }4.1.2 逐行拆解
代码 | 作用 |
|---|---|
| 创建一个新的 Prompt 构建器 |
| 设置用户消息,告诉 AI 要做什么 |
| 发起同步调用,等待模型返回完整响应 |
| 将模型返回的 JSON 反序列化为 |
4.1.3 调用与结果
请求:
GET http://localhost:8080/structured/weather?city=北京返回:
{ "city": "北京", "date": "2026-08-22", "temperature": 30.5, "condition": "晴", "humidity": 47 }在 Java 代码中使用:
WeatherInfo weather = getWeather("北京"); String city = weather.getCity(); // "北京" double temp = weather.getTemperature(); // 30.5 String condition = weather.getCondition(); // "晴"4.1.4 核心要点
entity(WeatherInfo.class)是call()的终结方法,不是链式调用的中间步骤- 返回的不是字符串,而是已经反序列化好的 Java 对象
- 类必须有无参构造器和标准的getter/setter,否则反序列化会失败
4.2 用法二:泛型类型——返回 List
4.2.1 代码
/** * 用法二:泛型类型——返回 List * * 场景:推荐某个城市的多个旅游景点 * 接口:GET /structured/attractions?city=成都&count=3 */ @GetMapping("/attractions") public List<AttractionInfo> getAttractions(@RequestParam(defaultValue = "成都") String city, @RequestParam(defaultValue = "3") int count) { return chatClient.prompt() .user("请推荐" + city + "的" + count + "个热门旅游景点。" + "返回一个列表,每个景点包含:名称、所在城市、简介、评分(满分5分)、游玩建议(数组)。") .call() .entity(new ParameterizedTypeReference<List<AttractionInfo>>() {}); }4.2.2 逐行拆解
代码 | 作用 |
|---|---|
| 告诉 Spring AI 要反序列化成 |
4.2.3 为什么不能用entity(List<AttractionInfo>.class)
Java 的泛型在运行时会被擦除。List<AttractionInfo>.class这种写法在 Java 中是不合法的,因为运行时只知道是List,不知道List里装的是什么类型。
ParameterizedTypeReference通过匿名内部类的写法,在编译期捕获泛型信息,运行时仍然可以获取到完整的List<AttractionInfo>类型信息。
正确写法:
// ✅ 正确:带 {} 的匿名内部类,保留泛型信息 .entity(new ParameterizedTypeReference<List<AttractionInfo>>() {}) // ❌ 错误:不带 {},泛型信息在运行时被擦除 .entity(new ParameterizedTypeReference<List<AttractionInfo>>())4.2.4 调用与结果
请求:
GET http://localhost:8080/structured/attractions?city=成都&count=3返回:
[ { "name": "宽窄巷子", "city": "成都", "description": "由宽巷子、窄巷子和井巷子组成的清代古街,是成都保存最完好的历史文化街区之一。", "rating": 4.5, "tips": ["建议傍晚前往", "可以品尝三大炮等小吃"] }, { "name": "大熊猫繁育研究基地", "city": "成都", "description": "世界著名的大熊猫保护研究机构,游客可以近距离观察大熊猫。", "rating": 4.8, "tips": ["建议早上8点入园", "至少预留3小时"] }, { "name": "都江堰", "city": "成都", "description": "战国时期李冰父子修建的水利工程,至今仍在发挥作用。", "rating": 4.6, "tips": ["建议请导游讲解", "春秋两季景色最佳"] } ]在 Java 代码中使用:
List<AttractionInfo> list = getAttractions("成都", 3); for (AttractionInfo item : list) { String name = item.getName(); double rating = item.getRating(); List<String> tips = item.getTips(); }4.3 用法三:泛型类型——返回 Map
4.3.1 代码
/** * 用法三:泛型类型——返回 Map * * 场景:批量查询多个城市的天气预报 * 接口:GET /structured/city-weather?cities=北京,上海,广州 */ @GetMapping("/city-weather") public Map<String, WeatherInfo> getMultiCityWeather(@RequestParam(defaultValue = "北京,上海,广州") String cities) { return chatClient.prompt() .user("请生成以下城市今天的天气预报:" + cities + "。" + "返回一个 Map,key 是城市名称,value 是包含 date、temperature、condition、humidity 的天气对象。") .call() .entity(new ParameterizedTypeReference<Map<String, WeatherInfo>>() {}); }4.3.2 逐行拆解
代码 | 作用 |
|---|---|
| 告诉 Spring AI 要反序列化成 |
4.3.3 调用与结果
请求:
GET http://localhost:8080/structured/city-weather?cities=北京,上海,广州,深圳,杭州返回:
{ "北京": { "city": null, "date": "2026-08-22", "temperature": 26.5, "condition": "晴", "humidity": 46 }, "上海": { "city": null, "date": "2026-08-22", "temperature": 29.2, "condition": "多云", "humidity": 69 }, "广州": { "city": null, "date": "2026-08-22", "temperature": 33.8, "condition": "阵雨", "humidity": 83 }, "深圳": { "city": null, "date": "2026-08-22", "temperature": 15.0, "condition": "雷阵雨", "humidity": 88 }, "杭州": { "city": null, "date": "2026-08-22", "temperature": 6.0, "condition": "阴", "humidity": 65 } }4.3.4 Map 结构的优势
相比 List,Map 结构在按城市查找时更方便:
// List 方式:需要遍历查找 List<WeatherInfo> list = ...; for (WeatherInfo w : list) { if ("北京".equals(w.getCity())) { /* 找到了 */ } } // Map 方式:直接通过 key 获取 Map<String, WeatherInfo> map = ...; WeatherInfo w = map.get("北京"); // 一步到位4.3.5 ⚠️ 踩坑一:city 字段为 null
现象:返回的 JSON 中,每个WeatherInfo对象的city字段都是null。
原因:城市名已经被放在外层 Map 的 key 中了("北京": {...}),模型认为内层的city字段是冗余信息,所以直接留空或忽略。
这不是 Bug,而是模型的一种「合理化」行为——既然你已经通过 key 知道了城市名,为什么还要在内层重复一遍?
解决方案:
方案一:接受 Map 结构,使用时直接从 key 获取城市名(推荐)
既然外层 Map 的 key 已经是城市名,内层的city字段就没必要用了。使用时直接从 Map key 获取:
Map<String, WeatherInfo> map = getMultiCityWeather("北京,上海,广州"); for (Map.Entry<String, WeatherInfo> entry : map.entrySet()) { String cityName = entry.getKey(); // 从 key 获取城市名 WeatherInfo weather = entry.getValue(); // weather.getCity() 是 null,不用它 System.out.println(cityName + ":" + weather.getTemperature() + "°C"); }方案二:在 Prompt 中强制要求填充 city 字段
.user("请生成以下城市今天的天气预报:" + cities + "。" + "返回一个 Map,key 是城市名称。" + "注意:每个 value 对象中的 city 字段也必须填写城市名称,不能为空,不能省略。" + "即使外层 key 已经有了城市名,内层的 city 字段也要重复填写一遍。")方案三:去掉 POJO 中的 city 字段(如果不需要)
如果 Map 的 key 已经足够标识城市,可以考虑把WeatherInfo中的city字段去掉:
public class WeatherInfo { // private String city; // 去掉这个字段 private String date; private double temperature; private String condition; private int humidity; // ... }4.3.6 ⚠️ 踩坑二:模型返回不稳定导致反序列化失败
现象:方法三调用时,时而成功,时而抛出以下异常:
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception: tools.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `double` from String "18°C": not a valid `double` value at [Source: REDACTED; byte offset: #UNKNOWN] (through reference chain: java.util.LinkedHashMap["北京"] ->com.yoyo.demo.dto.WeatherInfo["temperature"])原因分析:异常信息暴露了两个不匹配的问题:
问题一:结构不匹配
异常中的LinkedHashMap["北京"]表明:模型这次返回的 JSON 结构是{"北京": {...}},外面包了一层城市名 key。但WeatherInfo本身已经有city字段了,模型却在外层又套了一个 Map key。
这是因为模型对 Prompt 的理解不稳定。有时它严格按照你要求的「返回一个 Map,key 是城市名称」来执行,有时它又自作主张把城市名提取出来作为外层 key,导致WeatherInfo内部的city字段反而变成了冗余信息。
问题二:类型不匹配
WeatherInfo.temperature声明为double,但模型给了"18°C"(带单位的字符串)。Jackson 无法把"18°C"转成double,所以抛出InvalidFormatException。
这是因为模型有时会自作聪明地在数值后面加上单位(°C、℃),而不是只返回纯数字。
解决方案:
方案一:优化 Prompt,明确约束格式(推荐)
@GetMapping("/city-weather") public Map<String, WeatherInfo> getMultiCityWeather(@RequestParam(defaultValue = "北京,上海,广州") String cities) { return chatClient.prompt() .user("请生成以下城市今天的天气预报:" + cities + "。" + "返回一个 JSON 对象,key 是城市名称,value 是天气对象。" + "要求:temperature 字段只返回纯数字,不要带单位(如 26.5,不要写成 26.5°C);" + "不要在外层再嵌套多余的 key,直接返回 {城市名: {...}} 格式;" + "每个 value 对象中的 city 字段也必须填写城市名称,不能为空。") .call() .entity(new ParameterizedTypeReference<Map<String, WeatherInfo>>() {}); }方案二:修改 POJO,用 String 接收 temperature(容错性更强)
public class WeatherInfo { private String city; private String date; private String temperature; // 改为 String,避免类型转换失败 private String condition; private String humidity; // 也改为 String,统一处理 // 无参构造器 public WeatherInfo() {} // getter/setter... // 提供一个便捷方法,获取纯数字温度 public double getTemperatureValue() { if (temperature == null) return 0; return Double.parseDouble(temperature.replaceAll("[^0-9.]", "")); } }方案三:开启validateSchema()自动纠错
@GetMapping("/city-weather") public Map<String, WeatherInfo> getMultiCityWeather(@RequestParam(defaultValue = "北京,上海,广州") String cities) { return chatClient.prompt() .user("请生成以下城市今天的天气预报:" + cities + "。") .call() .entity(new ParameterizedTypeReference<Map<String, WeatherInfo>>() {}, spec -> spec.validateSchema()); }validateSchema()会检测反序列化是否失败,如果失败则将错误信息附加到 Prompt 中重新请求模型,默认最多重试 3 次。
⚠️ 注意:validateSchema()适用于偶发性的格式异常(比如模型偶尔一次输出不规范)。但当前这个场景中,模型返回带单位的温度字符串(如"18°C")是系统性行为,每次调用大概率都会出现,不属于偶发情况。所以validateSchema()在这里无法根治问题,重试 3 次后依然会报错。根本解决方案还是要用方案一优化 Prompt,或者用方案二让 POJO 兼容字符串格式。
最佳实践:方案一 + 方案二组合使用。先用清晰的 Prompt 约束模型行为,同时 POJO 做好容错,双管齐下。
4.4 用法四:获取完整响应——单对象 + 元数据
4.4.1 代码
/** * 用法四:获取完整响应(类型化对象 + 元数据) * 使用 responseEntity() 同时拿到实体和 ChatResponse * * 场景:查询天气的同时,监控 token 消耗 * 接口:GET /structured/weather-with-meta?city=武汉 */ @GetMapping("/weather-with-meta") public Map<String, Object> getWeatherWithMetadata(@RequestParam(defaultValue = "武汉") String city) { // 1. 调用 responseEntity(),同时拿到实体和原始响应 ResponseEntity<ChatResponse, WeatherInfo> result = chatClient.prompt() .user("请生成" + city + "今天的天气预报。返回城市名称、日期、温度、天气状况和湿度。") .call() .responseEntity(WeatherInfo.class); // 2. 分别取出实体和响应 WeatherInfo weather = result.entity(); ChatResponse response = result.response(); // 3. 构建返回结果 Map<String, Object> output = new HashMap<>(); output.put("city", weather.getCity()); output.put("date", weather.getDate()); output.put("temperature", weather.getTemperature()); output.put("condition", weather.getCondition()); output.put("humidity", weather.getHumidity()); // 4. 提取 token 用量(可能为 null,需要判空) if (response.getMetadata() != null && response.getMetadata().getUsage() != null) { output.put("promptTokens", response.getMetadata().getUsage().getPromptTokens()); output.put("completionTokens", response.getMetadata().getUsage().getCompletionTokens()); output.put("totalTokens", response.getMetadata().getUsage().getTotalTokens()); } return output; }4.4.2 逐行拆解
代码 | 作用 |
|---|---|
| 替代 |
| 取出反序列化后的 |
| 取出原始的 |
| 获取本次调用的总 token 数 |
4.4.3 调用与结果
请求:
GET http://localhost:8080/structured/weather-with-meta?city=武汉返回:
{ "city": "武汉", "date": "2026-08-22", "temperature": 36.2, "condition": "晴", "humidity": 57, "promptTokens": 42, "completionTokens": 51, "totalTokens": 93 }4.4.4 什么时候用responseEntity()
场景 | 用 | 用 |
|---|---|---|
只需要业务数据 | ✅ | ❌ |
需要监控 token 消耗 | ❌ | ✅ |
需要 finishReason 判断是否被截断 | ❌ | ✅ |
需要做可观测性埋点 | ❌ | ✅ |
4.5 用法五:获取完整响应——List 类型 + 元数据
4.5.1 代码
/** * 用法五:获取完整响应(类型化对象 + 元数据) * 使用 responseEntity() 同时拿到实体和 ChatResponse * * 场景:批量查询多个城市的天气预报,同时监控 token 消耗 * 接口:GET /structured/weather-with-meta-citys?cities=武汉,长沙,深圳 */ @GetMapping("/weather-with-meta-citys") public Map<String, Object> getWeatherWithMetadataCitys(@RequestParam(defaultValue = "武汉") String cities) { // 1. 调用 responseEntity(),同时拿到实体和原始响应 String[] cityArray = cities.split(","); ResponseEntity<ChatResponse, List<WeatherInfo>> result = chatClient.prompt() .user("请生成以下城市:" + cityArray + "今天的天气预报。返回城市名称、日期、温度、天气状况和湿度。") .call() .responseEntity(new ParameterizedTypeReference<List<WeatherInfo>>() {}); // 2. 分别取出实体和响应 List<WeatherInfo> weather = result.entity(); ChatResponse response = result.response(); // 3. 构建返回结果 Map<String, Object> output = new HashMap<>(); output.put("weather", weather.toArray()); // 4. 提取 token 用量(可能为 null,需要判空) if (response.getMetadata() != null && response.getMetadata().getUsage() != null) { output.put("promptTokens", response.getMetadata().getUsage().getPromptTokens()); output.put("completionTokens", response.getMetadata().getUsage().getCompletionTokens()); output.put("totalTokens", response.getMetadata().getUsage().getTotalTokens()); } return output; }4.5.2 逐行拆解
代码 | 作用 |
|---|---|
| 泛型声明:第一个参数是 |
| 替代 |
| 取出反序列化后的 |
| 取出原始的 |
4.5.3 调用与结果
请求:
GET http://localhost:8080/structured/weather-with-meta-citys?cities=武汉,长沙,深圳,成都返回:
{ "weather": [ { "city": "北京市", "date": "2023-10-01", "temperature": 22.5, "condition": "晴", "humidity": 54 }, { "city": "上海市", "date": "2023-10-01", "temperature": 24.0, "condition": "多云", "humidity": 67 }, { "city": "广州市", "date": "2023-10-01", "temperature": 23.5, "condition": "小雨", "humidity": 78 }, { "city": "深圳市", "date": "2023-10-01", "temperature": 27.0, "condition": "雷阵雨", "humidity": 85 }, { "city": "成都市", "date": "2023-10-01", "temperature": 19.0, "condition": "阴", "humidity": 72 } ], "promptTokens": 347, "completionTokens": 148, "totalTokens": 495 }4.5.4 与用法四的区别
对比维度 | 用法四(单对象) | 用法五(List) |
|---|---|---|
接口路径 |
|
|
实体类型 |
|
|
核心代码 |
|
|
适用场景 | 查一个城市 | 查多个城市 |
4.5.5 核心要点
responseEntity()同样支持ParameterizedTypeReference,可以处理泛型类型- 这是唯一一种既能拿到类型安全的对象列表,又能拿到原始响应元数据的方式
- 适合生产环境中需要批量查询并监控 token 消耗的场景
五、五种用法对比
用法 | 接口路径 | 返回类型 | 核心代码 | 适用场景 |
|---|---|---|---|---|
基础对象 |
|
|
| 单个对象的查询 |
List |
|
|
| 多条记录的列表 |
Map |
|
|
| 按 key 查找的数据 |
单对象+元数据 |
|
|
| 需要监控 token 消耗 |
List+元数据 |
|
|
| 批量查询+监控 token |
六、结构化输出的工作原理
Spring AI 的entity()方法在幕后做了三件事:
你的 Java POJO 类 ↓ ① JSON Schema 生成器 ↓ 将类的字段和类型转换为 JSON Schema ② 将 Schema 附加到系统提示 ↓ 引导模型按指定格式返回 JSON ③ JSON 反序列化 ↓ 将模型返回的 JSON 解析为你的 POJO 对象 类型安全的 Java 对象6.1 生成的 JSON Schema 长什么样
对于WeatherInfo类:
public class WeatherInfo { private String city; private String date; private double temperature; private String condition; private int humidity; }Spring AI 自动生成类似这样的 JSON Schema:
{ "type": "object", "properties": { "city": { "type": "string" }, "date": { "type": "string" }, "temperature": { "type": "number" }, "condition": { "type": "string" }, "humidity": { "type": "integer" } }, "required": ["city", "date", "temperature", "condition", "humidity"] }这个 Schema 被附加到系统提示中,告诉模型:「你必须按这个格式返回 JSON」。
七、POJO 类的注意事项
7.1 必须有无参构造器
Spring AI 使用 Jackson 进行 JSON 反序列化,Jackson 默认通过无参构造器创建对象,然后调用 setter 方法赋值。
// ✅ 正确:有无参构造器 public WeatherInfo() {} // ❌ 错误:只有带参构造器,没有无参构造器 public WeatherInfo(String city, String date, double temperature, String condition, int humidity) { this.city = city; // ... }7.2 getter/setter 命名规范
Jackson 通过 getter 方法推断 JSON 字段名:
getter 方法 | JSON 字段名 |
|---|---|
|
|
|
|
|
|
setter 方法也必须对应:
setter 方法 | JSON 字段名 |
|---|---|
|
|
|
|
7.3 字段类型匹配
Java 类型 | JSON 类型 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
八、踩坑记录
坑 1:缺少无参构造器
现象:启动时不报错,但调用接口时抛出JsonMappingException,提示无法实例化对象。
原因:Jackson 找不到无参构造器。
解决:给 POJO 类加上无参构造器。
坑 2:ParameterizedTypeReference忘记写{}
现象:编译报错,提示泛型信息丢失。
原因:new ParameterizedTypeReference<List<AttractionInfo>>()后面必须跟{},否则 Java 无法在运行时保留泛型信息。
解决:
// ✅ 正确 .entity(new ParameterizedTypeReference<List<AttractionInfo>>() {}) // ❌ 错误 .entity(new ParameterizedTypeReference<List<AttractionInfo>>())坑 3:模型返回的 JSON 不符合预期
现象:entity()抛出异常,提示 JSON 解析失败。
原因:模型可能返回了 Markdown 代码块包裹的 JSON,或者多了/少了字段。
解决:可以使用validateSchema()开启自动纠错:
WeatherInfo weather = chatClient.prompt() .user("请生成北京的天气预报。") .call() .entity(WeatherInfo.class, spec -> spec.validateSchema());坑 4:流式响应不支持结构化输出
现象:在.stream()后面调用.entity()编译报错。
原因:结构化输出需要完整的响应才能反序列化,流式返回的是文本块,不是完整对象。
解决:结构化输出只能用.call(),不能用.stream()。
坑 5:Map 结构中内层 city 字段为 null
现象:返回的 JSON 中每个WeatherInfo的city字段都是null。
原因:模型认为外层 Map key 已经标识了城市名,内层不再需要。
解决:从 Map key 获取城市名,或在 Prompt 中强制要求填充。
坑 6:模型返回带单位的字符串导致类型转换失败
现象:temperature字段声明为double,但模型返回"18°C",Jackson 无法解析。
原因:模型自作聪明地在数值后加上了单位。
解决:优化 Prompt 明确要求纯数字,或将 POJO 字段改为String类型做容错。
九、速查表
你需要 | 使用 |
|---|---|
返回单个对象 |
|
返回 List |
|
返回 Map |
|
防止输出格式错误 |
|
获取 token 用量等元数据 |
|
获取 List 类型 + 元数据 |
|
流式响应 | 不支持,用 |
十、总结
这篇博客通过五个接口,逐个拆解了结构化输出的五种用法:
/weather:.entity(WeatherInfo.class)—— 返回单个对象,最简单/attractions:.entity(new ParameterizedTypeReference<List<AttractionInfo>>() {})—— 返回 List/city-weather:.entity(new ParameterizedTypeReference<Map<String, WeatherInfo>>() {})—— 返回 Map,需注意 city 字段为 null 的问题/weather-with-meta:.responseEntity(WeatherInfo.class)—— 单对象 + 元数据/weather-with-meta-citys:.responseEntity(new ParameterizedTypeReference<List<WeatherInfo>>() {})—— List + 元数据
有了结构化输出,你的业务代码就不再需要手写 JSON 解析,代码更干净、更安全。
十一、参考链接
- Spring AI 结构化输出文档
- Spring AI ChatClient entity() API
- Spring AI ParameterizedTypeReference
- Spring AI validateSchema 文档