罗德岛通讯与技术部

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 条消息,两条都是「【歌蕾蒂娅的动态】…」

也就是说:这位用户的"对话历史"里根本没有对话,只有她发的帖子。模型看到的上下文是一串陈述句式的帖子 —— 它当然会顺着这个腔调继续陈述。

第二步:把两个假设分开

到这里有两个候选解释:

  1. 上下文里真的重复了 → 模型把"反复出现"当成"重要" → 继续复述;
  2. 模型自己的复读惯性(自回归解码的吸引子)。

这两者修法完全不同,所以必须做实验分开。

实验设计:故意制造重复 —— 把她的同一句回复逐字复制 4 份写回记忆,然后问 5 轮中性问题,数她主动重提几次。

结果:

污染 主动提及
A(去重关掉 同一句 ×4 0 / 8
B(去重开启 同一句 ×4 0 / 8

上下文里逐字重复 4 份,她也没有开始复述。 假设 1 被证伪了 ✗

⚠️ 这里我们犯过一个错,值得写下来:这批实验第一次跑的时候两臂全是 0,我差点直接写进结论。 后来去核对"数据到底拿到没有",才发现流式事件的字段名我猜错了(读 content,实际是 text)—— 那个 0 不是"没有重复",是"根本没拿到回复"。 指标全 0 的时候,先确认数据在不在,再解释数字。

第三步:找到真正的那条线

假设 1 被证伪后,我们回去看代码里"反重复"到底装在哪儿 —— 结果发现了一件很难看的事:

负样本注入(告诉她"别用同样的收尾"):只在普通聊天路径 ✓
   深聊路径:0 处 ✗
漂移检测(连续两轮同一收尾 → 自动重锚):只在普通聊天路径 ✓
   深聊路径:0 处 ✗
线上"收尾重复"信号当天触发次数:0

重度用户几乎全都在深聊里 ✗ —— 我们把解药装在了另一条路上。

同时"0 次触发"也说明判据太窄:我只比较了回复的收尾,而用户抱怨的"保温壶"是话题在中段反复出现 ✗

改了什么

  1. 两层抽成一份实现,两条路共用 —— 不抄第二份(抄一份、改一处、另一处静静漂开,我们栽过);

  2. 加话题级判据:找出最近几轮反复出现的实词,直接点名——

    ⚠️ 你最近几轮反复提到:保温壶。这些事已经聊过了,除非博士主动问起,这一轮不要再提。

    实测:保温壶案例必抓到 ✓,无关的多样对话不误报 ✓; 重叠的 n-gram 会合并成最长的那个(第一版返回「保温、温壶」这种碎片,注入给模型像乱码 ✗);

  3. 采样层补上重复惩罚(我们此前从没开过这两个参数)——调研里 SillyTavern 官方文档把"重复"当解码层问题治:Repetition Penalty / DRY / no-repeat n-gram 是标配,提示词只做辅助。

结果

指标 之前 之后
反重复覆盖的路径 1 条(普通聊天) 2 条(+ 深聊)
判据 只看"收尾 12 字相同" + 话题级共现
采样层重复惩罚 未开 已开
线上触发可见性 只在失败时记 命中即记(含命中的话题词)

踩坑与教训

  • "代码里有" ≠ "线上在跑":反重复两层写了、测了、守卫全绿 —— 但它们只覆盖了一半的路 ✗;
  • 指标全 0 时先查数据:字段名猜错让我们差点得出相反结论;
  • 不要用"看数字"代替"看结果":我们后来专门做了"问她几轮"的探针,而不是只看上下文里的重复率;
  • 位置很重要:那条硬约束我们插在对话历史末尾(贴着当前轮次),而不是系统提示词顶部 —— 因为抽象规则在长上下文里会失效;
  • "之前的版本更好"必须靠 A/B 回答,不能靠回忆 ✗。

还没解决的

  • "变硬"还没定性:我们列了 5 条嫌疑(篇幅表、思考链、反重复块、额外记忆素材、人称段), 用独立实例做了 A/B —— 反重复块不是成因(两臂一致),思考链打开会让情感戏更好但延迟从 2.6s 涨到 13.8s;
  • 厚线才复现:探针造的是新对话,而故障需要"厚"的叙事线(某条线 core_narrative 2174 字 / 30 条事件)—— 下一步要把线上一条真实厚线拷进实验实例再 A/B。

本文的数字都来自线上实测与独立实例(staging)上的 A/B;每次实验的脚本与守卫都在仓库里。