大概是从上一个被模型输出坑惨的项目开始我就把“解析模型回复”这件事当成了正经工作。那天花了一个下午排查发现模型给前端返回的字段一会儿是userName一会儿是username更离谱的是它会把 JSON 藏在一段“好的我不小心开始介绍了”的废话里剪完代码块再切割文本前端照样拿不到一个稳定的对象。后来我把整条链路迁到 Spring AI 上顺手把结构化输出做成了三重保障引导解码、自研工具、原生调用。这篇文字就是把这三套方案的思路、代码、坑位记录下来给同样被大模型自由发挥折磨的开发者一个参考无论你是刚接触 Spring AI还是已经在生产环境里接了好几个模型都能找到可以落地的方案。1. 为什么AI一句话回复让后端代码又长又脆1.1 从一次“三段式输出”事故说起先说一个真实场景。当时的内部辅助系统要从一段客户反馈里抽取联系人姓名、手机号和地区我用某个热门对话大模型随手调了一下接口返回内容长这样好的根据你提供的文本我提取到的信息如下 json { name: 王小明, mobile: 13800138000, region: 华东 }希望对你有帮助放在人眼里这条回复清晰得不能再清晰。可放进程序里它完全是三块互不相干的内容一句客套话、一个被 Markdown 代码块包裹的 JSON、一句结束语。我如果想拿到 name要么写正则匹配“name”后面的值要么先剥离代码块再截取花括号范围要么让模型“老实一点”别废话。最开始我选择了前者然后问题就像滚雪球一样来了。 同一个模型在温度参数不一样时可能把 mobile 输出成手机号也可能输出一个列表换了一个模型版本后字段名从 region 变成 area有时候 JSON 里还会多一个注释字段直接让解析器抛出异常。那一周里我光给各种怪异的输出打补丁就写了小两百行的字符串处理代码而且每次换个模型都要重新调一次。后来我才意识到问题不在单个模型而在于我的下游链路压根没有把“结构化输出”当成一个正式的设计维度。 ### 1.2 结构化输出要解决的三个问题 如果你去问一位后端同事AI 返回的文字能不能直接用他大概率会说“能用但我得拿 Regex 再切一下”。这其实就点出了结构化输出的本质我们需要从一段自由文本里稳定地拿到符合类型约束的数据。具体拆开看有三个非解决不可的问题。 第一是“可预测”。模型的输出天然是概率性的哪怕同一个提示词连续跑两次也可能产生不同的格式。结构化输出就是要把这种不确定压缩到一个足够小的范围让下游代码不需要再穷举各种形式。第二是“可转换”。即便模型输出了一段 JSON服务端也需要把它从字符串变成强类型对象否则就失去了类型安全业务代码里到处是 Map.get(xxx) 和强转。第三是“可容错”。模型再稳定也架不住特殊情况输出被截断、识别错字段、夹杂多余内容总会发生方案设计必须给这些异常留出兜底空间不能一遇异常就整个链路崩掉。 Spring AI 比较有意思的点在于它把模型调用封装成了统一的 ChatClient同时自带一套输出转换机制。这意味着结构化输出这件事不一定非要靠我们自己在下游写一堆解析逻辑框架本身就留了入口。我用下来最大的体会是最好用“引导 解析 类型映射”三层思路去解而不是把希望寄托在某一个方法上。下面逐步展开这三种具体做法。 ## 2. 方案一引导解码用提示词把模型“框”在JSON里 ### 2.1 提示词模板把JSON Schema直接写进请求 引导解码说白了就是提示词工程里的格式约束。我们不在代码里做任何魔法而是把期望的输出结构直接写成一段提示词模板让模型在生成阶段就往这个结构上靠。Spring AI 里最朴素的做法就是通过 ChatClient 把系统提示词和用户原文一起发出去。 java String systemTemplate 你是一个数据抽取助手。请从用户的输入文本中抽取联系人信息。 你只能输出一个 JSON 对象不要输出 markdown 代码块不要输出任何解释性文字。 JSON 对象的结构必须如下 { name: string, mobile: string, region: string } ; String result chatClient.prompt() .system(systemTemplate) .user(userInputText) .call() .content();这段代码执行完之后result大概率就是一段比较干净的 JSON 字符串不会再带开场白和结束语。它的原理说穿了不难大模型生成文本是一个 token 一个 token 往前推测的过程当系统提示词里给出了明确结构模型在每一个生成位置都会把概率向符合结构化模板的方向倾斜。这就像给一个员工发了一张固定表格他填表时自然会按表里的栏位写而不是自由发挥写成一段散文。实际跑下来这个方案的代码量几乎可以忽略也不需要额外引入依赖所以我把它当作结构化输出的“第一道保险”。不过要注意这里的“大概率”很关键提示词只是引导不是强制约束模型仍然存在“不听指挥”的可能。它适合用来减少噪音但不能作为唯一的可靠性保障否则迟早会踩到格式漂移的坑。2.2 为什么“格式词越多”越容易翻车很多人试过在提示词里写“请一定严格按照 JSON 返回”“如果格式不对系统将崩溃”之类的话以为语气越强硬越有效。我试过一段时间后发现大部分时候确实有效但偶尔模型会因为写了太多“请一定”而开始自我解释输出一堆带有威胁意味的废话。这背后的原因很简单模型的提示词里同时包含了约束指令和大量冗余情绪词注意力和输出分布被分散了反而更容易产生意外格式。我的建议是把系统提示词做得“干瘪一点”只给结构、给字段、给示例并声明禁止输出什么。像上面那段模板里我并没有写几百字的规则也没有放一堆示例只放了一个最小 JSON 模板。这样做的另一个好处是当模型输出偏航时我们能更准确地定位是提示词的问题还是模型版本的问题。除了提示词本身还有三个参数需要顺手调一下。第一个是温度尽量设成 0温度越高自由发挥空间越大结构化输出的失败率肉眼可见地上升。第二个是最大输出 token 数要根据模板长度留出安全余量否则一份很长的 JSON 可能在中间被截断下游解析必挂。第三个是停止符如果框架支持配置 stop 序列可以在 JSON 的结束括号}后加上停止条件但不同模型对 stop 的支持差异较大需要自己验证。等你把这些都调过一轮引导解码方案就基本稳定了它能做到让大部分输出格式正确但还远远做不到“格式一定正确”。3. 方案二自研工具解析前先“洗一遍数据”3.1 清洗杂质从Markdown和前缀里“抠”出JSON提示词方案防得住君子防不住小人。当我换了一个新版本模型或者业务场景里塞入了用户自定义内容之后模型输出又出现了新的“花活”。最常见的三种情况是输出被json代码块包裹、JSON 前面多了一句“好的以下是……”、JSON 后面跟了一句“希望这对你有帮助”。于是我开始写自己的解析工具不求它多智能只求它稳定地把杂质洗掉。public class AiOutputParser { public static String cleanAndExtractJson(String raw) { if (raw null || raw.isBlank()) { throw new IllegalArgumentException(模型输出为空); } String content raw.strip(); // 去掉 Markdown 代码块围栏 if (content.startsWith()) { content content.replaceFirst(^(?:json)?\\s*, ); content content.replaceFirst(\\s*$, ); } // 取第一个 { 到最后一个 } 之间的内容 int start content.indexOf({); int end content.lastIndexOf(}); if (start 0 end start) { content content.substring(start, end 1); } return content; } public static T T parseAs(String raw, ClassT clazz) { String json cleanAndExtractJson(raw); ObjectMapper mapper new ObjectMapper(); try { return mapper.readValue(json, clazz); } catch (JsonProcessingException e) { throw new IllegalArgumentException(清洗后的内容无法解析为 JSON, e); } } }这个工具类的逻辑很直白先把首尾空白去掉如果遇到代码块围栏就剥离再用indexOf({)和lastIndexOf(})把可能是 JSON 对象的片段截出来最后交给ObjectMapper反序列化成目标类型。有人可能会问直接截取花括号会不会把别的信息也截进来我在使用中保留了这个方法是因为模型输出的字段通常都在一个最外层 JSON 对象里取首尾花括号能覆盖绝大多数正常情况反而比写各种正则更直观。但这里我自己也要泼一盆冷水这方案解决的是“清洗”不是“语义”。如果模型把字段名都改了或者输出的是一个数组而不是对象parseAs该怎么报错还是怎么报错。所以自研工具的正确定位是“兜底清洗器”它不是万能转换器目标是让原本成功率只有 70% 的解析提升到 95% 以上剩下的 5% 要靠重试或回退策略去处理。3.2 解析失败兜底与重试从“一崩到底”到“有点弹性”工具类再健壮也挡不住模型偶尔输出非法 JSON或者只输出了一半就被截断。这种情况下最粗暴的处理是直接抛异常让调用方感知但这会让一个本来只是“模型一时抽风”的问题升级成业务故障。更合理的做法是在解析失败后做一次带提示词修正的重试。我在项目中就是这么处理的解析失败后把原始返回内容和异常信息拼到一个新的 prompt 里明确告诉模型“你上次输出的不是合法 JSON请重新只输出一个合法 JSON 对象”。public String retryWithCorrection(ChatClient chatClient, String originalInput, String badOutput) { String prompt 你上一次的输出不是合法 JSON内容如下 %s 请忽略上面的错误内容重新根据用户原始输入抽取信息。 只输出 JSON 对象不要包含 markdown 代码块不要解释。 用户原始输入 %s .formatted(badOutput, originalInput); return chatClient.prompt() .system(你是一个严格的数据抽取工具只能输出 JSON。) .user(prompt) .call() .content(); }这个重试模板之所以有效是因为它把“错误样例”直接暴露给了模型模型看到自己的输出被判定为不合法后通常会在下一轮收敛回 JSON 结构。我实测下来一次修正后的成功率已经相当可观。如果重试两次还失败那就说明当前模型版本或输入内容确实有问题这时候再抛异常或者进入人工处理通道也不迟。还有一个小细节解析时不要总盯着ClassT一种类型。对于比较动态的结构可以先解析成JsonNode或Map再按字段是否存在做逐步提取。这样即使模型多返回了一个字段、少返回了一个字段我们也能在业务层给出默认值而不是直接崩溃。自研工具的定位就是一个“有弹性的清洗与转换层”它可以在框架原生能力覆盖不到的地方发挥价值。4. 方案三Spring AI原生调用让输出直接变成对象4.1 从字符串到强类型BeanOutputConverter的正确打开方式如果你希望少写点自己的解析代码Spring AI 其实已经提供了结构化输出的原生能力。核心是把输出结构定义成一个普通类或 Java Record然后通过转换器让模型按这个结构生成再自动反序列化。我常用的版本里BeanOutputConverter就是这么用的public record PersonInfo(String name, String mobile, String region) { } BeanOutputConverterPersonInfo converter new BeanOutputConverter(PersonInfo.class); String content chatClient.prompt() .system(converter.getSystemMessage()) .user(userInputText) .call() .content(); PersonInfo person converter.convert(content);这段代码和第一节的“提示词模板”相比最大的差别是在 Spring AI 框架里我们不再需要自己写那段 JSON 结构模板converter.getSystemMessage()会帮我们生成一段引导模型按PersonInfo结构输出的系统提示词。到了拿到content之后converter.convert()又帮我们把字符串反序列化成强类型的PersonInfo对象。整个过程既完成了“引导解码”又完成了“类型映射”自研工具里那套清洗逻辑在这里基本不用写。如果是在ChatClient的链式调用里许多版本还提供了更简洁的写法PersonInfo person chatClient.prompt() .user(userInputText) .call() .entity(PersonInfo.class);这行调用等价于上面那三行框架底层仍然是先构造系统提示词再解析返回内容。对于只想要“一个稳定 Java 对象”的场景这种方式是最省事的。它直接把我之前手写的模板大战、解析工具、类型强转都收编进了框架。但我也要提醒一句Spring AI 的 API 在版本迭代中变化不算小早期版本方法名可能叫getInstruction而不是getSystemMessageentity方法在部分版本也可能有细微差异。如果你打开代码发现方法对不上直接去依赖的原生类里查看一下方法签名即可思路是一样的不用死磕一个名字。4.2 底层原理与自定义转换器扩展为什么原生方案值得优先考虑因为它把两条最关键的链路都做掉了。一条是“输出引导”converter.getSystemMessage()会根据目标类型的字段名、字段类型生成结构化的 JSON Schema 提示词相当于内置的引导解码另一条是“输出转换”converter.convert()在拿到模型回复后会用ObjectMapper把 JSON 序列化成目标类型。对我们来说这两条链路恰好就是前面方案一和方案二的核心只不过 Spring AI 把它们标准化了。遇到框架无法直接覆盖的场景比如字段名需要映射、某些字段需要自定义反序列化还可以自己实现一个转换器。以“把下划线字段映射成 Java 驼峰字段”为例可以在自定义转换器里配置ObjectMapper的命名策略public class CustomPersonInfoConverter implements StructuredOutputConverterPersonInfo { private final ObjectMapper objectMapper new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); private final BeanOutputConverterPersonInfo delegate new BeanOutputConverter(PersonInfo.class); Override public String getFormat() { return delegate.getFormat(); } Override public PersonInfo convert(String text) { try { return objectMapper.readValue(text, PersonInfo.class); } catch (JsonProcessingException e) { throw new IllegalArgumentException(自定义转换器解析失败, e); } } }这样写的好处是一旦自定义转换器配置好后续接入新模型时不用再变动业务层代码。你可以先跑一个简单模型等稳定之后再切到另一个模型只要模型都支持 JSON 输出转换器就能复用。唯一要留意的是转换器里自定义逻辑别写太复杂我见过有人把“清洗代码块”“截取花括号”也塞进转换器导致convert方法变得很难维护其实这些兜底逻辑更适合放在外层工具类或过滤过程中保持转换器职责单一。如果你需要嵌套对象、泛型集合这类复杂结构原生方案同样可以处理。比如要返回一个ListProductInfo可以借助ParameterizedTypeReference来保留泛型信息或者在 Record 里嵌套另一个 Record。这里有个容易踩的坑如果用Class去解析泛型集合运行时类型会被擦除反序列化结果可能变成ListLinkedHashMap而不是ListProductInfo所以复杂结构最好走ParameterizedTypeReference或直接定义包裹对象。5. 三重方案对比与选型建议5.1 一张表看清三种方式的差异三种方案我都在项目里跑过不存在谁绝对替代谁更多是看场景。下面这张表是我自己用来做技术选型的参照对比维度引导解码自研工具Spring AI 原生调用实现成本最低改一段提示词即可中等需要维护清洗逻辑较低定义类和一行调用可靠性依赖模型服从度有一定概率漂移能清洗常见杂质无法根治字段语义变化框架封装程度高通常最稳定类型安全弱返回的是字符串中可反序列化到指定类型强直接得到强类型对象维护成本提示词随模型版本可能需要调整工具类需要不断补分支依赖框架版本升级时要注意 API 变化适用场景快速验证、低并发内部工具模型输出杂乱、需要多级兜底生产环境、需要稳定类型映射的服务如果只让我给一句话建议能用原生调用解决的就别自研原生调用偶尔不给力的再加提示词引导提示词和原生都还差一口气的就用自研工具兜底。这不是技术洁癖而是维护成本决定的。自研工具里的每一条清洗规则本质上都是在替模型的不可控买单等到模型迭代一版规则可能就废了。5.2 我的组合式降级链路我目前的标准姿势是“原生调用为主、引导解码为辅、自研工具兜底”。用原生BeanOutputConverter获取强类型对象这是第一选择如果转换时报错就把原始模型输出交给自研工具清洗一次再重试清洗后仍然失败再走一次带修正提示词的重试。整体链路类似下面这样调用 Spring AI 原生转换器 - 成功直接返回强类型对象 - 失败用自研工具清洗模型输出 - 成功返回对象 - 失败用错误样例修正提示词后重试一次 - 无论成败记录日志并返回结构化异常信息在这个链路里引导解码并没有消失而是被拆成了两层一层是BeanOutputConverter生成系统提示词时的隐式引导另一层是重试模板里显式要求模型修正输出。自研工具也不再承担主要的解析职责只作为异常分支的清洗器。这样设计的最大好处是我不用再去维护一个庞大臃肿的解析类日常业务代码基本只跟强类型对象打交道。6. 踩坑实录与排查技巧6.1 高频异常速查表结构化输出看着简单实际跑起来能踩的坑不少。我把几个高频问题连同排查思路整理成了一张速查表方便你用的时候直接对照现象可能原因排查建议输出被json包裹模型默认的 Markdown 回复习惯用自研工具剥围栏或在提示词里明确“不要输出 markdown 代码块”字段名被模型改写提示词和模型训练习惯冲突在模板里给出精确字段名或者用原生转换器生成 JSON Schema输出中途被截断最大 token 数不够调大 max tokens或检查提示词精简度解析List时变成LinkedHashMap集合泛型类型擦除使用ParameterizedTypeReference或定义包裹对象字段少返回或返回 null原文中确实缺少信息转换器里对不可空字段做空值校验代码里给默认值重试后仍然失败输入内容本身过度模糊记录日志转人工处理或返回降级文本这些坑单看技术含量都不高难的是定位和排查。我建议一开始就为结构化输出加上日志记录原始返回内容、清洗后内容和解析结果哪怕只是文本文件也会让排查效率大幅提升。6.2 两个让方案更稳的细节第一个细节是“先小样本后全量”。不要拿着真实业务线上流量直接试方案先准备三五十条覆盖各种格式的样例批量跑一遍把失败样例全部收集起来。很多格式问题自己就会暴露比如有的样例里人名带数字、手机号带分机、地区为空你提前调好解析规则比上线后再打补丁省心得多。我第一次做这个项目时就是靠着几十条失败样例把清洗规则补齐之后上线一个月基本没出过乱子。第二个细节是“给错误留余量”。自研工具和原生转换器只负责把大部分场景接住接不住的情况要设计成可观测、可降级的。比如在转换失败时返回一个带默认值的空对象而不是让整个请求 500 报错又比如在重试模板里保留一次原始模型输出方便自己和同事复盘。总之模型永远是概率系统概率系统的铁律就是“永远留退路”。把这三套方案都跑了一遍之后我的体会是结构化输出从来不是单点问题而是从提示词到解析到类型映射的一条链路。今天如果有同事问我做 AI 输出解析选哪套我会反问他在生产环境的容忍度有多大然后建议他先把提示词约束写好再接上原生转换器实在不顺再补一把自研清洗工具这条降级链路跑通之后开发工作量反而小了很多。