跳到主要内容

回填问题:为什么智能体记忆需要像数据库一样进行迁移

· 阅读需 12 分钟
Tian Pan
Software Engineer

你在周二发布了一个更好的内存格式。新架构将自由形式的 summary 字符串拆分为结构化字段 —— entitiespreferenceslast_verified_at —— 因为旧的 blob 难以检索且无法干净地更新。这个改动显然是正确的。它通过了评审,并上线了。

你没有注意到的是,周二之前写入的每一条内存现在都微妙地出错了。有些记录仍保留旧的 summary 字段而没有 entities,因此现在以 entities 为键的检索代码会跳过它们。还有一些记录的 summary 被新解析器解释为空的偏好集。智能体没有崩溃。它只是悄悄地遗忘了积累了一年的上下文,而且没有人提交 Bug,因为看起来并没有什么坏掉 —— 智能体依然在回答,只是表现变差了。

这就是数据回填(backfill)问题,也是将智能体内存视为一个“功能”而非“数据库”的必然结果。内存存储持续积累记录,这些记录具有结构,且每次有人改进它时,该结构都会发生偏移。关系型数据库在应对这类问题上拥有数十年的工具积累和来之不易的纪律 —— 迁移(migrations)、版本化架构(versioned schemas)、回填任务、双写窗口。而智能体内存只有一个 JSON 文档和一个追加调用(append call)。两者之间的差距,就是你智能体历史记录的葬身之地。

内存是无人监管架构的数据库

看看内存存储实际上是如何构建的。版本 1 是附加到每个用户文档的字符串列表。版本 2 增加了时间戳,因为检索时新鲜度很重要。版本 3 将字符串拆分为类型化记录,因为智能体需要更新单个事实,而不是重写整个 blob。版本 4 增加了 confidence 评分,然后是 source 字段,接着在发现内存互斥时增加了 superseded_by 指针。每一步都是由最接近代码的人发布的微小且合理的改进。

在整个过程中,没有人编写过迁移脚本。由于存储是只增不减的,旧记录只是保持原来的形状。一年后,你拥有一个包含四五种不同记录格式的集合,只能通过碰巧存在的字段来区分。架构(schema)没有在任何地方定义 —— 它是写入代码曾输出过的所有形状的并集,而唯一“了解”架构的系统是读取端,它现在已经变成了一堆没人能完全理解的 if "entities" in record 检查。

真正的数据库会强制你思考这个问题。你不能在已填充数据的表中添加一个不可为空(non-nullable)的列,而不决定如何处理现有行;迁移工具会迫使你明确地处理它。智能体内存则没有这种强制约束。文档存储接受你写入的任何内容,检索端通过返回 None 来容忍缺失字段,下游的 LLM 也足够配合,能从部分输入中生成看似合理的答案。技术栈中的每一层都各自分担了“容错”,而这种容错的总和就是一个你再也无法推导其内容的存储系统。

解决这一问题的思路既枯燥又正确:你的内存存储就是一个生产环境数据库。它有架构。架构有版本。更改它就是一次迁移,而忽略现有数据的迁移是一个 Bug,而不是一种简化。

架构变更悄悄破坏过去的三个方式

当旧记录遇到新代码时,破坏会以三种形式呈现,而且检测难度递增。

无法读取(Unreadable) 是最幸运的情况。新的读取端期望得到一个旧记录没有的字段,触发了 KeyError 或空值,随后抛出异常或跳过该记录。这是你想要的失败,因为它动静很大。跳过的记录会表现为覆盖范围缺口;异常会出现在你的错误追踪器中。你丢失了内存,但你察觉到了。

静默误读(Silently misinterpreted) 情况更糟。新旧架构有足够的重叠,使得新的读取端在不报错的情况下接受了旧记录,但解读错误。典型的例子是单位或枚举(enum)的变化:旧记录以秒为单位存储 duration,新记录以毫秒为单位,那么迁移前的内存现在会声称某项操作耗时太长了 1000 倍。或者你重命名了一个枚举值,导致旧字符串回落到了默认值。智能体吸收了一个自信、格式正确但完全错误的事实,并据此进行推理。没有任何错误日志,因为从结构上讲,没有任何错误。

静默丢弃(Quietly dropped) 是导致周二发布出问题的原因。记录在技术上是可读的,但某个过滤器 —— 检索查询、相关性阈值、或者“仅保留使用当前模型向量化(embeddings)的内存”的保护机制 —— 把它排除了。内存依然存在于存储中,只是再也不会出现。你智能体的有效内存缩减了,而你的存储账单却显示一切正常。唯一的症状是回答质量缓慢下降,而这几乎不可能归因于三周前的架构变更。

贯穿始终的核心问题是:流水线末端的 LLM 会掩盖这三种情况。它能根据接收到的任何上下文生成流畅的输出,因此受损或截断的内存集不会像受损的数据库行导致报表崩溃那样发出警报。你需要在模型察觉之前捕捉到这些问题,因为模型并不会报错。

对记录进行版本控制,而不只是代码

第一个具体的修复几乎不费分毫,而且你应该在第一天就完成:在每条内存记录上添加一个 schema_version 整数。不是用来推断模式的时间戳,也不是基于存在哪些字段的猜测 —— 而是一个明确记录的整数,说明 “此记录是由版本 N 的写入器生成的”。

这个单一字段将问题从考古转变为分发。你的读取器不再嗅探 entities 字段是否存在,而是开始根据已知的版本号进行分支。你可以编写一个真正的 migrate(record) 函数 —— 一系列小的升级步骤,v1 → v2 → v3,每一步都是一个纯函数转换,你可以针对真实旧记录的 fixture 进行单元测试。当一条版本 2 的记录进入时,你让它运行步骤 2 → 3 和 3 → 4,并交给读取器一个保证是当前形状的记录。读取器只看到最新版本,这意味着 if field in record 的丛林坍塌成一条整洁的代码路径。

它还使无法迁移的情况变得可见。有些 v1 → v2 的步骤是无损且琐碎的 —— 重命名字段,给新字段设置默认值。其他的则不然:如果 v2 将自由格式的字符串拆分为结构化实体,那么就没有纯函数可以恢复这些实体,因为这些信息从未被捕获。版本控制迫使你在设计迁移时面对这一点,而不是在生产环境中才发现。你要么明确接受损失,要么花一次 LLM 调用从旧的 blob 中重新提取结构并标记结果为低置信度。两者都可以。但意外地什么都不做则不行。

借鉴数据库实践中的其余纪律。扩展-收缩模式(expand-contract pattern)—— 也称为并行变更(parallel change)—— 是对实时存储进行任何破坏性变更的安全形状。扩展 (Expand):在旧字段旁边添加新字段,并让写入器同时填充两者。迁移 (Migrate):将历史记录回填到新形状中。收缩 (Contract):一旦所有读写都使用新字段,就删除旧字段。在每个阶段,旧代码和新代码并存,你可以回滚。版本字段是使每个阶段都可观察的关键:你可以查询每个版本还剩下多少条记录,并观察迁移的收敛情况。

读时惰性迁移与批量回填

一旦有了版本化记录和迁移函数,你就面临整个问题中唯一真正的架构决策:迁移到底什么时候运行?

读时惰性迁移 (Lazy migrate on read) 在获取记录时运行 migrate(),并可选地将升级后的记录写回。它能立即交付 —— 没有批处理作业,没有维护窗口 —— 并且它永远不会在没人读取的记录上花费计算资源,当大多数内存是冷的且再也不会被检索时,这一点很重要。然而,代价也是真实的。在存储收敛之前,每次读取都会带来迁移开销。从未被读取的旧记录永远不会被升级,因此你必须永远保留每一个历史迁移步骤 —— 你永远不能删除 v1 → v2 的代码,因为一条 v1 记录可能会在五年后出现。而且扫描原始存储的分析工具仍然会看到旧的形状,因为没有任何东西迁移了它们。

批量回填 (Bulk backfill) 运行一个批处理作业,遍历整个存储并将每条记录重写为当前版本。之后,存储是统一的:读取是干净的,旧的迁移代码可以退役,分析是一致的。代价是操作上的。这是一个针对生产数据集的迁移任务,需要所有通常的谨慎 —— 分成几千条记录的小块,在批次之间休眠以免 I/O 饱和,使其可恢复以便中途失败时不会从零开始,并处理在作业运行时并发写入的记录。

在生产环境中实际有效的模式是两者按顺序进行。首先发布读时惰性迁移,以便新代码从部署那一刻起就是正确的,并且没有记录会以过时的形状被读取。然后在后台运行批量回填以收敛长尾的冷记录。当回填报告低于当前版本的记录为零时,你可以删除最旧的迁移步骤并进行简化。惰性迁移立即为你带来正确性;批量回填为你换取了以后清理的权利。跳过回填是常见的错误 —— 系统运行正常,所以没人资助这项工作,结果你背负了十年的迁移代码积累,作为你选择不偿还的债务的永久利息。

你无法编写的迁移

有两种情况值得警惕,因为它们看起来像模式问题,但作为模式问题是无法完全解决的。

第一种是语义漂移 (semantic drift)。你改变的不是字段的形状,而是它的含义 —— priority 以前由启发式算法分配,现在由模型给出;category 以前对提取提示词意味着一件事,在提示词重写后意味着更狭窄的一件事。记录在结构上在前后是相同的。没有迁移函数能区分它们,因为差异不在数据中,而是在产生数据的过程中。唯一的防御措施是将含义变更也视为版本升级 —— 给记录打上创建它们的提示词或提取器版本的印记,这样未来的读取器至少可以知道它在看哪种语义,即使它无法在它们之间进行转换。

第二种是嵌入层 (embedding layer)。如果你的内存存储使用向量检索,并且你升级了嵌入模型,那么所有升级前的向量都生活在不同的坐标空间中,与新向量不可比。没有一种转换能将旧向量迁移到新空间中 —— 唯一的真正修复是重新嵌入源文本。这是一种不同类型的回填任务,前提是你仍然在向量旁边存储了原始文本。如果你为了节省空间而丢弃了源文本,那么这些内存根本无法迁移;在你更换模型的那天,它们就彻底丢失了。保留源文本。它是你唯一真正无法重新生成的。

像对待数据库一样对待记忆

令人不安的总结是,“智能体记忆”是一个持久化的、有 schema 的、持续增长的数据存储,而它之所以没有得到数据库级别的规范约束,唯一的原因是它是通过 AI 功能之门进入的,而不是通过数据基础设施之门。这里的工程设计并没有什么新颖之处。版本化记录、升级链、扩张-收缩、延迟读取加批量回填、保留源数据以便重新计算衍生数据——这些都是数据领域多年前就已确定的标准实践。

在进行下一次记忆 schema 变更之前,有三件事要做。现在就添加一个 schema_version 字段,即使目前每条记录都是版本 1,因为你无法回溯性地为那些已经盲目写入的记录添加版本。将 schema 变更编写为显式的、经过测试的迁移函数,而不是在读取器中编写防御性的 if 语句。并且为任何衍生内容——嵌入、提取的实体、摘要——保留原始文本,因为在你改进生成过程的那天,衍生形式就会过时,而原始文本是让你能够重建它的唯一依据。你发布更好的记忆格式的那天,应该是你的智能体记住更多内容的那天,而不是它悄悄遗忘之前所有内容的那天。

References:Let's stay in touch and Follow me for more thoughts and updates