Agent 记忆系统设计:上下文窗口装不下的长期项目,什么该记住、什么该忘掉
前言
上一篇聊部署形态,结尾留了个钩子:会话跨天之后,记忆怎么设计。这篇把它补上。
先摆一个做 Agent 绕不开的场景:项目持续三周,用户周一说"数据库用 PostgreSQL,别用 ORM",周三的会话里 Agent 又推荐了 Prisma。它不是故意忘的——上下文窗口只保留当前对话,前几天的会话早就被截断了。用户视角这叫"失忆",工程视角这是没有记忆层。
先把两个概念切开:
- 上下文(context):这次对话的"内存",模型推理时真正能看到的全部内容,会话结束(或截断)就没了;
- 记忆(memory):跨会话的"磁盘",昨天存的东西今天还在,需要主动决定写什么、读什么、什么时候删。
记忆系统设计的全部问题,就是往这块磁盘写什么、怎么组织、什么时候读、什么时候扔。这篇文章讲我落地的分层方案:从最简的 Markdown 文件,到检索式记忆库,再到最难的部分——写入策略。代码是 TypeScript。
一、先分清三种"记住"
不是所有"记住"都是同一种东西,混着设计必然做砸:
- 会话内记忆。当前对话的上下文,模型天然"记得"。要处理的只是装不下怎么办:截断、压缩、摘要——这是上下文管理,不是记忆系统,这篇不展开;
- 工作记忆(项目状态)。跨会话但要持续更新的内容:当前进行到哪、做过什么决策、下一步是什么。特征是"每条记忆都有半衰期",上周的状态这周可能就作废;
- 长期记忆(稳定事实)。用户的偏好、项目的技术栈约束、踩过的坑。特征是低频写、高频读,写下去基本不改。
三种"记住"对应三种完全不同的存储和淘汰策略。工作记忆适合"一个文件整体覆盖更新",长期记忆适合"逐条积累 + 检索"。最常见的失败是把工作记忆塞进长期记忆库——三个月后检索回来一堆过期进度,比没有记忆更糟。
二、最简可用方案:一个 Markdown 文件,可能就够了
动手上向量库之前,先看看需求的真实规模。单项目、单用户的 Agent,有价值的跨会话记忆通常不超过十几条:技术栈约束、几个关键决策、当前进度、几条教训。这个量级,一个结构化的 Markdown 文件就是最优解:
# 项目记忆 ## 技术约束 - 数据库:PostgreSQL 16,禁用 ORM,统一用 Kysely - 部署:Docker Compose,单机,无 K8s ## 关键决策 - [10-12] 认证选了 session 而不是 JWT:理由是同域部署,不需要跨服务鉴权 - [10-14] 放弃微服务拆分:三人团队,拆了没法维护 ## 当前进度 - 支付回调联调中,沙箱环境返回签名偶尔校验失败(待排查) ## 教训 - 直接改 DB 数据不写迁移脚本,下次部署会丢——已经踩过一次使用方式同样简单:会话开始时整个注入 system prompt,会话结束(或每完成一个里程碑)时让 Agent 输出更新后的版本整体覆盖:
async function loadMemory(): Promise<string> { try { return await fs.readFile(MEMORY_PATH, "utf8"); } catch { return "(暂无项目记忆)"; } } async function saveMemory(content: string): Promise<void> { // 整体覆盖,不 append——过期状态靠这次写入自然消失 await fs.writeFile(MEMORY_PATH, content, "utf8"); }整体覆盖而不是追加,是关键设计:过期信息的退出机制靠"重写"实现,不需要任何删除逻辑。Agent 每次更新时自然会把"支付回调联调中"替换成新状态,旧的进度不会堆积。
这个方案的边界也很清楚:记忆条目多到全量注入会挤占上下文(经验值大约超过 4-8KB),或者多项目共享同一个记忆库需要隔离,就到了换方案的临界点。
三、检索式记忆:什么时候该上向量库
当记忆条目超出"全量注入"的规模——比如 Agent 服务多个项目、或者要积累用户在长期使用中的碎片事实——就该按需检索了。套路和 RAG 一样:写入时嵌入,查询时 top-k:
interface MemoryEntry { id: string; content: string; embedding: number[]; createdAt: number; kind: "fact" | "decision" | "lesson" | "preference"; } async function recall(query: string, topK = 5): Promise<MemoryEntry[]> { const [qv] = await embed([query]); const all = await store.all(); return all .map(e => ({ e, score: cosine(qv, e.embedding) })) .sort((a, b) => b.score - a.score) .slice(0, topK) .map(x => x.e); }但检索式记忆有三个 RAG 场景没有的坑:
- 最相似的往往不是最该用的。用户问"数据库怎么连接",检索召回三条连接相关的旧记忆,但上周刚做过"连接池从 10 调到 50"的决策可能排在第五。所以排序要叠加时间权重,同分近邻里新的优先,甚至"最近 N 天内的事实直接保送";
- 碎片事实会互相矛盾。记忆库里同时存在"用户在用 Vite"(三个月前)和"用户迁到 webpack 了"(上周),两条都高度相似。不处理冲突,检索等于随机翻旧账;
- 每轮都检索是浪费。很多问题根本不涉及历史记忆。先用轻量判断(这轮对话是否涉及用户偏好/历史决策)决定要不要查,而不是无脑每轮 top-5 注入——那是在用上下文预算给记忆库交税。
四、写入策略:比读取难一个量级
读取顶多是排序问题,写入是"什么值得记住"的判断问题,这才是记忆系统的成败点。
按价值分三档:
- 必记:用户显式要求记住的("记住我用 pnpm");纠正性反馈("不要用 var,项目统一 const")——这类不记,下个会话必然重犯;
- 值得记:稳定的约束和事实(技术选型及理由、部署环境);踩坑教训(成因和规避方法都成立的);
- 别记:过程性内容(尝试了什么失败了什么)、一次性任务细节、可以被代码和文档直接查到的信息。记忆库里最没价值的就是"我们讨论过 X"这种会议纪要式条目。
两个落地规则:
其一,冲突用覆盖不用追加。新事实和旧事实矛盾时,旧的那条必须删除或标记作废,而不是两条并存。实现上给写入加一步"相似检索 + 冲突消解":新条目写入前先查 top-3,语义冲突(同一主体、相反断言)就替换。用户搬家了,记忆库里只能有一个地址。
其二,遗忘要有机制,不能靠自觉。最简单的两种:createdAt超过 TTL 且未被检索命中的条目定期清理;每过一段时间(或条目超阈值时)跑一次"蒸馏"——让模型把几十条碎片合并成几条结构化事实,比如二十条关于部署的零散记录合并成"部署环境"一节。蒸馏和第二节的整体覆盖是同一件事在检索式存储上的翻版:给记忆一个收敛机制,否则它只会单调膨胀。
五、务实的分层:三种存储各管一段
把上面的方案叠成分层结构,按记忆的性质路由:
| 层 | 存储 | 内容 | 读法 | 写法 | | --- | --- | --- | --- | --- | | L1 | 会话上下文 | 当前对话 | 自动 | 自动 | | L2 | 项目记忆文件 | 约束/决策/进度/教训 | 每次会话全量注入 | 里程碑时整体覆盖 | | L3 | 向量记忆库 | 碎片事实/偏好/长尾细节 | 相关时 top-k 检索 | 逐条写入+冲突消解 |
路由规则一句话:结构化的、每次都该看的知识进 L2;零散的、按需才相关的进 L3。团队共享的项目约束写成文档和 L2 注入,用户个人的使用偏好进 L3 检索——混在一起,两边都做不好。
一个容易忽略的点:给用户看记忆、让用户改记忆。记忆错了(过期的偏好、错误的教训)比没有记忆更伤,因为它会固执地影响每次会话。一个/memory命令让用户查看和编辑当前记忆,是信任问题,也是调试问题——记忆系统出问题时,你总得有个地方看它到底记了什么。
写在最后
清单版总结:
- 先分清会话内/工作记忆/长期记忆,三者存储和淘汰策略完全不同;
- 单项目先上 Markdown 文件整体覆盖,超出 4-8KB 再考虑向量库——不要一上来就 RAG;
- 检索式记忆叠加时间权重,同分近邻新的优先;每轮检索前先判断"这轮需不需要记忆";
- 写入按三档过滤:显式指令和纠正反馈必记,过程性内容别记;冲突覆盖不追加;遗忘靠 TTL + 定期蒸馏;
- L2 进度状态、L3 碎片偏好,分层路由;给用户一个查看和编辑记忆的入口。
记忆系统做好之后,你会发现下一个瓶颈是上下文本身:长会话里早期内容被截断,Agent 行为就开始"变形"——下一篇文章可能聊聊会话内的上下文管理:压缩时机、摘要策略,以及什么内容永远不该被压缩掉。