Spring AI 2.0 · 结构化输出
大模型通常返回文本,但业务代码更希望拿到
OrderRisk、List<Product>这样的 Java 对象。Spring AI 2.0 把“约束模型输出形状”和“把响应解析成 Java 类型”收敛为结构化输出 API。本文从最常用的ChatClient.entity(...)开始,再下探到BeanOutputConverter和StructuredOutputConverter。
1. 先弄清:结构化输出不是普通 JSON 反序列化
假设模型返回:
{
"level": "HIGH",
"reason": "收货地址与常用地址不一致",
"score": 86
}最后一步确实可以交给 JSON 反序列化工具,但真正困难的是前一步:如何让概率模型稳定生成符合目标类型的内容。
Spring AI 的默认结构化输出链路包含三步:
- 根据 Java 类型生成 JSON Schema。
- 把格式要求加入提示词,让模型按 Schema 返回 JSON。
- 清理并解析模型文本,映射成 Java 对象。
这条默认链路是“尽力而为”,不是模型层面的强制保证。Spring AI 2.0 因此又提供了两种可靠性增强:
validateSchema():响应不符合 Schema 时,把校验错误反馈给模型并重试。useProviderStructuredOutput():把 Schema 交给支持该能力的模型提供商,由上游 API 原生约束输出。
三者解决的问题不同:默认模式兼容性最好,Schema 校验负责发现和自修复错误,原生结构化输出负责从生成源头提高可靠性。
2. 先用最高层 API:ChatClient.entity(...)
Spring AI 2.0 中,多数业务代码不需要手动创建转换器。先定义目标类型:
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):
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 并不存在。泛型目标要这样写:
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,使用:
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(...)”是结构化输出的典型写法:
// 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 节):
// Spring AI 2.x 推荐写法:一行 entity 完成生成约束 + 解析
WorldCupTeam team = chatClient.prompt()
.user("介绍一下 2022 世界杯冠军球队")
.call()
.entity(WorldCupTeam.class);因此在 2.x 项目里,业务代码应优先用 .entity(...),只有在需要复用低层 ChatModel API、或需要显式检查格式提示与原始响应时,才回退到显式操作 BeanOutputConverter:
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);
}
}这里的调用顺序不能颠倒:
- 调用
getFormat(),把格式要求真正送给模型。 - 等模型返回完整文本。
- 调用
convert(...)做解析。
只调用 convert(...) 并不会约束模型;只调用 getFormat() 也不会自动得到 Java 对象。
3.1 泛型 BeanOutputConverter
显式转换泛型时仍然使用 ParameterizedTypeReference:
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 并列的“工具类”,而是后者实现的核心接口:
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、泛型对象 | 有稳定业务结构,首选 |
MapOutputConverter | Map<String, Object> | 仅用于字段完全动态、临时探查的场景,不建议直接暴露给业务 |
ListOutputConverter | List | 简单的逗号分隔列表 |
业务接口优先返回明确的 record / POJO。
不建议直接使用 Map<String, ...> 作为结构化输出的落地类型。 Map 虽灵活,却会把字段名拼写、值类型和必填约束全部推迟到运行时:编译器无法帮你发现 map.get("scoer") 这类拼写错误,取值还要处处强转和判空,IDE 补全、字段描述(@JsonPropertyDescription)和 Bean Validation 也都失效。
即便模型侧确实返回了 Map 结构(如 Map<String, WorldCupTeam>),也应尽快在边界处转成 DTO 再向下传递:
// 不推荐:直接把 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 格式,或供应商响应需要特殊清洗时,可以自己实现接口。下面把模型输出转换为风险规则列表:
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(...):
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():校验失败后自修复
OrderRisk risk = chatClient.prompt()
.user("分析订单:同一设备短时间绑定 8 张银行卡")
.call()
.entity(OrderRisk.class, spec -> spec.validateSchema());启用后,Spring AI 会校验模型响应;失败时把具体错误加入上下文,再次请求模型修正,默认最多尝试 3 次。
代价也很直接:失败场景会增加请求次数、延迟和 token 消耗。因此它适合“结构错误不能直接下游传播”的接口,而不是所有聊天都无脑开启。
6.2 useProviderStructuredOutput():使用上游原生约束
OrderRisk risk = chatClient.prompt()
.user("分析订单:夜间连续购买大量高价值礼品卡")
.call()
.entity(OrderRisk.class,
spec -> spec.useProviderStructuredOutput());这会把 JSON Schema 发送到模型提供商的原生结构化输出接口。它通常比仅靠提示词更可靠,但前提是当前提供商、模型和所用 API 真正支持 JSON Schema。
若下游不能容忍结构漂移,可以组合二者:
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 Schema | useProviderStructuredOutput() |
| YAML、CSV 或私有格式 | 自定义 StructuredOutputConverter<T> |
| 流式文本 | .stream(),不要期待直接得到类型化实体 |
9. 总结
Spring AI 2.0 中,结构化输出的推荐入口是 ChatClient.entity(...)。它背后通常使用 BeanOutputConverter 完成 Schema 生成和 Java 映射,而 BeanOutputConverter 又实现了更通用的 StructuredOutputConverter 契约。
落地时记住四句话:
entity(...)是业务代码的首选入口。BeanOutputConverter适合需要显式掌控格式提示和解析过程的场景。- 自定义
StructuredOutputConverter用于非 JSON 或特殊清洗需求。 - 类型转换不等于结果可信;重要链路应叠加 Schema 校验、原生结构化输出和业务校验。
