在事件溯源系统中,去重标记设24小时TTL是常见做法,但由它推导出的数据并不随之过期。当回填重新读取旧事件时,标记已失效而事实已存在,导致同一事实被写入两次,无任何报错。
这是一个在任何地方都不会报错 bug:内存存储会慢慢被同一个事实填满两次。

你处理一个事件,写入一个去重标记,并设置 24 小时 TTL,使标记表不会无限增长。每个指南都告诉你这样做,对于"投递级别"的幂等性这是正确的建议。然后一次回填重新读取了去年的一张工单。标记早在几个月前就已过期,因此 ingest 层对它没有任何记忆。但从该事件派生的事实仍然在存储里,因为派生事实不会按计划过期。
现在你有了两份。没有任何东西报错。
我翻阅了收集的论文、演讲和实践者文章,"幂等"这个词在其中承载了四种互不兼容的含义。每个建议使用去重标记的来源都同时建议在标记上设置 TTL,但没有一篇讨论过"会过期的标记"与"永不过期的派生事实"之间的相互作用。
请求级别(Request-level):重复同一个请求,在第一次之后状态保持不变。HTTP 版本是熟悉的,RFC 9110 在 9.2.2 节定义了它:GET 和 DELETE 天生是幂等的,POST 不是,PATCH 是否幂等完全取决于它做什么。PATCH 将 name 设为 "DJ" 是幂等的。PATCH 递增一个计数器则不是。同样的动词,完全相反的保证。
投递级别(Delivery-level):同一个事件到达两次,只执行一次。大多数事件驱动系统指的是这个级别,并用标记来强制执行。
合并级别(Merge-level):合并函数本身是幂等的,同时满足结合律和交换律,因此副本无论到达顺序或重复次数如何都会收敛。这是 CRDT 属性。
派生级别(Derivation-level):用相同的输入重新运行派生操作,不会产生新的派生记录。这个级别对 Agent 记忆至关重要,但几乎没有人给它命名。
满足其中任何一个定义,都无法告诉你关于其他三个定义的任何信息。一个系统可以完全具备"投递幂等性",但仍然会积累重复的事实——正是上述机制造成的。
重试是显而易见的来源。即使是一个承诺"恰好一次"投递的 broker,也无法阻止上游服务在两个不同的事件 ID 下发布同一个逻辑事件,这就是为什么 ID 检查通常会配上对订单号之类业务键的检查。
对记忆系统特定的是,重同步不是故障。从 GitHub Issues、Slack 线程或支持工单拉取数据的连接器按计划运行,每次都会重读重叠的窗口,这是设计如此。回填会重读所有内容。这两者都不是错误条件,所以把重复抑制当作错误处理会把检查放在错误的位置。
停止生成键,开始从事件的逻辑身份派生键。派生键永久保存几乎没有成本,因为它是记录的属性,而不是一个不断增长的外挂表的行。
github:acme:api-server:issue:4471:opened
来源、组织、仓库、记录类型、Issue 编号、事件类型。重新同步那个 Issue 一千次,你得到的只是一个 episode。不需要 UUID,不需要时间戳,不需要 TTL。
同属一个连接器套件的另一个文件里,IDE 伴侣使用的是相反的规则:幂等性是内容寻址的,所以重新运行一个未改变的工作区扫描会映射到同一个键,而改变了的工作区会产生新的记忆。
同一个代码库里两种相反的策略,两者都是对的。GitHub Issue 拥有独立于其文本的稳定身份,所以以身份作为键是正确的——编辑后的标题不应该产生第二个 episode。工作区扫描除了其内容之外没有身份,所以以内容哈希作为键是正确的——改变了的工作区应该产生一个新的。
你的键编码了对"同一个事件"的定义,而这个定义是一个按来源而定的决策。一个方向错了会淹没存储。另一个方向错了会悄悄丢弃真正的更新。
Ingest 去重只覆盖 ingest 阶段。编译步骤需要自己的保证,而这来自一个设计选择:从完整的 episode 日志派生当前状态的编译过程,其行为类似一个集合。本次运行发现什么就追加什么的编译过程,其行为类似一个增量,而增量永远不是幂等的。
这个属性使得编译端点可以安全地从重试循环、cron 作业和 webhook 处理器同时调用。它还让你可以把编译批处理移到请求路径之外,而不是在每次对话后内联运行。
用一行代码测试它:
def test_recompile_is_a_noop(client, subject_id):
first = client.post("/v1/memories/compile", json={"subject_id": subject_id})
assert first.json()["memories_created"] > 0
second = client.post("/v1/memories/compile", json={"subject_id": subject_id})
assert second.json()["memories_created"] == 0
整个测试就是那个第二个数字。如果它不是零,你就有了一个本应是派生却做成了追加的问题,再多的 ingest 去重也救不了你。
重复是两条记录说了同样的事情,解决方案是保留一条。冲突是两条记录说了不同的事情,但都是合法写入的,不存在一个明显正确的"保留一条"版本。
幂等性对第二种情况毫无作用。Martin Kleppmann 的冲突处理分类法仍然是最简洁的:让人类解决、自动选一个赢家、或者自动合并。关于中间选项,他精确地指出了成本:一些系统选择一个版本作为赢家并丢弃其他版本。
丢弃输家是大多数 Agent 记忆栈的默认设置,而这正是损害发生的地方。你不只是丢失了旧值。你丢失了曾经存在分歧的证据。
"近者胜出"几乎是普遍的自动规则,对于确实会改变的状态(比如一个团队运行哪个数据库)是没问题的。对于一个被错误提取错误断言的稳定属性,它是错误的。对于那些只是看起来矛盾的 fact也是如此:"我这周在柏林"并不与"这个用户住在里斯本"矛盾,而一个用前者覆盖后者的系统让记忆变得更差了。
将旧 fact 标记为 superseded,在读取时将其过滤掉,但在记录中保留它并带上指向两边的出处链接。三种状态覆盖它:active、superseded、tombstoned。
一个我可以指向的实际例子:三个 Agent 并发读取三个源文档并写入一个共享主题。其中一个提交了 Stripe 预逆转处理费率,另一个提交了更正值。当两条记忆带有注册的单值声明键时,编译器直接比较声明。否则它会回退到两条编译后记忆之间的 Jaccard 词重叠度,达到 0.6 或以上时将较旧的那条标记为 superseded,并将决策记录下来,链接到两个源 episode。
在下游,合成 Agent 的 bundle 只包含赢家。审计追踪包含两者,加上触发这次调用的相似度得分——在发布的运行中是 0.72。
把 0.6 看作一个旋钮而不是常数。设得更低,不相关的事实会碰撞,产生误报 supersession,把真实信息从读取路径中删除。设得更高,真正矛盾的两者都存活到 bundle 中,所以你得花 token 让模型面对两个不兼容的答案。
词重叠是回退方案,不是机制。Jaccard 能捕捉到"Stripe charges 3.5% + 35c"和"Stripe charges 2.9% + 30c"之间的差异。它偶尔会对共享常用词的不相关事实触发,而且无法捕捉到用完全不同的词汇表达的矛盾。
幂等不等于确定。运行一个 LLM 编译器,提取步骤可能在不同运行中对相同的 episode 产生不同的事实。幂等编译意味着对已处理 episode 的第二次传递不会添加任何新内容。它不意味着第一次传递是可复现的。基于正则表达式的启发式编译器则是可复现的。
有些冲突不应该被解决。两个相互矛盾的高置信度声明可能意味着边界条件不同。如果在你的领域里犯错代价高昂,把它们标记出来并放弃自动解决,而不是强行自动解决。
这些都解决不了糟糕的提取。对低质量事实做去重、superseding 和审计,给你的是一个干净的、有良好审计的、低质量事实存储。
连续运行两次编译步骤,然后看第二个数字。如果它创建了任何东西,那就是你要在所有其他工作之前修复的 bug。
如果你不想自己构建键派生、编译标记和 supersession 逻辑,这篇文章示例背后的运行时在 GitHub 上是 Apache-2.0 许可,运行在你自己的 Postgres 上。这篇文章的完整版本涵盖了每种类型的冲突策略表和我跳过的重放边界。