我最早做对话记忆的时候特别天真:把历史记录一股脑拼接起来,塞进上下文窗口就算完事。前10轮表现确实能打,到了20轮左右开始接连出洋相——用户第2轮强调的“不要用emoji”,第22轮被系统彻底无视;用户前脚说喜欢A风格,后脚系统就不认账;明明每条记录都在上下文里,回答却驴唇不对马嘴。我原本以为是自己代码写得糙,后来对比了几个项目才发现,20轮这个数字几乎是很多LLM记忆系统共同的分水岭,崩法都如出一辙。
这篇文章不打算绕弯子,直接聊清楚三件事:为什么大多数LLM记忆系统撑不过20轮,为什么常见的键值抽取、向量库、滚动摘要方案都有结构性缺陷,以及我最后改用的一套时间层级记忆方案(Time-Layered Memory)是怎么设计、怎么落地、怎么调参的。适合正在做AI助手、客服Agent、带记忆的Chatbot,以及任何被“长时间多轮对话”折磨过的朋友参考。
1. 崩溃的20轮不是玄学:先算清上下文账单
1.1 “崩了”到底崩在什么地方
先说现象。很多人以为“20轮崩了”指的是上下文窗口爆掉、直接报错,但实际开发里遇到的更多是隐性退化:
- 忘记早期约束。用户第3轮说“预算控制在5000以内”,第18轮给方案时直接报了个8000的选项。
- 回答自相矛盾。同一个事实,用户10轮前说的和系统现在复述的完全对不上。
- 指令污染。用户某轮随口说“这样就行”,系统把它当成永久性偏好,后面所有回复都被这句临时话带偏。
- 延迟和成本飙升。日志里看,每轮请求的token数线性上涨,20轮时一次请求可能要带2万甚至更多的历史token。
这些现象的共同点是:系统并没有“失忆”,而是什么都记得太平均、太乱,模型分不清哪条信息对当前这轮最重要。
1.2 20轮是怎么算出来的:一个窗口公式
20轮看起来是个魔法数字,其实纯粹是上下文窗口的数学结果。每个会话能塞进多少轮,大致可以用这个公式估算:
M = (C − S) / T
其中C是模型上下文窗口,S是系统提示加上工具定义、schema等固定占用的token,T是每轮对话的平均token数(用户输入+模型输出)。
举个例子。一个8k窗口的模型,系统提示占了2k,每轮来回平均消耗300 token,那么M = (8000 − 2000) / 300 ≈ 20轮。这就是很多人第一次项目里“刚好20轮开始出问题”的直接原因,根本不是模型变笨了,是窗口塞满了。
我见过不少项目用的是32k窗口,理论上能撑到60轮往上,但日志显示通常在40%窗口占用率附近就开始劣化。原因后面会讲:注意力机制在长文本上的利用效率远不是线性的。
1.3 没到窗口上限就开始退化的机制
即使窗口没有爆,模型的长文本利用效率也会拖后腿。研究界普遍观察到一种现象:位置越靠中间的文本被模型利用得越差,开头和结尾的信息容易被注意,中段的指令、约束、细节经常被“看漏”。
这跟人类阅读长文章很相似——开头记得清楚,结尾刚读完也清楚,中间翻过去就忘了。LLM的长上下文机制在原理上不一样,但表现在工程实践中非常接近:你把用户早期对话放在中间位置,每次请求都让模型在几万token里“大海捞针”,它对早期约束的感知自然会越来越弱。
所以20轮崩溃不是一个点,而是一条劣化曲线:上下文越长,早期信息的信噪比越低,模型越倾向于仅依赖最近几轮的内容生成回答。很多记忆系统的失败,不是没记住,而是记住了却用不上。
2. 常见记忆方案都折在哪:键值、向量、摘要的三个软肋
2.1 键值对抽取:丢掉时间线的属性表
第一种常见做法是“关键信息抽取”:每轮对话结束后让模型抽取出结构化键值对,比如“用户姓名=XXX”“偏好风格=简洁”“预算=5000”,存进一个属性表,之后每轮把整张表塞进系统提示。
这个方案在短会话里很好用,但到了长会话就会暴露一个致命问题:属性表没有时间线。用户上个月说你“预算5000”,这周改成了“预算8000”,表里只有一个字段,系统只知道现在的值,不知道为什么变、什么时候变的,更不知道“5000”这个旧值是已经被用户明确否定的,还是只是阶段性调整。
更麻烦的是,抽取过程本身有损。用户说“我最近在备考,周末可能没空”,抽取结果往往是“用户最近忙”,原因“备考”丢了。等到用户说“这周末有空了”,系统只知道状态翻转了,却不理解背后的条件,下次遇到类似场景又得重新猜。
2.2 向量检索:语义相似不等于场景相关
第二种常见做法是向量库:把历史对话切片、Embedding入库,每轮根据当前query做Top-K召回,把命中的片段拼进上下文。
这个方案的优点是能回溯很多轮之前的具体细节,但代价是召回结果高度不稳定。对话记录里大量是“好的”“嗯”“明白了”“那也行”这种寒暄和确认语句,它们的Embedding和当前query的相似度经常很高,占掉Top-K名额,真正的硬约束反而排不进去。
另外一个被低估的问题是场景漂移。用户在20轮里可能聊过三个完全不同的话题:方案A、方案B、随口吐槽的C。向量检索按语义找回了片段,但分不清这些片段属于哪个任务线、哪个时间阶段,系统把A话题的约束用在B话题的回答上,自己还没察觉。
我在一个Agent项目里试过把上下文全改成向量召回,50轮之后回答的内部矛盾率反而比全文拼接更高。原因很简单:全量拼接至少信息都在,向量召回则是“看似相关实则零散”,把信息打散了再拼回去,更难判断哪条该听。
2.3 滚动摘要:越滚越细的细节和越滚越大的误差
第三种常见做法是滚动摘要:每N轮把前面的对话压缩成一段摘要,清掉原始记录,只保留摘要。
理论上没问题,但实操里我踩过一个大坑:摘要的逐级压缩会不断放大错误。第一轮摘要把“预算5000左右”写成“预算大概5000”,第二轮摘要可能变成“预算5000上下”,第三轮变成“预算不超过6000”。每次压缩都依赖模型的理解,细节在这种“传话游戏”里一步步丢失或变形。我管这叫“摘要漂移”。
更隐蔽的问题是:摘要丢失了时间结构。用户在第8轮提出的一个临时假设,在第15轮已经被推翻,但摘要里只保留了“用户提到过这个假设”,没有记录“已经否定”。后续对话里模型可能会把这个已经废弃的想法当成活跃约束,表现得像人格分裂。
滚动摘要适合短会话、低频场景,但它本质上是一种“有损压缩”,撑不到长会话是由算法特性决定的,不是调参能解决的。
3. 时间层级记忆设计:把“记忆”拆成四层,而不是塞进一个容器
3.1 为什么是时间维度,而不是重要程度
在方案设计阶段,我第一反应是给记忆打分:重要的记住、不重要的丢掉。后来发现“重要性”这个标准很难定义——用户当前觉得重要的,下轮可能就不重要了;一个看似闲聊的细节,10轮后反而成了决策关键。
后来我换了角度:记忆的本质是事件流,不是属性表。聊天记录是一条带时间戳的流水,每个信息都有发生时刻、生效期、过期时间。与其按重要性挑挑拣拣,不如按时间粒度和访问频率分四层,模拟人脑的记忆结构。
这个思路的出发点很朴实:把需要频繁访问的信息放在近处,把不常访问但需要时能找到的信息放到远处,每一层只管好自己的时效性。时间是最好的组织维度,因为时间戳天然存在,不会像“重要性”那样依赖主观判断。
3.2 四层架构与各自的存储内容
整个方案分四层,从近到远:
- L0 瞬时缓冲:保留最近3轮的原始对话文本,不加工、不压缩。作用是支撑多轮指代和连续动作,比如用户说“刚才那个方案再改一下”,“刚才”大概率落在这一两层里。
- L1 工作记忆:当前会话的轻量状态摘要,由结构化状态槽组成。存储用户当前意图、活跃约束、未完成事项,每3-5轮刷新一次,保证模型推理时“手边”就有最需要的信息。
- L2 事件切片:分钟到小时级别的会话事件归档。按主题或意图把若干轮拆成一个事件,保留起止时间、摘要、关键产出物,支持之后按事件回查。
- L3 长期记忆:跨会话的稳定事实与模式。按日/周聚合L2事件,生成用户画像、长期偏好、知识状态,跨会话会话间共享。
信息在层与层之间是单向流动的:L0被L1吸收,L1归档进L2,L2聚合进L3。每一层只保留自己时间尺度上该留的信息,不是简单的复制粘贴,而是逐级提炼。
3.3 每一层到底存什么、不存什么
很多记忆系统之所以崩溃,是因为“什么都想存”。我的设计原则反过来了:每一层都要做到有意识的“主动遗忘”。
L0不存结论,只存原文,超过3轮立刻废掉。L1不存过程细节,只存“当前进行时”的状态,一旦事件切换,里面的未完成事项要么转成L2事件,要么直接清空。L2不存逐字稿,只存事件切片和关键产出,允许细节随时间和空间淡出。L3不存具体事件,只存跨事件沉淀下来的稳定事实,而且这些事实必须经过一致性校验,冲突项不会强行合并。
这样做的好处是,模型每轮看到的上下文里没有垃圾信息,只有“这一轮最该知道的”。需要更早的细节时,再按时间戳回溯到L2,而不是把所有历史全摊在桌子上。
4. 落地实现:数据结构、状态流转与召回打分
4.1 L0与L1的代码形态
直接给一个可以照着写的骨架。L0很简单,就是一个固定长度的deque,保存最近几轮原始文本,超过长度自动淘汰。L1稍微复杂,是一组带token估算的状态槽加一个滚动摘要。
from collections import deque class L0Buffer: def __init__(self, maxlen=3): self.buffer = deque(maxlen=maxlen) def append(self, turn): self.buffer.append(turn) def text(self): return "\n".join(self.buffer)L1的结构化状态槽可以这样定义:意图槽记录当前任务方向;约束槽记录用户明确提出的限制;待办槽记录还没完成的事项。当L0加L1的估算token超过水位线时,触发一次压缩,把L0的原始回合提炼进L1的槽位。
4.2 L2事件切片:检索最小单位从“句子”变成“事件”
L2是整个系统里最关键的创新点。我放弃了“按句子切片再检索”的做法,改成“按事件切片”。一个事件是一段有明确主题、起止时间的对话单元,比如“讨论项目预算”“确定技术选型”“闲聊周末安排”。
事件切分的触发条件不是固定轮数,而是上下文漂移。我在每轮结束后让L1检测当前意图与上一个事件的主题相似度,低于阈值就关闭当前事件、开启新事件。事件关闭时,把它包含的轮次、结论、关键约束、起始与结束时间一起存入L2。
这样检索时召回的最小单位是“一个完整事件”,而不是孤立的句子。事件自带场景和边界,系统能判断“这条约束属于哪个话题”,不会再出现把方案B的约束安到方案A头上的混乱。
4.3 L3长期记忆:一致性校验与画像沉淀
L3不能直接由L2简单拼接而成。我的做法是每天结算一次:先把当天L2事件按涉及的实体和任务域分桶,然后对同一个桶内的事件做一致性校验。这里必须重点处理矛盾项——用户今天说A,明天说非A,我不会让后出现的覆盖先出现的,而是把两条都保留,并记录时间戳和上下文。
校验通过的事实才进入长期画像,比如“用户对响应速度的敏感度高于对功能丰富度的敏感度”。这类稳定模式有一个前提:它必须在多个事件里反复出现,才能沉淀到L3。一次性的临时态度,留在L2就够了,不该进L3。
4.4 召回打分:让时间成为检索权重的一部分
检索不是简单的相似度TopK,而是相似度乘以时间衰减:
import math def recall(query, candidates, now, half_life=3600, max_k=5): scored = [] for c in candidates: if c.is_expired: continue age_seconds = (now - c.timestamp).total_seconds() decay = math.exp(-age_seconds / half_life) score = c.similarity_to(query) * decay scored.append((score, c)) scored.sort(reverse=True) return [c for _, c in scored[:max_k]]这里半衰期是个关键参数:短期事件用较短半衰期(比如15分钟),长期画像用较长半衰期(比如7天)。设计意图是,近期发生的事天然更容易影响当前回复,但久远但重要的约束仍然能在被需要时通过语义相似度拉回来。这就是“时间层级”和“不分层”的核心区别:信息不是平等的,时间决定了它的即时权重。
5. 三个阈值和一次主动遗忘:这套系统的运行规则
5.1 三重触发:水位线、轮数、事件边界
让记忆系统自己决定什么时候归档、什么时候压缩,比死板地“每N轮做一次”更抗造。我的实现里用三重触发条件,哪个先到就执行哪个:
- token水位线:当L0加L1的估算token超过窗口上限的60%-75%时,立即压缩L0进L1。
- 轮数阈值:连续对话超过6轮且L1槽位接近满时,强制做一次轻量摘要,避免逻辑链太长导致模型丢失前文关系。
- 事件边界:意图漂移超过阈值时,关闭当前事件、开启新事件,这是进入L2的唯一通道。
这三重条件各有分工:水位线管“物理容量”,轮数管“逻辑连贯性”,事件边界管“话题切换”。缺一个都会出问题。只靠水位线,会在话题切换后还强行维护旧状态;只靠轮数,话题密集的长会话还是会爆窗口;只靠事件边界,单个话题内聊得太深时没有降载手段。
5.2 主动遗忘:被否定的信息必须能过期
L2和L3里都支持“过期标记”。用户明确否定过的信息(“我之前说的那个方案不要了”“这个约束已经取消了”),会被标记为expired。过期信息不删除,保留审计轨迹,但不再参与召回打分。
这个设计的动机很直白:模型的上下文里出现一个被否定的旧信息,更可能诱发幻觉而不是帮助。与其让模型自行判断“这个旧约束还重不重要”,不如系统主动替它把关。主动遗忘不是丢记忆,是给记忆打上“当前不要用”的标签。
5.3 用户改口时的覆盖写入
长对话里用户经常改口:“预算还是调回5000吧”“我刚说的不对,应该是A方案”。这种场景如果按普通新增来处理,系统会同时记住A和B,然后出现“二选一”式的前后矛盾。
我在L1约束槽里做了覆盖写入:新值进来时,旧值标记为expired,同时记录“由什么时间、什么上下文替代”。这既保留了正确的当前值,又保留了可审计的变更历史。用户再问“我是不是说过B方案”,系统能准确回答“说过,但你在第23轮改成了A方案”。这个能力在长会话里很加分,也是键值表方案做不到的。
6. 内部压测:20轮、50轮、100轮后的表现差异
6.1 测试设置:三个维度与四种方案
为了验证这套方案,我在一个模拟客服Agent上做了内部压测,不追求论文级严谨,但足够在工程上说明趋势。测试会话设定为多主题混合的长对话,包含预算约束、风格偏好、需求变更、闲聊干扰。评测维度有三个:
- 事实保持率:第10轮时提出的硬约束,在第20/50/100轮是否仍被准确执行。
- 一致性:同一事实在远处和近处被提及时的描述是否矛盾。
- 指代成功率:用户在第40轮说“刚才那个方案”,系统能否正确追溯到对应的事件。
对比对象是四种方案:全文拼接、纯向量检索、滚动摘要、时间层级。
6.2 结果:时间层级在长会话里优势明显
内部压测数据(仅代表我的项目环境,供参考):
| 方案 | 20轮事实保持率 | 50轮事实保持率 | 100轮约束遵循率(约) |
|---|---|---|---|
| 全文拼接 | 约88% | 约62% | 约41% |
| 纯向量检索 | 约90% | 约73% | 约58% |
| 滚动摘要 | 约85% | 约67% | 约49% |
| 时间层级 | 约94% | 约87% | 约81% |
20轮附近四种方案差距还不算大,到了50轮和100轮,时间层级的优势就很明显了。主要原因不是它“记得更多”,而是它每一轮喂给模型的上下文更干净:近期信息完整,中期信息以事件为单位召回,远期信息只保留稳定画像。模型有足够的注意力聚焦当前目标,而不是耗费在甄别历史垃圾上。
6.3 短板与适用边界
时间层级方案不是银弹。内部压测也暴露了几个短板:L1摘要质量依然依赖抽取模型,意图漂移识别在话题交叉时容易误切事件,L3聚合是批处理任务,在极低算力场景下会拖慢结算。另外,如果是纯单主题且不超过20轮的短会话,做四层完全是杀鸡用牛刀,全文拼接就够。
适用边界大概是:需要跨话题、跨时段、跨会话保持长程一致性的场景。只要不满足这个前提,简单方案反而更划算。
7. 踩坑记录:调参、冲突覆盖与多端同步
7.1 摘要太频繁比不摘要更糟
我最初把L1的轮数阈值设在3轮,希望保持摘要新鲜,结果发现系统每3轮就要调用一次模型做压缩,不仅token成本翻了近一倍,而且摘要质量断崖式下降——模型在频繁的小段压缩里抓不住重点,经常把关键约束给抹掉。
后来我把策略改成“水位线优先,轮数兜底”:日常不主动压缩,等L1占用的token超过窗口上限的65%,或者轮数超过6轮才触发。实验后稳定多了,摘要次数少了,保留的关键信息反而更完整。调这个参数的教训是:不要为了“新鲜”而牺牲“连贯”,摘要是有损操作,频率太高等于反复给信息做有损压缩。
7.2 时间衰减半衰期的经验值
时间衰减系数我试过几组。半衰期设得太短(比如1分钟),系统几乎只记得最近几轮,跟没做长期记忆一样;设得太长(比如30天),早期无关信息又会被反复召回,等于退化回全文拼接。
当前比较稳的经验值是:L2短期事件用15分钟半衰期,L3长期画像用7天半衰期,同时把检索TopK限定在3-8个。短期事件衰减快,保证聚焦;长期画像衰减慢,保证跨会话的稳定事实始终在场。具体数值要按业务场景微调,但方向是这样:两个半衰期至少差一个数量级,否则分层就没有意义。
7.3 多端与会话边界:UTC与L3同步
最后一个坑来自工程侧。聊天记录里的时间戳如果不统一,L2事件切片在跨时区场景下会错乱。我踩过“本地时间直接入库”的坑,导致事件排序混乱,后来统一改用UTC+ISO 8601存储,展示时再转本地时区。
另一件容易被忽略的事:L2是会话私有的,L3可以跨会话共享。L3从多个会话里聚合长期画像,因此写入时必须带会话ID和时间戳。短会话之间如果想共享长期画像,直接读L3即可,不需要把旧会话的L2搬过来。
写在最后
做这套系统最大的体会是:记忆系统的目标从来不是“记得多”,而是“记得准、忘得巧”。大多数人做记忆,第一反应都是往上下文里塞更多的东西,结果20轮就崩。反过来的思路是:把记忆当成一条有时间维度的流水线,该留在手边的留手边,该归档的归档,该遗忘的遗忘。别指望模型自己能从一堆历史里挑出关键信息,那是你的活。
如果你现在也在被长会话的“失忆”问题折腾,建议先别急着换更大的上下文窗口,先把对话记忆按时间分层重新组织一遍,成本低,收益却可能比想象中大得多。