本章导读:在企业级应用中,我们通常不希望AI返回一大段自由文本,而是希望它输出格式规整的数据——比如JSON对象、Java Bean或列表,以便直接传递给下游系统处理。然而直接要求模型“返回JSON”再手动解析,常常面临格式不固定、字段缺失、类型错误等问题。

Spring AI的结构化输出转换器体系,提供了端到端、类型安全的解决方案,能将AI模型的原始输出精准转换为任意Java对象。

5.1 为什么需要结构化输出

5.1.1 传统方式的痛点

AI模型本质上是文本生成器。即使你在提示词中明确要求“返回JSON”,它仍然可能:

  • 添加解释性文字:“好的,这是你要的JSON:”
  • 忽略某些字段
  • 使用错误的数据类型(如将数字写成字符串)
  • 生成语法不完整的JSON

这种不确定性给程序解析带来了巨大挑战。下面是开发中常见的真实案例:

场景一:前端需要JSON,AI返回纯文本

前端开发者期望的响应格式:

{"code":200,"data":{"name":"iPhone 15","price":7999}}

实际得到的响应:

好的,这是您需要的信息:
产品名称:iPhone 15
价格:7999元
库存:充足

前端代码在解析response.data.name时报错——因为AI根本没有按预期格式返回。

场景二:要求返回JSON,AI却在JSON前后添加解释性文字

String prompt = "请返回JSON格式的用户信息:{\"name\":\"张三\",\"age\":25}";
String response = chatModel.call(prompt).getContent();
User user = objectMapper.readValue(response, User.class);
// 当response包含额外解释性文字时,反序列化失败!

这种方式的核心风险包括:格式不保证、字段缺失、类型错误、维护困难。

5.1.2 结构化输出的价值
传统方式 结构化输出转换器
手动解析字符串,易出错 自动转换为Java对象,类型安全
提示词与Java类型定义脱节 基于目标类型自动生成格式指令
模型可能忽略格式要求 双重保障:调用前引导,调用后安全转换
无校验机制 可结合验证逻辑确保输出符合预期
5.1.3 初识结构化输出

让我们先直观感受一下结构化输出的效果。下面这段代码将AI的原始输出直接转换为Java对象:

// 定义目标类型
public record Person(String name, int age, String city) {}

// 一行代码完成转换!
Person person = ChatClient.create(chatModel)
        .prompt()
        .user("我叫张三,今年25岁,住在北京")
        .call()
        .entity(Person.class);
System.out.println(person); // Person[name=张三, age=25, city=北京]

ChatClient.entity()方法会在调用前自动生成格式指令,在调用后自动完成转换,开发者无需关心中间细节。

5.1.4 API演进:从OutputParser到StructuredOutputConverter

在讲解具体用法之前,先了解Spring AI在这一领域的API演进,这对理解后续内容非常重要。

Spring AI最初提供了OutputParser接口及相关实现,包括BeanOutputParserMapOutputParserListOutputParser。自2024年5月2日起,这些旧类已被正式弃用,取而代之的是org.springframework.ai.converter包下的新类:

旧API(已弃用) 新API(推荐使用)
OutputParser StructuredOutputConverter
BeanOutputParser BeanOutputConverter
MapOutputParser MapOutputConverter
ListOutputParser ListOutputConverter

改名的主要原因有两个:

  1. 命名准确性:新API实际上是“转换”而不是“解析”,OutputParser的“Parser”语义并不准确。
  2. 与Spring生态对齐:新命名与Spring框架的org.springframework.core.convert.converter包保持一致,提升了API的命名规范性与可维护性。

⚠️ 重要提醒:旧API已被弃用。建议在代码中全部使用StructuredOutputConverter系列。

5.2 Converter核心设计和不同实现

StructuredOutputConverter在AI模型调用的前后各执行关键操作:

┌─────────────────────────────────────────────────────────────────┐
│  阶段一:调用前                                                  │
│  用户输入 → 添加格式指令(FormatProvider.getFormat())            │
│           → 构造完整Prompt → 发送给AI模型                        │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  阶段二:调用后                                                  │
│  AI返回原始字符串 → Converter.convert() → 目标类型T对象          │
└─────────────────────────────────────────────────────────────────┘

阶段一的核心:在提示词末尾附加格式指令,告诉AI应该以什么样的格式输出。

阶段二的核心:将模型返回的字符串精准转换为目标Java类型。

StructuredOutputConverter<T>接口同时扮演两个角色,其定义如下:

public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
    // 继承自FormatProvider,提供格式指令
    // String getFormat();
    
    // 继承自Converter<String, T>,将模型输出转换为T
    // T convert(String source);
}
  • FormatProvider.getFormat():返回格式说明字符串,附加到Prompt末尾。
  • Converter.convert(String source):将AI返回的原始文本转换为目标类型T的对象。

💡 重要提示:转换器是“尽力而为”(best-effort)的,AI模型并不保证100%遵循格式指令。如果模型不理解提示或无法按要求生成结构化输出,转换可能失败。建议在生产环境中添加验证逻辑,并在需要工具调用的场景下直接使用@Tool注解,因为工具调用的输出本身就是结构化的,不需要额外转换。

Spring AI提供了以下几种内置转换器:

转换器 用途 输出类型
BeanOutputConverter<T> 将输出映射为Java Bean/Record T
MapOutputConverter 转换为键值对Map Map<String, Object>
ListOutputConverter 转换为列表 List<?>
AbstractConversionServiceOutputConverter 基于Spring ConversionService的通用基类 自定义
AbstractMessageOutputConverter 基于MessageConverter的通用基类 自定义

其中最常用的是前三种。

5.2.1 BeanOutputConverter详解

BeanOutputConverter是最常用的转换器,能够将AI模型的输出转换为指定的Java对象。它通过生成目标类型的JSON Schema来约束AI的输出格式,并使用Jackson进行反序列化。

通常可以有两种方式实现。

5.2.1.1 使用方式

方式一:基础用法,通过**ChatClient**

Spring AI的ChatClient提供了.entity()方法,内部自动使用BeanOutputConverter,是日常开发中最便捷的方式:

// 定义目标Record
public record Movie(String title, String director, int year, double rating) {}

@RestController
public class StructuredOutputController {

    private final ChatClient chatClient;

    public StructuredOutputController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/extract/movie")
    public Movie extractMovie(@RequestParam String text) {
        // 一行代码完成:构造Prompt + 格式约束 + 自动转换
        return chatClient.prompt()
                .user("从以下文本中提取电影信息:" + text)
                .call()
                .entity(Movie.class);
    }
}

调用示例:

curl "http://localhost:8080/extract/movie?text=《肖申克的救赎》由弗兰克·达拉邦特执导,1994年上映,IMDB评分9.3"
// 返回:{"title":"肖申克的救赎","director":"弗兰克·达拉邦特","year":1994,"rating":9.3}

方式二:通过PromptTemplate手动构造

对于需要精细控制提示词内容的场景,可以通过PromptTemplate手动构造:

@GetMapping("/extract/movie-manual")
public Movie extractMovieManual(@RequestParam String text) {
    // 创建转换器
    BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
    
    // 获取格式指令
    String format = converter.getFormat();
    
    // 构造Prompt模板
    PromptTemplate promptTemplate = new PromptTemplate("""
        从以下文本中提取电影信息,严格按照指定的JSON格式输出。
        
        文本:{text}
        
        {format}
        """);
    promptTemplate.add("text", text);
    promptTemplate.add("format", format);
    
    // 调用模型并转换
    Prompt prompt = promptTemplate.create();
    String response = chatClient.prompt(prompt).call().content();
    return converter.convert(response);
}
5.2.1.2 Java Record vs Class

Spring AI推荐使用Java Record(record)来定义结构,因为它天然适合承载AI解析出的不可变数据对象。当然,也支持传统的Java Class:

// 使用Record(推荐)
public record Product(String name, double price, int stock) {}

// 使用Class
public class Product {
    private String name;
    private double price;
    private int stock;
    // getters/setters
}

Record的优势在于代码更简洁,且自动实现了equals()hashCode()

5.2.1.3 高级特性:字段排序控制

通过@JsonPropertyOrder注解可以控制JSON Schema中属性的顺序,使格式指令更清晰:

import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonPropertyOrder({"name", "price", "stock"})
public record Product(
    String name,
    double price,
    int stock
) {}
5.2.2 MapOutputConverter

当输出结构不确定或需要动态处理时,MapOutputConverter非常适用。它将AI输出转换为Map<String, Object>类型的键值对结构。

@RestController
public class MapOutputController {

    private final ChatClient chatClient;
    private final MapOutputConverter mapConverter = new MapOutputConverter();

    public MapOutputController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/extract/map")
    public Map<String, Object> extractAsMap(@RequestParam String text) {
        // 获取格式指令
        String format = mapConverter.getFormat();
        
        // 构造Prompt并调用
        String response = chatClient.prompt()
                .user(u -> u.text("从以下文本中提取关键信息:" + text + "\n\n" + format))
                .call()
                .content();
        
        // 转换为Map
        return mapConverter.convert(response);
    }
}

调用示例:

curl "http://localhost:8080/extract/map?text=苹果公司2024年营收3910亿美元"
// 返回:{"company":"苹果公司","year":2024,"revenue":3910,"currency":"亿美元"}

使用场景

  • 输出字段不固定
  • 字段数量或名称随输入变化
  • 快速原型验证
5.2.3 ListOutputConverter

ListOutputConverter用于将AI输出转换为List列表类型,适用于枚举型输出场景。

@RestController
public class ListOutputController {

    private final ChatClient chatClient;
    private final ListOutputConverter listConverter = new ListOutputConverter();

    public ListOutputController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/extract/list")
    public List<String> extractAsList(@RequestParam String topic) {
        String format = listConverter.getFormat();
        
        String response = chatClient.prompt()
                .user(u -> u.text("列出关于{0}的5个关键词:\n{1}", topic, format))
                .call()
                .content();
        
        return listConverter.convert(response);
    }
}

调用示例:

curl "http://localhost:8080/extract/list?topic=人工智能"
// 返回:["机器学习","深度学习","自然语言处理","计算机视觉","大语言模型"]

ListOutputConverter期望AI输出的格式为逗号分隔的值,转换后会自动转换为Java List对象,适用于关键词抽取、标签生成等场景。

5.3 JSON Schema约束输出格式

JSON Schema是一种声明JSON数据结构的规范,用于验证JSON数据的格式。在结构化输出的场景中,通过提供JSON Schema给AI模型,可以让模型按照预定义的结构输出响应,从而提高输出的准确性。

使用BeanOutputConverter时,框架会自动从Java类/Record生成JSON Schema,开发者无需手动编写:

BeanOutputConverter<Order> converter = new BeanOutputConverter<>(Order.class);
String jsonSchema = converter.getJsonSchema();
System.out.println(jsonSchema);
// 自动生成的JSON Schema,包含字段类型、必填信息等

BeanOutputConverter通过Jackson自动生成JSON Schema,指定JSON Schema规范的DRAFT_2020_12版本,因为OpenAI表示这将提供最佳结果。

对于支持原生JSON Schema的模型(如OpenAI的gpt-4o系列),也可以直接通过ChatOptions配置响应格式:

import org.springframework.ai.openai.OpenAiChatOptions;

String jsonSchema = """
    {
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "age": {"type": "integer"},
            "city": {"type": "string"}
        },
        "required": ["name", "age", "city"]
    }
    """;

ChatResponse response = chatModel.call(new Prompt(
    "我叫张三,今年25岁,住在北京",
    OpenAiChatOptions.builder()
        .withModel("gpt-4o")
        .withResponseFormat(new ResponseFormat(
            ResponseFormat.Type.JSON_SCHEMA, 
            jsonSchema
        ))
        .build()
));

注意:BeanOutputConverter的通用方案适用于所有模型,而通过ChatOptions配置原生JSON Schema的方式仅部分模型支持。

5.4 综合实战:从文本中提取订单信息

让我们通过一个完整的实战案例,将本章所学内容整合起来:从用户输入的文本中提取订单信息,转换为结构化的Order对象,并输出JSON格式的响应。

5.4.1 定义目标Bean
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonPropertyOrder({"orderId", "customerName", "items", "totalAmount"})
public record Order(
    String orderId,
    String customerName,
    List<OrderItem> items,
    double totalAmount
) {}

public record OrderItem(
    String productName,
    int quantity,
    double price
) {}
5.4.2 提取控制器
@RestController
@RequestMapping("/api/structured")
public class StructuredOutputDemoController {

    private final ChatClient chatClient;

    public StructuredOutputDemoController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    /**
     * 从文本中提取订单信息
     */
    @GetMapping("/extract-order")
    public ApiResponse<Order> extractOrder(@RequestParam String text) {
        try {
            String promptTemplate = """
                你是一个订单信息提取助手。请严格按照以下要求输出:
                
                1. 从以下文本中提取订单信息
                2. 输出必须是一个合法的JSON对象
                3. 不要包含任何解释性文字,只输出JSON
                
                文本:{text}
                """;
            
            Order order = chatClient.prompt()
                    .user(u -> u.text(promptTemplate).param("text", text))
                    .call()
                    .entity(Order.class);
            
            return ApiResponse.success(order);
        } catch (Exception e) {
            return ApiResponse.error(500, "提取失败:" + e.getMessage());
        }
    }

    /**
     * 批量提取演示(同时返回Map和原始字符串)
     */
    @GetMapping("/extract/batch")
    public Map<String, Object> extractionDemo(@RequestParam String text) {
        // 1. BeanOutputConverter
        Order order = chatClient.prompt()
                .user(u -> u.text("从以下文本中提取订单信息:" + text))
                .call()
                .entity(Order.class);
        
        // 2. MapOutputConverter
        MapOutputConverter mapConverter = new MapOutputConverter();
        String format = mapConverter.getFormat();
        String response = chatClient.prompt()
                .user(u -> u.text("从以下文本中提取关键信息:\n" + text + "\n\n" + format))
                .call()
                .content();
        Map<String, Object> extractedMap = mapConverter.convert(response);
        
        return Map.of(
                "order", order,
                "extractedMap", extractedMap,
                "rawResponse", response
        );
    }
}
5.4.3 响应封装类
public record ApiResponse<T>(
    int code,
    String message,
    T data,
    long timestamp
) {
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "success", data, System.currentTimeMillis());
    }
    
    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null, System.currentTimeMillis());
    }
}
5.4.4 测试效果
curl "http://localhost:8080/api/structured/extract-order?text=订单号2024001,客户张三购买了2个iPhone15,一个5999元,和1个MacBook9999元,总金额15998元"

// 响应:
{
    "code": 200,
    "message": "success",
    "data": {
        "orderId": "2024001",
        "customerName": "张三",
        "items": [
            {
                "productName": "iPhone15",
                "quantity": 2,
                "price": 5999.0
            },
            {
                "productName": "MacBook",
                "quantity": 1,
                "price": 9999.0
            }
        ],
        "totalAmount": 15998.0
    },
    "timestamp": 1780234149636
}

再测试下批量接口:

curl "http://localhost:8080/api/structured/extract-order?text=订单号2024001,客户张三购买了2个iPhone15,一个5999元,和1个MacBook9999元,总金额15998元"

// 响应:
{
    "code": 200,
    "message": "success",
    "data": {
        "orderId": "2024001",
        "customerName": "张三",
        "items": [
            {
                "productName": "iPhone15",
                "quantity": 2,
                "price": 5999.0
            },
            {
                "productName": "MacBook",
                "quantity": 1,
                "price": 9999.0
            }
        ],
        "totalAmount": 15998.0
    },
    "timestamp": 1780234149636
}

5.5 常见误区与最佳实践

5.5.1 ❌ 误区一:依赖纯文本指令
// ❌ 不推荐
String prompt = "请返回JSON格式:{'name':'xxx','age':xxx}";
String response = chatClient.prompt().user(prompt).call().content();
User user = objectMapper.readValue(response, User.class);

问题:模型可能返回带解释的文本,JSON解析会失败。

正确做法:使用StructuredOutputConverter

// ✅ 推荐
User user = chatClient.prompt()
        .user("提取用户信息:我叫张三,25岁")
        .call()
        .entity(User.class);
5.5.2 ❌ 误区二:忘记添加格式指令

手动构造Converter时,必须在提示词中包含converter.getFormat()返回的格式指令,否则模型没有输出格式约束。

正确做法:始终包含格式指令

BeanOutputConverter<Movie> converter = new BeanOutputConverter<>(Movie.class);
String promptWithFormat = userPrompt + "\n\n" + converter.getFormat(); // 必须包含
5.5.3 ❌ 误区三:不考虑转换失败

AI模型不保证100%按要求输出,直接调用converter.convert()可能抛出异常。

正确做法:添加验证机制

try {
    Movie movie = converter.convert(response);
    // 后续处理
} catch (Exception e) {
    // 重试或降级处理
    return getDefaultMovie();
}
5.5.4 最佳实践总结
最佳实践 说明
优先使用ChatClient.entity() 最简单可靠,自动处理格式指令和转换
使用Java Record定义数据结构 简洁且适合不可变数据
始终添加异常处理 模型输出可能不符合预期
考虑添加输出后验证 验证必填字段、数据类型
对于工具调用场景直接使用@Tool 工具调用本身就返回结构化数据

5.6 本章小结

在本章中,我们系统学习了Spring AI的结构化输出体系:

  • 为什么需要结构化输出:传统方式存在格式不保证、字段缺失、类型错误等痛点
  • 核心设计StructuredOutputConverter<T>继承Converter<String,T>FormatProvider,实现调用前格式引导+调用后安全转换
  • 内置转换器
    • BeanOutputConverter:转换为Java Bean/Record,最常用
    • MapOutputConverter:转换为Map<String,Object>,适合动态结构
    • ListOutputConverter:转换为List,适合枚举型输出
  • JSON Schema约束:通过BeanOutputConverter自动生成,或通过ChatOptions手动配置
  • 综合实战:从非结构化文本中提取订单信息
  • 最佳实践:优先使用ChatClient.entity()、使用Java Record、添加异常处理和验证

掌握结构化输出后,你将能够构建更可靠、更易于集成的AI应用。下一章,我们将学习多模态交互——让AI理解并分析图像内容。

参考来源

  • Spring AI官方Structured Output Converter文档
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐