1. 为什么“请返回 JSON”无法支撑生产系统?
在构建大模型驱动的应用(如航班信息提取、医疗实体抽取、企业工单自动化归类等)时,业务下游通常需要强类型的对象(如 Java Record、POJO)。很多初期的技术尝试往往只在 Prompt 层面做软约束:
“请从上述文本中提取航班信息,必须且只能返回标准的 JSON 格式,不要包含任何前缀或后缀文字。”
在简单的测试样例中,这种写法看似能够跑通,但在高并发、长尾输入或使用小参数量化本地模型(如 7B/8B 量级)的真实生产场景下,解析崩溃率通常在 5% 到 15% 不等。
大模型本质上是一个基于概率分布的自回归 Token 预测器,它没有编译器的静态语法约束机制。主要面临三类核心结构化故障:
┌────────────────────────────────────────────────────────┐
│ 大模型结构化抽取的三类核心故障 │
└───────────────────────────┬────────────────────────────┘
│
┌────────────────────────────────────────────────┼────────────────────────────────────────────────┐
▼ ▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ 1. 语法与字符级碎裂 │ │ 2. 反序列化类型错配 │ │ 3. 业务语义与逻辑幻觉 │
├─────────────────────────────────┤ ├─────────────────────────────────┤ ├─────────────────────────────────┤
│ • 携带 Markdown 围栏 (```json) │ │ • 模型捏造额外属性 (未知字段) │ │ • 必填字段缺失或填入空字符串 │
│ • 前后附带客套话/引导句 │ │ • 数组与单值格式错乱 │ │ • 数值越界 (如负数时长、超极量) │
│ • 括号未完全闭合 / 非法转义符 │ │ • 数字属性被填充为字符串空串 │ │ • 格式不合规 (如航班号规则错误) │
└─────────────────────────────────┘ └─────────────────────────────────┘ └─────────────────────────────────┘
为了解决上述问题,系统需要建立一套分层的输入输出治理机制。
2. 6 步结构化防御管线架构总览
针对上述故障,系统设计了一套“前置语法硬约束 ➔ 中置清洗校验 ➔ 后置自愈与降级”的 6 步漏斗型防御管线:
[原始自然语言 Prompt 输入]
│
▼
+-----------------------------------------------------------------------------------+
| Step 1: 前置语法级硬约束 (Grammar-based Constrained Decoding / JSON Schema) |
| - 利用 CFG / JsonSchema 作用于 Logits 采样层,物理封杀非 JSON 字符 |
+----------------------------------------+------------------------------------------+
│
▼ (输出受控文本)
+-----------------------------------------------------------------------------------+
| Step 2: 文本鲁棒性清洗与去围栏 (Regex Sanitization & Boundary Truncation) |
| - 正则剥离 ```json 围栏标记,双指针探测首个 '{' 与末个 '}',剔除前后客套话 |
+----------------------------------------+------------------------------------------+
│
▼ (纯净 JSON 片段)
+-----------------------------------------------------------------------------------+
| Step 3: Jackson 宽容反序列化 (Fault-Tolerant Deserialization) |
| - 忽略未知幻觉字段、单值自适应转数组、空字符串安全转 null,杜绝反序列化直接抛 500 |
+----------------------------------------+------------------------------------------+
│
▼ (实例化 Java Record / POJO)
+-----------------------------------------------------------------------------------+
| Step 4: JSR-380 业务语义强校验 (Bean Validation) |
| - 执行 @NotBlank, @Min, @Pattern 等领域硬约束,拦截“空值幻觉”与非法业务数据 |
+----------------------------------------+------------------------------------------+
│
┌───────────────────────────────┴───────────────────────────────┐
│ 校验通过 (Valid) │ 校验违规 (Invalid)
▼ ▼
+---------------------------------------+ +---------------------------------------+
| 包装为标准结果对象 | | Step 5: 错误截断回灌与自愈重试 |
| - parsed = true | | (Reflection & Self-Healing Loop) |
| - value = 强类型对象 | | - 提取 FieldError 生成纠错提示词 |
+---------------------------------------+ | - 追加上下文重新调用大模型 (上限 3 次)|
+-------------------+-------------------+
│
┌───────────────────────┴───────────────────┐
│ 重试成功 │ 3 次依然违规 / 无法识别
▼ ▼
[进入正常业务流程] +---------------------------------------+
| Step 6: 结果包装与熔断兜底 |
| (Structured Result & Fallback) |
| - parsed = false |
| - value = null (触发降级逻辑) |
| - 保留 rawText 用于追溯与人工审核 |
+---------------------------------------+
3. 管线各环节技术实现
3.1 Step 1:前置语法级硬约束(Constrained Decoding)
解决的问题
杜绝模型输出非法字符、括号未闭合以及格式完全偏离 JSON 的问题。
实现机制
现代推理框架(如 llama.cpp、Ollama、vLLM)支持在 Token 采样层(Sampling Layer)配置上下文无关文法(CFG, Context-Free Grammar)。
在预测下一个 Token 的概率分布时,推理引擎根据预定义的 JSON Schema 语法树,动态将所有不符合当前语法状态转移的 Token 概率强制置为负无穷(Logits Masking)。
// LangChain4j 配置结构化输出约束契约
ResponseFormat responseFormat = ResponseFormat.builder()
.type(ResponseFormatType.JSON)
.jsonSchema(JsonSchema.from(FlightInfo.class))
.build();
ChatRequest chatRequest = ChatRequest.builder()
.messages(userMessage(prompt))
.responseFormat(responseFormat) // 映射至底层的 format=json_schema
.build();
3.2 Step 2:文本鲁棒性清洗与去围栏(Text Sanitization)
解决的问题
部分量化版开源小模型或包含思考链(<think>...</think>)的模型,仍然可能在输出外层包裹 Markdown 代码块标记(```json)或结尾客套话。如果直接将其传给 JSON 解析器,会直接触发 JsonParseException。
实现机制
在进入反序列化引擎前,采用轻量正则替换配合双指针下标截断:
package com.example.ai.pipeline;
public class TextSanitizer {
public static String cleanJsonText(String raw) {
if (raw == null || raw.isBlank()) {
return "{}";
}
// 1. 过滤思考链内容 (如 DeepSeek-R1 等推理性模型)
String cleaned = raw.replaceAll("(?s)<think>.*?</think>", "").trim();
// 2. 剥离 Markdown 围栏代码块标记
cleaned = cleaned.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.trim();
// 3. 边界探测截断:取首个 '{' 到最后一个 '}' 之间的子串
int firstBrace = cleaned.indexOf('{');
int lastBrace = cleaned.lastIndexOf('}');
if (firstBrace != -1 && lastBrace != -1 && lastBrace >= firstBrace) {
return cleaned.substring(firstBrace, lastBrace + 1);
}
return cleaned;
}
}
3.3 Step 3:Jackson 宽容反序列化(Fault-Tolerant Deserialization)
解决的问题
- 未知属性侵入:大模型生成了非预期的额外字段(如自作聪明的
"confidence": 0.95)。默认情况下 Jackson 会抛出UnrecognizedPropertyException。 - 单值与集合格式不一致:Java 字段定义为
List<String>,模型偶尔会直接输出单字符串"tag"。 - 空字符串类型转换:数值类型字段在未抽取到时被填充为
""。
实现机制
为结构化抽取配置专用的 ObjectMapper,关闭强一致性限制:
package com.example.ai.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonLlmConfig {
@Bean("llmObjectMapper")
public ObjectMapper llmObjectMapper() {
return JsonMapper.builder()
// 1. 忽略未知属性,防止模型多输出幻觉字段引发异常
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
// 2. 允许单值作为单元素数组反序列化
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
// 3. 允许将空字符串强制绑定为 null,避免数值字段报类型转换错误
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true)
.build();
}
}
3.4 Step 4:JSR-380 / Bean Validation 语义硬校验
解决的问题
语法正确并不意味着业务合规。大模型容易出现“空值幻觉”与“数值失真”:
- 必填字段缺失或填充了空字符串
""; - 数值逻辑冲突(如飞行时长计算为负数、药品用量为 0)。
实现机制
在目标 Java Record 上声明标准的 JSR-380 注解,反序列化完成后立即触发校验:
package com.example.ai.dto;
import jakarta.validation.constraints.*;
public record FlightInfo(
@NotBlank(message = "航空公司代码不能为空")
@Pattern(regexp = "^[A-Z0-9]{2}$", message = "航空公司代码必须为2位大写字母或数字")
String airline,
@NotBlank(message = "航班号不能为空")
String flightNumber,
@NotNull(message = "飞行时长不能为空")
@Min(value = 1, message = "飞行时长必须大于0分钟")
Integer durationMinutes
) {}
// 语义校验器实现
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<FlightInfo>> violations = validator.validate(flightInfo);
if (!violations.isEmpty()) {
// 聚合所有被打破的业务规则
List<String> errorMessages = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.toList();
throw new ExtractionValidationException(errorMessages);
}
3.5 Step 5:错误截断回灌与自愈重试(Reflection & Self-Healing Loop)
解决的问题
在传统业务中,校验失败通常直接返回 400 Bad Request。但在大模型场景中,模型具备上下文纠错能力,直接抛异常会造成系统可用性下降。
实现机制
系统捕获校验失败产生的 FieldError,将其格式化为纠错反馈(Feedback Prompt)追加到会话上下文中,触发定向自愈循环(最大尝试 3 次)。
[第 1 次尝试] 模型输出: {"airline": "CA", "flightNumber": "", "durationMinutes": 145}
│
▼ (JSR-380 拦截: flightNumber 不能为空)
[构建自愈 Feedback 追加上下文]:
"你上一次输出的 JSON 存在以下业务语义校验错误:
- flightNumber: 航班号不能为空
请仔细核对原始输入文本,修正上述字段后重新输出合规的 JSON。"
│
▼
[第 2 次尝试] 模型成功修正输出: {"airline": "CA", "flightNumber": "1234", "durationMinutes": 145}
3.6 Step 6:结果容器包装与熔断降级(Structured Result & Fallback)
解决的问题
当遇到无关输入(如向航班抽取接口提问天气)或达到重试上限仍无法合规抽取时,避免直接向客户端返回 null 或抛出系统 500 导致调用方崩溃,同时保证线上有据可查。
实现机制
管线向外暴露标准的 ExtractionResult<T> 通用容器,封装解析状态、实体数据、错误摘要与原始报文快照(rawText):
package com.example.ai.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ExtractionResult<T> {
private boolean parsed; // 是否解析成功
private T value; // 强类型解析对象
private String rawText; // 模型原始文本快照 (降级排查核心证据)
private String errorMessage; // 失败原因摘要
public static <T> ExtractionResult<T> success(T value) {
return ExtractionResult.<T>builder()
.parsed(true)
.value(value)
.build();
}
public static <T> ExtractionResult<T> fallback(String rawText, String error) {
return ExtractionResult.<T>builder()
.parsed(false)
.value(null)
.rawText(rawText)
.errorMessage(error)
.build();
}
}
4. 生产级完整抽取管线代码实现
将上述 6 步完整组合到业务抽取服务中:
package com.example.ai.service;
import com.example.ai.dto.ExtractionResult;
import com.example.ai.dto.FlightInfo;
import com.example.ai.pipeline.TextSanitizer;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.model.chat.ChatLanguageModel;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
@Service
public class RobustFlightInfoExtractor {
private static final Logger log = LoggerFactory.getLogger(RobustFlightInfoExtractor.class);
private static final int MAX_RETRIES = 3;
private final ChatLanguageModel chatModel;
private final ObjectMapper objectMapper;
private final Validator validator;
public RobustFlightInfoExtractor(
ChatLanguageModel chatModel,
@Qualifier("llmObjectMapper") ObjectMapper objectMapper,
Validator validator) {
this.chatModel = chatModel;
this.objectMapper = objectMapper;
this.validator = validator;
}
public ExtractionResult<FlightInfo> extract(String userPrompt) {
String currentPrompt = buildInitialPrompt(userPrompt);
String lastRawResponse = "";
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
log.info("执行结构化抽取,当前为第 {} 次尝试...", attempt);
// Step 1: 调用受 Schema 约束的模型接口
lastRawResponse = chatModel.generate(currentPrompt);
// Step 2: 鲁棒性清洗与去围栏
String sanitizedJson = TextSanitizer.cleanJsonText(lastRawResponse);
FlightInfo flightInfo;
try {
// Step 3: Jackson 宽容反序列化
flightInfo = objectMapper.readValue(sanitizedJson, FlightInfo.class);
} catch (Exception e) {
log.warn("第 {} 次尝试反序列化失败: {}", attempt, e.getMessage());
if (attempt == MAX_RETRIES) {
return ExtractionResult.fallback(lastRawResponse, "反序列化崩溃: " + e.getMessage());
}
currentPrompt = buildSyntaxRetryPrompt(userPrompt, sanitizedJson, e.getMessage());
continue;
}
// Step 4: JSR-380 业务语义硬校验
Set<ConstraintViolation<FlightInfo>> violations = validator.validate(flightInfo);
if (violations.isEmpty()) {
log.info("结构化抽取验证成功!解析数据: {}", flightInfo);
return ExtractionResult.success(flightInfo);
}
// 收集校验违规信息
List<String> errors = violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.toList();
log.warn("第 {} 次尝试业务语义校验未通过: {}", attempt, errors);
if (attempt == MAX_RETRIES) {
// Step 6: 达到最大重试上限,触发熔断降级
return ExtractionResult.fallback(lastRawResponse, "业务校验违规: " + String.join(", ", errors));
}
// Step 5: 错误截断回灌,组装纠错提示词
currentPrompt = buildValidationRetryPrompt(userPrompt, sanitizedJson, errors);
}
return ExtractionResult.fallback(lastRawResponse, "未知抽取终止");
}
private String buildInitialPrompt(String input) {
return "请从以下用户输入中提取航班信息,输出标准 JSON 格式:\n" + input;
}
private String buildSyntaxRetryPrompt(String originalInput, String failedJson, String errorMsg) {
return String.format(
"原始需求: %s\n你上一次输出的内容无法解析为合法 JSON: %s\n错误原因: %s\n请修复语法并重新输出纯净的 JSON 字符串。",
originalInput, failedJson, errorMsg
);
}
private String buildValidationRetryPrompt(String originalInput, String failedJson, List<String> errors) {
return String.format(
"原始需求: %s\n你上一次给出的 JSON 为: %s\n存在以下业务字段校验错误:\n%s\n请重新分析原始文本,纠正上述错误字段并重新输出合规的 JSON。",
originalInput, failedJson, String.join("\n", errors)
);
}
}
5. 管线落地价值与设计对比
| 评估维度 | 纯 Prompt 软约束方案 | 6 步工程化防御管线 |
|---|---|---|
| 解析成功率 | 85% ~ 90%(长尾数据易崩溃) | 99.5% 以上(绝大部分异常通过前置约束与自愈消化) |
| 异常防护能力 | 易因多余围栏字符抛出系统 500 | 正则与双指针截断,容忍各类冗余标记 |
| 语义幻觉控制 | 容易生成空字符串、负值等业务坏数据 | JSR-380 注解硬性拦截,不合规字段无法渗透进业务层 |
| 故障自愈机制 | 无(直接报错并终止请求) | 将错误描述转化为 Feedback 回灌,支持定向自愈修正 |
| 降级追溯能力 | 返回空值或报错,排查线索丢失 | 结果容器封装,保留 rawText,支持日志排查与人工质控 |
通过将结构化输出任务从单一的文本处理升级为多层防御管线,系统将概率性输出收敛为具备确定性的企业级业务数据流,确保了大模型应用在线上生产环境中的稳定性与可维护性。