小崔写了一个 AI 成绩分析接口,AI 返回的一大段 Markdown 文本让他没法直接存数据库。白歌递过一杯咖啡:"试试 BeanOutputConverter,把你的 Record 类传给 AI,它自动生成 JSON Schema,返回直接映射。"
输出解析器与 BeanOutputConverter
定义与作用
输出解析器(Output Converter)是 Spring AI 的 结构化响应映射层。它做了三件事:
- 解析:从 AI 返回的文本中提取结构化数据(JSON / Map / List)
- 验证:确保输出符合目标类型的字段约束
- 映射:将解析结果转换为 Java 对象
Spring AI 提供三种内置解析器:
| 解析器 | 输出类型 | 适用场景 |
|---|---|---|
BeanOutputConverter<T> | 任意 POJO / Record | 按自定义 Bean 映射结构化输出 |
MapOutputConverter | Map<String, Object> | 动态字段输出 |
ListOutputConverter | List<T> | 列表类输出 |
核心原理:带输出解析器的完整调用流程
图释:BeanOutputConverter 在 Prompt 发送前自动注入 JSON Schema 到 SystemMessage 中(或通过 user() 方法追加),告诉 AI"请按此格式输出 JSON"。AI 返回 JSON 后,Converter 用 Jackson 反序列化到目标类型。
完整示例一:课程成绩分析(BeanOutputConverter)
场景说明
孔蓝要求 AI 分析学生成绩后返回结构化的分析报告,前端要直接用 JSON 渲染图表。
依赖引入与配置
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
关键代码
// 步骤1:定义目标数据结构(Record 类)
public record CourseScore(
@JsonProperty("course_name") String courseName,
@JsonProperty("score") int score,
@JsonProperty("rank") String rank // A/B/C/D
) {}
public record StudentAnalysis(
@JsonProperty("student_name") String studentName,
@JsonProperty("overall_grade") String overallGrade,
@JsonProperty("gpa") double gpa,
@JsonProperty("courses") List<CourseScore> courses,
@JsonProperty("strengths") List<String> strengths,
@JsonProperty("suggestions") List<String> suggestions
) {}
// 步骤2:Controller 中使用 BeanOutputConverter
@RestController
public class ScoreAnalysisController {
private final ChatClient chatClient;
public ScoreAnalysisController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/analyze-scores")
public StudentAnalysis analyze(@RequestParam String studentName) {
// 创建针对 StudentAnalysis 的转换器
BeanOutputConverter<StudentAnalysis> converter =
new BeanOutputConverter<>(StudentAnalysis.class);
// 调用 AI,converter 自动注入 Schema
String responseContent = chatClient.prompt()
.user(userSpec -> userSpec
.text("""
分析学生「%s」的以下成绩单,给出综合分析和选课建议:
高等数学: 92, 线性代数: 85, 程序设计: 78,
大学英语: 88, 离散数学: 72
""".formatted(studentName))
.text(converter.getFormat())) // 注入 Schema 约束
.call()
.content();
// 自动映射
return converter.convert(responseContent);
}
}
运行结果
GET /analyze-scores?studentName=大翔
{
"student_name": "大翔",
"overall_grade": "B+",
"gpa": 3.3,
"courses": [
{"course_name": "高等数学", "score": 92, "rank": "A"},
{"course_name": "线性代数", "score": 85, "rank": "B"},
{"course_name": "程序设计", "score": 78, "rank": "C"},
{"course_name": "大学英语", "score": 88, "rank": "B"},
{"course_name": "离散数学", "score": 72, "rank": "C"}
],
"strengths": ["数学基础扎实", "英语水平良好"],
"suggestions": ["加强程序设计实践", "离散数学建议参加辅导班"]
}
完整示例二:智能客服多维度分析(MapOutputConverter)
场景说明
高英需要 AI 分析用户反馈,但反馈中可能出现的字段不固定。
关键代码
@RestController
public class FeedbackController {
private final ChatClient chatClient;
public FeedbackController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/analyze-feedback")
public Map<String, Object> analyze(@RequestParam String feedback) {
MapOutputConverter converter = new MapOutputConverter();
String result = chatClient.prompt()
.user(userSpec -> userSpec
.text("分析以下用户反馈,提取关键信息:\n" + feedback)
.text(converter.getFormat()))
.call()
.content();
return converter.convert(result);
}
}
运行结果
GET /analyze-feedback?feedback=你们选课系统的搜索太慢了,而且每次都要重新登录,烦死了!
{
"sentiment": "负面",
"pain_points": ["搜索速度慢", "频繁重新登录"],
"urgency": "高",
"suggested_action": "优化搜索索引;增加 session 超时时间"
}
对比:手动解析 vs BeanOutputConverter
操作前:手动 JSON 解析
// 操作前:手写 Jackson 解析,需要 try-catch + 字段校验
String aiResponse = chatClient.prompt().user(message).call().content();
// AI 返回:"{\"sentiment\": 负面, ...}" ← JSON 错误,缺少引号
try {
ObjectMapper mapper = new ObjectMapper();
FeedbackAnalysis analysis = mapper.readValue(aiResponse, FeedbackAnalysis.class);
// 抛异常:Unrecognized token '负面'
} catch (JsonProcessingException e) {
// 需要重试或手动修复 JSON
}
操作后:BeanOutputConverter 自动处理
// 操作后:Converter 注入 Schema,AI 输出规范 JSON
BeanOutputConverter<FeedbackAnalysis> converter =
new BeanOutputConverter<>(FeedbackAnalysis.class);
String aiResponse = chatClient.prompt()
.user(userSpec -> userSpec.text(message).text(converter.getFormat()))
.call().content();
FeedbackAnalysis analysis = converter.convert(aiResponse);
// 直接拿到类型安全的对象
| 维度 | 手动解析 | BeanOutputConverter |
|---|---|---|
| Schema 约束 | 无,AI 随意输出 | 自动注入,AI 按 Schema 输出 |
| 异常处理 | try-catch + 重试 | Converter 内部处理 |
| 字段校验 | 手动写 validate() | Jackson 反序列化自动校验 |
| 代码量 | 30+ 行 | 5 行 |
易错场景与面试考点
易错场景一:目标类型字段名与 AI 输出不一致
// ❌ 错误:Java 字段名是 courseName,但 JSON 输出使用 course_name
public record CourseScore(String courseName, int score) {}
// AI 输出:{"course_name": "高等数学", "score": 92}
// BeanOutputConverter 转换失败
问题分析
AI 模型倾向于生成 snake_case 格式的 JSON 字段名(course_name),而 Java 习惯使用 camelCase(courseName)。
// ✅ 正确:使用 @JsonProperty 显式映射
public record CourseScore(
@JsonProperty("course_name") String courseName,
@JsonProperty("score") int score
) {}
易错场景二:泛型类型(List)解析失败
// ❌ 错误:直接 new BeanOutputConverter<>(List.class)
// 无法获取泛型参数,转换结果可能是 List<LinkedHashMap>
BeanOutputConverter<List<CourseScore>> converter =
new BeanOutputConverter<>(List.class); // 编译警告
问题分析
Java 的类型擦除导致 List.class 丢失内部的 CourseScore 类型信息。
// ✅ 正确:使用 ParameterizedTypeReference
BeanOutputConverter<List<CourseScore>> converter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<CourseScore>>() {});
面试高频题
Q1:BeanOutputConverter 是如何让 AI 输出合法 JSON 的?
通过
converter.getFormat()生成目标类型的 JSON Schema 文本描述,注入到 UserMessage 或 SystemMessage 中,告诉 AI "请严格按照此 Schema 输出 JSON"。AI 模型配合度很高,因为 JSON Schema 是 LLM 训练数据中常见的结构化格式。
Q2:如果 AI 返回的 JSON 不合法(如多了一个逗号),如何处理?
BeanOutputConverter 内置了 JSON 修复逻辑——它会尝试从 AI 的 Markdown 代码块中提取 JSON,处理常见的格式问题(如多余逗号、缺少引号)。但如果 AI 完全没按 JSON 输出,则需要使用降级策略(如重试或使用更低的 temperature)。
本章小结
- BeanOutputConverter 自动注入 JSON Schema,让 AI 输出结构化数据
- 用
@JsonProperty解决 Java 驼峰命名与 JSON snake_case 的不匹配 - 泛型场景使用
ParameterizedTypeReference保留类型信息 - 对比手动 JSON 解析,Converter 减少 80% 代码量和异常处理成本