资讯动态

Spring AI结构化输出:从自由文本到强类型Java对象

发布时间:2026/10/14 3:51:46 来源:尧图企业网站定制
上周五晚上十一点我的定时任务告警响了。日志里躺着一行很眼熟的异常Unrecognized token json: was expecting null, true, false or NaN。我打开原始模型输出一看模型把我们要的JSON包在了json代码块里还在开头加了一句“好的以下是抽取结果”。业务方只看结果觉得“模型变聪明了”但下游程序直接崩了。类似的问题遇到不止一次字段类型对不上、该返回数组的时候返回了字符串、多给了一个并不存在的键。你可能会说“换个更强的模型不就好了”但真正的问题不在模型强弱而在你从来没给模型的输出加上“结构约束”。这篇文章会围绕Spring AI结构化输出把它的底层原理拆开讲清楚再用可运行的代码演示怎么把一段乱七八糟的自然语言文本直接变成强类型的Java对象。适合正在做AI应用开发、被模型输出恶心过的人也适合准备把LLM接进业务系统但还没想好怎么处理“自由文本”的同学。1. 一次线上故障引发的思考LLM输出“能看不能用”的真相1.1 语言模型天生是个“话痨”不是结构化接口我在刚开始接入大模型时犯过一个认知错误觉得大模型这么智能告诉它返回JSON它总该听话。后来才意识到语言模型本质上是在做下一个词元token的预测。你问它“你是谁”它不是打开数据库去查身份信息而是在预测一段“最符合对话语境的连续文本”。既然核心机制是预测文本那输出就天然是自由的、口语化的、带冗余的。你可以把LLM想象成一个业务能力很强、但极其话痨的实习生。你让他“把会议纪要整理成表格”他会交给你一份带前言、带后记、甚至带表情符号的回复。你要是不做约束他在说什么、怎么说全看当时心情。这就是为什么很多人第一次调大模型API拿到的输出常常“能看不能用”——人眼读着通顺程序一解析就崩。Spring AI结构化输出解决的就是这件事把“机器我要一个规范的JSON”翻译成模型能听懂的约束同时让程序能可靠地把模型输出还原成对象。1.2 我在生产环境遇到过的三类翻车现场可以把我踩过的坑总结成一张表看了你就明白为什么不能靠“在提示词里多强调几遍”来解决问题模型输出情况表示例解析结果用Markdown代码块包裹JSONjson\n{name:张三}\nJackson直接抛出Unrecognized token在JSON前后加解释性文本好的以下是抽取结果{name:张三}可能解析成功也可能被前缀干扰字段类型不符合约定期望workYears是Integer模型输出未知类型转换异常字段缺失或多出期望skills必填模型只返回了{name:张三}下游NPE或出现脏数据单看每一条都觉得是“模型不稳”但放到业务里就变成了接口返回结构不固定、消息队列里的数据脏、数据库字段约束被破坏、前端渲染直接白屏。尤其当模型输出进入异步任务或数据同步链路时一个格式错误就能让整条流水线卡死。1.3 结构化输出真正要解决的核心矛盾结构化输出要解决的核心矛盾是LLM的自由文本生成机制与业务系统的强类型契约之间的冲突。业务系统喜欢确定的东西一个接口返回什么字段、字段是什么类型、缺了怎么办最好在编译期就锁死。而LLM讨厌被锁死它希望你用自然语言告诉它“大概要什么”。Spring AI的解法比较务实它不试图改变LLM的根本机制而是把“约束”前置到生成阶段再加一道“解析后置兜底”。一句话概括你需要的不只是“把文本解析成JSON”而是从生成阶段就用一套明确的Schema约束模型再通过类型转换器把结果安全接进Java对象。这就是Spring AI结构化输出的整套思路了。2. 结构化输出的三层机制从“说人话”到“按契约执行”2.1 第一层提示词模板约束只解决“大方向”最原始的结构化输出做法是把JSON样例和字段说明直接写进提示词。Spring AI早期的结构化输出很多都是这么手动拼出来的请从下面的文本中抽取人员信息并按如下格式返回JSON { name: 张三, company: 某某科技, title: Java架构师, skills: [Java, Spring], workYears: 8 } 要求 1. 只返回JSON不要返回解释。 2. 不要使用Markdown代码块。这种做法我也用过优点是兼容所有模型缺点是效果完全依赖模型的“悟性”。你会发现gpt-4o这种模型大概率能遵守换成一些本地小模型或中文开源模型它就经常在JSON前面加一句“好的”或者把数组写成skills: Java, Spring。提示词约束本质上是一个“软约束”你把规则写得再清楚模型也只当作“对话语境的参考”而不是“必须执行的协议”。所以它只能作为一个基础不能作为保障。2.2 第二层JSON Schema 模型侧Structured Output真正让输出变得稳定可控的是第二层机制给模型一个结构化的JSON Schema并要求模型端在解码层面满足它。JSON Schema你完全可以理解成“数据契约说明书”它声明了这个JSON对象里有哪些字段、每个字段是什么类型、哪些字段必填、哪些枚举值可选。给你看个简化示例{ title: PersonInfo, type: object, properties: { name: {type: string}, company: {type: string}, title: {type: string}, workYears:{type: integer}, skills: {type: array, items: {type: string}} }, required: [name, company, title, skills, workYears] }Spring AI拿到一个Java类型后会利用类型元数据自动生成类似上面的JSON Schema。接下来这个Schema有两个去处一部分会拼进提示词告诉模型“按这个结构输出”。另一部分会在模型支持的前提下通过API传给后端模型服务利用平台自身的Structured Output能力做约束解码。OpenAI的Structured Outputs、Google Gemini的responseSchema、Ollama的format参数本质上都是同一类东西在模型生成token时逐token检查是否符合Schema不符合的候选token直接排除。这让“模型必定返回合法JSON”成为可能而不是“祈求模型听话”。2.3 第三层JSON到Java对象的映射与转换就算模型侧约束再强模型返回的也只是一段文本最终还是要变成Java对象。Spring AI在这里提供了一个统一的抽象叫StructuredOutputConverterT。它的核心逻辑可以理解成这样一个接口public interface StructuredOutputConverterT { // 告诉模型“你该输出什么格式” String getFormat(); // 把模型返回的字符串变成目标Java对象 T convert(String structuredOutput); }其中最常用的实现就是BeanOutputConverterT它的工作方式是根据你传入的ClassT或ParameterizedTypeReferenceT生成JSON Schema。getFormat()返回一段“请只输出符合Schema的JSON不要输出多余内容”的指令。模型返回文本后convert()方法用ObjectMapper把JSON反序列化成目标类型。理解了这三层机制你再去看Spring AI的ChatClient.entity()就会发现它其实是在帮你把这三件事一次性做完生成Schema、拼提示词、调用模型、解析结果。下面就用代码验证一下。3. 代码实战用ChatClient.entity()把简历文本变成PersonInfo对象3.1 工程配置Spring Boot Spring AI依赖先说环境。我用的是Spring Boot 3.x Spring AI 1.0.x引入官方starter即可。无论后端接OpenAI还是Ollama都推荐先引入BOM来统一版本dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement如果你有OpenAI的API Key引入OpenAI的starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency如果你像我一样不想折腾API费用本地用Ollama跑开源模型也很方便dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-ollama/artifactId /dependency对应的配置spring: ai: openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0 # 如果用Ollama则换成 # ai: # ollama: # base-url: http://localhost:11434 # chat: # options: # model: qwen2.5:7b # temperature: 0有个非常值得注意的细节温度要设置成0。虽然生成类任务通常需要一点随机性但结构化输出场景下温度越高越容易出现“发挥失常”、字段错乱、多余解释。把温度设为0能最大化输出的可复现性。3.2 定义目标类型一个record就够用为了演示我定义一个PersonInfo用来从简历文本中抽取人员信息。用Java record最合适因为字段清晰、天然自带equals/hashCode还方便序列化public record PersonInfo( String name, String company, String title, ListString skills, Integer workYears ) { }注意这里workYears我用的是Integer而不是int。原因是当模型无法从文本中推断出某个字段时我们希望它能输出null而不是因为int无法为空导致反序列化直接炸掉。我在第5节还会详细讲这个坑。3.3 核心调用ChatClient.entity()一行搞定注入ChatClient然后直接调用Service public class ResumeParsingService { private final ChatClient chatClient; public ResumeParsingService(ChatClient.Builder builder) { this.chatClient builder.build(); } public PersonInfo parseResume(String resumeText) { return chatClient.prompt() .system(你是一个信息抽取助手。请只返回JSON不要返回任何解释、Markdown或多余文本。) .user(resumeText) .call() .entity(PersonInfo.class); } }entity(PersonInfo.class)是Spring AI结构化输出最核心的入口。它的内部流程就是我们在第2节讲的三层机制根据PersonInfo.class生成JSON Schema把格式指令追加到消息里调用模型最后把模型返回文本反序列化成PersonInfo。给你一个可测试的输入String resume 张三2016年毕业于某理工院校计算机专业。 毕业后加入本地一家电商公司从Java开发做起三年后晋升为技术主管 2021年跳槽到一家出行平台任Java架构师负责订单中台重构带过8人团队。 关注高并发、微服务治理熟悉Spring Boot、Redis、Kafka、Kubernetes。 ; PersonInfo person resumeParsingService.parseResume(resume); System.out.println(person);正常情况下你会得到一个完整的对象PersonInfo[ name张三, company某出行平台, titleJava架构师, skills[Java, Spring Boot, Redis, Kafka, Kubernetes], workYears8 ]这里有两点比较有意思。第一workYears8并不是简历里直接写的而是模型推算出的“工作经验年限”。也就是说Spring AI的结构化输出不仅帮你做了格式约束还能保留LLM的语义抽取能力。第二skills被规整成了数组而不是原文里的逗号分隔文本这就是模型的“整理能力”与“结构约束”合力的结果。3.4 先看原始响应再调试如果你想知道模型到底返回了什么样的原始文本用content()代替entity()即可String raw chatClient.prompt() .system(你是一个信息抽取助手。) .user(resumeText) .call() .content(); System.out.println(raw);我在实际调试时永远先看一眼content()再决定要不要上entity()。有些问题不是Spring AI的转换器不行而是模型压根没遵循指令。先看原始文本能快速判断问题到底出在“提示词没写清楚”还是“转换器太严格”。4. 进阶玩法列表、Map、枚举与自定义StructuredOutputConverter4.1 返回数组绕开Java类型擦除很多场景下一条消息里可能包含多个实体。比如一份会议纪要里提到了好几个项目人员你要一次全部抽出来。这时可以直接请求一个Listpublic ListPersonInfo parseResumeList(String text) { return chatClient.prompt() .system(从文本中抽取所有出现的人员信息逐条放入JSON数组。) .user(text) .call() .entity(new ParameterizedTypeReferenceListPersonInfo() {}); }这里有坑不能直接写entity(List.class)。因为Java泛型存在类型擦除运行时只知道这是一个List不知道List里的元素是PersonInfo。JSON反序列化时它不知道应该把每个元素转成什么类型非常容易报Cannot construct instance of ...。正确的做法是借助ParameterizedTypeReference捕获泛型信息new ParameterizedTypeReferenceListPersonInfo() {}这是SPring AI能拿到ListPersonInfo完整类型信息的关键。4.2 返回Map可以但别滥用有时你只是想让模型返回几个“键名不确定”的字段比如“从文本中提取所有客户反馈键为反馈编号值为反馈内容”用Map似乎挺方便MapString, String result chatClient.prompt() .user(提取反馈键为编号值为内容) .call() .entity(new ParameterizedTypeReferenceMapString, String() {});但我的建议是Map尽量少用尤其不要用MapString, Object。原因是Map缺乏固定的字段约定生成的JSON Schema很难做到严格约束模型容易看着自由发挥。而且一旦值是Object反序列化出来的可能是LinkedHashMap或Integer下游还得再做一次类型判断。更稳的做法是只要你能提前预知字段结构就定义record。Map适合“键名确实不可枚举”的场景比如动态标签、自定义属性集合。能用record锁定的结构别交给Map自由发挥。4.3 枚举值约束提示词里要写清楚取值清单业务里经常遇到风险等级、状态类型这种枚举字段。比如public enum RiskLevel { LOW, MEDIUM, HIGH } public record RiskAssessment( String summary, RiskLevel level, ListString reasons ) { }这里有个比较隐性的问题Java枚举反序列化默认大小写敏感。模型输出的是MEDIUM还好一旦输出Medium或者直接输出中文中等Jackson就会抛InvalidFormatException。我常用的解决方案分两步在system提示词里明确告诉模型“level字段只能取LOW、MEDIUM、HIGH这三个值不要输出其他写法。”在实体字段上用String接收拿到后再用业务代码做映射保留足够容忍度。如果你确实想直接用枚举也可以给枚举加一个JsonCreator让Jackson能够把非标准写法也映射进来public enum RiskLevel { LOW, MEDIUM, HIGH; JsonCreator public static RiskLevel from(String value) { if (value null) { return null; } return switch (value.trim().toUpperCase()) { case LOW, 低 - LOW; case MEDIUM, 中 - MEDIUM; case HIGH, 高 - HIGH; default - HIGH; }; } }但你要明白枚举解析失败的问题归根结底是模型不规范。业务系统如果对枚举值敏感我更建议在反序列化之后再专门做一层校验。4.4 自定义StructuredOutputConverter处理模型爱包Markdown的老毛病Spring AI默认的BeanOutputConverter会很严格地把模型返回文本直接当成JSON解析。如果模型抽风返回了Markdown代码块就会像开头那样直接告警。虽然你可以在提示词里反复强调“不要用Markdown”但总有小模型不听。这时候最稳妥的办法是自己写一个容忍Markdown代码块的自定义转换器。public class MarkdownStrippingConverterT implements StructuredOutputConverterT { private static final Pattern JSON_BLOCK Pattern.compile((?s)(?:json)?\\s*(.*?)\\s*); private final ObjectMapper objectMapper; private final JavaType targetType; public MarkdownStrippingConverter(ObjectMapper objectMapper, JavaType targetType) { this.objectMapper objectMapper; this.targetType targetType; } Override public String getFormat() { return 只输出JSON不要用Markdown代码块包裹。; } Override public T convert(String structuredOutput) { try { Matcher matcher JSON_BLOCK.matcher(structuredOutput); String json matcher.find() ? matcher.group(1) : structuredOutput; return objectMapper.readValue(json, targetType); } catch (JsonProcessingException e) { throw new RuntimeException(Failed to parse structured output, e); } } }然后通过entity(converter)来调用JavaType type objectMapper.getTypeFactory() .constructParametricType(PersonInfo.class, /* 如果有泛型这里需要处理 */); MarkdownStrippingConverterPersonInfo converter new MarkdownStrippingConverter(objectMapper, type); PersonInfo result chatClient.prompt() .system(抽取信息) .user(resumeText) .call() .entity(converter);这个自定义转换器最大的价值在于它把“模型的任性”兜住了。即便模型抽风加了代码块也能先剥离再解析而不是直接让接口抛异常。类似的思路可以扩展比如先做文本清洗、再去掉首尾解释语、甚至对缺失字段做默认值填充。5. 生产环境稳定性五个必须知道的坑和补救策略5.1 重要字段全用包装类型别用基本类型这是我在生产环境被教育过最惨的一次。当时字段用了int workYears模型在某个样本里实在推算不出经验年限输出了null。Jackson反序列化null到int直接失败任务失败率5%左右。改成Integer workYears之后就稳定了模型无法推断时输出null业务层再去单独处理“未知情况”。所有非必填数值字段都建议用Integer、Long、BigDecimal而不是int、long。字符串字段本来就能接null问题不大。这个改动成本极低但能消掉一大类解析异常。5.2 温度设置为0必要时开启重试结构化输出场景下温度0是默认配置。你不需要模型“有创意”你需要它“稳定可复现”。把温度调到0模型会倾向选择概率最高的token格式偏离的概率显著下降。但即使如此我也没有哪一次敢说模型100%遵守格式。所以生产环境里最好做一个带重试的包装public T T callWithRetry(SupplierT action, int maxRetries) { int attempt 0; while (true) { try { return action.get(); } catch (Exception e) { attempt; if (attempt maxRetries) { throw e; } // 也可以在这里短暂sleep避免触发限流 } } }每次重试时模型重新生成一次大概率能把上一次的格式错误修正过来。当然重试带来的成本和延迟要评估好。5.3 在提示词里主动给“不知道”留出口模型不敢说“我不知道”很多时候会强行编一个值。比如简历里没有明确的毕业年份它可能编一个“2020”出来。这是幻觉问题但通过结构化输出的提示词设计可以缓解。我的做法是在system提示词里主动声明如果某个字段在原文中无法确定请将对应字段设为null绝对不要编造内容。这个提示非常有效。它给了模型一个合法的“我不知道”出口模型就不需要通过编造来完成任务。你可以在第3节的system提示词里直接加上这句实体类里对应字段用包装类型接收就能大大减少幻觉脏数据入库。5.4 结果反序列化之后还要做业务校验很多人误以为entity()能反序列化成功就代表数据没问题。实际上ObjectMapper只保证“JSON语法合法”不保证“字段值符合业务规则”。模型可能会输出workYears100这种离谱值也可能抽出无关人员。所以我一般在反序列化之后接一层业务校验比如字符串字段非空校验枚举值白名单校验数值范围校验如workYears必须在0到50之间嵌套对象或数组长度的基本校验。用Spring的Valid加NotBlank、Min、Max也可以但要注意反序列化失败和业务校验失败是两个阶段。前者是格式问题后者是语义问题不能混为一谈。5.5 上线前先看50个真实样本的失败率最后一个建议也是所有接LLM的项目都该做的别拿一条测试用例验证一次就宣告完成。把50条真实样本跑一遍统计以下指标指标观察点直接解析失败率有多少次entity()抛异常字段为null的比例必填字段缺失情况字段值不合理比例类型对但值荒谬平均返回延迟是否影响业务响应时间基于这50条样本你才能真正决定是调提示词、换模型、还是加自定义转换器。我见过太多的项目开发阶段看起来一切正常一上真实数据就原形毕露——因为测试数据太干净根本没触发模型的发挥路径。我在实际项目里的做法是先跑50个样本看失败率把失败样本全部打印出来分类再决定下一步。这个习惯帮我省下了大量“换模型、调参数”的无用功。结构约束是免费的可靠性但真正让一个AI功能能扛住生产的永远是“格式约束业务校验合理重试”这个组合拳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑