news 2026/10/19 13:11:24

Spring AI 结构化输出实战:引导解码、自研工具与原生调用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI 结构化输出实战:引导解码、自研工具与原生调用

大概是从上一个被模型输出坑惨的项目开始,我就把“解析模型回复”这件事当成了正经工作。那天花了一个下午排查,发现模型给前端返回的字段一会儿是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, Class<T> 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 结构。我实测下来,一次修正后的成功率已经相当可观。如果重试两次还失败,那就说明当前模型版本或输入内容确实有问题,这时候再抛异常或者进入人工处理通道也不迟。

还有一个小细节:解析时不要总盯着Class<T>一种类型。对于比较动态的结构,可以先解析成JsonNode或Map,再按字段是否存在做逐步提取。这样即使模型多返回了一个字段、少返回了一个字段,我们也能在业务层给出默认值,而不是直接崩溃。自研工具的定位就是一个“有弹性的清洗与转换层”,它可以在框架原生能力覆盖不到的地方发挥价值。

4. 方案三:Spring AI原生调用,让输出直接变成对象

4.1 从字符串到强类型:BeanOutputConverter的正确打开方式

如果你希望少写点自己的解析代码,Spring AI 其实已经提供了结构化输出的原生能力。核心是把输出结构定义成一个普通类或 Java Record,然后通过转换器让模型按这个结构生成,再自动反序列化。我常用的版本里,BeanOutputConverter就是这么用的:

public record PersonInfo(String name, String mobile, String region) { } BeanOutputConverter<PersonInfo> 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而不是getSystemMessage,entity方法在部分版本也可能有细微差异。如果你打开代码发现方法对不上,直接去依赖的原生类里查看一下方法签名即可,思路是一样的,不用死磕一个名字。

4.2 底层原理与自定义转换器扩展

为什么原生方案值得优先考虑?因为它把两条最关键的链路都做掉了。一条是“输出引导”,converter.getSystemMessage()会根据目标类型的字段名、字段类型,生成结构化的 JSON Schema 提示词,相当于内置的引导解码;另一条是“输出转换”,converter.convert()在拿到模型回复后,会用ObjectMapper把 JSON 序列化成目标类型。对我们来说,这两条链路恰好就是前面方案一和方案二的核心,只不过 Spring AI 把它们标准化了。

遇到框架无法直接覆盖的场景,比如字段名需要映射、某些字段需要自定义反序列化,还可以自己实现一个转换器。以“把下划线字段映射成 Java 驼峰字段”为例,可以在自定义转换器里配置ObjectMapper的命名策略:

public class CustomPersonInfoConverter implements StructuredOutputConverter<PersonInfo> { private final ObjectMapper objectMapper = new ObjectMapper() .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); private final BeanOutputConverter<PersonInfo> 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方法变得很难维护,其实这些兜底逻辑更适合放在外层工具类或过滤过程中,保持转换器职责单一。

如果你需要嵌套对象、泛型集合这类复杂结构,原生方案同样可以处理。比如要返回一个List<ProductInfo>,可以借助ParameterizedTypeReference来保留泛型信息,或者在 Record 里嵌套另一个 Record。这里有个容易踩的坑:如果用Class去解析泛型集合,运行时类型会被擦除,反序列化结果可能变成List<LinkedHashMap>,而不是List<ProductInfo>,所以复杂结构最好走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 输出解析选哪套,我会反问他在生产环境的容忍度有多大,然后建议他先把提示词约束写好,再接上原生转换器,实在不顺再补一把自研清洗工具,这条降级链路跑通之后,开发工作量反而小了很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 13:11:16

open-code-review:面向 Git 生命周期的本地化代码审查工具

1. 这不是又一个“AI写代码”工具&#xff1a;open-code-review 的真实定位与设计哲学你点开 GitHub 搜索 “open-code-review”&#xff0c;大概率会看到几个星标不多、更新不勤的仓库&#xff0c;README 里写着“LLM-powered code review CLI”。第一反应可能是&#xff1a;又…

作者头像 李华
网站建设 2026/10/19 13:10:54

open-code-review:开源自托管代码审查体系搭建与工程实践

做代码审查这件事&#xff0c;我踩过最大的坑不是没工具&#xff0c;而是工具搭好了&#xff0c;团队照样把烂代码合进主干。后来我才想明白&#xff0c;代码审查从来不是装个平台就能解决的流程问题&#xff0c;它是一整套把“约定、工具、门禁、反馈闭环”揉进日常开发动作里…

作者头像 李华
网站建设 2026/10/19 13:09:19

Simulink实现符合ISO 3890的ABS模型与工程级参数整定

简介&#xff1a;本资源是一套面向车辆工程、控制理论与MATLAB仿真初学者的汽车防抱死制动系统&#xff08;ABS&#xff09;教学级Simulink建模与分析方案&#xff0c;聚焦PID控制器在ABS中的原理实现与参数调优。压缩包共4个文件&#xff08;2个M脚本用于定义车辆动力学参数与…

作者头像 李华
网站建设 2026/10/19 13:08:34

Java集成钉钉API避坑指南:扫码登录/通讯录同步/消息推送全链路实现

简介&#xff1a;本资源是一套面向Java开发者的企业级钉钉集成实战项目&#xff0c;聚焦阿里钉钉开放平台API在业务系统中的落地应用&#xff0c;适用于需实现组织通讯、审批流对接、消息推送及移动办公集成的中高级后端工程师。压缩包共174个文件&#xff0c;含32个核心Java源…

作者头像 李华
网站建设 2026/10/19 13:08:34

AI重塑神经科学:开发者实战指南

AI for Science这两年已经从一个洋气的口号&#xff0c;变成了实打实的生产力工具。我这两年大部分时间都泡在神经科学与机器学习的交叉项目里&#xff0c;一个很直观的感受是&#xff1a;这个领域正在经历一场静悄悄但极其深刻的范式转移。如果你是一名开发者&#xff0c;想找…

作者头像 李华
网站建设 2026/10/19 13:08:31

Git协作事故复盘:强制推送、冲突解决与数据恢复的终极技巧

Git 协作最扎心的一句话&#xff0c;我听过的是&#xff1a;“别慌&#xff0c;我刚才误操作&#xff0c;把 dev 分支强制推送了一下。”会议室瞬间安静的程度&#xff0c;不亚于大促当天核心订单库被人按了重置键。严格说这不叫“删库跑路”&#xff0c;但事故严重程度一点不低…

作者头像 李华