「请在回答中不要编造」这句话,我见过它出现在至少二十个生产 prompt 里,也见过它一次都没起作用。
原因不复杂:模型不是在「编造」和「诚实」之间做选择,它是在预测下一个 token。你让它别编,等于让它一边生成一边自我审查,而审查能力恰好是它最弱的部分。有效的手段在生成之后:给引用、做校验、允许拒答。
幻觉其实是四种病#
把它们分开,因为可检测性完全不同。
- 事实错误:说了一个不存在的规定。有外部权威可查时,能校验。
- 无据推断:从片段里推出片段没说的结论。要看证据覆盖度。
- 引用错配:结论对,但标了错误的出处。查编号和内容是否对应即可。
- 张冠李戴:把 A 产品的条款安到 B 产品上。需要实体对齐。
其中引用错配最容易修,也最值得先修:它让「看起来有依据」变成「真的有依据」,用户的信任成本完全不同。
引用式生成:让每句话都能回溯#
做法是把检索到的片段编号后放进上下文,要求模型在每句结论后标注来源编号,并约定「没有依据就写『未找到相关说明』」。
几个实现细节决定成败:
- 片段要带稳定的编号,别用「上面第三段」这种相对指代;
- 片段里要包含标题路径,否则模型无法判断它属于哪个产品;
- 要求引用紧跟在结论后,而不是段末统一列出处,后者几乎无法逐句校验;
- 上下文里塞入一两条无关片段,专门用来观察模型会不会乱引。
然后做一件很多团队跳过的事:把引用当结构化数据解析,而不是当装饰。
"""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 里的「不要编造」不能当机制用,能校验的是引用编号、数字和证据覆盖。
- 引用要逐句标注并结构化解析,段末统一列出处等于放弃校验。
- 三道校验按成本排序:结构层必做,覆盖层次之,语义层看错误代价。
- 拒答要有触发条件、降级话术和下一步建议,同时监控误杀率。
- 字面校验是盲区过滤器,别把它当真理;否定与因果仍需语义层或人工兜底。
完