Administrator
发布于 2025-11-09 / 1704 阅读
18

幻觉问题的工程缓解方案

客服转来一条投诉:AI 给用户编了个退款政策

十月底,客服同学转来一条用户投诉,附了聊天截图:

用户:我这个订单上周下的,还能退吗?

机器人:可以的,我们支持 15 天内无理由退款,您直接在订单页面点击申请退款即可。

用户:我看到页面上写的是 7 天?

机器人:抱歉造成困扰,7 天是普通商品的标准,您购买的是会员类商品,适用 15 天政策。

我们的退款政策就是 7 天,没有任何 15 天的说法。机器人不但第一次答错了,被用户质疑后还又编了一套更具体的说辞来圆谎。用户截图发到了社区。

这篇记录我们从这次投诉开始的幻觉治理方案,以及上线两个月的实际效果。

先搞清楚幻觉从哪来

我们拉取了三个月内所有被用户点踩的客服回答,一共 1,847 条,人工标注了每一条的幻觉类型:

类型占比举例
无中生有31%编造知识库里不存在的政策、活动、参数
张冠李戴27%把 A 商品的规则说成 B 商品的
数值漂移19%7 天说成 15 天,88 元说成 98 元
过度推理14%从「可能有库存」推成「肯定有库存」
时间错位9%用过期的活动规则回答当前问题

「无中生有」和「张冠李戴」加起来 58%,是主要矛盾。而且这两类有个共同特征:模型在知识库找不到答案时,倾向于「合理编造」而不是说不知道

我们做过一个实验:拿 200 个知识库里明确没有答案的问题去问,模型的回答分布是:

  • 明确表示不知道/转人工:23%
  • 给出了看似合理但错误的答案:64%
  • 给出了正确答案但来源是模型自身知识(非我们的知识库):13%

77% 的情况下它没有说不知道。这就是问题所在。

方案一:强制 grounding,把「不知道」变成合法答案

最直接的做法是在提示词里允许并鼓励模型说不知道。但光这么说效果有限,我们试过三个版本:

// v1:简单指令,几乎无效(不知道率仍为 26%)
"如果知识库中没有相关信息,请回答'不知道'。"

// v2:加了后果说明,有效果(不知道率 41%)
"如果知识库中没有相关信息,请明确告知用户。编造信息的危害远大于承认不知道。"

// v3:要求先引用后作答 + 给出兜底动作,效果最好(不知道率 78%)
"""
回答流程:
1. 先从【参考资料】中找出与问题直接相关的段落
2. 如果找到了,回答时必须标注来源编号 [1][2]
3. 如果没有找到任何相关段落,回答:"这个问题我在当前知识库中
   没有找到确切信息,我可以帮你转接人工客服。" 然后调用 transfer_to_human 工具
4. 严禁基于常识或推测补充参考资料中没有的具体信息
"""

v3 的两个关键点:把「不知道」和一个具体动作绑定(转人工),以及要求标注来源编号。绑定动作很重要,因为单纯让模型「说不知道」它会觉得没帮到用户,而「转人工」是个有建设性的替代方案。

还有一个细节:提示词里要写「严禁基于常识补充具体信息」,而不是「严禁补充信息」。完全禁止常识会让回答非常僵硬(连「请稍等」都不会说),我们只要卡住具体事实。

方案二:引用溯源,让每句话都有出处

要求模型标注来源之后,我们做了自动校验。这一步是真正把幻觉率压下来的关键。

public class CitationVerifier {

    public VerificationResult verify(String answer, List<Doc> refs) {
        // 1. 提取回答中的所有引用标记 [1] [2] ...
        List<Integer> cited = extractCitations(answer);

        // 2. 每条引用必须对应真实存在的参考文档
        List<Violation> v = new ArrayList<>();
        for (Integer i : cited) {
            if (i < 1 || i > refs.size()) {
                v.add(new Violation("引用了不存在的来源 [" + i + "]"));
            }
        }

        // 3. 关键:检查回答中的"事实性片段"是否能在被引用的文档中找到
        List<Fact> facts = extractFacts(answer);   // 数值、日期、金额、期限、专有名词
        for (Fact f : facts) {
            boolean supported = cited.stream()
                    .map(i -> refs.get(i - 1))
                    .anyMatch(doc -> doc.contains(f.normalized()));
            if (!supported) {
                v.add(new Violation("事实 '" + f.text() + "' 在引用来源中找不到依据"));
            }
        }
        return new VerificationResult(v);
    }
}

第 3 步的 extractFacts 是我们自己写的规则抽取器,重点抽这几类:

NUMBER_UNIT : \d+\s*(天|小时|分钟|元|块|次|折|%)     →  7天、15天、88元
DATE        : \d{4}[-/年]\d{1,2}[-/月]\d{1,2}       →  2025-11-01
POLICY_TERM : (支持|不支持|仅限|适用|有效期|门槛)
PROPER_NOUN : 商品名、活动名(从业务字典匹配)

规则方式覆盖率有限(我们评估只覆盖了约 70% 的事实性内容),但胜在零延迟、零成本、零误判。剩下的 30% 靠人工抽检兜底。

我们试过用 LLM 做事实校验(拿回答和文档一起喂给小模型问「是否有依据」),检出率能到 88%,但每次多花 600ms 和约 900 token。最后只在「高风险问题」上开(涉及金额、退款、账号安全的问题,约占 12%)。

校验不通过时的处理策略我们分了三档,不是一律拒绝:

违规情况处理触发比例
引用了不存在的来源丢弃回答,重新生成一次;再失败转人工1.2%
数值类事实无依据丢弃回答,转人工3.7%
非数值事实无依据(如商品描述)放行但降级展示,加「仅供参考」标记5.1%

第三档是权衡的结果。非数值事实的误判率高(规则抽取器容易把普通的描述句当成事实),如果一律拒绝会误伤大量正常回答。

方案三:置信度提示,但别用数字

我们一开始想得很美:让模型输出一个 0~100 的置信度分数,低于阈值就转人工。做完发现完全不能用。

问题是模型的置信度校准极差。我们统计了 500 条带置信度的回答,模型给出 90 分以上但实际错误的占 14%,给出 60 分以下但实际正确的占 31%。这个校准水平,阈值设多少都是错的。

后来改成了「分类型提示」而不是打分,效果反而好:

// 让模型自评"信息完备度",三选一,而不是输出一个数字
"""
在回答开头输出一行自评,三选一:
CONFIDENCE: GROUNDED      - 所有关键信息都有明确的参考来源
CONFIDENCE: PARTIAL       - 部分信息有来源,部分基于通用规则推断
CONFIDENCE: UNKNOWN       - 没有找到相关参考,已转人工

不要输出其他置信度表述,不要输出百分比。
"""

三选一比打分可靠得多,因为模型更容易做分类判断。我们测下来,模型自评 GROUNDED 的回答里,人工检出幻觉的比例是 4.2%;自评 PARTIAL 的是 21%。

前端展示上,我们把 PARTIAL 的回答加了浅黄色底和一行小字「部分内容基于通用规则,如有疑问请咨询人工」。用户看到这个提示,质疑的比例反而下降了——坦诚比假装确定更能获得信任

方案四:知识库本身的问题

做了以上三步之后,我们发现还有一类幻觉根治不了——知识库里的内容本身就是矛盾的。

我们扫了一遍知识库,2.1 万篇文档里:

  • 1,340 篇有明确的过期标记但未归档;
  • 217 组文档内容互相冲突(同一政策有新旧两个版本,都没标注生效期);
  • 89 篇内容有明显错误(参数写错、链接失效)。

这部分是纯工程活,我们做了三件事:

-- 1. 给所有文档加生效期字段,检索时自动过滤
ALTER TABLE kb_doc ADD COLUMN valid_from DATE, ADD COLUMN valid_to DATE;
CREATE INDEX idx_kb_valid ON kb_doc (valid_from, valid_to);

-- 检索 SQL 加上时间过滤
SELECT id, title, content FROM kb_doc
WHERE valid_from <= CURRENT_DATE
  AND (valid_to IS NULL OR valid_to >= CURRENT_DATE)
  AND embedding <=> :query_vec < 0.35
ORDER BY embedding <=> :query_vec
LIMIT 20;
  1. 冲突检测:每周跑一次,对语义相似度 > 0.9 但内容差异大的文档对,生成冲突报告给业务方确认;
  2. 过期自动降级:超过 18 个月未更新的文档,检索权重乘 0.6,并在 prompt 里标注「(最后更新:2024-03)」;
  3. 检索时带上更新时间:让模型知道这份文档有多老。

第三项效果最明显。以前模型看到一份 2023 年的活动规则会当成现行规则用,现在 prompt 里写了「最后更新 2023-05」,它在回答时会主动说「根据 2023 年的规则……」,或者提示用户可能有更新。

两个月后的数据

方案分三批上线,每批跑两周:

指标基线+grounding+溯源校验+知识库治理
幻觉率(人工抽检 500 条)18.3%9.1%4.2%2.7%
「无中生有」类31%14%5%3%
用户点踩率4.7%3.9%3.1%2.6%
转人工率11.2%19.4%23.8%21.3%
首响 P991.8s1.9s2.4s2.3s

幻觉率从 18.3% 降到 2.7%,但转人工率从 11.2% 涨到 21.3%。这是必须接受的代价——我们是用「更多地承认不知道」换来了「更少地说错」

从业务角度看这笔账是划算的:一次错误回答导致的客诉处理成本约 80 元(客服人力 + 可能的赔付),一次转人工的成本约 6 元。日均 3.2 万次咨询,幻觉率降 15.6 个百分点,等于每天少 5,000 次错误回答。

响应变慢的 0.5 秒主要花在溯源校验的事实抽取上。我们做了异步化——先流式输出回答,校验在后台跑,发现问题再撤回。撤回的用户体验不好,但发生率只有 3.7%,比让用户等 0.5 秒划算。

还没解决的问题

  • 多跳推理的幻觉。如果答案需要组合三份文档的信息,模型经常在「组合」这一步出错,而每一句话单独看都有依据。我们的溯源校验抓不到这类问题。
  • 隐含否定。「除了 A 和 B,其他商品都支持」这种表述,模型容易理解成「所有商品都支持」。
  • 知识库之外但正确的信息。有用户问「你们支持微信支付吗」,知识库里没写,模型说不知道,但其实我们支持。这类「知识库盲区」我们通过每周分析转人工的问题来补,现在补了 340 条。

下篇预告

这篇先把《幻觉问题的工程缓解方案》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考