AI 笔记 · 2026 年 11 月 11 日

大模型幻觉的工程解法:引用、校验与拒答

在 prompt 里写「不要编造」基本无效。把答案拆成句子,逐句检查引用是否存在、数字是否对得上、证据是否覆盖,再把不达标的部分换成拒答,并给出下一步建议。

「请在回答中不要编造」这句话,我见过它出现在至少二十个生产 prompt 里,也见过它一次都没起作用。

原因不复杂:模型不是在「编造」和「诚实」之间做选择,它是在预测下一个 token。你让它别编,等于让它一边生成一边自我审查,而审查能力恰好是它最弱的部分。有效的手段在生成之后:给引用、做校验、允许拒答。

幻觉其实是四种病#

把它们分开,因为可检测性完全不同。

  • 事实错误:说了一个不存在的规定。有外部权威可查时,能校验。
  • 无据推断:从片段里推出片段没说的结论。要看证据覆盖度。
  • 引用错配:结论对,但标了错误的出处。查编号和内容是否对应即可。
  • 张冠李戴:把 A 产品的条款安到 B 产品上。需要实体对齐。

其中引用错配最容易修,也最值得先修:它让「看起来有依据」变成「真的有依据」,用户的信任成本完全不同。

引用式生成:让每句话都能回溯#

做法是把检索到的片段编号后放进上下文,要求模型在每句结论后标注来源编号,并约定「没有依据就写『未找到相关说明』」。

几个实现细节决定成败:

  1. 片段要带稳定的编号,别用「上面第三段」这种相对指代;
  2. 片段里要包含标题路径,否则模型无法判断它属于哪个产品;
  3. 要求引用紧跟在结论后,而不是段末统一列出处,后者几乎无法逐句校验;
  4. 上下文里塞入一两条无关片段,专门用来观察模型会不会乱引。

然后做一件很多团队跳过的事:把引用当结构化数据解析,而不是当装饰。

python
"""verify_citations.py —— 逐句校验引用编号、数字与证据覆盖(纯标准库)"""
import re, unicodedata

CHUNKS = {
    1: "退货需在签收后 7 天内发起,定制商品不支持退货。",
    2: "电子发票在订单完成后 24 小时内发送至预留邮箱。",
}
def norm(s):
    return re.sub(r"\s+", "", unicodedata.normalize("NFKC", s))  # 全角数字转半角

def numbers(s):
    return set(re.findall(r"\d+(?:\.\d+)?", norm(s)))  # 金额、天数、时长

def overlap(sentence, chunk, n=3):
    """字符 n-gram 覆盖率:粗略衡量这句话有多少字面证据"""
    a, c = norm(sentence), norm(chunk)
    if len(a) < n:
        return 1.0 if a in c else 0.0
    grams = [a[i:i + n] for i in range(len(a) - n + 1)]
    return sum(1 for g in grams if g in c) / len(grams)
def verify(answer, min_overlap=0.6):
    bad, uncited, ok = [], [], 0
    for sent in (s for s in re.split(r"[。!?\n]", answer) if s.strip()):
        ids = [int(x) for x in re.findall(r"\[(\d+)\]", sent)]
        if not ids:
            uncited.append(sent); continue
        clean = re.sub(r"\[\d+\]", "", sent)   # 去掉引用标记再比对,否则标记本身算噪声
        valid = [i for i in ids if i in CHUNKS]
        # 数字必须出现在被引片段里,否则就是改数字或编数字
        nums_ok = bool(valid) and all(numbers(clean) <= numbers(CHUNKS[i]) for i in valid)
        cover_ok = any(overlap(clean, CHUNKS[i]) >= min_overlap for i in valid)
        if nums_ok and cover_ok:
            ok += 1
        else:
            bad.append(sent)
    return {"ok": ok, "unsupported": bad, "uncited": uncited}

if __name__ == "__main__":
    ans = "定制商品支持退货[1]。发票会在 7 天内寄出[2]。建议联系客服。"
    print(verify(ans))  # 否定被翻转却通过了;7 天与片段冲突;末句无引用

跑出来的结果很说明问题:第一句把「不支持」写成了「支持」,字符三元组重合率仍有三分之二,校验直接放过;第二句把 24 小时改成 7 天,被数字检查抓住;第三句没有编号,进了未引用列表。

所以这层校验只能当第一道闸,用来拦数字篡改、编号越界和完全无据的句子。否定、因果倒置这类错误,得靠语义层判断。

三道校验,按成本排序#

校验层 手段 能抓什么 成本
结构层 解析引用编号、数字、单位 越界引用、改数字 极低,纯代码
覆盖层 n-gram 覆盖率、关键实体比对 无据推断、张冠李戴 低
语义层 小模型判断「片段是否蕴含结论」 否定、因果倒置 中,逐句调用

多数团队只需要前两层就能把明显错误压下去。第三层要不要上,取决于错误代价:医疗、金融值得,内部知识库检索大概不值。

上语义层要注意误杀。蕴含模型对「改写」比较宽容,对「同义不同事实」又很严厉,阈值要在自己的语料上校准,并保留人工复核的样本。

拒答不是失败,是产品能力#

一个只会答的助手,遇到覆盖不到的问题必然编。要让「不知道」成为一个被设计过的出口。

工程上给它三个条件:

  • 检索覆盖度不足就直接拒答。先看召回片段和问题的相似度、关键词命中,不达标就不进入生成。
  • 校验不通过的句子,不要让模型重写整段。把不合格的句子摘掉,换成「关于这一点,文档中没有找到说明」,其余部分保留。
  • 拒答要给下一步。「暂未找到,可联系客服或在工单里提问」,比一句冷冰冰的「无法回答」有用。

命中率指标也要改。只看「回答率」会鼓励模型硬答,正确做法是同时看「拒答率」和「拒答中的误杀率」——本该能答却被拒的比例。后者高,说明阈值太严或检索太弱。

拒答还要能分层。完全没检索到内容是 A 类,检索到但校验不通过是 B 类,两者的话术和对内告警应该不同:A 类多半是知识库缺口,值得反馈给内容团队;B 类往往是模型改写过度,值得调 prompt 或校验阈值。

怎么衡量「幻觉变少了」#

上线前先定义指标,否则永远说不清有没有改进。抽样两百条真实问题,按前面四种病分类计数,得到一个基线错误率。

每次改检索、改 prompt、换模型,都在这两百条上重跑,分别看四类错误的变化。总错误率下降不一定代表变好:如果「无据推断」降了但「引用错配」涨了,用户信任反而更差,因为挂错编号的答案看起来更可信。

线上再补两个便宜信号:用户点「有帮助」的比例,以及追问率。答案有问题时,用户往往会追问同一件事或者直接转人工,这两个行为比点赞更能说明问题。

注意

不要在拒答话术里透露内部细节,比如「知识库相似度 0.42,低于阈值 0.5」。这类信息对用户无用,还会暴露系统结构。日志里记,回复里不写。

结论#

  • prompt 里的「不要编造」不能当机制用,能校验的是引用编号、数字和证据覆盖。
  • 引用要逐句标注并结构化解析,段末统一列出处等于放弃校验。
  • 三道校验按成本排序:结构层必做,覆盖层次之,语义层看错误代价。
  • 拒答要有触发条件、降级话术和下一步建议,同时监控误杀率。
  • 字面校验是盲区过滤器,别把它当真理;否定与因果仍需语义层或人工兜底。

完

#幻觉#RAG#引用#评测
回到顶部 ↑