结构化输出
本教程共 45 篇 · 第 8 篇 · 更新于 2026-08-16 · 约 8 分钟阅读
本节目标:学会用 BeanOutputConverter、MapOutputConverter、ListOutputConverter 把模型输出转成 Java 类型,并了解原生结构化输出。
模型返回的是文本,应用要的是对象。下游代码要解析 JSON、填充实体、落库,每一步都依赖格式可靠。常规做法是让模型输出 JSON,再用 Jackson 解析。问题是”让模型输出 JSON”并不总是成立:它可能加解释,可能格式不对。结构化输出转换器专门解决这件事。
8.1 原理:调用前给指令,调用后做转换
StructuredOutputConverter<T> 接口由两部分组成:
Converter<String, T>:把模型输出文本转成 T 类型FormatProvider:提供格式指令文本
调用前,转换器把格式指令追加到提示词末尾。指令大致长这样:
Your response should be in JSON format. The data structure for the JSON should match this Java class. Do not include any explanations, only provide a RFC8259 compliant JSON response without deviation.
这段指令就是给模型的”蓝图”。调用后,转换器解析模型输出,映射成目标类型实例。整个过程围绕 LLM 的文本补全接口展开,前后各加一道工序。两处配合,下游代码拿到的类型才是可靠的。
指令追加的位置有讲究,通常拼在用户输入的末尾,通过模板的 {format} 占位符注入。模型读完全部需求,最后看到格式要求,照做的概率最高。
接口定义也很简洁:
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
}
public interface FormatProvider {
String getFormat();
}
getFormat() 产出格式指令,convert() 完成文本到对象的转换。
Note转换器是尽力而为。模型不保证一定按请求返回格式。生产环境建议加校验,比如第 6 章的 StructuredOutputValidationAdvisor。
另外,工具调用(Tool Calling)不走转换器,它天生就是结构化输出。
8.2 BeanOutputConverter:输出变对象
最常用的是 BeanOutputConverter。给它一个 Java 类型,它自动生成 JSON Schema(DRAFT_2020_12),随提示词发给模型。模型输出后,它用 Jackson 反序列化成对象。
定义目标类型:
record ActorsFilms(String actor, List<String> movies) {}
用 ChatClient 的高层 API 最简单:
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
.user(u -> u.text("列出 {actor} 出演的 5 部电影")
.param("actor", "Tom Hanks"))
.call()
.entity(ActorsFilms.class);
.entity() 内部完成三件事:生成格式指令、追加到提示词、解析响应。底层 API 则要手动做这三步:
BeanOutputConverter<ActorsFilms> converter = new BeanOutputConverter<>(ActorsFilms.class);
String template = """
列出 {actor} 出演的 5 部电影。
{format}
""";
Prompt prompt = PromptTemplate.builder()
.template(template)
.variables(Map.of("actor", "Tom Hanks", "format", converter.getFormat()))
.build().create();
Generation generation = chatModel.call(prompt).getResult();
ActorsFilms actorsFilms = converter.convert(generation.getOutput().getText());
注意模板里的 {format} 占位符,它被替换成转换器的格式指令。convert() 负责把模型文本变成 Java 对象。对比两段代码能看出,高层 API 把模板拼接和解析都封装掉了。
JSON Schema 是描述 JSON 结构的标准。转换器根据 ActorsFilms 的字段生成 schema,模型照着 schema 输出,Jackson 再按同样的结构反序列化。schema 贯穿前后,保证两边对齐。
8.3 属性顺序与泛型类型
生成的 schema 里,属性顺序默认跟声明顺序一致。想调整用 @JsonPropertyOrder:
@JsonPropertyOrder({"actor", "movies"})
record ActorsFilms(String actor, List<String> movies) {}
注解对 record 和普通 class 都有效。record 是 Java 16 引入的语法,Spring Boot 4.0.x 要求 Java 17+,教程的基线版本完全支持。要 List<ActorsFilms> 这种泛型时,Class 表达不了,用 ParameterizedTypeReference:
List<ActorsFilms> actorsFilms = ChatClient.create(chatModel).prompt()
.user("列出汤姆·汉克斯和比尔·默瑞各自的 5 部电影")
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});
底层写法同样支持,BeanOutputConverter 的构造函数接受 ParameterizedTypeReference。
8.4 MapOutputConverter:不建类也能用
输出结构不固定时,为每种结构建类太累。MapOutputConverter 把输出转成 Map<String, Object>,模型的 JSON 直接变成键值对,灵活但需要自己处理嵌套。
Map<String, Object> result = ChatClient.create(chatModel).prompt()
.user(u -> u.text("给我一个 {subject} 列表")
.param("subject", "1 到 9 的数字数组,放在 numbers 键下"))
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() {});
拿到 Map 之后,按 key 取数即可。适合结果形状经常变的场景。代价是没有类型安全,字段名拼错只有运行时才知道。键值结构简单直观,调试时把 Map 打出来就能看全貌。
8.5 ListOutputConverter:逗号分隔列表
输出是简单列表时用 ListOutputConverter。它引导模型输出逗号分隔的文本,再转成 List<String>:
List<String> flavors = ChatClient.create(chatModel).prompt()
.user(u -> u.text("列出五种 {subject}")
.param("subject", "冰淇淋口味"))
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
三种转换器的选择很简单:固定结构用 Bean,不固定结构用 Map,简单列表用 List。前两个产出 JSON,第三个产出纯文本列表。除这三个外,还有两个抽象基类 AbstractConversionServiceOutputConverter 和 AbstractMessageOutputConverter,需要自定义转换器时从它们继承,省去配置转换服务的麻烦。
8.6 原生结构化输出
提示词方案靠模型自觉。现在很多模型提供原生结构化输出 API:schema 直接传给模型,模型保证输出符合 schema。更可靠,提示词也更干净,模型还能针对结构化输出做内部优化。
开启方式是在调用前加 advisor 参数:
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
.user("随机选一位演员,列出他的 5 部电影")
.call()
.entity(ActorsFilms.class);
也可以全局开启:
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultAdvisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
.build();
}
Warning原生结构化输出默认不开启,各模型支持程度差异大。启用前按模型逐个测试。
提示词方案兼容所有模型,原生方案更可靠但挑模型。官方建议:默认用提示词方案,需要 API 级强约束时再开原生,并且必须针对具体模型版本验证。两种方案在代码上只有一行之差,切换成本低,先用提示词跑通业务,再按需升级。
官方列出的支持列表:OpenAI GPT-4o 及以后、Anthropic Claude 3.5 Sonnet 及以后、Google Gemini 1.5 Pro 及以后、Mistral Small 及以后、Ollama 部分模型。集成测试还覆盖了 Azure OpenAI 和 Vertex AI Gemini。
列表会随模型迭代变化。接入新模型前,查一下官方文档的当前状态。
两个已知的坑:
OpenAI 不支持顶层数组。 原生模式下请求 List<T> 会报错。包一层容器 record 解决:
record FilmographyList(List<ActorsFilms> films) {}
Ollama 带思考模式的模型不稳定。 qwen3:8b 这类模型可能把思考过程当文本输出,导致反序列化失败。换模型(如 llama3.1),或退回提示词方案。也可以用 useProviderStructuredOutput() 配合 validateSchema(),让格式错误的响应自动重试。校验和重试正是第 6 章递归 advisor 的用武之地。
8.7 内置 JSON 模式
部分模型提供专门的 JSON 配置项,写进 application.properties 即可:
- OpenAI:
spring.ai.openai.chat.response-format.type,可选 JSON_OBJECT 或 JSON_SCHEMA - Ollama:
spring.ai.ollama.chat.format,目前只接受 json - Mistral:
spring.ai.mistralai.chat.response-format,可设{"type": "json_object"}
JSON_OBJECT 保证输出是合法 JSON,JSON_SCHEMA 保证输出匹配指定 schema。这是模型层面的硬保证,和转换器配合使用效果最好。配置项按厂商分开,换模型时记得改对应前缀。内置 JSON 模式和原生结构化输出解决的是同一个问题,区别在触发方式:一个靠配置,一个靠 API 参数。实际项目里两者常二选一,不要重复叠加。
8.8 小结
结构化输出的核心是转换器:调用前给格式指令,调用后解析文本。Bean 转换器最常用,Map 和 List 适合轻量场景。追求可靠性就上原生结构化输出,注意模型兼容性。再配合校验重试 advisor,输出的可靠性才算完整。