六层记忆:在有限上下文里保留最有用的信息
摘要:长期在线的对话 Agent 面临一个结构性矛盾——上下文窗口有限且昂贵,而历史会无限增长。本文说明我采用的六层记忆结构、每层的写入与淘汰策略,以及为什么"全塞进上下文"和"全部外部检索"都不对。
1. 矛盾
对话系统的上下文有三条硬约束:
- 窗口有限:模型能吃的 token 就那么多。
- 成本随长度线性上升:即使有缓存折扣,长上下文仍然更贵。
- 注意力会被稀释:塞进去的无关内容多了,模型对关键信息的把握反而下降。
于是问题变成:在有限窗口里,放哪几条信息收益最高?
答案不是"最重要的那条",而是"这一轮真正需要的那条"。而"这一轮需要什么"在请求发出前才知道。
2. 分层结构
我的做法是把记忆切成六层,每层的写入时机、读取方式和生命周期都不同:
| 层 | 内容 | 写入时机 | 是否进上下文 |
|---|---|---|---|
| L1 | 最近若干轮原始对话 | 每轮 | 是(窗口内全量) |
| L1.5 | 滚动摘要 | 历史超过阈值时压缩 | 是(一段摘要) |
| L2 | 稳定事实(偏好、身份、约定) | 抽取后去重合并 | 是(短清单) |
| L3 | 事件卡片(某天发生了什么) | 每天汇总 | 按需 |
| L4 | 原始归档 | 全量落盘 | 否 |
| L5 | 附件索引 | 收到图片/文件时 | 否(工具按需取) |
关键在于分层的判据不是"新旧",而是"变化频率"。
- 高频变化的内容(本轮在聊什么)只能靠 L1;
- 中频变化的内容(这周的偏好)适合 L2 这类压缩层;
- 低频不变的内容(身份、称呼)适合常驻注入;
- 几乎不再使用的内容(三个月前的对话原文)放 L4,靠检索找回。
3. 每层的取舍
L1:窗口不要频繁滑动
最初的配置是"保留 20 轮、每轮丢 2 轮"。从资源角度看起来合理,实际结果是每一轮都在裁剪,历史边界持续变化,前缀缓存全部失效。
改成"保留 100 轮、一次丢 50 轮"之后,窗口大多数时候是静止的,只在撑满时批量裁剪一次。同样的信息量,成本差了一个数量级。
L1.5:滚动摘要的触发条件
摘要压缩不是免费的——它本身是一次模型调用。所以触发条件要足够保守:只有在历史长度超过阈值时才压缩,并且压缩最老的一段,而不是全量重写。
这一层目前处于"设计完成、按需启用"的状态:在对话量足够大的场景下收益明显,但在日均几十轮的负载下,压缩调用本身的成本可能超过它节省的部分。是否启用要看实际负载,不能照搬。
L2:事实需要去重与合并
稳定事实如果只是不断追加,很快会膨胀成一段谁都读不完的清单。
我的做法是抽取后与已有条目比对:同一主体、同一属性的事实做覆盖而不是新增。80 条事实最终压到约 186 字符,注入成本可以忽略。
L5:附件不该占上下文
图片以 base64 塞进历史时,一张截图就能顶掉几千 token,而且是每一轮都要重新传输。
改成外置之后:原图存到磁盘,按内容哈希命名去重;注入上下文时只留一行标记(编号、时间、十几个字的描述);需要原图时通过工具按描述或时间检索。
这一步单独把会话体积减少了约 80%。
4. 效果
| 指标 | 处理前 | 处理后 |
|---|---|---|
| 会话历史体积 | 约 106 万字符 | 约 27 万字符 |
| 每轮未命中 token | 7,093 | 2,443 |
| 缓存命中率 | 57.8% | 93.1% |
注:这里的体积是全部会话文件的总字符数,不是单次请求的上下文长度。
5. 两个教训
第一,分层的目的不是"记得更多",而是"决定忘什么"。
任何人第一反应都是"把历史都留着,反正可以压缩"。但压缩本身要花钱,而塞进上下文的内容越多,注意力越分散。记忆系统的核心指标不是召回率,而是在给定 token 预算下的信息密度。
第二,每层都要能回答"什么时候删"。
设计 L1 的时候我只想了"保留多少",没想"什么时候删"。结果就是每轮都在删一点点——从没超出预算,但缓存全废。有了淘汰策略还不够,淘汰的节奏同样影响性能。
6. 后续
还在做的两件事:
- 按场景隔离:群聊和私聊的记忆目前是混的,需要按会话类型分区。
- 召回质量评估:现在靠关键词和向量相似度召回,缺一套"召回是否真的有用"的离线评估。
后者更重要——没有评估,调参就是换个方式许愿。