2024 年我给公司做一个内部知识库问答 AI,把 HR、行政、财务的制度文档喂进去,让员工能直接问"出差怎么报销""年假怎么算"。上线那天我还挺得意,直到法务同事在群里发了张截图:她问"出差超过 7 天的住宿费报销标准",AI 回答得条理清晰、语气笃定,还煞有介事地引用了一个文号"《差旅管理办法》HR-2022-038 第 4 条"——问题是,这个文号根本不存在,那个数字也是编的。最让我后背发凉的不是它答错了,而是它答错的"姿态":它没有任何犹豫,没有说一句"我不太确定",而是用和回答正确问题时一模一样的自信,流畅地编出了一段以假乱真的政策。我一开始以为是检索没匹配上、或者模型不够强,想着换个更贵的模型就好了。后来我认真去查"大模型幻觉(hallucination)"这个词才明白:我错得很彻底——幻觉不是模型坏了,它是生成式模型的工作方式本身决定的;指望"换个好模型"消灭幻觉,就像指望"换块好硬盘"让程序不再有 bug。这篇文章把我后来一整套压制幻觉的工程方法梳理清楚:幻觉从哪来、为什么删不掉,以及在产品里怎么把它的发生率和危害都压到可接受的范围。
问题背景
现象:内部知识库问答 AI,对它知识范围外的问题,
不会说"我不知道",而是自信地编造答案,甚至编造文档编号
我的错误认知:
"幻觉 = 模型不够聪明 / 检索没做好,换个强模型或调一调就能根治"
真相:
幻觉是生成式语言模型的本质属性,不是可以被彻底修复的 bug
模型的训练目标是"预测下一个最可能的词",不是"说真话"
它天然倾向于把话说得流畅、完整、自信 —— 哪怕内容是错的
它没有"我不知道"这个内置倾向,需要你显式地教它、逼它
能做的不是"消灭幻觉",而是"工程化地压制":
1. 用检索增强(RAG)给模型一份开卷材料,让它别靠记忆
2. 用提示词约束逼它"材料里没有就说没有"
3. 把模型每一个输出都当成待核查的稿件,做引用校验和事实核查
4. 调对解码参数、管理好用户预期
危害分级思维:
幻觉在"闲聊"里是瑕疵,在"政策/医疗/金额/法律"里是事故
越是高风险场景,上面的防线要叠得越厚
一、先认清:幻觉不是 bug,是生成模型的工作方式
# 要治幻觉,先得接受一个反直觉的事实:它不是故障,是设计使然
# 大模型的本质是一个"下一个词预测器":
# 给定前面的一串词,它输出"下一个词"在整个词表上的概率分布
# 然后按这个分布采样一个词,接上去,再预测下一个,循环往复
# 它的训练目标自始至终是"让预测的词尽量接近训练文本",
# 从来不是"输出的内容必须是真的"
# 这带来两个要命的后果:
# 1. 它追求的是"流畅、像样、符合语言习惯",不是"事实正确"
# 一段编造的、但语法通顺逻辑自洽的话,在模型看来就是"高分输出"
# 2. 它没有"知识边界感"
# 模型不知道自己"不知道"什么,对没学过的东西,
# 它一样会把概率最高的词串起来,串出来就是一本正经的胡说
# 所以"模型答错时为什么那么自信"这个问题本身就问错了:
# 模型回答正确和回答错误,用的是完全相同的机制,
# 它的"自信"只是语言流畅度,和"事实把握"没有任何关系
# 结论:不要试图"修好"幻觉,要把它当成一个永远存在的故障率,
# 像对待网络抖动、磁盘错误一样,在它之上做容错设计
# === 小结 ===
# 幻觉是"预测下一个词"这一目标的副产品,你能控制它的概率,但删不掉它
from openai import OpenAI
client = OpenAI()
def naive_ask(question: str) -> str:
"""最朴素的问法:把问题直接丢给模型。
模型会基于训练时记住的统计规律,生成最"像答案"的文本。
它没有"我不知道"这个倾向,只有"把话说圆"的倾向。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": question}],
)
return resp.choices[0].message.content
# 问一个公司内部的、模型训练数据里根本不可能有的问题
answer = naive_ask("我们公司出差超过 7 天的住宿费报销标准是多少?")
print(answer)
# 模型不会回答"我不知道你们公司的政策",
# 它会流畅地编出一个数字、甚至编出一个文档编号 —— 这就是幻觉。
# 注意:同样这段代码,问"什么是 B+ 树"它能答对 ——
# 答对和答错走的是同一套机制,区别只在于"训练数据里有没有"。
二、幻觉从哪来:四个你能动手干预的根源
# 幻觉删不掉,但它的"发生概率"由几个具体因素决定,逐个能干预:
# 根源 1:知识缺失 —— 模型压根没学过
# 公司内部资料、训练截止日期之后的新闻、太冷门的细分领域
# 模型没有这部分知识,被问到就只能靠"猜"
# 干预手段:检索增强(RAG),把外部知识喂给它(第三节)
# 根源 2:解码采样 —— 生成时按概率"掷骰子"
# 模型每一步是按概率分布采样下一个词,temperature 越高越发散
# 高 temperature 会让它选中低概率的词,更容易"飘"出训练内容
# 干预手段:事实类任务把 temperature 压到 0~0.3(第六节)
# 根源 3:提示词诱导 —— 你的问法在"逼"它编
# 问题里夹带错误前提("众所周知 X 是对的,请论证")
# 或者根本没给它"可以说不知道"的退路
# 干预手段:中立提问 + 显式授权它说"无法确定"(第四节)
# 根源 4:对齐副作用 —— 模型被训得"过于乐于助人"
# RLHF 让模型倾向于"给出一个让用户满意的回答",
# 而"我不知道"在训练里常常是低分回答,于是它学会了"硬答"
# 干预手段:用强约束的 system prompt 把"诚实"的优先级抬到"有用"之上
# === 小结 ===
# 知识缺失、解码采样、提示词诱导、对齐副作用 —— 每个根源都有对应的工程手段
def ask_with_temperature(question: str, temperature: float) -> str:
"""同一个问题,用不同 temperature 各问一次,直观感受'发散程度'。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": question}],
temperature=temperature,
)
return resp.choices[0].message.content
# 解码是按概率采样的:temperature 越高,越可能选中低概率的词,
# 也就越容易"发散"出训练数据里本来没有的内容。
for t in (0.0, 0.7, 1.5):
print(f"--- temperature={t} ---")
print(ask_with_temperature("用两句话介绍一下 Paxos 算法", t))
# 经验法则:
# 事实类任务(知识问答 / 信息抽取 / 分类 / 代码)→ temperature 0 ~ 0.3
# 创意类任务(起名 / 头脑风暴 / 营销文案) → temperature 0.7 ~ 1.0
# 把一个问答系统的 temperature 留在默认值(常是 0.7~1.0),
# 等于主动给幻觉开了一道口子。
三、检索增强 RAG:把"闭卷考试"改成"开卷考试"
# 针对"知识缺失"这个最大根源,最有效的手段是检索增强 RAG
# 核心思路一句话:别让模型靠"记忆"答题,让它靠"材料"答题
# 闭卷考试:直接问模型 → 它翻自己脑子里记没记牢 → 记不牢就编
# 开卷考试:先从知识库检索出相关资料,连同问题一起给模型
# → 模型变成"阅读理解",照着材料回答
# 一轮 RAG 的标准流程:
# 1. 把用户问题向量化,在向量库里检索 top-k 个最相关的文档片段
# 2. 把这些片段拼进提示词,作为【已知资料】
# 3. 要求模型"只依据已知资料回答"
# 4. 让模型在回答里标注每条结论来自哪个片段(可追溯)
# RAG 把幻觉问题"收窄"了,但注意它不是银弹:
# - 检索没召回正确片段 → 模型还是没材料 → 还是会编
# - 检索回来的片段相互矛盾 / 过期 → 模型可能答错
# - 模型可能"无视材料"按自己记忆答 → 要靠提示词强约束(第四节)
# 所以 RAG 必须和"提示词约束 + 输出校验"配套使用,单独用不够
# === 小结 ===
# RAG 把"考记忆"变成"考阅读",是压制知识缺失型幻觉的主力,但要配套使用
from typing import List, Dict
def retrieve(question: str, top_k: int = 4) -> List[Dict]:
"""从公司知识库检索与问题最相关的文档片段。
真实实现是"问题向量化 + 向量库相似度检索",这里用结构化伪实现表达。
关键:每个片段必须带上可追溯的来源 doc_id。"""
# query_vec = embed(question)
# hits = vector_store.search(query_vec, top_k=top_k)
hits = [
{"doc_id": "HR-2023-011", "title": "差旅报销管理办法",
"text": "出差 7 天(含)以上,一线城市住宿费上限 500 元/晚。"},
{"doc_id": "HR-2023-011", "title": "差旅报销管理办法",
"text": "报销需在出差结束后 15 个工作日内提交。"},
]
return hits[:top_k]
def build_grounded_prompt(question: str, docs: List[Dict]) -> str:
"""把检索到的材料拼进提示词,让模型'看着材料回答'。
这一步就是把闭卷考试改成开卷考试。"""
context = "\n\n".join(
f"[{d['doc_id']}] {d['title']}\n{d['text']}" for d in docs
)
return (
f"【公司资料】\n{context}\n\n"
f"【问题】{question}\n\n"
f"请只依据上面的【公司资料】回答。"
)
docs = retrieve("出差超过 7 天住宿费标准")
print(build_grounded_prompt("出差超过 7 天住宿费标准是多少?", docs))
四、提示词约束:逼模型学会说"我不知道"
RAG 把材料递到了模型面前,但模型会不会"乖乖照着材料答"完全是另一回事——它完全可能瞥一眼材料,然后继续按自己的记忆发挥。提示词约束,就是用一组明确的规则把模型"摁"在材料上,其中最关键的一条,是显式地给它一条"我不知道"的退路。
# 提示词约束有四条必须写进 system prompt 的硬规则:
# 规则 1:划定边界
# "只能依据【公司资料】回答,禁止使用资料以外的任何知识"
# 不写这条,模型会把 RAG 材料和自己脑子里的记忆混着用,真假难辨
# 规则 2:给出退路(这是最关键的一条)
# "找不到答案就回答'无法确定',这是正确行为,不是失败"
# 你必须显式地告诉模型:说"不知道"是被允许、被鼓励的。
# 否则对齐训练带来的"乐于助人"惯性,会推着它硬answ —— 硬答就是编
# 规则 3:强制引用
# "每一条结论后面用方括号标注来源 doc_id"
# 引用让回答变得可核查;而且它有奇效:模型一旦要标来源,
# 就"编不下去"了 —— 编造的内容根本没有真实来源可标
# 规则 4:禁止编造引用
# "只能引用资料里真实出现过的 doc_id,禁止编造"
# 不写这条,模型会顺手编一个格式完全正确的假文号(引用幻觉)
# 提问侧也要注意:别在问题里夹带错误前提
# 坏问法:"众所周知 X 是对的,请论证 X" —— 你在逼它顺着错误前提编
# 好问法:"关于 X,依据资料说明它是否成立"
# 你喂给模型什么前提,它就会顺着这个前提一路编下去
# === 小结 ===
# 划边界、给退路、强制引用、禁造引用 —— 四条规则把模型摁回材料上
STRICT_SYSTEM_PROMPT = (
"你是公司知识库问答助手,必须严格遵守以下规则,违反任何一条都是严重错误:\n"
"1. 只能依据用户提供的【公司资料】回答,绝对禁止使用资料以外的任何知识。\n"
"2. 如果【公司资料】里找不到答案,必须如实回答'根据现有资料无法确定',"
"禁止猜测、推断、脑补或编造。回答'不知道'是正确行为,不是失败。\n"
"3. 每一条结论后面必须用方括号标注来源,例如 [HR-2023-011]。\n"
"4. 只能引用【公司资料】里真实出现过的 doc_id,绝对禁止编造或拼凑 doc_id。\n"
"5. 不要为了让回答显得完整,而补充资料里根本没有的细节。"
)
def ask_grounded(question: str) -> str:
"""带强约束 system prompt + RAG 材料 + 低 temperature 的问法。
三个手段叠在一起,才构成对'知识缺失型幻觉'的有效压制。"""
docs = retrieve(question)
user = build_grounded_prompt(question, docs)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": STRICT_SYSTEM_PROMPT},
{"role": "user", "content": user},
],
temperature=0.0, # 事实类任务,把采样的随机性压到最低
)
return resp.choices[0].message.content
print(ask_grounded("出差超过 7 天住宿费标准是多少?"))
# 现在模型要么照着材料答并标注 [HR-2023-011],
# 要么回答"根据现有资料无法确定" —— 这两种都是可接受的。
五、输出校验:把每个回答都当成待核查的稿件
前面几节都在"降低幻觉发生的概率",但概率再低也不是零。最后一道防线,是把模型的每一个输出都当成一份"待核查的稿件"——你不会把一篇没人审过的稿子直接见报,模型的回答也一样。要特别记住:不要相信模型的自我评价,你问它"你确定吗",它回答的"我确定"本身也是生成出来的,没有任何可信度。
# 可工程化的输出校验手段,按成本从轻到重:
# 手段 1:引用校验(便宜、必做)
# 用正则抽取出回答里引用的所有 doc_id,逐个检查是否真的在
# 本次检索材料里 —— 只要有一个不在,这条回答就有"引用幻觉",拦截
# 手段 2:自一致性校验
# 同一个问题用稍高的 temperature 连问几次,
# 如果几次答案彼此矛盾,说明模型对这个问题其实在"瞎蒙",可信度低
# 手段 3:事实核查(LLM as a judge)
# 用另一个、通常更强的模型当裁判,
# 给它"材料 + 问题 + 待核查答案",让它判断答案是否被材料支撑
# 这一步也是离线量化"幻觉率"的核心手段
# 手段 4:兜底降级
# 任何一道校验不通过,绝不把可疑答案抛给用户,
# 而是降级为"根据现有资料无法确定,请联系人工",
# 或者把检索到的原文直接附上,让用户自己判断
# === 认知 ===
# 模型输出是不可信的外部数据 —— 要校验、要核查、要兜底,不能直接展示给用户
import re
def verify_citations(answer: str, docs: List[Dict]) -> List[str]:
"""检查回答里引用的每个 doc_id 是否真实存在于本次检索材料中。
模型可能引用一个格式完全正确、却根本不在材料里的 doc_id —— 引用幻觉。"""
valid_ids = {d["doc_id"] for d in docs}
cited = set(re.findall(r"\[([A-Z]+-\d+-\d+)\]", answer))
fake = [c for c in cited if c not in valid_ids]
return fake # 返回非空 = 出现了编造的引用,这条回答不可信
def answer_with_guard(question: str) -> Dict:
"""RAG + 强约束 + 引用校验的完整问答流程。"""
docs = retrieve(question)
answer = ask_grounded(question)
fake = verify_citations(answer, docs)
if fake:
# 出现编造引用,直接拦截,绝不把这条回答抛给用户
return {"ok": False, "reason": f"编造引用 {fake}",
"answer": "根据现有资料无法确定,请联系人工。"}
grounded = "无法确定" not in answer
return {"ok": True, "answer": answer, "grounded": grounded}
result = answer_with_guard("出差超过 7 天住宿费标准是多少?")
print(result)
六、工程坑:温度、引用幻觉、评估、用户预期
把幻觉治理真正落到生产环境,还有几个绕不开的工程问题。它们大多不是"算法问题",而是"工程纪律"和"产品设计"问题——而恰恰是这些不起眼的地方,决定了你的 AI 是"基本可信"还是"时不时出事故"。
# 落地幻觉治理,五个最容易被忽视的工程坑:
# 坑 1:temperature 没压低
# 把问答系统的 temperature 留在默认值(常是 0.7~1.0),
# 等于主动给幻觉开口子。事实类任务一律压到 0~0.3。
# 注意 temperature=0 也不是绝对确定性,仍可能有微小波动,别依赖它做幂等
# 坑 2:引用幻觉防得不够
# 模型最爱编的就是"看起来很专业"的标识符:
# 文档编号、法律条款、论文标题、API 名、版本号、人名职称
# 凡是格式规整的标识符,一律做白名单校验,绝不肉眼相信
# 坑 3:没有评测集,所有"优化"都是凭感觉
# 建一个固定的评测集(几十到上百个带标准答案的问题),
# 每次改提示词 / 换模型 / 调检索后,跑一遍,用 judge 模型算幻觉率
# 没有这个数字,你根本不知道这次改动是变好还是变坏
# 坑 4:不管理用户预期
# 这是产品问题:UI 上要明示"回答由 AI 生成,可能有误";
# 高风险结论(金额/政策/医疗/法律)旁边永远附原文出处链接;
# 永远给用户一个一键"查看原文"和"转人工"的出口
# 坑 5:用放松约束去掩盖检索的烂
# 如果模型老是答"无法确定",十有八九是检索没召回对的片段。
# 该去优化切片策略和向量检索,而不是放松提示词让模型"自由发挥"
# —— 那只是用幻觉掩盖了检索的问题
# === 认知 ===
# 幻觉治理是 RAG + 提示词 + 校验 + 评估 + 产品设计的合力,没有单点银弹
import json
def judge_answer(question: str, answer: str, docs: List[Dict]) -> Dict:
"""用一个独立的、更强的模型当'裁判',核查答案是否真被材料支撑。
这是 'LLM as a judge' 评估法,是自动化量化幻觉率的常用手段。"""
context = "\n\n".join(f"[{d['doc_id']}] {d['text']}" for d in docs)
prompt = (
f"【材料】\n{context}\n\n"
f"【问题】{question}\n\n"
f"【待核查答案】{answer}\n\n"
"请逐句判断:答案里的每一个事实陈述,是否都能在【材料】中找到依据?\n"
"只输出 JSON:{\"supported\": true 或 false, \"reason\": \"简述理由\"}"
)
resp = client.chat.completions.create(
model="gpt-4o", # 裁判用更强的模型,能力要压过被评估的模型
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
def eval_hallucination_rate(test_set: List[Dict]) -> float:
"""跑固定评测集,统计'答案未被材料支撑'的比例,作为幻觉率指标。
每次改提示词 / 换模型 / 调检索,都跑一遍,用这个数字判断好坏。"""
bad = 0
for case in test_set:
docs = retrieve(case["question"])
answer = ask_grounded(case["question"])
verdict = judge_answer(case["question"], answer, docs)
if not verdict["supported"]:
bad += 1
return bad / len(test_set)
# rate = eval_hallucination_rate(my_test_set)
# print(f"当前幻觉率:{rate:.1%}") # 没有这个数字,优化就是盲人摸象
关键概念与手段速查
概念 / 手段 说明
---------------------- --------------------------------------------
幻觉 hallucination 模型生成的、流畅但与事实不符的内容
为什么删不掉 训练目标是"预测下一个词",不是"说真话"
四个根源 知识缺失 / 解码采样 / 提示词诱导 / 对齐副作用
RAG 检索增强 把闭卷考试改成开卷考试,治"知识缺失"
temperature 事实任务 0~0.3,创意任务 0.7~1.0
强约束 system prompt 划边界 / 给退路 / 强制引用 / 禁造引用
引用幻觉 编造格式正确但不存在的文号/条款/API,必做白名单校验
自一致性校验 多次提问答案矛盾 = 模型在瞎蒙,可信度低
LLM as a judge 用更强的模型当裁判,核查 + 量化幻觉率
评测集 固定问题集 + 标准答案,优化的唯一客观标尺
兜底降级 校验不过就回"无法确定/转人工",不抛可疑答案
EXPLAIN 式自检三连(写完一个 AI 问答功能就问自己)
---------------------- --------------------------------------------
材料从哪来 靠记忆还是靠检索?靠记忆=高幻觉风险
有没有给"不知道"的退路 system prompt 里有没有显式授权说"无法确定"
输出有没有人/程序核查 引用校验做了吗?高风险结论附原文了吗
避坑清单
- 别把幻觉当 bug 去"修复",它是生成模型的本质属性,只能压制概率、不能根除,要在它之上做容错。
- 不要靠"换个更强的模型"消灭幻觉,强模型只是幻觉率更低,该编时照样编,工程防线一个都不能省。
- 事实类任务(知识问答、信息抽取、分类)的 temperature 一律压到 0~0.3,别留在默认值。
- 对模型知识范围外的问题(公司内部资料、训练截止后的新闻),必须上 RAG,让它靠材料而不是靠记忆回答。
- RAG 不是银弹:检索没召回正确片段、片段过期或矛盾,模型照样会错,必须配合提示词约束和输出校验。
- system prompt 里一定要显式给模型"说不知道"的退路,明确告诉它"无法确定"是正确行为而非失败。
- 强制模型为每条结论标注来源,并校验引用的文号/编号是否真实存在,引用幻觉是最高发的重灾区。
- 不要相信模型的自我评价,问它"你确定吗"得到的"确定"也是生成的,没有可信度,要用独立手段核查。
- 建固定评测集,用 LLM as a judge 量化幻觉率,每次改动都跑一遍,没有这个数字优化就是凭感觉。
- 高风险结论(金额、政策、医疗、法律)旁边永远附原文出处,给用户"查看原文"和"转人工"的出口,并明示回答由 AI 生成。
总结
回到那张让我后背发凉的截图。它教给我的最重要一课,不是"模型答错了",而是"模型答错时和答对时长得一模一样"。我们习惯于从一个人的语气里读取他的把握程度——犹豫、停顿、"我不太确定"都是有用的信号。但大模型彻底抹掉了这层信号:它回答你训练数据里有的问题,和回答它压根没学过的问题,用的是完全相同的流畅、完整和笃定。那份"自信"不是它对事实的把握,只是它对语言的熟练。把这一点想透,你对 AI 产品的整个设计姿态都会变。
所以正确的问题不是"我怎么让 AI 不说谎",而是"我怎么在一个注定会说谎的系统之上,搭一个基本可信的产品"。这两个问法的差别是根本性的:前者会让你一直在"换模型、调参数"的原地打转,因为幻觉是删不掉的;后者会让你把精力放到真正有用的地方——给模型一份开卷材料(RAG),用规则把它摁在材料上(提示词约束),把它的每个输出都当稿件来审(引用校验、事实核查),并且永远准备好一个"无法确定"的兜底。这一整套不是某一个聪明技巧,而是一层一层叠起来的防线,任何单独一层都挡不住,叠在一起才能把事故率压到可接受的范围。
这套思路其实和我们写传统后端代码时对待"外部输入"的态度一模一样。我们从不相信前端传来的参数,要校验;我们从不假设网络一定通,要重试和降级;我们从不相信第三方接口的返回,要做容错。大模型的输出,本质上就是又一种"不可信的外部输入"——只不过它伪装得格外好,流畅、专业、还会自己加引用。一旦你在心里把模型的每一句话都重新归类为"待核查的外部数据",而不是"一个聪明助手给的答案",前面所有的工程手段就都成了自然而然的事:校验、核查、限流、降级、兜底,一个都不会少。
最后想说的是,治理幻觉的目标从来不是"零幻觉",那既不可能也不必要。目标是"让幻觉发生在它无伤大雅的地方,而不发生在会造成事故的地方"。AI 闲聊时记错一个无关紧要的年份,没人会受伤;但它在报销标准、用药剂量、合同条款上一本正经地编一个数字,就可能酿成真实的损失。所以把你的防线按场景的风险等级来铺:低风险场景可以轻一点,换取流畅的体验;高风险场景就要把 RAG、强约束、引用校验、人工兜底层层叠满,宁可多说几次"无法确定",也绝不让一个编出来的数字溜到用户面前。能分清这两者、并为它们设计不同的防护强度,才算真正理解了如何与一个会说谎的系统一起工作。
—— 别看了 · 2026