不知不觉,Spring AI 系列已经写到第 24 篇了。前几篇我们把 ChatClient、Prompt、RAG、Function Calling 都过了一遍,这次聊一个我在实际项目中几乎每个功能都会撞上的问题:怎么让大模型返回的数据,不是一段“人话”,而是一份干净、直接能用的结构化数据。
这事儿的痛点在真实场景里太常见了。上个月我做一个合同要素抽取的功能,输入是一份几十页的招标文件,我要从里面提取“项目编号、采购人、预算金额、投标截止时间”这几个字段。第一次用最朴素的方式让模型返回 JSON,结果模型给了我一段解释:“好的,根据文档内容,我提取到以下信息:项目编号是 XX...”然后才跟着一段不完整的 JSON,还带 ```json 代码块标记。我写了个正则清洗,刚跑通一个文档,换一个文档格式又变了,直接被逼疯。
后来我把项目升到 Spring AI 1.x,认真用了它的结构化输出 API,这个问题才算真正解决。Spring AI 1.x 把这个需求封装成了一套非常顺手的 API:你定义一个 Java record,框架负责把“让模型输出指定 JSON 格式”的提示词自动塞进请求,然后在拿到模型回复后自动解析成对应的 Java 对象。你不需要手写格式提示词,不需要写解析器,更不需要面对那堆乱七八糟的清洗逻辑。这篇文章我就把这套 API 从接口到实战,再到我踩过的坑,完整拆一遍。
1. 为什么需要结构化输出
1.1 大模型输出的“自由”反而是问题
大模型的本职工作是“预测下一个 token”,不是“严格遵守数据格式”。你可以通过提示词要求它输出 JSON,但它并没有内置的、百分百可靠的保证机制。实际生产里,模型回复可能长这样:
text复制根据您的要求,以下是提取出的结果:
```json
{
"name": "张三",
"age": 28,
"city": "杭州"
}
如果您还有其他需求,请随时告诉我。
code复制
这段内容从人的视角完全正常,但对程序来说是灾难。你要处理前置解释、尾部补充、代码块包裹,还要担心名字是“张三”还是“Zhang San”,年龄到底是不是字符串“28”。
我习惯用一个类比:让一个优秀实习生写报告,他内容写得很好,但格式总有自己的喜好,一会儿用表格一会儿用列表,一会儿中文冒号一会儿英文冒号。你需要的不是一个更聪明的实习生,而是一套“无论谁来写,交上来格式都一致”的模板和检查流程。结构化输出 API 干的就是这件事,只不过模板和检查都由 Spring AI 自动完成了。
### 1.2 结构化输出在做什么
Spring AI 1.x 的结构化输出,本质上是把“让模型按格式输出 + 把输出转成类型”这两步走封装成了标准接口。你在业务代码里做的事情极其简单:
```java
Person person = chatClient.prompt()
.user("张三今年28岁,住在杭州")
.call()
.entity(Person.class);
框架在背后帮你做了三件事:第一条,根据 Person.class 生成 JSON Schema 或格式说明,通过提示词注入给模型;第二条,调用大模型拿到原始文本;第三条,把原始文本清洗、反序列化成 Person 对象。如果解析失败,还有容错机制兜底。
这套能力解决的不只是“解析 JSON”这个小事。它更大的价值是让“大模型返回”这件事在代码层面变成了一次普通的类型转换,后面的逻辑不用再关心“这段文本是不是合法 JSON”“字段够不够全”“要不要转义”,类型不匹配在运行早期就能暴露出来,而不是等到写库的时候才炸。
对于“用 Spring AI 开发 Agent、做 NL2SQL、做文档解析生成结构化文本(比如合同、招标文件里按页码/章节/段落输出字段)”这些场景,结构化输出几乎可以说是必经之路。你不可能让下游系统直接消费大模型的自由文本,除非你想体验什么叫“线上的不确定性”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心 API 全家桶
2.1 StructuredOutputConverter 接口
Spring AI 1.x 所有结构化输出转换器,都实现了同一个接口:
java复制public interface StructuredOutputConverter<T> {
String getFormat();
T convert(String text);
}
这个接口非常薄,只有两个方法。getFormat() 返回的是要给模型看的格式说明字符串,它会在请求时被拼到提示词里,告诉模型“你应该输出什么形状的东西”;convert(String text) 接收模型返回的原始文本,输出转换好的目标类型 T。
选这个设计的原因,我理解是把“提示词生成”和“结果解析”绑定在同一个对象里。你拿到一个 converter,既能知道怎么引导模型,又知道怎么解析结果,不会出现“提示词是一套、Parser 是另一套”的割裂感。
实际开发中,实现这个接口最多的场景是:公司内部有自定义的输出协议,比如加密串、压缩格式、特定编码,或者团队希望把解析结果先写日志再往下游传。但绝大多数场景我们不需要自己实现,直接用 Spring AI 内置的几个实现就够了。
2.2 BeanOutputConverter:主力转换器
BeanOutputConverter<T> 是我用得最频繁的转换器,没有之一。它做的事情,是把任意 Java Bean 或 record 类型作为目标,让模型输出匹配的 JSON,然后自动反序列化。
核心用法有两种。第一种是拿到格式模板,手动拼进自己的提示词:
java复制BeanOutputConverter<Person> converter = new BeanOutputConverter<>(Person.class);
String prompt = """
请从用户输入中提取人物信息,严格按照以下格式返回 JSON:
{format}
用户输入:{input}
""".replace("{format}", converter.getFormat())
.replace("{input}", "张三今年28岁,住在杭州");
String text = chatClient.call(prompt);
Person person = converter.convert(text);
第二种更简洁,不需要自己拼提示词,直接用 ChatClient 的 .entity() 方法(后面详细讲)。
为什么这类用 Java record 写数据类?因为 record 的字段就是天然的 JSON 字段名,配合 Jackson 反序列化零配置。我项目里基本全用 record 定义输出结构,省掉一大坨 Lombok 和 getter/setter。
2.3 MapOutputConverter 与 StringOutputConverter
不是所有输出都能提前定义成固定结构,比如某些场景需要动态 key,或者你想先把模型输出一次性地丢给大模型自己发挥,然后再人工提炼。这时候用 MapOutputConverter:
java复制MapOutputConverter converter = new MapOutputConverter();
String format = converter.getFormat();
String text = chatClient.call("列出北京、上海、杭州三个城市的主要产业,按城市分组输出");
Map<String, Object> result = converter.convert(text);
MapOutputConverter 会引导模型输出一个 JSON 对象,然后解析成 Map<String, Object>。它适合数据结构不确定、业务下游可以容忍动态 Map 的场景,缺点是失去了类型安全,取值时得自己做类型判断和强转。
StringOutputConverter 就更简单了,它基本不做转换,convert() 返回原字符串。它的意义在于让你在 .entity() 这套统一接口里,能拿到“未经过类型转换的原始文案”,适合丢给流程里后续环节继续处理。我实际中用的不多,但它参与实现了结构上的完备性,可以当做一个“透传转换器”理解。
2.4 ParameterizedTypeReference:解决泛型擦除
做 Spring AI 结构化输出,最容易卡住的点是 Java 的泛型擦除。你直接写 entity(List.class),底层拿到的是一个裸类型,Jackson 根本不知道 JSON 数组里的元素该转成什么,反序列化结果可能是 List<LinkedHashMap>,而不是 List<Person>。
解决办法是使用 ParameterizedTypeReference 保留泛型信息:
java复制List<Person> people = chatClient.prompt()
.user("从这段文本中提取所有人物信息:...")
.call()
.entity(new ParameterizedTypeReference<List<Person>>() {});
类似地,BeanOutputConverter 也支持传入 ParameterizedTypeReference:
java复制BeanOutputConverter<List<Person>> converter =
new BeanOutputConverter<>(new ParameterizedTypeReference<List<Person>>() {});
这个细节非常重要。我第一次用的时候图省事,直接 entity(List.class),结果后续代码拿到的元素全是 LinkedHashMap,强转 Person 一直报 ClassCastException。排查了半小时才意识到是泛型擦除的问题。只要涉及集合、嵌套泛型,一律用 ParameterizedTypeReference,别偷懒。
3. ChatClient 里的一行式转换
3.1 entity(Class) 基础用法
Spring AI 1.x 最舒服的用法,是让 ChatClient 在调用链上直接给结果定型。定义一个目标类型:
java复制public record Person(String name, Integer age, String city) {}
然后:
java复制Person person = chatClient.prompt()
.system("你是信息抽取助手,只能输出 JSON")
.user("张三今年28岁,住在杭州")
.call()
.entity(Person.class);
entity(Person.class) 这一步就是关键。框架在请求阶段自动生成了 JSON 格式提示词,注入到用户的请求上下文中;响应回来之后,框架拿到原始文本,走 BeanOutputConverter 完成反序列化,最终返回一个真正的 Person 对象。
这段代码看起来很短,但别小看它。以前用 0.8.x 或自制方案,这个需求要写提示词模板、写解析工具、写异常兜底,还要在多个 Service 里复制粘贴。现在一行搞定,而且类型安全是编译期就能确认的:Person 类字段一变,改一处即可。
3.2 entity(ParameterizedTypeReference) 输出集合
真实场景里,单个对象很少,更多的是“从一份文档里提取多条记录”。比如我从一份简历堆里提取所有候选人的姓名、工作年限、期望薪资:
java复制record Candidate(String name, Integer workYears, String expectedSalary) {}
List<Candidate> candidates = chatClient.prompt()
.system("你是招聘信息结构化助手")
.user("提取以下简历中的候选人信息:...")
.call()
.entity(new ParameterizedTypeReference<List<Candidate>>() {});
这里必须强调的是:参数必须传 new ParameterizedTypeReference<List<Candidate>>() {},而不是 Candidate.class 或 List.class。前者无法表达集合语义(因为 entity(Class) 会把整个返回当作一个对象,而不是对象列表),后者丢泛型。正确姿势就是我前面提到的,集合泛型永远走 ParameterizedTypeReference。
我实际项目里,这种“文档输入 + 结构化列表输出”的组合特别常用,比如合同条款提取、招标文件的资格要求列表抽取、PDF 解析后的分章节结构化,本质上都是同一个套路。
3.3 手动两步法:什么时候用
entity() 虽然方便,但它有个特点:你拿到的是最终结果,拿不到中间的原始文本。有些场景必须拿原始文本,比如你要记录模型的完整返回做审计,比如你集成了流式输出想先累积文本再统一转换,再比如你想在转成对象前做一层自定义的字符串清洗。
这时候用“手动两步法”:先用 call().content() 拿文本,再用 converter 转换。
java复制BeanOutputConverter<Contract> converter = new BeanOutputConverter<>(Contract.class);
String text = chatClient.prompt()
.system("你是合同要素抽取助手")
.user(u -> u.text("从以下合同文本中提取要素: {input}")
.param("input", contractContent))
.call()
.content();
Contract contract = converter.convert(text);
注意我这里的 .user(u -> u.text(...).param(...)) 用法,它允许你在运行时给模板填充参数,避免用字符串拼接把提示词搞得一团糟。这一步看着多写了几行,但换来的是:你能在 convert 前后做日志埋点,能在解析失败时拿到原始文本做人工兜底。这是生产环境里很实用的折中方案,不要觉得麻烦就省略。
3.4 数据类设计直接影响提取效果
很多人以为结构化输出只要定义了类就行,实际上类怎么设计直接决定了模型能不能准确映射。比如你想让模型提取“总金额”,字段名如果叫 totalAmount,模型大概率能理解;如果叫 x、y、field1,模型就很容易给错。
我踩过一个典型的坑:字段名用缩写和拼音。某个需求里有个字段叫 ztmc(项目名称的拼音缩写),结果模型经常把这个字段留空或者随便填一个值上去。改成 projectName 之后,准确率立刻上来了。大模型是通过语义理解字段含义的,字段名越接近自然语言表达,越少出岔子。
另外,Java record 的字段类型也要尽量贴合语义。金额用 BigDecimal 或 String,数量用 Integer,千万别图方便全都放 String。类型约束可以让解析阶段更健壮,但注意,如果模型返回的字符串里带了下划线“1_000”,Jackson 默认是会拒绝转成数字的,这种问题在测试阶段就要盯住。
4. 底层机制与进阶配置
4.1 getFormat() 到底生成了什么
很多读者好奇,getFormat() 返回的字符串到底长什么样。以 Person 为例,JsonSchemaGenerator 会根据 record 结构生成大致如下的 JSON Schema:
json复制{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "integer"
},
"city": {
"type": "string"
}
},
"required": ["name", "age", "city"]
}
Spring AI 会把这段 Schema 包装成模型能理解的格式指令,大概意思是:你的响应必须是 JSON,不要包含任何额外文本或解释,JSON 必须符合这个 Schema。所以当你用 .entity(Person.class) 时,不要以为模型是在“理解你的需求”,它其实是在“服从格式约束”,两者差别很大。
这里有个使用心智:格式提示词对模型的“压制力”是有限的。你越是用 r1、deepseek-r1 这类强推理模型,它可能越倾向于先解释再输出;你越是想让它输出深层嵌套 JSON,越容易出错。所以提示词里最好再叠一句“只输出 JSON,不要解释”,别全指望 getFormat() 一个字符串打天下。
4.2 容错解析与 FencedJsonParser
即便格式提示词写得再清楚,模型还是可能返回带 ```json 代码块的内容,或者在 JSON 前后加一句“这是结果”。Spring AI 的转换器内置有容错解析能力,其中我很喜欢的一个组件是 FencedJsonParser,它专门处理被 Markdown 代码块围起来的 JSON。
大致逻辑是:先用 Jackson 尝试直接解析;失败的话,检测文本里是否包含 ```json 代码块,如果有就提取代码块里的内容再次解析。这套机制保证了只要模型“大体按格式输出”,哪怕外面裹了一层壳,也能拆出可用的 JSON。
我自己手动封装过类似的逻辑,所以看到框架内置时很感慨,这种边缘场景真的只有跑过线上的人才会当回事。没有容错解析的时候,一次“```json” 包裹就能让整个同步任务失败,用户侧看到的是一片空白或者错误提示,排查起来非常狼狈。
4.3 嵌套对象与集合的复杂场景
文档解析这类场景,输出结构几乎不可能扁平,通常是一层套一层。好在 record 天然支持嵌套:
java复制public record Company(
String name,
List<Department> departments
) {}
public record Department(
String name,
List<Employee> employees
) {}
public record Employee(
String name,
String position,
Integer salary
) {}
直接:
java复制Company company = chatClient.prompt()
.user("提取下面这家公司的组织架构:...")
.call()
.entity(Company.class);
嵌套越多,模型出错率越高,这是客观规律。我的经验是两个缓解手段:第一,把复杂输出拆成多个结构化调用,先提取外层再针对每个外层元素做二次提取,而不是逼着模型一口气生成深度三层以上的 JSON;第二,在 system 提示里明确地说明层级关系,比如“公司下包含多个部门,部门下包含多个员工”。
再补充一个细节:嵌套结构里,如果某个子列表为空,模型可能给一个空数组 [],也可能直接不给这个字段。如果你的业务要求字段必填,可以在 record 字段上加上约束,或者在拿到对象后主动校验和处理。别以为模型输出非空就是标准,表驱动的心态在这类场景里很重要。
4.4 字段映射与日期处理
模型输出的字段名不一定和 Java 字段名完全一致。比如文档里写的“Full_Name”,模型在 JSON 里可能输出 full_name,你的 record 字段是 fullName,直接反序列化后该字段就为 null。这时候用 Jackson 注解显式指定字段名:
java复制public record Person(
@JsonProperty("full_name") String fullName,
@JsonFormat(pattern = "yyyy-MM-dd") LocalDate birthDate
) {}
日期字段是大坑。模型通常会把日期输出成“2025年6月18日”或者“2025/06/18”这种格式,LocalDate 默认解析不了。我建议方案是:要么在提示词里明确日期格式,要么用 @JsonFormat 给一个宽松的解析模式,要么干脆用 String 接收日期然后业务层自己转。
这里要提醒的是,@JsonFormat 只对 Jackson 生效,对模型本身没有任何约束。想让模型按格式输出日期,还是要靠提示词。有效的做法是在 system 提示里写明:“日期统一输出为 yyyy-MM-dd ISO 格式”。格式提示词、JSON Schema、注解三者配合,才能把解析的成功率拉到比较高的水平。
5. 常见问题与排坑实录
5.1 JSON 解析失败,异常信息看不懂
症状是老朋友了:JsonProcessingException,或者 UnrecognizedPropertyException,或者 MismatchedInputException。排查顺序我建议这样走:
先看原始返回。不要只看异常堆栈,把 call().content() 的原始文本打出来,肉眼检查它在格式上到底哪一步离谱。是多了前缀,还是字段名大小写不对,还是某个值带了单位。
再看类型匹配。"age": "28" 这种字符串年龄,映射到 Integer 必挂。遇到这种高频问题,我会在数据类里故意把可能被模型输出成字符串的字段设为 String,进入业务层再转换,减少解析失败的触发面。
最后看模型选择。同一个提示词,小模型和今天能跑的大模型的表现差距很大。如果解析频繁失败,先换一个指令遵循能力更强的模型做对照实验,别急着改代码。
5.2 实体返回了,但字段一直是 null
这个坑非常隐蔽,因为它不报错,只是返回对象里某个字段是 null。最常见的三个原因:
第一,字段名不一致。模型输出 userName,record 字段是 name,就没映射上。高发场景是模型按自然语言给字段起了同义词,比如“城市”它写 city,你写 cityName。处理方式就是 @JsonProperty("city") 显式映射,或者把 record 字段名改成和真实语义一致的词。
第二,model 把字段放在嵌套对象里了。比如你想让它输出 contractName,它极可能输出 { "contract": { "name": "..." } }。这种就属于提示词没把结构约束清楚。我一般会在提示词模板里给一个完整的示例 JSON,让模型照着抄,效果比只给 Schema 好得多。
第三,可选链式提取失败。如果你用的是 Function Calling 或 Agent 多轮调用,第二次调用的输入上下文里没有第一次的字段了,结果模型“合理”地返回了一个最小化 JSON,该给的字段没给。这种问题要靠上下文保留和 prompt 编排去解决,不是结构化输出 API 单点能兜住的。
5.3 流式输出与结构化输出怎么调和
.entity() 走的是同步请求,stream() 走的是流式响应,两者在 Spring AI 1.x 里不是天然兼容的关系。很多人想一边流式渲染一边结构化解析,结果发现拿不到最终的完整 JSON。
我踩过坑后的结论是:流式和结构化输出本质上水火不容。结构化输出要求“拿到完整文本后才能解析”,流式输出追求“边拿边显示”,两者逻辑就是冲突的。折中方案有两种:
第一种,先流式把文本渲染给用户看,同时在服务端把完整文本按块累积起来,complete 事件之后再做一次结构化转换。相当于所有展示、解析分离,展示走流,解析走完整文本,体验和性能我全都要。
第二种,放弃流式,直接用同步调用 + 手动两步法。用户感知不到几秒钟的延迟,换来的是代码简单和解析可靠。我这里特别想说了,很多功能真的没必要追求流式,结构化数据展示本来就不可能像对话一样逐步“长出”一行行 JSON,硬做流式只会给自己加戏。
5.4 输出被截断怎么办
模型有 token 上限,你让它一口气输出几百条结构化记录,它很容易在 JSON 写到一半就断了,返回的文本连 } 都不闭合。这时候 BeanOutputConverter 也会无能为力,因为 JSON 根本不完整。
最优解是换模型或扩 maxOutputTokens,不太优雅但最直接。其次是把任务拆小,比如一次只提取 20 条,分成多轮调用来累积结果。还有一种常见做法是提示词里要求“如果内容太多,只输出前 N 条最重要的信息”,牺牲完整性保证格式可用。
我见过很多团队在这里疯狂调提示词,但本质上是输出长度和模型能力不匹配,提示词救不了根本问题。拆数据、控长度、加配额判断,哪个都比硬刚靠谱。
5.5 模型不按格式输出,还能怎么做
总有那么几次,模型完全无视你的 JSON 格式要求,输出了一段优美的自然语言。这时候先别骂模型,回头检查三点:你是不是在 .system() 里同时塞了太多任务,让格式指令被稀释了;你有没有给示例(few-shot);你用的模型是不是本身格式遵循能力就差。
如果这些都排查过了,还有一条路径叫“Q&A 后处理”:把模型当成话痨,你把它输出塞回一次“整理格式”的调用,让一个专门的格式整理 Agent 把自然语言重新整理成 JSON。这层后整理逻辑可以做成兜底链,虽然多花一次 token,但能在关键业务上把成功率拉到一个可接受的水平。
6. 我的几条实战心得
结构化输出 API 用久了,我自己沉淀了三条比较受用的经验,分享出来可能对你有帮助。
第一,所有输出类型的 record,全部集中放在一个包里,比如 dto/ai/,命名上直接叫 ContractExtractionResult、PersonInfoResult。这样你能一眼看出哪些类是给 AI 输出用的,和数据库实体、接口请求体完全隔离。AI 模型的输出接口应该被视为外部系统,它和业务模型的耦合越少,越不容易污染核心代码。
第二,converter 最好封装一层统一工具方法。我项目中写了一个 AiOutputs 工具类,提供 parse(Class, text)、parseList(ParameterizedTypeReference, text) 之类的静态方法,内部统一走 BeanOutputConverter,并做统一异常包装。这样所有调用点的风格一致,将来想换解析策略、加日志、接入监控,改一个地方就够。
第三,不要把所有希望都押在框架的容错上。我见过很多人用了 .entity() 就以为高枕无忧,结果生产环境里模型版本一换,输出风格变化,原封不动的代码开始小概率报解析失败。结构化输出的本质是一次“格式协商”:提示词引导、Schema 约束、解析兜底,三者缺一不可。上线前多跑几十条真实输入,把耗时、失败率、字段为空的概率都统计清楚,比什么都重要。
说实话,Spring AI 1.x 这一套结构化输出 API,刚开始用会觉得很“魔法”,但深入理解 getFormat() 和 convert() 的分工之后,你会发现它只是把 AI 工程的脏活累活标准化了。我的建议是,先按这篇文章把 BeanOutputConverter 手动流程跑通一次,再切换到 .entity(),你会对它背后的设计有完全不同的体会。
