ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringAI结构化输出实战:从规则解析到JSON对象

2026/9/17 7:27:56 拓冰建站 浏览量
SpringAI结构化输出实战:从规则解析到JSON对象 单独把SpringAI拎出来写一篇“结构化输出”的实战挺应景的。最近在做Java后端接入大模型的项目最烦的就是模型返回一段带Markdown标记的文本你还得拿正则去抠关键字段——抠得想摔键盘。SpringAI这套结构化输出能力说白了就是让模型“按合同办事”你告诉它字段清单和类型它返回干净的JSON程序拿到直接反序列化不用再做文本清洗。这篇文章我从原理讲到代码再把踩过的坑和排查思路一并整理出来希望能帮到正在用SpringAI做项目的朋友。1. 项目概述与核心价值1.1 为什么需要结构化输出大模型本质是一个“文本生成器”它擅长的是对话、总结、翻译这类自由文本任务。但落到企业级应用里程序没法直接“读懂”自然语言比如我需要从一个工单描述里提取设备编号、故障类型、处理优先级这要求模型返回的不是一段话而是一个结构清晰的JSON对象。没有结构化输出你就得自己去解析大模型吐出来的长文本用正则匹配关键词用字符串截取提取序号这方案脆弱得离谱模型稍微换个说法就抓瞎。结构化输出的价值在于让“自然语言”到“程序可消费的数据”之间有一条稳定的、可验证的通道。SpringAI把这条通道封装成了几个开箱即用的OutputConverter我们只管定义好实体类框架负责把格式要求“翻译”成模型能理解的指令再把模型返回的文本还原成对象。这一来一回中间全是脏活框架都替你扛了。1.2 SpringAI结构化输出的适用场景不是每个接入大模型的项目都需要结构化输出但下面这几类场景用了会非常舒服信息抽取类从工单、邮件、PDF文本里抽取固定字段比如客户姓名、身份证号、订单金额。数据映射类把用户输入的自然语言比如“帮我查一下上个月华东区的销售额”转成查询参数对象直接拼SQL或调用其他API。内容分类类让模型对文本打标签、判断情感倾向返回的结果要落到有限枚举值里。工具调用类在Agent场景下模型需要决定调用哪个工具、传什么参数工具的入参就是一个结构化的JSON对象。注意如果你的场景只需要模型写一段没有固定格式的文案那直接字符串返回就行没必要上结构化输出反而画蛇添足。2. 核心原理解析与方案选型2.1 SpringAI结构化输出三种实现方式我在项目里实际使用过程中发现SpringAI提供三种途径可以拿到结构化数据方式核心类适用场景依赖项Bean输出BeanOutputConverterT字段固定的实体类Jackson Description注解Map输出MapOutputConverter字段动态变化事先不确定结构JacksonList输出ListOutputConverter需要返回一组同构数据需要指定元素类型描述三种方式底层思路一致把“要什么样的结构”拼进提示词强制模型按JSON格式返回再用Jackson反序列化验证。区别在于结构确定的程度。Bean方式最严谨字段名、类型、描述都写在实体类里Map方式灵活但失去编译期保障List方式适合“给一段文本找出其中所有商品名称”这种批处理需求。实际项目中如果字段是稳定的业务上90%字段固定务必用Bean方式只有字段确实无法预先设计的场景才退而求其次用Map。2.2 BeanOutputConverter底层工作机制BeanOutputConverter的工作流程很有意思它利用Jackson的AnnotationIntrospector读取实体类上的Description注解把Java类结构转换成一份JSON Schema定义。这个Schema随后会被注入到系统提示词里告诉模型“请严格按以下JSON结构返回”。模型生成完毕converter.convert()方法再调用ObjectMapper把文本反序列化成目标实体类。这一步是能成立的根基是目前主流大模型都对JSON Schema有较好的遵循能力尤其在系统提示词里声明“只输出JSON不要输出任何解释性文字”的情况下。SpringAI只是在“提示词工程”上帮你生成了标准化的格式约束而不是模型本身具备什么魔法。因此理解这条链路对排查问题至关重要——解析不了的时候多数是提示词生成的Schema有歧义或者模型没遵守约束。2.3 为什么用实体类和Record定义结构我在实操里强烈建议用Javarecord来定义输出结构而不是传统class。原因有三record天然不可变反序列化之后不会因为后续代码误操作改变字段值。record语法简洁尤其是字段较多时一行一个参数清晰直观。Jackson从2.12开始对record支持已经很完善SpringAI内部也直接兼容。当然用普通class也能正常工作只是要多写一堆getter/setter还容易遇到无参构造器的问题。我踩过一次坑定义实体类时只写了带参构造器Jackson反序列化直接报错提示无法实例化后来换成record一劳永逸。在定义每个字段时尽量用Description把业务含义写清楚比如“设备编号字符串类型例如SN-2024-001”模型理解得越准确返回数据越规范。3. 实操配置与完整代码实现3.1 环境准备与依赖引入先交代我的工程环境JDK 17Spring Boot 3.3.xSpringAI目前还在快速迭代期我用的是1.0.0-M6版本。不同小版本API可能略有差异但核心的BeanOutputConverter稳定可用。在pom.xml加入核心依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M6/version /dependency这里的spring-ai-openai-spring-boot-starter会带来OpenAI兼容接口的自动配置。如果你的模型服务是其他厂商比如阿里云百炼、DeepSeek、本地化部署的Ollama只要兼容OpenAI的/chat/completions接口都可以通过调整base-url和api-key配置来复用。然后在application.yml里配置spring: ai: openai: base-url: https://api.your-model-service.com/v1 api-key: ${MODEL_API_KEY} chat: options: model: your-model-name temperature: 0.2temperature我建议固定设在0.2以下。结构化输出任务要的是“稳定地填表格”不是“灵感蓬勃地写散文”温度太高模型发挥空间大JSON格式就容易跑偏。实测temperature0.7时格式错误率翻倍不止。3.2 定义结构化输出实体类我用一个最常见的“工单信息抽取”场景来演示。给定一段非结构化的用户描述让模型输出工单的关键字段。import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.Map;实体类定义public record WorkOrderInfo( Description(工单编号字符串类型例如WO-2024-0931如果描述中没有则填null) String orderId, Description(故障类型只能从以下值中选择硬件故障、软件故障、网络故障、未知) String faultType, Description(影响范围描述受影响的业务范围) String impactScope, Description(上报人姓名如果描述中没有则填null) String reporterName, Description(紧急程度只能从以下值中选择高、中、低) String urgency ) {}几个细节值得展开Description别偷懒不写。模型全靠这段描述理解字段含义描述得越具体、枚举范围越明确返回数据越符合预期。对“可能不存在”的字段要在描述里明确“没有则填null”否则模型会自作主张编一个值填进去。对枚举类字段一定要把所有可能值列全。模型不是业务专家你不告诉它只能选哪几个它能给你造出“十分紧急”“一般紧急”这种不在列表里的值。3.3 编写核心服务逻辑接下来是核心的服务层代码Service public class WorkOrderParsingService { private final ChatModel chatModel; public WorkOrderParsingService(ChatModel chatModel) { this.chatModel chatModel; } public WorkOrderInfo parseWorkOrder(String userDescription) { BeanOutputConverterWorkOrderInfo converter new BeanOutputConverter(WorkOrderInfo.class); // 生成带JSON Schema约束的格式指令 String formatInstruction converter.getFormat(); // 组装系统提示词 String systemPrompt 你是一名工单信息抽取助手。请从用户描述中提取工单信息。 只输出JSON对象不要输出任何解释或Markdown代码块标记。 %s .formatted(formatInstruction); PromptTemplate promptTemplate new PromptTemplate(用户描述{input}); MapString, Object params Map.of(input, userDescription); var message chatModel.call( new SystemMessage(systemPrompt), promptTemplate.createMessage(params) ); // 将大模型返回的JSON文本反序列化为实体对象 return converter.convert(message.getContent()); } }如果你想用SpringAI更新的ChatClient流式API写法会更简洁Service public class WorkOrderParsingService { private final ChatClient chatClient; public WorkOrderParsingService(ChatClient.Builder builder) { this.chatClient builder.build(); } public WorkOrderInfo parseWorkOrder(String userDescription) { BeanOutputConverterWorkOrderInfo converter new BeanOutputConverter(WorkOrderInfo.class); return chatClient.prompt() .system(spec - spec.text( 你是一名工单信息抽取助手。请从用户描述中提取工单信息。 只输出JSON对象不要输出任何解释或Markdown代码块标记。 {format} ) .param(format, converter.getFormat())) .user(userDescription) .call() .entity(converter); } }个人更推荐ChatClient这种风格链路清晰entity(converter)直接把返回文本转成对象省去手动调convert()。不过要注意ChatClient在M系列版本刚引入如果你用的还是老版本就退回ChatModel converter.convert()的写法两者都能跑通。3.4 编写Controller并测试效果写一个简单的Controller暴露接口RestController RequestMapping(/api/work-order) public class WorkOrderController { private final WorkOrderParsingService parsingService; public WorkOrderController(WorkOrderParsingService parsingService) { this.parsingService parsingService; } PostMapping(/parse) public WorkOrderInfo parse(RequestBody String description) { return parsingService.parseWorkOrder(description); } }启动服务后用curl模拟一次调用curl -X POST http://localhost:8080/api/work-order/parse \ -H Content-Type: text/plain \ -d 早上9点销售部的张明上报了一个紧急问题说是客户管理系统登录报错影响整个华东销售团队下单工单号是WO-2024-0931。返回结果{ orderId: WO-2024-0931, faultType: 软件故障, impactScope: 华东销售团队下单功能, reporterName: 张明, urgency: 高 }字段抽取准确枚举值也在限定范围里。这个效果已经足够直接落到业务流程里——比如接到工单后根据urgency字段自动决定通知级别根据faultType自动路由到对应处理小组。4. 常见问题与排查技巧实录4.1 模型返回了Markdown代码块导致解析失败这是在实战中最常见的坑。模型名义上遵守了“只输出JSON”的要求但输出里带着json包裹的代码块Jackson解析直接抛JsonParseException。原因是大模型在训练数据里见惯了“Markdown包裹代码”的格式系统提示词一句“不要输出Markdown”在部分模型上约束力不足。我处理这个问题分两步走。第一步在提示词里把约束写得更极端严格输出JSON对象不要添加任何额外字符、注释、代码块标记。 不要输出json。 直接以{开头以}结尾。第二步仍然准备一层兜底清理逻辑因为提示词约束再强也无法100%保证特别是不同模型能力参差public class JsonCleaner { private static final Pattern CODE_BLOCK_PATTERN Pattern.compile((?:json)?\\s*([\\s\\S]*?)\\s*); public static String extractJson(String raw) { if (raw null || raw.isBlank()) { throw new IllegalArgumentException(模型返回为空); } // 第一优先提取代码块内容 Matcher codeBlockMatcher CODE_BLOCK_PATTERN.matcher(raw); if (codeBlockMatcher.find()) { return codeBlockMatcher.group(1).trim(); } // 第二优先找第一个{和最后一个}之间的内容 int firstBrace raw.indexOf({); int lastBrace raw.lastIndexOf(}); if (firstBrace ! -1 lastBrace firstBrace) { return raw.substring(firstBrace, lastBrace 1); } return raw.trim(); } }在convert()之前先调用JsonCleaner.extractJson()格式稳定性提升非常明显。我后来在每个结构化输出服务里都配了这个工具。4.2 字段被模型随意设置为null模型返回的JSON结构完整、语法合法但有些字段值是null而业务上这些字段明明能从输入文本里提取出来。排查后发现是Description写得太简单了比如只写了“工单编号”模型不知道“工单编号”在用户自然语言里对应什么形态的特征。解决方式是把描述写详细给出格式示例和来源说明Description(工单编号格式为WO-数字编号例如WO-2024-0931。从用户描述中查找类似工单号、编号、WO-开头的字符串并提取。若确实没有则填null) String orderId,同时可以在系统提示词里加一句“对于输入中出现的信息必须提取对于没有出现的信息才允许填null”。模型在“被要求提取”和“被允许跳过”之间的边界感需要靠提示词和描述双向加强。4.3 枚举字段返回不在列表里的值如果把urgency定义为String即使你在Description里列了“高、中、低”三个取值模型有时还是返回“紧急”、 “一般”这类不在列表里的词。这不是模型故意捣乱而是它对中文语义的理解比你的枚举范围更“灵活”。最稳妥的方案是用Java枚举类型来定义字段public enum Urgency { Description(高) HIGH, Description(中) MEDIUM, Description(低) LOW }然后实体类里改成Urgency urgency。这样Jackson在反序列化时遇到无法匹配的值会直接抛异常至少能做到“快速失败”不会给业务层塞入脏数据。如果模型经常返回列表之外的枚举值可以减少描述里的语气词直接列出可选项并在系统提示词里强调“只能选择给定的枚举值之一”。4.4 不同SpringAI版本的API差异SpringAI目前版本迭代非常快M3到M6之间API都有调整。比如早期版本用ChatModel.call(new Prompt(List.of(...)))后续版本推荐ChatClient还有版本里converter.getFormat()返回的不是传统JSON Schema而是指令文本。这导致网上教程经常“跑不通”。我的建议是确定一个版本后锁定它不要盲目升级。我在项目里锁定1.0.0-M6所有示例代码基于这个版本验证过。遇到API变化直接查看官方文档的版本迁移说明或者阅读对应版本的源码——BeanOutputConverter这类核心类结构还算稳定直接读源码比百度可靠得多。5. 进阶扩展与踩坑心得5.1 与多轮对话和Agent场景的结合在Agent类场景里可以用结构化输出的能力继续扩展。模型在规划阶段需要决定调用哪个工具、传什么参数。这个参数如果靠模型自己“自由发挥”Agent的行为就完全不可控。我通常在Agent的Tool定义里把参数结构用类似于BeanOutputConverter的方式描述出来让模型严格按照JSON Schema给工具传参。SpringAI的skill agent、以及热搜里提到的agentscope、lang4j这些框架本质都在做同一件事让大模型的输出变成可执行的动作。一方面模型需要输出“调用什么函数、传什么参数”这样的结构化指令这就是结构化输出在Agent侧的典型应用另一方面工具返回的结果也要结构化再喂给模型做下一轮推理。两层加在一起Agent的稳定性才能有保障。如果你只在自己项目里简单使用不引入这些框架也建议至少做到工具入参的定义全部使用Bean方式 Description不要把工具入参设计成自由文本。5.2 我整理的结构化输出经验清单把踩坑经历固化成一张清单方便直接对照影响因素推荐设置原因temperature0 ~ 0.2温度越低输出越稳定结构化任务不需要创造力字段描述写清含义、格式示例、缺失处理模型靠描述理解字段描述越细越准确枚举值用Java枚举类型反序列化能拦截非法值避免脏数据流入业务系统提示词明确“只输出JSON不要解释和代码块”降低Markdown包裹等格式污染概率后处理必须保留JsonCleaner工具提示词约束不是100%可靠必须有一层兜底5.3 一个留了许久的注意事项用结构化输出时要对“字段容忍度”有清醒认知。模型的返回本质上是一个概率采样的结果尽管有格式约束字段值仍然可能出现轻微的措辞变化比如“软硬件故障”和“硬件故障含软件冲突”。如果业务对字段值的精确性要求极高建议增加一层校验逻辑类似DTO的NotNull、Pattern校验让非法数据在入口处就被拦截而不是让问题流到下游。我试过把结构化输出直接接到数据库入库流程第一个版本没做校验结果一天跑了2000条工单有27条格式解析通过但字段值非法比如优先级字段值为“紧急处理”而不是“高”/“中”/“低”。后来加了枚举校验和自定义注解校验这批脏数据才彻底挡住。结构化输出解决的是“从文本到对象”的格式问题但值域合法性的问题还得靠自己的校验逻辑。最后再分享一个心得这套能力说是“结构化输出”其实背后的核心是“提示词约束 反序列化验证”的组合拳。理解这条链路比背熟某个API更有用——因为SpringAI版本在变API在变但“把JsonSchema塞进提示词、再把模型返回文本反序列化”这套思路不会变。顺着这个思路排查问题基本不会跑偏。