20 让大模型稳定吐 JSON:Java 端怎么校验兜底
面试官你们那个 AI 应用模型返回的结构化数据是怎么保证不出错的候选人我在 Prompt 里写了请返回 JSON 格式。面试官它每次都乖乖只返回 JSON 吗有没有给你在前面加一句好的这是结果候选人……有过我用了正则把 JSON 抠出来。面试官抠出来之后呢字段少了怎么办数量给你返回个负数怎么办提示里返回的日期是下周一这种词你怎么处理候选人想了半天这个我们后面再统一处理……面试官心里已经打分了。让模型吐 JSON是一句话的事让模型每次都吐对的 JSON是一整套工程。面试官问这个考的不是你会不会调 API而是你有没有把模型的输出当成不可信的外部输入来对待。这篇就把这件事拆开讲怎么让模型稳定吐结构化数据以及 Java 端拿到之后怎么校验、怎么重试、怎么兜底。先想清楚为什么非要结构化大模型天生输出的是自然语言。它喜欢说好的根据您的描述我帮您整理如下……喜欢在结尾加一句希望对您有帮助。但你的下游系统不是人是代码。代码要的是字段product、quantity、deliveryDate。你不可能让代码去读一段散文那不如直接上正则乱猜。结构化输出的本质是让模型把它理解的世界翻译成一份程序能消费的契约。这份契约一旦不稳定下游全乱套——订单建错了、金额算错了、工单派错了。所以在 AIGC 应用里结构化输出不是锦上添花是有下游依赖时的刚需。三条路让模型吐出结构路一JSON Moderesponse_formatOpenAI 兼容接口里有个参数叫response_format。你把它设成 JSON 对象模式模型就会保证返回值是一个合法的 JSON{ model: gpt-4o, messages: [ {role: system, content: 你是订单信息抽取器只返回 JSON。}, {role: user, content: 帮我订 2 台显示器下周一送到上海} ], response_format: { type: json_object } }开了这个之后模型不会再给你前面加好的这是结果了返回的就是一个能直接 parse 的 JSON。但它有个关键限制它只保证这是一个合法 JSON不保证字段名、字段类型、字段值符合你的预期。你想要的字段叫product它可能给你写成productName你想要数量是整数它可能给你写成字符串2。所以 JSON Mode 解决的是语法层的问题语义层还得你自己管。顺带提一句现在的接口还支持更严格的jsonschema模式业界叫 Structured Outputs你直接给模型一份 JSON Schema它会严格按 schema 输出。这比单纯的 jsonobject 强但要注意不是所有模型、所有版本都支持用之前得确认你接的模型能力。路二Function Calling工具调用如果你本来就在用工具调用Function Calling其实可以让抽数据这件事借它的壳。思路是把你想抽取的字段定义成一个工具的入参parameters这个 parameters 就是一份 JSON Schema。然后告诉模型调用这个工具。模型会返回一个tool_call里面的arguments就是它按 schema 填好的结构化数据。这么做的额外好处是字段有 schema 约束模型填得会更规矩。代价是语义上有点绕——你本来不是真的要调工具只是想让它填空。所以 Function Calling 抽数据适合顺便不适合专门为了抽数据硬造一个假工具。路三框架转换器——Spring AI 的 BeanOutputConverter如果你在 Java 里用 Spring AI有一个更省事的办法让框架根据你的 Java 类型反向生成格式说明再把模型返回的文本转回对象。import org.springframework.ai.converter.BeanOutputConverter; // 泛型里传你要的 Java 类型 BeanOutputConverterOrder converter new BeanOutputConverter(Order.class); // getFormat() 会生成一段请按以下格式输出的说明拼进 Prompt String format converter.getFormat(); String prompt 从用户输入中抽取订单信息。 %s .formatted(format); String raw chatClient.prompt().user(prompt).call().content(); // 把模型返回的文本转回 Order 对象 Order order converter.convert(raw);它的价值在于你不用手写那段请返回包含 xxx 字段的 JSON的说明框架会根据Order这个类的结构自动生成。字段改了说明跟着变不容易出现代码改了 Prompt 没改的错位。Spring AI 还提供了更短的写法直接在调用上接一个entity()Order order chatClient.prompt() .user(帮我订 2 台显示器下周一送到上海) .call() .entity(Order.class);.entity(Order.class)一步到位它内部就帮你做了生成格式说明 → 调模型 → 把返回转成对象这三件事。用起来最舒服。三种路子不互斥。实际项目里常见的组合是用框架转换器生成格式说明用 JSON Mode 兜住语法自己再做一层业务校验。下面重点讲这层校验。关键一步把模型输出当不可信输入到这里你拿到了一个Order对象。但请立刻停止相信它。这个对象是模型猜出来的不是你的数据库查出来的。它会怎么骗你举个典型场景- 用户说帮我订两箱牛奶模型把quantity填成2——对- 用户说多订点模型可能填quantity 10——它凭空替你决定了数量- 用户压根没提数量模型可能编一个quantity 1——因为你的 schema 里quantity是必填它不敢留空再看类型和范围。你写的是Integer quantity但 JSON 里给了2字符串反序列化时可能直接抛异常也可能被宽松地转成 2。你期望数量是正数模型给了-1语法上是合法 JSON业务上是胡说。所以合法 JSON 和 业务合法的数据 是两码事。前者是解析器的事后者得靠校验。Java 里现成的校验工具就是Bean ValidationJakarta Bean Validation实现一般是 Hibernate Validator。你在类上挂注解声明这个字段不能为空、这个数必须为正、这个字符串必须匹配某个格式然后让校验器去跑import jakarta.validation.constraints.*; import java.util.List; public record Order( NotBlank(message 商品名不能为空) String product, NotNull(message 数量不能为空) Min(value 1, message 数量至少为 1) Max(value 999, message 数量过大疑似抽取错误) Integer quantity, // 把下周一这种自然语言日期在抽取阶段就要求模型规整成 yyyy-MM-dd NotBlank(message 交付日期不能为空) Pattern(regexp \\d{4}-\\d{2}-\\d{2}, message 日期格式须为 yyyy-MM-dd) String deliveryDate, String address, NotEmpty(message 订单项不能为空) Valid // 级联校验把校验传递到列表里的每个元素 ListOrderItem items ) {} public record OrderItem( NotBlank String product, Positive Integer quantity ) {}这段就是契约。业务规则用注解表达出来数量必须是 1 到 999日期必须是那个格式订单项不能为空。Valid是级联校验——如果items是个列表光校验列表本身不够得让校验器钻进每个OrderItem里也校验一遍。校验怎么触发在业务入口拿到对象后用校验器跑一遍import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ConstraintViolation; import java.util.Set; Validator validator Validation.buildDefaultValidatorFactory().getValidator(); SetConstraintViolationOrder violations validator.validate(order); if (!violations.isEmpty()) { // 有字段不合法 String errors violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining()); // → 走重试或兜底 }Validation.buildDefaultValidatorFactory()会拿到一个默认校验器通常是 Hibernate Validatorvalidate(order)返回所有违反约束的项。每一项都带着错误消息就是你注解里写的那句中文。校验和模型调用是解耦的哪怕哪天你换了个模型、换了家供应商只要它返回的对象过不了校验照样被拦下来。这层是模型无关的安全网。校验不过怎么办重试 兜底校验失败的常见处理是带错误信息重试把你上次哪几个字段错了拼回 Prompt让模型改。public Order extractOrder(String userText) { String feedback ; int maxAttempts 3; for (int i 0; i maxAttempts; i) { try { Order order chatClient.prompt() .user(userText feedback) .call() .entity(Order.class); SetConstraintViolationOrder violations validator.validate(order); if (violations.isEmpty()) { return order; // 校验通过收工 } // 把错误拼回去让模型照着改 feedback \n上次输出存在以下问题请修正后重新输出\n violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining(\n)); } catch (Exception e) { // 反序列化失败模型返回了非法 JSON 或类型对不上 feedback \n上次输出无法解析请严格按格式要求返回合法 JSON。; } } // 重试耗尽 → 兜底 throw new IllegalStateException(结构化输出重试 maxAttempts 次仍未通过校验); }几个细节值得说第一捕获异常。.entity()在模型返回的 JSON 不合法、或者字段类型对不上时会抛异常。不 catch 的话一次抖动就把整个请求打挂了。第二重试要有上限。别写成 while(true)模型要是理解不了你的反馈它会一直错下去把你的 token 额度烧光。三次是个常见的经验值。第三重试可以带退避。如果失败是因为限流rate limit立刻重试只会更糟。可以加个短延迟或者用 Spring Retry、Resilience4j 这类库做退避。这段和第 5 篇《大模型 API 容错》是一脉相承的。第四重试耗尽必须有兜底而且不能静默。兜底策略看业务客服对话场景可以转人工数据抽取场景可以进人工复核队列实在不行就返回一个明确的错误让上游知道这次没抽出来绝不能返回一个半成品对象让下游当真。静默失败比报错可怕得多。还有个提升成功率的实用招结构化任务temperature 调低甚至调到 0。抽取是确定性任务不需要模型发挥创造力温度越低越稳。Schema 演进别让上游一改下游全炸还有一个容易被忽略的坑schema 是会变的。今天Order只有三个字段后来业务要加一个couponCode再后来deliveryDate要拆成开始和结束。你的 Java 类一改旧的调用方、旧的缓存数据、旧的测试用例全可能挂。三条实践加字段别删字段。新增字段给默认值或设为可空老代码不传也不受影响。真要废弃某个字段先标记弃用、观察一段时间、确认没人用了再删。容忍未知字段。反序列化时模型可能多返回了你没定义的字段它热情地加戏。用 Jackson 的话加上JsonIgnoreProperties(ignoreUnknown true)让多出来的字段被忽略而不是直接抛异常。import com.fasterxml.jackson.annotation.JsonIgnoreProperties; JsonIgnoreProperties(ignoreUnknown true) public record Order(String product, Integer quantity) {}需要断代时加版本号。如果新老结构没法兼容比如字段含义变了与其偷偷改不如把新旧两版做成两个类型、加一个version字段区分平滑过渡。schema 演进这件事本质上和传统的接口版本管理是一回事。把模型输出当成对外接口来管思维就对了。一张表三种产出结构化数据的方式方式原理优点局限JSON Moderesponse_format请求声明要 JSON语法稳定接入简单只保证是 JSON字段名/类型不保证Structured Outputs / json_schema给定 JSON Schema模型严格照填字段约束强最接近契约并非所有模型/版本都支持Function Calling把字段定义成工具入参有 schema 约束适合顺带抽参语义上略绕框架转换器BeanOutputConverter / entity()Java 类型反向生成格式说明再转回与 Java 类型一一对应最省事转换失败仍需兜底记住一条主线无论用哪种都逃不过Bean Validation 校验 重试 兜底这道关。 面试官视角的标准回答如果面试官问怎么让大模型稳定输出 JSONJava 端怎么处理我会分三层来做。第一层让模型尽量吐对。可选的手段有三个用 response_format 开 JSON Mode保证返回是合法 JSON或者用 Structured Outputs 给一份 JSON Schema字段约束更强或者用 Function Calling把想要的字段定义成工具入参。在 Spring AI 里我一般直接给 ChatClient 的 call() 接一个.entity(Order.class)或者用 BeanOutputConverter 根据 Java 类型生成格式说明再转换省得手写 Prompt。结构化任务我会把 temperature 调低抽取是确定性任务不需要创造力。第二层把模型输出当成不可信的外部输入。语法合法不代表业务合法。我会用 Jakarta Bean Validation 在 record 上挂注解——NotBlank、Min、Positive、Pattern 之类把必填、取值范围、格式这些业务规则声明出来然后在校验器里跑一遍。这里要注意 Valid 做级联校验覆盖嵌套对象和列表。第三层校验不过就重试和兜底。把校验失败的错误信息拼回 Prompt让模型照着改重试设上限一般三次失败可能来自非法 JSON 或限流要 catch 异常、必要时带退避。重试耗尽必须兜底——转人工、进复核队列或者明确报错绝不能返回半成品让下游当真静默失败最可怕。补一点 schema 演进把模型输出当对外接口管加字段别删字段反序列化加 JsonIgnoreProperties(ignoreUnknowntrue) 容忍未知字段不兼容时用版本号断代。下一篇聊每个上线项目都躲不开的现实问题——成本与并发。线上 QPS 只有 100业务却要 1000怎么设计语义缓存、限流、批量、连接池怎么搭聊完这个你的 AIGC 方案才算能扛住生产。