Spring AI Structured Output 实战:把大模型返回稳定转成 Java DTO
摘要:很多 Java 项目接入大模型后,第一版代码通常是拿到一段自然语言,然后在业务里硬切字符串、找关键字、甚至让前端自己猜字段。Demo 可以这样玩,生产系统不行。只要后续要落库、走审批流、触发任务、展示结构化卡片,就必须把模型输出稳定转换成 Java DTO。本文基于 Spring AI 官方 Structured Output Converter 文档,演示如何用 BeanOutputConverter / MapOutputConverter 把模型输出约束为结构化 JSON,并补充字段校验、解析失败重试和降级策略。
验证状态:本文机制来自 Spring AI 官方
Structured Output Converter文档,文档当前可访问版本显示为 Spring AI 2.0.0。官方文档明确说明 Structured Output Converter 是一种 best effort 机制,模型不保证一定按要求返回结构化结果,需要业务侧做校验。当前机器没有 JDK / Maven,本文代码未做本机编译运行,示例按官方机制整理为工程骨架;具体包名、依赖版本和 API 签名请以你项目实际 Spring AI 版本、IDE 提示和官方文档为准。
1. 为什么不能直接相信大模型的自然语言返回
假设你在 Java 服务里做一个"需求文本自动提取"的功能:用户输入一段需求描述,系统希望提取出优先级、模块、截止时间、风险点,然后进入工单或研发流程。
最粗糙的写法可能是这样:
java
`String answer = chatClient.prompt()
.user("从下面需求中提取优先级、模块和截止时间:" + input)
.call()
.content();
// 然后再用字符串解析 answer
`
问题很快会出现:
| 问题 | 生产里的后果 |
|---|---|
| 字段名不稳定 | 有时叫 priority,有时叫 优先级 |
| 格式不稳定 | 有时 JSON,有时 Markdown,有时解释一大段 |
| 字段缺失 | 模型觉得没有截止时间,就直接不返回字段 |
| 类型不稳定 | deadline 可能是日期、中文描述,也可能是空字符串 |
| 解析失败 | 后续 Jackson / Gson 反序列化直接报错 |
| 业务误触发 | 低优先级被误识别成高优先级,触发错误流程 |
所以结构化输出不是"代码优雅一点"的问题,而是 Java 服务能不能把 AI 能力接入业务链路的问题。
Spring AI 的 Structured Output Converter 解决的是这个中间层:
业务输入
-> Prompt + 格式指令
-> 大模型返回文本
-> Converter 解析文本
-> Java DTO / Map / List
-> 业务校验
-> 后续流程
它不是魔法。它做两件事:
- 调用前,把期望格式写进 prompt;
- 调用后,把模型文本转换成 Java 对象。
官方文档也提醒:这是一种 best effort,不能替代业务校验。
2. 适合写成 DTO 的场景
Structured Output 最适合下面这类需求:
| 场景 | 结构化对象示例 | 后续动作 |
|---|---|---|
| 需求分析 | RequirementExtractResult |
创建工单、分配模块 |
| 客服工单分类 | TicketClassifyResult |
路由到不同队列 |
| 简历信息抽取 | ResumeExtractResult |
入库、搜索、评分 |
| 代码评审摘要 | CodeReviewSummary |
生成检查项、阻断 CI |
| 内容安全识别 | ModerationResult |
通过、拦截、人工复核 |
| 日志异常归类 | LogDiagnosisResult |
告警分组、排查建议 |
不适合的场景:
- 纯闲聊回答;
- 需要长篇解释的内容生成;
- 字段边界非常模糊、业务自己也没定义清楚的需求;
- 高风险强动作,例如直接扣款、删库、封号,不能只靠模型结构化结果决定。
一个简单判断标准:如果模型返回结果会被 Java 代码继续消费,就应该优先考虑结构化输出。
3. 定义一个结果 DTO
下面用"需求文本提取"为例。先定义业务期望的 DTO:
java
`package com.example.ai.dto;
import java.time.LocalDate;
import java.util.List;
public record RequirementExtractResult(
String module,
Priority priority,
LocalDate deadline,
List<String> risks,
List<String> acceptanceCriteria,
boolean needHumanReview,
String reason
) {
public enum Priority {
LOW, MEDIUM, HIGH
}
}
`
字段设计要注意三点。
第一,字段不要太多。第一次接入建议控制在 5 到 10 个字段。字段越多,模型越容易漏。
第二,字段要有明确业务含义。例如 reason 用来解释为什么需要人工复核,而不是让模型随便写一段总结。
第三,高风险决策要留人工复核字段。例如 needHumanReview,不要让模型的分类结果直接驱动不可逆动作。
4. 用 BeanOutputConverter 生成格式指令
Spring AI 官方文档里,StructuredOutputConverter<T> 同时继承了:
java
`Converter<String, T>
FormatProvider
`
也就是说,它既能提供格式指令,也能把字符串转换成目标类型。
BeanOutputConverter<T> 的核心思路是:根据 Java 类生成 JSON Schema 相关格式要求,引导模型输出符合结构的 JSON,然后再反序列化成 Java 对象。
示例骨架如下:
java
`package com.example.ai.service;
import com.example.ai.dto.RequirementExtractResult;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class RequirementAiService {
private final ChatClient chatClient;
public RequirementAiService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public RequirementExtractResult extract(String requirementText) {
BeanOutputConverter<RequirementExtractResult> converter =
new BeanOutputConverter<>(RequirementExtractResult.class);
String content = chatClient.prompt()
.user(user -> user
.text("""
你是一个研发需求分析助手。
请从用户输入中提取结构化需求信息。
用户输入:
{requirementText}
输出要求:
{format}
""")
.params(Map.of(
"requirementText", requirementText,
"format", converter.getFormat()
)))
.call()
.content();
return converter.convert(content);
}
}
`
这段代码的重点不是 convert,而是 {format}。
converter.getFormat() 会给模型一段格式指令。官方文档说明,Structured Output Converter 会在调用前向 prompt 追加格式说明,调用后把模型输出映射成结构化对象。
如果你只写"请返回 JSON",模型仍然可能返回:
下面是你要的 JSON:
```json
{ ... }
希望这能帮到你。
而 converter 的格式指令会更明确地约束输出结构。
## 5. 让字段更稳定:DTO 不是越"聪明"越好
Structured Output 的稳定性很大程度取决于 DTO 设计。
不建议一开始就这样设计:
```java
public record AiAnalysisResult(
String summary,
String result,
String type,
String score,
String action
) {}
这些字段太泛,模型很难知道每个字段到底该放什么。
更推荐把业务含义写清楚:
java
`public record TicketClassifyResult(
String category,
String subCategory,
int urgencyScore,
boolean needEscalation,
String escalationReason,
String suggestedOwnerGroup
) {}
`
同时在 prompt 里补充字段规则:
字段规则:
1. category 只能从 PAYMENT、DELIVERY、REFUND、ACCOUNT、OTHER 中选择。
2. urgencyScore 取值 1-5,5 表示必须立即处理。
3. needEscalation 只有在涉及投诉、资损、法律风险时才为 true。
4. 如果无法判断,category 填 OTHER,needEscalation 填 true。
对于 Java 后端来说,稳定性的关键不是"让模型多想想",而是减少自由度:
| 设计点 | 推荐做法 |
|---|---|
| 分类字段 | 用枚举或固定候选集 |
| 分数字段 | 规定整数范围 |
| 日期字段 | 规定 ISO 格式,解析失败就置空或人工复核 |
| 文本字段 | 限制长度和用途 |
| 高风险字段 | 增加 reason 和 humanReview |
6. MapOutputConverter 和 ListOutputConverter 什么时候用
官方文档提到 Spring AI 提供了多种 converter,包括:
BeanOutputConverterMapOutputConverterListOutputConverterAbstractConversionServiceOutputConverterAbstractMessageOutputConverter
实际项目里可以这样选:
| Converter | 适合场景 | 不适合场景 |
|---|---|---|
BeanOutputConverter<T> |
字段固定、要进入业务 DTO | 临时探索、字段不确定 |
MapOutputConverter |
字段较动态、需要先观察模型输出 | 强类型业务流程 |
ListOutputConverter |
提取关键词、标签、短列表 | 复杂对象数组 |
我更建议生产代码优先用 BeanOutputConverter,因为 Java 服务最终需要的是稳定类型,而不是把一堆 Map<String, Object> 继续传来传去。
MapOutputConverter 可以用在探索阶段。例如你还不确定字段怎么设计,可以先让模型返回 Map,观察一两天日志后再固化 DTO。
7. 解析失败不能直接 500:要有重试和降级
Structured Output 最大的坑,是很多人以为用了 converter 就不会失败。
官方文档明确说,模型不保证一定返回你想要的结构。生产代码必须把解析失败当成正常分支。
一个更稳的骨架应该这样写:
java
`public RequirementExtractResult extractWithFallback(String requirementText) {
try {
return extract(requirementText);
} catch (Exception firstError) {
try {
return retryExtract(requirementText, firstError.getMessage());
} catch (Exception secondError) {
return fallbackToHumanReview(requirementText, secondError);
}
}
}
private RequirementExtractResult retryExtract(String requirementText, String errorMessage) {
BeanOutputConverter<RequirementExtractResult> converter =
new BeanOutputConverter<>(RequirementExtractResult.class);
String content = chatClient.prompt()
.user(user -> user
.text("""
上一次输出无法被系统解析,错误信息:{errorMessage}
请重新输出严格符合格式要求的 JSON,不要输出 Markdown,不要解释。
用户输入:
{requirementText}
输出要求:
{format}
""")
.params(Map.of(
"errorMessage", errorMessage,
"requirementText", requirementText,
"format", converter.getFormat()
)))
.call()
.content();
return converter.convert(content);
}
private RequirementExtractResult fallbackToHumanReview(String requirementText, Exception error) {
return new RequirementExtractResult(
"UNKNOWN",
RequirementExtractResult.Priority.MEDIUM,
null,
List.of("AI 结构化解析失败:" + error.getClass().getSimpleName()),
List.of(),
true,
"模型输出无法稳定解析,转人工复核"
);
}
`
生产建议:最多重试一次。不要无限重试,因为格式错误通常和 prompt、字段设计、模型能力有关,不是多试几次就一定好。
8. 业务校验要放在 converter 后面
即使成功转成 DTO,也不代表可以直接进入业务流程。
你至少要做一层业务校验:
java
`public RequirementExtractResult validate(RequirementExtractResult result) {
if (result == null) {
return humanReview("结果为空");
}
if (result.priority() == RequirementExtractResult.Priority.HIGH
&& result.reason() == null) {
return humanReview("高优先级缺少判断原因");
}
if (result.acceptanceCriteria() == null || result.acceptanceCriteria().isEmpty()) {
return new RequirementExtractResult(
result.module(),
result.priority(),
result.deadline(),
result.risks(),
List.of("待产品或研发补充验收标准"),
true,
"缺少验收标准,需人工确认"
);
}
return result;
}
`
常见校验项:
| 校验项 | 示例 |
|---|---|
| 必填字段 | 模块、分类、风险原因不能为空 |
| 枚举范围 | 分类必须属于系统允许值 |
| 数值范围 | 分数必须在 1-5 或 0-100 |
| 日期格式 | 截止日期不能早于当前日期 |
| 文本长度 | reason 不超过 200 字 |
| 高风险复核 | 涉及资损、合规、删除、封禁必须人工确认 |
这一步很关键。Structured Output 解决的是"格式可消费",不是"业务一定正确"。
9. 日志怎么打,方便排查结构化失败
AI 接入业务后,线上最难排查的通常不是接口报错,而是"为什么这次模型返回的字段不对"。
建议每次结构化调用记录这些字段:
| 日志字段 | 含义 |
|---|---|
traceId |
请求链路 ID |
scene |
例如 requirement_extract |
model |
使用的模型名称 |
converter |
BeanOutputConverter / MapOutputConverter |
dtoType |
目标 DTO 类型 |
promptVersion |
prompt 版本号 |
parseSuccess |
是否解析成功 |
retryCount |
重试次数 |
validationResult |
业务校验结果 |
fallbackType |
人工复核 / 默认值 / 重新输入 |
latencyMs |
总耗时 |
但不要把完整用户输入、完整模型输出、Token、手机号、身份证、地址等敏感内容直接打进日志。可以记录摘要、长度、哈希和脱敏后的错误片段。
示例:
java
`log.info("ai structured output result, traceId={}, scene={}, dtoType={}, parseSuccess={}, retryCount={}, fallbackType={}, latencyMs={}",
traceId,
"requirement_extract",
"RequirementExtractResult",
true,
0,
"none",
latencyMs);
`
如果解析失败,可以记录异常类型和脱敏后的前 200 字:
java
`log.warn("ai structured output parse failed, traceId={}, scene={}, dtoType={}, errorType={}, outputPreview={}",
traceId,
"requirement_extract",
"RequirementExtractResult",
e.getClass().getSimpleName(),
maskAndTrim(modelOutput, 200));
`
10. 和 Tool Calling 的区别
很多人会把 Structured Output 和 Tool Calling 混在一起。
它们都和"结构化"有关,但解决的问题不同:
| 能力 | 解决的问题 | 典型结果 |
|---|---|---|
| Structured Output | 把模型最终文本结果转成 Java 对象 | DTO / Map / List |
| Tool Calling | 让模型选择并调用外部工具 | 工具名 + 参数 + 工具结果 |
官方文档也说明,Structured Output Converter 不用于 Tool Calling,因为 Tool Calling 本身已经提供结构化的工具调用参数。
一个实际系统里,两者可能串起来:
用户输入需求
-> Structured Output 提取工单字段
-> Java 校验字段
-> Tool Calling 查询历史工单 / 项目成员 / 排期
-> 模型生成建议
-> Java 再结构化为执行计划 DTO
但第一步落地时,不建议一上来两者全开。更稳的路径是:
- 先用 Structured Output 做只读抽取;
- 加业务校验和人工复核;
- 再引入只读 Tool Calling;
- 最后再评估是否允许有限的写操作。
11. 一套生产落地清单
如果你准备在 Spring Boot 项目里接 Structured Output,可以按这个清单推进:
| 步骤 | 要做什么 | 通过标准 |
|---|---|---|
| 1 | 选一个低风险场景 | 只读抽取,不直接触发强动作 |
| 2 | 定义 DTO | 字段少、语义清楚、枚举明确 |
| 3 | 加 format 指令 | 使用 converter.getFormat() |
| 4 | 建立解析失败分支 | 最多重试一次,失败转人工复核 |
| 5 | 增加业务校验 | 不因 JSON 合法就直接放行 |
| 6 | 打点日志 | 能看见成功率、失败类型、耗时 |
| 7 | 做样本回放 | 用历史输入批量观察字段稳定性 |
| 8 | 灰度上线 | 先只读旁路,再接入主流程 |
我不建议第一版就把结构化结果直接写入核心业务表。更安全的做法是先做"旁路建议":模型输出 DTO,系统展示给运营、产品或研发确认,稳定一段时间后再逐步自动化。
12. 常见坑
最后列几个容易踩的坑。
| 坑 | 结果 | 建议 |
|---|---|---|
| 只写"返回 JSON" | 模型夹带解释文本 | 使用 converter 的格式指令 |
| DTO 字段太泛 | 结果看似合法但业务不可用 | 字段写成业务语言 |
| 没有枚举边界 | 分类漂移严重 | prompt 中给固定候选集 |
| 解析失败直接抛出 | 接口 500 | 重试一次,失败走人工复核 |
| JSON 合法就放行 | 业务误判 | converter 后加业务校验 |
| 打完整日志 | 泄露隐私和业务数据 | 只记录摘要、脱敏片段和状态 |
| 一开始就接强动作 | 误触发风险高 | 先只读旁路,逐步灰度 |
结尾
Spring AI Structured Output 的价值,不是让大模型"看起来更像后端接口",而是让 Java 服务能用可控方式消费模型结果。
真正落地时,要记住三句话:
- converter 负责格式约束和对象转换;
- 业务代码负责校验、重试、降级和审计;
- 高风险动作必须有人类复核或额外规则兜底。
如果你的 Spring Boot 项目已经开始接入大模型,但还停留在字符串解析阶段,建议先从一个低风险的 DTO 抽取场景开始,把 Structured Output 跑通,再逐步扩展到更复杂的 Agent 流程。
后续我会继续把 Java AI 工程落地里容易踩坑的部分整理成系列文章,包括 Tool Calling、MCP、RAG、模型网关和可观测性。