大模型输出解析:JSON Mode、工具调用、结构化输出的可靠性保障
大模型输出解析:JSON Mode、工具调用、结构化输出的可靠性保障
适读人群:Java后端工程师、AI应用开发者 | 阅读时长:约18分钟 | 依赖:Spring AI 1.0、Jackson 2.16
开篇故事
我做过一个自动信息抽取的功能:用户输入一段采购订单的文字描述,系统自动解析出供应商名称、商品清单、金额、交期等结构化字段,存入数据库。
第一版的做法很简单:在Prompt里说"请以JSON格式输出",然后用JSON.parse()解析输出。上线第一天就出问题了——模型的输出是这样的:
模型没有返回纯JSON,而是加了前后文字和 ```json 代码块标记,完整输出大概是这样:
{
"supplier": "...",
...
}但这段JSON被包在了 ```json 和 ``` 里面,我的代码抓取整个content字符串拿去JSON.parse,直接抛异常。到处补了try-catch,还有一版是用正则提取代码块里的JSON,结果遇到嵌套代码块又炸了。
这个血的教训让我系统性地研究了大模型结构化输出的各种方法,从最不可靠的"Prompt里说输出JSON",到最可靠的"工具调用+JSON Schema约束",中间有很多工程细节值得讲透。
一、核心问题分析
LLM结构化输出的可靠性从低到高:
Level 1 - Prompt约束:在System Prompt里说"只输出JSON"。不可靠,LLM会添加解释文字,特别是在error case或不确定时。
Level 2 - JSON Mode:OpenAI的response_format: {type: "json_object"}。保证输出是合法JSON,但不保证字段结构符合预期。
Level 3 - JSON Schema约束:提供完整的JSON Schema,LLM输出会严格匹配Schema(OpenAI的Structured Outputs功能)。可靠性极高,支持的模型上约99%的准确率。
Level 4 - 工具调用:把输出定义为函数调用的参数,通过工具调用机制约束输出结构。本质上和Level 3类似,但API语义更清晰。
二、原理深度解析
2.1 四种结构化输出方式对比
2.2 JSON Schema的工作原理
Structured Outputs(结构化输出)利用受约束的采样:在LLM每步token生成时,只允许选择符合当前JSON Schema的token。比如正在生成一个integer类型的字段,模型就只能选择数字字符;生成enum类型,只能选择枚举值之一。
这种约束在解码层面强制执行,比Prompt约束可靠得多,但代价是需要模型提前编译Schema,首次使用时有一定延迟。
三、完整代码实现
3.1 鲁棒JSON提取器(处理Level 1的输出)
@Component
public class RobustJsonExtractor {
private static final Logger log = LoggerFactory.getLogger(RobustJsonExtractor.class);
private final ObjectMapper objectMapper;
// 按优先级尝试不同的JSON提取策略
private static final List<JsonExtractionStrategy> STRATEGIES = List.of(
new DirectParseStrategy(), // 策略1:直接解析
new CodeBlockStrategy(), // 策略2:从```json代码块提取
new BraceExtractionStrategy(), // 策略3:找最外层{}
new PartialRepairStrategy() // 策略4:截断修复
);
public RobustJsonExtractor(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 从LLM原始输出中提取JSON对象,多策略降级
*/
public <T> Optional<T> extract(String llmOutput, Class<T> targetClass) {
for (JsonExtractionStrategy strategy : STRATEGIES) {
try {
String jsonStr = strategy.extract(llmOutput);
if (jsonStr != null && !jsonStr.isEmpty()) {
T result = objectMapper.readValue(jsonStr, targetClass);
log.debug("JSON提取成功(策略:{})", strategy.getName());
return Optional.of(result);
}
} catch (Exception e) {
log.debug("策略{}失败:{}", strategy.getName(), e.getMessage());
}
}
log.warn("所有JSON提取策略均失败,原始输出:{}", llmOutput);
return Optional.empty();
}
interface JsonExtractionStrategy {
String extract(String text);
String getName();
}
static class DirectParseStrategy implements JsonExtractionStrategy {
@Override
public String extract(String text) {
String trimmed = text.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return trimmed;
}
return null;
}
@Override public String getName() { return "DirectParse"; }
}
static class CodeBlockStrategy implements JsonExtractionStrategy {
private static final Pattern JSON_BLOCK =
Pattern.compile("```(?:json)?\\n?([\\s\\S]*?)```");
@Override
public String extract(String text) {
Matcher m = JSON_BLOCK.matcher(text);
// 找到所有代码块,取最大的(最完整)
String largest = null;
while (m.find()) {
String candidate = m.group(1).trim();
if (largest == null || candidate.length() > largest.length()) {
largest = candidate;
}
}
return largest;
}
@Override public String getName() { return "CodeBlock"; }
}
static class BraceExtractionStrategy implements JsonExtractionStrategy {
@Override
public String extract(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
start = text.indexOf('[');
end = text.lastIndexOf(']');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
return null;
}
@Override public String getName() { return "BraceExtraction"; }
}
static class PartialRepairStrategy implements JsonExtractionStrategy {
@Override
public String extract(String text) {
// 找到最后一个完整的字段,在那里截断并闭合
int lastComma = text.lastIndexOf(",\n");
if (lastComma > 0) {
String partial = text.substring(0, lastComma) + "\n}";
if (partial.startsWith("{")) return partial;
}
return null;
}
@Override public String getName() { return "PartialRepair"; }
}
}3.2 Spring AI的结构化输出(Level 3)
@Service
public class StructuredOutputService {
private final ChatClient chatClient;
private final RobustJsonExtractor jsonExtractor;
public StructuredOutputService(ChatClient.Builder builder,
RobustJsonExtractor jsonExtractor) {
this.chatClient = builder.build();
this.jsonExtractor = jsonExtractor;
}
/**
* 方法1:Spring AI的entity()自动反序列化(最简洁)
*/
public PurchaseOrder extractPurchaseOrder(String orderText) {
return chatClient.prompt()
.user("请从以下采购订单文本中提取结构化信息:\n\n" + orderText)
.call()
.entity(PurchaseOrder.class); // Spring AI自动生成JSON Schema并解析
}
/**
* 方法2:指定JSON Schema的详细控制
*/
public PurchaseOrder extractWithSchema(String orderText) {
// 构建详细的JSON Schema约束
BeanOutputConverter<PurchaseOrder> converter =
new BeanOutputConverter<>(PurchaseOrder.class);
String format = converter.getFormat();
String prompt = """
请从以下采购订单文本中提取结构化信息,严格按照以下JSON Schema输出:
%s
订单文本:
%s
""".formatted(format, orderText);
String rawOutput = chatClient.prompt(prompt).call().content();
return converter.convert(rawOutput);
}
/**
* 方法3:工具调用方式(工程上最清晰)
*/
public PurchaseOrder extractWithTool(String orderText) {
// 把"提取采购订单信息"定义为一个工具
// LLM决定调用工具,调用参数就是结构化信息
String prompt = "请从以下文本中提取采购订单信息,调用extract_order工具返回结果:\n\n" + orderText;
// 实际实现中通过Function Calling API,这里简化展示核心思路
return chatClient.prompt(prompt)
.tools("extractOrderTool")
.call()
.entity(PurchaseOrder.class);
}
/**
* 方法4:鲁棒提取(带多层降级的通用方案)
*/
public Optional<PurchaseOrder> robustExtract(String orderText) {
String prompt = """
请从以下采购订单文本中提取信息,只输出JSON,不要有其他文字。
需要提取的字段:
- supplier: 供应商名称
- items: 商品列表,每项含name(名称)、quantity(数量)、unit(单位)、price(单价)
- totalAmount: 总金额
- currency: 货币(默认CNY)
- deliveryDate: 交货日期(格式YYYY-MM-DD)
- remarks: 备注
订单文本:
%s
""".formatted(orderText);
String rawOutput = chatClient.prompt()
.options(ChatOptions.builder()
.responseFormat(new ResponseFormat(ResponseFormat.Type.JSON))
.build())
.user(prompt)
.call()
.content();
return jsonExtractor.extract(rawOutput, PurchaseOrder.class);
}
}3.3 结构化输出的验证框架
@Component
public class StructuredOutputValidator {
private final Validator javaxValidator;
public StructuredOutputValidator(Validator validator) {
this.javaxValidator = validator;
}
/**
* 验证结构化输出是否符合业务规则
*/
public <T> ValidationResult<T> validate(T output) {
if (output == null) {
return ValidationResult.failure("输出为null");
}
// 1. Bean Validation(@NotNull、@Size等注解)
Set<ConstraintViolation<T>> violations = javaxValidator.validate(output);
if (!violations.isEmpty()) {
String errorMsg = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining("; "));
return ValidationResult.failure("字段验证失败: " + errorMsg);
}
// 2. 业务规则验证
if (output instanceof PurchaseOrder order) {
return validatePurchaseOrder(order);
}
return ValidationResult.success(output);
}
private ValidationResult<PurchaseOrder> validatePurchaseOrder(PurchaseOrder order) {
List<String> errors = new ArrayList<>();
if (order.getItems() == null || order.getItems().isEmpty()) {
errors.add("商品列表不能为空");
}
if (order.getTotalAmount() != null && order.getTotalAmount() <= 0) {
errors.add("总金额必须大于0");
}
// 交叉验证:各商品金额合计应该等于总金额(允许1%误差)
if (order.getItems() != null && order.getTotalAmount() != null) {
double computedTotal = order.getItems().stream()
.mapToDouble(item -> item.getPrice() * item.getQuantity())
.sum();
double diff = Math.abs(computedTotal - order.getTotalAmount());
if (diff / order.getTotalAmount() > 0.01) {
errors.add(String.format("明细合计%.2f与总金额%.2f不一致",
computedTotal, order.getTotalAmount()));
}
}
if (!errors.isEmpty()) {
return ValidationResult.failure(String.join("; ", errors));
}
return ValidationResult.success(order);
}
@Data
public static class ValidationResult<T> {
private final boolean valid;
private final T data;
private final String errorMessage;
static <T> ValidationResult<T> success(T data) {
return new ValidationResult<>(true, data, null);
}
static <T> ValidationResult<T> failure(String msg) {
return new ValidationResult<>(false, null, msg);
}
}
}3.4 自动重试与修复
@Service
public class ReliableExtractionService {
private static final Logger log = LoggerFactory.getLogger(ReliableExtractionService.class);
private final ChatClient chatClient;
private final StructuredOutputValidator validator;
private final RobustJsonExtractor extractor;
@Value("${ai.extraction.max-retries:3}")
private int maxRetries;
public ReliableExtractionService(ChatClient.Builder builder,
StructuredOutputValidator validator,
RobustJsonExtractor extractor) {
this.chatClient = builder.build();
this.validator = validator;
this.extractor = extractor;
}
/**
* 带自动重试和错误修复的可靠提取
*/
public <T> T reliableExtract(String text, String instruction,
Class<T> targetClass) {
String lastError = null;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
// 构建Prompt(包含上次错误反馈)
String prompt = buildPrompt(text, instruction, lastError, targetClass);
String rawOutput = chatClient.prompt()
.options(ChatOptions.builder()
.responseFormat(new ResponseFormat(ResponseFormat.Type.JSON))
.temperature(0.1) // 低temperature提高稳定性
.build())
.user(prompt)
.call()
.content();
Optional<T> extracted = extractor.extract(rawOutput, targetClass);
if (extracted.isEmpty()) {
lastError = "JSON解析失败,请确保只输出合法JSON";
log.warn("第{}次提取失败(JSON解析)", attempt);
continue;
}
StructuredOutputValidator.ValidationResult<T> validation =
validator.validate(extracted.get());
if (validation.isValid()) {
if (attempt > 1) {
log.info("第{}次提取成功", attempt);
}
return validation.getData();
} else {
lastError = "输出验证失败:" + validation.getErrorMessage();
log.warn("第{}次提取失败(验证):{}", attempt, validation.getErrorMessage());
}
}
throw new ExtractionFailedException(
"经过" + maxRetries + "次尝试仍无法提取有效数据,最后错误:" + lastError);
}
private <T> String buildPrompt(String text, String instruction,
String lastError, Class<T> targetClass) {
StringBuilder sb = new StringBuilder(instruction);
sb.append("\n\n待处理文本:\n").append(text);
sb.append("\n\n要求:只输出JSON对象,不要有任何其他文字。");
if (lastError != null) {
sb.append("\n\n注意:上次尝试失败,错误原因:").append(lastError);
sb.append("\n请重新生成,确保修正以上问题。");
}
return sb.toString();
}
}四、效果评估与优化
在采购订单信息提取任务(500个样本)上各方案的对比:
| 方案 | 解析成功率 | 字段完整率 | 验证通过率 | 平均延迟 |
|---|---|---|---|---|
| Prompt约束 + 直接解析 | 71.4% | 84.2% | 62.3% | 850ms |
| JSON Mode | 99.2% | 87.6% | 71.8% | 880ms |
| Spring AI entity() | 99.6% | 94.3% | 82.1% | 920ms |
| 工具调用 | 99.8% | 93.8% | 88.5% | 940ms |
| 可靠提取(3次重试) | 100% | 96.1% | 94.7% | 1350ms(均值) |
解析成功率从71.4%提升到100%(带重试),字段完整率从84.2%提升到96.1%,验证通过率从62.3%提升到94.7%。
五、踩坑实录
坑1:Spring AI的entity()在字段缺失时不报错,静默返回null
chatClient.call().entity(MyClass.class)如果LLM输出的JSON缺少某些字段,Spring AI不会抛异常,而是把对应字段设为null。在业务代码里如果没有做null check,会出现空指针。我加了@NotNull注解配合Bean Validation,在entity解析后立刻做验证,能及时发现缺失字段。
坑2:JSON Schema太复杂时LLM开始"发明"字段
当schema有20+个字段,嵌套3层时,GPT-3.5-turbo开始在输出里"发明"schema里没有定义的字段,同时漏掉几个已定义字段。换成GPT-4o问题消失,或者把schema简化拆分成多步提取(先提取主字段,再提取嵌套字段),效果也很好。
坑3:工具调用的参数类型在Java枚举上有问题
我定义了一个enum类型的工具参数(订单状态),但LLM有时候返回的是枚举的中文描述("待审核"),而不是英文枚举值("PENDING_REVIEW"),导致Jackson反序列化失败。解决方案:在工具的description里明确写出所有允许的枚举值,比如"状态,只能是以下之一:PENDING_REVIEW/APPROVED/REJECTED"。
六、总结
LLM结构化输出的可靠性工程是一个容易被低估的问题。"让LLM输出JSON"听起来简单,真正做到生产级别的99%+准确率,需要选对API级别的约束机制(Structured Outputs或工具调用),加上业务层面的字段验证,以及兜底的自动重试机制。
推荐的优先级:首选Spring AI的entity()(简单场景)或工具调用(复杂场景),只在需要更精细控制时才手写JSON Schema。无论哪种方式,业务验证层都不能省。
