Skip to content

Spring AI 2.0 · 结构化输出

大模型通常返回文本,但业务代码更希望拿到 OrderRiskList<Product> 这样的 Java 对象。Spring AI 2.0 把“约束模型输出形状”和“把响应解析成 Java 类型”收敛为结构化输出 API。本文从最常用的 ChatClient.entity(...) 开始,再下探到 BeanOutputConverterStructuredOutputConverter

1. 先弄清:结构化输出不是普通 JSON 反序列化

假设模型返回:

json
{
  "level": "HIGH",
  "reason": "收货地址与常用地址不一致",
  "score": 86
}

最后一步确实可以交给 JSON 反序列化工具,但真正困难的是前一步:如何让概率模型稳定生成符合目标类型的内容

Spring AI 的默认结构化输出链路包含三步:

  1. 根据 Java 类型生成 JSON Schema。
  2. 把格式要求加入提示词,让模型按 Schema 返回 JSON。
  3. 清理并解析模型文本,映射成 Java 对象。

这条默认链路是“尽力而为”,不是模型层面的强制保证。Spring AI 2.0 因此又提供了两种可靠性增强:

  • validateSchema():响应不符合 Schema 时,把校验错误反馈给模型并重试。
  • useProviderStructuredOutput():把 Schema 交给支持该能力的模型提供商,由上游 API 原生约束输出。

三者解决的问题不同:默认模式兼容性最好,Schema 校验负责发现和自修复错误,原生结构化输出负责从生成源头提高可靠性。

2. 先用最高层 API:ChatClient.entity(...)

Spring AI 2.0 中,多数业务代码不需要手动创建转换器。先定义目标类型:

java
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonPropertyOrder({"level", "score", "reason", "actions"})
public record OrderRisk(
    @JsonPropertyDescription("风险等级,只能是 LOW、MEDIUM 或 HIGH")
    RiskLevel level,

    @JsonPropertyDescription("0 到 100 的整数风险分")
    int score,

    @JsonPropertyDescription("判定理由,使用简体中文")
    String reason,

    @JsonPropertyDescription("建议采取的处理动作")
    List<String> actions
) {
}

enum RiskLevel {
    LOW, MEDIUM, HIGH
}

然后直接调用 .entity(OrderRisk.class)

java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class OrderRiskService {
    private final ChatClient chatClient;

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

    public OrderRisk analyze(String orderText) {
        return chatClient.prompt()
            .system("""
                你是电商风控分析员。
                只根据用户提供的订单信息判断,不要虚构缺失事实。
                """)
            .user(u -> u.text("分析下面订单的风险:\n{order}")
                .param("order", orderText))
            .call()
            .entity(OrderRisk.class);
    }
}

.entity(Class<T>) 会在内部生成 Schema、提示模型并完成转换。它不是简单的 content()ObjectMapper.readValue(...):格式说明如何进入请求、模型响应如何清理和转换,都由 Spring AI 管理。

2.1 泛型结果使用 ParameterizedTypeReference

由于 Java 类型擦除,List<OrderRisk>.class 并不存在。泛型目标要这样写:

java
import org.springframework.core.ParameterizedTypeReference;

List<OrderRisk> risks = chatClient.prompt()
    .user("生成 3 个不同风险等级的订单风控示例")
    .call()
    .entity(new ParameterizedTypeReference<List<OrderRisk>>() {});

同样的方式也适用于 Map<String, OrderRisk>、嵌套列表等类型。不过 Map 作为落地类型有明显代价,应尽量转成 DTO,详见第 4 节。

2.2 需要元数据时使用 responseEntity(...)

.entity(...) 只返回业务对象。如果还需要 token 用量、模型元数据或原始 ChatResponse,使用:

java
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.ResponseEntity;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;

@Service
public class OrderRiskService {
    private final ChatClient chatClient;

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

    public OrderRisk analyzeWithMetadata() {
        ResponseEntity<ChatResponse, OrderRisk> result = chatClient.prompt()
            .user("分析订单:同一账号一分钟内提交 20 笔异地订单")
            .call()
            .responseEntity(OrderRisk.class);

        OrderRisk risk = result.entity();
        ChatResponse response = result.response();
        long totalTokens = response.getMetadata().getUsage().getTotalTokens();
        return risk;
    }
}

3. BeanOutputConverter 到底做了什么

BeanOutputConverter<T>StructuredOutputConverter<T> 的一个实现,专门处理 JSON Schema → Java Bean / record 的场景。

它的两个核心动作是:

  • getFormat():返回供模型阅读的格式说明,其中包含从目标 Java 类型生成的 JSON Schema。
  • convert(String):把完整的模型文本转换为目标 Java 对象。

在 Spring AI 1.x 中,这套“手动 getFormat() 拼进 PromptTemplate,再手动 convert(...)”是结构化输出的典型写法:

java
// Spring AI 1.x 风格:手动拼接格式说明 + 手动解析
PromptTemplate promptTemplate = PromptTemplate.builder()
    .template("介绍一下 2022 世界杯冠军球队,输出格式按下面方式输出:{format}")
    .build();
BeanOutputConverter<WorldCupTeam> converter =
    new BeanOutputConverter<>(WorldCupTeam.class);

String content = chatClient
    .prompt(promptTemplate.create(Map.of("format", converter.getFormat())))
    .call()
    .content();
WorldCupTeam team = converter.convert(content);

到了 Spring AI 2.x,这一整套模板拼接与解析被 ChatClient.entity(...) 收敛掉了(见第 2 节):

java
// Spring AI 2.x 推荐写法:一行 entity 完成生成约束 + 解析
WorldCupTeam team = chatClient.prompt()
    .user("介绍一下 2022 世界杯冠军球队")
    .call()
    .entity(WorldCupTeam.class);

因此在 2.x 项目里,业务代码应优先用 .entity(...),只有在需要复用低层 ChatModel API、或需要显式检查格式提示与原始响应时,才回退到显式操作 BeanOutputConverter

java
import java.util.Map;

import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.stereotype.Component;

@Component
public class OrderRiskAnalyzer {
    private final ChatModel chatModel;

    public OrderRiskAnalyzer(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public OrderRisk analyze(String orderText) {
        BeanOutputConverter<OrderRisk> converter =
            new BeanOutputConverter<>(OrderRisk.class);

        String template = """
            分析下面订单的风险:
            {order}

            {format}
            """;

        Prompt prompt = PromptTemplate.builder()
            .template(template)
            .variables(Map.of(
                "order", orderText,
                "format", converter.getFormat()))
            .build()
            .create();

        Generation generation = chatModel.call(prompt).getResult();
        String rawText = generation.getOutput().getText();
        return converter.convert(rawText);
    }
}

这里的调用顺序不能颠倒:

  1. 调用 getFormat(),把格式要求真正送给模型。
  2. 等模型返回完整文本。
  3. 调用 convert(...) 做解析。

只调用 convert(...) 并不会约束模型;只调用 getFormat() 也不会自动得到 Java 对象。

3.1 泛型 BeanOutputConverter

显式转换泛型时仍然使用 ParameterizedTypeReference

java
BeanOutputConverter<List<OrderRisk>> converter =
    new BeanOutputConverter<>(
        new ParameterizedTypeReference<List<OrderRisk>>() {});

3.2 Schema 描述和字段顺序

Spring AI 2.0 的 BeanOutputConverter 从 Java 类型生成 JSON Schema。可用 Jackson 注解增强语义:

  • @JsonPropertyDescription:告诉模型字段含义和约束。
  • @JsonPropertyOrder:控制生成 Schema 中的属性顺序。

不过注解只是提高模型理解度,不能替代 Bean Validation 或业务校验。例如 score 的范围仍应在程序中检查,不能因为描述写了“0 到 100”就假设一定成立。

4. StructuredOutputConverter:结构化输出的扩展契约

它不是另一个与 BeanOutputConverter 并列的“工具类”,而是后者实现的核心接口:

java
public interface StructuredOutputConverter<T>
        extends Converter<String, T>, FormatProvider {
}

可以把它理解为两个职责的组合:

方法阶段职责
getFormat()请求模型之前告诉模型应该返回什么格式
convert(String source)模型响应之后把完整文本解析为 T
getJsonSchema()可选能力向 2.0 的校验或原生输出链路暴露 JSON Schema

Spring AI 内置的常用实现包括:

实现目标结果适用场景
BeanOutputConverter<T>Bean、record、泛型对象有稳定业务结构,首选
MapOutputConverterMap<String, Object>仅用于字段完全动态、临时探查的场景,不建议直接暴露给业务
ListOutputConverterList简单的逗号分隔列表

业务接口优先返回明确的 record / POJO。

不建议直接使用 Map<String, ...> 作为结构化输出的落地类型。 Map 虽灵活,却会把字段名拼写、值类型和必填约束全部推迟到运行时:编译器无法帮你发现 map.get("scoer") 这类拼写错误,取值还要处处强转和判空,IDE 补全、字段描述(@JsonPropertyDescription)和 Bean Validation 也都失效。

即便模型侧确实返回了 Map 结构(如 Map<String, WorldCupTeam>),也应尽快在边界处转成 DTO 再向下传递:

java
// 不推荐:直接把 Map 抛给业务层
Map<String, WorldCupTeam> raw = chatClient.prompt()
    .user("介绍一下 2022 世界杯前三的球队")
    .call()
    .entity(new ParameterizedTypeReference<Map<String, WorldCupTeam>>() {});

// 推荐:直接声明目标 DTO,让类型系统兜底
List<WorldCupTeam> teams = chatClient.prompt()
    .user("介绍一下 2022 世界杯前三的球队")
    .call()
    .entity(new ParameterizedTypeReference<List<WorldCupTeam>>() {});

只有当键集合本身不可预知、且下游只做透传或日志时,才考虑保留 Map

5. 自定义 StructuredOutputConverter:以 CSV 为例

非 JSON 格式,或供应商响应需要特殊清洗时,可以自己实现接口。下面把模型输出转换为风险规则列表:

java
import java.util.Arrays;
import java.util.List;

import org.springframework.ai.converter.StructuredOutputConverter;

public record RiskRule(String code, int weight, String description) {
}

public final class RiskRuleCsvConverter
        implements StructuredOutputConverter<List<RiskRule>> {
    @Override
    public String getFormat() {
        return """
            只输出 CSV,不要 Markdown 代码围栏或解释。
            第一行固定为:code,weight,description
            weight 必须是 0 到 100 的整数。
            description 不得包含换行;若包含逗号,使用双引号包裹。
            """;
    }

    @Override
    public List<RiskRule> convert(String source) {
        String[] lines = source.strip().split("\\R");
        if (lines.length < 2
                || !"code,weight,description".equals(lines[0].strip())) {
            throw new IllegalArgumentException("模型没有返回预期的 CSV 表头");
        }

        return Arrays.stream(lines)
            .skip(1)
            .map(this::parseLine)
            .toList();
    }

    private RiskRule parseLine(String line) {
        // 教学示例只处理简单三列;生产环境应使用 Apache Commons CSV 等解析器。
        String[] columns = line.split(",", 3);
        if (columns.length != 3) {
            throw new IllegalArgumentException("非法 CSV 行:" + line);
        }
        return new RiskRule(
            columns[0].strip(),
            Integer.parseInt(columns[1].strip()),
            columns[2].strip());
    }
}

传给 ChatClient.entity(...)

java
RiskRuleCsvConverter converter = new RiskRuleCsvConverter();

List<RiskRule> rules = chatClient.prompt()
    .user("为电商订单生成 3 条风险规则。")
    .call()
    .entity(converter);

传入转换器后,ChatClient 会使用它的格式说明,无需再手动把 getFormat() 拼进用户提示词。自定义转换器的 getFormat() 必须足够明确,convert(...) 必须对空响应、非法行和数值越界进行防御。上例为突出接口而简化了 CSV 解析;生产代码不要用 split(",") 处理完整 CSV 语法。

6. Spring AI 2.0 的两级可靠性增强

6.1 validateSchema():校验失败后自修复

java
OrderRisk risk = chatClient.prompt()
    .user("分析订单:同一设备短时间绑定 8 张银行卡")
    .call()
    .entity(OrderRisk.class, spec -> spec.validateSchema());

启用后,Spring AI 会校验模型响应;失败时把具体错误加入上下文,再次请求模型修正,默认最多尝试 3 次。

代价也很直接:失败场景会增加请求次数、延迟和 token 消耗。因此它适合“结构错误不能直接下游传播”的接口,而不是所有聊天都无脑开启。

6.2 useProviderStructuredOutput():使用上游原生约束

java
OrderRisk risk = chatClient.prompt()
    .user("分析订单:夜间连续购买大量高价值礼品卡")
    .call()
    .entity(OrderRisk.class,
        spec -> spec.useProviderStructuredOutput());

这会把 JSON Schema 发送到模型提供商的原生结构化输出接口。它通常比仅靠提示词更可靠,但前提是当前提供商、模型和所用 API 真正支持 JSON Schema。

若下游不能容忍结构漂移,可以组合二者:

java
OrderRisk risk = chatClient.prompt()
    .user("分析订单:新账号首次下单金额 20 万元")
    .call()
    .entity(OrderRisk.class, spec -> spec
        .useProviderStructuredOutput()
        .validateSchema());

不要仅根据“提供商支持”就推断“所有模型版本都支持”。官方文档明确提醒,原生能力随模型和提供商变化,尤其本地 Ollama 模型需要逐个实测。

7. 常见误区

7.1 把“能解析”当成“业务正确”

Schema 只能证明形状基本正确,不能证明事实正确。例如模型可能返回合法的 score=90,但理由来自幻觉。结构校验之后仍要做:

  • Bean Validation / 手工范围校验。
  • 枚举和业务状态机校验。
  • 关键事实与数据库、检索结果或工具返回值交叉验证。
  • 对高风险动作保留人工确认。

7.2 结构化输出和工具调用混为一谈

结构化输出用于“让模型的最终答案成为业务对象”;工具调用用于“让模型选择并调用某个函数”。工具参数本身已经是结构化的,不经过 StructuredOutputConverter

7.3 在流式响应上直接 .entity(...)

.entity(...).responseEntity(...) 需要完整响应,因此属于 .call() 路径。.stream() 返回文本分片,不能直接得到一个尚未完整生成的 Java 对象。需要流式 UI 时,可以先流式展示文本,结束后另行解析;需要可靠业务对象时则优先非流式调用。

7.4 目标类型设计得过于复杂

模型面对深层嵌套、过多可选字段和宽泛的 Object 类型时更容易漂移。更稳妥的做法是:

  • 使用小而明确的 record。
  • 用枚举代替自由文本状态。
  • 避免一次生成巨大对象图。
  • 字段描述写业务含义,不只复述字段名。
  • 复杂任务拆成“抽取 → 校验 → 决策”多个步骤。

8. 怎么选

需求推荐方式
普通 POJO / record.call().entity(Type.class)
List<T>Map<K,V> 等泛型.entity(new ParameterizedTypeReference<...>() {})
想检查原始响应或手动控制提示词显式使用 BeanOutputConverter<T>
想拿 token 和响应元数据.responseEntity(...)
响应结构错误时自动反馈重试validateSchema()
模型提供商支持原生 JSON SchemauseProviderStructuredOutput()
YAML、CSV 或私有格式自定义 StructuredOutputConverter<T>
流式文本.stream(),不要期待直接得到类型化实体

9. 总结

Spring AI 2.0 中,结构化输出的推荐入口是 ChatClient.entity(...)。它背后通常使用 BeanOutputConverter 完成 Schema 生成和 Java 映射,而 BeanOutputConverter 又实现了更通用的 StructuredOutputConverter 契约。

落地时记住四句话:

  1. entity(...) 是业务代码的首选入口。
  2. BeanOutputConverter 适合需要显式掌控格式提示和解析过程的场景。
  3. 自定义 StructuredOutputConverter 用于非 JSON 或特殊清洗需求。
  4. 类型转换不等于结果可信;重要链路应叠加 Schema 校验、原生结构化输出和业务校验。

参考资料