第1661篇:大模型幻觉问题的根源分析与工程化缓解——不只是提示词的事
第1661篇:大模型幻觉问题的根源分析与工程化缓解——不只是提示词的事
很多人刚接触大模型幻觉(Hallucination)问题的时候,第一反应都是——"提示词没写好"。
这个想法我完全理解,因为我当时也这么想过。2023年初我们团队在做一个金融问答系统,用户问某只股票的历史分红记录,模型给出的数字看起来非常像真的,格式标准、来源清晰,但全是编的。我们QA同事反复调提示词,加了"请只回答你确定的内容"、"如果不知道请说不知道",效果有限。
后来花了两个月深挖这个问题,才明白:幻觉不是提示词能根治的,它是语言模型的结构性缺陷。
这篇文章我想系统讲一下幻觉的根源,以及在工程实践中我们能做哪些事——不只是提示词层面的,是系统性的缓解策略。
一、幻觉到底是什么?为什么会产生?
幻觉这个词本身就挺有意思,它暗示了模型"看到了不存在的东西"。学术上的定义比较精确:模型生成了与事实不符、与上下文矛盾,或完全无法验证的内容。
大致可以分三类:
事实性幻觉(Factual Hallucination):模型生成了明显错误的事实,比如说某个人生于某年,但实际上不是。
忠实性幻觉(Faithfulness Hallucination):模型生成的内容偏离了给定的上下文或指令,比如你让它总结一篇文章,它加入了文章里没有的内容。
内部一致性幻觉(Intrinsic Inconsistency):模型在同一段回答里前后矛盾,A说完了又说B,但A和B互相否定。
要理解为什么会这样,得从模型训练机制说起。
1.1 自回归语言模型的本质
GPT系列、Claude系列,包括开源的LLaMA,核心都是自回归语言模型(Autoregressive LM)。它的训练目标很简单:给定前缀,预测下一个token。
数学上就是最大化:
这个训练目标让模型学会了语言的统计规律,知道某些词后面大概率跟什么词。但这里有个关键问题:语言的统计规律 ≠ 事实。
比如"北京是中国的首都,上海是中国的……",下一个词大概率是"经济中心"或者某个类似的描述。但如果问"某家公司的CEO是谁",模型会根据它见过的类似模式,生成一个听起来合理的名字。如果那个名字恰好存在,就对了;如果不存在,就是幻觉。
模型生成的是"高概率的token序列",不是"真实的事实"。
1.2 训练数据的问题
预训练数据量巨大,质量参差不齐。爬下来的网页里有大量错误信息、过时信息、矛盾信息。模型在这些数据上学习,天然带入了这些噪声。
更棘手的是:知识截止日期(Knowledge Cutoff)。模型的训练数据有时间上限,之后的事情它根本不知道,但它不会承认"我不知道",而是根据已有模式"推断"一个答案。
1.3 RLHF带来的新问题
强化学习人类反馈(RLHF)训练阶段是为了让模型更符合人类偏好。但人类评分员往往更倾向于给"流畅、自信、详细"的回答高分,而不是"准确但简短、带不确定性"的回答。
这导致模型学到了一个策略:与其说"我不确定",不如自信地给一个听起来靠谱的答案。
这是个深层次的对齐问题,不是调提示词能改变的。
二、幻觉的分类体系——工程视角
从工程角度,我更倾向于按照"如何缓解"来分类:
这三类问题的缓解路径差异很大:
- 知识类幻觉:主要靠RAG(检索增强生成)、知识库注入
- 推理类幻觉:主要靠思维链(CoT)、工具调用(计算器、代码执行)
- 指令遵循类幻觉:主要靠提示词工程、输出格式约束、后处理验证
三、工程化缓解策略
3.1 RAG——最直接有效的知识类幻觉缓解
我现在团队的标准做法是:涉及具体事实的问答,一律走RAG,不让模型凭记忆回答。
核心思路:把"让模型记住事实"变成"在回答时实时查事实"。
@Service
public class HallucinationMitigationService {
@Autowired
private VectorStoreService vectorStore;
@Autowired
private LLMClient llmClient;
/**
* 带幻觉缓解的问答接口
* 核心思路:强制模型基于检索到的上下文回答,不允许凭空创造
*/
public AnswerResult answerWithGrounding(String question) {
// 1. 从向量库检索相关文档
List<Document> retrievedDocs = vectorStore.similaritySearch(question, 5);
if (retrievedDocs.isEmpty()) {
// 没有检索到相关文档,明确告知无法回答
return AnswerResult.builder()
.answer("根据当前知识库,暂无相关信息,建议联系专业人士。")
.confidence(0.0)
.grounded(false)
.build();
}
// 2. 构建带约束的系统提示词
String systemPrompt = buildGroundingPrompt(retrievedDocs);
// 3. 调用LLM,要求基于文档回答
LLMResponse response = llmClient.chat(systemPrompt, question);
// 4. 后处理:验证答案是否有依据
double confidenceScore = calculateGroundingScore(response.getContent(), retrievedDocs);
return AnswerResult.builder()
.answer(response.getContent())
.confidence(confidenceScore)
.grounded(confidenceScore > 0.6)
.sourceDocs(retrievedDocs)
.build();
}
private String buildGroundingPrompt(List<Document> docs) {
StringBuilder sb = new StringBuilder();
sb.append("你是一个严谨的信息助手。请严格基于以下参考资料回答问题。\n");
sb.append("如果参考资料中没有相关信息,请直接说明\"根据现有资料无法回答\",不要编造内容。\n\n");
sb.append("参考资料:\n");
for (int i = 0; i < docs.size(); i++) {
sb.append(String.format("[资料%d] %s\n", i + 1, docs.get(i).getContent()));
}
return sb.toString();
}
/**
* 计算答案与检索文档的关联度(Grounding Score)
* 简化版:实际生产中可以用NLI模型做更精确的判断
*/
private double calculateGroundingScore(String answer, List<Document> docs) {
// 提取答案中的关键词
Set<String> answerKeywords = extractKeywords(answer);
// 计算与文档的重叠度
long matchCount = docs.stream()
.flatMap(doc -> extractKeywords(doc.getContent()).stream())
.filter(answerKeywords::contains)
.distinct()
.count();
return answerKeywords.isEmpty() ? 0.0 :
Math.min(1.0, (double) matchCount / answerKeywords.size());
}
private Set<String> extractKeywords(String text) {
// 简化实现,实际中用jieba等分词工具
return Arrays.stream(text.split("[\\s,。、!?,.!?]+"))
.filter(word -> word.length() >= 2)
.collect(Collectors.toSet());
}
}3.2 自一致性检验(Self-Consistency Check)
这个技术来自2022年的一篇论文,核心思想是:对同一个问题,让模型生成多个答案,取一致性最高的那个。
逻辑很简单:如果模型在某个事实上是"幻觉",不同采样路径产生的答案往往不一样;如果是真实知识,大多数路径会收敛到相同答案。
@Service
public class SelfConsistencyService {
@Autowired
private LLMClient llmClient;
/**
* 自一致性推理
* 适用于有明确答案的问题(是非题、选择题、数值计算)
*/
public ConsistencyResult selfConsistencyCheck(String question, int sampleCount) {
List<String> answers = new ArrayList<>();
// 生成多个答案(使用较高temperature增加多样性)
for (int i = 0; i < sampleCount; i++) {
LLMConfig config = LLMConfig.builder()
.temperature(0.7) // 适当的随机性
.maxTokens(500)
.build();
String answer = llmClient.chat(question, config);
String normalizedAnswer = normalizeAnswer(answer);
answers.add(normalizedAnswer);
}
// 统计各答案出现频率
Map<String, Long> answerFrequency = answers.stream()
.collect(Collectors.groupingBy(a -> a, Collectors.counting()));
// 找出最高频的答案
String mostConsistentAnswer = answerFrequency.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("");
double consistencyRatio = (double) answerFrequency.get(mostConsistentAnswer) / sampleCount;
return ConsistencyResult.builder()
.answer(mostConsistentAnswer)
.consistencyScore(consistencyRatio)
.sampleCount(sampleCount)
.answerDistribution(answerFrequency)
// 一致性低于0.5说明模型不确定,可能是幻觉
.reliable(consistencyRatio >= 0.5)
.build();
}
/**
* 答案归一化:处理不同表述形式的相同答案
*/
private String normalizeAnswer(String rawAnswer) {
// 提取答案中的核心部分
// 实际中需要根据问题类型定制化处理
return rawAnswer.trim()
.replaceAll("答案是[::]?", "")
.replaceAll("根据.*?,", "")
.trim()
.toLowerCase();
}
}这个方法有个明显缺点:调用次数是原来的N倍,成本较高。我们在实践中只对"高风险"的问题用这个策略,比如涉及金额、日期、人名等关键信息。
3.3 不确定性感知生成(Uncertainty-Aware Generation)
让模型在回答时显式表达不确定性。这听起来简单,实现起来需要一些技巧。
@Component
public class UncertaintyAwareGenerator {
private static final String UNCERTAINTY_SYSTEM_PROMPT = """
你是一个诚实的AI助手。在回答问题时,请遵循以下原则:
1. 如果你非常确定某个事实,直接陈述。
2. 如果你有一定把握但不完全确定,用"据我了解"、"通常情况下"等措辞。
3. 如果你不确定或信息可能过时,明确说明"这需要进一步确认"或"建议查阅最新资料"。
4. 如果完全不知道,直接说"我不知道",不要猜测。
5. 涉及数字、日期、人名等具体事实时,如果不确定,请给出范围或说明来源。
绝对不允许做的事:编造虚假事实、虚构来源、假装确定性。
""";
@Autowired
private LLMClient llmClient;
public UncertaintyResponse generateWithUncertainty(String userQuery) {
String rawResponse = llmClient.chat(UNCERTAINTY_SYSTEM_PROMPT, userQuery);
// 解析响应,检测不确定性表达
UncertaintyLevel level = detectUncertaintyLevel(rawResponse);
return UncertaintyResponse.builder()
.content(rawResponse)
.uncertaintyLevel(level)
.requiresVerification(level == UncertaintyLevel.HIGH)
.build();
}
private UncertaintyLevel detectUncertaintyLevel(String response) {
List<String> highUncertaintyPhrases = Arrays.asList(
"需要进一步确认", "建议查阅", "不确定", "可能是", "也许", "我不知道"
);
List<String> mediumUncertaintyPhrases = Arrays.asList(
"据我了解", "通常情况下", "一般来说", "大概", "左右"
);
long highCount = highUncertaintyPhrases.stream()
.filter(response::contains).count();
long mediumCount = mediumUncertaintyPhrases.stream()
.filter(response::contains).count();
if (highCount > 0) return UncertaintyLevel.HIGH;
if (mediumCount > 0) return UncertaintyLevel.MEDIUM;
return UncertaintyLevel.LOW;
}
}3.4 事实核查管道(Fact-Checking Pipeline)
对于生成的内容,在返回给用户之前做一次自动核查。这是我们金融系统里用得比较多的方案。
@Service
public class FactCheckingPipeline {
@Autowired
private ClaimExtractor claimExtractor;
@Autowired
private FactVerifier factVerifier;
@Autowired
private LLMClient llmClient;
/**
* 对LLM生成的内容进行事实核查
* 流程:提取声明 -> 逐条验证 -> 标注可信度 -> 返回
*/
public FactCheckedResult checkAndAnnotate(String generatedContent,
List<Document> knowledgeSources) {
// 1. 提取内容中的事实性声明
List<FactualClaim> claims = claimExtractor.extract(generatedContent);
List<VerifiedClaim> verifiedClaims = new ArrayList<>();
boolean hasUnverifiedClaims = false;
for (FactualClaim claim : claims) {
// 2. 在知识源中验证每个声明
VerificationResult result = factVerifier.verify(claim, knowledgeSources);
VerifiedClaim verified = VerifiedClaim.builder()
.claim(claim)
.status(result.getStatus())
.evidence(result.getSupportingEvidence())
.build();
verifiedClaims.add(verified);
if (result.getStatus() == VerificationStatus.UNVERIFIED ||
result.getStatus() == VerificationStatus.CONTRADICTED) {
hasUnverifiedClaims = true;
}
}
// 3. 如果有无法验证的声明,生成修正版本
String finalContent = generatedContent;
if (hasUnverifiedClaims) {
finalContent = generateCorrectedContent(generatedContent, verifiedClaims);
}
return FactCheckedResult.builder()
.originalContent(generatedContent)
.finalContent(finalContent)
.verifiedClaims(verifiedClaims)
.overallTrustScore(calculateTrustScore(verifiedClaims))
.modified(hasUnverifiedClaims)
.build();
}
private String generateCorrectedContent(String original, List<VerifiedClaim> claims) {
// 构建修正提示
StringBuilder correctionPrompt = new StringBuilder();
correctionPrompt.append("原始内容:\n").append(original).append("\n\n");
correctionPrompt.append("以下声明存在问题,请修正:\n");
claims.stream()
.filter(c -> c.getStatus() == VerificationStatus.UNVERIFIED ||
c.getStatus() == VerificationStatus.CONTRADICTED)
.forEach(c -> {
correctionPrompt.append("- 声明:").append(c.getClaim().getText()).append("\n");
correctionPrompt.append(" 问题:").append(c.getStatus().getDescription()).append("\n");
if (c.getEvidence() != null) {
correctionPrompt.append(" 参考证据:").append(c.getEvidence()).append("\n");
}
});
correctionPrompt.append("\n请基于上述反馈,修正原始内容中的错误,保持其余部分不变。");
return llmClient.chat(correctionPrompt.toString());
}
private double calculateTrustScore(List<VerifiedClaim> claims) {
if (claims.isEmpty()) return 1.0;
long verifiedCount = claims.stream()
.filter(c -> c.getStatus() == VerificationStatus.VERIFIED)
.count();
return (double) verifiedCount / claims.size();
}
}四、工具调用——解决推理类幻觉的杀手锏
数学计算幻觉是最经典的推理类幻觉。模型说"1234 * 5678 = 7006652",这个数字是错的(正确答案是7006652,等等让我算……其实是7006652,好像对的?),但重点是不应该让模型算数学题,而是调用计算器。
@Component
public class ToolCallingHallucinationFix {
/**
* 定义工具集合,让模型在需要时调用工具而不是凭空计算
*/
private List<ToolDefinition> buildToolSet() {
return Arrays.asList(
// 计算器工具
ToolDefinition.builder()
.name("calculator")
.description("执行数学计算,包括加减乘除、幂次、开方等")
.parameters(Map.of(
"expression", "数学表达式字符串,如 '1234 * 5678'",
"precision", "小数点精度(可选,默认2位)"
))
.build(),
// 日期计算工具
ToolDefinition.builder()
.name("date_calculator")
.description("计算日期差值、日期加减")
.parameters(Map.of(
"operation", "操作类型:diff/add/subtract",
"date1", "日期1(ISO格式)",
"date2", "日期2(ISO格式,diff时使用)",
"days", "天数(add/subtract时使用)"
))
.build(),
// 知识库查询工具
ToolDefinition.builder()
.name("knowledge_search")
.description("在公司知识库中搜索相关信息,用于回答事实性问题")
.parameters(Map.of(
"query", "搜索关键词",
"top_k", "返回结果数量(默认3)"
))
.build()
);
}
@Autowired
private LLMClient llmClient;
@Autowired
private ToolExecutor toolExecutor;
public String answerWithTools(String question) {
List<ToolDefinition> tools = buildToolSet();
// 第一轮:让模型决定是否需要工具
LLMResponse firstRound = llmClient.chatWithTools(question, tools);
if (!firstRound.hasToolCalls()) {
// 不需要工具,直接返回
return firstRound.getContent();
}
// 执行工具调用
List<ToolResult> toolResults = new ArrayList<>();
for (ToolCall toolCall : firstRound.getToolCalls()) {
Object result = toolExecutor.execute(toolCall.getName(), toolCall.getArguments());
toolResults.add(ToolResult.of(toolCall.getId(), result));
}
// 第二轮:携带工具结果,让模型生成最终答案
LLMResponse finalRound = llmClient.chatWithToolResults(
question, firstRound, toolResults
);
return finalRound.getContent();
}
}五、监控与告警——幻觉的线上检测
上线之后怎么知道幻觉发生了?这是个很实际的问题。
我们做了一套简单但有效的线上幻觉监控:
@Component
public class HallucinationMonitor {
@Autowired
private MetricsService metrics;
@Autowired
private AlertService alertService;
/**
* 线上幻觉检测规则
* 这些是我们踩坑后总结的启发式规则
*/
public HallucinationSignal detectSignals(String question, String answer) {
List<String> signals = new ArrayList<>();
double riskScore = 0.0;
// 规则1:答案包含具体数字但问题中没有数字 -> 可能的数字幻觉
if (containsNumbers(answer) && !containsNumbers(question)) {
signals.add("SPURIOUS_NUMBERS");
riskScore += 0.3;
}
// 规则2:答案包含具体日期 -> 高风险,日期类幻觉很常见
if (containsDate(answer)) {
signals.add("DATE_PRESENT");
riskScore += 0.2;
}
// 规则3:答案长度远超必要长度 -> 可能在填充内容
int expectedLength = estimateExpectedLength(question);
if (answer.length() > expectedLength * 3) {
signals.add("EXCESSIVE_LENGTH");
riskScore += 0.15;
}
// 规则4:答案包含"根据...研究"、"数据显示" -> 可能是虚假引用
List<String> riskyPhrases = Arrays.asList(
"研究表明", "数据显示", "报告指出", "据统计", "权威机构"
);
long phraseCnt = riskyPhrases.stream().filter(answer::contains).count();
if (phraseCnt > 0) {
signals.add("UNVERIFIED_CITATION");
riskScore += 0.2 * phraseCnt;
}
// 规则5:自相矛盾检测(简化版)
if (hasContradictions(answer)) {
signals.add("INTERNAL_CONTRADICTION");
riskScore += 0.4;
}
HallucinationSignal signal = HallucinationSignal.builder()
.signals(signals)
.riskScore(Math.min(1.0, riskScore))
.highRisk(riskScore >= 0.5)
.build();
// 上报指标
metrics.recordHallucinationRisk(riskScore);
// 高风险告警
if (signal.isHighRisk()) {
alertService.sendAlert(AlertLevel.WARNING,
"高幻觉风险对话",
Map.of("question", question, "answer", answer, "score", riskScore));
}
return signal;
}
private boolean containsNumbers(String text) {
return text.matches(".*\\d+.*");
}
private boolean containsDate(String text) {
return text.matches(".*\\d{4}[年/-]\\d{1,2}[月/-]\\d{1,2}.*") ||
text.matches(".*\\d{1,2}月\\d{1,2}日.*");
}
private int estimateExpectedLength(String question) {
// 根据问题类型估算合理答案长度
if (question.contains("是什么") || question.contains("什么是")) return 100;
if (question.contains("如何") || question.contains("怎么")) return 200;
if (question.contains("详细") || question.contains("详解")) return 500;
return 150;
}
private boolean hasContradictions(String text) {
// 简化的矛盾检测:寻找转折后否定前文的模式
String[] sentences = text.split("[。!?]");
// 实际中需要更复杂的NLI模型
return false; // 占位实现
}
}六、我的踩坑总结
干了这一年多的大模型工程,关于幻觉问题几个比较实在的经验:
第一,永远不要信任模型的数字。 凡是涉及具体数值的场景,一定走RAG或工具调用,不能依赖模型记忆。我们有个项目初期没做这个,导致用户看到的某产品报价是模型"猜"的,差点出大事。
第二,提示词只是第一道防线,不是唯一防线。 提示词能让模型"意识到"要谨慎,但不能改变它的底层概率分布。系统性方案必须包含:检索接地 + 输出验证 + 线上监控。
第三,幻觉和模型大小正相关,但不是线性的。 7B的模型幻觉明显比70B多,但70B也会幻觉,只是频率更低、幻觉更"逼真"更难发现。小模型的幻觉容易被发现,大模型的幻觉更危险。
第四,RAG不是银弹。 RAG能解决知识类幻觉,但如果检索质量差(召回了错误文档),反而会让模型生成基于错误前提的内容,这叫"有依据的幻觉",比无依据的幻觉更难处理。
第五,自一致性检验适合决策型问题,不适合开放式生成。 让模型对"写一首诗"做自一致性检验没有意义,但对"这份合同里甲方是谁"做自一致性检验是有价值的。
七、幻觉缓解的完整架构
这套架构是我们在几个项目里逐步演化出来的,不是一开始就这么复杂,而是被幻觉坑了之后一层一层加上去的。
幻觉问题没有彻底解决的一天,因为它是语言模型的本质属性。我们能做的是:通过工程手段,把幻觉的影响控制在可接受的范围内,同时建立感知和响应机制。
下一篇我们讲RAG质量评估体系,你搭了RAG系统怎么知道它好不好用?光靠人工体验是不够的,要有量化指标。
