LLM输出质量控制:过滤·校验·重试的完整防护方案
2026/4/30大约 8 分钟
LLM输出质量控制:过滤·校验·重试的完整防护方案
适读人群:已上线AI功能、被输出质量问题困扰的Java工程师 阅读时长:约18分钟
那次"灵魂"输出事故
上个月,我们一个合同辅助撰写系统出了个事故。一个用户提交了一段复杂的合同条款,让AI帮忙分析。AI给出的分析里有一句话是:
"根据本合同第7条,乙方须在三个工作日内提供灵魂担保,否则视为违约。"
"灵魂担保"。
这词在中国合同法里根本不存在,是AI凭空生成的。用户把这段分析直接复制进了正式合同草案,差点造成了一起法律纠纷,幸好对方律师在审核时发现了。
事后我们排查,AI有时候在处理长篇法律文本时,因为上下文太长,会出现一些"创造性发挥"。这就是LLM输出的典型问题:不是完全错,但关键地方会出毫无预兆的幻觉。
对于医疗、法律、金融这类高风险场景,LLM输出必须有严格的防护体系。今天把我们这套"三层防护"方案完整分享出来。
输出质量问题的分类
三层防护架构
第一层:输入过滤
Prompt注入(Prompt Injection)是AI应用特有的安全攻击。用户通过特殊输入,试图覆盖系统Prompt,让AI做出不应该做的事情。
/**
* 输入安全过滤器
* 防御Prompt注入和其他恶意输入
*/
@Component
@Slf4j
public class InputSecurityFilter {
// Prompt注入的常见攻击模式
private static final List<Pattern> INJECTION_PATTERNS = List.of(
Pattern.compile("ignore (?:all )?(?:previous|above) (?:instructions?|prompt)",
Pattern.CASE_INSENSITIVE),
Pattern.compile("forget (?:everything|all) (?:you|i) (?:told|said)",
Pattern.CASE_INSENSITIVE),
Pattern.compile("你现在是|你不再是|扮演|角色扮演|assume the role",
Pattern.CASE_INSENSITIVE),
Pattern.compile("system(?:\\s*)?:(?:\\s*)?(?:you are|ignore)",
Pattern.CASE_INSENSITIVE),
Pattern.compile("\\[SYSTEM\\]|\\[ADMIN\\]|\\[OVERRIDE\\]"),
Pattern.compile("DAN mode|jailbreak|绕过限制|破解",
Pattern.CASE_INSENSITIVE)
);
// 敏感词过滤(根据业务场景配置)
private final Set<String> sensitiveKeywords;
public InputFilterResult filter(String userInput, String scenarioCode) {
if (userInput == null || userInput.isBlank()) {
return InputFilterResult.rejected("输入内容为空");
}
// 长度限制
if (userInput.length() > 10000) {
return InputFilterResult.rejected("输入内容过长,请压缩后重试");
}
// Prompt注入检测
for (Pattern pattern : INJECTION_PATTERNS) {
if (pattern.matcher(userInput).find()) {
log.warn("检测到Prompt注入攻击: pattern={}, input_preview={}",
pattern.pattern(), userInput.substring(0, Math.min(100, userInput.length())));
return InputFilterResult.rejected("检测到不合法的输入内容");
}
}
// 敏感词检测(根据场景不同,规则不同)
for (String keyword : getSensitiveKeywords(scenarioCode)) {
if (userInput.contains(keyword)) {
return InputFilterResult.flagged("包含敏感词,已记录审查");
}
}
return InputFilterResult.allowed(sanitize(userInput));
}
/**
* 输入清洗:移除可能干扰Prompt格式的特殊字符
*/
private String sanitize(String input) {
// 移除可能被解释为Prompt格式的特殊前缀
String sanitized = input
.replaceAll("(?m)^(system|user|assistant)\\s*:", "")
.replaceAll("<\\|(?:system|user|assistant)\\|>", "") // 某些模型的分隔符
.trim();
return sanitized;
}
private Set<String> getSensitiveKeywords(String scenarioCode) {
// 根据场景返回不同的敏感词列表
return sensitiveKeywords;
}
}第二层:输出校验引擎
/**
* 输出校验引擎
* 对LLM返回的内容进行多维度校验
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class OutputValidationEngine {
private final List<OutputValidator> validators; // 校验器列表,按顺序执行
private final ContentSafetyService contentSafety;
/**
* 执行完整校验流程
*/
public ValidationResult validate(String output, ValidationContext context) {
ValidationResult result = new ValidationResult();
result.setOriginalOutput(output);
for (OutputValidator validator : validators) {
if (!validator.supports(context.getScenario())) continue;
SingleValidationResult singleResult = validator.validate(output, context);
result.addResult(singleResult);
// 遇到严重错误,提前终止
if (singleResult.getSeverity() == Severity.CRITICAL) {
result.setFinalStatus(ValidationStatus.CRITICAL_FAILURE);
log.error("输出严重违规: validator={}, reason={}",
validator.getName(), singleResult.getReason());
return result;
}
}
result.setFinalStatus(
result.hasAnyFailure() ? ValidationStatus.FAILURE : ValidationStatus.SUCCESS
);
return result;
}
}
/**
* JSON格式校验器
*/
@Component
@Order(1)
public class JsonFormatValidator implements OutputValidator {
private final ObjectMapper objectMapper;
@Override
public boolean supports(String scenario) {
return scenario.endsWith("_json") || scenario.startsWith("extract_");
}
@Override
public SingleValidationResult validate(String output, ValidationContext context) {
try {
// 先尝试提取JSON(LLM有时会在JSON前后加额外文字)
String jsonStr = extractJson(output);
JsonNode node = objectMapper.readTree(jsonStr);
// 检查必须字段
if (context.getRequiredFields() != null) {
List<String> missingFields = context.getRequiredFields().stream()
.filter(field -> !node.has(field))
.collect(Collectors.toList());
if (!missingFields.isEmpty()) {
return SingleValidationResult.failure(
"JSON缺少必要字段: " + missingFields,
Severity.ERROR
);
}
}
return SingleValidationResult.success(jsonStr);
} catch (JsonProcessingException e) {
return SingleValidationResult.failure(
"输出不是有效的JSON格式: " + e.getMessage(),
Severity.ERROR
);
}
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
// 也可能是JSON数组
start = text.indexOf('[');
end = text.lastIndexOf(']');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
return text;
}
}
/**
* 业务规则校验器(法律场景示例)
*/
@Component
@Order(2)
public class LegalContentValidator implements OutputValidator {
// 法律文本中不应该出现的词汇(示例)
private static final Set<String> INVALID_LEGAL_TERMS = Set.of(
"灵魂担保", "口头承诺即视为", "永久免责"
);
// 必须包含的免责声明模式
private static final Pattern DISCLAIMER_PATTERN =
Pattern.compile("本内容仅供参考|不构成法律建议|请咨询专业律师");
@Override
public boolean supports(String scenario) {
return scenario.startsWith("legal_") || scenario.equals("contract_analysis");
}
@Override
public SingleValidationResult validate(String output, ValidationContext context) {
// 检查不合法的法律术语
for (String term : INVALID_LEGAL_TERMS) {
if (output.contains(term)) {
log.warn("输出包含无效法律术语: {}", term);
return SingleValidationResult.failure(
"输出包含不规范的法律表述: " + term,
Severity.CRITICAL // 法律场景下这是严重问题
);
}
}
// 检查是否包含必要的免责声明
if (!DISCLAIMER_PATTERN.matcher(output).find()) {
// 不是CRITICAL,但需要修复(可以自动追加免责声明)
return SingleValidationResult.warning(
"输出缺少必要的法律免责声明",
"建议在输出末尾追加:本内容仅供参考,不构成法律建议"
);
}
return SingleValidationResult.success(output);
}
}
/**
* 内容完整性校验器
* 检测输出是否被意外截断
*/
@Component
@Order(3)
public class CompletenessValidator implements OutputValidator {
// 不完整结尾的特征(被截断通常以这些结尾)
private static final List<String> TRUNCATION_SIGNS = List.of(
"...", "等等", "以此类推", "其中包括", "另外还有"
);
// 不完整的中文句子结尾
private static final Pattern INCOMPLETE_SENTENCE =
Pattern.compile("[,,、;;::]\\s*$");
@Override
public boolean supports(String scenario) {
return true; // 所有场景都检查完整性
}
@Override
public SingleValidationResult validate(String output, ValidationContext context) {
if (output == null || output.trim().isEmpty()) {
return SingleValidationResult.failure("输出为空", Severity.ERROR);
}
String trimmed = output.trim();
// 检查是否以省略号或逗号等不完整符号结尾
if (INCOMPLETE_SENTENCE.matcher(trimmed).find()) {
return SingleValidationResult.failure(
"输出可能被截断(句子不完整)",
Severity.WARNING
);
}
// 检查是否过短(可能是答非所问或模型出错)
if (trimmed.length() < context.getMinExpectedLength()) {
return SingleValidationResult.failure(
String.format("输出过短(%d字),可能没有完整回答", trimmed.length()),
Severity.WARNING
);
}
return SingleValidationResult.success(output);
}
}第三层:智能重试策略
/**
* 带重试的LLM调用执行器
* 校验失败后,自动调整Prompt重试
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ResilientLlmExecutor {
private final ChatClient chatClient;
private final OutputValidationEngine validationEngine;
private final InputSecurityFilter inputFilter;
private static final int MAX_RETRIES = 3;
/**
* 执行LLM调用,带校验和重试
*/
public String executeWithValidation(LlmExecutionRequest request) {
// 输入过滤
InputFilterResult filterResult = inputFilter.filter(
request.getUserInput(), request.getScenarioCode());
if (!filterResult.isAllowed()) {
throw new InputFilterException(filterResult.getReason());
}
String sanitizedInput = filterResult.getSanitizedInput();
ValidationContext validationContext = buildValidationContext(request);
int attempt = 0;
String lastFailureReason = null;
while (attempt < MAX_RETRIES) {
attempt++;
// 构建Prompt(失败后追加修正指令)
String systemPrompt = buildSystemPrompt(request, attempt, lastFailureReason);
try {
String output = chatClient.prompt()
.system(systemPrompt)
.user(sanitizedInput)
.call()
.content();
// 校验输出
ValidationResult result = validationEngine.validate(output, validationContext);
if (result.getFinalStatus() == ValidationStatus.CRITICAL_FAILURE) {
// 严重违规,不重试,直接抛异常
log.error("LLM输出严重违规,不重试: {}", result.getSummary());
throw new OutputViolationException("AI输出不符合安全规范");
}
if (result.getFinalStatus() == ValidationStatus.SUCCESS) {
log.debug("LLM执行成功: attempt={}", attempt);
return postProcess(output, result);
}
// 校验失败,记录原因,下次重试时告诉模型哪里错了
lastFailureReason = result.getFailureSummary();
log.warn("LLM输出校验失败,准备重试: attempt={}/{}, reason={}",
attempt, MAX_RETRIES, lastFailureReason);
} catch (OutputViolationException e) {
throw e;
} catch (Exception e) {
lastFailureReason = "调用异常: " + e.getMessage();
log.error("LLM调用异常: attempt={}", attempt, e);
}
}
log.error("LLM调用{}次均未通过校验", MAX_RETRIES);
throw new MaxRetriesExceededException("AI多次尝试仍无法生成有效输出,请人工处理");
}
/**
* 根据失败原因动态调整系统Prompt
*/
private String buildSystemPrompt(LlmExecutionRequest request,
int attempt, String lastFailureReason) {
String basePrompt = request.getSystemPrompt();
if (attempt == 1 || lastFailureReason == null) {
return basePrompt;
}
// 把失败原因注入到下次请求的系统提示中
return basePrompt + String.format("""
---
【重要提示】你上一次的回答存在以下问题,请修正:
%s
请重新回答,确保避免上述问题。
""", lastFailureReason);
}
/**
* 输出后处理:自动追加必要内容
*/
private String postProcess(String output, ValidationResult result) {
String processed = output;
// 如果有警告(如缺少免责声明),自动追加
for (ValidationWarning warning : result.getWarnings()) {
if (warning.getAutoFixContent() != null) {
processed = processed + "\n\n" + warning.getAutoFixContent();
}
}
return processed;
}
}各场景防护策略对比
| 场景 | 格式校验 | 内容校验 | 重试策略 | 特殊要求 |
|---|---|---|---|---|
| 普通问答 | 宽松 | 基础过滤 | 1次 | - |
| 结构化提取 | 严格JSON | 字段完整性 | 3次 | 失败时降级 |
| 法律/合同 | 一般 | 严格规则 | 2次 | 人工复核队列 |
| 医疗咨询 | 一般 | 必须有免责 | 2次 | 敏感词增强 |
| 代码生成 | 代码格式 | 安全扫描 | 3次 | 静态分析 |
| 财务分析 | 数字格式 | 数值合理性 | 2次 | 审计日志 |
我踩过的坑
坑1:重试时Prompt越来越长
把失败原因追加进Prompt,但忘记限制长度,三次重试后Prompt超出Token上限,反而更容易失败。现在加了最大长度限制,失败原因只保留最后一次。
坑2:JSON提取正则过于贪婪
提取JSON时用了{.*}的贪婪匹配,结果在嵌套JSON里从第一个{匹配到最后一个},把中间的普通文字也包进去了。改用逐字符的括号匹配或者Jackson的宽松模式。
坑3:CRITICAL和WARNING混淆
早期所有校验失败都触发重试,结果一个只是"建议追加免责声明"的小警告,也触发了3次重试,成本翻了三倍。后来严格区分等级:WARNING可以自动修复或忽略,只有ERROR才重试,CRITICAL直接拦截。
