AI应用的Schema验证:确保LLM输出符合业务规范
AI应用的Schema验证:确保LLM输出符合业务规范
开篇故事:JSON格式随机变化,解析代码崩了无数次
2025年4月,刘鑫的团队上线了一套AI辅助工单处理系统。系统从用户提交的问题描述中自动提取结构化信息:问题类型、优先级、影响范围、建议处理人。
上线第一周,一切正常。第二周,客服主管找来了:
"系统昨晚崩了4次,一大早堆了200张工单没有处理。"
刘鑫查了日志,问题出在AI返回的JSON格式上。
正常情况下AI返回:
{"type": "billing", "priority": "high", "scope": "individual", "assignee": "张三"}但实际上,AI有时候返回:
{"issue_type": "billing", "priority_level": "high", ...} ← 字段名变了
{"type": "billing", "priority": 3, ...} ← 类型从字符串变成了数字
{"type": "账单问题", "priority": "高", ...} ← 中英文混用
[{"type": "billing", ...}] ← 多包了一层数组刘鑫统计了一下,30天里,GPT-4在同一个Prompt下返回了17种不同格式的JSON。他的代码假设格式固定,每次格式变化就崩一次。
修了好几轮之后,他意识到:与其修解析代码,不如在架构层面强制LLM输出规范格式,并在使用前做严格验证。
这就是结构化输出与Schema验证的必要性。
一、LLM输出不稳定的根本原因
1.1 格式不稳定的常见表现
| 问题类型 | 出现频率 | 危害程度 |
|---|---|---|
| 字段名不一致(snake_case vs camelCase) | 高 | 中 |
| 值类型变化(String vs Integer) | 中 | 高 |
| 枚举值超出范围 | 中 | 高 |
| 嵌套层级变化 | 低 | 极高 |
| 多余的包装层 | 中 | 高 |
| 中英文混用 | 低 | 中 |
| 添加注释字段(_explanation等) | 高 | 低 |
1.2 解决方案层次
层次1:通过Prompt约束(最弱)
- 在Prompt中详细描述格式要求
- 效果:降低格式错误率到约20%
层次2:Spring AI BeanOutputConverter(推荐)
- 自动生成JSON Schema注入Prompt
- 自动将响应反序列化为Java对象
- 效果:降低格式错误率到约5%
层次3:Function Calling/Tool Use(最强)
- 强制模型按Schema生成参数
- 效果:格式错误率接近0%
层次4:运行时验证+重试(兜底)
- 上述三层的补充,确保最终结果符合规范
- 重试2-3次,覆盖残余的格式错误二、Spring AI结构化输出:BeanOutputConverter
2.1 基础用法
@SpringBootTest
@Slf4j
public class StructuredOutputDemo {
@Autowired
private ChatClient chatClient;
// 目标Java对象
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true) // 忽略多余字段
public static class WorkOrderInfo {
@JsonProperty("type")
@Schema(description = "工单类型", allowableValues = {"billing", "technical", "complaint", "inquiry"})
private String type;
@JsonProperty("priority")
@Schema(description = "优先级: 1=低, 2=中, 3=高, 4=紧急")
private Integer priority;
@JsonProperty("scope")
@Schema(description = "影响范围", allowableValues = {"individual", "department", "company_wide"})
private String scope;
@JsonProperty("assignee")
@Schema(description = "建议处理人的用户名")
private String assignee;
@JsonProperty("summary")
@Schema(description = "问题摘要,不超过50字")
private String summary;
}
public WorkOrderInfo extractWorkOrderInfo(String userDescription) {
// BeanOutputConverter自动:
// 1. 根据WorkOrderInfo类生成JSON Schema
// 2. 将Schema注入Prompt,告诉LLM输出格式
// 3. 将LLM的JSON响应自动反序列化为WorkOrderInfo对象
BeanOutputConverter<WorkOrderInfo> converter =
new BeanOutputConverter<>(WorkOrderInfo.class);
String formatInstructions = converter.getFormat();
WorkOrderInfo result = chatClient.prompt()
.system("你是一个工单信息提取助手,从用户描述中提取结构化信息。\n" +
"必须严格按照指定格式输出,不要添加任何解释性文字。")
.user(u -> u.text("""
请从以下用户描述中提取工单信息:
{userDescription}
{format}
""")
.param("userDescription", userDescription)
.param("format", formatInstructions))
.call()
.entity(WorkOrderInfo.class);
return result;
}
}2.2 自动生成的JSON Schema样例
BeanOutputConverter会根据Java类自动生成类似这样的指令注入到Prompt中:
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response.
The JSON should match the following schema:
{
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "工单类型",
"enum": ["billing", "technical", "complaint", "inquiry"]
},
"priority": {
"type": "integer",
"description": "优先级: 1=低, 2=中, 3=高, 4=紧急",
"minimum": 1,
"maximum": 4
},
...
},
"required": ["type", "priority", "scope", "assignee", "summary"]
}三、完整的JSON Schema约束系统
3.1 Schema定义(支持复杂业务对象)
/**
* 商品分析报告 - 演示复杂嵌套结构
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ProductAnalysisReport {
@NotNull(message = "商品ID不能为空")
@JsonProperty("product_id")
private String productId;
@NotBlank(message = "商品名称不能为空")
@JsonProperty("product_name")
private String productName;
// 枚举约束
@Pattern(regexp = "^(electronics|clothing|food|beauty|sports|other)$",
message = "无效的商品类别")
@JsonProperty("category")
private String category;
// 数值范围约束
@Min(value = 1, message = "评分最小为1")
@Max(value = 5, message = "评分最大为5")
@JsonProperty("overall_score")
private Integer overallScore;
// 嵌套对象
@Valid // 触发嵌套验证
@NotNull
@JsonProperty("sentiment_analysis")
private SentimentAnalysis sentimentAnalysis;
// 数组元素约束
@Size(min = 1, max = 10, message = "关键词数量必须在1-10个之间")
@JsonProperty("key_features")
private List<@NotBlank String> keyFeatures;
// 可选字段
@JsonProperty("improvement_suggestions")
private List<ImprovementSuggestion> improvementSuggestions;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SentimentAnalysis {
@NotNull
@JsonProperty("overall")
@Pattern(regexp = "^(very_positive|positive|neutral|negative|very_negative)$")
private String overall;
@JsonProperty("positive_aspects")
@Size(max = 5)
private List<String> positiveAspects;
@JsonProperty("negative_aspects")
@Size(max = 5)
private List<String> negativeAspects;
@JsonProperty("confidence")
@DecimalMin("0.0") @DecimalMax("1.0")
private Double confidence;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ImprovementSuggestion {
@NotBlank
@JsonProperty("area")
private String area;
@NotBlank
@Size(min = 10, max = 200)
@JsonProperty("suggestion")
private String suggestion;
@NotNull
@Pattern(regexp = "^(high|medium|low)$")
@JsonProperty("priority")
private String priority;
}
}3.2 带Schema验证的AI服务
@Service
@Slf4j
@RequiredArgsConstructor
public class ProductAnalysisService {
private final ChatClient chatClient;
private final Validator validator;
private final MeterRegistry meterRegistry;
private static final int MAX_RETRY_ATTEMPTS = 3;
/**
* 分析商品评论,返回结构化报告
*/
public ProductAnalysisReport analyzeProduct(String productId, String reviewText) {
for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
try {
ProductAnalysisReport report = callLLMWithSchema(productId, reviewText, attempt);
validateReport(report);
meterRegistry.counter("ai.structured.output.success",
"attempt", String.valueOf(attempt)).increment();
return report;
} catch (OutputValidationException e) {
log.warn("Structured output validation failed on attempt {}/{}: {}",
attempt, MAX_RETRY_ATTEMPTS, e.getMessage());
meterRegistry.counter("ai.structured.output.validation_failure",
"attempt", String.valueOf(attempt)).increment();
if (attempt == MAX_RETRY_ATTEMPTS) {
throw new MaxRetryExceededException(
"Failed to get valid structured output after " + MAX_RETRY_ATTEMPTS + " attempts", e);
}
// 将验证错误反馈给模型,引导修正
// 在下一次尝试时,会把错误信息加入Prompt
} catch (JsonProcessingException e) {
log.warn("JSON parsing failed on attempt {}/{}", attempt, MAX_RETRY_ATTEMPTS);
if (attempt == MAX_RETRY_ATTEMPTS) {
throw new OutputParseException("Failed to parse LLM output as JSON", e);
}
}
}
throw new IllegalStateException("Should not reach here");
}
private ProductAnalysisReport callLLMWithSchema(
String productId, String reviewText, int attempt) throws JsonProcessingException {
BeanOutputConverter<ProductAnalysisReport> converter =
new BeanOutputConverter<>(ProductAnalysisReport.class);
String userPrompt = attempt == 1
? buildInitialPrompt(productId, reviewText, converter.getFormat())
: buildRetryPrompt(productId, reviewText, converter.getFormat(), attempt);
String rawResponse = chatClient.prompt()
.system(SYSTEM_PROMPT)
.user(userPrompt)
.call()
.content();
log.debug("LLM raw response (attempt {}): {}", attempt, rawResponse);
// 提取JSON(处理模型返回了额外解释文字的情况)
String jsonStr = extractJson(rawResponse);
return converter.convert(jsonStr);
}
/**
* 使用Bean Validation进行运行时验证
*/
private void validateReport(ProductAnalysisReport report) {
Set<ConstraintViolation<ProductAnalysisReport>> violations = validator.validate(report);
if (!violations.isEmpty()) {
String errorDetails = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; "));
throw new OutputValidationException("Output validation failed: " + errorDetails);
}
// 额外的业务逻辑验证
validateBusinessRules(report);
}
private void validateBusinessRules(ProductAnalysisReport report) {
// 规则1:如果整体评分是1-2,那么sentimentAnalysis.overall必须是negative或very_negative
if (report.getOverallScore() <= 2) {
String sentiment = report.getSentimentAnalysis().getOverall();
if (!sentiment.contains("negative")) {
throw new OutputValidationException(
"Business rule violation: low score must match negative sentiment");
}
}
// 规则2:如果有improvement_suggestions,每个suggestion的priority字段必须存在
if (report.getImprovementSuggestions() != null) {
boolean allHavePriority = report.getImprovementSuggestions().stream()
.allMatch(s -> s.getPriority() != null && !s.getPriority().isBlank());
if (!allHavePriority) {
throw new OutputValidationException(
"Business rule violation: all suggestions must have priority");
}
}
}
/**
* 从可能包含额外文字的LLM输出中提取JSON
*/
private String extractJson(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
throw new OutputParseException("LLM returned empty response");
}
// 情况1:纯JSON(最理想)
String trimmed = rawResponse.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return trimmed;
}
// 情况2:JSON包在代码块中
Pattern jsonCodeBlock = Pattern.compile("```(?:json)?\\n?([\\s\\S]*?)```");
Matcher matcher = jsonCodeBlock.matcher(rawResponse);
if (matcher.find()) {
return matcher.group(1).trim();
}
// 情况3:JSON混在文字中,提取第一个完整的JSON对象
int jsonStart = rawResponse.indexOf('{');
if (jsonStart >= 0) {
int jsonEnd = findMatchingBrace(rawResponse, jsonStart);
if (jsonEnd > jsonStart) {
return rawResponse.substring(jsonStart, jsonEnd + 1);
}
}
throw new OutputParseException("Cannot extract JSON from LLM response: " +
rawResponse.substring(0, Math.min(200, rawResponse.length())));
}
private int findMatchingBrace(String str, int openPos) {
int depth = 0;
for (int i = openPos; i < str.length(); i++) {
if (str.charAt(i) == '{') depth++;
if (str.charAt(i) == '}') {
depth--;
if (depth == 0) return i;
}
}
return -1;
}
private String buildRetryPrompt(String productId, String reviewText,
String format, int attempt) {
return String.format("""
这是第%d次尝试。上次的输出不符合格式要求,请重新分析。
商品ID: %s
评论内容: %s
注意事项(针对上次错误):
- 必须严格使用指定的枚举值
- 不要添加任何解释性文字
- category必须是以下之一: electronics, clothing, food, beauty, sports, other
- sentiment必须是以下之一: very_positive, positive, neutral, negative, very_negative
%s
""", attempt, productId, reviewText, format);
}
private static final String SYSTEM_PROMPT = """
你是一个商品评论分析专家。
请从商品评论中提取关键信息,并以严格的JSON格式返回。
不要返回任何JSON以外的内容,不要有解释性文字。
严格遵守字段名称和枚举值的约束。
""";
private String buildInitialPrompt(String productId, String reviewText, String format) {
return String.format("""
请分析以下商品评论并提取结构化信息:
商品ID: %s
评论内容: %s
%s
""", productId, reviewText, format);
}
}四、枚举值约束:确保LLM只输出有效选项
4.1 枚举处理的正确姿势
/**
* 使用Java枚举类型,配合自定义序列化器确保类型安全
*/
public enum TicketPriority {
LOW("low", 1),
MEDIUM("medium", 2),
HIGH("high", 3),
URGENT("urgent", 4);
private final String code;
private final int level;
TicketPriority(String code, int level) {
this.code = code;
this.level = level;
}
@JsonCreator
public static TicketPriority fromCode(String code) {
if (code == null) return MEDIUM; // 默认值
// 宽松匹配:处理LLM返回的各种变体
String normalized = code.toLowerCase().trim();
return switch (normalized) {
case "low", "1", "低", "低优先级" -> LOW;
case "medium", "2", "中", "中优先级", "normal" -> MEDIUM;
case "high", "3", "高", "高优先级" -> HIGH;
case "urgent", "4", "紧急", "critical", "p0" -> URGENT;
default -> {
log.warn("Unknown priority value from LLM: {}, defaulting to MEDIUM", code);
yield MEDIUM;
}
};
}
@JsonValue
public String getCode() {
return code;
}
}
// 在实体类中使用
@Data
public class TicketInfo {
@NotNull
@JsonProperty("priority")
private TicketPriority priority; // 自动处理枚举转换
@NotNull
@JsonProperty("category")
private TicketCategory category;
@NotBlank
@JsonProperty("title")
@Size(max = 100)
private String title;
@JsonProperty("description")
@Size(max = 500)
private String description;
}4.2 枚举值验证器
/**
* 自定义枚举验证注解,用于String字段
*/
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValueValidator.class)
public @interface ValidEnumValue {
Class<? extends Enum<?>> enumClass();
String message() default "Invalid enum value";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
boolean ignoreCase() default true;
}@Slf4j
public class EnumValueValidator implements ConstraintValidator<ValidEnumValue, String> {
private List<String> acceptedValues;
private boolean ignoreCase;
@Override
public void initialize(ValidEnumValue annotation) {
ignoreCase = annotation.ignoreCase();
acceptedValues = Arrays.stream(annotation.enumClass().getEnumConstants())
.map(e -> {
try {
// 尝试调用 getCode() 方法
return (String) e.getClass().getMethod("getCode").invoke(e);
} catch (Exception ex) {
return ((Enum<?>) e).name();
}
})
.collect(Collectors.toList());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null值让@NotNull处理
boolean valid = acceptedValues.stream()
.anyMatch(accepted -> ignoreCase
? accepted.equalsIgnoreCase(value)
: accepted.equals(value));
if (!valid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"Value '" + value + "' is not valid. Accepted values: " + acceptedValues)
.addConstraintViolation();
}
return valid;
}
}五、嵌套对象:复杂业务对象的结构化生成
5.1 多层嵌套示例
/**
* 复杂的合同信息提取 - 演示多层嵌套和条件验证
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ContractInfo {
@NotBlank
@JsonProperty("contract_type")
@ValidEnumValue(enumClass = ContractType.class)
private String contractType;
@NotNull
@Valid
@JsonProperty("parties")
private Parties parties;
@NotNull
@Valid
@JsonProperty("financial_terms")
private FinancialTerms financialTerms;
@Size(max = 5)
@JsonProperty("key_obligations")
private List<@Valid Obligation> keyObligations;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Parties {
@Valid
@NotNull
@JsonProperty("party_a")
private Party partyA;
@Valid
@NotNull
@JsonProperty("party_b")
private Party partyB;
@Data
public static class Party {
@NotBlank
@JsonProperty("name")
private String name;
@NotBlank
@JsonProperty("role")
@ValidEnumValue(enumClass = PartyRole.class)
private String role;
@JsonProperty("registration_number")
private String registrationNumber;
}
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class FinancialTerms {
@Positive(message = "合同金额必须大于0")
@JsonProperty("total_amount")
private BigDecimal totalAmount;
@NotBlank
@Pattern(regexp = "^(CNY|USD|EUR|HKD)$", message = "不支持的货币类型")
@JsonProperty("currency")
private String currency;
@JsonProperty("payment_schedule")
@Size(max = 12)
private List<@Valid PaymentInstallment> paymentSchedule;
@Data
public static class PaymentInstallment {
@NotBlank
@JsonProperty("due_date")
@Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "日期格式必须是YYYY-MM-DD")
private String dueDate;
@Positive
@JsonProperty("amount")
private BigDecimal amount;
@NotBlank
@JsonProperty("description")
private String description;
}
}
@Data
public static class Obligation {
@NotBlank
@JsonProperty("party")
private String party;
@NotBlank
@Size(max = 300)
@JsonProperty("content")
private String content;
@NotBlank
@ValidEnumValue(enumClass = ObligationType.class)
@JsonProperty("type")
private String type;
}
}5.2 分步提取策略(长文档)
当合同文档很长时,一次性提取可能导致格式错误率升高。分步提取更稳定:
@Service
@RequiredArgsConstructor
public class ContractExtractionService {
private final ChatClient chatClient;
private final Validator validator;
/**
* 分步提取合同信息(大文档推荐使用这种方式)
* 每步只提取一个部分,降低单次输出复杂度,提高准确率
*/
public ContractInfo extractContractInfo(String contractText) {
// 步骤1:提取基本信息(合同类型、当事人)
ContractBasicInfo basicInfo = extractBasicInfo(contractText);
// 步骤2:提取财务条款
FinancialTerms financialTerms = extractFinancialTerms(contractText);
// 步骤3:提取核心义务
List<ContractInfo.Obligation> obligations = extractObligations(contractText);
// 合并结果
ContractInfo contract = new ContractInfo();
contract.setContractType(basicInfo.getContractType());
contract.setParties(basicInfo.getParties());
contract.setFinancialTerms(financialTerms);
contract.setKeyObligations(obligations);
// 最终验证
Set<ConstraintViolation<ContractInfo>> violations = validator.validate(contract);
if (!violations.isEmpty()) {
throw new OutputValidationException("Contract info validation failed");
}
return contract;
}
private ContractBasicInfo extractBasicInfo(String contractText) {
BeanOutputConverter<ContractBasicInfo> converter =
new BeanOutputConverter<>(ContractBasicInfo.class);
return chatClient.prompt()
.user(u -> u.text("""
从合同中提取基本信息(合同类型和当事方信息):
{text}
{format}
""")
.param("text", truncate(contractText, 3000))
.param("format", converter.getFormat()))
.call()
.entity(ContractBasicInfo.class);
}
private String truncate(String text, int maxLength) {
return text.length() > maxLength ? text.substring(0, maxLength) + "..." : text;
}
}六、运行时验证:Jackson Validation集成
6.1 统一的验证层
@Component
@Slf4j
@RequiredArgsConstructor
public class LlmOutputValidator {
private final Validator beanValidator;
private final ObjectMapper objectMapper;
/**
* 通用的LLM输出验证方法
* 集成Bean Validation + 业务规则验证
*/
public <T> ValidationResult<T> validate(String jsonOutput, Class<T> targetClass) {
// 阶段1:JSON语法检查
JsonNode jsonNode;
try {
jsonNode = objectMapper.readTree(jsonOutput);
} catch (JsonProcessingException e) {
return ValidationResult.failure("JSON syntax error: " + e.getMessage());
}
// 阶段2:必填字段检查
ValidationResult<T> fieldCheck = checkRequiredFields(jsonNode, targetClass);
if (!fieldCheck.isValid()) {
return fieldCheck;
}
// 阶段3:反序列化
T parsed;
try {
parsed = objectMapper.treeToValue(jsonNode, targetClass);
} catch (JsonProcessingException e) {
return ValidationResult.failure("Deserialization error: " + e.getMessage());
}
// 阶段4:Bean Validation
Set<ConstraintViolation<T>> violations = beanValidator.validate(parsed);
if (!violations.isEmpty()) {
String details = violations.stream()
.map(v -> v.getPropertyPath() + "=" + v.getInvalidValue() +
": " + v.getMessage())
.collect(Collectors.joining("; "));
return ValidationResult.failure("Bean validation failed: " + details);
}
return ValidationResult.success(parsed);
}
/**
* 检查必填字段是否存在(在反序列化前检查,可以给出更友好的错误信息)
*/
private <T> ValidationResult<T> checkRequiredFields(JsonNode node, Class<T> clazz) {
List<String> missingFields = new ArrayList<>();
// 获取所有标注了@NotNull或@NotBlank的字段
for (java.lang.reflect.Field field : clazz.getDeclaredFields()) {
JsonProperty jsonProp = field.getAnnotation(JsonProperty.class);
String fieldName = jsonProp != null ? jsonProp.value() : field.getName();
boolean isRequired = field.isAnnotationPresent(NotNull.class)
|| field.isAnnotationPresent(NotBlank.class)
|| field.isAnnotationPresent(NotEmpty.class);
if (isRequired && !node.has(fieldName)) {
missingFields.add(fieldName);
}
}
if (!missingFields.isEmpty()) {
return ValidationResult.failure(
"Missing required fields: " + String.join(", ", missingFields));
}
return ValidationResult.success(null); // 只是检查,不返回对象
}
}@Data
@AllArgsConstructor
public class ValidationResult<T> {
private boolean valid;
private T data;
private String errorMessage;
private List<String> errorDetails;
public static <T> ValidationResult<T> success(T data) {
return new ValidationResult<>(true, data, null, Collections.emptyList());
}
public static <T> ValidationResult<T> failure(String message) {
return new ValidationResult<>(false, null, message, Collections.emptyList());
}
}七、验证失败重试:智能重试策略
@Service
@Slf4j
@RequiredArgsConstructor
public class RetryableStructuredOutputService {
private final ChatClient chatClient;
private final LlmOutputValidator validator;
/**
* 带智能重试的结构化输出
* 关键:把上次的错误反馈给模型,而不是盲目重试
*/
public <T> T extractWithRetry(String userPrompt, Class<T> targetClass) {
BeanOutputConverter<T> converter = new BeanOutputConverter<>(targetClass);
String lastError = null;
String lastRawOutput = null;
for (int attempt = 1; attempt <= 3; attempt++) {
String prompt = buildPromptWithErrorFeedback(
userPrompt, converter.getFormat(), lastError, lastRawOutput, attempt);
try {
String rawOutput = chatClient.prompt()
.user(prompt)
.call()
.content();
lastRawOutput = rawOutput;
// 提取JSON部分
String jsonStr = extractJsonFromOutput(rawOutput);
// 验证
ValidationResult<T> result = validator.validate(jsonStr, targetClass);
if (result.isValid()) {
log.info("Structured output succeeded on attempt {}", attempt);
return result.getData();
} else {
lastError = result.getErrorMessage();
log.warn("Validation failed on attempt {}: {}", attempt, lastError);
}
} catch (Exception e) {
lastError = e.getMessage();
log.warn("Exception on attempt {}: {}", attempt, e.getMessage());
}
}
throw new MaxRetryExceededException(
"Failed to get valid output after 3 attempts. Last error: " + lastError);
}
private String buildPromptWithErrorFeedback(
String originalPrompt, String format,
String lastError, String lastOutput, int attempt) {
if (attempt == 1) {
return originalPrompt + "\n\n" + format;
}
// 给模型具体的错误反馈,引导它修正
return String.format("""
这是第%d次尝试,请修正上次的错误。
原始任务:
%s
上次你的输出:
%s
上次的错误信息:
%s
请根据以上错误,重新生成符合格式要求的输出。
%s
""", attempt, originalPrompt,
lastOutput != null ? lastOutput.substring(0, Math.min(500, lastOutput.length())) : "无",
lastError, format);
}
private String extractJsonFromOutput(String output) {
if (output == null) throw new OutputParseException("Empty output");
String trimmed = output.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return trimmed;
// 从markdown代码块中提取
Matcher matcher = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```").matcher(output);
if (matcher.find()) return matcher.group(1).trim();
// 查找第一个完整的JSON对象
int start = trimmed.indexOf('{');
if (start >= 0) {
int depth = 0, end = -1;
for (int i = start; i < trimmed.length(); i++) {
if (trimmed.charAt(i) == '{') depth++;
if (trimmed.charAt(i) == '}') {
depth--;
if (depth == 0) { end = i; break; }
}
}
if (end > start) return trimmed.substring(start, end + 1);
}
throw new OutputParseException("Cannot find JSON in output: " +
trimmed.substring(0, Math.min(200, trimmed.length())));
}
}八、部分失败处理:列表输出的健壮性
当LLM输出一个列表,某些元素不合规时,不应该因为少数元素失败而丢弃全部结果:
@Service
@Slf4j
@RequiredArgsConstructor
public class PartialSuccessHandler {
private final Validator validator;
private final ObjectMapper objectMapper;
/**
* 处理列表输出的部分失败场景
* 返回所有通过验证的元素,记录失败元素
*/
public <T> PartialResult<T> processListOutput(String jsonArrayStr, Class<T> elementClass) {
List<T> validItems = new ArrayList<>();
List<PartialFailure> failures = new ArrayList<>();
ArrayNode arrayNode;
try {
arrayNode = (ArrayNode) objectMapper.readTree(jsonArrayStr);
} catch (JsonProcessingException e) {
throw new OutputParseException("Invalid JSON array: " + e.getMessage());
}
for (int i = 0; i < arrayNode.size(); i++) {
JsonNode itemNode = arrayNode.get(i);
try {
T item = objectMapper.treeToValue(itemNode, elementClass);
Set<ConstraintViolation<T>> violations = validator.validate(item);
if (violations.isEmpty()) {
validItems.add(item);
} else {
String errorMsg = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; "));
failures.add(new PartialFailure(i, itemNode.toString(), errorMsg));
log.warn("Item {} failed validation: {}", i, errorMsg);
}
} catch (JsonProcessingException e) {
failures.add(new PartialFailure(i, itemNode.toString(),
"Parse error: " + e.getMessage()));
}
}
double successRate = arrayNode.size() > 0
? (double) validItems.size() / arrayNode.size() : 0;
if (successRate < 0.5) {
// 成功率低于50%,说明整体质量有问题,触发全量重试
log.error("Too many failures in batch output: {}/{} items passed",
validItems.size(), arrayNode.size());
throw new BatchOutputQualityException(
"Batch output quality too low: " + validItems.size() + "/" + arrayNode.size());
}
return PartialResult.<T>builder()
.validItems(validItems)
.failures(failures)
.successRate(successRate)
.build();
}
@Data
@AllArgsConstructor
public static class PartialFailure {
private int index;
private String rawItem;
private String errorMessage;
}
@Data
@Builder
public static class PartialResult<T> {
private List<T> validItems;
private List<PartialFailure> failures;
private double successRate;
public boolean hasFailures() {
return !failures.isEmpty();
}
}
}九、Function Calling的类型安全
9.1 Tool定义与类型绑定
@Service
@Slf4j
@RequiredArgsConstructor
public class TypeSafeToolService {
private final ChatClient chatClient;
/**
* 使用Function Calling实现零格式错误的结构化输出
* 模型通过tool_use机制填充参数,格式由Schema严格约束
*/
public OrderAnalysis analyzeOrder(String orderDescription) {
// 定义工具(函数签名即Schema)
return chatClient.prompt()
.system("你是一个订单分析助手,请调用analyze_order工具分析订单信息。")
.user(orderDescription)
.functions("analyzeOrderTool") // 注册的工具名称
.call()
.entity(OrderAnalysis.class);
}
// 工具实现 - Spring AI会自动根据方法签名生成JSON Schema
@Bean("analyzeOrderTool")
@Description("分析订单信息并提取结构化数据")
public Function<OrderAnalysisInput, OrderAnalysis> analyzeOrderTool() {
return input -> {
// 这个函数在Function Calling场景下是被LLM调用的
// 输入已经是类型安全的Java对象
log.info("Tool called with input: {}", input);
// 返回值会作为工具响应发回给LLM
return OrderAnalysis.builder()
.orderId(input.getOrderId())
.status(input.getStatus())
.build();
};
}
}
// Tool的输入参数(LLM填充这些参数)
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrderAnalysisInput {
@NotBlank
@JsonProperty("order_id")
@Schema(description = "订单ID")
private String orderId;
@NotNull
@JsonProperty("status")
@Schema(description = "订单状态",
allowableValues = {"pending", "processing", "shipped", "delivered", "cancelled"})
private String status;
@Positive
@JsonProperty("total_amount")
@Schema(description = "订单总金额(元)")
private BigDecimal totalAmount;
@Size(max = 5)
@JsonProperty("items")
@Schema(description = "订单商品列表")
private List<OrderItem> items;
@Data
public static class OrderItem {
@NotBlank
@JsonProperty("name")
private String name;
@Positive
@JsonProperty("quantity")
private Integer quantity;
@Positive
@JsonProperty("unit_price")
private BigDecimal unitPrice;
}
}十、整体架构与性能数据
10.1 完整的结构化输出流程
10.2 刘鑫团队改造后的效果
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 格式错误率 | 12.3% | 0.8% |
| 因格式错误导致的系统崩溃 | 4次/月 | 0次 |
| 解析成功率 | 87.7% | 99.2% |
| 平均延迟增加(验证) | - | +12ms |
| 工单处理自动化率 | 67% | 94% |
10.3 性能基准
BeanOutputConverter Schema生成:< 1ms(本地运算)
JSON提取(正则):< 1ms
Bean Validation:< 5ms
重试触发率(GPT-4o):约0.8%
重试触发率(GPT-4o-mini):约3.2%
平均重试次数(当触发时):1.2次FAQ
Q1:Function Calling和BeanOutputConverter哪个格式更稳定?
A:Function Calling(Tool Use)明显更稳定,格式错误率接近0%。但Function Calling有限制:不是所有模型都支持,流式输出时使用较复杂,且Tool定义本身增加了Token消耗。对于高稳定性要求的场景,优先选Function Calling;对于一般场景,BeanOutputConverter+重试足够了。
Q2:我的Java对象嵌套很深(5层),Schema会不会太复杂导致模型理解错误?
A:会的。建议超过3层嵌套时,考虑分步提取。先提取顶层,再根据顶层信息提取子层。每次只处理2-3层嵌套,准确率会显著提升。
Q3:如何处理LLM输出了Schema中没有的额外字段?
A:在Java类上加@JsonIgnoreProperties(ignoreUnknown = true),会自动忽略额外字段。不要用这个来掩盖问题,但对于LLM偶尔加的_explanation、_note等解释字段,忽略是合理的。
Q4:重试会不会大幅增加成本?
A:根据实测,重试触发率在1-3%之间,每次重试Token消耗约多50%(因为要附带错误信息)。整体成本增加约1.5-4.5%,可接受。如果重试率超过10%,说明Prompt或Schema设计有问题,需要优化。
Q5:如何为自定义枚举值提供宽松匹配(避免模型返回同义词被拒绝)?
A:在枚举的@JsonCreator方法中实现宽松匹配逻辑(见第四节示例)。同时在Schema描述中明确说明"只能使用以下值之一",并给出所有合法值的完整列表。
