Spring AI结构化输出:JSON Schema让LLM输出可靠结构化数据
Spring AI结构化输出:JSON Schema让LLM输出可靠结构化数据
适读人群:需要从LLM获取结构化数据、苦于解析失败的Java工程师 阅读时长:约18分钟 文章价值:告别手动解析JSON,Spring AI自动类型映射,生产可靠
先说一件真实的事
大概三个月前,一个做猎头软件的朋友老王给我发消息,说他快疯了。
他做了个简历解析功能,让GPT从简历文本里提取候选人信息:姓名、工作年限、技能列表、期望薪资。听起来简单,两天就跑通了。
但跑通不代表能用。
因为LLM的输出格式,完全不受控制。
有时候返回的是干净的JSON:
{"name": "张三", "years": 5, "skills": ["Java", "Spring"]}有时候整数变成了字符串:
{"years": "五年", "salary": "15000元至20000元"}有时候数组变成了逗号分隔:
{"skills": "Java, Spring Boot, MySQL"}有时候LLM心情好,在JSON外面套了一段废话:
好的,我来帮你解析这份简历。以下是结构化信息:
{"name": "张三", "years": 5, ...}
希望对你有帮助!还有一种更坑的——LLM加了markdown代码块标记,原始返回不是纯JSON,而是包含了 ```json 前缀和 ``` 后缀的字符串,你的 JSON.parse() 直接报错。
老王为了兜底,写了一大堆正则表达式,try-catch套try-catch,越写越丑,越写越脆。测试环境好好的,线上三天两头报解析异常。
后来我告诉他Spring AI有结构化输出。
他把那坨代码全删了。
这篇文章就讲这件事。
Spring AI结构化输出的核心原理
在聊代码之前,先把原理搞清楚。否则遇到问题你不知道从哪里下手。
Spring AI解决这个问题的思路其实很朴素:既然LLM不知道你想要什么格式,那就在Prompt里告诉它——不是说"请输出JSON"这种废话,而是给它一份JSON Schema合同。
流程如下:
核心是两件事:
- 生成JSON Schema注入Prompt:把你的Java类结构变成Schema,告诉LLM"你必须严格按这个格式输出,不能有多余文字,不能有markdown"
- 解析返回值映射到Java对象:Jackson负责反序列化,拿到强类型对象
以老王的简历解析为例,Spring AI实际生成并注入到Prompt里的格式指令是这样的:
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response
following this format without deviation.
Do not include markdown code blocks in your response.
Here is the JSON Schema instance your output must adhere to:后面跟着完整的JSON Schema:
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "候选人姓名"
},
"years_of_experience": {
"type": "integer",
"description": "工作年限,整数,如5"
},
"skills": {
"type": "array",
"items": { "type": "string" },
"description": "技能列表,如['Java', 'Spring Boot', 'MySQL']"
},
"education": {
"type": "string",
"description": "最高学历,如本科/硕士/博士"
},
"expected_salary": {
"type": "string",
"description": "期望薪资范围,如'15k-20k',无法确定则为null"
}
},
"required": ["name", "years_of_experience", "skills", "education"]
}"不能有markdown代码块"——这一条明确写在指令里,这正是老王那个问题的根本解法。
基础用法:从LLM提取结构化数据
第一步:定义数据模型
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ResumeInfo(
@JsonProperty("name")
@JsonPropertyDescription("候选人姓名")
String name,
@JsonProperty("years_of_experience")
@JsonPropertyDescription("工作年限,整数,如5")
Integer yearsOfExperience,
@JsonProperty("skills")
@JsonPropertyDescription("技能列表,如['Java', 'Spring Boot', 'MySQL']")
List<String> skills,
@JsonProperty("education")
@JsonPropertyDescription("最高学历,如本科/硕士/博士")
String education,
@JsonProperty("expected_salary")
@JsonPropertyDescription("期望薪资范围,如'15k-20k',无法确定则为null")
String expectedSalary
) {}这里有几个点值得单独说:
@JsonPropertyDescription 是核心。 里面写的描述会被Spring AI原封不动地放进JSON Schema的description字段,直接影响LLM对字段的理解。
写"工作年限"和写"工作年限,整数,如5",效果差别很大。前者LLM可能返回"五年",后者基本都返回5。
@JsonProperty 控制JSON字段名。 Java用驼峰,JSON用下划线,统一规范避免混乱。
@JsonInclude(NON_NULL) 表示空值字段不序列化。 适合"能提取就提取,提取不到就跳过"的场景,不然LLM会强行给你填一个猜测值。
为什么推荐用 record? Java 16+的record自动生成构造器、getter、equals、hashCode,Jackson反序列化兼容好,不需要Lombok。
第二步:使用BeanOutputConverter
@Service
@Slf4j
public class ResumeParsingService {
private final ChatClient chatClient;
private static final String PARSE_PROMPT = """
请从以下简历文本中提取候选人的关键信息。
如果某个字段信息不存在,请设置为null,不要猜测或捏造。
简历内容:
{resume_text}
""";
public ResumeInfo parseResume(String resumeText) {
BeanOutputConverter<ResumeInfo> converter = new BeanOutputConverter<>(ResumeInfo.class);
String response = chatClient.prompt()
.user(u -> u.text(PARSE_PROMPT + "\n\n{format}")
.param("resume_text", resumeText)
.param("format", converter.getFormat())) // 注入JSON Schema指令
.call()
.content();
return converter.convert(response);
}
}进阶用法:ChatClient的entity()方法(最简洁)
Spring AI 1.0 提供了 entity() 方法,把上面的所有步骤全封装了:
@Service
public class StructuredOutputService {
private final ChatClient chatClient;
// 最简洁的结构化输出方式
public ResumeInfo parseResumeDirect(String resumeText) {
return chatClient.prompt()
.system("你是一个简历解析助手,从简历中准确提取结构化信息。不确定的字段设为null。")
.user("请解析以下简历:\n" + resumeText)
.call()
.entity(ResumeInfo.class); // 一行代码完成
}
// 提取列表
public List<JobRequirement> extractJobRequirements(String jobDescription) {
return chatClient.prompt()
.user("从以下职位描述中提取所有职位要求:\n" + jobDescription)
.call()
.entity(new ParameterizedTypeReference<List<JobRequirement>>() {});
}
}
public record JobRequirement(
@JsonPropertyDescription("所需技能名称,如Java/MySQL/Docker")
String skill,
@JsonPropertyDescription("要求级别:必须 或 优先")
String level,
@JsonPropertyDescription("具体要求描述")
String description
) {}entity() 和手动用 BeanOutputConverter 有什么区别?
几乎没区别,entity() 内部就是封装了 BeanOutputConverter。
但有一个场景必须手动用 BeanOutputConverter:当你需要在Prompt里分开放置内容和格式指令的时候。比如用了复杂的模板,需要把格式指令放在特定位置,而不是直接追加到末尾。
简单场景用 entity(),需要精细控制Prompt结构时用 BeanOutputConverter。
复杂嵌套结构
Spring AI对嵌套对象支持很好,生成的JSON Schema会递归包含子类的Schema。
public record ProductInfo(
@JsonPropertyDescription("产品名称")
String productName,
@JsonPropertyDescription("产品类目,如手机/电脑/家电")
String category,
@JsonPropertyDescription("价格,纯数字,如1999.0")
Double price,
@JsonPropertyDescription("规格参数列表")
List<Specification> specifications,
@JsonPropertyDescription("产品卖点列表,每条不超过20字")
List<String> features,
@JsonPropertyDescription("最高分评价")
Review topReview
) {
public record Specification(
@JsonPropertyDescription("规格名称,如内存/存储/颜色")
String name,
@JsonPropertyDescription("规格值,如16GB/512GB/深空黑")
String value
) {}
public record Review(
@JsonPropertyDescription("评价者昵称")
String author,
@JsonPropertyDescription("评分,1-5的整数")
Integer rating,
@JsonPropertyDescription("评价正文")
String content
) {}
}Spring AI生成的嵌套JSON Schema长这样,子类字段也有完整的type和description:
{
"type": "object",
"properties": {
"productName": { "type": "string", "description": "产品名称" },
"price": { "type": "number", "description": "价格,纯数字,如1999.0" },
"specifications": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "规格名称,如内存/存储/颜色" },
"value": { "type": "string", "description": "规格值,如16GB/512GB/深空黑" }
}
}
},
"topReview": {
"type": "object",
"properties": {
"author": { "type": "string", "description": "评价者昵称" },
"rating": { "type": "integer", "description": "评分,1-5的整数" },
"content": { "type": "string", "description": "评价正文" }
}
}
}
}@Service
public class ProductExtractionService {
private final ChatClient chatClient;
public ProductInfo extractFromDescription(String description) {
return chatClient.prompt()
.system("""
你是一个产品信息提取助手。
请从产品描述中提取结构化信息,保持数据准确。
价格只提取数字,如199.0;评分只提取1-5的整数。
""")
.user("请提取以下产品信息:\n" + description)
.call()
.entity(ProductInfo.class);
}
}流式场景下的结构化输出
流式输出不能直接用 entity()——数据是一块一块来的,必须等全部收集完才能解析JSON。
@Service
public class StreamStructuredService {
private final ChatClient chatClient;
public Mono<AnalysisResult> analyzeWithStream(String text) {
BeanOutputConverter<AnalysisResult> converter =
new BeanOutputConverter<>(AnalysisResult.class);
return chatClient.prompt()
.user(u -> u.text("分析以下文本的情感和关键词:{text}\n\n{format}")
.param("text", text)
.param("format", converter.getFormat()))
.stream()
.content()
.collect(Collectors.joining()) // 收集所有流式chunk
.map(converter::convert); // 一次性解析
}
}
public record AnalysisResult(
@JsonPropertyDescription("情感倾向:POSITIVE/NEGATIVE/NEUTRAL 三选一")
String sentiment,
@JsonPropertyDescription("置信度,0.0到1.0之间的小数,保留两位")
Double confidenceScore,
@JsonPropertyDescription("关键词列表,不超过10个")
List<String> keywords,
@JsonPropertyDescription("不超过50字的摘要")
String summary
) {}为什么流式还要用 BeanOutputConverter 而不是 entity()?
因为 entity() 是 call()(阻塞式)的方法,stream() 返回的是 StreamResponseSpec,没有 entity()。流式调用只能手动收集再解析,这是API设计的限制,不是bug。
输出验证:加上JSR-303校验
LLM再配合Schema,偶尔还是会出错。比如:
- 手机号少一位:
"1381234567"(10位) - 金额提取成负数:
"amount": -500 - 枚举值拼错:
"status": "PANDING"而不是"PENDING"
加上Bean Validation做二次校验,是生产环境的标配:
@Service
@Slf4j
public class ValidatedOutputService {
private final ChatClient chatClient;
private final Validator validator;
public OrderInfo extractOrderInfo(String orderText) {
OrderInfo result = chatClient.prompt()
.user(u -> u.text("从以下订单信息中提取关键字段:\n{text}")
.param("text", orderText))
.call()
.entity(OrderInfo.class);
Set<ConstraintViolation<OrderInfo>> violations = validator.validate(result);
if (!violations.isEmpty()) {
String errors = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));
log.warn("提取结果校验失败: {},原始文本: {}", errors, orderText);
throw new ExtractionValidationException("数据提取结果不合法: " + errors);
}
return result;
}
}
// 带校验注解的订单模型
public record OrderInfo(
@NotBlank
@JsonPropertyDescription("订单号,格式如ORD-12345")
String orderId,
@NotNull @Positive
@JsonPropertyDescription("订单金额,正数,如299.0")
Double amount,
@NotBlank
@JsonPropertyDescription("收货人姓名")
String receiverName,
@NotBlank @Pattern(regexp = "\\d{11}")
@JsonPropertyDescription("手机号,11位纯数字,不含+86或破折号")
String phone,
@NotNull
@JsonPropertyDescription("下单时间,ISO格式如2024-01-01T10:00:00")
LocalDateTime orderTime
) {}注意 phone 字段同时有 @Pattern(regexp = "\\d{11}") 和 @JsonPropertyDescription("11位纯数字,不含+86或破折号")——两者相辅相成:注解保证你能检测出错误,Description引导LLM在提取时就按正确格式来。
重试机制:解析失败自动重试
输入文本里包含引号、特殊字符、或者本身就是JSON格式的时候,LLM偶尔会输出不合法的JSON。加个重试,生产更稳:
@Service
@Slf4j
public class RetryableExtractionService {
private final ChatClient chatClient;
private static final int MAX_RETRIES = 3;
public <T> T extractWithRetry(String prompt, Class<T> targetClass) {
BeanOutputConverter<T> converter = new BeanOutputConverter<>(targetClass);
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
// 失败后加强约束提示
String prefix = attempt > 1
? "【严格要求】你必须只输出纯JSON,不能有任何额外文字或markdown代码块标记。\n\n"
: "";
String response = chatClient.prompt()
.user(prefix + prompt + "\n\n" + converter.getFormat())
.call()
.content();
T result = converter.convert(response);
if (attempt > 1) {
log.info("第{}次尝试提取成功", attempt);
}
return result;
} catch (Exception e) {
log.warn("第{}次提取失败: {}", attempt, e.getMessage());
if (attempt == MAX_RETRIES) {
throw new ExtractionException("提取失败,已重试" + MAX_RETRIES + "次", e);
}
}
}
throw new IllegalStateException("unreachable");
}
}我自己踩过的几个坑
做了这么多Spring AI项目,有几个坑特别想说一下,网上基本找不到:
坑1:@JsonPropertyDescription 写得太简单
我曾经写过 @JsonPropertyDescription("薪资"),结果LLM有时候输出数字,有时候输出字符串,有时候直接输出"面议"。后来改成 @JsonPropertyDescription("期望薪资范围,字符串格式如'15k-20k',无法确定则为null"),稳定多了。
描述越精确,LLM越不容易发挥。你得把"边界条件"也写进去。
坑2:嵌套对象的子类字段忘加注解
父类写得很详细,子类一个 @JsonPropertyDescription 都没加。结果生成的JSON Schema里子类字段只有类型,没有任何描述,LLM对子类字段的填充质量大幅下降。
嵌套结构每一层都要加注解,不能只加外层。
坑3:输入文本里本身包含JSON
如果你让LLM解析的原始文本里本身包含JSON(比如某些API调用日志),偶尔会出现LLM把原始文本里的JSON和输出格式混在一起的情况。
解决方法:在System Prompt里明确说:"输入内容里可能包含JSON格式的数据,但你只需要按照格式要求提取字段并输出,不要受输入内容中JSON格式的干扰。"
坑4:Temperature忘了调低
默认Temperature通常是0.7甚至更高,在结构化输出场景会导致更多格式偏差。建议降到0.1-0.3:
chatClient.prompt()
.options(OpenAiChatOptions.builder()
.temperature(0.1)
.build())
.user(...)
.call()
.entity(MyClass.class);这一条很多人忽略,但效果很明显。
常见类型对比
| 输出类型 | Spring AI方式 | 适用场景 |
|---|---|---|
| 单个对象 | .call().entity(MyClass.class) | 提取单条结构化数据 |
| 对象列表 | .call().entity(new ParameterizedTypeReference<List<T>>(){}) | 提取多条记录 |
| Map结构 | .call().entity(Map.class) | 字段不确定的动态场景 |
| 枚举值 | BeanOutputConverter<MyEnum> | 分类、意图识别 |
| 布尔值 | BooleanOutputConverter | 是/否判断 |
最后
老王把简历解析那坨正则代码全删了,换成 entity(ResumeInfo.class),代码从120行变成了20行,线上解析成功率从92%涨到了99.2%。
剩下的0.8%是格式极度奇特的简历(有人把整份简历写成一首诗),加上重试机制后处理率达到了99.8%。
我在项目里见过太多"LLM功能上线了但不敢放量"的情况,根本原因就是输出不可控,一旦解析失败,整个链路就断掉了。
把结构化输出这一关打通,AI功能才算真正生产就绪。
