模型返回的 JSON 总在变花样
让 LLM 抽取工单里的"订单号、问题类型、紧急度"并输出 JSON,结果它时而多回一句解释,时而把字段名写成 order_num 而非 orderId,解析直接炸,下游拿到 null 又是一堆空指针。要做可靠的系统,结构化输出必须可控。我把"约束加重试加校验"几层手段叠起来,把解析失败率从 8% 压到 0.6%。
第一层:用 JSON Schema 约束
OpenAI 等支持 response_format 传 JSON Schema,让模型严格按结构输出:
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "ticket",
"schema": {
"type": "object",
"properties": {
"orderId": {"type": "string"},
"type": {"type": "string", "enum": ["退款","物流","其他"]},
"urgency": {"type": "integer", "minimum": 1, "maximum": 3}
},
"required": ["orderId","type","urgency"]
}
}
}
}
这一层叫"约束解码"的思路(模型在生成时就被限制只能吐合法 token)。但它仍可能偶发不守约——我们实测 92% 能严格遵循,剩下 8% 会多输出或漏字段,不能全信,必须再加防御。
第二层:解析失败就重试
约束之外加防御。解析异常时带"错误反馈"重试一次,让模型知道哪错了,通常第二次就能修正:
for (int i = 0; i < 2; i++) {
String raw = llm.call(prompt + (i>0 ? "上次的JSON解析失败,请严格按格式" : ""));
try { return objectMapper.readValue(extractJson(raw), Ticket.class); }
catch (JsonProcessingException e) { lastErr = e; }
}
throw new StructuredOutputException(lastErr);
实测加一次重试后,解析成功率从 92% 升到 99.4%,剩下 0.6% 走人工队列,不影响主流程。重试要控次数,避免模型死循环浪费 token。
第三层:Java 侧类型安全封装
把模型输出映射成强类型,并用 Bean Validation 兜底字段合法性,防止脏数据进下游:
public record Ticket(
@NotBlank String orderId,
@Pattern(regexp="退款|物流|其他") String type,
@Min(1) @Max(3) int urgency) {}
// 校验
validator.validate(ticket).forEach(v -> log.warn("字段非法:{}", v));
record 不可变且天然适配 JSON 绑定,比手写 getter 清爽。校验不通过则进人工队列,不污染下游,也不会因为 orderId 为空导致后续查库出错。
小结
可靠的结构化输出是"约束加重试加校验"三层防御:Schema 把概率问题变成大概率,重试兜偶发失约,Java 类型与校验守最后一道门。别指望模型永远听话,把不确定性的成本用工程手段吸收掉,系统才稳。这层封装我们已经抽成公共组件,所有 LLM 抽取任务都复用。