抑制幻觉的工程手段:让模型"说不知道"而非编造
幻觉(Hallucination)是大模型把”不确定”的推断呈现为”确定”的事实。工程上无法彻底消除幻觉,但可以通过多层防御将其影响控制在可接受范围内。关键思路是:给模型提供可核查的信息来源,再用代码验证输出。
如果你是被一次线上事故推过来的——比如客服机器人给用户报了一个不存在的退款政策条款号,或者内部知识库助手把两份文档的数字对调了——先别急着加”你必须诚实回答”这种 prompt,这类话对幻觉基本无效。模型不是在”说谎”,它是在做下一个 token 的概率预测,你要做的是改变它能选择的输出空间,而不是喊话让它变乖。下面这套四层防御,就是围绕”改变输出空间”这个核心思路展开的。
幻觉的三种类型
| 类型 | 表现 | 主要原因 |
|---|---|---|
| 事实幻觉 | 编造不存在的数据、引用、名称 | 训练数据稀疏或截止日期限制 |
| 忠实度幻觉 | 回答与提供的上下文矛盾 | 模型过度”补全”而忽视约束 |
| 推理幻觉 | 结论正确但论证链断裂 | 长链推理积累误差 |
这三种类型在生产环境里的排查方式完全不同,混在一起查是查不出结果的:
- 事实幻觉排查起来最直接:把回答里的具体实体(人名、产品型号、法条编号、日期)单独抽出来,逐条去权威源核对。如果你的业务有内部知识库,直接做字符串/语义双重比对;没有内部库的,用搜索引擎接口回查一遍,能命中就是真的,命不中就大概率是编的。
- 忠实度幻觉要靠”上下文差异对比”来抓:把模型输出和你喂给它的 context 做一次简单的关键信息比对(数字、结论方向、是否有/否定),如果模型说的和资料原文对不上号,这是最容易自动化检测的一类,因为你手里本来就有”标准答案”(也就是 context 本身)。
- 推理幻觉最难查,因为结论可能是对的,只是中间过程站不住脚。实操上一般靠”要求模型先输出推理步骤再给结论”(也就是让它把思考过程摊开),再人工或用第二个模型审查步骤之间是否存在跳跃。这也是为什么第四层”多模型交叉验证”通常只用来治推理幻觉和高风险的事实幻觉,忠实度幻觉靠第三层代码校验就能解决大半,没必要动用整套交叉验证。
不同类型需要不同的抑制策略,下面按层次从高到低介绍,越靠前的层级成本越低、见效越快,越靠后的层级越贵但覆盖面更广。
第一层:RAG 接地(最根本的手段)
让模型”看着资料说话”而非凭记忆:
GROUNDED_PROMPT = """
你是企业知识库助手,只根据以下【参考资料】回答问题。
规则:
1. 若参考资料中有明确答案,直接引用并注明来源。
2. 若参考资料中没有相关信息,回答"根据现有资料无法确认"。
3. 禁止推测或补充参考资料以外的信息。
【参考资料】
{retrieved_context}
【用户问题】
{user_question}
"""
关键短语:“禁止推测”、“无法确认” 比”请如实回答”效果好得多。给模型一个”可以说不知道”的许可,是减少幻觉最低成本的 prompt 技巧。
底层原因不难理解:模型每次生成都是在词表上做一次概率采样,如果你的 prompt 里根本没有出现过”无法确认”这种表达方式,模型的输出分布里就没有一个”安全落点”能对应到”我不知道”这个意思——它只能在词表里挑一个看起来最贴近答案形状的 token 序列往下续写,续着续着就把没根据的内容续成了”事实”。这就是为什么”请如实回答""请确保准确性”这类话基本不起作用:它们没有给模型提供一个具体的、可以选择的输出路径,只是在情绪上”要求”模型,而模型并不会因为你态度诚恳就改变概率分布。反过来,“无法确认”这四个字一旦出现在 prompt 里,就相当于在词表的候选序列里显式点亮了一条路径,模型在遇到检索资料为空或矛盾时,有更大概率走这条路径而不是硬编。
这也是为什么 RAG 接地这一层,效果好坏很大程度上不取决于你的向量库选型或 chunk 策略,而取决于两件事:一是检索命中率(Hit Rate,也就是真正相关的文档有没有被召回到 top-k 里),二是 prompt 里”允许说不知道”的措辞是否够硬。这两者中,命中率排查起来更繁琐:你需要拿一批真实用户问题,人工标注”标准答案应该来自哪几篇文档”,再看你的检索结果里有没有覆盖到,这个比例低于八成基本就说明问题不在模型身上,而在检索环节——这时候去调 prompt 是治标不治本的,先去看看 embedding 模型选型和 chunk 切分是不是有问题(这两块可以参考 RAG 落地那篇里的具体做法)。
第二层:Prompt 约束
# 结构化输出 + 置信度要求
STRUCTURED_PROMPT = """
请按以下 JSON 格式回答,每个字段都必须填写:
{
"answer": "你的回答",
"confidence": "high|medium|low", # 你对这个回答的确信程度
"sources": ["来源1", "来源2"], # 依据来源,无来源则填 []
"caveats": "任何不确定之处" # 无不确定则填 null
}
当 confidence 为 low 时,answer 中必须明确说明不确定性。
"""
置信度字段迫使模型进行”元认知”——让它在给出答案之前,先对自己刚才生成的内容做一次自我评估。这个动作看起来简单,实际效果不小:我们在跑内部知识库助手时观察到一个很典型的现象,如果不加 confidence 字段,模型对着一个查不到的冷门问题也会以肯定语气给出答案;一旦要求它同时输出 confidence,同样的问题它会更倾向于标 low 并在 answer 里带上”这个信息我不确定”的措辞。原因也简单:confidence 字段相当于强迫模型在正式给结论前多做一轮”回顾”,这一轮回顾会让模型有机会把”我其实是在瞎编”这个信号暴露出来,而不是一路平滑地把编造内容和真实内容用同样自信的语气续写下去。
这里有个容易踩的坑:JSON 格式要求越复杂,模型解析失败率越高。如果你的 STRUCTURED_PROMPT 里字段嵌套超过两层,或者要求数组里再套对象,线上会时不时收到模型吐出的”半成品 JSON”——比如漏了闭合括号,或者在字符串里没转义引号导致 json.loads 直接抛 json.decoder.JSONDecodeError: Expecting ',' delimiter。稳妥的做法是:字段尽量拍平(不要嵌套),并且在代码里做一次”宽松解析”兜底——先尝试严格 json.loads,失败了就用正则把 answer/confidence/sources 这几个字段单独抠出来,抠不出来的就整条标记为需要人工复核而不是让请求直接 500。不要指望模型每次都吐出完美 JSON,这个假设在生产环境里迟早会崩。
第三层:输出后校验
对可机器验证的字段做代码层校验:
import re
import httpx
def validate_output(model_response: dict) -> dict:
"""对模型输出做多项校验,标记可疑内容"""
issues = []
# 1. URL 有效性校验
for url in re.findall(r'https?://\S+', model_response.get("answer", "")):
try:
r = httpx.head(url, timeout=3, follow_redirects=True)
if r.status_code >= 400:
issues.append(f"URL 返回 {r.status_code}: {url}")
except Exception:
issues.append(f"URL 不可达: {url}")
# 2. 数字范围合理性检查(业务相关)
numbers = re.findall(r'\b\d{4,}\b', model_response.get("answer", ""))
for num in numbers:
if int(num) > 2100: # 示例:年份不可能超过 2100
issues.append(f"可疑数字: {num}")
# 3. 置信度与答案长度一致性
if model_response.get("confidence") == "high" and len(model_response.get("sources", [])) == 0:
issues.append("high confidence 但无来源引用,需人工复核")
model_response["validation_issues"] = issues
return model_response
这段代码放到生产环境里跑,大概率会先踩到两个坑:
坑一:httpx.head 被目标网站拒绝。 很多网站的服务端没有正确实现 HEAD 方法,或者出于反爬考虑直接对 HEAD 请求返回 405 Method Not Allowed,这时候 r.status_code >= 400 会把一个实际存在的有效链接误判成”URL 返回 405”,反而制造了假阳性。稳妥的做法是 HEAD 失败后自动降级用 httpx.get(只取响应头,不下载正文,加 follow_redirects=True),两次都失败才真正判定为坏链接。
坑二:同步循环校验会拖慢整条请求链路。 上面的代码是 for 循环里逐个发请求,如果模型输出里带了 3 个 URL,每个 3 秒超时,最坏情况这一次校验就要多花 9 秒——用户等一个回答等到 9 秒开外,体验上跟超时没区别。生产上应该把这部分改成并发执行:
import asyncio
import httpx
async def validate_urls_async(urls: list[str], max_concurrent: int = 5) -> list[str]:
"""并发校验 URL 有效性,用 Semaphore 限制并发数避免打崩目标站点或触发对方的限流"""
issues = []
semaphore = asyncio.Semaphore(max_concurrent)
async def check_one(client: httpx.AsyncClient, url: str):
async with semaphore:
try:
r = await client.head(url, timeout=3, follow_redirects=True)
if r.status_code >= 400:
r = await client.get(url, timeout=3, follow_redirects=True)
if r.status_code >= 400:
issues.append(f"URL 返回 {r.status_code}: {url}")
except httpx.TimeoutException:
issues.append(f"URL 超时: {url}")
except httpx.ConnectError:
issues.append(f"URL 无法连接: {url}")
async with httpx.AsyncClient() as client:
await asyncio.gather(*(check_one(client, u) for u in urls))
return issues
这里限制并发数(max_concurrent=5)不是为了省资源,是为了避免你的校验流量本身触发目标站点的速率限制反而被拉黑,或者在同一批请求里把多个外部站点同时打出一堆超时——这种”校验代码本身制造噪音”的情况在做批量核查任务时特别常见。另外要注意区分 TimeoutException 和 ConnectError:前者往往是目标站点响应慢(可以重试),后者往往是域名已失效(重试也没用),把这两种异常都归成一句”URL 不可达”会让你后续排查错失方向。
数字合理性校验那一段也有个容易漏掉的边界情况:正则 \b\d{4,}\b 会把电话号码、订单号、身份证片段这类四位以上数字全部当成”疑似年份”来检查,如果你的业务场景里回答经常带订单号(比如客服场景),这条规则会产生大量误报,把人工复核的队列灌满没用的噪音。更稳的做法是限定上下文——比如只在数字前后出现”年""在”这类年份相关词时才触发年份合理性检查,而不是无差别扫描所有四位数。
第四层:多模型交叉验证
对高风险输出(医疗、法律、财务),用第二个模型做一致性检查:
def cross_validate(question: str, answer: str) -> bool:
"""让第二个模型判断答案是否与问题逻辑一致"""
prompt = f"""
判断以下回答是否存在明显事实错误或自相矛盾之处。
问题:{question}
回答:{answer}
只回答 JSON: {{"is_consistent": true/false, "reason": "..."}}
"""
result = call_llm_json(model="claude-3-5-sonnet", prompt=prompt)
return result.get("is_consistent", False)
成本提示:交叉验证会加倍 token 开销,建议只对 confidence=low 或包含数字/日期/引用的输出触发。
第二个模型本身调用失败怎么办? 这是个容易被忽略的细节——如果 call_llm_json 抛出异常(比如那次调用触发了对端的 429 限流,或者网络超时),cross_validate 不应该让整个主流程跟着挂掉。稳妥的处理是外面包一层 try/except,交叉验证失败时默认按”未通过一致性检查”处理并转人工复核,而不是直接 500 或者悄悄跳过校验——后者看起来省事,但相当于在系统压力大的时候(也就是最容易出问题的时候)自动关闭了防御层,这个退化路径必须显式记录到日志里,不能悄悄发生。
四层防御到底该怎么组合,不是所有场景都要上满:
| 业务场景 | 建议层级 | 理由 |
|---|---|---|
| 内部文档问答、FAQ 机器人 | 第一层 + 第二层 | 风险低,接地 + 置信度基本够用,交叉验证性价比不高 |
| 客服工单自动回复 | 第一层 + 第二层 + 第三层(URL/数字校验) | 涉及具体承诺(退款金额、政策条款),代码校验能拦住大部分低级错误 |
| 医疗/法律/财务咨询类应用 | 全四层 | 单次误判的代价远超交叉验证的 token 成本,必须上满 |
| 纯创意生成(文案、头脑风暴) | 不需要额外防御 | 这类场景本来就期待”发散”,强行接地反而破坏产出质量 |
判断依据很简单:一次幻觉输出如果会导致用户做出错误决策或造成实际损失,就值得上更贵的防御层;如果只是”不够精彩”,加防御反而是在浪费 token 和拖慢响应。 不要一上来就四层全上,先看清楚场景属于哪一档。
常见问题
RAG 之后模型还是在编造,怎么办? 检查两个方向:一是检索质量——相关文档是否确实被召回(Hit Rate);二是 prompt 约束是否足够强,尝试把”禁止补充参考资料以外信息”改为单独的系统消息而非嵌入用户消息,优先级更高。
要求模型输出”置信度”,它说的置信度可信吗? 不能完全信任,但有统计意义:校准研究表明,模型说”low confidence”时错误率确实更高。把置信度作为”触发人工复核的过滤器”而非”绝对准确的度量”,是合理的工程用法。
长对话中幻觉会增加吗? 会。messages 历史每增长一轮,早期错误信息就多一次被模型当作”已确认事实”继续引用的机会。解决方案:对话超过 10 轮后做有损摘要(明确标注哪些是模型推断而非用户确认的事实),或定期清空上下文重置。
URL 校验、多模型交叉验证这些额外调用,会不会把响应延迟拖得没法用? 会有影响,但可以控制在可接受范围。经验做法是把校验拆成”同步阻塞”和”异步补充”两部分:第一层 RAG 接地和第二层 prompt 约束必须同步完成,因为它们直接决定这次答案的内容;第三层的 URL/数字校验可以异步执行——先把答案返回给用户,校验结果如果发现问题再通过消息推送或者下次对话时补充提醒(“上次提到的链接经核实已失效”),不必让用户等在原地。第四层多模型交叉验证同理,只在离线批处理或者对高风险请求单独走一条较慢的链路,不要挂在主响应路径上。
怎么知道这套防御体系上线之后到底有没有用?
不要只看”有没有报错”,要建立一个可持续跟踪的幻觉率指标。具体做法:把 validation_issues 非空的请求占比、confidence=low 的占比、以及人工复核后确认”确实是幻觉”的比例,这三个数字按天或按周画成趋势线放进你的监控面板。上线新一层防御之后,如果这三条线没有明显下降,说明这层防御没起作用,需要回头检查是 prompt 措辞不够硬还是校验规则本身有漏洞,而不是想当然地认为”加了就一定有效”。
落地自查清单
如果你准备把这套防御体系搬到自己的项目里,按下面顺序过一遍,比一上来四层全铺更容易出效果:
- 先只加第一层——把”禁止推测""无法确认”这类措辞加进你现有的 RAG prompt 里,跑一批真实用户问题,看模型主动说”不知道”的比例有没有从接近零变成一个看得见的数字。
- 抽 20~30 条被判定为”高置信度”的历史回答,人工核对是否真的可信,如果错误率不低,说明该上第二层置信度字段了。
- 挑出回答里最容易出现”可机器验证”内容的字段(链接、金额、日期、订单状态),针对这几类单独写校验函数,不要一开始就想着覆盖所有可能的错误类型。
- 只有当你的业务场景落在”错误会造成实际损失”这一档时,才引入第四层交叉验证,并且优先只对触发了前几层告警的请求做,而不是对所有请求全量跑一遍。
四层跑完一轮之后,把每一层拦截到的问题数量记下来,你会很清楚看到边际收益在哪一层开始变小——这就是你该停手、把精力挪去优化检索质量或者数据源本身的信号。
← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题
相关阅读:大模型应用评测 Eval · RAG 怎么做:架构与落地
需要同时接入多模型做交叉验证?力达云聚合 API 统一管理多家 API Key,交叉验证只需切换 model 参数,无需管理多套认证。