
写这篇文章的起因是我在实际项目里用 Spring AI 调大模型时被“非结构化输出”折磨得够呛。模型返回一段带 Markdown 标记的伪 JSON或者夹着几句解释性文字的结果前端解析一锅粥。后来深入研究并测试了 Spring AI 的结构化输出能力发现这套机制确实能省掉大量脏活累活。今天这篇就围绕 Spring AI 结构化输出把原理、代码、踩坑记录一次性讲透适合正在用或准备用 Spring AI 做工程化落地的后端开发者参考。1. 结构化输出到底解决了什么问题1.1 先聊聊大模型输出的“原罪”做过 LLM 应用开发的人都知道大模型的输出本质是“概率生成的文本”它天然不具备稳定的结构。同样是问“帮我解析这条订单张三买了两杯拿铁”模型可能返回一段散文“好的张三购买了2杯拿铁一共消费40元。”一段带 json 标签的代码块“json { ... }”一段 JSON但字段名一会儿是“customerName”一会儿是“客户姓名”这些问题在 Demo 里看起来无所谓但一旦进入真实业务链路——比如要将结果写入数据库、对接前端表格、触发后续工作流——就会变成灾难。你需要在应用层写大量正则、硬编码字符串截取、容错解析逻辑而且每换一个模型或提示词解析逻辑就可能失效。1.2 Spring AI 结构化输出的核心价值Spring AI 的“结构化输出”Structured Output本质是一套“约束 解析”的组合方案。它做三件事在请求阶段通过提示词工程或模型原生能力要求模型只输出指定格式的数据典型是 JSON。在响应阶段利用内置的转换器Converter将模型返回的 JSON 字符串自动映射为 Java 对象Class、Record、Bean。在错误兜底阶段通过格式指令和解析校验尽可能减少解析失败的概率。换句话说你在代码里定义一个 Java 类或 Record模型就“按这个类的结构”给你返回数据Spring AI 自动帮你实例化。从 API 层面看你的代码不再是“接收字符串然后自己解析”而是直接拿到强类型对象后续逻辑干干净净。提示虽然 Spring AI 官方文档对 Structured Output 的称呼有时是“结构化输出”有时也包含 “OutputConverter” 概念但本质上都是同一个能力把大模型从“文本生成器”变成“结构化数据生成器”。1.3 什么人最需要这个能力如果你属于下面几类人Spring AI 结构化输出基本上称得上刚需做 RAG 知识库问答但希望系统只返回“匹配的文档片段列表 相关度评分”而不是一段上下文混乱的散文。做信息抽取类应用例如从合同中抽取甲方、乙方、金额、日期并直接写入数据库。做智能表单、智能客服工单需要模型输出固定字段并交给工作流引擎处理。做数据分析助手希望模型返回标准的 SQL 或图表配置 JSON而不是让用户看一段解释。对入门者来说结构化输出能让你绕开“自己在字符串里做文章”的巨大坑对老手来说它能帮你统一团队内部的大模型调用规范减少因为提示词改动引发的下游格式地震。2. 原理剖析Spring AI 是如何“约束”模型的2.1 核心组件OutputConverter 与 BeanOutputConverterSpring AI 中与结构化输出直接相关的接口是OutputConverter。它有两个主要方法getFormat()返回一段字符串这段字符串会被拼接进发送给模型的提示词中用来“告诉模型你应该输出什么格式”。convert(String text)将模型返回的原始字符串转换成目标类型。常用的实现是BeanOutputConverterT。它接收一个 Java 类型参数比如你定义了一个OrderInfo类然后通过new BeanOutputConverter(OrderInfo.class)创建转换器。调用getFormat()时它内部会做两件事将 Java 类型信息转换成一个 JSON Schema。将 JSON Schema 嵌入一段固定的格式化指令中大致内容是“请严格根据给定的 JSON Schema 输出 JSON不要输出任何其他内容”。public class OrderInfo { private String customerName; private ListString drinkNames; private BigDecimal totalPrice; // getter/setter 省略 }当你在提示词中注入:{format}Spring AI 会自动将getFormat()的返回值填入模型在生成回复时就能看到完整的输出约束。2.2 JSON Schema 的生成机制BeanOutputConverter生成 JSON Schema 的核心思路是使用 Jackson 的相关能力。它拿到你传入的 Java 类型后会分析这个类型的字段、类型、嵌套结构并生成对应的 JSON Schema。例如OrderInfo大致会生成如下结构{ type: object, properties: { customerName: {type: string}, drinkNames: { type: array, items: {type: string} }, totalPrice: {type: number} }, required: [customerName, drinkNames, totalPrice] }这个 Schema 被偷偷塞进提示词后模型在生成答案时会“照着这个格式写”。虽然模型没有真正做 JSON Schema 校验但实验证明显式给出 Schema 比只写“请输出 JSON”的成功率高得多。这里还有个细节值得注意当使用 Java Record 作为目标类型时Jackson 对 Record 的支持需要额外的依赖例如jackson-databind2.12 已支持 Record但某些旧版本或特定环境需要关注。如果你在转换时遇到“cannot construct instance”之类的错误大概率是 Jackson 版本问题。2.3 提示词注入与模型原生能力Spring AI 结构化输出第一版主要依赖“提示词注入”也就是通过{format}这种模板占位符把格式指令贴在对话里。这种方式有一个明显的弱点如果模型本身不擅长遵循格式指令或者指令被用户上下文冲淡输出仍可能偏离。为了弥补这一点Spring AI 在后来的版本中增加了对模型原生 JSON Mode 的支持。例如 OpenAI 的response_format: json_object参数Spring AI 会通过OpenAiChatOptions暴露相关配置OpenAiChatOptions.builder() .withResponseFormat(new OpenAiResponseFormat(json_object)) .build();这种模式下模型被强制约束输出合法 JSON这比单纯提示词约束稳定得多。当然如果你用的是国内模型或者开源模型需要考虑对方是否兼容 OpenAI 的 Response Format不兼容时退回纯提示词模式也能正常工作只是成功率可能略低。2.4 从字符串到 Java 对象的转换链路当模型返回一段文本例如{customerName:张三,drinkNames:[拿铁],totalPrice:40}Spring AI 的调用层会把这段字符串交给OutputConverter.convert()。BeanOutputConverter内部调用 Jackson 的ObjectMapper.readValue(text, javaType)完成反序列化。所以结构化输出的链路是定义目标 Java 类型。使用BeanOutputConverter生成提示词片段。调用模型接口获取响应。将响应字符串通过convert()转为 Java 对象。业务层直接使用该对象。理解这条链路后你会发现一个关键点结构化输出的成败一半在提示词约束一半在类型定义是否合理。3. 代码实战Spring AI 结构化输出完整示例3.1 环境准备与依赖我使用的是 Spring Boot 3.x Spring AI 1.0.0-SNAPSHOT不同版本 API 会有细微差别但整体思路一致。你需要先引入 Spring AI 相关依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-SNAPSHOT/version /dependency然后在application.yml配置模型 key 和基础地址spring: ai: openai: api-key: sk-xxxx base-url: https://api.openai.com chat: options: model: gpt-4o-mini temperature: 0.2注意不同模型的结构化输出成功率差异很大。实测gpt-4o和gpt-4o-mini配合 JSON Schema 约束几乎不会出错一些旧型号如gpt-3.5-turbo偶发非 JSON 文本。生产环境务必选对模型。3.2 定义目标数据结构为了演示我设计了一个更贴近真实业务的场景从一段餐厅点单备注中抽取“顾客姓名、饮品列表、总价、是否堂食、备注”。public record OrderInfo( String customerName, ListString drinks, BigDecimal totalPrice, boolean dineIn, String note ) {}用 Record 的好处是代码简洁不可变性天然适合数据传输。如果你的项目还在用传统 Lombok 风格Class Data也没问题BeanOutputConverter两者都支持。3.3 核心调用代码写一个 Service 方法接收用户输入的一段自然语言描述返回OrderInfo对象Service public class OrderParsingService { private final ChatClient chatClient; public OrderParsingService(ChatClient.Builder builder) { this.chatClient builder.build(); } public OrderInfo parseOrder(String userMessage) { BeanOutputConverterOrderInfo converter new BeanOutputConverter(OrderInfo.class); String prompt 请从以下用户消息中提取订单信息并严格按给定的 JSON 格式输出 用户消息{userMessage} 输出格式要求 {format} ; String response chatClient.prompt() .system(你是一个订单信息抽取助手只能输出 JSON禁止输出解释性文字。) .user(u - u.text(prompt) .param(userMessage, userMessage) .param(format, converter.getFormat())) .call() .content(); return converter.convert(response); } }调用效果如下OrderInfo order service.parseOrder(张三点了两杯拿铁和一块蛋糕一共85元打包带走不要吸管); System.out.println(order);此时order应该是一个完整填充的OrderInfo对象customerName为“张三”drinks包含“拿铁”、“蛋糕”或模型理解后的其他表达totalPrice为 85dineIn为 falsenote为“不要吸管”。3.4 参数选择与调优这段代码中有三个参数值得展开讲temperature我设置为 0.2目的是降低模型随机性让输出尽量可预测。结构化输出场景temperature 不宜超过 0.5否则即使格式约束再强字段内容也可能出现幻觉或偏离。system message我在 system 中明确加了一句“只能输出 JSON禁止输出解释性文字”。这是纯提示词结构化方案的经典补充手段虽然 Spring AI 的 format 指令本身就包含类似要求但多重约束可以进一步提高成功率。converter.getFormat() 的注入位置我放在 user prompt 里并且用{format}占位符。Spring AI 的 PromptTemplate 机制会自动将其替换为完整的格式指令。请格外重视这一步骤。因为我见过不少同学只定义BeanOutputConverter却忘记把getFormat()注入提示词结果模型根本不知道要输出什么格式Converter 自然无法反序列化。3.5 泛型结构与 List 嵌套实际业务中你往往需要返回一个包含多个对象的集合。例如要同时抽取多条订单此时目标类型可以设计为public record OrderBatch(ListOrderInfo orders) {}调用逻辑完全不变BeanOutputConverterOrderBatch converter new BeanOutputConverter(OrderBatch.class); String response chatClient.prompt() .system(你是订单批量抽取助手只能输出 JSON禁止输出解释性文字。) .user(u - u.text( 请从以下文本中抽取所有订单信息{text} 输出格式要求 {format} ) .param(text, userText) .param(format, converter.getFormat())) .call() .content(); OrderBatch batch converter.convert(response);这里有一个易踩的坑Java 泛型擦除。如果你试图写成BeanOutputConverterListOrderInfo由于运行时无法获取ListOrderInfo的类型信息Jackson 只把泛型当List处理反序列化时可能变成ListLinkedHashMap。正确做法是外层再包一层 Record或者使用 Spring AI 的ParameterizedTypeReference来传递泛型类型。我推荐外层包 Record代码最直观也最不容易出错。4. 常见问题与排查技巧实录4.1 模型返回了 Markdown 代码块导致解析失败这是最典型的问题。即使你反复要求“只输出 JSON”某些模型仍会画蛇添足地输出{ customerName: 张三 }Spring AI 的BeanOutputConverter.convert()不会自动帮你剥掉json前缀。在 1.0.0 版本中AbstractMessageConverter处理响应内容时也不会做这种“清理”。我用 map 包了一层应答若解析遇到这种情况先用正则把代码块剥掉再走convert()。一个可行方案是在 Service 层写一个预处理方法private static final Pattern CODE_BLOCK_PATTERN Pattern.compile((?:json)?\\s*(.*?)\\s*, Pattern.DOTALL); private String stripCodeBlock(String raw) { Matcher matcher CODE_BLOCK_PATTERN.matcher(raw); if (matcher.find()) { return matcher.group(1).trim(); } return raw.trim(); }虽然 Spring AI 在后续版本中可能优化了响应清理逻辑但作为防御性设计建议保留这段代码。它不会影响正常 JSON 解析。4.2 字段缺失导致 Jackson 报错模型偶尔会漏掉某个非必填字段特别是当源文本根本没有相关信息时例如订单备注为空模型直接不返回note字段。如果你的 Record 里note没有默认值Jackson 反序列化时会失败。解决方案在 Record 中给可选字段设置默认值public record OrderInfo( String customerName, ListString drinks, BigDecimal totalPrice, boolean dineIn, String note ) { public OrderInfo { note note null ? : note; } }或者用一个宽松的 DTO使用包装类型如String note而非OptionalString在业务层再做空值兜底。我个人更推荐 Record 构造器内兜底。因为 DTO 层直接兜底可以保证下游永远拿到的不是 null省得业务代码到处判空。4.3 枚举类型如何约束如果你想让模型输出固定枚举值例如性别字段只能是MALE或FEMALE直接使用 Java 枚举类型即可。BeanOutputConverter会分析枚举结构并将枚举值列表写入 JSON Schemapublic record UserProfile( String name, Gender gender ) { public enum Gender { MALE, FEMALE, UNKNOWN } }对应 Schema 大约会生成gender: { type: string, enum: [MALE, FEMALE, UNKNOWN] }实测这种方法比在提示词里写“只能输出 MALE 或 FEMALE”靠谱得多。因为 Schema 对模型的约束力很强尤其在使用 OpenAI 系模型时枚举值会直接进入受限输出集合。4.4 大响应体的截断问题当目标对象包含大量字段或超大数组时模型输出可能触达 max_tokens 上限导致 JSON 被截断。此时 Jackson 反序列化一定失败。排查方法很简单先打印response看末尾是否出现经典的截断痕迹例如最后一个属性值只有一半或者缺少结尾}。解决方案提高maxTokens参数。Spring AI 中通过OpenAiChatOptions配置OpenAiChatOptions.builder() .withMaxTokens(2000) .build();将大对象拆成多个小对象分批抽取。这是更工程化的思路。例如将“订单抽取”拆成“抽取订单基本信息”和“抽取订单明细列表”避免一次要模型生成超大 JSON。4.5 使用原生 JSON Mode 后的隐藏陷阱当你启用 OpenAI 的response_format: json_object后模型只会输出 JSON但有一个新的问题如果你在提示词中没有提到“json”字样部分模型版本会返回空对象{}或输出一个无意义 JSON。这是因为 JSON Mode 要求提示词里必须包含“JSON”这个词否则模型不知道你要输出什么类型的 JSON。所以我的建议是启用 JSON Mode 时一定在提示词里保留“JSON”字样例如请以 JSON 格式输出订单信息。Spring AI 的BeanOutputConverter.getFormat()返回的内容本身就包含 “JSON Schema” 等字样基本能满足这个要求。但如果你的提示词是纯中文且没带“JSON”三个字母就有触发该问题的风险。4.6 中文内容反序列化乱码这个问题在 Spring AI 中不是必现但如果你配置了不正确的字符编码或者对响应内容做了额外编码转换就会出现中文乱码。保持默认 UTF-8 通常没问题。排查时直接打印 response 字符串如果原文是正常中文但转换后乱码留意是不是在 Controller 层做了额外的编码处理。一个更隐蔽的问题是模型返回的 JSON 字符串包含转义字符例如张三变成\u5f20\u4e09。这对 Jackson 来说是合法 JSON反序列化后仍是中文所以一般不用额外处理。但如果你要打印日志会看到一堆\uXXXX这是正常的不要误判为乱码。4.7 实体类设计层面的建议最后分享一个我踩过几次坑后总结出的设计原则结构化输出用的 Java 类型要尽量贴近“模型的理解”和“前端的需求”而不是贴近数据库表结构。假设你的数据库表是t_order字段有id、create_time、status但你让模型输出这些字段毫无意义因为模型根本不知道id应该填什么。正确做法是设计一个“抽取专用 DTO”只包含模型能从自然语言中提取的信息public record ExtractedOrder( String customerName, ListString items, BigDecimal totalAmount, String address ) {}拿到这个 DTO 后再由业务层去填充id、create_time、status等系统字段最终完成入库。这样职责清晰也让模型更专注于它擅长的事情——从文本中抽信息而不是猜业务状态。5. 一些进阶扩展与经验总结结构化输出虽然解决了解析问题但它不是银弹。我自己在项目中还做过一些扩展这里一并分享出来算是给你提供几个后续可以继续深挖的方向。方向一对转换结果做二次校验。BeanOutputConverter只保证“能转成 Java 对象”不保证“字段内容一定满足业务约束”。例如总价可能是负数顾客姓名可能为空字符串。我的做法是在 DTO 上做一些简单校验结合 Spring Boot Validation 的Validated一起用。因为 DTO 是 Record可以用 compact constructor 做校验public record OrderInfo( String customerName, BigDecimal totalPrice ) { public OrderInfo { if (totalPrice ! null totalPrice.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(totalPrice 不能为负数); } } }方向二将不同输出结构的转换器做成策略模式。一个成熟的项目往往有多个抽取场景每种场景对应不同的 DTO。把所有BeanOutputConverter全部注册成 Spring Bean然后在调用时根据业务类型选择可以避免反复 new 对象也让代码结构更清晰。Component public class OrderConverter extends BeanOutputConverterOrderInfo { public OrderConverter() { super(OrderInfo.class); } }不过要注意BeanOutputConverter本身是线程安全的可以在多个请求间共享使用。不需要每次都 new 一个。方向三结合 Spring AI 的 Advisor 做统一的格式兜底。官方提供的SafeGuardAdvisor可以在模型响应前做校验如果响应不是合法 JSON它会重试一次或抛出异常。如果你的项目对成功率要求很高可以自定义Advisor在响应链路上增加“检测到非 JSON 就再调一次模型”的重试机制。最后说一个我个人的感受与其在代码层面写一堆“清理 JSON 代码块”的补丁不如在选型阶段就明确模型、明确结构化方案、明确异常兜底策略。Spring AI 的结构化输出并不是把“你不需要理解格式约束”这事变成魔法而是把这套约束从你手写正则的繁琐中抽象出来收敛到类型和转换器上。在简单的信息抽取、RAG 结果格式化、Agent 工具调用参数生成等场景中它带来的开发提效是实打实的但如果你的业务对字段格式有极端严格的校验要求还是建议保留一层显式校验别把全部信任交给模型和转换器。