我们如何给 AI 编程助手做「记忆」:不靠向量库的一整套设计
做一个能长期陪你写代码的 AI 助手,最难的往往不是「这一轮答得对不对」,而是------下次打开时,它还记不记得你是谁、这个项目怎么约定、上次踩过哪些坑。
上下文窗口再大,也会被工具输出、日志、长对话撑爆;纯靠「把历史全塞进 prompt」既贵又不稳。于是几乎所有 Agent 产品都会上「记忆系统」。常见路线是向量库 + Embedding 检索;我们走了另一条路:全部用 Markdown 文件持久化,人能直接打开改,系统用规则 + 少量 LLM 完成写入、召回、整合与遗忘。
下面按「为什么这样设计 → 记什么 → 怎么写 → 怎么读 → 怎么睡(整合)→ 怎么忘」把整套机制讲清楚。适合做 Agent / RAG / 长会话产品的同学对照。
一、先回答一个问题:为什么不用向量库?
向量检索很强,但对「编程助手的长期记忆」并不总是最优解,我们刻意约束了自己:
- 记忆必须可审计 用户偏好、项目约定一旦写错,会长期污染行为。Markdown + YAML 头信息,打开就能改、能删、能 diff,比「黑盒向量点」友好得多。
- 记忆量通常不大 真正值得跨会话保留的,往往是几十到上百条「高价值事实」,不是百万级语料。关键词 + 小规模 LLM 精排,足够用;上向量库会多一层运维与依赖。
- 写比读更危险 Agent 最容易犯的错不是「没召回」,而是「把安装进度、命令流水账、单次指令写进长期记忆」。所以我们把工程重心放在门控、白名单、置信度、会话上限,而不是检索召回率刷榜。
- 规则能做的事,不交给模型 索引表格维护、死链修复、秘密脱敏、条数压顶,全部确定性规则;LLM 只负责语义合并与精排。这样行为可测、可回归。
一句话:长期记忆是「少而准的跨会话知识」;会话过程是「多而脏的工作草稿」------两者必须分家。
二、两层记忆:长期 vs 会话
可以类比人脑:
| 层次 | 类比 | 存什么 | 生命周期 |
|---|---|---|---|
| 工作记忆 | 眼前这张桌子 | 当前对话消息 | 随轮次增长,会被压缩 |
| 会话笔记 | 今天的草稿纸 | 任务状态、命令、错误纠正、下一步 | 本会话有效,压缩后用来「续命」 |
| 长期记忆 | 笔记本里抄下来的结论 | 习惯、可复用排错、项目简介 | 跨会话;会衰减、会淘汰 |
会话笔记:压缩后的「连续性保险」
长任务里,上下文总会被压缩。压缩若只留一句「之前在改登录」,模型会失忆。我们维护一份结构化会话笔记,大致包含:
- 会话标题 / 当前状态 / 任务规格 --- 在做什么、还差什么
- 关键文件与函数 --- 别每次重新搜一遍
- 工作流与命令 --- 怎么跑、怎么验
- 错误与纠正 --- 踩过的坑、用户否定过的做法
- 经验与关键结果 --- 有效做法、用户要的交付物
- 运行时证据(机器维护) --- 根据真实工具快照与项目元数据自动覆盖,防止模型把「假设中的技术栈」写成「当前已用栈」
- 工作日志 --- 极短的逐步摘要
更新节奏偏「长对话才写」:上下文涨了一截、工具调用攒够几次,或出现自然对话断点时,才在后台刷新。压缩前会强制更新一次,再用这份笔记做零额外摘要成本的连续性恢复------不必再花一次 LLM 去「总结历史」。
原则:安装到第几步、今天敲过哪些命令,只进会话笔记,不自动升格为长期记忆。
长期记忆:只记三类东西
白名单非常窄:
- 用户习惯 --- 沟通风格、Git/测试偏好、明确说过的规则
- 可复用的排错 / 解决模式 --- 「当 X 发生,期望 Y」这类被纠正或确认过的工作流
- 项目简介与稳定约定 --- README 没写清、又无法从代码一眼看出的目标与架构约束
明确不记:单次指令、临时选型、安装进度、命令流水、可从仓库直接读出的事实。
三、长期记忆长什么样?
每条记忆是一个独立 Markdown 文件,头部是元数据,正文是人读得懂的说明。例如:
yaml
---
name: 用户的 commit 风格
description: Git 提交信息用中文,标题不超过 50 字,正文用列表
type: user
level: preference
evidenceStrength: explicit
source: user_explicit
confidence: 1.0
tags: ["dimension:git"]
memoryCategory: stable_preference
---
Git commit 规范:
- 一律使用中文
- 标题不超过 50 字
- 正文使用 bullet 列表
四种类型
| 类型 | 含义 | 例子 |
|---|---|---|
| 用户(user) | 跨项目的人设与偏好 | 「中文沟通、先给结论」「commit 用中文」 |
| 反馈(feedback) | 被纠正或确认的工作方式 | 「Smart Mode 被拦时改走审批卡片」 |
| 项目(project) | 项目特有、代码推不出来的事实 | 「本仓库是单体 Runtime,禁止引入某某库」 |
| 引用(reference) | 外部系统指针 | 某文档/工单/服务的稳定入口说明 |
用户类记忆会强制落到「用户级」目录,避免某个项目的对话把全局偏好写进项目库。
五种层级(影响召回优先级)
从硬到软大致是:
硬规则 → 项目事实 → 偏好 → 观察 → 会话状态
执行类任务(改代码、修 bug)会刻意丢掉「会话状态」类条目,避免把过期草稿当成铁律。
证据强度
明确声明 > 反复出现 > 推断 > 弱信号
冲突时(比如旧偏好说「别改代码」,用户现在说「就改这里」),按证据强度、置信度、更新时间裁决,同主题只注入一侧,防止模型左右互搏。
索引文件
每个记忆目录有一份总索引(分区表格:反馈与纠错 / 用户偏好 / 项目规则......)。 写盘时用规则同步更新一行;死链、孤儿文件用规则审计和重建。 索引健康是规则层的事;大模型只做语义合并,不负责修表格。 这是我们踩过坑之后定下的分工。
四、一整轮对话里,记忆在干什么?
把 Agent 主循环摊开,记忆穿插在这些节点:
javascript
用户发话
→ 循环开始:异步预取相关记忆 + 检测用户对上次记忆的肯定/否定
→ 调主模型之前:粗召回(纯关键词,快,大约 2~3 条)
→ 主模型思考 / 调工具
→ 工具轮结束后:标准召回(关键词粗筛 → 必要时 LLM 精排 → 预算裁剪)
→ 以「系统提醒」形式注入:结构化 JSON + 使用说明(Chain-of-Note)
→ 本轮结束:
· 后台尝试提取长期记忆(默认不写,层层门控)
· 按需更新会话笔记
· 条件满足则做 Dream 整合
· 条数超标则加权淘汰
上下文将满时:
→ 用会话笔记做压缩后的连续性恢复
为什么要「粗召回 + 标准召回」两阶段?
- 粗召回:在第一次调主模型前就要有一点上下文,否则「用户一开口就该想起的偏好」会晚一拍。它必须快,所以不用 LLM,条数也少。
- 标准召回:工具跑完后,任务意图更清晰(改了哪些文件、报了什么错),这时再做更准的召回更划算。粗召回不会「占坑」阻止标准召回。
闲聊 / 轻问询路径会更克制,注入更少,避免把编程约定硬塞进寒暄里。
五、怎么写进长期记忆?(默认:不写)
这是整套系统里最「反直觉」、也最重要的部分:默认不写长期记忆。
三条写入通道
- 用户明确要求记住 --- 「请记住......」「以后都用 Vitest」
- 后台从对话里提取 --- 轮次结束后异步跑,受严格门控
- Dream 整合时合并改写 --- 去重、压缩、语义整理,串行加锁,避免并发踩踏
主代理不能随便写记忆目录
即便模型想调用写文件 / 打补丁 / 用 shell 往记忆目录塞东西,工具层也有守卫: 只有本轮用户明确发出「记住」类指令时才放行。
这里刻意不用「偏好 / 不要 / 不对」这类宽信号------否则用户说「不要写长期记忆」反而会误触发写入。宽信号只用于「要不要启动后台提取」的讨论,不用于「放不放行写盘」。
后台提取的门控逻辑(简化)
消息为空? → 不写
本会话已经成功提取过? → 不写(每会话硬上限很低,默认成功 1 次、落盘约 1 条)
正在装环境 / 部署 / 解压这类运维任务? → 默认跳过
(但用户明确纠正「以后别这样」可以突破)
用户说了「记住」或明确纠正? → 立即提取
出现偏好/规则类信号词? → 允许进入提取
否则看深度门槛:轮次、累计 token、是否用过工具、工具调用间隔、节流
闲聊意图:门槛更高,默认还要求会话里出现过工具调用
写盘前还有一层质量闸:
- 置信度默认至少约 0.6 才落盘
- 推断出来的用户偏好建议更高(约 0.75),不够就降级成反馈类或直接丢弃
- 项目类记忆必须带「项目约定」类别,防止把临时安装过程写成项目事实
- 描述和已有条目过于相似(约 0.85)则合并更新,而不是再新建一份
- 扫描密钥 / Token,命中则脱敏成占位符
用户反馈闭环
刚写入或刚注入后,若用户说「不对 / 错了」或「对 / 就是这样」,可在短窗口内下调或上调相关条目的置信度。记忆不是写死的碑文,而是带反馈的活文档。
六、怎么召回?(读比写更讲究「预算」)
上下文是稀缺资源。记忆注入默认大约只占窗口的 5%,还有绝对上限(量级约三千 token),预算再紧也尽量保住最相关的几条。
标准召回流水线
- 扫描项目级 + 用户级记忆(有扫描缓存,避免每轮全盘读)
- 推断意图:执行 / 查看 / 提问 ------ 决定过滤哪些层级
- 丢掉低置信(默认低于约 0.3 不参与)
- 关键词粗筛:TF-IDF 加权;标题和文件名比正文预览更重要;支持否定查询展开(「不要用 Jest」会连带搜测试相关词)
- 候选够少时直接关键词回退;够多且可用模型时,用一次旁路查询:选文件 + 对文件内事实精排
- 关联扩展:标签相似度够高的邻居记忆,一跳扩展,数量封顶
- 冲突去重:同主题只留证据更强的一侧
- 相关性门控:和当前几轮用户话重叠太低的丢掉;通过率过低时可用 LLM 救援判定
- 按 token 预算裁剪
- 回写召回次数与时间(批量刷盘,避免每条都写文件)
还会用到:时间范围加权(「上周」「最近三天」)、事件日期加权、新鲜度衰减、异步预取命中加分、实体名匹配等。
话题切换
若本轮用户话与上次注入时的话重叠很低(集合相似度很低),视为换题:重新召回,并提高「约定/规范」类权重、压低「个人偏好」权重------换题写代码时,少被旧聊天习惯带偏。
注入长什么样?为什么是 JSON + Chain-of-Note?
注入不是塞一段散文,而是:
- 结构化 JSON --- 每条边界清晰,带来源类型、置信度、事实摘要
- Chain-of-Note 指令 --- 要求模型先抽取与当前问题相关的信息,再基于抽取结果推理(思路来自长程记忆评测工作,实践上比「直接贴原文列表」更稳)
- 明确优先级(冲突时谁说了算): 当前对话 > 会话笔记 > 长期记忆
并反复强调:这些只是参考,绝不能覆盖用户最新指令;陈旧记忆要对照当前代码再信。
「事实切片」
每条记忆正文会按规则切成多条短事实(句号、列表项、过长行拆分),供精排与关键词扩展。切片在内存里维护,不必再搞一套事实数据库。
七、Dream:给记忆一个「睡眠整合」
人睡觉时会巩固记忆;Agent 的记忆库也会逐渐重复、过期、索引漂移。我们有一个定期(也可手动)的整合过程,内部叫 Dream。
什么时候会做梦?
常见触发包括:
- 会话次数与文件数量同时过线
- 距上次整合又新增了不少文件
- 检出多条过期记忆
- 索引里死链过多
- 磁盘上的文件与索引严重脱节(孤儿比例过高)
- 条数超过整合后的软顶
还有最短间隔(例如数小时)和最少会话次数,避免刚写完就反复整合。
做梦时做什么?
大致四步:定向 → 收集 → 整合 → 修剪。 库很大时只喂清单元数据、或分批处理,控制单次模型耗时与超时。改写前可备份,失败可回退。
整合结束后,再用规则淘汰把项目库、用户库压回条数上限------语义合并归模型,条数压顶归规则,分工清晰。
空跑(触发了但没什么可做)有独立退避,避免「索引有点脏 → 反复叫模型 → 什么也没干」的空转。
八、遗忘:加权淘汰,不是简单 LRU
条数有软顶(项目库大约几十量级,用户库更紧)。超了不是删最久没碰的文件那么简单,而是打一个淘汰分,分高者先归档:
- 越久不活跃,分越高(更容易走)
- 置信度高、被召回多,有保护
- 用户类、硬规则、明确证据、用户亲口声明,更难被淘汰
- 反馈类、引用类略偏向淘汰(相对更容易过时)
硬保护:
- 置信度拉满(用户明确声明)的条目不淘汰
- 最近几天还活跃的不淘汰
- 索引文件本身不淘汰
- 淘汰是挪到归档区,可以恢复,不是直接砸碎
另有时间衰减:大约三个月偏陈旧、半年偏过期;高置信条目衰减更慢。召回时会提示模型「这条可能过时,请对照现状」。
九、安全与多级范围
- 用户级 / 项目级 / 目录级可叠加;冲突时更局部的覆盖更全局的。
- 路径必须落在记忆根目录内,防目录穿越。
- 写入前扫密钥;评测模式可整体关闭提取,避免污染基准。
- 标准召回有冷却:同一批记忆清单 + 同一问题,短时间内不重复烧 LLM。
十、你能感知到的产品面
对使用者来说,记忆系统大致体现为:
- 记忆管理页:浏览、删除项目级与用户级条目,有的界面还带关系图
- 对话中的提示:注入或被动提取完成时,可用轻量提示(例如宠物气泡)告知「想起了什么 / 记下了什么」
- 手动整合:在界面或接口触发一次 Dream
- 可观测性:记录召回是否走了模型、提取是否命中缓存、Dream 是否空跑、库里有多少条------用来调参,而不是玄学
读遥测时要注意:粗召回永远走关键词,会把「LLM 召回率」表观值拉低,这是预期现象;小库时候选太少,标准召回也会故意跳过模型,省一次无意义调用。
十一、设计上我们坚持的几条原则(可复用)
- 会话过程 ≠ 长期知识 --- 分存储、分门控、分注入优先级。
- 默认不写 --- 写比读更伤;用白名单 + 置信度 + 会话 cap。
- 规则修结构,模型修语义 --- 索引、脱敏、压顶、死链用规则。
- 冲突时只留一侧 --- 宁可少记,不要让模型同时听见「别改」和「快改」。
- 注入要讲规矩 --- 结构化 + 使用说明 + 优先级,比堆原文重要。
- 遗忘要可恢复 --- 归档优于物理删除,尤其在个人助手场景。
- 可观测、可回归 --- 门控和召回都有事件与测试;调参靠数据不是靠感觉。
十二、局限:我们清楚自己还不够好的地方
- 没有向量检索:库涨到一百五十份以上时,靠「清单 + 旁路选文件」精度会掉,这是已知瓶颈。
- 时间类问答仍偏弱:相对日期加权有,但复杂时间推理不是强项。
- 跨会话去重仍可加强:同事实在不同会话被写成多份的情况,要靠 Dream 和写时相似度兜底。
- 刻意不加新依赖:优化方向是提示词、门控、粗筛与精排策略,而不是先上一套向量基建。
这些限制换来的是:部署简单、记忆透明、行为可测------对「本地 / 自托管编程助手」往往更划算。
十三、若你也要做 Agent 记忆,可直接抄的检查清单
- 长期与短期是否物理分离?
- 写入是否默认关闭?有没有「明确记住」与「运维任务勿记」?
- 每会话写入是否有硬上限?
- 冲突记忆是否会同时注入?
- 注入是否占用固定 token 预算?
- 压缩后靠什么恢复任务状态?(别只靠模型「回忆」)
- 索引损坏时,是规则修还是指望模型修?
- 淘汰是删除还是归档?用户能否改一条错误记忆?
- 密钥会不会进记忆文件?
- 有没有遥测区分「粗召回 / 精召回 / 提取 / 整合」?
结语
记忆系统的本质,不是「多存一点历史」,而是在正确的时间,以正确的权限,把少量正确的事实放进模型眼前,并在错误时允许人与系统一起改掉它。
我们用文件而不是向量库,不是因为怀旧,而是因为:编程助手的长期记忆,首先是一份可治理的知识资产,其次才是检索问题。写得少、读得准、睡得着、忘得干净------这四件事同时成立,比单一指标上的召回率更重要。
如果你也在做 Agent 的记忆层,欢迎对照这套「双层存储 + 默认不写 + 双阶段召回 + Dream 整合 + 加权遗忘」的拆法,按自己的产品形态裁剪。少即是多,往往从「敢于不写」开始。