Spring AI Structured Output 实战:把大模型返回稳定转成 Java DTO

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
  -> 业务校验
  -> 后续流程

它不是魔法。它做两件事:

  1. 调用前,把期望格式写进 prompt;
  2. 调用后,把模型文本转换成 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,包括:

  • BeanOutputConverter
  • MapOutputConverter
  • ListOutputConverter
  • AbstractConversionServiceOutputConverter
  • AbstractMessageOutputConverter

实际项目里可以这样选:

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

但第一步落地时,不建议一上来两者全开。更稳的路径是:

  1. 先用 Structured Output 做只读抽取;
  2. 加业务校验和人工复核;
  3. 再引入只读 Tool Calling;
  4. 最后再评估是否允许有限的写操作。

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 服务能用可控方式消费模型结果。

真正落地时,要记住三句话:

  1. converter 负责格式约束和对象转换;
  2. 业务代码负责校验、重试、降级和审计;
  3. 高风险动作必须有人类复核或额外规则兜底。

如果你的 Spring Boot 项目已经开始接入大模型,但还停留在字符串解析阶段,建议先从一个低风险的 DTO 抽取场景开始,把 Structured Output 跑通,再逐步扩展到更复杂的 Agent 流程。

后续我会继续把 Java AI 工程落地里容易踩坑的部分整理成系列文章,包括 Tool Calling、MCP、RAG、模型网关和可观测性。

相关推荐
星辰_mya1 小时前
限流、漏斗桶和令牌桶的区别
java·开发语言·面试·架构·高并发
我是一颗柠檬1 小时前
【Java项目技术亮点】滑动窗口限流算法
java·开发语言·算法
我登哥MVP1 小时前
SpringCloud Alibaba 核心组件解析:分布式事务(Seata)
java·spring boot·分布式·spring·spring cloud·java-ee·intellij-idea
于指尖飞舞1 小时前
java后端面试题(jvm极简)
java·开发语言·jvm
Seven971 小时前
面试官:你们项目里的线程池是怎么用的?怎么管理的?
java
xieliyu.1 小时前
Java数据结构:从0开始手搓Hash桶
java·数据结构·哈希算法
影视飓风TIM2 小时前
C++ 核心语法笔记:拷贝构造、深浅拷贝与运算符重载
java·开发语言·javascript
极创信息2 小时前
信创产品适配测试认证,域名和SSL是必须的吗?
java·开发语言·网络·python·网络协议·ruby·ssl
Y学院2 小时前
Java 智能体开发实战:从核心架构到生产级落地,告别AI调用积木式编程
java·人工智能·架构