结构化输出深度剖析

大模型的输出是自然语言文本,格式不固定。如果是人类阅读没问题,但程序处理起来就麻烦了。

比如你问模型"推荐几本书",它可能这样回答:

我给你推荐三本不错的书:
1.《代码整洁之道》- 讲代码规范的经典之作
2.《设计模式》- 四人组的名著
3.《重构》- Martin Fowler的代表作

这种格式人读着没问题,但你想用代码解析出书名、作者,就得写正则或者其他脏活累活。

结构化输出就是让模型按指定格式返回,比如直接返回JSON,甚至直接转成Java对象。今天我们来深入聊聊Spring AI是怎么实现的。

Spring AI 的结构化输出机制分两步:

  • 调用前:根据目标 Java 类自动生成 JSON Schema,注入到提示词中,告诉模型要按什么格式输出
  • 调用后:把模型返回的 JSON 文本反序列化成目标 Java 类型

结构化输出的基本原理

Spring AI的结构化输出机制,核心思想是:

  • 调用前:往提示词里注入格式说明,告诉模型要按什么格式输出
  • 调用后:把模型返回的文本解析成目标类型
2-Learning/05-项目/08-企业级项目深读/01-SuperAgent/06-系统讲解AI相关技术/06-SpringAI详细解析/assets/FsN7vjgN4XUWjLacWbIqmtp-tdXF-10bd9dc0

StructuredOutputConverter接口

Spring AI定义了一个统一的接口来处理结构化输出:

public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
    // 继承自Converter: 把字符串转成目标类型
    // T convert(String source)

    // 继承自FormatProvider: 获取格式说明文本
    // String getFormat()
}

这个接口有几个实现类:

实现类 用途
BeanOutputConverter 转成Java Bean
ListOutputConverter 转成List
MapOutputConverter 转成Map

其中BeanOutputConverter是最常用的。

BeanOutputConverter源码剖析

getFormat()方法

先来看看BeanOutputConverter是怎么生成格式说明的:

@Override
public String getFormat() {
    String template = """
            Your response should be in JSON format.
            Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
            Do not include markdown code blocks in your response.
            Remove the ```json markdown from the output.
            Here is the JSON Schema instance your output must adhere to:
            ```%s```
            """;
    return String.format(template, this.jsonSchema);
}

翻译一下这段提示词:

  • 你的回答必须是JSON格式
  • 不要包含任何解释,只输出符合RFC8259的JSON
  • 不要包含markdown代码块
  • 必须遵循下面的JSON Schema

关键在this.jsonSchema,它是根据你传入的Java类自动生成的。

JSON Schema生成

假设我们有这样一个record:

public record Product(
    @JsonPropertyDescription("商品ID") String productId,
    @JsonPropertyDescription("商品名称") String name,
    @JsonPropertyDescription("价格,单位元") BigDecimal price,
    @JsonPropertyDescription("商品描述") String description
) {}

BeanOutputConverter会把它转成这样的JSON Schema:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "productId": {
      "type": "string",
      "description": "商品ID"
    },
    "name": {
      "type": "string",
      "description": "商品名称"
    },
    "price": {
      "type": "number",
      "description": "价格,单位元"
    },
    "description": {
      "type": "string",
      "description": "商品描述"
    }
  },
  "required": ["productId", "name", "price", "description"]
}

大模型收到这个Schema后,就知道应该按什么结构输出了。

convert()方法

模型返回JSON文本后,需要反序列化成Java对象:

@Override
public T convert(@NonNull String text) {
    try {
        // 去除首尾空白
        text = text.trim();

        // 处理markdown代码块(有些模型会自作聪明加上```json)
        if (text.startsWith("```") && text.endsWith("```")) {
            String[] lines = text.split("\n", 2);
            if (lines[0].trim().equalsIgnoreCase("```json")) {
                text = lines.length > 1 ? lines[1] : "";
            } else {
                text = text.substring(3);
            }
            text = text.substring(0, text.length() - 3).trim();
        }

        // 使用Jackson反序列化
        return (T) this.objectMapper.readValue(text,
                this.objectMapper.constructType(this.type));
    } catch (JsonProcessingException e) {
        throw new RuntimeException("JSON解析失败: " + text, e);
    }
}

可以看到,它还贴心地处理了markdown代码块的情况。

部分大模型会"自作主张"在 JSON 输出前后加上 ```json 的 markdown 标记。BeanOutputConverterconvert() 方法中会自动识别并剥离这些标记,保证 JSON 解析不会失败。

手动使用BeanOutputConverter

来看看怎么在代码里使用:

@RestController
@RequestMapping("/structured")
public class StructuredOutputController {

    private final ChatClient chatClient;

    @GetMapping("/product")
    public String getProduct(@RequestParam String category) {
        // 1. 创建Converter
        BeanOutputConverter<Product> converter = new BeanOutputConverter<>(Product.class);

        // 2. 构建包含格式说明的提示词
        PromptTemplate template = new PromptTemplate("""
                请为"{category}"类目生成一个虚构的商品信息。

                {format}
                """);

        Prompt prompt = template.create(Map.of(
            "category", category,
            "format", converter.getFormat()  // 注入格式说明
        ));

        // 3. 调用模型获取JSON
        String jsonResult = chatClient.prompt(prompt)
                .call()
                .content();

        // 4. 转成Java对象
        Product product = converter.convert(jsonResult);

        System.out.println("商品名称: " + product.name());
        System.out.println("价格: " + product.price());

        return jsonResult;
    }
}

entity()方法:更简洁的写法

手动使用Converter有点繁琐,Spring AI提供了更简洁的写法——entity()方法:

@GetMapping("/product-simple")
public String getProductSimple(@RequestParam String category) {
    Product product = chatClient.prompt("为" + category + "类目生成一个虚构商品")
            .call()
            .entity(Product.class);  // 一步到位

    return product.toString();
}

一个方法搞定,内部自动完成了格式注入和反序列化。

entity()源码追踪

来看看它是怎么实现的:

// ChatClient.CallResponseSpec
@Nullable
public <T> T entity(Class<T> type) {
    Assert.notNull(type, "type cannot be null");
    // 创建Converter
    BeanOutputConverter<T> outputConverter = new BeanOutputConverter<>(type);
    // 调用实际处理方法
    return this.doSingleWithBeanOutputConverter(outputConverter);
}

private <T> T doSingleWithBeanOutputConverter(StructuredOutputConverter<T> outputConverter) {
    // 1. 发起调用,把格式说明注入到请求中
    ChatResponse chatResponse = this.doGetObservableChatClientResponse(
            this.request,
            outputConverter.getFormat()  // 关键:注入格式说明
    ).chatResponse();

    // 2. 提取文本响应
    String stringResponse = getContentFromChatResponse(chatResponse);

    // 3. 转换成目标类型
    return (stringResponse == null) ? null : outputConverter.convert(stringResponse);
}

核心就两步:

  • getFormat()的内容注入到请求里
  • 把响应文本用convert()转成对象

使用@JsonPropertyDescription增强准确性

如果你发现输出的字段不够准确,可以用注解给字段加上描述:

public record OrderInfo(
    @JsonPropertyDescription("订单编号,格式为ORD开头的12位字符串")
    String orderId,

    @JsonPropertyDescription("订单状态:PENDING-待支付, PAID-已支付, SHIPPED-已发货, COMPLETED-已完成")
    String status,

    @JsonPropertyDescription("订单总金额,保留两位小数")
    BigDecimal totalAmount,

    @JsonPropertyDescription("下单时间,ISO 8601格式")
    String createTime,

    @JsonPropertyDescription("收货人姓名")
    String receiverName
) {}

这些描述会被包含在JSON Schema里,帮助模型更准确地生成数据。

转换成List

如果需要返回多个对象怎么办?用ParameterizedTypeReference

@GetMapping("/products")
public String getProducts(@RequestParam String category) {
    List<Product> products = chatClient.prompt(
            "为" + category + "类目生成3个虚构商品")
            .call()
            .entity(new ParameterizedTypeReference<List<Product>>() {});

    products.forEach(p ->
        System.out.println(p.name() + " - ¥" + p.price()));

    return products.toString();
}

注意:不能直接写List.class,必须用ParameterizedTypeReference包装,否则泛型信息会丢失。

Java 泛型在运行时会被擦除,直接传 List.class 无法知道列表的元素类型。必须使用 new ParameterizedTypeReference<List<Product>>() {} 的匿名子类写法来保留完整的泛型类型信息。

转换成Map

Map的转换稍微复杂一点,Spring AI提供了MapOutputConverter,但它只能转成Map<String, Object>

@GetMapping("/product-map")
public Map<String, Object> getProductAsMap(@RequestParam String category) {
    return chatClient.prompt("""
            为%s类目生成一个商品,包含productId、name、price、description字段
            """.formatted(category))
            .call()
            .entity(new MapOutputConverter());
}

如果需要更精确的Map结构,建议在提示词里明确说明:

@GetMapping("/category-products")
public Map<String, Object> getCategoryProducts() {
    return chatClient.prompt("""
            生成一个商品分类Map,格式如下:
            {
                "电子产品": {"count": 数量, "topSeller": "热销商品名"},
                "服装": {"count": 数量, "topSeller": "热销商品名"},
                "食品": {"count": 数量, "topSeller": "热销商品名"}
            }
            """)
            .call()
            .entity(new MapOutputConverter());
}

流式调用不支持结构化输出

流式调用(stream)不支持 entity()。流式输出是分段返回的,无法保证每段都是有效的完整 JSON。如果需要结构化输出,必须使用同步调用(call())。

// 这样是不行的!
chatClient.prompt("xxx")
    .stream()
    .entity(Product.class);  // 编译通过,但运行会出问题

处理复杂嵌套结构

结构化输出支持嵌套对象:

public record Order(
    String orderId,
    Customer customer,      // 嵌套对象
    List<OrderItem> items,  // 嵌套列表
    BigDecimal totalAmount
) {}

public record Customer(
    String customerId,
    String name,
    String phone
) {}

public record OrderItem(
    String productId,
    String productName,
    int quantity,
    BigDecimal price
) {}

使用方式和简单对象一样:

Order order = chatClient.prompt("生成一个包含3个商品的模拟订单")
        .call()
        .entity(Order.class);

常见问题排查

遇到解析失败时,先用 .call().content() 打印原始响应内容,看看模型实际返回了什么。常见问题:

  • 模型返回了额外的解释文字(需要在提示词中更强调"只输出JSON")
  • 返回的 JSON 格式不正确
  • 包含了 markdown 代码块(BeanOutputConverter 会自动处理,但若自行解析则需注意)

字段值不准确时,用 @JsonPropertyDescription 添加详细的字段描述,帮助模型理解字段的含义和格式要求。

小结

这篇文章深入剖析了Spring AI的结构化输出机制:

  • 原理:通过JSON Schema告诉模型期望的输出格式
  • BeanOutputConverter:核心转换器,负责格式注入和反序列化
  • entity()方法:简化的API,一步完成结构化输出
  • List/Map转换:用ParameterizedTypeReference处理泛型
  • 限制:流式调用不支持结构化输出

掌握结构化输出,你的AI应用就能和业务代码无缝衔接了。下一篇我们来聊聊Advisor拦截器机制。


企业级项目导航:⬅️ 07-提示词工程实践指南 | 08-结构化输出深度剖析 | ➡️ 01-Reference和Script实战指南