第2127篇:LLM结构化输出的工程实践——让AI输出可靠的JSON,而不是"看起来像JSON"
第2127篇:LLM结构化输出的工程实践——让AI输出可靠的JSON,而不是"看起来像JSON"
适读人群:需要解析AI输出的后端工程师 | 阅读时长:约18分钟 | 核心价值:掌握多种结构化输出技术,从Prompt约束到JSON Schema强制,彻底解决LLM输出解析失败的问题
"LLM返回的JSON里多了一个逗号,解析直接报错。"
"AI有时候在JSON前面加了一段解释文字,导致JSON提取失败。"
"字段名有时是camelCase,有时是snake_case,完全没有规律。"
这是我在做LLM应用集成时最频繁遇到的问题。LLM是生成式的,它"生成"的JSON看起来很像,但不保证格式完全正确。在生产环境里,解析失败率达到5%以上是常见的事。
这篇文章从多个技术层面,系统地解决结构化输出的可靠性问题。
为什么LLM的JSON输出不可靠
/**
* LLM输出不可靠的根本原因
*
* LLM的本质:预测下一个token的概率分布
* 不是解释器/编译器,没有"语法强制"的概念
*
* 常见的JSON输出问题:
*
* 1. 前置文字
* "以下是结果:\n{\"key\":\"value\"}"
* → 前面多了"以下是结果:"
*
* 2. 尾部注释
* "{\"key\":\"value\"}\n注:以上数据仅供参考"
* → JSON后面有额外文字
*
* 3. 字段缺失
* 要求5个字段,只返回了3个
* → 解析后对象不完整
*
* 4. 类型不匹配
* 要求integer,返回了"123"(字符串)
* → 运行时类型错误
*
* 5. Markdown代码块
* ```json\n{...}\n```
* → 需要额外提取,如果忘了就报错
*
* 6. 中英文混用字段名
* {"用户名": "张三", "email": "test@test.com"}
* → 和预期的全英文字段名不一致
*
* 可靠性策略(从低到高):
* - 纯Prompt约束(最不可靠,失败率5-15%)
* - Prompt + 鲁棒解析(失败率1-3%)
* - JSON Schema约束模式(失败率<1%)
* - Structured Output API(失败率约0%,但受支持模型限制)
*/鲁棒的JSON提取与修复
/**
* 鲁棒的JSON解析器
*
* 不假设LLM输出是完美的JSON
* 尽量从不规范的输出中提取有用信息
*/
@Service
@Slf4j
public class RobustJsonParser {
private final ObjectMapper mapper = new ObjectMapper();
/**
* 从LLM输出中提取JSON
*
* 尝试多种提取策略
*/
public Optional<JsonNode> extractJson(String llmOutput) {
if (llmOutput == null || llmOutput.isBlank()) {
return Optional.empty();
}
// 策略1:直接解析(最理想情况)
try {
return Optional.of(mapper.readTree(llmOutput.trim()));
} catch (Exception ignored) {}
// 策略2:提取markdown代码块中的JSON
Optional<String> fromCodeBlock = extractFromCodeBlock(llmOutput);
if (fromCodeBlock.isPresent()) {
try {
return Optional.of(mapper.readTree(fromCodeBlock.get()));
} catch (Exception ignored) {}
}
// 策略3:提取第一个完整的JSON对象(大括号匹配)
Optional<String> fromBraces = extractFirstJsonObject(llmOutput);
if (fromBraces.isPresent()) {
try {
return Optional.of(mapper.readTree(fromBraces.get()));
} catch (Exception e) {
// 策略4:尝试修复常见JSON错误后再解析
Optional<String> fixed = tryFixJson(fromBraces.get());
if (fixed.isPresent()) {
try {
return Optional.of(mapper.readTree(fixed.get()));
} catch (Exception ignored) {}
}
}
}
log.warn("无法从LLM输出提取JSON: {}",
llmOutput.substring(0, Math.min(100, llmOutput.length())));
return Optional.empty();
}
/**
* 提取markdown代码块中的内容
*
* 匹配 ```json ... ``` 或 ``` ... ```
*/
private Optional<String> extractFromCodeBlock(String text) {
Pattern codeBlockPattern = Pattern.compile(
"```(?:json)?\\s*\\n?(.*?)\\n?```",
Pattern.DOTALL
);
Matcher matcher = codeBlockPattern.matcher(text);
if (matcher.find()) {
return Optional.of(matcher.group(1).trim());
}
return Optional.empty();
}
/**
* 通过括号匹配提取第一个JSON对象
*/
private Optional<String> extractFirstJsonObject(String text) {
int start = text.indexOf('{');
if (start < 0) return Optional.empty();
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int i = start; i < text.length(); i++) {
char c = text.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (c == '\\' && inString) {
escaped = true;
continue;
}
if (c == '"') {
inString = !inString;
continue;
}
if (!inString) {
if (c == '{') depth++;
else if (c == '}') {
depth--;
if (depth == 0) {
return Optional.of(text.substring(start, i + 1));
}
}
}
}
return Optional.empty();
}
/**
* 修复常见JSON错误
*
* 注意:这只修复简单问题,复杂错误无法自动修复
*/
private Optional<String> tryFixJson(String json) {
String fixed = json;
// 修复尾部多余逗号({...,})
fixed = fixed.replaceAll(",\\s*}", "}");
fixed = fixed.replaceAll(",\\s*]", "]");
// 修复单引号(Python风格JSON)
// 注意:这个替换很激进,可能破坏内容包含单引号的字符串
// 实际使用时要更谨慎
// fixed = fixed.replace("'", "\"");
if (!fixed.equals(json)) {
return Optional.of(fixed);
}
return Optional.empty();
}
/**
* 从JSON节点安全地获取字段值
*/
public <T> T safeGet(JsonNode node, String field, Class<T> type, T defaultValue) {
try {
JsonNode fieldNode = node.path(field);
if (fieldNode.isMissingNode() || fieldNode.isNull()) {
return defaultValue;
}
return mapper.treeToValue(fieldNode, type);
} catch (Exception e) {
return defaultValue;
}
}
}Prompt约束的最佳实践
/**
* 结构化输出Prompt模板
*
* 好的Prompt能把解析失败率从15%降到3%
*/
@Component
public class StructuredOutputPromptBuilder {
/**
* 构建要求输出JSON的Prompt
*
* 关键技巧:
* 1. 明确说"只返回JSON",不要其他内容
* 2. 给出JSON示例(Few-shot)
* 3. 用XML标签或特殊分隔符包裹JSON(便于提取)
* 4. 在结尾重申格式要求(LLM更可能遵守)
*/
public String buildStructuredPrompt(
String task,
String jsonSchema,
String exampleInput,
String exampleOutput) {
return """
你是一个数据提取助手,专门将文本转换为结构化JSON数据。
任务:%s
输出格式要求:
- 只输出JSON,不要任何其他文字
- 不要markdown代码块(不要```json包裹)
- 所有字符串字段使用双引号
- 数字字段不要引号
- 字段名严格使用snake_case
JSON结构:
%s
示例:
输入:%s
输出:%s
现在请处理以下输入(只返回JSON,不要其他内容):
""".formatted(task, jsonSchema, exampleInput, exampleOutput);
}
/**
* 带XML标签的输出约束
*
* 要求LLM把JSON放在<result>标签内,便于精确提取
*/
public String buildTaggedOutputPrompt(String task, String jsonSchema) {
return """
%s
输出格式:把JSON放在<result>标签内:
<result>
{JSON内容}
</result>
JSON结构:
%s
注意:<result>标签内只有JSON,不要任何说明文字。
""".formatted(task, jsonSchema);
}
/**
* 从带标签的输出中提取JSON
*/
public Optional<String> extractFromTag(String output, String tagName) {
Pattern pattern = Pattern.compile(
"<" + tagName + ">(.*?)</" + tagName + ">",
Pattern.DOTALL
);
Matcher matcher = pattern.matcher(output);
if (matcher.find()) {
return Optional.of(matcher.group(1).trim());
}
return Optional.empty();
}
}JSON Schema强制约束
/**
* 使用JSON Schema约束LLM输出
*
* OpenAI的Structured Output(response_format: json_schema)
* 支持的模型:gpt-4o, gpt-4o-mini(2024年8月以后的版本)
*
* 优点:解析失败率接近0
* 限制:只支持特定模型,Schema有一些限制(不支持anyOf等复杂结构)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class JsonSchemaConstrainedOutputService {
/**
* 使用LangChain4j的结构化输出功能
*
* LangChain4j支持通过Java接口定义输出结构
* 自动生成JSON Schema并强制LLM遵守
*/
// 定义输出结构
record OrderAnalysisResult(
String orderId,
String status,
String issueType, // DELAY/QUALITY/MISSING_ITEM/OTHER
String suggestedAction,
int urgencyLevel, // 1-5
List<String> requiredDepartments
) {}
/**
* 使用接口定义约束输出
*
* LangChain4j会通过反射分析接口,生成对应的JSON Schema
* 发给支持的模型,强制输出遵守结构
*/
public OrderAnalysisResult analyzeOrderComplaint(String complaintText) {
// 使用LangChain4j的AiServices(自动处理结构化输出)
interface OrderAnalysisService {
@UserMessage("""
分析以下订单投诉,提取关键信息:
投诉内容:{{complaint}}
返回分析结果。urgencyLevel:1=不紧急,5=非常紧急。
issueType必须是以下之一:DELAY/QUALITY/MISSING_ITEM/OTHER
""")
OrderAnalysisResult analyze(@V("complaint") String complaint);
}
// 实际创建时使用 AiServices.create(OrderAnalysisService.class, llm)
// 这里展示的是概念
return null; // 简化示意
}
/**
* 手动构建JSON Schema约束请求
*
* 适合需要精细控制的场景
*/
public String generateWithSchemaConstraint(
String prompt, JsonSchema outputSchema) {
// 直接调用支持JSON Mode的API
// 这里展示核心概念
// 关键:设置 response_format = json_object 或 json_schema
// LLM会确保输出是合法JSON
// 在LangChain4j中,可以通过ChatRequest的responseFormat参数设置
ChatRequest request = ChatRequest.builder()
.messages(List.of(UserMessage.from(prompt)))
.responseFormat(ResponseFormat.JSON)
.build();
// ...执行请求
return null;
}
/**
* 带验证的输出处理
*
* 即使用了JSON Schema约束,也建议做一层校验
* 防止模型未能完全遵守约束(edge cases)
*/
public <T> T parseAndValidate(String json, Class<T> type) {
ObjectMapper mapper = new ObjectMapper();
// 尝试解析
T result;
try {
result = mapper.readValue(json, type);
} catch (Exception e) {
log.error("JSON解析失败: {}", json, e);
throw new LlmOutputParseException("LLM输出格式不正确", e);
}
// 使用Bean Validation校验
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<T>> violations = validator.validate(result);
if (!violations.isEmpty()) {
String violationMessages = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));
log.warn("LLM输出不满足约束: {}", violationMessages);
throw new LlmOutputValidationException(
"输出格式约束不满足: " + violationMessages);
}
return result;
}
}容错重试策略
/**
* 结构化输出的重试策略
*
* 当第一次输出不符合格式要求时,用错误信息提示LLM修正
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class StructuredOutputWithRetry {
private final ChatLanguageModel llm;
private final RobustJsonParser jsonParser;
/**
* 带重试的结构化输出
*
* @param prompt 原始Prompt
* @param outputType 期望的输出类型
* @param maxRetries 最大重试次数
*/
public <T> T generateWithRetry(String prompt, Class<T> outputType, int maxRetries) {
ObjectMapper mapper = new ObjectMapper();
List<ChatMessage> messages = new ArrayList<>();
messages.add(UserMessage.from(prompt));
for (int attempt = 0; attempt <= maxRetries; attempt++) {
String llmOutput = llm.generate(messages.stream()
.filter(m -> m instanceof UserMessage || m instanceof AiMessage)
.toList());
// 把LLM的输出加入历史(用于后续重试时的上下文)
messages.add(AiMessage.from(llmOutput));
// 尝试解析
try {
Optional<JsonNode> jsonNode = jsonParser.extractJson(llmOutput);
if (jsonNode.isPresent()) {
T result = mapper.treeToValue(jsonNode.get(), outputType);
if (attempt > 0) {
log.debug("第{}次重试后成功解析", attempt);
}
return result;
}
} catch (Exception e) {
// 解析失败,准备重试
if (attempt < maxRetries) {
// 把错误信息告诉LLM,让它重新生成
String errorFeedback = String.format(
"你的上一次输出有格式问题:%s\n\n" +
"请重新生成,只输出合法的JSON,不要其他内容:",
e.getMessage()
);
messages.add(UserMessage.from(errorFeedback));
log.debug("结构化输出重试: attempt={}, error={}",
attempt + 1, e.getMessage());
}
}
}
throw new LlmOutputParseException(
"经过" + maxRetries + "次重试仍无法获得正确格式的输出");
}
}实践建议
从简单到复杂,选择合适级别的方案
不是所有场景都需要JSON Schema强制约束。对于非核心场景,鲁棒的JSON提取+重试已经足够。对于解析失败代价大的场景(比如会触发后续业务操作的提取),才需要投入JSON Schema约束或Structured Output API。评估成本:普通Prompt方案开发快,但维护成本高(需要处理各种格式变体);Schema约束方案开发稍慢,但运行时更稳定。
先写Few-shot示例,再写格式说明
LLM从示例学习效果比从规则学习效果好。"输出格式:json,包含字段name, age, score"效果不如直接给一个具体的输入输出对。当输出格式复杂时,给2-3个示例往往比写1000字的格式说明更有效。示例要覆盖边界情况:字段为空时怎么表示、数组为空时是null还是[]。
鲁棒解析代码要维护,定期回顾失败日志
即使用了所有技巧,还是会有一些奇怪的输出无法解析。把每次解析失败的原始输出记录下来,定期回顾。会发现大部分失败有规律:某个特定问题类型的输出格式总是不对、某个字段总是被遗漏。针对这些有规律的失败做专项优化,通常能把失败率从1%降到0.1%。
