Spring AI 的 Structured Output 踩坑全记录
Spring AI 的 Structured Output 踩坑全记录
去年底我们在做一个合同信息抽取的功能,需要从大段文字里提取出合同编号、金额、甲方乙方、生效日期这些字段。
我信心满满地写了个 Java Bean,配上 BeanOutputConverter,本地测了十几次,完美。推到测试环境,跑了三天,到第四天早上一看 error log,有将近 30% 的请求解析失败。
报错是 JsonParseException。
我第一反应是:模型返回了非法 JSON。
去看日志,模型的原始输出是这样的:
```json
{
"contractNo": "HT-2024-001",
"amount": 150000.00,
"partyA": "北京某某科技有限公司",
"partyB": "上海某某贸易有限公司",
"effectiveDate": "2024-01-01"
}
```你看出问题了吗?外面有 markdown 的代码块标记 ```json 和 ```,而 BeanOutputConverter 直接拿这个字符串去做 JSON 解析,当然炸了。
更气的是,本地测不出来。因为本地用的是 GPT-4,它很乖,基本不加 markdown 格式。测试环境接的是 Claude,Claude 在 system prompt 里有一句「返回格式良好的内容」,它就把 JSON 用代码块包了起来,逻辑上没毛病,但破坏了我的解析。
这就是 Structured Output 最让人头疼的地方:你永远不知道模型什么时候会「好心办坏事」。
三种 OutputConverter 的适用场景
Spring AI 提供了三个开箱即用的 Converter,先说清楚它们各自能干什么。
BeanOutputConverter
最常用的一个,把模型输出直接映射到 Java 对象。它背后用的是 Jackson,所以你的 Bean 要遵循 Jackson 的规则。
@Data
public class ContractInfo {
@JsonProperty("contractNo")
private String contractNo;
@JsonProperty("amount")
private BigDecimal amount;
@JsonProperty("partyA")
private String partyA;
@JsonProperty("partyB")
private String partyB;
@JsonProperty("effectiveDate")
@JsonFormat(pattern = "yyyy-MM-dd")
private LocalDate effectiveDate;
}使用方式:
BeanOutputConverter<ContractInfo> converter = new BeanOutputConverter<>(ContractInfo.class);
String formatInstructions = converter.getFormat();
// getFormat() 会生成类似这样的指令:
// "Your response should be in JSON format.
// The data structure for the JSON should match this Java class: ContractInfo
// ..."
PromptTemplate promptTemplate = new PromptTemplate("""
从以下合同文本中提取关键信息:
{contractText}
{format}
""");
Prompt prompt = promptTemplate.create(Map.of(
"contractText", contractText,
"format", formatInstructions
));
ChatResponse response = chatModel.call(prompt);
ContractInfo info = converter.convert(response.getResult().getOutput().getContent());BeanOutputConverter 的 getFormat() 会自动生成一段提示词,告诉模型要输出什么结构的 JSON。这个提示词是它的核心,也是它的软肋——它只能「建议」模型,不能「强制」模型。
适用场景:字段明确、结构固定的数据抽取;模型可靠(GPT-4、Claude)的场景。
不适用场景:字段嵌套层级很深;有多态的结构(比如不同类型的合同有不同字段);需要严格校验的场景。
MapOutputConverter
更宽松的一种,输出是 Map<String, Object>,不需要预先定义 Bean:
MapOutputConverter converter = new MapOutputConverter();
String response = chatClient.prompt()
.user("分析这段文本的情感倾向,返回 sentiment、score、keywords 三个字段")
.call()
.content();
Map<String, Object> result = converter.convert(response);
String sentiment = (String) result.get("sentiment");
Double score = (Double) result.get("score");适用场景:字段不确定或动态变化;快速原型阶段;输出结构由模型决定的场景。
踩坑:Map<String, Object> 里数值类型是 Double 不是 Integer,如果你的代码写了 (Integer) result.get("count") 直接 ClassCastException。这个坑我也踩过,还好是在测试阶段发现的。
ListOutputConverter
输出列表,比如让模型生成关键词列表、步骤列表之类的:
ListOutputConverter converter = new ListOutputConverter(new DefaultConversionService());
String response = chatClient.prompt()
.user("列出 Java 开发中最重要的 10 个设计原则,只列标题")
.call()
.content();
List<String> principles = converter.convert(response);踩坑:ListOutputConverter 用的是逗号分隔的格式,不是 JSON 数组。如果模型返回了带序号的列表(1. xxx\n2. xxx),直接就解析成一个只有一个元素的列表了。
最麻烦的坑:模型输出了 Markdown 格式的 JSON
回到开头说的那个问题。
这个问题本质上是:模型的「格式化习惯」和你的「解析期望」不一致。
三种解法,我都试过:
解法一:在 Prompt 里明确说不要 markdown
String systemPrompt = """
你是一个数据提取助手。
重要:你的输出必须是纯 JSON,不要用 ```json 代码块包裹,不要有任何其他文字。
直接输出 JSON 对象,以 { 开头,以 } 结尾。
""";有效果,但不是 100% 可靠。大约能降低到 5% 的失败率。
解法二:在解析前做清洗
public String cleanJsonOutput(String raw) {
if (raw == null) return null;
String cleaned = raw.trim();
// 移除 ```json ... ``` 包裹
if (cleaned.startsWith("```json")) {
cleaned = cleaned.substring(7);
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.substring(3);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
return cleaned.trim();
}这个方法简单粗暴,但处理不了所有情况,比如 JSON 之前还有一段解释文字。
解法三:用正则抽取 JSON 片段
public String extractJson(String raw) {
if (raw == null) return null;
// 尝试找到最外层的 {} 包裹的内容
Pattern pattern = Pattern.compile("\\{[\\s\\S]*\\}", Pattern.MULTILINE);
Matcher matcher = pattern.matcher(raw);
if (matcher.find()) {
return matcher.group();
}
// 尝试找到 [] 包裹的内容(List 场景)
Pattern arrayPattern = Pattern.compile("\\[[\\s\\S]*\\]", Pattern.MULTILINE);
Matcher arrayMatcher = arrayPattern.matcher(raw);
if (arrayMatcher.find()) {
return arrayMatcher.group();
}
return raw;
}这个方法更稳健,但有一个边界问题:如果模型返回的文字里恰好有 { 和 } 但不是你要的 JSON,就会抽出错误的内容。
最终我用的是:Prompt 里明确约束 + 解析前清洗 + 校验兜底。
自定义 OutputConverter 加校验逻辑
上面三种解法是「治标」,更好的做法是在 OutputConverter 里统一处理,并且加上字段校验。
public class ValidatingBeanOutputConverter<T> implements OutputConverter<T> {
private final BeanOutputConverter<T> delegate;
private final Class<T> targetType;
private final Validator validator;
public ValidatingBeanOutputConverter(Class<T> targetType) {
this.delegate = new BeanOutputConverter<>(targetType);
this.targetType = targetType;
this.validator = Validation.buildDefaultValidatorFactory().getValidator();
}
@Override
public T convert(String source) {
// Step 1: 清洗原始输出
String cleaned = preProcess(source);
// Step 2: 调用原始 converter 解析
T result;
try {
result = delegate.convert(cleaned);
} catch (Exception e) {
throw new OutputParseException(
"JSON 解析失败,原始输出:" + source + ",清洗后:" + cleaned, e);
}
// Step 3: Bean Validation 校验
if (result != null) {
`Set<ConstraintViolation<T>>` violations = validator.validate(result);
if (!violations.isEmpty()) {
String errors = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; "));
throw new OutputValidationException("字段校验失败: " + errors);
}
}
return result;
}
@Override
public String getFormat() {
return delegate.getFormat();
}
private String preProcess(String raw) {
if (raw == null) return null;
String cleaned = raw.trim();
// 移除 markdown 代码块
if (cleaned.startsWith("```json")) {
cleaned = cleaned.substring(7).trim();
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.substring(3).trim();
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3).trim();
}
// 如果还是有前置文字,尝试找到 JSON 起始位置
int jsonStart = cleaned.indexOf('{');
int arrayStart = cleaned.indexOf('[');
if (jsonStart > 0 && (arrayStart < 0 || jsonStart < arrayStart)) {
cleaned = cleaned.substring(jsonStart);
} else if (arrayStart > 0 && (jsonStart < 0 || arrayStart < jsonStart)) {
cleaned = cleaned.substring(arrayStart);
}
return cleaned;
}
}给 ContractInfo 加上校验注解:
@Data
public class ContractInfo {
@NotBlank(message = "合同编号不能为空")
@JsonProperty("contractNo")
private String contractNo;
@NotNull(message = "金额不能为空")
@Positive(message = "金额必须为正数")
@JsonProperty("amount")
private BigDecimal amount;
@NotBlank(message = "甲方不能为空")
@JsonProperty("partyA")
private String partyA;
@NotBlank(message = "乙方不能为空")
@JsonProperty("partyB")
private String partyB;
@NotNull(message = "生效日期不能为空")
@JsonProperty("effectiveDate")
private String effectiveDate; // 用 String 接收,避免日期格式问题
}使用:
ValidatingBeanOutputConverter<ContractInfo> converter =
new ValidatingBeanOutputConverter<>(ContractInfo.class);
try {
ContractInfo info = converter.convert(modelOutput);
// 正常处理
} catch (OutputParseException e) {
// JSON 解析失败,记录日志,触发重试或降级
log.error("合同信息解析失败,模型输出: {}", modelOutput, e);
return fallback();
} catch (OutputValidationException e) {
// 字段不合法,可能需要重新提问
log.warn("合同信息字段校验失败: {}", e.getMessage());
return retry(contractText);
}Structured Output 的处理流程
一个更深的坑:嵌套对象和多态
如果你的输出结构里有嵌套的列表或者多态,问题会更复杂。
比如这样的场景:合同可能是销售合同、服务合同、租赁合同,每种合同有不同的特有字段。
// 这样写会让 BeanOutputConverter 很头疼
@Data
public class Contract {
private String type; // "SALES" | "SERVICE" | "LEASE"
private String contractNo;
private Object details; // 根据 type 不同,结构不同
}我的解法是:不要指望模型一次性返回多态结构,而是分两步:
// 第一步:提取基础信息
BeanOutputConverter<BaseContractInfo> baseConverter =
new BeanOutputConverter<>(BaseContractInfo.class);
BaseContractInfo base = baseConverter.convert(baseModelOutput);
// 第二步:根据类型,再次提取特有字段
Class<?> detailClass = getDetailClass(base.getType());
BeanOutputConverter<?> detailConverter = new BeanOutputConverter<>(detailClass);
Object detail = detailConverter.convert(detailModelOutput);多一次模型调用,但稳定性大幅提升。
关于 Spring AI 1.0 GA 的改进
Spring AI 1.0 GA 之后,对 Structured Output 做了一些改进,主要是支持了 JSON Schema 的直接指定:
// 可以直接提供 JSON Schema,不依赖 Java Bean 自动生成
String jsonSchema = """
{
"type": "object",
"properties": {
"contractNo": {"type": "string"},
"amount": {"type": "number"},
"partyA": {"type": "string"}
},
"required": ["contractNo", "amount", "partyA"]
}
""";
// 部分模型(如 OpenAI)支持 JSON Mode,可以强制模型输出合法 JSON
ChatOptions options = OpenAiChatOptions.builder()
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT))
.build();OpenAI 的 JSON Mode 和 Structured Outputs(带 Schema 约束)是目前最可靠的方式,模型层面保证输出合法 JSON,解析失败率基本接近 0。
但如果你用的是其他模型,或者私有部署的模型,只能靠上面说的那套清洗 + 校验的方案。
最后的建议
用了大半年 Structured Output,我的经验是:
- 能用 OpenAI Structured Outputs(JSON Schema 模式)就用,模型层面保证,最可靠
- 一定要加预处理和校验,不要相信模型 100% 遵守格式要求
- 字段尽量扁平化,嵌套层级越深,解析失败概率越高
- 失败要有降级策略,宁可降级到返回空结果,也不要让解析异常穿透到业务层
- 日志里记录原始模型输出,方便排查,这个教训是生产事故里得来的
Structured Output 看起来简单,实际上是 AI 应用里最容易出问题的环节之一,因为它跨越了「非确定性的语言模型」和「确定性的业务系统」这个边界,而边界处理不好,就会爆。
