如果你在Spring AI里做结构化输出,多半遇到过这种场景:模型返回的content里,明明要求JSON,结果它给你包了一对```json标签,或者中途夹一句"好的,这是你要的JSON:",更头痛的是这次返回orderId,下次可能变成order_id,直接让Jackson解析崩在线上。我最近在模拟项目X里做客服工单自动分类,就是被这个问题折腾了一整轮。顺着这个标题聊点实际的:Spring AI的结构化输出,本质上是"生成时约束、解析时兜底、必要时绕开框架"三件事,我把这三条路分别走了一遍,这里记录下完整思路和可直接参考的代码。
先说明白,适合看这篇文章的人:你在Java项目里接了大模型API,想稳定拿到可控的JSON/POJO,而不是靠正则或者来回提示词去碰运气;你用过Spring AI的ChatClient,但发现高层的entity()方法有时候并不那么听话。下面的内容会从原理讲到实战,三条路径各有取舍,最后我会给出一个我目前默认的分层组合。
1. 结构化输出不是"让它返回JSON"这么简单
1.1 模型输出不可控带来的连锁反应
很多人一开始觉得,结构化输出就是在Prompt里写一句"请以JSON格式返回",然后用ObjectMapper一解析就完事。实际上等你跑到生产环境,问题会一连串地冒出来。
第一层问题是格式层。模型生成的文本里可能混入Markdown代码块、前后解释性文字、多余换行和空格。你写解析器时当然可以indexOf('{')把对象切出来,但遇到模型在JSON里塞了注释或者非法尾逗号时,Jackson默认会直接拒绝。
第二层问题是语义层。就算JSON语法合法,字段也可能不齐全、字段名漂移、枚举值变成模型自创的相似词。比如我要求状态字段取值只能是open、closed、pending,模型给你返回了一个waiting。这种问题不是语法解析能解决的,必须靠schema校验和值映射。
第三层问题是链路层。当这些错误发生在生产任务里,比如批量处理工单分类,一条解析失败会中断整个批次。你没法保证上一轮能成功的Prompt,下一轮换个用户问法还能成功。
所以,结构化输出不是加一句Prompt就能收工的事。它要求你在生成、解析、请求三个层面都做好防御,这也是这个标题里"三重奏"想表达的意思。
1.2 Spring AI给出的三条救援路线
我在Spring AI里实际试下来,能把问题拆成三条互相补充的路线:
一是引导解码。不是靠"请务必"这类提示词,而是利用模型本身的解码约束或者JSON模式,让模型在生成token的时候就只能按合法的JSON结构走。作用在生成阶段,能解决语法格式合法性的问题,但对内容是否完全符合业务schema帮助有限。
二是自研工具。写一个可复用的结构化输出转换器,负责从模型返回的脏文本里提取JSON、清洗、类型转换、校验,并且把校验失败的信息再回传给模型让它自我修正。作用在链路解析阶段,解决的是"模型已经输出但不符合预期"的兜底问题。
三是原生调用。不再经过Spring AI的高层Client封装,直接用HTTP客户端请求模型接口,自己控制请求参数、响应解析和重试策略。适合Spring AI封装覆盖不到或者你想完全掌控请求细节的场景。
这三条路不是替代关系,而是层层递进。下面我分三部分展开每一路的实操姿势和踩坑记录。
2. 路径一:引导解码,让模型在生成阶段就跨过格式红线
2.1 引导解码的原理:不是提示词更凶,而是搜索空间被锁死
先纠正一个常见误解。引导解码不是"在Prompt里写更多要求",而是从推理底层限制模型能生成的token范围。简单类比:普通生成是让模型在一本很厚的字典里随便选词,引导解码相当于提前告诉它"你只能在符合JSON语法的路径上选词",遇到需要字符串的位置才选字符串,遇到{或[的位置不能随便给别的东西。
实现上常见有几类:
- 模型服务商在API层面提供JSON模式,比如设置
response_format为json_object,服务端会做一层约束; - 本地推理框架支持结构化解码/语法约束,比如用GBNF语法或者JSON Schema约束生成过程;
- 有些服务内置"强制JSON"开关,本质也是解码器层面的grammar约束。
引导解码的价值在于它从源头减少了脏数据的产生。你不必再写正则去剥离解释性文字,因为模型根本没机会输出那部分内容。这在批量任务里效果尤其明显,失败率能下来一大截。但要注意,它通常保证"输出是合法JSON",不保证"输出符合你的业务Schema"。
2.2 在Spring AI里打开JSON模式的一次实操
Spring AI没有把所有模型的JSON模式都抽象成一个统一API,具体配置方式和你用的模型服务商以及Spring AI版本有关。我以最通用的ChatOptions思路为例,给你画一下核心写法。
// 伪代码风格,具体方法名以你当前Spring AI版本的实现类为准 ChatOptions options = ChatOptions.builder() .withModel("model-name") .withTemperature(0.2) .withResponseFormat("json_object") // 开启服务端的JSON模式 .build(); String schemaPrompt = """ 你是客服工单分类器。请根据工单内容输出JSON,必须使用以下结构: { "category": "string", "priority": "high|medium|low", "summary": "string", "confidence": "number" } 只输出JSON对象,不要输出任何其他内容。 """; Prompt prompt = new Prompt(schemaPrompt, options); String raw = chatClient.call(prompt) .getResult() .getOutput() .getText();真正动手前,我建议你先用命令行或Postman直接调一次模型服务商的接口,确认它能接收response_format之类的参数,并确认返回结构。Spring AI的高层ChatClient未必会把所有参数透传到请求体里,如果发现配了没效果,再去翻ChatOptions具体实现类中有没有暴露对应字段。
从Spring AI社区近期的演进看,StructuredOutputConverter配合ChatClient的entity()方法已经能帮你完成"对象到格式说明、文本到对象"的往返,但它是靠Prompt来引导的,不是严格意义上的解码约束。你要的是生成侧硬约束,就必须在模型请求参数上做文章。
2.3 实测效果和三个坑
我在模拟项目X里实测,开启JSON模式后,格式本身的问题少了很多,但踩了三个坑值得讲。
第一个坑:JSON Schema写得过严会截断输出。有些服务端会把Schema直接编入解码约束,嵌套层级很深或者字段很多时,模型生成到一半可能截断,返回的JSON不完整。这时候你不是该去吐槽模型,而是应该把Schema精简。能拆成多个小接口的就不要让一个对象承担所有字段,尤其是数组里套对象再套数组的场景。
第二个坑:模型不兼容。不是所有模型服务商都支持JSON模式,有些本地模型开关形同虚设。我在没有该开关的模型上验证过,加不加withResponseFormat,返回的文本没有任何变化。这个问题没办法从Spring AI层面解决,只能换模型,或者在下面的自研工具里兜底。
第三个坑:官方文档不会把参数差异写全。同是JSON mode,有的服务要求你必须在Prompt里出现"json"字样,否则报错;有的要求Schema只能作为额外参数传入,不能写进Prompt。你只能一个个试。所以我后来养成了一个习惯:重要配置先直接用HTTP调用验证一遍接口行为,确认无误了再去Spring AI里配置。
3. 路径二:自研结构化解析工具,把脏数据按在地上摩擦
3.1 为什么要自己写解析器:后处理兜底的不可替代性
引导解码再强,也管不到业务语义层。模型完全可能返回一段合法JSON,但枚举值错了、字段缺失了、嵌套结构里多了一个空数组,这些在JSON语法层面都合法,你的业务代码却不能容忍。另一个现实情况是,你项目里可能用了不支持JSON模式的模型,或者你接的是开源模型,根本没法在生成阶段做约束。
这时候需要一个后处理的兜底工具。Spring AI本身提供了BeanOutputConverter这类转换器,能根据你的POJO生成格式说明,再把模型输出转成对象。但在实际项目里,它对于"脏文本清洗""枚举值兼容""失败重试"这些需求还差一层。你的选择可以是继承或者自己实现一个OutputConverter,把清洗和校验逻辑加进去。
为什么自己写而不是硬着头皮在调用方到处处理?因为结构化输出的消费方往往是Service层甚至消息队列消费者。如果每个地方都写一遍stripMarkdown、replace尾逗号,迟早会在某个没写的角落里崩溃。封装成一个工具类,所有入口走同一个解析管线,出了问题只改一处,这是最实际的收益。
3.2 一个耐脏的转换器长什么样
我设计的自研转换器,核心是三步:从原始文本中提取JSON片段、用Jackson做宽松反序列化、再调用Bean Validation做语义校验。
先看提取JSON片段的逻辑。模型返回的内容里可能包含Markdown代码块、解释性文字、甚至把多个JSON对象拼在一起。一个安全的第一层提取策略是找到第一个{和最后一个}之间的内容,其余部分全部丢弃。
public class JsonExtractor { public static String extractJson(String rawText) { if (rawText == null || rawText.isBlank()) { throw new IllegalArgumentException("模型返回内容为空"); } // 去掉Markdown代码块标记 String cleaned = rawText .replace("```json", "") .replace("```", "") .trim(); int start = cleaned.indexOf('{'); int end = cleaned.lastIndexOf('}'); if (start < 0 || end < 0 || end <= start) { throw new IllegalArgumentException("未找到合法JSON对象,原始内容:" + cleaned); } return cleaned.substring(start, end + 1); } }这个提取器简单但足够稳,前提是你要求模型只返回一个JSON对象,而不是JSON数组。如果要支持数组,可以再加一个[和]的判断分支。
接下来是转换器主体。我用ObjectMapper的configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)先忽略多余字段,再配合TypeReference保留泛型信息。
public class TolerantJsonConverter<T> { private final ObjectMapper objectMapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); private final TypeReference<T> typeReference; public TolerantJsonConverter(TypeReference<T> typeReference) { this.typeReference = typeReference; } public T convert(String rawText) throws Exception { String json = JsonExtractor.extractJson(rawText); // 清洗非法尾逗号:在JSON中,逗号只能出现在成员之间,不能出现在}或]之前 // 这里做一个正则替换,只处理简单场景 json = json.replaceAll(",\\s*}", "}").replaceAll(",\\s*]", "]"); return objectMapper.readValue(json, typeReference); } }注意这里只是"宽松"处理,不能指望它修复所有非法JSON。比如键没加引号、字符串里包含裸换行,这种应该直接进入重试流程而不是硬解析。清洗逻辑越写越复杂反而容易误伤合法JSON,所以我的原则是:只处理高频且可安全替换的问题,其余交给模型重试。
3.3 加一个"模型自纠错"重试循环
自研转换器真正的杀手锏,是和模型形成一个反馈闭环。第一次解析失败时,不要把异常直接抛给业务方,而是把异常信息整理成一段带有指令的文本,连同原始输出一起回传给模型,要求它修正后重新输出。这个操作听起来朴素,实测效果却出奇好,因为模型看到自己的错误文本后,往往能自己意识到问题。
核心代码思路是这样的:
public T callWithRetry(String userMessage, int maxAttempts) { String raw = chatClient.call(userMessage).getResult().getOutput().getText(); for (int i = 0; i < maxAttempts; i++) { try { T result = converter.convert(raw); validator.validate(result); // 业务校验:字段非空、枚举合法等 return result; } catch (Exception e) { if (i == maxAttempts - 1) { throw new IllegalStateException("模型结构化输出多次修正仍失败", e); } String correctionPrompt = """ 你刚才的输出存在以下错误: %s 原始输出如下: %s 请修正后严格按照JSON格式重新输出。 """.formatted(e.getMessage(), raw); raw = chatClient.call(correctionPrompt).getResult().getOutput().getText(); } } throw new IllegalStateException("无法获得结构化输出"); }这里有一点要提醒:不要把e.getMessage()原样拼进Prompt就完事。有些异常信息很长,比如Jackson的堆栈路径,模型看到会分心。更好的做法是只提取业务校验的失败原因,比如"字段status的值waiting不是合法的枚举值[open, closed, pending]"。让模型的修正目标足够明确。
实测下来,两到三次重试能解决绝大多数偶发性问题。但要注意,重试会带来额外的token成本和延迟,所以重试次数不要设置得太大。对于实时接口,我一般设2次;对于异步批处理任务,可以放宽到3次。
4. 路径三:原生调用模型API,越过框架全控请求
4.1 什么时候必须走底层
Spring AI确实方便,但它不可能把每个模型服务商的参数都封装得面面俱到。我遇到过的场景包括:需要同时设置多个stop序列、要为请求头加上自定义鉴权字段、要传入多模态图片数据、要调试模型服务商返回的原始响应结构。这些时候在Spring AI的ChatOptions里找来找去,不如直接拿起HTTP客户端发一次请求。
原生调用还有一个隐藏价值:排查问题。当引导解码配置了没效果、Spring AI自动构造的请求体和你想的不一样时,直接抓一次底层请求,一眼就能看出哪里不对。
4.2 手动发起一次带约束的chat请求
用JDK自带的HttpClient,不额外引入依赖,发一次带JSON模式约束的请求。下面是一个简化但完整的例子,端点用占位符表示。
public class NativeChatClient { private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); private final ObjectMapper objectMapper = new ObjectMapper(); public String call(String userMessage, String apiKey) throws Exception { Map<String, Object> body = new LinkedHashMap<>(); body.put("model", "your-model-name"); body.put("temperature", 0.2); Map<String, Object> responseFormat = new LinkedHashMap<>(); responseFormat.put("type", "json_object"); body.put("response_format", responseFormat); List<Map<String, String>> messages = new ArrayList<>(); Map<String, String> system = new LinkedHashMap<>(); system.put("role", "system"); system.put("content", "你是一个严格输出JSON的助手。"); Map<String, String> user = new LinkedHashMap<>(); user.put("role", "user"); user.put("content", userMessage); messages.add(system); messages.add(user); body.put("messages", messages); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.model-provider.example/v1/chat/completions")) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .POST(BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("模型接口调用失败,状态码:" + response.statusCode() + ",响应:" + response.body()); } JsonNode root = objectMapper.readTree(response.body()); return root.path("choices").path(0).path("message").path("content").asText(); } }这个代码里我特意写了response_format,因为在调试引导解码时,原生接口是最快能验证它是否生效的办法。等你确认请求体和响应结构都没问题,再决定要不要回到Spring AI封装。
4.3 原生调用后的自留地:日志、限流和异常映射
原生调用把控制权拿回来之后,你不能只盯着"能发请求"这个点,还要把工程化问题补上,否则只是换了个地方踩坑。
第一个要补的是日志。Spring AI帮你不打日志你也不知道,自己调接口时就要主动把请求体里的Model、messages条数、响应体里的content长度、耗时、状态码全部打出来。我一般会把请求体里的敏感字段做脱敏,比如对话内容里可能包含用户个人信息,日志里只保留长度和前缀。
第二个要补的是重试与异常映射。模型接口常见429限流、5xx超时、4xx参数错误。你需要在原生调用层做一次异常分类:429和5xx可以退避重试,400通常是参数问题,应该立即失败并告警。如果直接在业务层try-catch一把梭,重试操作会污染业务代码。
第三个要补的是超时控制。模型接口的响应时间波动很大,高峰期可能十几秒。HttpClient的connectTimeout只管连接阶段,完整的请求超时需要用HttpRequest的timeout方法或者异步调用的orTimeout。否则线程池容易被慢请求拖垮。
这条路适合放在一个独立的ModelClient组件里,和Spring AI的ChatClient隔离开。线上默认走Spring AI的封装,遇到封装搞不定的参数,再新开一个@Service专门走原生请求。
5. 三种方案放一起怎么选,我的默认组合
5.1 三条路径的对比
把三条路径放一起,不同的项目阶段和稳定性要求会指向不同答案。我整理了一张对照表:
| 维度 | 引导解码 | 自研工具 | 原生调用 |
|---|---|---|---|
| 作用阶段 | 生成阶段 | 解析阶段 | 请求阶段 |
| 能解决的问题 | JSON语法不合法、混杂无关文本 | 字段缺失、枚举漂移、脏文本清洗 | 框架参数覆盖不全、请求不可控 |
| 实现成本 | 低,改配置即可 | 中,需要写清洗和重试 | 较高,要自己处理HTTP细节 |
| 稳定性收益 | 高,源头减少脏数据 | 极高,兜底和自纠错 | 取决于你的工程质量 |
| 适用阶段 | 日常默认开启 | 所有项目都建议有 | 排查问题、特殊参数、定制客户端 |
从表格可以看出来,这三条路的边界其实很清晰。引导解码管"输出像不像JSON",自研工具管"JSON像不像业务要的POJO",原生调用管"请求有没有完全按你的想法发出去"。三者不冲突,组合使用效果最好。
5.2 我的分层防御组合
我在模拟项目X里的最终方案是这样的:ChatClient正常配置,请求层面能开JSON模式就开,这是第一层防御;自研的TolerantJsonConverter负责所有进入Service层的结果转换,遇到解析失败或者校验失败,自动触发一次带错误信息的模型修正重试,这是第二层;当某个新模型接入后表现异常,我会先用原生调用写一个几十行的调试脚本,把该模型请求和响应仔细过一遍,确认参数生效了,再回到Spring AI统一管理,这是第三层。
这三层之间有一个很重要的顺序:不要把自研解析器做成所有请求都绕不开的笨重环节。如果引导解码效果好,解析器大部分时候只是走个过场,直接返回对象;只有当异常发生时,才进入重试逻辑。这样既保证了稳定性,又没有牺牲太多性能和token成本。
5.3 最小可上手的路线图
如果你从零开始,不用一上来就把三条路全部实现。我建议按这个步骤推进:
- 先建一个POJO,字段尽量精简,枚举值和业务状态保持一致。
- 在
ChatClient调用中加入JSON模式参数,跑通最简单的entity转换。 - 写一个
JsonExtractor,把模型返回文本里的JSON片段稳定提取出来。 - 封装
TolerantJsonConverter,先处理尾逗号和Markdown包裹两个高频问题。 - 加一个
callWithRetry方法,把校验失败信息回传给模型修正。 - 遇到Spring AI封装的参数到不了模型接口时,写原生HTTP客户端验证并排查。
整个过程大概一个工作日就能完成,但它能让你的AI接口从"看运气"变成"可交付"。等你实际跑一段时间,会发现报错集中在几类:新的模型版本行为变化、Prompt里定义的格式和POJO不一致、业务枚举值扩展后模型还没学会。这三类问题在分层防御下都能快速定位。
最后再分享一个我养成的习惯:每次调整输出Schema后,先把模型返回的原始文本保存一份到测试资源目录,别只盯着解析后的对象看。这样一旦某个字段突然漂移了,你能立刻对比出是哪次Prompt改动引起的行为变化。结构化输出这件事,稳定比聪明更重要,留好样本,比你临时加一百条正则都管用。