模型张口就来还言之凿凿:把『幻觉检测』做成一条会拦上线的事实一致性断言
一个知识问答应用,用户问某产品的退换货政策。模型答得漂亮:语气笃定、条理清晰,还煞有介事地列了「7 天无理由退换、15 天质量问题包换、运费由商家承担」。用户看完直接照着操作了。
问题是——知识库里根本没有这条政策。那段「7 天无理由、15 天包换」是模型编的,编得比真的还真。而功能测试对此毫无办法:它去测,回答格式合法、字段齐全、语气正常,每一条断言都过。因为回答「看起来完全正确」。
这就是幻觉最难测的地方。幻觉不是「答得差」,是「答得对-looking 但没有依据」——它专门骗过所有只看「像不像样」的测试。
本篇要解决的,是把幻觉检测从「人肉看回答靠不靠谱」,变成一条会拦上线的可回归断言:模型说的每一个事实性句子,都必须能溯源到给定的上下文;溯源不到,就是幻觉,判红。
一、幻觉测不出来,是因为你测的是「像不像样」
先给结论:靠人肉或靠「格式对不对」的测试,天然抓不到幻觉。因为幻觉的输出在「形式」上是完美的——它语法通顺、结构完整、语气自信,唯一的问题是「内容在给定资料里找不到出处」。你测形式,它就过关;你要测内容有没有依据,就得换一套完全不同的思路。
这套思路你其实很熟。测一个报表接口时,你不会只看「数字格式对不对、能不能返回 200」,你还会追问「这个数字能不能追到源数据」。如果报表里冒出一个源库里根本不存在的销售额,那就是数据造假——哪怕它格式再漂亮。
事实一致性(groundedness)断言,就是把这条「引用完整性」校验搬到大模型输出上:模型说的每个事实性句子,都要能指回上下文里的哪一段。指不回去,就是「编造的数字」。下面这张表,把两种做法摊开对照:
| 维度 | 靠人肉看回答像不像样 | 事实一致性(groundedness)断言 |
|---|---|---|
| 能否发现言之凿凿的编造 | 越自信越像真的,人眼越难察觉 | 只看有无出处,编造句无支撑即暴露 |
| 可回归性 | 每次改 prompt 都要重新人肉看 | 用例固化,每次改动自动重跑 |
| 可归因到哪句 | 只能笼统说「感觉不对」 | 逐句判定,精确指出哪句无支撑 |
| 上线门禁 | 靠主观判断,无法当门禁 | 无支撑句数超阈值即 exit 1,硬门禁 |
看清楚了:人肉看「像不像样」,恰恰是幻觉最擅长骗过的东西;而 groundedness 断言根本不看像不像,只看「有没有出处」——这一换视角,言之凿凿的编造就藏不住了。
更何况人肉抽查在规模上也扛不住。知识库问答一天可能跑成千上万条,你不可能条条去看,只能抽样;而抽样偏偏最容易漏掉低频长尾问题里的编造——那些恰恰是知识库里资料最稀薄、模型最爱脑补的地方。更麻烦的是「自信偏差」:模型越是编得笃定、排版越是清晰,人越倾向于相信它,抽查时反而更容易挥手放行。所以把人肉换成断言,省的不只是人力,是把「靠一个人一时的主观判断」换成「靠一条每次都跑、不受语气和排版影响的规则」——规则不会因为回答写得好漂亮就心软。
二、逐句判定:抽出事实句,检查上下文里有没有支撑
要做这条断言,第一步是把模型输出拆成一个个「事实性句子」,再逐句去给定上下文里找支撑。支撑判定可以从便宜到昂贵分层做:最简单的是「关键实体/关键词覆盖」——句子里的核心实体,是否在 context 里出现过;更稳的是调一个判定函数(规则或小模型)判「这句话能不能由 context 推出」。
# groundedness.py —— 事实一致性(groundedness)逐句判定
import re
def split_factual_sentences(answer: str) -> list[str]:
"""把回答拆成事实性句子(本文示例:按句末标点切分,过滤纯客套)。"""
polite = [" hope this helps", "希望有帮助", "如有疑问"]
raw = re.split(r"[。;\n]", answer or "")
return [s.strip() for s in raw
if s.strip() and not any(p in s for p in polite)]
def extract_entities(sentence: str) -> set:
"""抽出句子里的关键实体:数字、天数、金额、专有名词(本文示例规则)。"""
return set(re.findall(r"\d+\s*[天日元%]|\d+", sentence))
def is_supported(sentence: str, context: str, min_cov: float = 0.6) -> bool:
"""判定一句话是否被 context 支撑:核心实体在上下文中的覆盖率 >= 阈值。"""
ents = extract_entities(sentence)
if not ents: # 无实体的一般性表述,本文示例视为不涉事实
return True
hit = sum(1 for e in ents if e.replace(" ", "") in context.replace(" ", ""))
return hit / len(ents) >= min_cov
def check_groundedness(answer: str, context: str) -> dict:
"""逐句判定,返回无支撑句明细。"""
details = []
for s in split_factual_sentences(answer):
ok = is_supported(s, context)
details.append({
"sentence": s, "supported": ok,
"entities": sorted(extract_entities(s))})
unsupported = [d for d in details if not d["supported"]]
return {
"details": details, "unsupported": unsupported,
"unsupported_count": len(unsupported)}
为什么这么写。第一,split_factual_sentences 先把客套话过滤掉——「希望有帮助」这类句子不承载事实,不该参与判定,否则会把无害的寒暄也误判成幻觉。第二,is_supported 用「核心实体在上下文里的覆盖率」当判据,是一条便宜、确定、可解释的规则:句子里说「7 天」「15 天」,就去 context 里找这两个数字在不在,找不到就是无源之水。阈值 min_cov = 0.6 是本文示例,意思是「一句话里至少六成的核心实体能在上下文找到」,太严会误杀合理的同义改写,太松会放过编造,得按你的场景调。第三,extract_entities 返回空集时我判为 True(视为不涉具体事实),这是个刻意的取舍——纯定性表述难以用实体覆盖判定,本文示例先放行,真实场景里这类句子应该交给更强的判定函数(小模型或 NLI)去兜。踩过的坑是,早期直接拿整段回答去 context 里做子串匹配,结果模型只要换个说法(「7 天」写成「一周」)就判成无支撑、假红一片;改成「抽实体 + 算覆盖率」之后,才把「换措辞但事实一致」和「真编造」区分开。
三、把 groundedness 写成会拦上线的 pytest 断言
有了逐句判定,下一步是把它接进 pytest:断言「无支撑事实句数量 == 0」,超阈值即红,并把 context、输出、逐句判定结果落盘成一份可复核的证据——出了争议,能拿出「哪句、缺哪个实体、上下文里确实没有」的铁证。
# test_groundedness.py —— 事实一致性上线门禁(pytest)
import json, pytest
from groundedness import check_groundedness
# 每行一条样本:{"context": 给定知识库片段, "answer": 模型回答}
CASES = [
{
"context": "本产品支持 7 天无理由退货,退货运费由买家承担。质量问题 15 天内可换货。",
"answer": "本产品支持 7 天无理由退货;质量问题 15 天内可换货。",
"expect_unsupported": 0, # 全部有据,应通过
},
{
"context": "本产品支持 7 天无理由退货,退货运费由买家承担。",
"answer": "本产品支持 7 天无理由退货,15 天质量问题包换,运费由商家承担。",
"expect_unsupported": 1, # 『15 天包换』上下文没有 = 幻觉
},
]
@pytest.mark.parametrize("case", CASES)
def test_no_unsupported_facts(case, tmp_path):
r = check_groundedness(case["answer"], case["context"])
# 证据落盘:context / answer / 逐句判定,出争议时可复核
(tmp_path / "grounded_evidence.json").write_text(
json.dumps({
**case, "result": r}, ensure_ascii=False, indent=2),
encoding="utf-8")
assert r["unsupported_count"] == case["expect_unsupported"], \
f"发现无支撑事实句:{[d['sentence'] for d in r['unsupported']]}"
def test_gate_zero_hallucination_on_golden():
"""门禁口径:golden 集上无支撑事实句必须为 0,否则不允许上线。"""
total = 0
for case in CASES:
total += check_groundedness(case["answer"], case["context"])["unsupported_count"]
# 演示门禁阈值:真实上线场景通常要求 total == 0(零容忍)
assert total <= 1, f"golden 集累计无支撑句 {total} 超门禁阈值"
为什么这么写。第一,参数化的两条样本刻意一正一反:第一条句句有据、expect_unsupported == 0,第二条偷偷塞了「15 天包换、运费商家承担」而上下文只写了「买家承担」——正好演示「言之凿凿的编造」怎么被逐句判定抓出来。把正反样本都固化进用例,才能防住有人哪天把判定逻辑放松了而没人发现。第二,每条都把 context / answer / 逐句判定 写进 grounded_evidence.json,这是 groundedness 断言比「人肉看」强的地方——它不只给结论,还给证据链,评审或申诉时能精确指出「哪句、缺哪个实体」。第三,test_gate_zero_hallucination_on_golden 是门禁入口,示例里写 total <= 1 只是为了让演示样本能过;真实上线口径通常是 total == 0 零容忍——一个会误导用户按假政策操作的问答应用,容不下任何一句编造。踩过的坑是,早期只断言「有没有幻觉」不给证据,红了之后开发根本不认「凭什么说这句是编的」,扯皮半天;把逐句判定和缺失实体一起落盘后,证据摆在眼前,改起来也不再靠猜。文中样本、覆盖率、阈值均为本文示例。
四、判定函数自己也会错:别用一个没校准的裁判去抓幻觉
groundedness 断言有个绕不开的元问题:判「有没有支撑」的那个判定函数,本身也可能判错。用实体覆盖率这种规则判定,会把「7 天」和「一周」当成两回事而假红,也可能因为上下文里恰好出现过某个数字、就放过一句真编造而漏检。所以判定函数不是写完就完事——它自己也得被校准、被回归。
做法跟你校准一个自动裁判(LLM-as-judge)是同一套:先手工标一批「这句话到底有没有出处」的金标准样本,再拿判定函数去跑,统计它的假红率和漏检率;等判定函数升级(比如从实体覆盖换成 NLI 模型或更强的判定服务),就重跑这批金标准,确认它没有悄悄变松或变严。只有对判定函数本身的准确率心里有数,你才敢拿它的输出去卡上线门禁——否则你只是把「模型编造」的风险,平移成了「裁判误判」的风险,看着有门禁,实则没底。
这也是为什么前面代码里我把判定逻辑单独抽成 is_supported 一个函数:它越独立、越好替换,你就越能拿金标准集去单独校准它,而不必动整套用例。判定函数是可插拔的一件「量具」,量具本身要先校准过,量出来的结论才作数。文中假红率、漏检率、阈值均为本文示例。
把这条断言接进 CI,幻觉检测就从「上线后人肉抽查、出了事才回溯」变成了「合并前自动跑、编造句直接拦下」。它治不了模型爱编的毛病,但它能保证——编出来的东西,上不了线。
幻觉最狡猾的地方是「答得对-looking」,所以别再看它像不像样,去看它有没有出处——每个事实句都溯源不到上下文,就该被一条断言拦在上线之前。
你们的知识问答/RAG 应用,是怎么防「模型言之凿凿地编政策」的?是靠人肉抽查,还是已经做了 groundedness 这类事实一致性断言?评论区聊聊你踩过的幻觉坑。