客服转来一条投诉: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;
- 冲突检测:每周跑一次,对语义相似度 > 0.9 但内容差异大的文档对,生成冲突报告给业务方确认;
- 过期自动降级:超过 18 个月未更新的文档,检索权重乘 0.6,并在 prompt 里标注「(最后更新:2024-03)」;
- 检索时带上更新时间:让模型知道这份文档有多老。
第三项效果最明显。以前模型看到一份 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% |
| 首响 P99 | 1.8s | 1.9s | 2.4s | 2.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 条。
下篇预告
这篇先把《幻觉问题的工程缓解方案》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。