
做了几年Spring服务端最难受的对接不是写CRUD而是接大模型输出。模型回答一堆流利白话前端却要一个规整的JSON字段两边互相折磨。直到我在Spring Boot 3项目里真正用上Spring AI的结构化输出能力才觉得这条路终于顺了。这篇就围绕Spring Boot 3集成Spring AI实现结构化输出展开不讲虚的把三种实现方式、底层原理、完整可跑的示例代码以及我实测中踩过的坑全部放出来。不管你是刚接触Spring AI的Java后端还是已经在项目里试过但被JSON解析折磨过的开发者这篇应该都能帮你省下不少时间。1. 先搞清楚Spring AI的“结构化输出”到底解决什么问题1.1 没用结构化输出之前的痛苦先说个最常见的业务场景。假设系统里有一个功能用户输入菜名AI返回一份包含食材、步骤、烹饪时长的菜谱。在没有做结构化输出的时候通常的做法是让模型“用JSON返回”然后代码拿到字符串后用Jackson去解析。听起来很简单实际跑起来全是问题。模型第一次返回的是标准JSON第二次多了一句“好的这是您要的菜谱”开头第三次整个变成了Markdown格式的代码块包裹第四次字段名从cookingTime变成了cooking_time。前端拿到这些乱七八糟的结果轻则解析失败重则页面直接崩掉。这不是模型笨而是你根本没告诉它“必须严格遵守什么样的输出框架”。自然语言模型天生倾向于用自然语言回答你口头说“给我JSON”它只能靠猜测理解你的意思。而且不同的模型、不同的上下文长度、不同的提示词写法都会影响输出的稳定性。1.2 结构化输出在Spring AI里的实现路径概览Spring AI从设计之初就考虑到了这个问题。它提供了BeanOutputConverter和基于JSON Schema的类型约束机制把“让模型输出符合我们Java类型定义的内容”这件事封装成了几行代码就能完成的工作。简单说你定义一个Java record告诉Spring AI“我要这个类型的结果”模型就会收到一份详细的JSON Schema约束然后严格按照这个结构返回。这样做有三个直观的好处输出格式由你的Java类型定义驱动不再依赖人肉写提示词返回结果可以直接用Spring AI自带的转换器转成Java对象省掉手动解析字段缺失、类型错误这类问题能更早暴露而不是等前端报错才回头查Spring AI在1.0.0版本中把结构化输出作为核心能力之一1.0.0-M系列版本就开始提供稳定API。到了1.0.0正式版JsonSchema注解、BeanOutputConverter这些组件已经相当成熟和Spring Boot 3.2以上版本的配合也很顺。我在写这篇文章时主推的是Spring AI 1.0.0及后续版本如果你项目里用的是更早的M系列快照版本API命名上可能会有差异。2. 快速验证三种拿到“干净JSON”的方式2.1 最朴素的路径直接让模型按约定输出JSON先看最基础的做法虽然不推荐直接上生产但理解它有助于看懂Spring AI帮你省掉了哪些麻烦。String prompt 请根据菜名生成菜谱只输出JSON不要输出任何解释文字。 JSON格式如下 { name: 菜名, ingredients: [食材1, 食材2], steps: [步骤1, 步骤2], cookingTimeMinutes: 30 } ;然后你用一个普通的ChatClient或者ChatModel去调用拿到字符串后自己解析String response chatModel.call(prompt); ObjectMapper mapper new ObjectMapper(); Recipe recipe mapper.readValue(response, Recipe.class);这种方案在小规模试验或者内部工具里能用但坑非常明显模型稍微多输出一个词解析就失败。而且你把格式说明写在提示词里每个需要结构化输出的接口都要复制粘贴一段格式描述维护起来很痛苦。2.2 Spring AI 1.0推荐的方案BeanOutputConverter Prompt模板Spring AI的BeanOutputConverter就是冲着上面这些坑去的。它的思路是把你的Java类型转换成模型能理解的格式描述拼进提示词里模型输出后再用同一个转换器把字符串解析回Java对象。看一段实际代码BeanOutputConverterRecipe converter new BeanOutputConverter(Recipe.class); String promptTemplate 请根据用户提供的菜名生成一份详细菜谱。 菜名{dishName} 输出要求{format} ; PromptTemplate promptTemplateObj new PromptTemplate(promptTemplate, Map.of(dishName, 宫保鸡丁, format, converter.getFormat())); Prompt prompt new Prompt(promptTemplateObj.createMessage()); ChatResponse response chatModel.call(prompt); String content response.getResult().getOutput().getText(); Recipe recipe converter.convert(content);converter.getFormat()返回的就是一段包含JSON Schema描述的指令文本它会被注入到提示词的{format}位置。模型看到这段内容后会明白需要输出符合条件的JSON结构。你不需要自己写那些复杂的格式说明类型改一处所有地方跟着变。converter.convert()方法内部用的是Jackson它会把模型返回的字符串反序列化成Recipe对象。这里有个很重要的前提模型必须严格按照Schema输出。Spring AI在这里会提示模型配合但最终输出质量仍然受模型本身能力影响这点后面踩坑部分细说。2.3 JsonSchema注解把类型定义变成约束合同如果不想手动创建BeanOutputConverter再拼提示词Spring AI还提供了另一种更优雅的做法直接在Java类型上声明约束然后作为message消息直接传给模型。public record Recipe( JsonSchema(description 菜名, required true) String name, JsonSchema(description 食材列表, required true) ListString ingredients, JsonSchema(description 烹饪步骤按顺序排列, required true) ListString steps, JsonSchema(description 烹饪总时间单位分钟, required true) int cookingTimeMinutes ) { }调用侧就清爽很多Message userMessage new UserMessage(请生成宫保鸡丁的做法, new Recipe(, List.of(), List.of(), 0)); ChatResponse response chatModel.call(new Prompt(userMessage)); Recipe recipe response.getResult().getOutput().getEntity();这里的关键在于UserMessage的第二个参数。Spring AI收到带类型的消息后会通过OutputMessageConverter把Recipe类型转换成JSON Schema附加在消息中送给模型。模型返回的JSON会被自动转换成Recipe对象连手动调converter.convert()这一步都省了。我自己的偏好是简单场景用BeanOutputConverter更直白一旦要管理多个复杂的嵌套类型JsonSchema注解方式的可维护性优势就非常明显。前者把类型和调用逻辑分开了后者把约束写在数据定义旁边各有各的适用场景。3. 从解析到约束结构化输出背后的工作原理3.1 模型是怎么“看懂”你的格式要求的结构化输出并不是Spring AI独有的概念OpenAI、Anthropic这些模型厂商都支持类似的能力。底层的通用机制是把你想让模型输出的结构描述成一个JSON Schema提供给模型API。你把类型转换成JSON Schema后模型在生成回答时就会受到这个Schema的约束。哪怕是不同厂商的模型只要支持JSON Schema或function calling机制都能返回符合结构的结果。Spring AI实际上是把这个过程抽象掉了你在Java里定义类型它负责转换成模型能理解的Schema再负责把模型输出翻译回Java类型。JSON Schema本身是一种描述JSON数据结构的规范支持字段类型、是否必填、枚举值、嵌套对象、数组等表达。对于Java开发者来说它有点像你定义了一个DTO类只不过这个类同时被模型和你的程序读取。3.2 Spring AI帮你做了哪些事在Spring AI的实现里结构化输出这一整套流程主要被拆成了几块类型到Schema的转换BeanOutputConverter构造时会分析你的Java类型通过Jackson的generateJsonSchema生成对应的JSON Schema。嵌套类型、集合类型、枚举类型都能处理。Schema到提示词的注入把生成的Schema文本格式化后放进提示词中告诉模型“你的回答必须符合这个结构”。模型输出到Java对象的转换模型回答的文本会被提取出来调用Jackson反序列化成目标对象。如果模型返回了多余的文字convert()方法会尝试从中提取JSON片段。类型安全的接口设计ChatResponse的getOutput().getEntity()可以直接返回泛型对象让调用方不需要关心底层字符串处理。这一套设计最让我欣赏的地方是它尽量把脏活累活吸进框架内部。业务代码里你只需要关心“我要什么类型的结果”而不是“模型返回的字符串怎么清洗”。3.3 为什么偶尔还是会解析失败哪怕是用了BeanOutputConverter也不能完全保证每一次都成功。根据我实际跑过的经验失败原因通常集中在几个地方模型本身不支持严格的JSON Schema约束。部分开源模型或者一些偏轻量的模型在训练时没有针对JSON Schema的严格对齐虽然提示词里写了“必须输出JSON”它还是会忍不住输出一段解释性文字。这时候convert()方法会尝试从结果中提取JSON片段但如果模型输出过于离谱照样会抛异常。还有就是枚举类型和record的兼容性问题。Java的record在反序列化时对字段名很敏感如果JSON里的字段名和你定义的不一致反序列化就会失败。Spring AI在生成Schema时用了你的字段名作为约束大多数情况下一致但如果你在模型对话中改了字段名或者模型自己臆造了相似字段就可能对不上。再有就是复杂嵌套对象加集合的场景。我在一个订单分析项目里定义过一个三层嵌套的返回结构模型偶尔会在中间层丢掉一个字段导致最终解析出来的对象某些字段为null。这种问题不报错但一上线就出bug非常隐蔽。4. 完整实战一个可运行的Spring Boot 3 Spring AI项目4.1 工程初始化与依赖配置用Spring Initializr创建一个Spring Boot 3.3.x或3.4.x项目Java版本建议17以上。引入Spring AI的依赖前需要先确认你的Maven仓库配置了Spring仓库地址因为Spring AI的组件并不完全发布在Maven Central。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency /dependencies如果你用的是阿里云的DashScope模型可以换成spring-ai-alibaba相关starter如果用本地Ollama则引入spring-ai-ollama-spring-boot-starter。概念是一样的只是底层模型通道不同。然后在application.yml里配置模型密钥和基础参数spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL:https://api.openai.com} chat: options: model: gpt-4o-mini temperature: 0.2temperature这个参数值得多说一句。结构化输出场景下建议调低温度我个人习惯设成0.1或0.2。温度越低模型输出的随机性越小越容易遵守格式约束。如果业务上确实需要模型有点创造性可以拆成两个接口一个用高温度做生成一个用低温度做格式化不要图省事混在一起。4.2 定义结构化实体与业务Service定义一个菜谱的record类型public record Recipe( JsonSchema(description 菜名) String name, JsonSchema(description 食材列表) ListIngredient ingredients, JsonSchema(description 烹饪步骤) ListString steps, JsonSchema(description 烹饪总时间单位分钟) int cookingTimeMinutes ) { public record Ingredient( JsonSchema(description 食材名称) String name, JsonSchema(description 用量描述如200克) String amount ) { } }再看Service层的写法Service public class RecipeService { private final ChatModel chatModel; public RecipeService(ChatModel chatModel) { this.chatModel chatModel; } public Recipe generateRecipe(String dishName) { BeanOutputConverterRecipe converter new BeanOutputConverter(Recipe.class); PromptTemplate promptTemplate new PromptTemplate( 请为“{dishName}”生成菜谱要求包含食材和步骤。\n{format}, Map.of(dishName, dishName, format, converter.getFormat()) ); ChatResponse response chatModel.call(new Prompt(promptTemplate.createMessage())); String content response.getResult().getOutput().getText(); return converter.convert(content); } }这里设计了一个很关键的点把BeanOutputConverter的创建放在方法内部。因为每次调用都要重新绑定类型和格式信息如果做成类字段共享在多线程环境下虽然不影响正确性但也没有必要。倒是一些耗时操作比如创建ObjectMapper这种便宜对象用局部变量就行不需要过度优化。4.3 写个Controller跑通全流程Controller层很简单RestController RequestMapping(/api/recipes) public class RecipeController { private final RecipeService recipeService; public RecipeController(RecipeService recipeService) { this.recipeService recipeService; } GetMapping(/{dishName}) public Recipe getRecipe(PathVariable String dishName) { return recipeService.generateRecipe(dishName); } }启动项目后访问http://localhost:8080/api/recipes/宫保鸡丁第一次调用会等待模型生成。响应的大致样子{ name: 宫保鸡丁, ingredients: [ { name: 鸡胸肉, amount: 250克 }, { name: 花生米, amount: 50克 }, { name: 干辣椒, amount: 8个 } ], steps: [ 鸡胸肉切丁用料酒、生抽、淀粉腌制15分钟, 热锅冷油下干辣椒和花椒炒香, 加入鸡丁滑炒至变色盛出备用, 调好宫保汁白糖、醋、生抽、淀粉、水搅匀, 锅中留底油倒入料汁煮沸加入鸡丁和花生米翻炒均匀 ], cookingTimeMinutes: 25 }如果返回结果中ingredients里的字段偶尔变成null可以考虑在record的JsonSchema注解加上required true。不过不能完全依赖这个标记部分模型厂商的API对required字段的强制程度不一致最终还是要靠模型能力兜底。5. 上线前必须知道的事踩坑记录与边界建议5.1 枚举、可选字段和嵌套对象的处理结构化实体中如果用到枚举Spring AI的JSON Schema生成是能识别的但有个细节枚举值必须是字符串而且模型返回的值必须和枚举常量完全匹配。比如你定义了enum Level { EASY, MEDIUM, HARD }模型如果返回easy小写反序列化时就会报错。我一般这样规避在record里用String接收再写一个转换方法映射到枚举。虽然多一步但容错性提升明显。可选字段的处理也需要注意。Java record的字段没有默认值如果模型输出里没包含某个可选字段反序列化时就会得到null。对于基础类型如int甚至会在解析时直接抛异常因为null无法赋给int。建议所有可能缺省的字段都用包装类型或String并且在业务层做空值兜底。嵌套对象加集合是目前最容易出问题的组合。比如上面例子里的ListIngredient模型有时候会生成一个数组有时候会生成一个对象导致解析失败。这种问题没有一劳永逸的解法我自己的临时方案是在convert()调用外面包一层异常处理捕获解析失败后重试一次并把上一次的提示词原文和模型输出记录下来方便排查是模型问题还是Schema问题。5.2 兼容性问题不是所有模型都吃这一套我把同一个项目在几个不同的模型间切换做过对比。OpenAI的GPT-4o系列对JSON Schema的遵循程度最高基本不会出错。阿里云的qwen-plus在较新版本上也表现不错。但一些本地部署的量化模型尤其是参数量比较小的即使提示词里明确写了JSON Schema输出还是经常跑偏。针对跑偏的情况我的处理策略是分两层第一层是重试。捕获JsonProcessingException后重试一次有时候模型只是偶发抽风第二次会正常。第二层是兜底。重试还失败的话返回一个默认对象或者明确的错误信息给前端而不是让请求直接500。AI接口的失败率不可能降到零设计好降级逻辑比试图消灭所有异常更实际。5.3 结构化输出的性能与成本影响加了JSON Schema约束后模型的输出会严格很多但也带来两个实际代价一是输出token数会变多。因为要生成符合Schema的完整JSON结构比直接回答一段话要长出不少成本自然上升。如果接口被高频调用建议在提示词里明确要求“不要输出多余的解释文字”能省一些token。二是响应的首字延迟会变高。尤其是使用流式输出时结构化输出的流式处理要更小心。Spring AI目前对流式场景下的结构化输出支持程度不一如果你用的是stream()方法需要注意最终返回的可能是拼接好的完整文本而不是一个个实体对象。对于要求实时性的场景我目前的做法是普通对话接口走流式结构化数据接口走非流式。这样既保证了聊天体验又简化了结构化处理的逻辑。最后一个建议如果你还没有升级到Spring AI 1.0.0尽量别停留在老版本上。1.0.0之前的M系列版本里BeanOutputConverter的包路径和API都有调整网上的教程和实际代码经常对不上。升级之后按照最新官方文档里的示例去写基本不会踩到那些遗留坑。2.0版本目前还在迭代中等正式版发布后结构化输出这块应该会更强到时候再实测一轮。