第1937篇:结构化输出的可靠性保障——JSON模式失败时的降级与修复
第1937篇:结构化输出的可靠性保障——JSON模式失败时的降级与修复
结构化输出是AI应用开发里最常见的需求之一,也是最让开发者头疼的地方之一。
你让模型返回一个JSON,格式是商品信息:名称、价格、类别。大多数时候都正常。但偶尔就会出现:
- 返回了JSON,但缺少某个字段
- 多了一段解释性文字在JSON前面或后面
- 数字类型的字段返回了字符串
- 嵌套结构和你定义的不一样
- 字段名用了中文而不是英文
- 用了单引号而不是双引号,导致JSON.parse失败
更烦的是,这些问题往往低频出现,在测试环境很难复现,到了生产环境才暴露,而且报错信息通常就是一个JSON解析异常,完全看不出是模型输出什么导致的。
这篇文章我把结构化输出的可靠性保障体系系统讲一遍。
为什么JSON模式不是银弹
现代LLM大都支持JSON模式(JSON Mode),原理是通过受控解码,强制模型只输出符合JSON语法的Token序列。这确实能解决大部分JSON语法问题,但还有几类问题JSON模式解决不了:
语义不符合Schema:JSON Mode只保证语法合法,但不保证字段名、字段类型、字段值符合你的业务Schema。模型可能生成一个合法的JSON,但里面的字段名拼错了,或者数值超出了你的范围限制。
字段缺失:模型可能只返回了你要求的部分字段,特别是当Schema字段很多时。
结构层级错误:嵌套对象有时会变平,或者数组有时只有一个元素时模型会直接返回对象。
枚举值自由发挥:你指定status字段的值只能是"active"或"inactive",模型可能返回"enabled"或"disabled"——都是合法字符串,但不在你的枚举里。
// 测试了一下,让GPT-4返回这个Schema
// {"name": "string", "price": "number", "category": "enum(electronics|clothing|food)"}
// 某次实际返回结果:
// {"name": "蓝牙耳机", "price": "298", "category": "电子产品"}
// 两个问题:price是字符串不是数字,category没用枚举值三道防线的结构化输出框架
我把结构化输出的保障体系分为三层:
第一层:Prompt工程——从源头减少错误率 第二层:自动修复——解析失败后尝试自动修正 第三层:降级处理——修复也失败时,回退到最小可用结果
第一层:Prompt工程提升准确率
public class StructuredOutputPromptBuilder {
/**
* 构建强制结构化输出的系统提示
*/
public String buildSystemPrompt(JsonSchema schema) {
return String.format("""
你必须以JSON格式返回结果,严格遵守以下规则:
1. 只返回JSON,不要有任何前言、解释或后缀文字
2. 字段名必须完全匹配规格,不能增减字段
3. 字段类型必须严格正确:数字不能用字符串,布尔值必须是true/false
4. 枚举值必须使用规格中列出的值,不能替换为同义词
5. 必填字段不能缺失,即使值不确定也要给出默认值
JSON规格:
%s
示例(严格按此格式):
%s
""",
schema.toDescription(),
schema.toExample()
);
}
/**
* 在用户消息中添加格式提醒
*/
public String appendFormatReminder(String userMessage, JsonSchema schema) {
return userMessage + "\n\n[请以JSON格式回复,Schema:" + schema.toCompactDescription() + "]";
}
}有几个Prompt设计的细节很重要:
- 给出示例:只描述Schema字段不如给一个完整的输出示例,模型模仿示例的准确率更高
- 明确禁止前缀文字:很多失败的案例就是模型在JSON前面加了"好的,根据您的要求,返回如下:"这种废话
- 对枚举值额外强调:枚举值的自由发挥是最常见的错误,要单独说明
第二层:自动修复机制
即使Prompt设计得很好,还是会有小概率出错。这时候需要自动修复:
@Service
public class JsonOutputRepairer {
private final ObjectMapper objectMapper;
private final LlmClient llmClient;
/**
* 尝试解析JSON,失败时自动修复
*/
public <T> ParseResult<T> parseWithRepair(String rawOutput, Class<T> targetClass, JsonSchema schema) {
// 第一步:直接解析,成功则返回
try {
String cleanedJson = extractJson(rawOutput);
T result = objectMapper.readValue(cleanedJson, targetClass);
// 还要验证业务Schema
List<String> violations = schema.validate(result);
if (violations.isEmpty()) {
return ParseResult.success(result, RepairAction.NONE);
}
// JSON合法但不符合Schema,记录违规然后尝试修复
log.warn("JSON合法但Schema违规:{}", violations);
return repairSchemaViolations(cleanedJson, violations, targetClass, schema);
} catch (JsonProcessingException e) {
log.warn("JSON解析失败,尝试自动修复。原始输出:{}", rawOutput);
return attemptJsonRepair(rawOutput, e, targetClass, schema);
}
}
/**
* 提取JSON:清除前后缀文字
*/
private String extractJson(String text) {
// 去掉Markdown代码块
text = text.replaceAll("```json\\s*", "").replaceAll("```\\s*$", "").trim();
// 找第一个{和最后一个}之间的内容
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start == -1 || end == -1 || start > end) {
// 可能是数组
start = text.indexOf('[');
end = text.lastIndexOf(']');
}
if (start == -1 || end == -1 || start > end) {
return text; // 实在找不到,原样返回让后续处理
}
return text.substring(start, end + 1);
}
/**
* 尝试修复常见的JSON语法问题
*/
private <T> ParseResult<T> attemptJsonRepair(String rawOutput, JsonProcessingException originalError,
Class<T> targetClass, JsonSchema schema) {
String text = rawOutput;
// 修复尝试1:清除前后缀
text = extractJson(text);
// 修复尝试2:替换单引号为双引号(常见问题)
text = fixSingleQuotes(text);
// 修复尝试3:修复尾部逗号
text = removeTrailingCommas(text);
// 修复尝试4:修复控制字符
text = removeControlCharacters(text);
try {
T result = objectMapper.readValue(text, targetClass);
List<String> violations = schema.validate(result);
if (violations.isEmpty()) {
return ParseResult.success(result, RepairAction.SYNTAX_FIX);
}
return repairSchemaViolations(text, violations, targetClass, schema);
} catch (JsonProcessingException e) {
// 语法修复失败,尝试让LLM修复
return llmRepair(rawOutput, originalError.getMessage(), targetClass, schema);
}
}
/**
* 让LLM修复格式错误的输出
*/
private <T> ParseResult<T> llmRepair(String badOutput, String parseError,
Class<T> targetClass, JsonSchema schema) {
String repairPrompt = String.format("""
以下JSON有格式错误,错误信息:%s
请修复JSON,使其符合以下Schema,只返回修复后的JSON,不要有任何其他文字:
Schema:%s
有问题的JSON:
%s
""", parseError, schema.toDescription(), badOutput);
try {
String repairedOutput = llmClient.complete(repairPrompt);
String cleanedJson = extractJson(repairedOutput);
T result = objectMapper.readValue(cleanedJson, targetClass);
List<String> violations = schema.validate(result);
if (violations.isEmpty()) {
return ParseResult.success(result, RepairAction.LLM_REPAIR);
}
// LLM修复后仍有Schema违规,但能解析,返回带警告的结果
return ParseResult.successWithWarnings(result, RepairAction.LLM_REPAIR, violations);
} catch (Exception e) {
log.error("LLM修复也失败了,使用降级处理", e);
return ParseResult.failure(parseError);
}
}
private String fixSingleQuotes(String json) {
// 简单替换可能会误伤,只处理键名用单引号的情况
return json.replaceAll("'([^']+)'\\s*:", "\"$1\":");
}
private String removeTrailingCommas(String json) {
// 去掉对象或数组最后一个元素后面的多余逗号
return json.replaceAll(",\\s*([}\\]])", "$1");
}
private String removeControlCharacters(String json) {
// 去掉不可见的控制字符
return json.replaceAll("[\\x00-\\x1F&&[^\\x09\\x0A\\x0D]]", "");
}
}Schema违规的修复
private <T> ParseResult<T> repairSchemaViolations(String json, List<String> violations,
Class<T> targetClass, JsonSchema schema) {
String repairPrompt = String.format("""
以下JSON存在Schema违规,请修复后重新返回,只返回JSON:
当前JSON:
%s
违规问题:
%s
期望的Schema:
%s
修复说明:
- 枚举值必须使用Schema中列出的值
- 数字类型不能用字符串表示
- 不能缺少必填字段
""",
json,
String.join("\n", violations),
schema.toDescription()
);
try {
String repaired = llmClient.complete(repairPrompt);
T result = objectMapper.readValue(extractJson(repaired), targetClass);
return ParseResult.success(result, RepairAction.SCHEMA_REPAIR);
} catch (Exception e) {
// 修复失败,尝试构建最小可用对象
return buildMinimalResult(json, targetClass, schema);
}
}第三层:降级处理
所有修复都失败后,构建一个最小可用的结果:
@Service
public class StructuredOutputFallback {
private final ObjectMapper objectMapper;
/**
* 构建最小可用结果:只保留能正确解析的字段
*/
@SuppressWarnings("unchecked")
public <T> T buildMinimalResult(String partialJson, Class<T> targetClass, JsonSchema schema) {
try {
// 解析成Map,提取能用的字段
Map<String, Object> partialMap = new HashMap<>();
try {
partialMap = objectMapper.readValue(partialJson, Map.class);
} catch (Exception e) {
// 连Map都解析不了,用空Map
}
// 只保留Schema中定义的字段,剔除类型不符的字段
Map<String, Object> cleanMap = new HashMap<>();
for (JsonSchema.Field field : schema.getFields()) {
Object value = partialMap.get(field.getName());
if (value != null && field.isTypeCompatible(value)) {
cleanMap.put(field.getName(), value);
} else if (field.isRequired()) {
// 必填字段缺失或类型不对,用默认值
cleanMap.put(field.getName(), field.getDefaultValue());
}
}
// 把Map转为目标类型
String cleanJson = objectMapper.writeValueAsString(cleanMap);
return objectMapper.readValue(cleanJson, targetClass);
} catch (Exception e) {
log.error("降级处理也失败,返回空对象", e);
return createEmptyInstance(targetClass, schema);
}
}
/**
* 创建只有默认值的空实例
*/
private <T> T createEmptyInstance(Class<T> targetClass, JsonSchema schema) {
Map<String, Object> defaultMap = new HashMap<>();
for (JsonSchema.Field field : schema.getFields()) {
if (field.isRequired()) {
defaultMap.put(field.getName(), field.getDefaultValue());
}
}
try {
String defaultJson = objectMapper.writeValueAsString(defaultMap);
return objectMapper.readValue(defaultJson, targetClass);
} catch (Exception e) {
throw new StructuredOutputException("无法创建目标对象的默认实例", e);
}
}
}完整的使用示例
把上面三层整合起来,使用时是这样的:
@Service
public class ProductInfoExtractor {
private final LlmClient llmClient;
private final JsonOutputRepairer repairer;
private final StructuredOutputFallback fallback;
private final StructuredOutputMonitor monitor;
// 定义Schema
private static final JsonSchema PRODUCT_SCHEMA = JsonSchema.builder()
.field("name", FieldType.STRING, true, null)
.field("price", FieldType.NUMBER, true, null)
.field("category", FieldType.ENUM, true, null, "electronics", "clothing", "food", "sports")
.field("in_stock", FieldType.BOOLEAN, true, true)
.field("description", FieldType.STRING, false, "")
.build();
public ProductInfo extractProductInfo(String rawText) {
// 构建Prompt
String systemPrompt = promptBuilder.buildSystemPrompt(PRODUCT_SCHEMA);
String userMessage = "请从以下文本中提取商品信息:\n" + rawText;
// 调用LLM
String rawOutput = llmClient.chat(systemPrompt, userMessage);
// 解析,带自动修复
ParseResult<ProductInfo> result = repairer.parseWithRepair(
rawOutput, ProductInfo.class, PRODUCT_SCHEMA
);
// 记录指标
monitor.record(result.getRepairAction(), result.isSuccess());
if (result.isSuccess()) {
if (result.hasWarnings()) {
log.warn("结构化输出解析成功但有警告:{}", result.getWarnings());
}
return result.getValue();
}
// 最终降级
log.error("所有修复尝试失败,使用降级结果。原始输出:{}", rawOutput);
return fallback.buildMinimalResult(rawOutput, ProductInfo.class, PRODUCT_SCHEMA);
}
}监控与迭代改进
// 追踪不同修复动作的频率,发现问题所在
@Component
public class StructuredOutputMonitor {
private final MeterRegistry meterRegistry;
public void record(RepairAction action, boolean success) {
Counter.builder("structured_output_total")
.tag("repair_action", action.name())
.tag("success", String.valueOf(success))
.register(meterRegistry)
.increment();
}
// 如果LLM_REPAIR率超过5%,说明Prompt需要优化
// 如果FAILURE率超过1%,需要紧急介入
}有了这套监控,就能定期看到:SYNTAX_FIX比例高,说明模型输出语法问题多,可以考虑开启JSON Mode;SCHEMA_REPAIR比例高,说明枚举或类型问题多,要强化Prompt中的相关说明;FAILURE比例高,说明某些输入场景对模型挑战太大,可能需要换模型或重设计任务。
特定场景的额外技巧
流式输出场景
如果你用流式输出,无法在流完成前解析JSON,可以用一个缓冲区:
public class StreamingJsonBuffer {
private final StringBuilder buffer = new StringBuilder();
private boolean jsonStarted = false;
private int braceDepth = 0;
public Optional<String> consume(String token) {
buffer.append(token);
for (char c : token.toCharArray()) {
if (c == '{') {
jsonStarted = true;
braceDepth++;
} else if (c == '}') {
braceDepth--;
if (jsonStarted && braceDepth == 0) {
// JSON对象结束
return Optional.of(buffer.toString());
}
}
}
return Optional.empty();
}
}嵌套JSON的Schema验证
对于有嵌套结构的JSON,验证要递归进行:
public List<String> validateNested(Map<String, Object> json, JsonSchema schema, String path) {
List<String> violations = new ArrayList<>();
for (JsonSchema.Field field : schema.getFields()) {
Object value = json.get(field.getName());
String fieldPath = path.isEmpty() ? field.getName() : path + "." + field.getName();
if (value == null && field.isRequired()) {
violations.add(fieldPath + " 是必填字段,但值缺失");
continue;
}
if (value != null && !field.isTypeCompatible(value)) {
violations.add(fieldPath + " 类型不正确,期望" + field.getType() + ",实际" + value.getClass().getSimpleName());
}
if (field.getType() == FieldType.OBJECT && value instanceof Map) {
// 递归验证嵌套对象
violations.addAll(validateNested((Map<String, Object>) value, field.getNestedSchema(), fieldPath));
}
}
return violations;
}结构化输出的可靠性不是靠碰运气,靠的是系统性的三层防御:Prompt工程减少出错、自动修复处理偶发问题、降级机制保证系统不崩溃。这三层加上监控告警,能把结构化输出的失败率控制在一个可接受的范围内。
