本文深入探讨了在构建本地优先的AI Agent记忆系统时,作者如何从一个精心设计的遗忘曲线机制中意识到过度工程的陷阱,以及为何简单显式的提交和读取时排序反而更加有效。
在构建AI Agent的记忆系统时,我曾沉迷于一个优雅的比喻:人类记忆的遗忘曲线。希腊神话中的忘川(Lethe)让亡者在饮用其水后遗忘前世,这个意象如此美好,以至于我在代码中认真地实现了一个类似的机制。我为Eris这个本地优先的Agent构建了一套复杂的遗忘曲线系统,包含Session、Scratch、Promote三个层级,每个层级有不同的TTL和晋升阈值,背景守护进程每两分钟执行一次衰减和晋升检查。这套系统理论上能够让Agent记住被反复提及的内容,而让那些无人问津的临时记忆自然消退。运行数月后,我终于不得不承认一个令人尴尬的事实:我从未真正查看过任何Scratch层级的分数,那些精心设计的机制实际上只是在为空无一物的观众表演。 这个认知的觉醒来得并不突然。
系统的问题在日常使用中不断显现,只是我一直不愿意面对。首先是那套基于字符串重叠的提及匹配机制脆弱得令人沮丧。当用户说"提交那个笔记"或"和之前一样"时,系统根本无法识别用户正在指向已经被暂存的内容,因为这些指令性词汇永远不会出现在canonical_key的分词中。同样,"咖啡偏好"和"浓缩咖啡订单"在语义上明显相关,但字符串匹配给出了零分。在真实的对话中,同义词、指示代词和转述无处不在,而我的"遗忘曲线"只能在用户恰好重复了标题slug中的词汇时才能触发加分。这种伪语义匹配从一开始就是一个美丽的误会,我却把它当作真正的海马体来依赖。
更根本的问题在于架构层面的设计错误。我将两个不同的需求合并成了一个机制:写时筛选和读时排序。在写入时,系统试图通过评分机制决定哪些内容值得晋升到永久存储;在读取时,本应有另一套机制根据当前对话上下文对记忆进行语义检索。但我提前预支了未来的复杂性,为一个尚未构建的" curation模式"铺设了基础设施。这个 curation模式本应是Agent空闲时漫步自己暂存的记忆、阅读分数和层级、决定保留什么合并什么的园丁循环,但它从未被实现过。结果是:评分系统在实际没有任何读者的情况下持续运行,消耗计算资源,创造我一直假装在监控的配置债务。
配置本身的膨胀也揭示了问题的严重程度。我设置了三个不同的TTL值、两个阈值、一个衰减步长、一个评估间隔、一个提及加分和一个暂存加分,至少六个可调节的参数服务于一个我从未查看过的子系统。其中一个参数设置尤其能说明问题:暂存加分是3.5,而从Session晋升到Scratch的阈值恰好是3.0。这意味着单次暂存操作本身就足以让条目在下一次检查时自动晋升到Scratch层级。换言之,我创建了一个实际上无人驻足的中间层级。整个晋升阶梯在第一次暂存后就几乎自动完成了它的使命,完全违背了设计的初衷。
关于并发安全的例外处理最能说明设计已经偏离了正轨。我在代码库中有一条严格的规则:跨线程不允许共享可变状态。编排器和用户界面通过消息通道通信,绝不会因为方便就使用Arc加Mutex的组合。然而,为了保护遗忘曲线的运行不受竞态条件影响,我为promotion_suppressed_during_step这个标志单独做了一次例外。原子布尔值确保了在LLM调用和工具执行期间,守护进程不会同时修改层级和分数。但这个例外本不应该存在。
如果一个机制需要专门的同步保护,而该机制本身并没有被实际使用,那么这种保护就是在为错误的东西保驾护航。当你在代码中做出违背自身原则的例外时,通常意味着设计本身已经出现了问题。 讽刺的是,在我忙于衰减浮点数和检查阈值的同时,检索系统已经在做着真正重要的工作。每个对话轮次开始时,向量嵌入系统会根据当前上下文对保险库记忆进行语义相似度排序,然后将相关内容以字符限制内的片段形式注入系统提示。这个过程没有任何层级、没有评分、没有复杂的时序逻辑,只是简单地回答一个即时问题:考虑到用户刚才说的话,过去有哪些内容是相关的?这种方法不关心内容的"重要性"或"晋升可能性",它只关心当下的相关性。垃圾内容只要永远不匹配任何查询就永远不会造成伤害,而我本应更早意识到这一点。
真正的教训在于一次更深刻的概念反转:从写时决定转向读时排序。传统的记忆系统试图在信息进入时判断其未来价值,这本质上是一个不可能完成的任务,因为我们无法预知哪些知识会在何时变得重要。更有效的方法是将所有潜在有价值的内容都以低成本的方式暂存起来,然后在需要时根据即时上下文进行过滤和排序。这样做的好处是双重的:首先,写入时的决策压力被大幅降低,暂存变得廉价和频繁;其次,检索时的排序可以结合语义相似度、时间衰减、BM25评分等多种信号,比任何预设的层级机制都更加灵活和准确。 保险库本身的设计也反映了这种哲学。纯文本的Markdown文件是人类可读的,删除索引可以随时从保险库重建,而索引永远只是派生数据而非数据源。
这种设计确保了即使嵌入系统完全崩溃,信息依然以人类可访问的形式存在。我在构建这套遗忘机制时完全忽略了已有的简单工具:显式的memory:commit和memory:commit_all让模型或用户直接决定内容是否值得永久保存;needs_review标志可以阻止自动晋升;提及处理可以在关键词再次出现时刷新TTL。这两三个简单的机制已经足以防止保险库变成倾倒对话记录的垃圾场,而我却额外构建了第三道锁,同时还在锁上安装了一个假装能预知未来的传感器。 更深层的故事与尼可斯·卢曼的卡片盒(Zettelkasten)方法论有关。卢曼的伟大之处不在于保存所有内容,而在于构建一个可漫步的思想图谱:每条笔记都是原子化的,通过手工链接与相关笔记建立关联,新笔记必须首先找到它的邻居,思想的火花产生于链接之中而非卡片本身。这个愿景在我的保险库设计中有其影子:稳定的节点ID、版本化的Markdown提交、像拓扑、话语、合成这样的文件夹结构、对矛盾的明确标记而非静默覆盖。
理论上一切都准备就绪,可以转化为一个真正的知识图谱。但实际上我每天使用的系统要简单得多:暂存、偶尔提交、对块进行语义搜索、将一些片段预取到提示中。卢曼式图谱的梦想和遗忘曲线的梦想并肩而立,两者都是我所说的"人类记忆,但经过工程化"这种执念的不同表现形式,两者都在简单的循环还未变得可靠之前就让Agent的路径变得沉重。 作为独立开发者,最大的困境不是技术实现的困难,而是方向选择的焦虑。删除这个层级系统理论上是一个干净的胜利:几百行代码消失、一个并发例外消失、同样的功能、更少的维护负担。但现实是,我无法下定决心。
代码库里确实有一个大大的TODO注释写着"删除层级",但我完全预期自己每天都会走过它然后忽略它。Eris是一个爱好项目,我建造它纯粹是因为建造它的快乐,而这个层级系统不知怎么就成了我的一个小宝贝,即使我在整篇文章中都在解释它为什么是错误的。所以它继续存在,处于试用状态。 真正让我夜不能寐的不是这个层级系统,而是那个无法决定下一步方向的困境。把聊天会话存储到SQLite中让Agent能够用真正的SQL查询自己的历史;构建一个合适的沙箱让它可以编写和运行代码而无需我时刻盯着;创建可以交付给他人的实际构建版本而不仅仅是"克隆仓库然后祈祷";添加基于Web的LLM引擎后端支持OpenRouter等服务商让Eris不再绑在我本地的llama.cpp上。每一个选项都比删除一个浮点数和几个配置参数更令人兴奋,而这恰恰就是问题所在。
作为一个建造者的最大风险不是建造错误的东西,而是永远在建造新东西而从不清理旧账。 遗忘曲线本身仍然是一个好概念。我仍然非常欣赏忘川的意象。但我必须停止假装一个在守护进程滴答作响的浮点数曾经是那条河。TTL过期、显式提交、读取时排序,这些机制本身就已经遗忘了很多内容,而无需一个假装的观察者在上面监视。两种状态就足够了:暂存和已提交。
显式决定。读取时排序。明文笔记。野心完全没有被束缚。 最终,这个项目教会我的是关于技术债务和自我欺骗的深刻课程。当你在构建Agent记忆系统时,如果你发现自己正在创建Session、Scratch、长期记忆层级,用衰减机制"模仿人类海马体",停下来问一个无聊的问题:谁在阅读这些分数?如果答案是没有人,那么你不是在构建遗忘曲线,你是在构建配置债务,同时给自己讲一个关于神经科学的美好神话。
对于任何正在设计AI Agent记忆系统的开发者来说,我的建议是:从最简单的版本开始。暂存状态和已提交状态,可能加一个needs_review标志来阻止自动操作。让检索系统在读取时做排序的工作。让用户和模型显式决定什么是重要的。让Markdown成为事实的来源。让存储变得廉价,让过滤变得智能。
不要为未来可能需要的观众构建空的剧场。如果你后来确实需要一个curation模式,你可以在真实使用模式出现后再构建它,而不是提前预支复杂性然后假装它已经在工作。 也许有一天我会删除这个层级。也许不会。但现在我至少可以对自己诚实:这个系统是一个过度工程的纪念碑,一个关于我如何被优雅的比喻而不是实际效用所吸引的故事。每一个浮点数和阈值都在提醒我:技术决策应该基于真实的使用需求,而不是基于我们对某个概念的浪漫想象。
忘川可能是一个美丽的隐喻,但它不应该成为代码库中没人查看的配置参数的名称。 。