封仁华的工程笔记

六层记忆:在有限上下文里保留最有用的信息

摘要:长期在线的对话 Agent 面临一个结构性矛盾——上下文窗口有限且昂贵,而历史会无限增长。本文说明我采用的六层记忆结构、每层的写入与淘汰策略,以及为什么"全塞进上下文"和"全部外部检索"都不对。

1. 矛盾

对话系统的上下文有三条硬约束:

  1. 窗口有限:模型能吃的 token 就那么多。
  2. 成本随长度线性上升:即使有缓存折扣,长上下文仍然更贵。
  3. 注意力会被稀释:塞进去的无关内容多了,模型对关键信息的把握反而下降。

于是问题变成:在有限窗口里,放哪几条信息收益最高?

答案不是"最重要的那条",而是"这一轮真正需要的那条"。而"这一轮需要什么"在请求发出前才知道。

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. 后续

还在做的两件事:

  • 按场景隔离:群聊和私聊的记忆目前是混的,需要按会话类型分区。
  • 召回质量评估:现在靠关键词和向量相似度召回,缺一套"召回是否真的有用"的离线评估。

后者更重要——没有评估,调参就是换个方式许愿。