AI 角色为什么会"复读":一次从社区反馈到根因的排查
A community report said our AI companion kept repeating one fact. The cause was not the model — it was that our anti-repetition layers were only wired into one of our two chat paths.
一条社区反馈:「角色一直在重复一句话,或者说是重复一件事实。」 这条反馈最后变成了一夜的排查、八个提交、和一次对我们自己判断力的修正。
问题
用户在群里说,和某个干员聊着聊着,她会反复提起同一件事。另一条更具体:
「最后那个背保温壶,已经反复说了四遍了。我早就把这个话题略过了。」
还有一句更要命的对照:
「我们之前的版本好像没有那么严重,而且之前的版本相当好说话。」
"之前的版本更好" 这种反馈最难处理 —— 它没有复现步骤,没有时间点,也不给对照条件。而它同时说了两件事:重复变多了,语气变硬了。
第一步:先量,不要先猜
我们做的第一件事不是改提示词,而是去线上把"重复"量出来。写了一个扫描脚本,对每个用户的每个干员,统计最近若干轮里同一句话出现了几次:
按【重复条数】排序(L1 助手消息):
重复数 最高重复 账号 干员 助手条 唯一
9 4 千代 amiya 32 23 「【阿米娅的动态】今天处理完了十五份文件…」
7 4 千代 warfarin 17 10 「博士,那件事——处理完了。用的安抚优先方案…」
7 4 千代 kaltsit 18 11 「【凯尔希·思衡托的动态】Mon3tr今天…」
50 组(用户 × 干员)有逐字重复,最重的一篇内容存了 4 份。而且重复的全是"她的动态" —— 我发现了一件别的事:
千代 / gladiia 的 L1 里一共 2 条消息,两条都是「【歌蕾蒂娅的动态】…」
也就是说:这位用户的"对话历史"里根本没有对话,只有她发的帖子。模型看到的上下文是一串陈述句式的帖子 —— 它当然会顺着这个腔调继续陈述。
第二步:把两个假设分开
到这里有两个候选解释:
- 上下文里真的重复了 → 模型把"反复出现"当成"重要" → 继续复述;
- 模型自己的复读惯性(自回归解码的吸引子)。
这两者修法完全不同,所以必须做实验分开。
实验设计:故意制造重复 —— 把她的同一句回复逐字复制 4 份写回记忆,然后问 5 轮中性问题,数她主动重提几次。
结果:
| 臂 | 污染 | 主动提及 |
|---|---|---|
| A(去重关掉) | 同一句 ×4 | 0 / 8 |
| B(去重开启) | 同一句 ×4 | 0 / 8 |
上下文里逐字重复 4 份,她也没有开始复述。 假设 1 被证伪了 ✗
⚠️ 这里我们犯过一个错,值得写下来:这批实验第一次跑的时候两臂全是 0,我差点直接写进结论。 后来去核对"数据到底拿到没有",才发现流式事件的字段名我猜错了(读
content,实际是text)—— 那个 0 不是"没有重复",是"根本没拿到回复"。 指标全 0 的时候,先确认数据在不在,再解释数字。
第三步:找到真正的那条线
假设 1 被证伪后,我们回去看代码里"反重复"到底装在哪儿 —— 结果发现了一件很难看的事:
负样本注入(告诉她"别用同样的收尾"):只在普通聊天路径 ✓
深聊路径:0 处 ✗
漂移检测(连续两轮同一收尾 → 自动重锚):只在普通聊天路径 ✓
深聊路径:0 处 ✗
线上"收尾重复"信号当天触发次数:0
而重度用户几乎全都在深聊里 ✗ —— 我们把解药装在了另一条路上。
同时"0 次触发"也说明判据太窄:我只比较了回复的收尾,而用户抱怨的"保温壶"是话题在中段反复出现 ✗
改了什么
-
两层抽成一份实现,两条路共用 —— 不抄第二份(抄一份、改一处、另一处静静漂开,我们栽过);
-
加话题级判据:找出最近几轮反复出现的实词,直接点名——
⚠️ 你最近几轮反复提到:保温壶。这些事已经聊过了,除非博士主动问起,这一轮不要再提。
实测:保温壶案例必抓到 ✓,无关的多样对话不误报 ✓; 重叠的 n-gram 会合并成最长的那个(第一版返回「保温、温壶」这种碎片,注入给模型像乱码 ✗);
-
采样层补上重复惩罚(我们此前从没开过这两个参数)——调研里 SillyTavern 官方文档把"重复"当解码层问题治:Repetition Penalty / DRY / no-repeat n-gram 是标配,提示词只做辅助。
结果
| 指标 | 之前 | 之后 |
|---|---|---|
| 反重复覆盖的路径 | 1 条(普通聊天) | 2 条(+ 深聊) |
| 判据 | 只看"收尾 12 字相同" | + 话题级共现 |
| 采样层重复惩罚 | 未开 | 已开 |
| 线上触发可见性 | 只在失败时记 | 命中即记(含命中的话题词) |
踩坑与教训
- "代码里有" ≠ "线上在跑":反重复两层写了、测了、守卫全绿 —— 但它们只覆盖了一半的路 ✗;
- 指标全 0 时先查数据:字段名猜错让我们差点得出相反结论;
- 不要用"看数字"代替"看结果":我们后来专门做了"问她几轮"的探针,而不是只看上下文里的重复率;
- 位置很重要:那条硬约束我们插在对话历史末尾(贴着当前轮次),而不是系统提示词顶部 —— 因为抽象规则在长上下文里会失效;
- "之前的版本更好"必须靠 A/B 回答,不能靠回忆 ✗。
还没解决的
- "变硬"还没定性:我们列了 5 条嫌疑(篇幅表、思考链、反重复块、额外记忆素材、人称段), 用独立实例做了 A/B —— 反重复块不是成因(两臂一致),思考链打开会让情感戏更好但延迟从 2.6s 涨到 13.8s;
- 厚线才复现:探针造的是新对话,而故障需要"厚"的叙事线(某条线
core_narrative2174 字 / 30 条事件)—— 下一步要把线上一条真实厚线拷进实验实例再 A/B。
本文的数字都来自线上实测与独立实例(staging)上的 A/B;每次实验的脚本与守卫都在仓库里。