
做Java后端的人多半都有过这种体验大模型回答了一长串“看起来挺像样”的内容但当你准备用ObjectMapper去解析时不是多了一个字段就是少了一个引号更“贴心”的还会给你包一层Markdown代码块。Spring AI里的结构化输出Structured Output就是专门收拾这个烂摊子的。它的思路其实不复杂把Java类型翻译成大模型能看懂的JSON Schema塞进Prompt里约束它按格式输出最后再用Jackson把返回结果反序列化回业务对象。这篇文章我会先把这套机制在底层是怎么串起来的讲透再用一个客户反馈信息抽取的例子把代码完整过一遍最后把实际项目里容易踩的坑整理成速查表。不管你是刚开始接触Spring AI还是已经被自由文本输出折磨了一阵子这篇应该都能帮到你。1. 没有结构化输出时你被大模型“坑”过几次1.1 手写Prompt Jackson解析的翻车现场先说一个最常见的场景你想让模型从一段客户反馈里抽取“问题类型、情绪、涉及模块”三个字段。很多人第一版代码是这么写的——在Prompt里写一句“请返回JSON格式包含type、sentiment、module三个字段”然后调用模型拿到字符串之后丢给ObjectMapper去解析。看起来没什么问题但线上跑起来就会遇见各种奇奇怪怪的返回。我见过模型在JSON前面加一句“好的以下是抽取结果”也见过字段名从type变成Type还见过枚举值明明是BUG模型偏偏给你写一个“bug”或者“系统缺陷”。如果你把Prompt写得再复杂一点比如要求嵌套对象、列表、枚举映射手写Prompt基本就失控了——你不是在写代码你是在跟模型“商量”输出格式而模型每次商量的结果都不一样。更麻烦的是就算模型这次老实了你的Prompt换个措辞、换个模型版本输出格式又可能漂移。这种“碰运气”式的接入方式在开发环境能用一上生产就变成事故现场。真正的解法不是“把Prompt写得更狠”而是让框架在模型输入侧和输出解析侧同时做约束。1.2 Spring AI的结构化输出到底由哪几块组成Spring AI把“让模型输出Java对象”这件事拆成了三个独立环节这也是它比手写方案稳的根本原因。类型到Schema的转换框架通过JsonSchemaGenerator把你定义的Java类型POJO、record、枚举、集合扫描一遍自动生成一段JSON Schema描述。这个Schema不是给人看的是给模型看的“答题模板”。输出格式约束Schema生成之后会通过三种途径约束模型——拼进Prompt让模型遵循、开启模型的JSON模式让其只输出合法JSON、或者开启Strict模式让模型严格按字段约束输出。三者可以组合使用可靠性依次递增。结果反序列化模型返回的字符串最终会被BeanOutputConverter或StructuredOutputConverter接住用Jackson反序列化成你的目标类型。注意这里的“目标类型”是Java类型不是JSON Schema所以涉及泛型信息传递的问题。把这三个环节交给框架之后你的业务代码只需要关心两件事定义好目标Java类型以及把转换器配置好。Prompt拼接、格式校验、字符串清洗这些脏活都被框架收走了。这也是Spring AI结构化输出最核心的价值——它不是在“帮你说服模型”而是在工程上把模型输出变成可信赖的程序输入。2. 原理剖析三段链路把自由文本变成Java对象2.1 JsonSchemaGenerator把Java类翻译成模型能懂的“答题模板”先说第一个环节也是最容易被忽略的类型到JSON Schema的翻译。为什么要走JSON Schema这一层而不是直接把Java的toString()或者字段名列表丢给模型因为大模型在训练阶段见过海量的JSON Schema它天然理解$schema、type、properties、required这些关键字的意思。你给它一段规范的Schema它就明白“你要我输出什么形状的数据”。反过来你给它一段“属性有id、name请按此返回”模型只能靠猜字段一多必然跑偏。Spring AI里负责这个动作的是JsonSchemaGenerator接口默认实现会基于Jackson的JavaType做反射扫描。举个例子如果定义这样一个类public record UserInfo( String name, int age, JsonDescription(用户的会员等级只能从枚举中取值) UserLevel level ) {} public enum UserLevel { FREE, PLUS, PREMIUM }生成器扫描之后会得到类似这样的Schema描述{ $schema: https://json-schema.org/draft/2020-12/schema, type: object, properties: { name: { type: string }, age: { type: integer }, level: { type: string, enum: [FREE, PLUS, PREMIUM] } }, required: [name, age, level] }注意几个细节。第一枚举类型会被映射成enum数组模型看到这个就知道取值只能从数组里选这是约束枚举值最有效的手段。第二JsonDescription注解会被填入字段的描述信息相当于给模型“开小灶”——尤其当字段名有歧义时比如level到底指等级还是层级配上描述它就懂了。第三record和普通POJO都能扫描但record没有无参构造反序列化时需要Jackson的ParameterizedTypeModule支持这一点Spring Boot自动配置里已经处理好了手动使用时要留意。这一层设计得很聪明Schema描述只负责“告诉模型长什么样”不负责“强制模型必须遵守”所以它必须跟后面的输出约束配合使用。2.2 输出约束Prompt硬编码、JSON模式、Strict模式怎么选Schema生成之后接下来要解决“怎么让模型真的按这个Schema输出”的问题。Spring AI以及底层大模型API提供了三档强度的约束它们的可靠性、兼容性和适用场景各不相同。Prompt硬编码把Schema文本直接拼进系统消息或用户消息例如“请严格按照以下JSON Schema输出json ...”。这是最通用的方案几乎所有模型都吃这一套但可靠性最低模型可能忽略或者“自由发挥”。JSON模式底层API开启response_format json_object比如OpenAI的JSON Mode。模型被强制要求输出合法JSON但不保证JSON的字段跟你的Schema完全对齐。它解决的是“JSON语法合法性”问题不解决“字段匹配”问题。Strict模式底层API开启json_schema类型的response_format配合strict: true。模型被强制要求遵循你传入的Schema缺少必填字段、多出未知字段都会被判定为失败。这是目前可靠性最高的一档但只有部分模型支持而且开启后对Schema的格式有严格要求——比如所有字段必须标记为required。三种方式的取舍我平时是这么判断的如果能确认底层模型支持Strict模式优先用Strict不确定的就用JSON模式 Prompt硬编码兜底本地部署的模型或者一些国内模型API对response_format支持参差不齐那就老老实实用Prompt硬编码然后用后置校验和重试兜底。没有哪一种方案是“绝对安全”的真正稳的组合通常是“Prompt约束 API模式 业务校验”三层叠加。这里有一个容易被坑的点Strict模式下Schema里的字段默认都得是必填的Spring AI在生成Schema时可能不会自动帮你把字段都塞进required数组所以如果你手动写JSON Schema传给OpenAI得自己把所有字段名填进required否则API会直接报错模型根本不会开始生成。2.3 convert反序列化类型擦除这个地方最容易翻车最后一步就是把模型返回的JSON字符串变成Java对象。BeanOutputConverter.convert()内部的逻辑其实很简单核心就是一行代码的事return objectMapper.readValue(text, javaType);但为什么很多人用的时候翻车问题大多出在javaType上。Java的泛型存在类型擦除如果你直接new BeanOutputConverter(List.class)Jackson只知道目标是List却不知道List里面装的是什么类型反序列化出来要么是一堆LinkedHashMap要么直接报错。Spring AI的BeanOutputConverter构造函数有多个重载分别接收Class、JavaType和Type。建议在涉及泛型的场景下用Jackson的TypeFactory显式构造类型JavaType javaType TypeFactory.defaultInstance() .constructCollectionType(List.class, FeedbackRecord.class); BeanOutputConverterListFeedbackRecord converter new BeanOutputConverter(javaType);另一种更省事的办法是使用匿名子类让编译器帮你保留泛型信息BeanOutputConverterListFeedbackRecord converter new BeanOutputConverter() {}; // 通过父类泛型获取Type基于反射获取到的Type里实际携带了泛型参数Jackson就能正确解析出ListFeedbackRecord而不是裸的List。还有一个很多人没想明白的点JSON Schema跟Jackson的JavaType是两套东西。Schema用于约束模型输出JavaType用于解析字符串。不要试图让它们完全“一一对应”因为Schema里可能包含format、description这类Jackson根本不认识的扩展关键字而JavaType里又可能包含record、Optional这类Schema里没有的语义。Spring AI把它们拆开处理恰恰是为了各司其职。3. 代码实战做一个客户反馈结构化抽取3.1 先定义目标类型record 枚举 嵌套结构理论知识说完了下面进入实战。我拿一个特别常见的业务场景举例把一段客户反馈文本抽取成结构化的反馈工单。目标类型设计成下面这样public record FeedbackRecord( String summary, FeedbackCategory category, Sentiment sentiment, ListString keywords, JsonDescription(面向产品团队的一句话改进建议) String suggestion ) {} public enum FeedbackCategory { BUG, PERFORMANCE, USABILITY, FEATURE_REQUEST, OTHER } public enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE }字段不多但覆盖了核心数据类型字符串、枚举、ListString还带了一个描述注解。summary是给后续人工审核看的摘要keywords可以用于标签聚合suggestion直接对接产品需求池。在真实项目里你还可以继续加嵌套DTO比如ContactInfo contact这种Spring AI的Schema生成器会递归扫描进去原理跟上面一样。3.2 最小可用版本BeanOutputConverter ChatClient先写一个最小可用的版本让你直观感受完整链路。Service public class FeedbackExtractionService { private final ChatClient chatClient; public FeedbackExtractionService(ChatClient.Builder builder) { this.chatClient builder.build(); } public FeedbackRecord extract(String rawText) { // 1. 创建转换器目标是 FeedbackRecord BeanOutputConverterFeedbackRecord converter new BeanOutputConverter(FeedbackRecord.class); // 2. 把 Schema 描述拼进 prompt String prompt 从下面的客户反馈中提取结构化信息并严格按给定的格式输出JSON。 客户反馈 %s 输出格式 %s .formatted(rawText, converter.getFormat()); // 3. 调用模型 String response chatClient.prompt() .user(prompt) .call() .content(); // 4. 反序列化为目标对象 return converter.convert(response); } }这段代码看起来短但每一步都有讲究。converter.getFormat()返回的不只是JSON Schema还附带了一段类似“Your response should be a JSON object with the following structure...”的说明文本。把它拼进用户消息模型就同时接收到了“JSON格式约束”和“字段语义约束”。为什么用ChatClient而不是老的ChatClient模板或OpenAiChatClientSpring AI 1.0之后ChatClient是官方主推的流式API入口语义清晰、支持链式调用。这里用builder.build()创建了一个无状态的ChatClient如果应用里有多个模型配置可以按需指定model比如builder.defaultOptions(OpenAiChatOptions.builder().model(gpt-4o-mini).build())。3.3 抽取一个列表泛型和TypeFactory的正确用法单条抽取没问题之后下一个常见需求是把一批反馈批量抽取成列表。这里就踩到了类型擦除的坑如果你写成BeanOutputConverterListFeedbackRecord而没有正确处理泛型解析时大概率得到一堆Map而不是FeedbackRecord。正确写法是显式构造CollectionTypepublic ListFeedbackRecord extractBatch(ListString rawTexts) { JavaType targetType TypeFactory.defaultInstance() .constructCollectionType(List.class, FeedbackRecord.class); BeanOutputConverterListFeedbackRecord converter new BeanOutputConverter(targetType); String prompt 请依次从下面的多条客户反馈中提取结构化信息返回一个JSON数组。 每条反馈对应数组中的一个对象顺序保持一致。 客户反馈列表 %s 输出格式 %s .formatted( String.join(\n---\n, rawTexts), converter.getFormat() ); String response chatClient.prompt() .user(prompt) .call() .content(); return converter.convert(response); }注意TypeFactory.defaultInstance()是Jackson的静态工厂constructCollectionType接受两个参数集合类型和元素类型。这样构造出的JavaType里完整保留了泛型信息后面convert()才能准确反序列化。在真实项目里我不建议一次性把几十条反馈塞给模型因为输出token会暴涨、超时风险高、JSON数组也可能中途截断。更稳妥的做法是分组分批每批5到10条解析成功后合并结果。这在工程上叫“分而治之”对结构化输出同样适用。3.4 升级到官方StructuredOutput API注意版本差异Spring AI版本迭代很快早期用BeanOutputConverter手动拼Prompt后来官方推出了更上层的结构化输出API。在1.0.0-M6之后的版本里JsonSchemaGenerator、StructuredOutput这些类开始登场ChatClient也增加了对结构化输出的直接支持。大致的写法是StructuredOutput structuredOutput new StructuredOutput.Builder() .jsonSchemaGenerator(new JsonSchemaGenerator()) .build(); String response chatClient.prompt() .user(从反馈中抽取...) .options(OpenAiChatOptions.builder() .responseFormat(new ResponseFormat.JsonSchema( new JsonSchemaGenerator().generate(FeedbackRecord.class) )) .build()) .call() .content();这段代码我特意没给全因为Spring AI各版本的API差异实在太大了有的版本叫structuredOutput()有的版本叫jsonSchemaGenerator()有的选项叫responseFormat有的叫structuredOutputs。我的建议是先确认你项目里的Spring AI版本然后以该版本的Javadoc和官方示例为准。核心思路是一致的——框架负责生成Schema、设置模型API的输出格式、再在返回后反序列化只是API形态在不同版本里略有差别。如果你用的是稳定版1.0.x可以优先看官方文档里“Structured Output”一章那里面针对OpenAI和Ollama等不同模型商都给了示例。原理懂了以后换API形态只是查文档的事。4. 参数调优与容错设计照着抄就行4.1 temperature、maxTokens这些参数为什么影响结构化输出很多人在研究结构化输出时只盯着Prompt和Schema忽略了模型参数的影响这里面的坑其实很大。先说temperature。结构化抽取任务本质上是“从文本里找信息并映射到固定字段”这是一个高确定性任务随机性越小越好。我建议把temperature调到0到0.2之间。如果你用默认值1.0模型可能会在枚举值附近“发挥”比如该输出NEGATIVE的时候给你来一个negative该输出BUG的时候来一个bug。虽然可以靠后置清洗救回来但凭空多了一堆兼容逻辑。把温度调低这类漂移会少很多。再说maxTokens。结构化输出的JSON里包含大量字段名、引号、花括号这些“标点符号”都会消耗token。如果maxTokens设小了模型可能在JSON中途被截断返回一个不完整的字符串转换器直接反序列化失败。估算token时不要只算文本内容的长度还要把JSON包装的成本算进去。一般来说一条20个字段以内的记录建议maxTokens不低于500如果要返回数组按记录数乘以系数再加余量。还有topP跟temperature作用类似二选一调整即可不要两个同时拉低否则输出会变得机械且可能影响Schema中的语义理解。我的习惯是固定topP1只动temperature参数少一个就少一个变量。4.2 模型不支持JSON模式时用预处理和重试兜底现实世界不是所有模型都支持response_format尤其一些本地部署模型、开源模型、或者兼容接口做了一半的厂商。这种情况下结构化输出就得靠“Prompt硬编码 后置清洗 重试”三板斧扛下来。先说后置清洗。模型返回的文本可能包了Markdown代码块或者前后多了几句解释。BeanOutputConverter.convert()内部不会帮你做这些清理它直接丢给Jackson所以解析失败是常有的事。我自己写过一个简单的预处理方法public static String cleanJsonText(String modelOutput) { String text modelOutput.trim(); // 去掉 markdown 代码块围栏 if (text.startsWith()) { text text.replaceAll(^(?:json)?\\s*, ) .replaceAll(\\s*$, ); } // 去掉常见的前缀解释 int jsonStart text.indexOf({); int jsonEnd text.lastIndexOf(}); if (jsonStart 0 jsonEnd jsonStart) { text text.substring(jsonStart, jsonEnd 1); } return text; }这个方法不完美但对绝大多数“模型不听话”的情况够用。用的时候注意先清理再交给converter.convert()能少遇到一半的解析异常。再说重试。如果清洗之后还是解析失败多半是模型输出严重跑偏这时候单纯清洗已经救不回来了。我建议做一次“带错误信息的重试”把上一次的输出和报错信息一起回传给模型要求它修正后重新生成。这个思路跟LangChain里的OutputFixingParser类似核心公式是“原始输出 错误信息 修正后的输出”。在Spring AI里实现也很简单——调用失败后捕获异常把异常信息拼进新的Prompt最多再试一次。重试次数别太多模型连续两次都修不对说明Prompt或Schema本身有问题重试再多次也是浪费token。4.3 流式场景下怎么兼顾结构化输出流式输出Streaming是另一个容易让人犯迷糊的地方。很多人以为流式就不能结构化输出其实可以只是要做一次“先收集再解析”。流式返回的是一截一截的token碎片如果对每个chunk尝试解析JSON必然失败——因为你拿到的是半个字符串。正确的姿势是把所有chunk拼接成完整文本等stream()结束之后再交给converter.convert()统一解析StringBuilder collected new StringBuilder(); chatClient.prompt() .user(prompt) .stream() .content() .doOnNext(collected::append) .blockLast(); // 或者用 countDownLatch 等待结束 FeedbackRecord result converter.convert(cleanJsonText(collected.toString()));这里有一个体验上的取舍流式场景本来是为了让用户“边生成边看”但结构化输出恰恰是要把整段JSON都收齐了才能解析所以中间过程只能看到一堆裸JSON没有语义上的实时反馈。如果你的场景是“用户要看流式效果”那结构化输出可能不合适如果你的场景是“后台批量处理前端等最终结果”那用流式纯属增加复杂度直接用非流式call()反而更省事。5. 高频踩坑与排查实录5.1 报错速查表先对号入座下面这些是我在各种项目里见过最多的问题整理成速查表遇到报错先对号入座。报错信息可能原因解决办法UnrecognizedTokenException模型返回了Markdown代码块或前后有解释文本先做文本清洗再convert()重试时告知模型不要输出多余内容InvalidFormatException: Cannot deserialize value of type ... from String模型输出了枚举之外的词或者大小写不一致调低temperature在JsonDescription里给取值示例开Strict模式约束MismatchedInputException: Cannot deserialize value of type ... from Object value模型把对象返回成了数组或者字段类型搞错了检查Schema生成是否正常换用JavaType显式指定泛型UncheckedIOException/JsonParseExceptionJSON字符串中间被截断通常是token不足调大maxTokens减少单次请求的数据量缺少目标字段模型没按Schema输出可能因为required没生效开启Strict模式在Prompt里强调“缺少字段视为失败”convert()返回null模型返回了空字符串或null检查消息是否为空增加兜底默认值或重试报错分两类一类是模型侧输出不规范一类是代码侧类型信息传递不对。先判断是哪一类再对症下药不要上来就重写Prompt。5.2 转换成功不等于数据可用业务校验与重试结构化输出的最大错觉是“转换成功 数据正确”。实际上模型可能把所有字段都填上了但填的内容是错的——比如把FEATURE_REQUEST填到了BUG的位置或者摘要写的是原文引用而不是总结。所以转换完成之后一定要加一道业务校验。我建议至少校验三件事必填字段非空尤其summary不应为空串枚举值在白名单内如果模型输出了未知值直接判定失败嵌套列表不为空但也没有明显异常元素。如果校验不通过走跟4.2一样的重试流程。我自己习惯把“转换 校验 重试”封装成一个方法失败后自动把校验错误信息回传给模型修正最多重试两次。这样业务方拿到的要么是正确的对象要么是明确的失败信号不会出现“半对半错”的数据污染下游系统。5.3 几条实战经验帮你少走弯路最后分享几条从项目里磨出来的经验不一定写在官方文档里但很能影响线上效果。第一字段越少越稳。结构化输出的成功率和字段数量成反比字段越多模型越容易丢三落四。我接手过一个项目最早的抽取目标有二十多个字段线上成功率只有七成后来把字段压到八个、把次要信息改成可选成功率立刻提到九成以上。能不要的字段就不要让模型专注于核心信息。第二枚举值控制在可枚举的范围内。如果你有一个字段想让模型自由发挥比如“摘要”用String如果你希望它限定取值比如“类别”用枚举并且数量控制在十个以内。数量太多的枚举模型很容易混淆相近含义必要时靠JsonDescription给每个枚举加一行说明。第三先拿一条真实数据测再批量跑。结构化输出在不同业务场景里的表现差异很大别指望一套Prompt走天下。先拿几十条历史数据做验证集看看哪些字段经常抽错针对性调整Schema描述和Prompt措辞确认稳定之后再上全量。这个过程很朴素但效果比任何高级配置都明显。我在实际项目里对这些坑深有体会。早期做内容平台的反垃圾审核把一条UGC文本抽取出“内容类型、违规类别、严重程度、处理建议”四个字段看似简单结果模型经常把“广告”归类到“辱骂”折腾了好几个版本才稳定下来。后来总结下来问题不在模型而在我的Schema描述太模糊——枚举值没给语义字段说明不完整模型只能靠猜。把JsonDescription和枚举注释补齐、温度调到0.1之后问题基本绝迹。结构化输出这件事原理不复杂但每一个细节都值得较真。遇到解析失败不要急着骂模型先回头看看你的类型定义给模型讲清楚没有。