上一篇文章我们实现了打字机效果的流式对话但 AI 返回的一直是自由格式的文本。今天来解决一个更实际的痛点如何让 AI 把答案直接装进你定义好的 Java 对象里。在真实业务中你经常需要的不是一个聊天段落而是一个可以写入数据库、传给前端组件、参与计算的结构化结果。比如从用户输入中提取姓名、年龄、地区返回一个UserProfile对象分析一段商品评论输出SentimentResult情感倾向、关键词、评分生成一份日报摘要映射为DailyReport实体。如果靠正则或者字符串截取来解析 AI 的自由文本那可就太痛苦了。Spring AI 提供了一种优雅的方式你定义好 Java Bean剩下的映射工作框架帮你完成。一、痛点场景自由文本解析的噩梦假设你做了一个智能简历解析功能用户输入一段话我叫张三今年28岁是一名Java开发工程师base在北京。你希望得到一个这样的对象{name:张三,age:28,job:Java开发工程师,city:北京}如果让 AI 返回普通文本“好的解析结果姓名张三年龄28……”然后你自己写正则去抠数据不仅代码丑而且 AI 稍微换个表述你的正则可能就失效了。你真正想要的是AI 直接输出结构化的 JSON并且框架自动把 JSON 反序列化成你的 Java Bean。Spring AI 的结构化输出能力就是为此而生。二、核心概念快览2.1 结构化输出结构化输出是指让大语言模型返回符合预定义格式的数据通常是 JSON而不是自由文本。Spring AI 通过BeanOutputConverter实现了一套机制将你的 Java 类转换为 JSON Schema 描述随请求一起发给模型让模型按要求输出 JSON然后框架自动将返回的 JSON 反序列化为对应的 Bean。2.2 BeanOutputConverterSpring AI 内置了一个输出转换器BeanOutputConverter它可以根据给定的 Java 类生成对应的 JSON Schema将模型返回的文本解析为指定类型的对象自动处理一些常见的格式问题如 JSON 被包裹在 json 代码块中。在日常使用中你通常不需要直接操作BeanOutputConverter因为ChatClient已经提供了更简便的.entity(Class)方法。2.3 调用方式对比// 返回纯文本StringtextchatClient.prompt().user(...).call().content();// 返回结构化对象MyBeanbeanchatClient.prompt().user(...).call().entity(MyBean.class);entity()方法内部就使用了BeanOutputConverter。它会隐式地在系统提示中加入“请返回一个 JSON 对象格式如下……”的指令所以你甚至不需要自己写复杂的 prompt。三、环境准备不需要引入任何新依赖。我们之前用到的spring-ai-starter-model-openai已经包含了结构化输出所需的一切。如果想对映射成功的 Bean 做额外的字段校验可以引入 Spring Boot 的spring-boot-starter-validation但这一步是可选的今天我们先不依赖它也能演示。配置文件继续沿用之前的设置即可以 DeepSeek 为例spring:ai:openai:api-key:${DEEPSEEK_API_KEY}base-url:https://api.deepseek.comchat:options:model:deepseek-chattemperature:0.3# 低温度有助于生成更稳定的 JSON注意结构化输出时建议将temperature设得较低如 0.1-0.3降低模型“发挥”空间减少输出格式不正确的概率。四、代码实战4.1 定义目标 Bean我们以简历解析场景为例创建一个PersonProfile类packagecom.example.springaihelloworld.model;importcom.fasterxml.jackson.annotation.JsonProperty;/** * 人员信息实体 * 属性使用 JsonProperty 来明确 JSON 字段名 */publicclassPersonProfile{JsonProperty(name)privateStringname;JsonProperty(age)privateIntegerage;JsonProperty(job)privateStringjob;JsonProperty(city)privateStringcity;// 无参构造器反序列化必需publicPersonProfile(){}publicPersonProfile(Stringname,Integerage,Stringjob,Stringcity){this.namename;this.ageage;this.jobjob;this.citycity;}// getter / setterpublicStringgetName(){returnname;}publicvoidsetName(Stringname){this.namename;}publicIntegergetAge(){returnage;}publicvoidsetAge(Integerage){this.ageage;}publicStringgetJob(){returnjob;}publicvoidsetJob(Stringjob){this.jobjob;}publicStringgetCity(){returncity;}publicvoidsetCity(Stringcity){this.citycity;}OverridepublicStringtoString(){returnPersonProfile{namename\, ageage, jobjob\, citycity\};}}注意必须有无参构造器否则 Jackson 无法反序列化。使用JsonProperty明确指定字段名避免 Java 属性名和 JSON key 不一致时出问题。4.2 Service 层结构化调用在AIChatService中新增方法演示通过.entity()直接映射packagecom.example.springaihelloworld.service;importcom.example.springaihelloworld.model.PersonProfile;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.openai.OpenAiChatModel;importorg.springframework.stereotype.Service;ServicepublicclassAIChatService{privatefinalChatClientchatClient;publicAIChatService(ChatClientchatClient){this.chatClientchatClient;}/** * 结构化输出从用户输入中解析出 PersonProfile */publicPersonProfileextractPersonProfile(StringuserInput){returnchatClient.prompt().user(userInput)// 用户输入的原始文本.call().entity(PersonProfile.class);// 直接映射为实体}/** * 更精细的控制手工构造提示词并映射 * 当模型能力较弱时可以显式说明输出格式 */publicPersonProfileextractPersonProfileWithHint(StringuserInput){StringsystemHint从用户输入中提取人员信息只返回一个 JSON 对象字段包括name, age, job, city。年龄必须为整数。;returnchatClient.prompt().system(systemHint).user(userInput).call().entity(PersonProfile.class);}}关键点.entity(PersonProfile.class)会自动要求模型返回 JSON并将 JSON 反序列化为PersonProfile实例。如果模型返回的文本不是合法 JSON例如被包裹在 markdown 代码块中BeanOutputConverter会尽力清洗但如果实在无法解析会抛出异常。你还可以通过.system()给出更明确的指令提高成功率。4.3 Controller 层暴露接口创建或扩展ChatStructureControllerpackagecom.example.springaihelloworld.controller;importcom.example.springaihelloworld.model.PersonProfile;importcom.example.springaihelloworld.service.AIChatService;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;RestControllerpublicclassChatStructureController{privatefinalAIChatServicechatService;publicChatStructureController(AIChatServicechatService){this.chatServicechatService;}/** * 结构化解析接口 * 示例/chat/profile?input我叫张三28岁Java开发在北京 */GetMapping(/chat/profile)publicPersonProfilegetProfile(RequestParamStringinput){returnchatService.extractPersonProfile(input);}/** * 带提示词的结构化解析接口 */GetMapping(/chat/profile-v2)publicPersonProfilegetProfileV2(RequestParamStringinput){returnchatService.extractPersonProfileWithHint(input);}}现在调用/chat/profile?input...就会直接返回一个 JSON 格式的PersonProfile对象因为RestController自动将返回的 Bean 序列化为 JSON。4.4 进阶返回 Optional 进行校验有时我们想对返回的 Bean 做空值检查或自定义校验可以结合Optional和 Spring 的Validator。先在AIChatService中添加一个返回OptionalPersonProfile的方法importjava.util.Optional;publicOptionalPersonProfileextractPersonProfileOptional(StringuserInput){try{PersonProfileprofilechatClient.prompt().user(userInput).call().entity(PersonProfile.class);// 可以在这里进行额外的业务校验if(profile.getName()null||profile.getName().isEmpty()){returnOptional.empty();}returnOptional.of(profile);}catch(Exceptione){// 解析失败时返回空returnOptional.empty();}}在 Controller 中就可以据此返回不同的响应GetMapping(/chat/profile-safe)publicResponseEntity?getProfileSafe(RequestParamStringinput){OptionalPersonProfileoptchatService.extractPersonProfileOptional(input);returnopt.ResponseEntity?map(ResponseEntity::ok).orElseGet(()-ResponseEntity.badRequest().body(无法从输入中解析出人员信息请确认格式。));}4.5 手动使用 BeanOutputConverter了解即可如果你需要脱离ChatClient的默认行为自己处理转换流程可以这样importorg.springframework.ai.converter.BeanOutputConverter;importorg.springframework.ai.chat.messages.UserMessage;importorg.springframework.ai.chat.prompt.Prompt;importorg.springframework.ai.openai.OpenAiChatModel;publicPersonProfileextractManually(Stringinput){varconverternewBeanOutputConverter(PersonProfile.class);// 获取 JSON Schema 格式的描述可以嵌入到系统提示中Stringformatconverter.getFormat();PromptpromptnewPrompt(newUserMessage(input),OpenAiChatOptions.builder().withTemperature(0.3).build());// 使用 ChatModel 获取原始文本StringcontentchatModel.call(prompt).getResult().getOutput().getContent();// 手动转换returnconverter.convert(content);}但这比较底层日常开发直接使用ChatClient.entity()就足够了。五、运行与演示5.1 启动应用确保DEEPSEEK_API_KEY环境变量已设启动 Spring Boot。5.2 测试接口打开浏览器或使用 curlcurlhttp://localhost:8080/chat/profile?input我叫张三今年28岁是一名Java开发工程师base在北京返回结果可能是{name:张三,age:28,job:Java开发工程师,city:北京}5.3 测试带提示词的接口curlhttp://localhost:8080/chat/profile-v2?input李四35岁产品经理住在上海同样可以得到结构化的 JSON。5.4 测试一个异常输入curlhttp://localhost:8080/chat/profile-safe?input今天天气真好因为输入中不包含人员信息服务端会返回 400 错误并给出提示消息。这比直接崩溃要好得多。六、常见问题与避坑提示问题一模型返回的不是合法 JSON有时候模型会在 JSON 外面包裹json ...代码块标记或者添加额外的说明文字。Spring AI 的BeanOutputConverter内置了清洗逻辑会尝试提取其中的 JSON 部分。但若结构过于混乱仍可能解析失败抛出ConversionException。解决方案在.system()消息中明确要求“只返回纯 JSON 对象不要任何额外说明”。使用较低的温度值0.1-0.3。若模型支持原生response_format如 OpenAI可以在选项中设置。但目前的 DeepSeek 等兼容模型不一定原生支持需要通过 prompt 引导。问题二某些字段缺失或类型不匹配例如期望age是整数模型返回了字符串28Jackson 可能无法自动转换并抛异常。解决方案在 Java Bean 中使用宽松的类型定义如Object然后手动处理或者使用JsonFormat自定义反序列化器。在 system prompt 中强调字段类型“age 字段必须是一个整数不要加引号。”如果允许字段缺失可以在 Bean 中使用Optional或包装类型。问题三模型不支持结构化输出Spring AI 的结构化输出机制基于 prompt 引导适用于任何能够理解“请输出 JSON”的模型。但极少数轻量级模型可能根本不按格式回复。对于这类模型结构化输出的成功率会显著下降。解决方案选用能力较强的对话模型如 DeepSeek、GPT-4.1、通义千问 plus。如果必须用小模型可以考虑通过多次重试或辅助正则来补丁。问题四实体类中的字段名与 JSON key 不匹配如果 JSON 中是first_name你的 Java 属性是firstName但没有加JsonProperty(first_name)字段会为 null。解决方案严格使用JsonProperty显式映射。或者在application.yml中配置 Jackson 的命名策略但建议不要显式映射更清晰。spring:jackson:property-naming-strategy:SNAKE_CASE但这种方式是全局的不够灵活。七、小结与下一步预告本篇回顾理解了结构化输出的价值直接拿到可编程的 Java 对象告别手写正则。学会了通过ChatClient.entity(Class)一行代码实现 AI 回复到 Bean 的映射。了解了BeanOutputConverter的工作原理和清洗机制。掌握了添加系统提示词提升解析成功率、使用 Optional 处理解析失败的防御性编程思路。一个简单的对比传统方式结构化输出方式解析自由文本正则匹配直接拿到 Java 对象AI 换个说法代码就崩字段明确容错性强调试靠 print 字符串类型安全IDE 自动提示现在你的 Spring AI 工具箱里已经有了同步、异步、流式、角色预设、结构化输出这些核心武器。下一站我们要离开云端把模型部署到本地——使用 Ollama 在你自己电脑上跑大模型然后用 Spring AI 接入。全程离线数据不出本机而且免费。下一篇《本地部署大模型Ollama 安装与 Spring AI 接入》见。本系列博客基于 Spring AI 1.1.6 版本编写。结构化输出功能依赖模型的指令遵循能力不同模型的表现可能存在差异建议在实际项目中针对所选模型进行充分测试。