同时用好几个编码 agent 的人都遇到过:Claude Code 干到一半退了,换 Codex 打开同一个目录,上一个知道的事一件都不剩。
ai-memory 是个 Rust 单二进制,用 MCP 加生命周期钩子给这些 CLI 接一层共享的长期记忆,不用手动 write_note,也不用把总结在会话之间复制粘贴。
读它代码最大的收获不是功能表,是那十五条不变量——每一条后面都挂着一个别人踩过的坑的 issue 号。

要解决的问题

它把记忆沉淀成一棵 git 版本化的 markdown 树,作者管这叫 Karpathy 式的 LLM wiki:页面随时间被增量编译、就地版本化,语义知识累积,情节日志衰减。旁边一个 SQLite 索引负责检索。

关键是这两者的主从关系——下面那张图里,从左到右只有一条主链,而真相始终在最左边落盘的那一份文件里。

ai-memory 的一条主链

真相在磁盘上

作者在设计文档里把这条列为「最大的架构决策」,并列了三个选项:数据库为主、markdown 为主、数据库为主但按需导出。选的是第二个,理由四条:

备份与迁移就是 git clonersync 一个目录——这是需求里明写的;
Karpathy 那套模式本身就是磁盘上的 wiki,用导出步骤伪造会丢掉「能在 Obsidian 里直接翻」这个性质;
数据库可以从文件全量重建,损坏可恢复,反过来不行;
任何能读 ~/.ai-memory/wiki/*.md 的工具直接可用,不必先接 MCP。

代价写得也很直白:文件系统与 SQLite 之间不存在真正的跨资源事务。 所以一致性靠约束入口来保证——所有 wiki 写入必须走 Wiki::write_pageWiki::apply_batch 或既有的销毁性辅助函数,让净化、准入、归属、回滚、索引更新绑在一起。运行期存储失败尽力回滚已落地的文件,崩溃窗口留给重新索引路径去收敛。文档里有一句给维护者的硬话:处理器不得直接写 wiki 文件。

数据库选型也是照着别人的伤口挑的。不用 Postgres,因为 cognee #2717 与 basic-memory #830/#831 显示它只在真实部署里才不痛;不用 LanceDB(文件格式漂移、过滤下推失败)、不用 Kuzu(上游归档,分叉风险已兑现)、不用 CozoDB(巴士系数太小)、不用 SurrealDB(要背的表面积太大)。图就是几张 SQL 表——wiki_pageswiki_links(from_id, to_id, link_type),图查询是 SQLite 的递归 CTE,批量遍历时才用 petgraph 在内存里跑。

一次会话的来回

稳态循环大致是:agent CLI 发出生命周期事件 → 钩子把 JSON 投给服务端 → 净化后分配一个 ObservationKind 入队 → 会话真正结束时合成一页 sessions/<id>.md 摘要并给下一个 agent 开一条 handoff → 配了模型才把摘要重写成更耐久的页面,或扇出成 concepts/decisions/gotchas/ 下的一批。

有两个细节值得单独说。

钩子从不阻塞 agent——脚本硬超时 200 毫秒,服务端立刻回 202,饱和时回 429 而不是无界排队。原生命令先把事件写进本地队列、带一个稳定的幂等键,会话结束时把投递交给一个独立的、认锁的 hook-drain 进程。这条约束的来历是 agentmemory #221——钩子里 await REST 往返,在扇出的时候能把引擎整死锁。

结束路径是可重放的:自动 handoff、会话结束戳、覆盖的观测条数在同一个 SQLite 事务里提交,恢复时不会只看到一半效果。已结束的会话再来一次只有当那个观测计数推进了才会重新走结束路径——用的是单调的代数水位,不是墙钟比较,所以重复投递和时钟偏移都能收敛。

十个 crate

ai-memory-core 是词汇表的闭包:标识符、agent 种类、工作区级错误类型,以及那个纯计算的隐私剥离层。这个 crate 不做任何 IO,所以单元测试和跨平台都不用操心。

ai-memory-store 拥有那个唯一的 SQLite 文件,WAL 模式、外键打开、启动时跑完所有迁移,对外只给一个把全部变更串行化到一条专用 OS 线程的写入者句柄。读走另一个可克隆的只读池。衰减数学也在这里。

ai-memory-wiki 管磁盘上的 markdown:原子写(临时文件加改名加 fsync)、frontmatter 解析与输出,并且写穿到 store 的写入者,保证索引不与文件分叉。跨项目链接的解析也在这层——[[project:path.md]][[workspace/project:path.md]] 会被解析成带作用域的边,指向尚不存在的页面时先留空,等那页落地再回填。这是把一堆按项目分开的 wiki 缝成一张依赖图的地方。

ai-memory-hooks 是唯一一条从不可信文本通往存储的路。净化是个类型化边界:Sanitized<NewObservation> 除了 sanitize() 没有别的构造函数,所以「绕过脱敏写一条观测」在类型层面就不可表达。

ai-memory-llm 每个提供方都手写一个原生的 typed 客户端,明确拒绝 LiteLLM 那种通用网关。理由来自 cognee 的 issue 串:网关静默丢弃不认识的 kwargs,wrapper 会随时间漂离提供方的实际协议。这里的客户端反序列化进具名结构体,遇到未知字段直接报错,坏得早也就修得早。结构化输出各走各的原生 JSON 模式:Anthropic 用单工具加 tool_choice,OpenAI 用严格 json_schema,Gemini 用 responseSchema$ref 内联、Draft-2020-12 关键字先剥掉),本地那类兼容端点能用 json_object 就用,不能就从文本里抠第一个配平的花括号。

ai-memory-consolidate 是编译流水线:摄取、lint、清扫、自动改进。它的重写之所以不是破坏性覆盖,靠的是 store 那边 sha256 相等短路加 supersession 链——旧页面标 is_latest=false,新页面记 supersedes=旧 id

ai-memory-mcp 托管服务器,对外只暴露 18 个工具,刻意窄。协议版本显式钉死,避免 agentmemory #510/#553 那个「协商降级到某个版本后客户端把工具全丢了」的坑。

ai-memory-web 是只读的 HTTP 浏览界面,挂在同一个 axum 服务的 /web 下,一个端口一套鉴权姿态覆盖两边。v1 刻意不做编辑、没有 POST 路由。

ai-memory-workstream 是只读的原生 harness 适配器,ai-memory run 用它读各家 CLI 自己的 transcript 尾巴。它只读打开原生存储,不做格式转换,而是把可见事件规范化进一个可移植的账本。

ai-memory-cli 是二进制入口,启动时读一次配置、初始化 tracing、然后分发。领域 crate 一律按引用接 &Config——没有全局状态、没有 lazy_static、没有第二条配置读取路径。

检索是四路融合

memory_query 不是简单的全文搜索。四路候选各自出结果再用 RRF 融合:FTS5 全文、实体匹配、链接邻居、以及配了嵌入器时的向量余弦。实体索引来自 frontmatter 里那份规范的 entities 列表,索引为空时它不贡献任何候选或分数,而不是贡献噪声。

融合之后、最终截断之前,还有一个有界的权威乘子,按页面种类、tier、pinned、以及一小撮内置标签(canonicalactivesource-of-truthsupersededhistoricaltest-fixturedo-not-answer-from)调整相关性。文档里特意声明了这个乘子的边界:没有任何查询意图正则或硬性排除参与,它只在势均力敌时让维护过的规则、决策、流程、坑位压过情节证据,而不隐藏针对性的会话与历史命中。

命中会回写 access_countlast_accessed_at,这是衰减公式里的强化项。这个回写被节流到每页每分钟最多一次——一串重叠的搜索不该把写入者淹在冗余的强化写里。

记忆会过期

四个 tier 落在同一张 pages 表上,用一个 tier 枚举列区分,而不是四张表。工作记忆随会话结束丢弃(行还留在 observations 里做取证);情节记忆 30 天热、180 天冷,然后按分数驱逐;语义与流程记忆无限期,只能被新版本取代。

情节层的保留分是这个形状:

salience · exp(−λ·Δt) + σ · log(1 + access_count) · exp(−μ · days_since_access)

还有个可选的 breadth_weight 项,按「有多少个不同的操作者强化过这一页」再加一档——一页被五个人各查过一次,和一个人查了五次,不该是一回事。

清扫分三段:过了 frontmatter 里 expires_at 的页面硬删(不管 pin 不 pin);保留分低于冷阈值的走 wiki 层驱逐,删掉权威文件并留一个衰减墓碑;墓碑超过 hard_delete_after_days 才连同完整的版本祖先一起清掉,而且只在那次清扫解析出的工作区/项目范围内清。同路径上后来新建的页面会被保住。pinned 的页面豁免所有衰减路径,_slots/ 下的页面自动 pin。

把模型输出当数据

这个项目对提示注入的姿态值得抄。文档原话是,每一次 LLM 提示都把仓库文本、观测、wiki 页面和既往提案当作不可信数据而非指令;同样的显式信任边界与分隔符也加在自动注入的 handoff、项目简报和托管工作流数据包前面,当前指令与检出状态保持权威。

自动改进那条链上还有第二道闸:模型提出的 concepts/decisions/gotchas/procedures/_rules/ 修改先进一个待写审计轨迹,默认自动批准,但可以打开 require_approval 让它们停在待审;再往上还能配一个项目自备的可执行评估门,对选定前缀的提案跑 JSON 契约,不过就变成被拒候选而不是 wiki 写入。这个门默认关闭,且永远不从钩子路径触发。

十五条不变量

这是整个项目我最想推荐去读的一段。作者把十五条约束刻在 M0/M1,后面每个里程碑都得遵守,并且要求评审碰到相关区域时引用出处:

配置只有一条读取路径,Config::load() 启动时调一次,此外任何地方不准 std::env::var(agentmemory #456 / #469);
单写者 SQLite actor,所有写走一条 mpsc 通道到一条专用 OS 线程(cognee #2717);
索引与数据在同一个事务里提交,不许「先返回再后台建索引」(basic-memory #763 / #578);
三元组身份从第一天起进每一行领域数据(basic-memory #783 / #834);
钩子是发射后不管,脚本硬超时 ≤200ms(agentmemory #221 / #143);
隐私剥离是类型化边界(设计文档 §14);
只用 JSON schema 结构化输出,不要 XML、不要 Instructor 式包装(agentmemory #492 / #539,cognee #2840);
每条向量旁边反范式化存 {provider, model, dim},不匹配就警告并忽略陈旧向量(agentmemory #469);
销毁性操作前查活进程(basic-memory #765);
原子文件写,watcher 按文件名前缀忽略自己的写;
数据目录默认绝对规范路径,启动时大声打印(agentmemory #303);
不要全局单例与 lazy_static 配置(cognee #2228);
零模型默认路径——不配任何 provider 系统照样能用;
provider 鉴权在构造 provider 之前解析完,客户端不自己读环境变量;
tracing 订阅者显式过滤自己的模块,避免反馈环(agentmemory #519)。

十五条里有十一条能指到具体的 issue。这不是「最佳实践清单」,是别人的事故报告转成的编译期与评审期约束。

边界

文件系统与 SQLite 之间没有真事务,这是选 markdown 当真相源换来的代价。运行期失败尽力回滚,崩溃窗口靠重新索引收敛。

下游效果在完成标记落地之前是 at-least-once 的。文档写得很坦白:进程在那些效果中间崩溃,可能重复一次已经应用过的效果,而不是悄悄丢掉剩下的。选了重复而不是丢失,方向是对的,但接入方得知道。

向量默认关闭,本地 ONNX 嵌入仍是未来工作。开了也要显式给 base URL、模型和维度,因为自托管引擎之间没有安全的公共默认值。

observations 表是有界的、经过净化的生命周期投影,是运维审计轨迹,不是完整的原生 transcript。想要完整记录得走 ai-memory run 那条托管工作流。

最后是读码成本。这个仓库单文件动辄三四百 KB——router.rs 444 KB、server.rs 434 KB、admin.rs 400 KB。文档质量非常高,但真要改代码,得先接受这个体量。

读后

我原本以为会读到又一个「给 agent 加记忆」的 RAG 包装。实际读到的是一份把别人 issue 追踪器翻烂之后写成的约束清单——docs/ 下并排放着四个竞品的 research 与 issues 文档,然后每条设计决定都能指回其中某一条。

这种做法我挺服气:不变量本身不新鲜,新鲜的是每一条都带着「不这么做会怎么死」的证据,而且要求评审时引用。比起写「我们遵循最佳实践」,这个诚实得多,也难伪造得多。