智能体记忆 Schema 演进:Protobuf 的困难模式
第一次痛苦的智能体记忆(agent-memory)迁移总是教会我们同一个教训:存在两个模式(schema),而你只迁移了其中一个。存储层没问题 —— 每一行都已重写,每个键(key)都是新的形态,回填(backfill)作业也记录了成功。但智能体还是坏了。它继续向 user.preferences.theme 写入,却检索不到任何内容,然后从上下文中煞有介事地合成一个默认值,就好像这个键从未存在过一样。迁移操作手册显示一切正常。用户却报告记忆过时。
这种不对称是结构性的。一个依赖于重命名列的传统服务会收到硬错误,然后你进行修复。而一个依赖于重命名记忆键的智能体则会遇到软缺失,并围绕它进行胡编乱造。模式存在于两个地方 —— 你的存储和模型的上下文 —— 而你只能通过 SQL 脚本迁移其中的一个。
Protobuf 在二十年前通过规范化“仅限增加”的准则解决了这类问题的一个变体:字段是永恒的,数字是永恒的,网络类型永远不变,删除被弃用(deprecation)所取代。这一准则是智能体记忆的一个良好起点,但有一个额外的约束使其变得更加困难。Protobuf 接收者在设计上 会忽略未知字段。智能体则不会。
为什么“记忆存储拥有模式”是你后知后觉才领悟到的深刻见解
大多数团队构建智能体记忆的方式都大同小异:一个键值存储,一个用于语义回想的向量索引,以及一个让模型可以用任何它喜欢的键进行读写的轻量级 SDK。这里没有 DDL,没有迁移表,也没有检入仓库的版本化模式文件。模型和应用程序代码收敛于一组工作的键 —— user.profile.name、tasks.open[*].deadline、meeting_notes.2026Q1 —— 这些键在数周的生产流量中硬化成了一种隐式契约。
这就是一种模式。它只是没有写在任何代码评审能捕捉到的地方。
问题在有人第一次尝试清理它时浮现。一位开发人员注意到 user.profile.name 和 user.full_name 存储的是相同的数据,于是选择其中一个并运行了回填。存储层现在有了统一的键。智能体却没有。它仍然同时向两者写入 —— 有时用旧名,有时用新名 —— 因为数月的上下文历史向它展示过这两个键,而系统提示词中的 few-shot 示例向它展示了旧的那个。更糟糕的是,针对旧键的检索现在会失效,因为数据已经存在于新名称下。从用户的角度来看,智能体突然忘记了它曾经知道的一半内容。
团队痛苦地总结出的教训是,智能体使用的键并不是存储层的实现细节。它们是模型提示词的一部分,通过每一次检索到的记忆、每一个 few-shot 示例、每一段被导入回上下文的对话摘要得到强化。迁移存储而不迁移提示词,就像迁移数据库列而不迁移查询它的应用程序一样,是同一种错误 —— 只不过这里的应用程序是一个概率分布,你无法对其进行 grep 搜索。
移植到记忆中的 Protobuf 规则
Protobuf 关于安全模式演进的规则归结为一小组不变性:不要更改字段编号,不要更改网络类型,不要重复使用已退役的编号,永远不要将字段设为必填,并且宁可弃用也不要删除。其基本原则是二进制编码契约是不可变的;你只能添加旧读取器可以忽略的内容。
将这些规则移植到智能体记忆中,规则如下:
- 键是永恒的。 一旦智能体向
user.preferences.theme写入过内容,该路径就被预留了。即使你停止向其写入,也不能将其重用于不同的字段。 - 键的类型是永恒的。 如果
user.preferences.theme是一个字符串,它就永远是字符串。将其更改为结构化对象会破坏每一个将旧形状返回到上下文的检索,因为模型会根据错误的形状进行模式匹配。 - 增加,不要修改。 新的首选项形状应存在于一个新键下(
user.preferences.theme_v2或user.preferences.appearance)。旧键继续存在,可能由翻译层填充。 - 弃用是一个状态,而非一个事件。 只要存在任何引用该键的上下 文示例或摘要,弃用的键在读取时仍需能够解析。这个时间窗口是以月计的,而不是以天计。
- 删除需要证据。 在你实际删除一个键之前,你需要追踪数据证明模型在所有活跃会话和所有摘要重新生成中都已停止引用它。
这种准则与 Protobuf 有异曲同工之妙,但约束更强,因为接收者 —— 模型 —— 无法就地打补丁。使用 Protobuf,你发布一个新的生成的客户端,旧的字段处理程序就消失了。而对于智能体,每一个将旧键加载到上下文中的对话依然存在,明天的会话可能会加载一段引用它的摘要。你是在针对一群已经记住了旧 API 的受众进行迁移。
架构图上没人画出来的强化向量
当团队绘制 Agent 记忆系统的架构图时,他们通常会画一个存储框、一个指向写入路径的箭头、一个指向检索路径的箭头,以及一个模型框。图中遗漏的是 schema 存在的每一个其他地方。
Schema 还存在于:
- 系统提示词中的 few-shot 示例:这些示例通常会硬编码键名(key names)以演示读/写 API。
- 上下文中的对话历史:它会逐字重现过去的工具调用及其参数。
- 摘要记忆(Summary memory):之前的交互被压缩成散文,其中会指名道姓地提到键名。
- 反思或自我改进循环:这些循环生成的计划会引用 Agent 预期找到的键。
- 工具描述:其中通过 示例负载(payloads)记录了记忆操作。
每一个都是 schema 的独立副本。每一个都在强化模型对于存在哪些键以及它们具有什么形状的认知。如果你迁移了存储层而让这些地方保持原样,模型将继续针对旧契约运行——有时成功(因为旧键仍然有效),有时静默失败(因为无效),几乎从不报错。
这是 Agent 记忆迁移被称为“困难模式下的 protobuf”的结构性原因。对于 protobuf,新 schema 意味着新构建。对于 Agent,新 schema 意味着你需要找到模型上下文窗口中所有强化旧 schema 的地方,并进行更新或使其过期。没有任何编译器会告诉你漏掉了哪些地方。
影子写入、行为双跑迁移方案
数据库迁移中的影子写入(shadow-write)模式可以直接借鉴,但需要增加一个关键补充。零停机数据库迁移的标准方案大致是:在旧列旁添加新列,同时向两者写入,根据旧数据回填新数据,将读取切换到新列,在验证期内进行双读,然后删除旧列。向量数据库迁移也遵循同样的模式,在后台填充 embedding_v2 列,同时实时读取仍命中原始列。
对于 Agent 记忆,该方案适用于存储层,但在读取切换后迁移并未完成。你还需要在其之上进行行为双跑(behavioral dual-run)。
行为双跑是这样的:在存储层对两种形状进行双写后,你针对两种配置重放代表性的近期 Agent 追踪(traces):旧配置(带有引用旧键的提示词和 few-shot 示例)和新配置(更新为引用新键的提示词)。你不仅要比较最终答案,还要比较中间工具调用、Agent 尝试读取的键以及尝试写入的键。这些中间步骤中的差异就是你迁移中遗漏的部分——即仍在教导模型旧 schema 的强化表面。
你不能跳过这一步。追踪重放是揭示模型实际行为的唯一方法,因为模型的行为取决于完整的上下文窗口,你无法通过静态证明来确保下游的某些摘要不会在未来的提示词中重新引入旧键。重放使隐式契约变得可执行。
对应的切换规范如下:
- 阶段 1(增量阶段):同时向旧键和新键双写,读取仍指向旧键,提示词保持不变。无行为变化。验证回填的正确性。
- 阶段 2(影子读取):将一小部分读取路由到新键,记录与旧键相比的检索不匹配。进行调整,直到不匹配为零或得到解释。
- 阶段 3(提示词切换):更新 few-shot 示例和工具描述以引用新键。重放代表性追踪。将工具调用序列与基准进行比较。迭代直到分歧在可控范围内。
- 阶段 4(摘要重新生成):重新生成任何引用旧键的缓存摘要或反思。这是大多数团队会跳过的步骤,然后他们会花一个季度的时间纳闷为什么 Agent 偶尔会“想起”旧 schema。
- 阶段 5(弃用观察):保持旧键解析一段验证期,时间要长到足以覆盖最长的实时会话和最长的摘要生成周期。只有在那之后,退役该键才是安全的——即便如此,从 protobuf 的意义上来看,该键编号(key number)应永久保留。
这从根本上改变了你构建记忆的方式
经历过此类迁移的团队会以不同的方式构建第二代记忆层。出现的模式与 protobuf 一样保守。
将记忆键视为版本化的、经过审查的 schema 产物。在你的提示词旁边签入一个 memory_schema.yml。引入新键的拉取请求(PR)应该像新增数据库列一样接受代码审查,而不是像新增变量名那样随意。Schema 文件是 few-shot 示例、工具描述和验证层读取的唯一事实来源。
有意识地对键进行命名空间划分。LangGraph 的 (namespace, key) 元组模式——例如 ("memories", user_id)——在你第一次需要迁移时就会体现出价值,因为你可以将迁移范围限制在某个命名空间内而不影响其他部分。扁平的键空间迫使每次迁移都变成全局性的。
只增加字段,永不重构(mutate)字段。当你发现自己想要更改某个键的类型时,请接受你实际上是在添加一个新键,并在漫长的弃用期内进行两者之间的转换。转换层很便宜,而重写不便宜。
在需要之前就将追踪重放构建到部署管道中。如果你第一次尝试在事故期间进行行为双跑,你会发现你无法确定性地重放追踪,因为没有捕获工具输出,或者提示词没有随追踪一起进行版本化,或者会话 ID 没有传播。在平静的一周里解决这个问题,而不是在迁移期间。
将检索缺失(retrieval misses)作为一级信号进行监控。缺失并不是数据的缺失——它是系统中某些表面仍在引用已失效键的证据。将每个键的缺失计数对接到跟踪模型延迟和工具错误的同一个仪表板中。如果一个已弃用的键在你认为已经清理完毕后开始出现读取,那么某些东西(长效摘要、过时的 few-shot)正在重新引入它。
核心要点
Agent 记忆看起来像是一个上方运行着模型的键值存储(key-value store)。它的行为就像一个分布式系统,其中一个参与者是已经记住了你旧版 API 的概率分布。在这种情况下,Schema 演进是一种纪律,而不是存储层的一项功能。
Protobuf 的策略——仅限增加、编号永恒、以弃用代替删除——是正确的起点,因为它正是针对 Agent 记忆所带来的约束而设计的:你无法原子化地更新每一个读取器。额外的约束在于,你的一个读取器会通过之前的每一次对话、每一个 few-shot 示例以及你缓存过的每一个总结得到强化。那个读取器无法被修复。它只能通过改变每一个向其传达 Schema 的界面,在上下文中进行重新训练。
学习这一课最廉价的方式是在第一天就把 Schema 写下来并将其视为一份契约。而昂贵的方式则是通过一次数据迁移来让你意识到这一点。
- https://earthly.dev/blog/backward-and-forward-compatibility/
- https://protobuf.dev/programming-guides/editions/
- https://yokota.blog/2021/08/26/understanding-protobuf-compatibility/
- https://docs.langchain.com/oss/python/langgraph/memory
- https://langchain-ai.github.io/langmem/
- https://blog.langchain.com/langmem-sdk-launch/
- https://dev.to/googleai/migrating-vector-embeddings-in-production-without-downtime-5bli
- https://www.infoq.com/articles/shadow-table-strategy-data-migration/
- https://datalakehousehub.com/blog/2026-02-de-best-practices-05-schema-evolution/
- https://arxiv.org/pdf/2502.12110
