结构化输出
约 8321 字大约 28 分钟
2026-09-15
合集说明
本篇合并自参考文档「Structured Output」章节(结构化输出 / 模式校验与自我修正 / 厂商原生结构化输出 / 输出转换器),按官方文档顺序排列:(各篇之间以 --- 分隔)
- Structured Output(结构化输出)
- Schema Validation & Self-Correction(模式校验与自我修正)
- Provider-Native Structured Output(厂商原生结构化输出)
- Output Converters(输出转换器)
Structured Output(结构化输出)
📌 原文地址:https://docs.spring.io/java-ai/reference/api/structured-output.html 📌 原文版本:Java AI 2.0.1(Stable,稳定版)
大型语言模型本质上是"文本进、文本出"的系统。一旦下游代码需要根据某个字段做路由、把某个值存进数据库,或者根据结果走不同分支,这些文本就必须变成一个带类型的记录。结构化输出就是用来补上这个缺口的:引导模型产出符合 schema(模式/结构定义)的文本,然后应用程序把它解析回一个类型化对象,这样代码库的其他部分就能把它当成普通的领域类型来用。
Java AI 在 ChatClient 流式 API 上直接暴露了结构化输出能力,入口就是 .entity(…)。你只需要定义一个 Java 类型来描述想要的返回结构,剩下的 Java AI 全包了:它根据你的类型生成 JSON schema(JSON 模式),指示模型遵守这个 schema,最后把响应反序列化成你定义的类型。
本页讲的是高层级的 ChatClient 路径。关于可靠性开关和更底层的 API,请参阅:
- Schema Validation & Self-Correction(Schema 校验与自我修正) —— 用
validateSchema()检测格式错误的输出并自动重试。 - Provider-Native Structured Output(提供商原生结构化输出) —— 用
useProviderStructuredOutput()在提供商 API 层面强制约束 schema。 - Output Converters(输出转换器) —— 更底层的
StructuredOutputConverterAPI、内置转换器,以及自定义/非 JSON 转换器。
Typed Response(类型化响应)
先定义一个 Java record(记录类),描述你想要的返回结构:
// 返回结构:演员名字 + 电影列表
record ActorsFilms(String actor, List<String> movies) {}然后让 ChatClient 来填充它。注意:不要用 .content() 结束调用——那样拿到的是原始文本回复——而是用 .entity(…) 结束,并传入目标类型:
// 发起请求,并把模型的 JSON 回答解析成 ActorsFilms 对象
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.") // 让模型随机生成一位演员的作品列表
.call()
.entity(ActorsFilms.class); // 关键一步:把文本响应转换成类型化对象得到的就是一个类型化的 ActorsFilms,可以直接交给代码的其他部分使用:
films.actor(); // "Tom Hanks"
films.movies(); // ["Forrest Gump", "Cast Away", ...]幕后,Java AI 做了三件事:schema 生成器把你的 ActorsFilms record 转成了 JSON schema;该 schema 被追加到了 prompt(提示词)的 system context(系统上下文)里;模型返回的 JSON 答案被交给类型转换器,解析回你的 record。

白话解读
可以把 .entity() 理解为告诉 Java AI:"我不要纯文本,我要一个 Java 对象"。它帮你干了三件脏活:写 schema、把 schema 塞进提示词、把模型的 JSON 回答转成对象。写代码的体验就像调用一个普通方法,直接拿到强类型结果。
这套机制在 Java AI 支持的所有模型上都能用——完全没有任何提供商专属的东西。
注意
.entity(…) 只能配合 .call() 使用。类型化解析需要拿到完整响应,因此在流式路径上不可用(.stream() 返回的是文本块,不是类型化对象)。这个限制适用于本页介绍的所有变体——Class、ParameterizedTypeReference、自定义转换器,带不带可靠性开关都一样。
注意
默认的 .entity(…) 调用没有任何硬性保证。模型只是"被要求"生成符合 schema 的 JSON,而不是"被强制"。大多数时候它会照做;但有时它会多返回一个字段、漏掉一个必填字段,或者把 JSON 包在一段说明文字里,这时解析器就会抛异常。下面介绍的两个开关就是用来解决这个问题的。
Generic Types: Lists, Maps, and Beyond(泛型类型:List、Map 等)
.entity(Class) 只适用于具体类。对于泛型类型——比如 List<ActorsFilms>、Map<String, ActorsFilms>——要改用 ParameterizedTypeReference(参数化类型引用):
// 用 ParameterizedTypeReference 指明泛型类型 List<ActorsFilms>
List<ActorsFilms> films = chatClient.prompt()
.user("Generate filmographies for three random actors.") // 让模型生成三位随机演员的作品列表
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});白话解读
这是 Java 泛型擦除的老问题:运行时通过 .class 拿不到 List<ActorsFilms> 里装的是什么类型,所以要用 ParameterizedTypeReference 这个"匿名子类"小技巧把完整类型信息保留下来,Java AI 才知道该把 JSON 解析成什么。
Reliability Switches: EntityParamSpec(可靠性开关:EntityParamSpec)
所有 .entity(…)(以及 .responseEntity(…))的重载方法都接受一个可选的 Consumer<EntityParamSpec> 参数,用来开启两种相互独立、可自由组合的行为。
Don't Fail on Malformed Output: validateSchema()(遇到格式错误的输出不直接失败:validateSchema())
validateSchema() 会开启一个自我修正的重试循环:Java AI 用实体的 schema 校验响应,一旦校验失败,就把具体的错误信息追加到 prompt 里,然后重新发起调用——默认最多重试 3 次。
// 开启 schema 校验 + 失败自动重试
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec.validateSchema()); // 校验失败时把错误反馈给模型重试,默认最多 3 次关于重试循环的工作原理和自定义方式,请参阅 Schema Validation & Self-Correction。
Stronger Upstream Guarantees: useProviderStructuredOutput()(更强的上游保证:useProviderStructuredOutput())
useProviderStructuredOutput() 会把 schema 作为 API 层面的约束直接发给提供商,由提供商的运行时来强制保证输出合规,而不是仅仅依赖提示词指令。
// 让提供商在 API 层面强制约束输出格式
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());说明
这要求底层模型支持原生结构化输出。支持的提供商及相关限制,请参阅 Provider-Native Structured Output。
Combining Both(两者组合使用)
这两个开关解决的是不同的问题,可以自然地组合起来:
// 双保险:API 层面约束 + 校验重试
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec
.useProviderStructuredOutput() // 提供商 API 层面强制约束(从源头堵)
.validateSchema()); // 残余错误自动校验重试(兜底)useProviderStructuredOutput() 通过在 API 层面约束模型,把格式错误输出的概率降到最低;validateSchema() 则兜住剩下的漏网之鱼——提供商的边缘情况、推理模型的小毛病——并自动修正。当下游代码完全无法容忍结构漂移时,建议两个都开。
白话解读
打个比方:useProviderStructuredOutput() 相当于"从源头堵"——让提供商在出题时就强制按格式作答;validateSchema() 相当于"答错了打回去重写"——发现答案不合格就把错误反馈给模型重做一遍。两个一起开就是双保险。
Getting the Full Response(获取完整响应)
.entity(…) 只返回解析后的对象。如果你还需要底层的 ChatResponse——比如要拿 token(令牌)用量、可观测性元数据,或实体之外的任何信息——就用 .responseEntity(…):
// 同时拿到完整响应和解析后的实体
ResponseEntity<ChatResponse, ActorsFilms> result = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.responseEntity(ActorsFilms.class);
ActorsFilms films = result.entity(); // 解析后的类型化对象
ChatResponse raw = result.response(); // 原始 ChatResponse
long totalTokens = raw.getMetadata().getUsage().getTotalTokens(); // 拿到 token 总用量它的重载集合和 .entity(…) 完全一样——Class、ParameterizedTypeReference、自定义 StructuredOutputConverter,以及 EntityParamSpec consumer 全部适用。
Custom and Non-JSON Output(自定义输出与非 JSON 输出)
当内置的 JSON 解析不够用时——比如模型把 JSON 包在了 markdown 代码块里,或者你需要 YAML、CSV 这类非 JSON 格式——可以把你自己的 StructuredOutputConverter<T> 传给 .entity(…)。详见 Output Converters。
Cheat Sheet(速查表)
| 你需要 | 使用方式 |
|---|---|
| 默认方式——所有提供商都能用 | .entity(Type.class) |
泛型类型,如 List<T>、Map<K,V> | .entity(new ParameterizedTypeReference<…>() {}) |
| 遇到格式错误输出不要直接失败 | .entity(Type.class, spec -> spec.validateSchema()) |
| 需要提供商更强的上游保证 | .entity(Type.class, spec -> spec.useProviderStructuredOutput()) |
| 两者都要——请求约束 + 响应重试 | .entity(Type.class, spec -> spec.useProviderStructuredOutput().validateSchema()) |
| 实体之外还要 token 用量/元数据 | .responseEntity(…)(重载相同) |
| 模型把 JSON 包在 markdown 代码块里,或需要非 JSON 格式 | 实现 StructuredOutputConverter<T> 并传给 .entity(…) |
| 流式响应 | 不支持——.entity(…) 只能配合 .call();.stream() 返回的是文本块而非类型化对象 |
注意
结构化输出是"尽力而为"地把模型输出转换成结构化类型。模型并不保证一定返回所请求的结构。当正确性至关重要时,请使用 validateSchema() 和/或 useProviderStructuredOutput()。
说明
StructuredOutputConverter 不会用于 LLM Tool Calling(工具调用),因为工具调用这个特性本身就默认产出结构化输出。
Schema Validation & Self-Correction(模式校验与自我修正)
📌 原文地址:https://docs.spring.io/java-ai/reference/api/structured-output/validation.html 📌 原文版本:Java AI 2.0.1
默认情况下,调用 .entity(…) 会请求模型输出符合指定模式(schema)的 JSON,但它并不能强制模型一定遵守。当模型返回了多余的字段、漏掉了必填字段,或者干脆把 JSON 包裹在自然语言叙述里时,解析器就会直接抛异常。
处理这种"格式烂掉了"的输出,最简单的办法就是——发现不对,就重试。Java AI 通过 EntityParamSpec 消费者(consumer)上的一个开关就能自动帮你做到这件事:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.") // 让模型随机生成一位演员的参演作品年表
.call()
.entity(ActorsFilms.class, spec -> spec.validateSchema()); // 打开模式校验 + 自动重试
How the Self-Correcting Loop Works(自我修正循环是如何运作的)
spec -> spec.validateSchema() 这个消费者会打开一个自我修正的重试循环(self-correcting retry loop):
- 模型返回响应。
- Java AI 拿响应内容和目标类型的 JSON schema 做校验。
- 校验通过 → 你拿到反序列化好的强类型对象(typed record)。
- 校验失败 → 把具体的校验错误信息(比如
missing required field \actor`、expected `array`, got `string``)追加到用户提示词(user prompt)里,然后重新发起调用——默认最多重试 3 次。
模型在每一次重试时都能看到具体错在哪里,所以第二次尝试并不是"闭着眼睛再猜一遍"——模型是带着错误反馈去改的。
白话解读
把这段流程想象成"考试+错题重做":第一次交卷,老师(Java AI)用标准答案(JSON schema)判卷;如果不对,老师不会默默打回重做,而是会明确告诉你"第 3 题少了 actor 这个字段"或"这里应该是数组你写成了字符串",然后让你带着这份批注再答一遍。所以重试是有方向、有依据的,不是瞎试。
这一切由 StructuredOutputValidationAdvisor 驱动——它是一个递归 advisor(recursive advisor),在你调用 validateSchema() 时会被自动注册(auto-registered)。你不需要手动接线,这一个开关就是全部配置。
注意:不支持流式(Streaming)
当 validateSchema() 处于激活状态时,不支持流式输出。原因是 advisor 必须拿到完整的响应内容才能进行校验。
Customizing the Advisor(自定义 Advisor)
StructuredOutputValidationAdvisor 默认使用 3 次重试,并采用 Java AI 默认的 JsonMapper。如果你想自定义——比如增加重试次数、预置 schema、或者换一个不同的 mapper——可以自己构建一个实例并注册到 ChatClient 上。显式注册的 advisor 会替换掉自动注册的那个:
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
.outputType(ActorsFilms.class) // 指定目标输出类型,schema 会自动推导
.maxRepeatAttempts(5) // 最多重试 5 次(默认是 3 次)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(validationAdvisor) // 显式注册,替换掉自动注册的 advisor
.build();该 advisor 可以通过 outputType(自动推导出 schema)或 outputJsonSchema(直接提供一个预置的 schema 字符串)来配置;这两个选项互斥,只能选其一。
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
.outputJsonSchema(myConverter.getJsonSchema()) // 使用预先准备好的 JSON schema 字符串
.build();Key Behaviors(关键行为)
- 从期望的输出类型推导出 JSON schema,或者直接接受一个预置的 schema 字符串。
- 使用 JSON Schema DRAFT_2020_12 规范对 LLM 的响应进行校验。
- 校验失败时重试调用(默认最多 3 次)。
- 在重试时把校验错误信息补充到提示词中,帮助模型自我修正。
- 累计每一次校验尝试的 token 用量(token usage),因此返回的
ChatResponse报告的是所有重试累计的用量,而不仅仅是最后一次尝试的用量(参见 Cumulative Usage Across Multi-Step Flows 一节)。 - 可选地支持自定义
JsonMapper。
关于递归 advisor 的细节
想了解这个 advisor 背后的递归机制,请参阅 StructuredOutputValidationAdvisor 章节。
Combining with Provider-Native Output(与 Provider-Native 输出组合使用)
validateSchema() 是响应侧的安全网(response-side safety net)——它在事后捕获坏输出并重试。而 useProviderStructuredOutput() 则是一个互补的请求侧约束(complementary request-side constraint)。两者组合起来非常自然:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.") // 让模型随机生成一位演员的参演作品年表
.call()
.entity(ActorsFilms.class, spec -> spec
.useProviderStructuredOutput() // 请求侧:让 provider 原生地约束输出结构
.validateSchema()); // 响应侧:再加一层模式校验 + 自动重试兜底这一组合在处理某些 provider 的边缘情况时特别有用——这些 provider 的原生结构化输出强制能力只是部分生效。例如,Ollama 的推理类模型(reasoning models)可能会输出一段纯文本的推理过程,而不是 JSON。详见 Known Limitations 一节。
白话解读
useProviderStructuredOutput() 像是"点菜时就跟厨师说清楚要什么形状的菜",属于事前约束;validateSchema() 则是"上菜后拿尺子量一量合不合格,不合格就退回去重做",属于事后兜底。两者叠加,事前事后双保险,对付那些"部分听话"的 provider 最稳。
相关章节导航: Structured Output(结构化输出) · Provider-Native Structured Output(厂商原生结构化输出)
相关 Spring 版本文档:
- Stable(稳定版):2.0.1 / 1.1.8 / 1.0.9
- Snapshot(快照版):2.0.2-SNAPSHOT / 1.1.9-SNAPSHOT
Provider-Native Structured Output(厂商原生结构化输出)
📌 原文地址:https://docs.spring.io/java-ai/reference/api/structured-output/native.html 📌 原文版本:Java AI 2.0.1
默认情况下,.entity(…)(实体转换方法)会把 JSON schema(JSON 结构描述)当作文本指令塞进 prompt(提示词)里。这是"响应端"的做法:让模型照做,事后再去解析它的回答。
另一种思路是"请求端"约束:直接通过 API 层告诉模型的服务商,输出必须符合某个 schema。现在大多数厂商都支持这一招:OpenAI 的 Structured Outputs、Anthropic 的结构化输出扩展、Gemini 的 responseSchema、Mistral 的 response_format。
Java AI 用 EntityParamSpec(实体参数规约)这个 consumer(回调函数)上的一个开关,把这事做成可移植的写法:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());白话解读
.entity(...) 就像是你对模型说"按这个格式回答我",以前是把格式说明塞在 prompt 里的,现在 useProviderStructuredOutput() 是把这个要求直接告诉模型背后的服务商,让服务商在 API 层强制约束。
到网络请求层面,具体发生了什么变化:
- system prompt(系统提示词)里不再带 JSON 格式说明,更干净,也省 token(模型按字数收费的字)。
- schema 作为 API 层的一个字段直接发给服务商。
- 服务商运行时强制校验,模型想输出不合规的内容根本发不出来。
这样做的好处:
- 更可靠:模型保证输出符合 schema。
- prompt 更干净:不用再追加格式说明。
- 性能更好:模型在内部可以针对结构化输出做优化。
How Java AI Detects Support(Java AI 如何检测支持情况)
Java AI 通过判断模型的 chat options(聊天选项)是否实现了 StructuredOutputChatOptions(结构化输出聊天选项接口)来检测原生支持。如果没有实现,这个开关会被悄悄忽略,调用就退回成默认的"塞 prompt"模式。
Supported Models(支持的模型)
下面这些厂商截至 Java AI 2.0 支持原生结构化输出。不管你接的是哪个,写法都一样,都是 .useProviderStructuredOutput():
- OpenAI:GPT-4o 及之后支持 JSON Schema 的模型。
- Anthropic:Claude 3.5 Sonnet 及之后的模型。
- Google GenAI:Gemini 1.5 Pro 及之后的模型。
- Mistral AI:Mistral Small 及之后支持 JSON Schema 的模型。
- Ollama:支持 JSON Schema 的模型(取决于具体型号,见下面的"已知限制")。
Why It's Off by Default(为什么默认是关闭的)
兼容性。比较老或者不支持的模型直接会拒绝请求,而默认那种"塞 prompt"的做法到处都能跑。
原生结构化输出之所以默认不开启,是因为不同厂商、不同模型的支持差异很大。只在你确实需要更强的 API 层 schema 强制约束时再打开它,并且一定要用你实际使用的模型版本做测试。
Known Limitations(已知限制)
就算厂商宣称支持,原生结构化输出通常也是残缺的——各家接受的 JSON Schema 覆盖面不一样。$ref、深层嵌套数组、allOf / anyOf / oneOf、正则表达式、递归类型,这些都是常见的限制项。这种 schema 漂移造成的输出变形,恰恰就是 validateSchema()(schema 校验)最擅长抓的情况。
Ollama: Model-Specific Instability(Ollama:因模型而异的不稳定情况)
不是所有 Ollama 模型都能稳定遵守结构化输出的 schema 约束。尤其是那些自带 reasoning(推理过程)或"thinking"(思考)模式的模型(比如 qwen3:8b、qwen3.5:9b 以及其他较新的 Qwen 变体),可能会把内部的推理过程以纯文本形式返回,而不是结构化 JSON,导致 BeanOutputConverter(Bean 输出转换器)反序列化时报错,类似下面这种:
StreamReadException: Unrecognized token 'The': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')如果你用 Ollama 时遇到这种情况,可以换个模型试试(比如 llama3.1:latest),或者退回默认的"塞 prompt"方案。你也可以把 useProviderStructuredOutput() 和 validateSchema() 一起用,这样格式不对的响应会自动重试:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class, spec -> spec
.useProviderStructuredOutput() // 启用厂商原生结构化输出
.validateSchema()); // 同时开启 schema 校验,格式不对会自动重试白话解读
简单说就是有些 Ollama 模型脑子太活跃,还没回答就先自己"想半天",而它把这段思考过程直接当成最终答案吐出来了。换个乖一点的模型,或者让它重写一遍,基本就能解决。
OpenAI: No Top-Level Array Support(OpenAI:不支持顶层数组)
OpenAI 的 Structured Outputs API 不接受把顶层 JSON 数组当作响应 schema(参见 OpenAI 社区讨论)。在原生结构化输出开启的情况下,请求 List<T> 会直接 API 报错。
// 这个写法在 OpenAI 原生结构化输出下是不行的:
List<ActorsFilms> films = chatClient.prompt()
.user("Generate filmographies for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {},
spec -> spec.useProviderStructuredOutput()); // 在 OpenAI 上会失败换成下面这两种替代方案:
// 方案一:用一个容器 record(记录类)把列表包起来
record FilmographyList(List<ActorsFilms> films) {}
FilmographyList result = chatClient.prompt()
.user("Generate filmographies for Tom Hanks and Bill Murray.")
.call()
.entity(FilmographyList.class, spec -> spec.useProviderStructuredOutput());
List<ActorsFilms> films = result.films();
// 方案二:退回默认的"塞 prompt"方案(不需要原生输出)
List<ActorsFilms> films = chatClient.prompt()
.user("Generate filmographies for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});默认的"塞 prompt"流程没有这种限制——不开 useProviderStructuredOutput(),顶层数组完全没问题。
白话解读
说白了 OpenAI 那边就是"我不允许你直接让我输出一个数组,你得给我包一层"。所以要么自己加一层包装,要么就别用原生结构化输出。
Enabling Globally(全局开启)
useProviderStructuredOutput() 是一个 per-call(每次调用)的开关。如果想让某个 ChatClient 上的每一次调用都启用原生结构化输出,可以设置 AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT(开启原生结构化输出的 Advisor 参数)这个 advisor 参数——既可以在 builder(构建器)上设为默认,也可以每次请求单独指定:
// 单次请求指定
ActorsFilms films = chatClient.prompt()
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) // 通过 advisor 参数启用原生结构化输出
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class);
// 在 builder 上全局生效
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultAdvisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) // 把原生结构化输出设为全局默认
.build();
}Provider Built-In JSON Mode(厂商自带的 JSON 模式)
和 useProviderStructuredOutput() 互相独立,有一些 AI 模型还专门提供了配置项来直接生成结构化(一般是 JSON)的输出:
OpenAI Structured Outputs 可以确保模型严格按照你提供的 JSON Schema 生成响应。可以在
JSON_OBJECT(合法 JSON)和带 schema 的JSON_SCHEMA之间二选一(对应spring.ai.openai.chat.response-format配置项)。Ollama 提供了一个
spring.ai.ollama.chat.format配置项来指定响应格式。目前唯一可接受的值就是json。Mistral AI 提供了一个
spring.ai.mistralai.chat.response-format配置项。把它设成{ "type": "json_object" }就开启 JSON 模式;设成{ "type": "json_schema" }并配上 schema,就启用跟你 schema 匹配的原
信息
本页涉及 Schema Validation & Self-Correction(schema 校验与自我修正)和 Output Converters(输出转换器)相关章节,可在 Java AI 参考文档的 Structured Output(结构化输出)部分继续阅读。
Output Converters(输出转换器)
📌 原文地址:https://docs.spring.io/java-ai/reference/api/structured-output/converters.html 📌 原文版本:Java AI 2.0.1
OUTPUT CONVERTERS(输出转换器概述)
白话解读
高层级的 .entity(…) API 是基于 StructuredOutputConverter(结构化输出转换器)这个底层抽象封装的。平时写代码基本用不到这个底层 API,啥时候才需要直接用它呢?大概有这么几种情况:
- LLM(大语言模型)返回的 JSON 被 markdown 代码块包裹了,自带解析器搞不定;
- 想让模型输出 YAML、CSV 这种非 JSON 的格式;
- 在低层
ChatModelAPI 里直接用转换器。
Java AI 的 Structured Output Converters(结构化输出转换器)负责把 LLM 的输出转成结构化格式。看下面这张图你就明白了:这种玩法是围绕 LLM 的文本生成接口展开的。

转换器在调用 LLM 前后都会发挥作用。调用前,它会把"格式说明"拼到提示词里,引导模型按指定结构输出;调用后,它负责把模型返回的文本解析并映射成目标类型的实例。
注意
StructuredOutputConverter 只是"尽力而为"地把模型输出转成结构化结果,AI 模型不一定真的会乖乖按要求返回。所以最好配合 schema(数据模式)校验一起用,确保输出符合预期。
相关信息
StructuredOutputConverter 不用于 LLM Tool Calling(工具调用)功能,因为工具调用天生就是结构化输出。
STRUCTURED OUTPUT API(结构化输出 API)
StructuredOutputConverter 接口让你能从文本形式的 AI 模型输出里拿到结构化结果,比如映射成 Java 对象或者一组值。接口定义长这样:
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
/**
* 返回 LLM 调用结构化输出对应的 JSON schema;
* 如果没有就返回 NO_JSON_SCHEMA(也就是空字符串 "")。
*/
default String getJsonSchema() {
return NO_JSON_SCHEMA;
}
}它同时继承了 Spring 的 Converter<String, T> 接口和 FormatProvider 接口:
public interface FormatProvider {
String getFormat();
}下面这张图展示了使用结构化输出 API 时的数据流转过程。

FormatProvider 负责给 AI 模型提供具体的格式说明,让模型输出的文本能通过 Converter 转换成指定的目标类型 T。下面是一段格式说明的示例:
Your response should be in JSON format.
The data structure for the JSON should match this Java class: java.util.HashMap
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.格式说明一般通过 PromptTemplate(提示词模板)追加到用户输入的末尾,套路是这样的:
StructuredOutputConverter outputConverter = ...
String userInputTemplate = """
... user text input ....
{format}
"""; // 用户输入模板,里面留了个 {format} 占位符
Prompt prompt = new Prompt(
PromptTemplate.builder()
.template(this.userInputTemplate)
.variables(Map.of(..., "format", this.outputConverter.getFormat())) // 把 {format} 占位符替换成转换器提供的格式说明
.build().createMessage()
);Converter<String, T> 负责把模型输出的文本转换成指定类型 T 的实例。
THE ROLE OF GETJSONSCHEMA()(getJsonSchema() 的作用)
白话解读
getJsonSchema() 是 Java AI 2.0 新加的默认方法,相当于转换器跟"可靠性开关"之间的桥梁。"可靠性开关"是啥?指的就是 useProviderStructuredOutput()(使用模型原生结构化输出)和 validateSchema()(校验 schema)这两个功能。如果你重写它返回自己的 schema(通常委托给 BeanOutputConverter),那这两个开关就能正常工作;如果你保留默认实现,那这两个开关对这个转换器来说就等于没生效。
getJsonSchema() 是 Java AI 2.0 加在 StructuredOutputConverter 上的默认方法,是让转换器能够参与 useProviderStructuredOutput() 和 validateSchema() 的桥梁。你可以让它返回自己的 schema(一般委托给 BeanOutputConverter),这样两个开关就能正常工作;保留默认实现的话,这两个开关对这个转换器来说就是 no-op(无效操作)。
AVAILABLE CONVERTERS(可用的转换器)
Java AI 内置了以下几种实现:AbstractConversionServiceOutputConverter、AbstractMessageOutputConverter、BeanOutputConverter、MapOutputConverter 和 ListOutputConverter。

AbstractConversionServiceOutputConverter<T>:提供了一个预配置好的
GenericConversionService(通用类型转换服务),用来把 LLM 输出转成想要的格式。它没有默认的FormatProvider实现。AbstractMessageOutputConverter<T>:提供了一个预配置好的
MessageConverter(消息转换器),用来把 LLM 输出转成想要的格式。它也没有默认的FormatProvider实现。BeanOutputConverter<T>:配置一个指定的 Java 类(比如 Bean)或
ParameterizedTypeReference(参数化类型引用),这个转换器会用FormatProvider实现来引导 AI 模型生成符合 DRAFT_2020_12 JSON Schema 的 JSON 响应,Schema 是根据指定 Java 类推导出来的。之后再用JsonMapper把 JSON 反序列化成目标类的 Java 对象。MapOutputConverter:在
AbstractMessageOutputConverter基础上加了FormatProvider实现,引导 AI 模型生成符合 RFC8259 规范的 JSON 响应。同时实现了转换逻辑,用提供的MessageConverter把 JSON 负载转成java.util.Map<String, Object>实例。ListOutputConverter:继承自
AbstractConversionServiceOutputConverter,带了专门处理逗号分隔列表的FormatProvider实现。转换逻辑用提供的ConversionService把模型文本输出转成java.util.List。
USING CONVERTERS(使用转换器)
下面几节分别演示怎么用这些转换器生成结构化输出。每种都会同时展示高层 ChatClient API 和低层 ChatModel API 两种用法。
BEAN OUTPUT CONVERTER(Bean 输出转换器)
下面示例演示怎么用 BeanOutputConverter 生成一位演员的电影作品列表。
目标 record(不可变数据类)代表演员的电影作品:
record ActorsFilms(String actor, List<String> movies) {
}用高层级、流畅的 ChatClient API 这么写:
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
.user(u -> u.text("Generate the filmography of 5 movies for {actor}.")
.param("actor", "Tom Hanks"))
.call()
.entity(ActorsFilms.class);或者直接用低层 ChatModel API:
BeanOutputConverter<ActorsFilms> beanOutputConverter =
new BeanOutputConverter<>(ActorsFilms.class);
String format = this.beanOutputConverter.getFormat();
String actor = "Tom Hanks";
String template = """
Generate the filmography of 5 movies for {actor}.
{format}
""";
// 构造提示词:把模板里的占位符替换成实际值
Prompt prompt = PromptTemplate.builder()
.template(this.template)
.variables(Map.of("actor", this.actor, "format", this.format))
.build()
.create();
// 调用模型并取出生成结果
Generation generation = chatModel.call(prompt).getResult();
// 把模型输出的文本解析成 ActorsFilms 对象
ActorsFilms actorsFilms = this.beanOutputConverter.convert(
this.generation.getOutput().getText()
);PROPERTY ORDERING IN GENERATED SCHEMA(生成 Schema 中的属性顺序)
BeanOutputConverter 支持通过 @JsonPropertyOrder 注解在生成的 JSON Schema 里自定义属性顺序。这个注解让你指定属性在 Schema 里出现的精确顺序,不管它们在类或 record 里声明的顺序如何。
比如,想让 ActorsFilms record 里的属性按特定顺序排:
@JsonPropertyOrder({"actor", "movies"}) // 指定属性顺序:先 actor 再 movies
record ActorsFilms(String actor, List<String> movies) {}这个注解对 record 和普通 Java 类都生效。
GENERIC BEAN TYPES(泛型 Bean 类型)
用 ParameterizedTypeReference 构造器可以指定更复杂的目标类型结构。比如,要表示一组演员和他们的电影作品列表:
用 ChatClient API:
List<ActorsFilms> actorsFilms = ChatClient.create(chatModel).prompt()
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
.call()
.entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});或者用低层 ChatModel API:
BeanOutputConverter<List<ActorsFilms>> outputConverter = new BeanOutputConverter<>(
new ParameterizedTypeReference<List<ActorsFilms>>() { });
String format = this.outputConverter.getFormat();
String template = """
Generate the filmography of 5 movies for Tom Hanks and Bill Murray.
{format}
""";
// 构造提示词模板,把 {format} 占位符替换成转换器提供的格式说明
Prompt prompt = PromptTemplate.builder()
.template(this.template)
.variables(Map.of("format", this.format))
.build()
.create();
// 调用模型拿到生成结果
Generation generation = chatModel.call(this.prompt).getResult();
// 把模型输出解析成 List<ActorsFilms>
List<ActorsFilms> actorsFilms = this.outputConverter.convert(
this.generation.getOutput().getText()
);MAP OUTPUT CONVERTER(Map 输出转换器)
下面代码演示如何用 MapOutputConverter 把模型输出转成一个 Map,里面装着一组数字。
用 ChatClient API:
Map<String, Object> result = ChatClient.create(chatModel).prompt()
.user(u -> u.text("Provide me a List of {subject}")
.param("subject", "an array of numbers from 1 to 9 under they key name 'numbers'"))
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() {});或者用低层 ChatModel API:
MapOutputConverter mapOutputConverter = new MapOutputConverter();
String format = this.mapOutputConverter.getFormat();
String template = """
Provide me a List of {subject}
{format}
""";
// 构造提示词模板,把 subject 和 format 占位符都替换成实际值
Prompt prompt = PromptTemplate.builder()
.template(this.template)
.variables(Map.of(
"subject", "an array of numbers from 1 to 9 under they key name 'numbers'",
"format", this.format
))
.build()
.create();
// 调用模型拿到结果
Generation generation = chatModel.call(this.prompt).getResult();
// 解析模型输出为 Map<String, Object>
Map<String, Object> result = this.mapOutputConverter.convert(
this.generation.getOutput().getText()
);LIST OUTPUT CONVERTER(List 输出转换器)
下面代码演示如何用 ListOutputConverter 把模型输出转成一个冰淇淋口味的列表。
用 ChatClient API:
List<String> flavors = ChatClient.create(chatModel).prompt()
.user(u -> u.text("List five {subject}")
.param("subject", "ice cream flavors"))
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));或者用低层 ChatModel API:
ListOutputConverter listOutputConverter = new ListOutputConverter(new DefaultConversionService());
String format = this.listOutputConverter.getFormat();
String template = """
List five {subject}
{format}
""";
// 构造提示词模板,把 subject 和 format 占位符替换成实际值
Prompt prompt = PromptTemplate.builder()
.template(this.template)
.variables(Map.of("subject", "ice cream flavors", "format", this.format))
.build()
.create();
// 调用模型拿到结果
Generation generation = this.chatModel.call(this.prompt).getResult();
// 解析模型输出为 List<String>
List<String> list = this.listOutputConverter.convert(
this.generation.getOutput().getText()
);CUSTOM CONVERTERS(自定义转换器)
白话解读
内置的 BeanOutputConverter 比较死板,要求模型返回的就是干净的、可以直接解析的 JSON。但模型经常把 JSON 包在 markdown 代码块里输出,比如:
{ "actor": "Tom Hanks", "movies": ["Forrest Gump", "Cast Away"] }BeanOutputConverter 看到第一个字符 "H"("Here's" 里的 H)就直接崩了。一般解法是写个自定义转换器,先把代码块围栏剥掉、提取出 JSON,再委托给默认解析器干活。
内置的 BeanOutputConverter 是个死脑筋:它要求模型的响应必须是可解析的 JSON,没得商量。但模型经常把 JSON 包在 markdown 代码块里:
Here's the filmography:
```json
{ "actor": "Tom Hanks", "movies": ["Forrest Gump", "Cast Away"] }
`BeanOutputConverter` 在 "Here's" 的第一个 H 上就抛异常了。常规解法是写个自定义转换器,先把代码块围栏剥掉、抽出 JSON,再委托给默认解析器:
```java
public class LenientJsonOutputConverter<T> implements StructuredOutputConverter<T> {
// 正则:匹配 ```json 或 ``` 包裹的代码块内容
private static final Pattern FENCE = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");
// 委托给底层的 BeanOutputConverter 做实际解析
private final BeanOutputConverter<T> delegate;
public LenientJsonOutputConverter(Class<T> targetType) {
this.delegate = new BeanOutputConverter<>(targetType);
}
@Override public String getFormat() { return delegate.getFormat(); }
@Override public String getJsonSchema() { return delegate.getJsonSchema(); } // 委托,让可靠性开关继续工作
@Override
public T convert(String source) {
// 匹配代码块围栏,命中就取中间内容,否则就用原始文本
var matcher = FENCE.matcher(source);
String json = matcher.find() ? matcher.group(1).trim() : source.trim();
return delegate.convert(json);
}
}传给 .entity(…) 时直接用自定义转换器实例,而不是 Class:
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(new LenientJsonOutputConverter<>(ActorsFilms.class));因为这个转换器把 getJsonSchema() 委托给了底层的 BeanOutputConverter,两个可靠性开关还能正常工作 —— validateSchema() 和 useProviderStructuredOutput() 用的还是默认转换器同款 schema。详见 "The Role of getJsonSchema()"。
NON-JSON FORMATS(非 JSON 格式)
白话解读
JSON 搞不定的场景(比如配置生成器用的 YAML、数据提取用的 CSV),就得从零实现 StructuredOutputConverter:自己写 getFormat() 的提示词、自己写 convert(…) 的解析器。getJsonSchema() 保持默认实现就行,那两个可靠性开关会主动跳过 —— 走的是跟内置转换器一样的"基于提示词"的路径。
JSON 搞不定的格式 —— 配置生成器要的 YAML、数据提取要的 CSV —— 就得从零实现 StructuredOutputConverter:自己写 getFormat() 的提示词、自己写 convert(…) 的解析器。getJsonSchema() 保持默认实现就好,那两个可靠性开关会主动跳过 —— 跟内置转换器一样走"基于提示词"的路径。