第2101篇:LLM输出的结构化解析与校验——让AI输出可靠地驱动业务流程
2026/4/30大约 9 分钟
第2101篇:LLM输出的结构化解析与校验——让AI输出可靠地驱动业务流程
适读人群:需要在业务流程中使用LLM输出结果的工程师 | 阅读时长:约18分钟 | 核心价值:掌握LLM JSON输出的可靠解析策略、结构校验、类型转换和容错处理
LLM的输出在本质上是文本,但业务流程需要的是结构化数据——订单对象、分类枚举、评分数字。
新手最常见的做法:让LLM输出JSON,然后直接ObjectMapper.readValue()。这在测试时看起来没问题,但到生产环境就是一系列头疼的问题:LLM有时在JSON前后加说明文字,有时字段名大小写不对,有时数字用字符串包了引号,有时整个结构都不对。
这篇文章系统地解决LLM结构化输出的工程可靠性问题。
为什么LLM的JSON输出不可靠
/**
* LLM JSON输出的常见问题
*
* 问题1:前后加了废话
* LLM输出:
* "这是您要的JSON格式:\n```json\n{...}\n```\n以上是结果。"
*
* 问题2:字段名随意变化
* 要求:{"orderId": "123"}
* 实际:{"order_id": "123"} 或 {"OrderId": "123"}
*
* 问题3:数字类型不稳定
* 要求:{"count": 5}
* 实际:{"count": "5"} 或 {"count": 5.0}
*
* 问题4:枚举值和预期不匹配
* 要求:{"status": "PENDING"}
* 实际:{"status": "pending"} 或 {"status": "待处理"}
*
* 问题5:结构完全不对(输出解释而不是JSON)
* 出现概率:小模型约3-5%,大模型约0.5-1%
*
* 解决方案:防御性解析 + 自动修复 + 失败重试
*/鲁棒的JSON提取器
/**
* 鲁棒的JSON提取和解析
*
* 处理LLM输出的各种异常格式
*/
@Component
@Slf4j
public class RobustJsonExtractor {
private final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
/**
* 从LLM原始输出中提取JSON对象
*
* 处理:
* - Markdown代码块包裹
* - 前后文字说明
* - 多余的转义字符
*/
public Optional<JsonNode> extractJson(String rawOutput) {
if (rawOutput == null || rawOutput.isBlank()) return Optional.empty();
// 尝试方案1:去除markdown代码块
String cleaned = removeMarkdownCodeBlock(rawOutput);
// 尝试方案2:找到JSON对象的开始和结束
String extracted = findJsonContent(cleaned);
if (extracted == null || extracted.isBlank()) {
log.debug("无法提取JSON内容: {}", rawOutput.substring(0, Math.min(200, rawOutput.length())));
return Optional.empty();
}
// 尝试修复常见问题后解析
extracted = fixCommonJsonIssues(extracted);
try {
return Optional.of(objectMapper.readTree(extracted));
} catch (JsonProcessingException e) {
log.warn("JSON解析失败: {}", e.getMessage());
log.debug("失败的JSON: {}", extracted);
return Optional.empty();
}
}
/**
* 提取并转换为特定类型
*/
public <T> Optional<T> extractAs(String rawOutput, Class<T> targetType) {
return extractJson(rawOutput)
.flatMap(json -> {
try {
return Optional.of(objectMapper.treeToValue(json, targetType));
} catch (Exception e) {
log.warn("JSON到{}转换失败: {}", targetType.getSimpleName(), e.getMessage());
return Optional.empty();
}
});
}
private String removeMarkdownCodeBlock(String text) {
// 去除```json ... ``` 或 ``` ... ```
String result = text.replaceAll("```(?:json)?\\s*\\n?", "");
result = result.replaceAll("```\\s*$", "");
return result.trim();
}
private String findJsonContent(String text) {
// 找到第一个{和最后一个}(对象)
int objStart = text.indexOf('{');
int objEnd = text.lastIndexOf('}');
// 找到第一个[和最后一个](数组)
int arrStart = text.indexOf('[');
int arrEnd = text.lastIndexOf(']');
// 选择最先出现的那个
if (objStart >= 0 && (arrStart < 0 || objStart <= arrStart)) {
if (objEnd > objStart) return text.substring(objStart, objEnd + 1);
}
if (arrStart >= 0 && arrEnd > arrStart) {
return text.substring(arrStart, arrEnd + 1);
}
return null;
}
private String fixCommonJsonIssues(String json) {
String fixed = json;
// 修复1:单引号替换为双引号(Python dict风格)
// 注意:只替换键值周围的单引号,不影响字符串内容
// 这个修复比较危险,只在简单情况下用
// fixed = fixed.replaceAll("'([^']*)':", "\"$1\":");
// 修复2:去除尾部多余逗号(,}或,])
fixed = fixed.replaceAll(",\\s*}", "}");
fixed = fixed.replaceAll(",\\s*]", "]");
// 修复3:修复布尔值大写(True/False/None → true/false/null)
fixed = fixed.replaceAll("\\bTrue\\b", "true");
fixed = fixed.replaceAll("\\bFalse\\b", "false");
fixed = fixed.replaceAll("\\bNone\\b", "null");
return fixed;
}
}字段级别的容错处理
/**
* 容错的字段提取器
*
* 处理字段名不一致、类型不稳定等问题
*/
@Component
@Slf4j
public class FlexibleFieldExtractor {
/**
* 字段名容错提取(支持多种命名风格)
*/
public Optional<JsonNode> getField(JsonNode node, String... possibleNames) {
for (String name : possibleNames) {
// 尝试原始名
if (node.has(name)) return Optional.of(node.get(name));
// 尝试转换: camelCase ↔ snake_case
String snakeCase = toSnakeCase(name);
if (node.has(snakeCase)) return Optional.of(node.get(snakeCase));
String camelCase = toCamelCase(name);
if (node.has(camelCase)) return Optional.of(node.get(camelCase));
}
return Optional.empty();
}
/**
* 安全地提取字符串
*/
public String getString(JsonNode node, String fieldName, String defaultValue) {
return getField(node, fieldName)
.filter(f -> !f.isNull())
.map(JsonNode::asText)
.map(String::trim)
.filter(s -> !s.isEmpty())
.orElse(defaultValue);
}
/**
* 安全地提取整数(处理字符串包裹的数字)
*/
public int getInt(JsonNode node, String fieldName, int defaultValue) {
return getField(node, fieldName)
.filter(f -> !f.isNull())
.map(f -> {
if (f.isNumber()) return f.intValue();
try {
return Integer.parseInt(f.asText().trim().replaceAll("[^0-9-]", ""));
} catch (NumberFormatException e) {
return defaultValue;
}
})
.orElse(defaultValue);
}
/**
* 安全地提取浮点数
*/
public double getDouble(JsonNode node, String fieldName, double defaultValue) {
return getField(node, fieldName)
.filter(f -> !f.isNull())
.map(f -> {
if (f.isNumber()) return f.doubleValue();
try {
return Double.parseDouble(f.asText().trim()
.replaceAll("[^0-9.-]", ""));
} catch (NumberFormatException e) {
return defaultValue;
}
})
.orElse(defaultValue);
}
/**
* 安全地提取布尔值(处理各种表示方式)
*/
public boolean getBoolean(JsonNode node, String fieldName, boolean defaultValue) {
return getField(node, fieldName)
.filter(f -> !f.isNull())
.map(f -> {
if (f.isBoolean()) return f.booleanValue();
String text = f.asText().trim().toLowerCase();
return switch (text) {
case "true", "yes", "1", "是", "对" -> true;
case "false", "no", "0", "否", "错" -> false;
default -> defaultValue;
};
})
.orElse(defaultValue);
}
/**
* 枚举值容错提取
*/
public <E extends Enum<E>> Optional<E> getEnum(
JsonNode node, String fieldName, Class<E> enumType) {
String rawValue = getString(node, fieldName, null);
if (rawValue == null) return Optional.empty();
String normalized = rawValue.toUpperCase().trim().replace(" ", "_");
try {
return Optional.of(Enum.valueOf(enumType, normalized));
} catch (IllegalArgumentException e) {
// 尝试模糊匹配
for (E enumConst : enumType.getEnumConstants()) {
if (enumConst.name().contains(normalized) ||
normalized.contains(enumConst.name())) {
return Optional.of(enumConst);
}
}
log.warn("枚举值无法识别: fieldName={}, value={}, enumType={}",
fieldName, rawValue, enumType.getSimpleName());
return Optional.empty();
}
}
/**
* 提取字符串数组(支持多种格式)
*/
public List<String> getStringList(JsonNode node, String fieldName) {
Optional<JsonNode> fieldOpt = getField(node, fieldName);
if (fieldOpt.isEmpty()) return List.of();
JsonNode field = fieldOpt.get();
if (field.isArray()) {
List<String> result = new ArrayList<>();
field.forEach(item -> result.add(item.asText().trim()));
return result;
}
// 如果是字符串,尝试按逗号分割
if (field.isTextual()) {
String text = field.asText();
return Arrays.stream(text.split("[,,、;;]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
return List.of();
}
private String toSnakeCase(String camelCase) {
return camelCase.replaceAll("([A-Z])", "_$1").toLowerCase()
.replaceAll("^_", "");
}
private String toCamelCase(String snakeCase) {
String[] parts = snakeCase.split("_");
StringBuilder sb = new StringBuilder(parts[0].toLowerCase());
for (int i = 1; i < parts.length; i++) {
if (!parts[i].isEmpty()) {
sb.append(Character.toUpperCase(parts[i].charAt(0)));
sb.append(parts[i].substring(1).toLowerCase());
}
}
return sb.toString();
}
}结构化输出的Schema校验
/**
* LLM输出的Schema校验
*
* 在容错解析后,还需要校验必填字段是否完整、值是否合理
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LlmOutputValidator {
/**
* 自定义校验规则
*/
@FunctionalInterface
public interface FieldValidator {
Optional<String> validate(JsonNode value);
}
/**
* 流式校验器(Builder模式)
*/
public static class SchemaValidator {
private final List<ValidationRule> rules = new ArrayList<>();
public SchemaValidator requireString(String fieldName, int minLength, int maxLength) {
rules.add(new ValidationRule(fieldName, true, value -> {
if (!value.isTextual()) return Optional.of(fieldName + "必须是字符串");
int len = value.asText().trim().length();
if (len < minLength) return Optional.of(fieldName + "太短(最少" + minLength + "字符)");
if (len > maxLength) return Optional.of(fieldName + "太长(最多" + maxLength + "字符)");
return Optional.empty();
}));
return this;
}
public SchemaValidator requireNumber(String fieldName, double min, double max) {
rules.add(new ValidationRule(fieldName, true, value -> {
if (!value.isNumber() && !value.isTextual()) {
return Optional.of(fieldName + "必须是数字");
}
double num;
try {
num = value.isNumber() ? value.doubleValue() : Double.parseDouble(value.asText());
} catch (NumberFormatException e) {
return Optional.of(fieldName + "不是有效的数字");
}
if (num < min || num > max) {
return Optional.of(String.format("%s必须在%.1f到%.1f之间", fieldName, min, max));
}
return Optional.empty();
}));
return this;
}
public SchemaValidator requireEnum(String fieldName, String... allowedValues) {
Set<String> allowed = Set.of(allowedValues);
rules.add(new ValidationRule(fieldName, true, value -> {
String v = value.asText().toUpperCase().trim();
if (!allowed.contains(v) && !allowed.contains(value.asText())) {
return Optional.of(fieldName + "必须是以下之一: " +
String.join(", ", allowedValues));
}
return Optional.empty();
}));
return this;
}
public SchemaValidator optionalArray(String fieldName, int maxSize) {
rules.add(new ValidationRule(fieldName, false, value -> {
if (!value.isArray()) return Optional.of(fieldName + "必须是数组");
if (value.size() > maxSize) {
return Optional.of(fieldName + "数组不能超过" + maxSize + "个元素");
}
return Optional.empty();
}));
return this;
}
public ValidationResult validate(JsonNode json) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
for (ValidationRule rule : rules) {
if (!json.has(rule.fieldName())) {
if (rule.required()) {
errors.add("缺少必填字段: " + rule.fieldName());
}
continue;
}
JsonNode value = json.get(rule.fieldName());
if (value.isNull()) {
if (rule.required()) {
errors.add("必填字段为null: " + rule.fieldName());
}
continue;
}
rule.validator().validate(value).ifPresent(
msg -> (rule.required() ? errors : warnings).add(msg));
}
return new ValidationResult(errors.isEmpty(), errors, warnings);
}
public record ValidationRule(
String fieldName, boolean required, FieldValidator validator) {}
public record ValidationResult(
boolean valid, List<String> errors, List<String> warnings) {}
}
}自动重试修复机制
/**
* 解析失败时的自动重试
*
* 如果LLM第一次输出解析失败,
* 把失败原因反馈给LLM,让它重新生成
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LlmOutputRetryService {
private final ChatLanguageModel llm;
private final RobustJsonExtractor jsonExtractor;
private static final int MAX_RETRY = 2;
/**
* 带重试的结构化输出生成
*/
public <T> T generateWithRetry(
String prompt,
Class<T> targetType,
LlmOutputValidator.SchemaValidator validator) {
String currentPrompt = prompt;
String lastError = null;
for (int attempt = 0; attempt <= MAX_RETRY; attempt++) {
// 如果是重试,把错误信息加进去
if (attempt > 0 && lastError != null) {
currentPrompt = buildRetryPrompt(prompt, lastError);
log.info("LLM输出重试 (第{}次): error={}", attempt, lastError);
}
String rawOutput;
try {
rawOutput = llm.generate(currentPrompt);
} catch (Exception e) {
log.error("LLM调用失败 (第{}次): {}", attempt + 1, e.getMessage());
lastError = "LLM调用失败: " + e.getMessage();
continue;
}
// 提取JSON
Optional<JsonNode> jsonOpt = jsonExtractor.extractJson(rawOutput);
if (jsonOpt.isEmpty()) {
lastError = "输出中找不到有效的JSON内容";
continue;
}
// Schema校验
LlmOutputValidator.SchemaValidator.ValidationResult validation =
validator.validate(jsonOpt.get());
if (!validation.valid()) {
lastError = "JSON结构校验失败: " + String.join("; ", validation.errors());
continue;
}
// 转换为目标类型
try {
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
T result = mapper.treeToValue(jsonOpt.get(), targetType);
if (attempt > 0) {
log.info("重试后成功解析 (第{}次)", attempt + 1);
}
return result;
} catch (Exception e) {
lastError = "类型转换失败: " + e.getMessage();
}
}
throw new LlmOutputParseException(
"LLM输出解析失败(已重试" + MAX_RETRY + "次): " + lastError);
}
private String buildRetryPrompt(String originalPrompt, String error) {
return originalPrompt + "\n\n" + """
⚠️ 注意:上一次的输出解析失败,错误信息:
%s
请重新生成,确保:
1. 严格按照要求的JSON格式输出
2. 不要在JSON前后添加任何解释文字
3. 确保所有必填字段都存在且格式正确
4. 只输出JSON,不要markdown格式
""".formatted(error);
}
public static class LlmOutputParseException extends RuntimeException {
public LlmOutputParseException(String message) { super(message); }
}
}LangChain4j的结构化输出集成
/**
* 利用LangChain4j的@AiService实现类型安全的结构化输出
*
* LangChain4j可以自动把Java接口的方法签名转换为JSON Schema
* 并在返回时自动反序列化
*/
@AiService
public interface StructuredOutputAssistant {
/**
* LangChain4j会自动生成JSON Schema并注入到Prompt
* 返回类型由Java编译器保证类型安全
*/
@SystemMessage("""
你是一个信息提取专家。请从用户提供的文本中提取结构化信息。
严格按照要求的JSON格式返回,不要添加任何额外文字。
""")
ProductReviewAnalysis analyzeProductReview(@UserMessage String reviewText);
@SystemMessage("""
你是一个文档分类专家。分析文档内容,返回分类结果。
只返回JSON,不要解释。
""")
DocumentClassification classifyDocument(@UserMessage String documentContent);
}
/**
* 商品评论分析结果
* LangChain4j会自动根据这个类的字段生成JSON Schema
*/
@Data
public class ProductReviewAnalysis {
private String sentiment; // POSITIVE/NEGATIVE/NEUTRAL
private double sentimentScore; // 0.0-1.0
private List<String> mentionedFeatures; // 提到的产品特性
private List<String> issues; // 投诉问题
private boolean willRecommend; // 是否会推荐
private int starRating; // 推测的评分(1-5)
}
@Data
public class DocumentClassification {
private String primaryCategory; // 主分类
private String subCategory; // 子分类
private double confidence; // 置信度
private List<String> keywords; // 关键词
private String summary; // 50字摘要
}
/**
* 使用示例
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ReviewAnalysisService {
private final StructuredOutputAssistant assistant;
public ProductReviewAnalysis analyze(String reviewText) {
try {
// LangChain4j自动处理JSON提取和反序列化
return assistant.analyzeProductReview(reviewText);
} catch (Exception e) {
log.error("评论分析失败: {}", e.getMessage());
// 降级处理
return createDefaultAnalysis();
}
}
private ProductReviewAnalysis createDefaultAnalysis() {
ProductReviewAnalysis analysis = new ProductReviewAnalysis();
analysis.setSentiment("NEUTRAL");
analysis.setSentimentScore(0.5);
analysis.setMentionedFeatures(List.of());
analysis.setIssues(List.of());
analysis.setWillRecommend(false);
analysis.setStarRating(3);
return analysis;
}
}实践中的选型建议
优先使用框架的内置结构化输出
LangChain4j的@AiService返回Java对象,Spring AI也有类似的BeanOutputConverter。这些框架级别的解决方案比自己写解析代码可靠得多:它们会自动生成JSON Schema注入到Prompt,处理常见的格式问题,还有重试机制。能用框架的,不要自己造轮子。
模型层的约束比提示词约束更可靠
很多API支持response_format参数(如OpenAI的{"type": "json_object"}),启用这个参数后LLM被强制输出有效JSON,解析失败率会从1-3%降到接近0。如果你的模型API支持,务必启用。
失败时的降级方案要提前设计
不要假设解析一定成功。每个使用LLM结构化输出的地方都要有降级处理:解析失败时返回默认值,或者触发人工处理。我见过生产系统因为偶发的LLM输出格式异常直接抛500,用户一头雾水。这类异常应该被优雅地处理,用户感知不到。
