Kotonia
ログイン今すぐ始める

Kotonia Articles

把角色的记忆做成摘要后,说谎的角色变成了老实人

聊得越久,AI 角色就越会忘记以前的事。给角色聊天加上把旧对话摘要折叠的“压缩”后,摘要的形式坏了 5 次:每次重写的摘要从最早的事开始遗忘,说谎角色的台词在记忆里变成了事实。本文介绍借鉴 GC 分代回收的设计,以及测量遗忘的方法。

作者 6分钟阅读
#llm#character-chat#memory#compaction#rust

TL;DR

长对话不可能整段交给 LLM,所以要把旧的部分做成摘要折叠起来。说起来只有一句话,但在 Kotonia 的角色聊天里,折叠的方式我重做了 5 次。

  • 超出上下文窗口的回复,没有任何报错,就在句子中间断掉了
  • 一超出就丢掉旧轮次,prompt cache 随之失效,结果正要折叠的那一轮反而挂了
  • 每次都重写的摘要,越早的事件越是几乎答不出来。于是改成和 GC 分代回收一样的形状:章节往上堆,偶尔折进老一代
  • 摘要器分不清台词和叙述,说谎角色的自我介绍在记忆里变成了事实
  • 把字数和 token 数搞混了,只有中文对话的记忆末尾被一点点削掉

写给谁

写给正在做 AI 角色或长对话应用、正面对"聊久了角色就忘了以前的事"这个问题的人。比起摘要的提示词,本文的重点是摘要放在哪里、什么时候生成、怎么测量。

前提:一段对话比模型的窗口还长

Kotonia 是和角色进行角色扮演对话的服务,关系能一直延续下去本身就是产品。最长的一段对话已经超过 5,000 条消息。

模型一次能读的量(上下文窗口)是有限的。现在的生产环境是本地运行的 GLM-5.3-Flash(llama.cpp),每段对话的槽位是 64k token。角色设定、角色图片、对话历史,以及接下来要写的回复,全都要塞进去。

而且当时读取历史时没有 LIMIT。每一轮都把整段对话的所有行重新拼一遍。

先用一张图展示最终的形态。旧消息被摘要成"梗概"和"章节",最近的部分按原文传入。本文剩下的内容,就是在讲图里每个部件为什么会变成这样。

整段对话的哪一部分,变成了提示词的哪一部分。较早的范围被摘要成梗概和章节,最近的轮次按原文传入

(下文的预算都按"字符数"计算,因为后端没有分词器。大部分对话是日语,1 token 大约相当于 1.2–1.6 个字符。)

1. 溢出了,却没有人发现

之前的模型窗口有 131k,历史再长也碰不到。2026-08-31 换成现在的模型后,马上就碰到了。

而且碰到的方式很糟。为了图片输入而加载了 --mmproj 的 llama-server 会强制关闭"窗口满时丢弃旧 token 继续生成"的功能(context shift)。一碰到窗口,生成就在当前 token 处直接停下。

有一段对话,输入从 13,475 token 涨到 32,767 token,此后每一轮的回复都在句子中间断掉。后端没有任何地方检查 finish_reason,所以既没有报错也没有日志。我之所以发现,是因为回复格式检查的通过率从前一天的 153/153 掉到了 45/67。

"把旧轮次截掉就好"在这个服务里行不通。价值在于关系能延续,截掉就等于扔掉了初次相遇那天的事。所以我决定把溢出的部分做成摘要折叠起来。

2. 正要折叠的那一轮反而挂了

第一版实现很直接:历史超出预算时,就在那一轮把旧的部分切掉,换成摘要。

这又弄坏了别的东西。llama-server 会复用提示词中与上一轮前缀相同的部分,不再重新计算(prompt cache)。把历史的前半段去掉,前缀就变了,复用整个失效。生产日志是这样的:

slot get_availabl: selected slot by LCP similarity, f_sim_best = 0.280   ← 平时是 0.97
slot      release: stop processing: n_tokens = 29071, truncated = 0      ← 正好 30 秒被打断

重新读 29,000 token,空闲时约 29 秒,繁忙时要 58 秒。超时是 30 秒。而让服务器繁忙的,正是同时在跑的摘要任务。本想救这段对话的处理,反而把它弄垮了。

改了 3 处。

超过阈值的那一轮不折叠。 开始折叠的基准(48,000 字符,约 30k token)大约是 64k 窗口的一半,所以在摘要写好之前的几轮照常发送也不会溢出。只在摘要写好后的下一轮折叠一次,prompt cache 也只在这一轮失效。为了防止摘要一再失败,另外设了一个天花板(56,000 字符),超过它就只能硬截。

历史长度超过 48,000 字符后仍照常发送几轮,等后台写好章节,下一轮只折叠一次

摘要在后台生成,并推迟 30 秒。 一次摘要实测约 30 秒。对话不能中途沉默 30 秒。推迟是为了不和正在进行的那一轮抢 GPU。

摘要不放进系统提示词。 系统提示词由同一角色的所有对话共享,是 prompt cache 的地基。把每段对话各自的摘要加进去,地基就会按对话裂开。所以摘要是作为"到目前为止的梗概"这样一个合成的问答,插在历史的开头。开头那张全景图里,记忆紧跟在角色设定后面,就是这个原因。

再记一个附带的失败:我把后端超时调到了 150 秒,前端却在 40 秒就断开了连接。只改一边,什么都不会变。

3. 每次重写的摘要,从最早的事开始遗忘

最早的摘要是 rolling 方式:每次折叠时,用"上一版摘要 + 新溢出的部分"重写唯一的一份摘要。

运行一段时间后,我追踪了摘要的内容。第一版摘要里的词,折叠 4–5 次后只剩 20–30%,折叠 18 次后只剩 5%。每次重写,摘要器都会优先保留最近的事件,先丢掉早期的前提。消失的正是撑起故事的信息,比如"谁是谁的婆婆"。

改成分代

按开发者的提议,做成了和垃圾回收的分代回收一样的形状。

  • 章节(年轻代):每个被折叠的范围单独写成一章,一章一章往上堆。写好的章节不再重写
  • 老一代:章节合计超过 6,000 字符时,把较早的一半折进一份梗概(major GC)

重写,也就是信息损耗,只会在 major GC 时发生。

rolling 摘要每折叠一次,早期范围所占的份额就减少一些。章节方式中章节不断往上堆,只有合计超过 6,000 字符时,较早的一半才被折进梗概

记忆长大,就会吃掉预算。如果保留同样多的原文历史、只让记忆增长,折叠的间隔就会变短,丢失 prompt cache 的沉重轮次就会变多。所以固定折叠后的余量,记忆长多少,原文历史就缩短多少(不过为了不让当前场景变单薄,原文至少保留 16,000 字符)。

折叠后 48,000 字符的构成。记忆越长原文历史越短,16,000 字符的余量始终不变

测量遗忘

为了不停留在"感觉变好了",我做了一个离线评测。

  1. 把真实的长对话,按与生产环境相同的位置切成若干区间
  2. 让 LLM 针对每个区间里发生的事实各出 8 道问答题
  3. 只给它对话结束时的记忆,让它作答并评分

把区间按从旧到新排列,"多久以前的事还记得多少"就成了一条曲线。

各区间的回忆率。每次重写的摘要越早的区间越接近 0,章节方式对较早的区间也保持在 0.5 左右

方式记忆长度(对话 1 / 2)平均回忆率(对话 1 / 2)
每次重写的摘要761 / 845 字符0.11 / 0.21
重写的摘要·较长1,823 / 2,131 字符0.30 / 0.49
堆叠章节(分代)3,817 / 4,409 字符0.56 / 0.69

每次重写的摘要只记得最新的区间,之前的几乎全是 0。章节方式即使是较早的区间,也能答对一半左右。

不过,这个差距中有多少来自结构,我无法断言。分代的记忆更长。我本想用来和它比较的"等长 rolling"组,即使要求写长,也只到分代长度的四成多(要求再强一些,又会暴涨到接近 5,000 字符)。我能说的只到这里:即使把 rolling 写长,越早的区间掉得越厉害这个曲线形状也没有变。

评测的其他局限:

  • 只有 3 段日语对话,其中 2 段是同一用户、同一角色
  • 出题、作答、评分、摘要都是同一个 LLM
  • 测的是能否答出事实,没有测对话的手感
  • 摘要每次都会波动。同一段对话跑 3 次,记忆长度在 4,785〜7,016 字符之间摇摆

即便如此,看到一条贴着 0 的曲线,已经足够成为更换方式的理由。跑评测时需要固定测试二进制。如果每次都让 cargo test 重新构建,评测途中一碰工作区,被评测的对象就变了(差点踩到)。

4. 说谎的角色,在记忆里变成了老实人

改成分代一周后,我重读生产环境里的章节时发现了问题。

57 章里有 44 章出现了"作为确定的事实"这样的说法。 看内容,角色的自我陈述(年龄、经历)被原样写成了事实。角色扮演里的角色会说谎。有隐藏秘密的角色,也有伪造经历的角色。这些台词经过记忆之后,变成了"确定的设定"。

原因有两个。一是摘要器的输入:角色的回复只把正文用空格拼起来传过去。Kotonia 的文本模式以 JSON 接收回复,区分着叙述、台词(带说话人)和没有说出口的真实想法。这种区分在摘要器前面被丢掉了。二是指示,它要求写"之后会被参考的确定事实"。

输入和指示改成了这样(示例是虚构的)。

修改前只把正文拼起来传,章节里把年龄写成了确定的事实。修改后带上类型和说话人传入,章节里把"自称的内容"和"实际情况"分开写

指示方面,要求把故事中人物的主张写成"谁对谁说了什么",除非有叙述或真实想法佐证,否则不写成事实。能看出是谎言或表演的,把"表面上"和"实际上"并列写出。记忆的标题里也写明:"人物的发言只是该人物这么说过的记录,不一定是真相。"

改过头了,丢了用户说的话

第一版修改方案把内容分成两类:"叙述中发生的事"和"人物的主张"。拿去评测,偏日常闲聊的那段对话,回忆率从 0.72 掉到了 0.50。

那段对话里重要的事实,大多是用户本人讲述自己的事。明天的安排、喜欢的东西、朋友的名字。这些既不是叙述也不是角色的主张,两栏都放不进去,于是被丢掉了。该怀疑的只有故事中人物的发言,用户讲述自己的事要当作事实。我把这一条补了进去。

在生产环境重新统计

评测里还加了"审计":把章节和原区间对照,统计把主张写成事实的句子,以及已经解决却仍作为未解决留着的话题。在 3 段日语对话上,把主张写成事实的句子 17 → 6,回忆率 0.68 → 0.73。

生产环境的章节也重新统计了(2026-10-27)。

修改前(66 章)修改后(67 章)
含"确定的事实"380
用"已解决:"收尾话题033
"~说 / 讲述 / 声称"之类的归属表达516

不过,把已解决的话题仍留作未解决的问题,评测里 10 → 11,完全没有改善。只靠补充指示没有用。

5. 一些细小的坑

用 n=1 解释了拒绝

摘要器在敏感的对话上偶尔会拒绝:"无法生成这份记录。"第一次看到时,我断定"把指示放在提示词末尾就会被拒绝",还写进了代码注释。实际一数,拒绝是 18 次中 2 次(约 11%),和指示放在哪里没有关系。一字不差地复现同一个提示词,3 次都通过了。只是低概率地抽中而已。

所以不是靠提示词,而是在代码里兜住。又短又包含套话的输出被视为拒绝,重抽一次(实际的拒绝是 101 字符,通过的摘要最短 519 字符,所以界线定在 400)。绝对不要把拒绝文本当作摘要保存。 一旦保存,此后每一轮梗概那一栏都会赖着一句"无法提供帮助"。

顺便一提,摘要器被赋予了故事中的角色"故事的记录者",但这不是为了减少拒绝。直接让它"总结",有时返回的不是摘要而是故事的续写。

搞混了字符和 token

Rust 这边没有分词器,所以预算按字符数算,换算用实测的 1.57 字符/token。但这是包含 JSON 的输入侧的值。对输出的摘要来说,日语是 1.2–1.3、中文 1.2、英语 4.7 字符/token,不同语言差了 4 倍。

major GC 的输出上限设成了"目标 1,500 字符 + 400" = 1,900 token,于是中文梗概 11 份中有 5 份在句子中间被截断。被截断的梗概会成为下一次 major GC 的输入。梗概的结尾写的是未解决的话题,所以还没结束的故事线最先被削掉。上限改成了只为不被截断而存在的天花板(4,096 token)。

窗口宽的模型就不折叠

上面的 48,000 是给本地模型的数字,决定它的与其说是窗口大小,不如说是 prompt cache 失效时重新读取的时间。把同样的基准套在窗口有 1M 的云端模型上,就等于把本可以按原文保留的对话特意换成摘要。DeepSeek / Gemini / Grok 改为按原文保留约 200,000 字符。

反过来,本地的摘要器一次读不了很大的范围。在云端按原文保留了很久的对话切换到本地后,就要一次折叠 15 万字符。这时会把范围按每 32,000 字符切开,作为多个章节按从旧到新的顺序堆上去。

6. 生产环境的实绩,以及还没修好的地方

从 2026-10-10 开始记录(截至 10-27)。

次数耗时中位数平均输出长度
写章节13327 秒1,110 字符
major GC1734 秒4,123 字符
在天花板处强制截断3——

一次平均折叠约 17,000 字符。记录期间,因拒绝而摘要失败的次数为 0(被重抽吸收了)。

老实说,现在真正碰到它的对话还不多。跑过压缩的约 40 段对话中,有 28 段是开发者自己的。只有聊得久的人才会碰到,所以用户越留下来,它就越起作用。

还没修好的地方也列出来:

  • 已解决的话题仍留在"未解决"里
  • 英文和中文的梗概不遵守目标长度,每次 major GC 都会变胖
  • 评测只有日语,也没有测对话的手感
  • 还没有只追加事实的账本,也没有把标志性台词按原文保留的机制

结语

在打磨摘要提示词之前,真正起作用的是一些关于"放法"的判断:摘要放在哪里(不让缓存裂开的位置)、什么时候生成(在轮次之外,错开时间)、什么不重写(章节只往上堆)。而让我能改变这些判断的,是把遗忘画成了曲线。

一旦看过那条贴着 0 的线,就再也说不出"已经做了摘要,所以没问题"了。

Kotonia 将语音 AI、AI 聊天、图像生成和团队协作整合到一个 AI 工作区中。

试用 Kotonia