← 返回博客2026-06-24

你的 AI agent 记忆,是一个数据库

多个 agent 跨会话共享的记忆,悄悄踩中了五个经典数据库故障:撑爆预算、并发写、孤儿行、schema 漂移、同步过期。有一个,离静默覆盖错误记录只差一次文件名比较。

问题

我用 AI 编码 agent 干活——常常是好几个 Claude Code 或 Codex 会话同时跑,每个还会再派生子 agent。其中的 Claude 会话和它们的子 agent 共享同一份持久记忆:一堆纯文本文件,agent 跨会话读写,于是周一学到的东西周五还记得。(Codex 有自己的一份;跨厂商的事实走仓库里的文件。)它运行得够好,好到我不再去想它。问题就出在这里。

记忆不是你随手记几笔的笔记本。一旦不止一个 agent 跨会话读写它,它就是一个数据库——而一个没有任何约束的数据库,只是一场迟早发生的损坏。几周下来,一份记忆库悄悄踩中了五个经典的数据库故障模式:撑爆固定预算、并发写入、孤儿行、schema 漂移、同步过期。没有一个是自己喊出来的。其中一个,离静默覆盖错误的记录只差一次文件名比较。

这是那份目录——每个故障、它为什么发生、以及怎么修——因为只要你的 agent 跨会话记住任何东西,你也有这个数据库,无论你是不是把它当数据库看。

记忆的形状

两层。有一个索引文件——一张扁平的一行一指针的清单,一条记录一行——它会被装进每一个会话的上下文窗口,每次都装。然后是主题文件:一个文件一个主题,一百多个,装着真正的细节(有的只一行,有的是几 KB 累积下来的上下文)。这些会预先加载;agent 沿着索引里的指针,按需把它要用的那几个拉进来。

这个切分就是全部的关键,因为上下文窗口有硬上限。只有索引在花上下文:它被限制在几十 KB,而且每个会话都要加载。那一百多个主题文件(加起来远超半兆)在被召回之前不花一分钱。切分对了,记忆就免费扩展;切分错了,索引会在会话还没开始前就吃光你的上下文窗口。

索引 — 每个会话都加载 固定预算 · 花上下文 — [事实 A](a.md) — 一行指针— [事实 B](b.md) — 一行指针— [事实 C](c.md) — 一行指针— [事实 D](d.md) — 一行指针 每行保持短 → 索引保持小 召回 主题文件 — 按需加载 100+ 个 · 一文件一主题 · 召回前免费 a.mdb.mdc.mdd.md 细节住在这里,不占上下文预算

索引花你的上下文预算,主题文件不花。下面的故障,有两个是这条边界被侵蚀,其余是数据库有、而这份存储没有的控制。

五个故障模式

这是值得直接搬走的部分。每一个都是一道教科书级的数据库难题,只是穿了记忆的外衣。

1 · 撑爆固定预算

索引本该是一条事实一个短指针。但人总忍不住把警告整条塞进去——「别再标记 X,这是设计如此」读起来更安全,就摆在那个总是被加载的索引里,而不是藏在没人打开的文件里。于是指针长成了摘要,摘要长成了段落,索引一路漂向上限。这就是工作集对缓存预算的问题:你没法把整个热数据塞进固定缓存,所以必须判断什么才真正是热的。修法是一种纪律,不是工具——索引只放指针(每行硬性长度上限),细节住在主题文件里,而你信任召回层去取它。把一份臃肿的索引修剪一遍,就把它拉回了预算之内,还留出了余量。

2 · 并发写入——差点咬人的那个

两个会话同时决定整理同一份索引。一个正跑着按行号定位的大改——「替换第 14 行、第 22 行、第 30 行……」。它干活的时候,另一个会话也在修剪,于是行在移动:文件在第一个改动脚下从 25KB 变 22KB 变 20KB。现在第 14 行已经不是那个改动当初读到的条目了——每一个行号都指向了和制定计划时不同的记录。往一个别人正在编辑的文件里按行号写,就是最纯粹的丢失更新问题,而它正要把几十条更新写到错误的行上。

救它的是一个廉价的完整性检查:每次写之前,把指针指向的文件名和这个改动期望的文件名比一比。它们不再匹配了,于是全部 48 处写入被拒绝,而不是被执行。这就是乐观并发控制——提交前先确认记录是不是你以为的那个——在慌乱中被重新发明了一遍。真正的修法,和数据库几十年前就定下的一样:别按位置定位,按稳定 id 定位;写之前紧贴着重读一遍记录;宁可单写入者,也别要花哨的合并。

3 · 孤儿行

那次激进的修剪有个副作用:它把一批指针从索引里删掉了,却把主题文件留在了磁盘上。一个没有指针的主题文件是够不到的——召回沿着指针走,所以一个脱了索引的文件,是一条任何查询都返回不了的行。死了,却还占着空间;更糟的是,看不见:有些孤儿文件是进行中的活儿。这就是一个断掉的外键——一条父引用被删掉的行。修法是一次引用完整性扫描:列出磁盘上的文件,减去索引指向的那些,对每个孤儿判定重新索引(还活着)或删除(已被取代)。别让这个集合不受审计地漂移。

4 · Schema 漂移——最危险的那个

记忆格式随时间变过。旧文件用一种方式标记元数据;新文件用了另一种形状。没问题——只是那个扫描陈旧规则的小审计,从头到尾只学过读形状。于是当超过一半的文件迁移到新格式时,审计干脆看不见它们。它没报错。它报的是干干净净、信心十足的「0 条陈旧」——同时对超过一半的记忆视而不见,包括一摞行为规则。一个静默跳过自己解析不了的记录的读取器,加上一次没有回填的迁移,就是一场 schema 漂移事故。审计被修成能看懂两种形状;新鲜度日期现在取自版本控制历史,而不是一个可能并不存在的字段。这个教训远不止于记忆:一个解析不了新数据的监控,不会大声失败——它会静默通过,而一个你没法信任的绿灯,比一个红灯更糟。唯一的防御,是拿已知的坏输入去测这个检查,确认它真的会触发

5 · 同步过期——悬崖

大部分记忆是在那么几个忙碌的日子里一次性种下的。陈旧规则会把任何超过三十天的东西标为待裁定。两者一叠加,报告就是一道悬崖:连着几周近乎零条陈旧,然后数十条在一两天之内挨个越线——而那个本能反应,「全部续期」,只是把同一道悬崖往后挪三十天重建一遍。这是 TTL 惊群:一队一起创建的键,一起过期。修法是加抖动——把复审日期错开,用最后触碰而不是最初创建来判断新鲜度,一次只续期一条真正还在承重的规则,绝不成批。

数据库故障在 agent 记忆里的表现
工作集 > 缓存预算总是被加载的索引撑爆上下文上限
丢失更新 / 陈旧位置写入两个会话修同一文件;按行号的写落到错误行
断外键(孤儿行)脱索引的主题文件召回够不到——包括进行中的活儿
无回填的 schema 迁移审计只读旧格式 → 对一半以上记忆假报「0 陈旧」
TTL 惊群成批种下的记忆全在同一天过期

那条原则,和一份清单

五个故障底下的同一个想法:只要 agent 要跨会话记住一样东西,就给这个存储数据库的基本件,而不是更好的散文。稳定的键。一个 schema 版本号。引用完整性检查。会回填的迁移。每次写之前的冲突检查。一条带抖动的 TTL 策略。这些都不玄——是一个 DBA 会带来的那种枯燥纪律,套用在一个恰好以 LLM 为客户端的文本文件目录上。

我现在用这份清单检查 agent 记忆:

  • 指针,不是散文。总是被加载的索引是一张短指针清单,每行硬性限长;细节住在按需文件里。
  • 单写入者,写前重读。按稳定 id 改,绝不按行号;写之前紧贴着重读记录;让正在维护的那个写完,而不是去和它抢。
  • 扫孤儿。定期把磁盘上的文件和索引指向的对一遍差;活的重新索引,旧的删除。
  • 让读取器 schema 感知——并且证明它。解析每一个格式版本;然后喂它一个已知陈旧的样本,确认这个检查真的会触发。静默通过才是故障。
  • 给过期加抖动。按最后触碰判断,错开复审日期,绝不成批续期。

一个诚实的注脚。「信任召回层」是个真实的赌注:它假设懒加载层会在对的时刻浮出对的主题文件。当它没做到时,一条事实存在,却等于不存在。这是这套设计公开的风险,而点名它是运行它的一部分——就像缓存的价值终究取决于命中率。

我在公开地做 BluffKing——一个几乎全部由 Claude 和 Codex agent 舰队造出来的浏览器扑克游戏——而这类管道,正是决定这支舰队是保持连贯、还是悄悄烂掉的东西。如果你在跑长期存活的编码 agent,趁还没有哪个咬到你之前,拿这五条去审一遍它们的记忆。如果你也这样在造东西,欢迎跟着看。