上周五晚上十一点,我的定时任务告警响了。日志里躺着一行很眼熟的异常: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代码块包裹JSON | json\n{"name":"张三"}\n | Jackson直接抛出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在这里提供了一个统一的抽象,叫StructuredOutputConverter<T>。
它的核心逻辑可以理解成这样一个接口:
public interface StructuredOutputConverter<T> { // 告诉模型“你该输出什么格式” String getFormat(); // 把模型返回的字符串,变成目标Java对象 T convert(String structuredOutput); }其中最常用的实现就是BeanOutputConverter<T>,它的工作方式是:
- 根据你传入的
Class<T>或ParameterizedTypeReference<T>,生成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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>如果你有OpenAI的API Key,引入OpenAI的starter:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>如果你像我一样不想折腾API费用,本地用Ollama跑开源模型也很方便:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-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, List<String> 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=某出行平台, title=Java架构师, skills=[Java, Spring Boot, Redis, Kafka, Kubernetes], workYears=8 ]这里有两点比较有意思。第一,workYears=8并不是简历里直接写的,而是模型推算出的“工作经验年限”。也就是说,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、枚举与自定义StructuredOutputConverter
4.1 返回数组:绕开Java类型擦除
很多场景下,一条消息里可能包含多个实体。比如一份会议纪要里提到了好几个项目人员,你要一次全部抽出来。这时可以直接请求一个List:
public List<PersonInfo> parseResumeList(String text) { return chatClient.prompt() .system("从文本中抽取所有出现的人员信息,逐条放入JSON数组。") .user(text) .call() .entity(new ParameterizedTypeReference<List<PersonInfo>>() {}); }这里有坑,不能直接写entity(List.class)。因为Java泛型存在类型擦除,运行时只知道这是一个List,不知道List里的元素是PersonInfo。JSON反序列化时它不知道应该把每个元素转成什么类型,非常容易报Cannot construct instance of ...。正确的做法是借助ParameterizedTypeReference捕获泛型信息:
new ParameterizedTypeReference<List<PersonInfo>>() {}这是SPring AI能拿到List<PersonInfo>完整类型信息的关键。
4.2 返回Map:可以,但别滥用
有时你只是想让模型返回几个“键名不确定”的字段,比如“从文本中提取所有客户反馈,键为反馈编号,值为反馈内容”,用Map似乎挺方便:
Map<String, String> result = chatClient.prompt() .user("提取反馈,键为编号,值为内容") .call() .entity(new ParameterizedTypeReference<Map<String, String>>() {});但我的建议是:Map尽量少用,尤其不要用Map<String, 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, List<String> 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 MarkdownStrippingConverter<T> implements StructuredOutputConverter<T> { 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, /* 如果有泛型这里需要处理 */); MarkdownStrippingConverter<PersonInfo> 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(Supplier<T> 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语法合法”,不保证“字段值符合业务规则”。模型可能会输出workYears=100这种离谱值,也可能抽出无关人员。
所以我一般在反序列化之后,接一层业务校验,比如:
- 字符串字段非空校验;
- 枚举值白名单校验;
- 数值范围校验(如
workYears必须在0到50之间); - 嵌套对象或数组长度的基本校验。
用Spring的@Valid加@NotBlank、@Min、@Max也可以,但要注意:反序列化失败和业务校验失败是两个阶段。前者是格式问题,后者是语义问题,不能混为一谈。
5.5 上线前先看50个真实样本的失败率
最后一个建议,也是所有接LLM的项目都该做的:别拿一条测试用例验证一次就宣告完成。把50条真实样本跑一遍,统计以下指标:
| 指标 | 观察点 |
|---|---|
| 直接解析失败率 | 有多少次entity()抛异常 |
| 字段为null的比例 | 必填字段缺失情况 |
| 字段值不合理比例 | 类型对但值荒谬 |
| 平均返回延迟 | 是否影响业务响应时间 |
基于这50条样本,你才能真正决定是调提示词、换模型、还是加自定义转换器。我见过太多的项目,开发阶段看起来一切正常,一上真实数据就原形毕露——因为测试数据太干净,根本没触发模型的发挥路径。
我在实际项目里的做法是:先跑50个样本看失败率,把失败样本全部打印出来分类,再决定下一步。这个习惯帮我省下了大量“换模型、调参数”的无用功。结构约束是免费的可靠性,但真正让一个AI功能能扛住生产的,永远是“格式约束+业务校验+合理重试”这个组合拳。