Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›AI 应用的记忆写侧工程 2026:抽取、晋升与冲突消解

Index

  • 一、为什么写侧是记忆系统里最难的部分
  • 1.1 全站认知盘点:这个象限确实是空白
  • 1.2 一手事实源:三家已经把写侧做成产品
  • 二、抽取器:写入决策的第一个闸门
  • 2.1 用 F1 找阈值,但要知道 F1 不是最终目标
  • 2.2 误写的期望代价:为什么阈值要往高处调
  • 2.3 否定词、引用、假设:一个容易被系统性忽略的抽取失效模式
  • 三、去重:写入的第二道闸门
  • 3.1 为什么必须在写侧去重
  • 3.2 去重阈值扫描
  • 3.3 去重的实现层次
  • 四、晋升:一条记忆凭什么从会话级变成用户级
  • 4.1 晋升漏斗的量化
  • 4.2 两种晋升触发器的对比
  • 4.3 作用域即安全边界
  • 五、冲突消解:用户改了主意,系统听谁的
  • 5.1 四种消解策略的实测
  • 5.2 冲突消解的数据结构要求
  • 5.3 冲突消解的三个常见坑
  • 六、过期与衰减:记忆不是只增不减的
  • 6.1 统一 TTL 的困境
  • 6.2 分层 TTL:按稳定性分类
  • 6.3 三种过期信号的组合
  • 七、污染的自强化:为什么写侧错误会指数放大
  • 7.1 自强化循环的模型
  • 7.2 抑制效果
  • 7.3 一个必须做对的细节:写侧闸门必须拦"从记忆中提取的陈述"
  • 八、可见性与台账:让写侧可以被用户和工程师审计
  • 8.1 用户可见的记忆面板
  • 8.2 记忆台账(audit ledger)
  • 8.3 扇出:写一次要触达几处,删一次也要触达几处
  • 8.4 台账作为回归测试语料
  • 九、统一架构:把写侧做成一条可审计的管线
  • 9.1 一个具体的实现骨架
  • 9.2 成本:写侧要不要花 token
  • 9.3 反模式清单
  • 9.4 落地路线
  • 参考文献
  • 一句话摘要

AI 应用的记忆写侧工程 2026:抽取、晋升与冲突消解

决定 AI 应用记忆质量下限的是写侧而非读侧——一条信息凭什么被记住、被合并、被提升作用域又如何被改回。本文把写侧拆成抽取、归一、闸门、消解、晋升、过期、台账七个可审计决策点,对照 Anthropic memory tool、Mem0、Letta 一手文档核对做法。文中数值均现场实算:分层 TTL 比统一 TTL 降 48.3% 代价,显式纠正优先的消解正确率是最后写入胜的 1.51 倍。

2026年10月30日·约 63 分钟阅读·18,801 字·2 次阅读·博主
#智能体与 AI 应用开发
AI 应用的记忆写侧工程 2026:抽取、晋升与冲突消解

Index

  • 一、为什么写侧是记忆系统里最难的部分
  • 1.1 全站认知盘点:这个象限确实是空白
  • 1.2 一手事实源:三家已经把写侧做成产品
  • 二、抽取器:写入决策的第一个闸门
  • 2.1 用 F1 找阈值,但要知道 F1 不是最终目标
  • 2.2 误写的期望代价:为什么阈值要往高处调
  • 2.3 否定词、引用、假设:一个容易被系统性忽略的抽取失效模式
  • 三、去重:写入的第二道闸门
  • 3.1 为什么必须在写侧去重
  • 3.2 去重阈值扫描
  • 3.3 去重的实现层次
  • 四、晋升:一条记忆凭什么从会话级变成用户级
  • 4.1 晋升漏斗的量化
  • 4.2 两种晋升触发器的对比
  • 4.3 作用域即安全边界
  • 五、冲突消解:用户改了主意,系统听谁的
  • 5.1 四种消解策略的实测
  • 5.2 冲突消解的数据结构要求
  • 5.3 冲突消解的三个常见坑
  • 六、过期与衰减:记忆不是只增不减的
  • 6.1 统一 TTL 的困境
  • 6.2 分层 TTL:按稳定性分类
  • 6.3 三种过期信号的组合
  • 七、污染的自强化:为什么写侧错误会指数放大
  • 7.1 自强化循环的模型
  • 7.2 抑制效果
  • 7.3 一个必须做对的细节:写侧闸门必须拦"从记忆中提取的陈述"
  • 八、可见性与台账:让写侧可以被用户和工程师审计
  • 8.1 用户可见的记忆面板
  • 8.2 记忆台账(audit ledger)
  • 8.3 扇出:写一次要触达几处,删一次也要触达几处
  • 8.4 台账作为回归测试语料
  • 九、统一架构:把写侧做成一条可审计的管线
  • 9.1 一个具体的实现骨架
  • 9.2 成本:写侧要不要花 token
  • 9.3 反模式清单
  • 9.4 落地路线
  • 参考文献
  • 一句话摘要

AI 应用的记忆写侧工程 2026:抽取、晋升与冲突消解

大多数关于 AI 应用记忆系统的讨论都停在读侧。

我们花了大量篇幅讨论检索该用什么嵌入模型、向量库选 pgvector 还是 Qdrant、rerank 放 cross-encoder 还是 ColBERT、相似度阈值调多少、top-k 取几、要不要做混合检索。这些问题有一半是合理的,因为记忆系统最终的目的确实是在正确的时刻把正确的信息送进上下文窗口。但另一半时间,我们在讨论一个更根本的问题时把它悄悄略过了:这个信息凭什么在记忆里。

一句用户随口说的"我最近在看 Rust 的异步运行时",被抽取器判定为"值得记住",写进了长期记忆库。三个月后,另一个话题触发检索,这条记忆被注入上下文,模型据此回答"你最近在研究 Rust 的异步生态"——用户早就不看了。更糟的情况是,用户当时说的是"我不看 Rust",抽取器漏掉了否定词,一条彻底相反的记忆被固化下来,此后每一轮相关对话都在被误导。

这类故障的特征是:读侧的所有指标都正常。召回率没降,rerank 分数没掉,相似度分布没变,检索延迟没涨。问题出在几个月前某一次写入决策上,而那次决策没有任何埋点、没有台账、没有用户可见的入口。

本文要处理的就是这个被长期忽略的半边:记忆系统的写侧——从一次对话到一条入库记忆之间,要经过哪些决策点,每个决策点可以用什么度量来调优,以及当记忆出错时用户如何自己把它改回来。

一、为什么写侧是记忆系统里最难的部分

读侧是幂等的,重复执行同一个查询得到同样的结果,这使得读侧的全部工作都可以用离线指标来评估:给定一个标注好的查询集合,算召回率、算 NDCG、调阈值、切模型。写侧不是幂等的,而且更重要的是,写侧的错误会潜伏。

一次错误的写入有两个时间尺度上的影响。在写入的那一刻,它的成本几乎为零——数据库多了一行,向量索引多了一个点,没有报警,没有异常,甚至没有人在看。在之后很长的时间里,它被检索到、被注入上下文、被模型基于它做出回答,每命中一次就放大一次代价,而且这些代价分散在不同会话里,无法归因回那一次写入。

这带来一个工程上很不舒服的结论:记忆系统的质量下限由写侧决定,而写侧的所有观测指标都是延迟的、聚合的、事后归因的。

还有一个更本质的不对称。读侧的失败模式是"没找到",用户会立刻反馈——"它明明知道这个怎么没想起来"。这类失败是显性的、快速的、可在线检测的。写侧的失败模式是"找到了错的",用户可能根本不会注意到,因为模型顺着错误记忆给出的回答本身是连贯的、自信的、看起来合理的。这类失败是隐性的、缓慢的,往往要等到某天用户说"你上次说我喜欢用 TypeScript,我明明用的是 Python"才暴露,而那时系统已经把这个错误传播了很多轮。

1.1 全站认知盘点:这个象限确实是空白

为了确认选题没有和站内既有文章撞车,我对全站 730 篇 published 文章的正文做了完整的离线词频扫描,分页用 ?pageSize=50&page=N 逐页拉取列表后对每篇单独 GET 正文,共 321 万中文字符。五轮扫描的结论是:

写侧的高信号词几乎全站零命中。记忆写入 5 次、记忆抽取 5 次、记忆晋升 2 次、记忆去重 0 次、记忆冲突 0 次、显式更正 0 次、用户更正 0 次、记忆可见 0 次、记忆管理界面 0 次、记忆 TTL 0 次、记忆审计 0 次、记忆溯源 0 次、记忆导出 0 次、记忆迁移 0 次、偏好建模 0 次、画像更新 0 次、向量画像 0 次、可读记忆 0 次。

相比之下,读侧的词是饱和的:语义缓存 159 次、缓存命中 164 次、交叉编码器 2 次但 cross-encoder 55 次、召回 633 次、精确定率 11 次而 precision 106 次、top-k 121 次。

更关键的是层级错开的核查。站内最接近的几篇全都停在读侧:

  • id=823《AI 应用 memory 工程 2026:从滑动窗口到 vector memory 装配》——讲的是 context window manager、compressor(摘要式语义投影)、persistor(状态快照 + 索引双层),核心问题是"已经进入上下文的记忆怎么存怎么取"。
  • id=429《对话 AI 应用的长期记忆与人格一致性工程 2026》——讲的是四层存储模型、五因子检索打分、七轴评估指标,核心问题是"记忆取回来之后人格怎么保持一致"。
  • id=736《LLM 应用的长上下文压缩与记忆蒸馏工程 2026》——讲的是压缩算子横评(截断、抽取、生成、结构化),§5 涉及"用户级事实图谱与遗忘曲线",但那是遗忘侧,属于读侧的生命周期管理。
  • id=592《Agent 长时记忆的遗忘曲线理论 2026》——从 Ebbinghaus 出发讲被动衰减、主动遗忘、检索触发的再巩固,是理论文章,讲的是"已有的记忆如何衰减"。
  • id=561《AI 应用的协作模式与多人共编工程 2026》——§7 讲"操作归因与团队记忆",涉及多用户操作冲突,不是记忆内容冲突。

也就是说,全站已经有一整套"记忆怎么读、怎么衰减、怎么检索"的成熟讨论,唯独"一条记忆是怎么被决定写下来的、写下来之后用户怎么改"这件事没人系统写过。

1.2 一手事实源:三家已经把写侧做成产品

有意思的是,产业界已经把写侧做成了产品能力,只是工程文章没跟上。

Anthropic 的 memory tool 是最直接的证据。官方文档明确写了它的定位:"Let Claude store and retrieve information across conversations by implementing the memory tool's file operations in your application"。这个工具在 Messages API 里以 {"type": "memory_20250818"} 的形式启用,对 Claude 4 及之后的全部模型可用。它提供的命令集合是 view、create、str_replace、insert、delete、rename 六个——注意这是一整套文件操作原语,而不是一个抽象的 add_memory(text) 接口。str_replace 和 insert 意味着对已有记忆做局部编辑,delete 意味着记忆可以被移除,rename 意味着记忆有标识可以被管理。

Anthropic 官方博客《Bringing memory to teams》给出了产品层的三个关键决策。第一是作用域:如果你使用 projects,Claude 会为每个 project 创建独立的记忆,官方原话是 "This ensures that your product launch planning stays separate from client work, and confidential discussions remain separate from general operations. These project boundaries help you and your teams manage complex, concurrent initiatives without mixing unrelated details, serving as a safety guardrail that keeps sensitive conversations contained."——作用域隔离被明确当作安全护栏,不只是组织手段。第二是可见可编辑:官方用一份 memory summary 汇总所有记忆,"In your settings, you can see exactly what Claude remembers from your conversations, and update the summary at any time by chatting with Claude." 第三是不可记忆的旁路:Incognito chat 的对话不进入记忆,也不进入会话历史,"your regular memory and conversation history remain untouched"。这三个决策构成了写侧治理的最小完备集:隔离、可见、退出。

Mem0 把同一件事做成了显式的 API 语义。它的 add 操作文档把写入流程拆成三步:information extraction(用 LLM 从消息里抽出关键事实、决策、偏好)、additive storage(新记忆增量写入,不覆盖不删除已有记忆)、retrieval(后续检索时对查询排序)。它的 update 操作定义了 memory_id、text、metadata、timestamp、batch_update、immutable 六个字段,其中 immutable 标记的记忆不能被更新,只能删除后重新添加。delete 操作则定义了 delete、batch_delete(单次最多 1000 条)、delete_all(按 user/agent/run/metadata 过滤)三种粒度。

Letta 走的是另一条路,把记忆做成显式的、可见的、可被人直接编辑的块。它的 memory block 文档给出了四个关键属性:label(唯一标识)、description(用途描述,也是 agent 判断"何时读写这一块"的主要依据)、value(内容)、limit(字符数上限)。block 默认是可读写的,agent 通过内置 memory 工具自行更新;设 read_only: true 后 agent 无法改动——官方给出的典型用途是"共享的组织政策块,给 agent 访问权但不允许它做潜在破坏性的修改"。Letta 还警告了一个非常实际的坑:设置 value 是完全替换而非追加,"If multiple processes (agents or external scripts) modify the same block concurrently, the last write wins and overwrites all earlier changes."

这三个产品放在一起看,写侧的可选空间其实已经很清晰了:抽象层级有"文件原语"(Anthropic)、"结构化记录"(Mem0)、"带描述的文本块"(Letta)三档;治理粒度有单条、项目级、用户级、团队/企业级四层;可见性从完全黑盒到用户可编辑面板。

本文要做的事情,是把这三家的产品决策翻译成一套可以自己算、自己测、自己调的工程方法。

二、抽取器:写入决策的第一个闸门

写侧的第一道闸门是"这句话值不值得记"。这是一个二分类问题,有两个方向的错误:

假阳性(误写)——记下了不该记的东西。比如用户说"这次的方案我不太满意",抽取器把它记成"用户对方案不满意",但这可能只是这一次的临时感受,不是长期偏好。误写的记忆会持续产生干扰,而且因为它"看起来像"一条真实的偏好,检索时很难被识别为噪声。

假阴性(漏写)——该记的没记。比如用户明确说"以后所有代码注释都用中文",抽取器认为这是一次性指令。这类错误的代价看起来更低——漏记的记忆只是没被用上,用户顶多需要重述一次。但长期看,漏写率过高会让整个记忆系统的命中率崩塌,最终用户不再相信这个产品"记得事"。

这两个错误代价不对称,而且不对称的方向取决于记忆的类型。这正是后面第五节分层 TTL 的出发点。

2.1 用 F1 找阈值,但要知道 F1 不是最终目标

最直接的做法是给抽取器一个判定阈值,然后扫描阈值看精确率、召回率、F1 的变化。我构造了一条符合实测直觉的曲线(召回率随阈值升高而下降、精确率随阈值升高而上升),扫出来的结果:

thr=0.50  P=0.5200  R=0.9700  F1=0.6770  误写率=0.4800
thr=0.60  P=0.6222  R=0.8322  F1=0.7121  误写率=0.3778
thr=0.70  P=0.7244  R=0.6944  F1=0.7091  误写率=0.2756
thr=0.75  P=0.7756  R=0.6256  F1=0.6925  误写率=0.2244
thr=0.80  P=0.8267  R=0.5567  F1=0.6653  误写率=0.1733
thr=0.85  P=0.8778  R=0.4878  F1=0.6271  误写率=0.1222
thr=0.90  P=0.9289  R=0.4189  F1=0.5774  误写率=0.0711
thr=0.95  P=0.9800  R=0.3500  F1=0.5158  误写率=0.0200

F1 最优落在 0.60 附近(F1=0.7121),而 0.70 的 F1 是 0.7091,几乎持平。但这两个阈值之间误写率从 37.78% 掉到 27.56%——差了 10 个百分点。

这个差距值得展开,因为它是本文第一个真正重要的判断:F1 最优阈值通常不是生产最优阈值,只要误写的代价显著高于漏写。生产环境里应该选更高的阈值。

2.2 误写的期望代价:为什么阈值要往高处调

我建了一个简单的期望代价模型。一条误写的记忆进入库后,会在后续会话中被检索到并注入上下文。每次注入带来的后果有两部分:一次常规的用户纠正(约 2 分钟),以及一个概率性的决策事故(一次约 30 分钟的返工,模型基于错误记忆给出了错误建议,用户事后才发现)。设单条记忆的检索注入概率为 p_ret,后续会话数为 N:

N=  20 p_ret=0.05 → 期望命中=   1.0次  期望代价=   3.50分钟
N=  20 p_ret=0.20 → 期望命中=   4.0次  期望代价=  14.00分钟
N= 100 p_ret=0.10 → 期望命中=  10.0次  期望代价=  23.00分钟
N= 100 p_ret=0.20 → 期望命中=  20.0次  期望代价=  46.00分钟
N= 200 p_ret=0.40 → 期望命中=  80.0次  期望代价= 172.00分钟

关键观察是代价随 N 近似线性增长。这意味着一条误写记忆在第 1 天写进去和第 100 天写进去,最终的总代价是同一量级的——因为它是复利式的:每次被注入都可能催生新的误写记忆。这一点在第七节会展开。

于是抽取器的阈值问题可以转成一个明确的最优化:设抽取器在阈值 thr 下的误写率为 fpr(thr),漏写率 fdr(thr),每条候选事实的写入决策成本为 c_gate,则

净收益 = N_fact × [fpr_base − fpr(thr)] × cost_per_false − N_fact × c_gate − N_fact × fdr(thr) × cost_per_miss

我用一组参数算了一遍(1000 条候选事实,单条误写记忆期望代价 18 分钟,闸门开销 0.002 分钟/条):

原 fpr=0.10 → 闸门后=0.0333  误写条数 100→33  节约=  1200.0分钟  闸门开销=2.0分钟  净收益=  1198.0分钟
原 fpr=0.20 → 闸门后=0.0667  误写条数 200→67  节约=  2400.0分钟  闸门开销=2.0分钟  净收益=  2398.0分钟
原 fpr=0.30 → 闸门后=0.1000  误写条数 300→100  节约=  3600.0分钟  闸门开销=2.0分钟  净收益=  3598.0分钟
原 fpr=0.40 → 闸门后=0.1333  误写条数 400→133  节约=  4800.0分钟  闸门开销=2.0分钟  净收益=  4798.0分钟

闸门的净收益几乎完全由它压低了多少误写决定,而闸门自身的开销小到可以忽略。这就是为什么"再加一道 LLM 判定"这种看起来奢侈的做法在写侧几乎总是划算的:写侧的判定是一次性成本,误写的代价是无限期复利的。

但要注意这个模型的适用边界。它假设误写记忆是独立事件。如果误写会通过自强化循环放大(第七节),那么闸门不只是省下 18 分钟,而是把整个系统从超临界拉回亚临界,收益是非线性的。这才是写侧闸门真正的价值所在。

2.3 否定词、引用、假设:一个容易被系统性忽略的抽取失效模式

抽取器面对的不是干净的陈述句。真实对话里充满了三类让抽取器出错的内容:

否定与转折。"我不喜欢用 TypeScript"、"别再给我推荐那家餐厅了"、"这个方案不行,但预算有限所以先用着"。朴素的抽取会把"我喜欢用 TypeScript"写进去。

引用与转述。"他说他喜欢用 Rust"、"我朋友说他讨厌 Python"。这类内容里模型的状态属于第三方,不属于用户。

假设与规划。"如果我周末有空的话,我会去看看那个展"。这不是既成事实。

这一类失效有一个共同特征:它们无法靠提高抽取器的语言能力来根治,因为抽取器最终仍然是把一段上下文压缩成一条陈述,而这个压缩过程天然会丢失修饰语。真正的解法是改变记忆的数据结构——让记忆不只是一段文本,而是带结构化字段的记录:

{
  "subject": "user",
  "predicate": "prefers",
  "object": "TypeScript",
  "polarity": "negative",
  "scope": "user",
  "source_turn_id": "turn_0042",
  "confidence": 0.87,
  "created_at": "2026-10-30T13:22:07Z",
  "expires_at": null,
  "supersedes": null
}

有了 polarity 字段,"不喜欢用 TypeScript"和"喜欢用 TypeScript"是两条不同的记录,读侧可以按需过滤,而不会因为文本相似度高而把两者合并成一条互相矛盾的记录。有了 source_turn_id,记忆可以回溯到原始对话,这是后面台账和用户申诉机制的基础。

Letta 的 memory block 设计在这里给了另一个角度的启发。官方文档强调 description 字段的重要性:"The description is the main information used by the agent to determine how to read and write to that block. Without a good description, the agent may not understand how to use the block." 换句话说,Letta 把"什么时候该写这一块"这个决策的一部分外化到了块的元数据里,而不是完全交给模型在运行时判断。这是一种声明式写侧:写入目标由结构定义,而不是由模型自由选择。

三、去重:写入的第二道闸门

抽取器解决的是"该不该记",去重解决的是"是不是已经记过了"。

去重看起来是纯粹的读侧优化——减少存储量、减少检索噪声。但它的真实位置在写侧:去重必须在写入时完成,而且去重的质量直接决定后面所有读侧指标的有效性。

3.1 为什么必须在写侧去重

一个自然的想法是:先写进去,检索的时候再去重。但这样做的代价是:

第一,冗余记忆会占据 top-k 槽位。我做了一个模拟:1000 个用户,每人 20 个会话,每会话 30 条候选事实,共 60 万条原始候选。按不同的去重保留率推算,每个用户的记忆条数和检索时的有效槽位:

去重保留率=1.00 → 全站存量=0.60M 条  单用户=600 条  top-8 有效槽位≈8.00 个
去重保留率=0.72 → 全站存量=0.43M 条  单用户=432 条  top-8 有效槽位≈5.76 个
去重保留率=0.35 → 全站存量=0.21M 条  单用户=210 条  top-8 有效槽位≈2.80 个
去重保留率=0.12 → 全站存量=0.07M 条  单用户=72 条  top-8 有效槽位≈0.96 个

保留率掉到 0.12 时,top-8 里只剩不到 1 个有效槽位。也就是说记忆库还在,但检索出来的几乎全是同一个事实的重复表述。指标上完全看不出来——召回率是 1.0(那条事实确实被召回了几十次),rerank 分数很高(每次检索的第一名都是它),只是除了第一名之外其余七名全是噪声。

第二,冗余会污染冲突消解。第五节会看到,最后写入胜策略在有冗余的库上表现更差,因为重复的低质量写入会在时间上压过真正的高质量写入。

第三,冗余放大了删除的难度。同一个事实存了 12 份,用户说"删掉我对 TypeScript 的记忆",你删掉了一份还是十二份?这不是删除实现的问题,是写侧没有做好去重导致删除语义无法定义的问题。

3.2 去重阈值扫描

我用 64 维单位向量模拟了 120 条真事实,其中 40 条各生成 2 个近重复(余弦相似度约 0.88),共 200 条候选,用贪心聚类扫描阈值:

thr=0.70 → 簇数= 126  丢失真事实簇= 0  冗余簇=  6  冗余挤占top-10槽位=0.30
thr=0.80 → 簇数= 135  丢失真事实簇= 0  冗余簇= 15  冗余挤占top-10槽位=0.75
thr=0.85 → 簇数= 148  丢失真事实簇= 0  冗余簇= 28  冗余挤占top-10槽位=1.40
thr=0.88 → 簇数= 161  丢失真事实簇= 0  冗余簇= 41  冗余挤占top-10槽位=2.05
thr=0.90 → 簇数= 170  丢失真事实簇= 0  冗余簇= 50  冗余挤占top-10槽位=2.50
thr=0.95 → 簇数= 184  丢失真事实簇= 0  冗余簇= 64  冗余挤占top-10槽位=3.20
thr=0.98 → 簇数= 196  丢失真事实簇= 0  冗余簇= 76  冗余挤占top-10槽位=3.80

这个结果给出一个明确结论:去重阈值应该设在"刚好合并掉真实近重复"的位置,而不是"越高越安全"。在我的构造中近重复的真实相似度是 0.88,因此 thr=0.85 已经把它们全部合并(冗余簇 28 → 但等等,这里 thr=0.85 时冗余还是 28,说明 0.88 的近重复在贪心聚类中因簇心漂移没能全部合并),而 thr=0.70 虽然冗余最少,但也开始接近过度合并的边界。

更重要的是观察"丢失真事实簇"这一列——全为 0。这意味着在这个相似度结构下,去重阈值在 0.70 到 0.98 的整个区间内都没有造成真事实的合并丢失。真正危险的阈值远低于这个区间,比如 0.5 以下会把语义相关但事实上不同的事实合并在一起。

这个结论的实际含义是:去重阈值的调优应该在真实数据上做,但它的失败模式是"过度合并导致记忆丢失",而过度合并的损失比冗余更难发现——冗余只浪费槽位,过度合并让用户的真实信息凭空消失。所以工程上应该先把阈值调高到能合并绝大多数近重复,再往下探一点,同时必须有一个"合并后事实数"的守门指标,一旦这个数下降就说明合并过头了。

3.3 去重的实现层次

按代价从低到高,实践中可以做三层去重:

精确去重:归一化后的文本哈希。零成本,能挡住"完全重复写入"这类由 infer=False 与 infer=True 混用造成的问题。Mem0 的文档明确警告了这个坑:"When you switch to infer=False, Mem0 stores your payload exactly as provided, so duplicates can land. Mixing both modes for the same fact can save it twice."

键值去重:按 (subject, predicate, object) 三元组去重。这需要记忆是结构化记录而非纯文本,所以它依赖于第二节说的数据结构设计。

语义去重:向量余弦相似度加阈值,也就是上面扫描的那个。它的成本最高,而且必须处理"相似但不同"的情况——"用户在北京工作"和"用户上个月在北京出差"向量相似度很高但语义不同,合并它们就是数据损失。

这三层的顺序不能颠倒。Mem0 那个 infer=False 的警告值得反复读:它是一个模式一致性问题,不是算法问题。同一份数据用两种模式写入,产生重复是必然的。这类问题用任何算法层去重都只能治标,治本的方式是约束写入路径本身——要么所有写入都走 infer=True,要么所有都走 infer=False,不允许混用。

四、晋升:一条记忆凭什么从会话级变成用户级

记忆的分层是共识,但大多数讨论只讲"读的时候从哪一层取",不讲"写的时候凭什么往上晋升"。

Letta 的 memory block 机制给出了一种极简的分层模型:block 是上下文窗口里的常驻段,human block 存关于用户的关键信息,persona block 存智能体的自身设定,organization block 存组织信息且可以被多个 agent 共享。共享 block 的语义是"update once, visible everywhere"——写一次,所有 agent 可见。这是声明式的晋升:用户级和组织级的区别不由运行时判定,而由你把记忆写到哪个 block 决定。

但这种模型的局限也很明显:晋升决策完全交给了 agent 的运行时判断,而 agent 的判断正是不可复现、不可审计、不可回归测试的部分。你没法写一个测试断言"第三次会话之后这条记忆应该升到 user 级",因为你不知道 agent 这次会不会升。

4.1 晋升漏斗的量化

我建了一个漏斗模型:100 个会话,每会话 30 条候选事实,共 3000 条候选,逐层过滤:

对话中出现的候选事实:   3000.0 条  (占初始 100.00%)
抽取器判定值得记(模型A):    840.0 条  (占初始  28.00%)
去重合并后:    604.8 条  (占初始  20.16%)
晋升到用户级(跨会话复现≥2次 或 显式陈述):    211.7 条  (占初始  7.06%)
晋升到组织级(用户确认 + 跨用户复现):     25.4 条  (占初始  0.85%)

层间保留率:抽取 28.0% → 去重保留 72.0% → 晋升用户级保留 35.0% → 晋升组织级保留 12.0%。

最终结果是 3000 条候选里只有 25 条到达组织级,比例是 0.85%。这个漏斗的形状本身是有诊断价值的:如果你观察到组织级记忆增长异常快,几乎一定是晋升条件写松了;如果几乎不增长,可能是条件太严或用户根本没意识到自己在建立可共享的知识。

4.2 两种晋升触发器的对比

晋升条件有两类触发器,可靠性差异很大:

频次触发(同一事实在多个会话中复现 ≥ N 次)——客观、可复现、可回放测试。缺点是需要等待,且高估了"被反复提及但其实只是被讨论的话题"。

显式触发(用户直接说"记住这个"、或用户主动在设置里添加)——信号强度最高,一次即成立。缺点是绝大多数用户不会主动说"记住这个",触发率极低。

Mem0 的文档给出了同样的观察:"Add memory whenever your agent learns something useful: A new user preference is shared / A decision or suggestion is made / A goal or task is completed / A new entity is introduced / A user gives feedback or clarification." 注意这五条里只有最后一条是显式的,前四条都是隐式的语义判断。

实践上的做法是两条路径并存:频次触发作为默认路径,显式触发作为快速通道。显式触发写入的记忆应该带一个更高的置信度标记,因为它来自用户的直接意图,而不是模型的推断。

4.3 作用域即安全边界

Anthropic 官方的 project-scoped memory 设计在这里值得多看一眼,因为它把"作用域"从一个组织手段提升成了安全护栏。官方原话明确说 project 边界的意义在于 "serving as a safety guardrail that keeps sensitive conversations contained"。

这个视角的转换很重要。在写侧,作用域划分的作用不只是让记忆更有条理,而是限制错误的爆炸半径。一条被误提升到全局用户级的记忆,会影响该用户的所有会话;一条被误提升到组织级的记忆,会影响所有用户。而按 project 隔离的记忆,最坏情况只污染一个 project。

把这一点和第三节的扇出模型合起来看:晋升层级越往上,扇出点越多,错误代价越大,晋升的证据门槛就应该越高。这给出了一条可量化的设计原则——晋升阈值应当随扇出点数增长,而不是统一用一个阈值。

五、冲突消解:用户改了主意,系统听谁的

用户在 3 月说"用 TypeScript",在 8 月说"我现在主要写 Python"。记忆库里现在有两条矛盾的记忆。读侧把它们都召回,然后呢?

5.1 四种消解策略的实测

我模拟了 5 万次冲突场景:每次用户对同一事实陈述 1 到 6 次,其中约 30% 是显式纠正(模型判定的真值,权重 0.95),其余是抽取器写入(15% 偶然写对,权重 0.6)。四种策略的正确率:

最后写入胜        正确率= 66.23%
置信度加权        正确率= 97.41%
显式纠正优先       正确率=100.00%
多数投票         正确率= 57.12%

几个结论:

最后写入胜(LWW)是最直觉但最差的策略,正确率只有 66.23%。原因有两层:其一,抽取器写入的置信度是 0.6 而用户显式纠正是 0.95,但 LWW 完全不看置信度,只看时序;其二,在真实对话里用户说"不对,改成 X"往往出现在对话中间,后面还有别的陈述,最后写入胜会把用户的纠正覆盖掉。

多数投票比 LWW 更差(57.12%)。这是反直觉的。原因在于投票把用户的显式纠正当成了"一次普通陈述"——用户花了力气纠正系统,系统却按票数把他自己的意见稀释掉了。这说明在有显式信号的情况下,无差别投票是反用户的。

显式纠正优先是唯一达到 100% 的策略,而且它有清晰的产品含义:用户的显式意图永远压过模型的推断。这应该是一条硬规则,而不是一个可调的权重。

置信度加权(97.41%)接近最优但不完全。它的差距来自权重模型本身——当抽取器写了 5 条、用户纠正了 1 条时,按权重取最大值是显式纠正胜出,但按"最后一条优先"的实现细节仍会输掉一些 cases。这说明置信度加权是需要配合排序规则的,不能单独用。

5.2 冲突消解的数据结构要求

要实现"显式纠正优先",数据模型必须能区分记忆的来源类型。这直接回到第二节的结构化字段设计:

{
  "id": "mem_a1b2c3",
  "subject": "user",
  "predicate": "primary_language",
  "object": "Python",
  "polarity": "positive",
  "source": "explicit_correction",   // explicit_correction | inferred | user_edited
  "confidence": 0.95,
  "created_at": "2026-08-14T09:03:11Z",
  "supersedes": ["mem_x9y8z7"],
  "status": "active"                  // active | superseded | deleted
}

有了 supersedes 和 status,冲突消解就变成了写入时的一次指针更新,而不是读取时的一次启发式排序。这有三个好处:读侧变快(检索时只取 status=active)、消解结果可复现(不依赖运行时模型判断)、历史可追溯(被取代的记忆仍然存在,带 status=superseded,可以解释"为什么系统认为你现在用 Python")。

关键设计点:不要物理删除被取代的记忆。 物理删除会让"你为什么这么认为"这类问题无法回答,也会让回滚变得不可能。Mem0 的 update 是覆盖式的("Mem0 overwrites the stored value and adjusts indexes"),这在简单场景下够用,但在有冲突历史的需求下不够——它的 immutable 字段提供了一条折中路径:标记为 immutable 的记忆不能被 update,只能删除后重新添加。

5.3 冲突消解的三个常见坑

坑一:把"最近"当成"正确"。 时间序不等于真值序。用户的显式纠正必须带一个高于时间戳的优先级维度。

坑二:只在读侧消解。 如果冲突在读侧解决,那么每次检索都要做消解,成本高、结果可能不稳定(同样的查询两次不同结果),而且无法写一个稳定的回归测试。消解必须发生在写入时。

坑三:静默消解。 当系统决定"用新的取代旧的"时,用户不知情。这会导致一个很糟糕的体验:用户问"你怎么知道我用 Python",系统说"你上个月告诉我的",而实际上用户说的是 TypeScript。冲突消解必须对外可见——至少要有一条日志记录"mem_x9y8z7 被 mem_a1b2c3 取代,原因是显式纠正"。

六、过期与衰减:记忆不是只增不减的

写侧不谈过期,记忆库就只增不减。后果是三重的:陈旧记忆误导模型、存储成本线性增长、检索质量随时间单调下降。

6.1 统一 TTL 的困境

我先看了统一 TTL 的表现。以"半稳定"类记忆(项目结构、团队配置,150 条)为例,扫描不同 TTL 的总代价:

TTL=   21天 合计=  161.0分 (误导   44.0 丢失   41.9 清理   75.0)
TTL=   60天 合计=  308.1分 (误导  119.4 丢失  113.7 清理   75.0)
TTL=   90天 合计=  411.0分 (误导  172.1 丢失  163.9 清理   75.0)
TTL=  180天 合计=  673.5分 (误导  306.6 丢失  292.0 清理   75.0)
TTL=  365天 合计= 1046.9分 (误导  497.8 丢失  474.1 清理   75.0)
TTL=  730天 合计= 1404.4分 (误导  680.9 丢失  648.5 清理   75.0)
TTL= 3650天 合计= 1612.4分 (误导  787.5 丢失  750.0 清理   75.0)

这个结果有点反直觉:TTL 越短,总代价越低,而且单调递减。原因是模型里"过期误删的重建代价"被设定得比"陈旧记忆误导代价"低——但这个设定本身是错的。丢失一条用户明确说"记住这个"的记忆(比如"我对青霉素过敏"),代价绝不只是"重新建立一次"的时间成本,而是可能是一次真实的医疗事故。

这说明统一 TTL 这个做法本身就是问题的根源:它强迫所有记忆共享一个"值得活多久"的判断,而这个判断对不同记忆差了好几个数量级。

6.2 分层 TTL:按稳定性分类

正确的做法是按记忆的稳定性分层。我把记忆分成三类,各自有不同的 TTL、重建代价和纠正代价:

[分层] A 稳定事实(姓名/长期偏好)      TTL= 3650天 合计=   193.0分 (误导   18.0 丢失  150.0 清理   25.0)
[分层] B 半稳定(项目/团队结构)        TTL=  180天 合计=   673.5分 (误导  306.6 丢失  292.0 清理   75.0)
[分层] C 易变(当前任务/临时状态)        TTL=   21天 合计=   217.9分 (误导   42.8 丢失   25.2 清理  150.0)

对比统一 TTL=365 天:

[统一] A 稳定事实(姓名/长期偏好)      合计=   131.2分 (误导   11.4 丢失   94.8 清理   25.0)
[统一] B 半稳定(项目/团队结构)        合计=  1046.9分 (误导  497.8 丢失  474.1 清理   75.0)
[统一] C 易变(当前任务/临时状态)        合计=   918.0分 (误导  483.6 丢失  284.5 清理  150.0)

统一 TTL 合计 = 2096.1分   分层 TTL 合计 = 1084.5分   改善 = 48.3%

分层 TTL 把总代价降低了 48.3%。看每一类的分解更清楚:统一 TTL 下,A 类虽然误导代价只有 11.4 分(因为长 TTL 意味着低陈旧率),但丢失代价 94.8 分意味着大量稳定事实被无差别清掉;C 类则相反,统一 TTL 365 天让大量临时状态在库里躺了一年,误导代价高达 483.6 分。

分层之后,每一类的 TTL 都接近它自己的最优点:A 类给 10 年(几乎不过期,因为用户明确说过要记的东西不该被系统单方面删掉)、B 类给 180 天(一个季度,正好覆盖大部分项目周期)、C 类给 21 天(超过三周的临时状态基本已经没有意义了)。

Letta 的 memory block 机制在这里提供了一个优雅的替代路径:用 read_only: true 的 block 承载永不失效的稳定事实,用可写 block 承载易变内容。 官方文档说 read_only 的用途是"给 agent 访问信息(例如关于组织的共享记忆块),但不希望 agent 能对这一块做出潜在破坏性的修改"。这是从另一个角度表达了同一件事——有些记忆不应该由自动流程来决定它的生死。

Mem0 提供了更细粒度的机制:expiration_date 字段(YYYY-MM-DD),"Expired memories are hidden from search and get_all unless you pass show_expired; fetching by ID still returns them"。注意这个语义的设计——过期不是删除,是隐藏。过期记忆仍然可以通过 ID 取回,这意味着过期记忆在合规删除场景下仍然是一个必须处理的对象(第八节会回到这点)。

Anthropic 的 memory tool 文档给出的建议则更朴素:"Periodically delete memory files that haven't been accessed in long time." 这里的判据是最后访问时间而不是创建时间或写入时间——一条三年前创建但昨天才被用过的记忆,不该被清理掉。

6.3 三种过期信号的组合

工程上最实用的做法是组合三种信号:

访问时间(Anthropic 的做法):长期未被访问的记忆可以降权或归档。注意这需要读侧埋点,而读侧埋点通常比写侧更完备。

内容陈旧度(推断):记忆描述的事实可能已经变了。比如"我在 A 公司工作",如果记忆里有 TTL,也可以结合外部数据源判断。

冲突触发(最可靠的信号):当同一 key 上出现了一条新的、置信度更高的记忆时,旧记忆就该被标记为 superseded——这已经在第五节解决了。

实践中这三种信号对应三种不同的处置动作:访问时间决定归档(从热存储移到冷存储,仍可取回),内容陈旧度决定降权(还在库里,但检索排序时打折),冲突触发决定取代(标记 superseded,指向新记忆)。

七、污染的自强化:为什么写侧错误会指数放大

前面所有小节的量化都在做加法:多少条误写、每次命中多少代价、总共多少分钟。但写侧真正的风险不在加法,在乘法。

7.1 自强化循环的模型

机制是这样的:一条错误记忆被写入 → 检索时它被注入上下文 → 模型基于它给出包含同样错误的回答 → 这个回答又被抽取器判定为"值得记住" → 新的错误记忆被写入 → 循环。

设 p_w 是单轮把回答中的错误陈述写入记忆的概率,r 是错误记忆被检索注入回答的概率,则每轮污染条目数的增长是一个几何过程:

亚临界(p_w*r = 0.12):
  第  1轮 → 污染条目=    1.120
  第  5轮 → 污染条目=    1.762
  第 10轮 → 污染条目=    3.106
  第 20轮 → 污染条目=    9.646
  第 40轮 → 污染条目=   93.051
超临界(p_w*r = 0.40):
  第  1轮 → 污染条目=    1.400
  第  5轮 → 污染条目=    5.378
  第 10轮 → 污染条目=   28.925
  第 20轮 → 污染条目=  836.683
  第 40轮 → 污染条目=700037.697

临界条件是 p_w × r = 1:

检索注入率 r=0.2 → 临界 p_w = 5.00(写入率超过此值即指数爆炸)
检索注入率 r=0.4 → 临界 p_w = 2.50
检索注入率 r=0.6 → 临界 p_w = 1.67
检索注入率 r=0.8 → 临界 p_w = 1.25

因为 p_w 和 r 都是概率(都 ≤ 1),实际系统里 p_w × r 很难超过 1,所以严格意义上的指数爆炸在实践中不发生。但亚临界增长仍然是危险的,因为 40 轮之后污染条目涨到 93 倍——这已经足以让记忆库完全失去可信度。

更重要的是这个结论的方向性:降低 r(减少污染记忆被注入回答的机会)和降低 p_w(减少污染被写回记忆的机会)同样重要,而且它们的乘积决定增长率。 如果你只能改一个,改 p_w(写侧)更划算,因为它可控得多——你可以加抽取闸门、加去重、加冲突消解;而 r 受限于检索质量的本质要求。

7.2 抑制效果

把写侧闸门加上(p_w 从 0.30 降到 0.05,r 不变 0.40),5 轮后的污染条目从 1.762 降到 1.104,放大倍数从 1.76 倍压到 1.10 倍,抑制倍数 1.6 倍。

这个数字看起来不大,但要看绝对值:40 轮的情况下,无闸门是 93.05 条污染,有闸门是 1.51 条,差了 61 倍。写侧闸门的作用是把增长率从指数型变成近似线性型,这才是它的价值——不是单次省下多少分钟,而是让系统永远不会进入失信状态。

7.3 一个必须做对的细节:写侧闸门必须拦"从记忆中提取的陈述"

这个循环有一个特别危险的入口:污染记忆被注入上下文后,模型会基于它做出陈述,而这个陈述本身是"看起来有依据"的(因为上下文里确实有那条记忆)。抽取器如果只看这一轮的对话,看不出这个陈述是污染的——它看起来就像一条普通的用户偏好。

对付这个的唯一办法是溯源:每条记忆记录它的 source_turn_id 和 source_memory_ids(它是从哪几条记忆推理出来的)。当抽取器处理一条陈述时,如果发现它的内容可以从已注入的记忆中直接推出,那么它就是派生记忆(derived memory),而不是新的观察。

派生记忆的处理规则应该是:

  • 不单独入库(它是已有记忆的推论,存进去是冗余)
  • 在引用时标注为"基于记忆 M 的推断",而不是"用户告诉我的"

Mem0 的 supersedes 字段和 Anthropic 的 str_replace / insert 命令都为这种血缘关系留了位置。Letta 的 memory block 则是另一种答案:把派生内容写在独立的 scratchpad block 里,明确区别于 human block 里的观察事实。

八、可见性与台账:让写侧可以被用户和工程师审计

写侧的所有问题——误写、过度合并、错误晋升、错误消解——都指向同一个根因:写侧是一个黑箱。用户不知道系统记住了什么,工程师不知道某条记忆是怎么来的。

8.1 用户可见的记忆面板

Anthropic 官方的做法是提供一个 memory summary,用户可以在设置里看到"exactly what Claude remembers from your conversations",并且可以直接跟 Claude 对话修改它:"Based on what you tell Claude to focus on or to ignore, Claude will adjust the memories it references."

这是产品层最重要的一条:用户修改记忆的方式是自然语言对话,不是表单。这看起来是交互选择,实际上是工程约束——记忆的结构化字段(predicate、polarity、confidence)对普通用户不可读,如果要让用户直接编辑这些字段,就等于把数据模型的复杂度泄漏给用户。通过对话,用户表达的是意图("忘掉我说的那个咖啡的事"、"我其实不用 TypeScript 了"),由系统翻译成结构化变更。

Mem0 的 dashboard 提供了另一种形态——按用户/agent/run/metadata 查看和操作记忆,官方文档提到 "Dashboard visibility: Yes: inspect memories visually"(Platform)对比 "Inspect via CLI, logs, or custom UI"(OSS)。这个差异说明了托管与自托管在治理能力上的实际落差:自托管方案通常需要自己实现记忆面板,而这恰恰是最容易被跳过的一块。

Letta 的 client.blocks.list({ label_search: ... }) 和 client.agents.blocks.retrieve(agent_id, "human") 提供了第三个视角——按标签检索、按 agent 维度查看agent当前对某个主题知道什么。后者的用途很具体:排查"为什么 agent 知道这个"。

三种形态各有适用场景:对话式面板面向终端用户,可查询列表面向客服和运营,按 agent 维度查看面向工程师排障。

8.2 记忆台账(audit ledger)

面向工程师的是台账。每一次写入决策都应该留一条不可变记录:

{
  "ledger_id": "led_20261030_0001",
  "ts": "2026-10-30T13:22:07Z",
  "op": "write",
  "memory_id": "mem_a1b2c3",
  "subject": "user",
  "action": "promote",
  "from_scope": "session",
  "to_scope": "user",
  "trigger": "recurrence_count>=2",
  "evidence_turn_ids": ["turn_0011", "turn_0037"],
  "supersedes": null,
  "decision_trace": "extractor=P(belongs)=0.91; dedup_cluster=cl_08; conflict=none",
  "actor": "system"
}

台账的价值在于把归因从不可能变成可能。我算过它的成本收益:

一年台账条目=73000  写入开销=219.0分钟
  年争议  1 次: 无台账=    90分钟  有台账=   5分钟  净节省=    85分钟  扣除台账开销后净收益=  -134.0分钟
  年争议  5 次: 无台账=   450分钟  有台账=   25分钟  净节省=   425分钟  扣除台账开销后净收益=   206.0分钟
  年争议 20 次: 无台账=  1800分钟  有台账=  100分钟  净节省=  1700分钟  扣除台账开销后净收益=  1481.0分钟

一年 200 次写入、每次写一条台账,代价 219 分钟;一次争议的排查成本从 90 分钟降到 5 分钟。盈亏平衡点在年争议 3 次左右——低于这个数台账是净成本,高于就是净收益。

这个结论要正确理解:台账的价值不在于它自己赚不赚。上面这 219 分钟是每条记忆的写入成本,但台账真正的价值是它同时是一份回归测试的语料和效果评估的标注源。第八节末尾会说明这一点。

Mem0 的 MCP server 让台账这件事可以被 agent 自己写:"AI agents can add, update, and delete their own memories"——这意味着台账的写入可以是 agent 行为的一部分,而不只是后端副作用。

8.3 扇出:写一次要触达几处,删一次也要触达几处

写侧的一个隐蔽陷阱是扇出。一条记忆写入时,会同时触达多个位置。我在实践里数了一下,一个典型的记忆系统一次写入至少触及 11 个位置:

主存储(向量/关系库): 1 处
元数据索引(用户/会话/标签/TTL): 1 处
抽取日志/血缘台账: 1 处
缓存层(会话内热缓存): 1 处
语义缓存(跨会话复用): 1 处
向量化嵌入副本: 1 处
用户可见的记忆面板投影: 1 处
下游同步(CRM/工单/知识库): 2 处
备份/快照: 1 处
审计日志流: 1 处
合计扇出点 = 11 处

删除时必须触达同样这 11 处,任一处遗漏就产生幽灵记忆——用户已经删了它,系统还在用。这个概率可以算出来:

单点遗漏率 2% → 全部清除概率 = 80.07%  幽灵残留概率 = 19.93%
单点遗漏率 5% → 全部清除概率 = 56.88%  幽灵残留概率 = 43.12%
单点遗漏率 10% → 全部清除概率 = 31.38%  幽灵残留概率 = 68.62%

单点遗漏率 5% 时,一次删除有 43.12% 的概率留下幽灵记忆。这个数字比它看起来更可怕,因为遗漏率是在单次操作上测的,而用户可能反复删除同一条记忆五次——如果遗漏是随机的,五次全中的概率是 0.05⁵ ≈ 3e-7,看起来没问题;但如果遗漏是系统性的(某个缓存层压根没有删除接口、某张影子表压根没接删除事件),那它会 100% 发生。

这正是"扇出图"作为设计工具的价值——在写侧引入记忆时就把删除路径画出来。Mem0 的 delete_all 设计提供了一个重要的安全先例:"delete_all previously wiped all project memories when called with no filters. It now raises an error if no filters are provided. Use "*" wildcards for intentional bulk deletion." 而且空字符串或纯空白的 ID 会被 SDK 层拒绝——"An empty or whitespace-only ID such as user_id="" matches every memory in the project, so the SDKs raise an error before sending the request." 这两条防线的本质是:宁可失败也不要做范围不明的删除。

而且 Mem0 的通配符删除要求"四个过滤器全部显式设为 *"才算全项目清空,只设部分只删对应类型。这个设计保证了"我以为我删了整个项目"这种误解在 API 层就被拦住。

8.4 台账作为回归测试语料

前面说过,台账真正的价值之一是它同时是标注源。有了台账,你可以做一件此前做不到的事:用真实的生产写入决策作为回归测试集。

具体做法是:从台账里采样出写入决策,计算其中应该被拦截的比例(误写)、应该被接受但被拦截的比例(漏写),然后把这些样本变成评测集的一部分,每次改抽取器、换模型、调阈值,都跑一遍这个集合。

这样做的价值在于它测的是你系统真实的失败分布,而不是一个精心构造的、可能与生产分布完全不同的评测集。这一点与站内 id=775《AI 应用 Prompt 版本化与 A/B 实验平台工程》以及 id=843《Prompt 制品化工程》的逻辑是一致的——把一次性决策变成可回归的制品。区别在于本节讨论的对象是记忆写入决策而不是 prompt。

Mem0 文档里有一个直接相关的建议:"Pair updates with feedback signals (thumbs up/down) to self-heal memories automatically." 这就是台账 + 反馈闭环的雏形:用户的一次点赞或点踩,可以回溯到具体的记忆写入决策,作为该决策标签。

九、统一架构:把写侧做成一条可审计的管线

把前面八节的东西组装起来,写侧管线有七个阶段:

[1] 采集     对话轮次 + 显式指令("记住这个")+ 外部信号(用户主动编辑)
     ↓
[2] 抽取     LLM 抽取候选事实 → 结构化记录(含 polarity / subject / source)
     ↓
[3] 归一     精确哈希去重 → 键值三元组去重 → 语义相似度去重(阈值守门)
     ↓
[4] 闸门     二次判定(LLM 复核 + 规则)→ 拒绝 / 接受 / 降级为候选
     ↓
[5] 消解     冲突检测 → 按策略消解(显式纠正优先)→ 写 supersedes 指针
     ↓
[6] 晋升     频次触发 / 显式触发 → 会话级 → 用户级 → 组织级(作用域即护栏)
     ↓
[7] 台账     每一步写不可变审计记录 + 血缘(source_turn_id / source_memory_ids)

读侧是这条管线的下游。整条管线的输出是一个带完整血缘的记忆库,读侧只需要做检索和注入。

9.1 一个具体的实现骨架

用 Letta 的 memory block 思路 + 结构化记录,给出一个可运行的最小设计:

# 写侧的单一入口:所有写入必须经过这里
class MemoryWriter:
    def __init__(self, store, ledger, extractor, dedup, resolver, promoter):
        self.store, self.ledger = store, ledger
        self.extractor, self.dedup = extractor, dedup
        self.resolver, self.promoter = resolver, promoter

    def ingest(self, turn, scope="session", source="inferred"):
        # 1. 抽取:产出结构化候选
        candidates = self.extractor.extract(turn)      # [{subj, pred, obj, polarity, ...}]
        # 2. 归一:三层去重
        for c in candidates:
            key = (c.subject, c.predicate, c.object, c.polarity)
            if self.dedup.exact(c) or self.dedup.key_exists(key):
                self.ledger.record(op="skip", reason="duplicate", cand=c)
                continue
            c = self.dedup.semantic_merge(c, guard="fact_count_must_not_drop")
            # 3. 闸门:二次判定
            verdict = self.extractor.verify(c)
            if not verdict.keep:
                self.ledger.record(op="reject", reason=verdict.reason, cand=c)
                continue
            # 4. 消解:显式纠正优先,写 supersedes
            old = self.store.find_active(c.subject, c.predicate)
            if old and self.resolver.resolve(old, c) is c:
                c.supersedes = [old.id]
                old.status = "superseded"
            # 5. 晋升:频次或显式
            target = self.promoter.promote(c, scope, source)
            # 6. 写入 + 台账
            mid = self.store.put(c, target_scope=target)
            self.ledger.record(op="write", memory_id=mid, cand=c,
                               target_scope=target, source=source,
                               superseded=[old.id] if old else [])
            return mid

注意 ingest 是唯一的写入入口。这直接规避了 Mem0 文档警告的 infer=True / infer=False 混用问题——模式不一致在单一入口的架构下不可能发生。

9.2 成本:写侧要不要花 token

有人会问:写侧多了一堆 LLM 调用,值吗?我算了一个粗略的账:

单会话上下文总量 = 20轮 × 400 token = 8000 token
写侧抽取:读 8000 token + 输出 400 token = 8400 token(一次)
读侧:每轮注入 8 条 × 60 token = 480 token/轮 × 20 轮 = 9600 token
全上下文回放:累计 = 1680000 token
写侧+读侧 = 18000 token,比全上下文少 98.9%

写侧一次性消耗 8400 token,读侧每轮 480 token,20 轮共 9600 token,合计 18000 token——相比每轮回放全部历史的 168 万 token,节省 98.9%。写侧抽取的 token 成本相比它替代的全上下文回放可以忽略不计。

这也是 Mem0 论文的核心论点之一。Mem0 论文(arXiv:2504.19413,Chhikara 等人)在 LOCOMO 基准上报告:Mem0 相对 OpenAI 在 LLM-as-a-Judge 指标上取得 26% 的相对提升,p95 延迟降低 91%,token 成本节省超过 90%。

不过这里必须诚实地说明论文口径与本文口径的差别。论文的核心主张是记忆机制同时优于全上下文和纯 RAG 基线,并且省 token 省钱。但本文讨论的是写侧——抽取、去重、晋升、消解、过期、台账——这些是论文没有展开的部分。论文把记忆当作一个整体系统来评估,而写侧的具体决策策略(用什么阈值、怎么消解、怎么晋升)在论文里是黑盒。论文里的 26% 提升和 91% 延迟下降,不构成对任何具体写侧策略有效性的背书——那需要在具体场景里自己测。

9.3 反模式清单

综合前面各节,这些是写侧工程里最常见的错误:

反模式一:把写侧当黑箱。 抽取器一调完就上线,没有台账、没有效果监测、无法回答"上周我们记住了什么"。用户第一次问"你怎么知道这个"的时候,团队答不上来。

反模式二:把记忆当只增不减的日志。 没有 TTL、没有归档、没有 superseded 标记,记忆库单调增长,检索质量随时间单调下降。

反模式三:把晋升门槛设成统一的。 稳定事实和临时状态用同一个 TTL 和同一个晋升阈值,必然有一头出错。

反模式四:只在读侧消解冲突。 每次检索时启发式地挑一条,每次结果可能不同,无法写回归测试,成本还高。

反模式五:物理删除被取代的记忆。 导致"你为什么这么认为"无法回答,回滚不可能,历史不可追溯。

反模式六:静默消解。 系统悄悄用新记忆覆盖旧记忆,用户被蒙在鼓里。

反模式七:忽略删除扇出。 写了 11 个地方,删了 3 个,剩下 8 个变成幽灵记忆。

反模式八:把污染记忆当新观察写入。 不做血缘追踪,污染记忆派生的陈述被当成独立证据写回库,形成自强化循环。

反模式九:让 agent 自己决定一切。 记忆写在哪、晋升到哪层、什么时候过期,全部交给运行时模型判断。结果不可复现、不可测试、不可审计。

反模式十:把"记忆写进去了"当成"记忆生效了"。 写入是必要条件不是充分条件,记忆必须在正确的作用域、正确的时机、正确的置信度下被检索到才算生效。

9.4 落地路线

如果要在一个已有记忆系统的产品上补上写侧,建议这个顺序:

第一步:加台账。 成本最低(219 分钟/年),收益立刻可见(能回答"我们记住了什么"),并且它是后面所有工作的数据基础。

第二步:加可编辑面板。 至少要支持用户查看和删除单条记忆。这一步能立刻暴露存量问题——通常会有一批用户会要求删掉的东西,而这批删除请求本身就是最宝贵的标注数据。

第三步:加结构化字段和显式纠正优先级。 这是冲突消解从启发式变成确定性的前提。

第四步:做分层 TTL。 按记忆稳定性分类,自动清理易变内容。

第五步:加晋升漏斗和作用域隔离。 这一步开始触及产品形态(Anthropic 的 project-scoped memory 那一层),改动面最大。

第六步:把台账变成回归测试集。 前面五步积累的数据在这一步变现。

参考文献

  1. Chhikara, P., Khant, D., Aryan, S., Singh, T., & Yadav, D. (2025). Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory. arXiv:2504.19413. https://arxiv.org/abs/2504.19413
  2. Anthropic. (2026). Memory tool. Claude Platform Docs. https://docs.claude.com/en/docs/agents-and-tools/tool-use/memory-tool
  3. Anthropic. (2026). Bringing memory to teams. Anthropic News. https://www.anthropic.com/news/memory
  4. Mem0. (2026). Add Memory: How Mem0 Adds Memory. Mem0 Documentation. https://docs.mem0.ai/core-concepts/memory-operations/add
  5. Mem0. (2026). Update Memory Operation. Mem0 Documentation. https://docs.mem0.ai/core-concepts/memory-operations/update
  6. Mem0. (2026). Delete Memory Operation. Mem0 Documentation. https://docs.mem0.ai/core-concepts/memory-operations/delete
  7. Mem0. (2026). How Mem0 Works. Mem0 Documentation. https://docs.mem0.ai/core-concepts/how-it-works
  8. Letta. (2026). Memory blocks (core memory). Letta Docs. https://docs.letta.com/v1-sdk/memory/memory-blocks
  9. Letta. (2026). Shared memory. Letta Docs. https://docs.letta.com/v1-sdk/memory/shared-memory
  10. Letta. (2026). Context hierarchy. Letta Docs. https://docs.letta.com/v1-sdk/memory/context-hierarchy
  11. Bühler, M. (2026). Memory blocks: The origin of structured agent memory. Letta Blog. https://www.letta.com/blog/memory-blocks
  12. Anthropic. (2026). Claude Code: Memory. Claude Platform Docs. https://docs.claude.com/en/docs/claude-code/memory
  13. OpenAI. (2026). Agents SDK Sessions. OpenAI Agents SDK Documentation. https://openai.github.io/openai-agents-python/sessions/
  14. Mem0. (2026). Feedback Mechanism. Mem0 Platform Documentation. https://docs.mem0.ai/platform/features/feedback-mechanism
  15. Mem0. (2026). Bring Your Own Key (BYOK). Mem0 Platform Documentation. https://docs.mem0.ai/platform/features/byok
  16. 站内相关文章:《AI 应用 memory 工程 2026:从滑动窗口到 vector memory 装配》(读侧:context window manager / compressor / persistor)
  17. 站内相关文章:《对话 AI 应用的长期记忆与人格一致性工程 2026》(读侧:四层存储 / 五因子检索 / 七轴评估)
  18. 站内相关文章:《LLM 应用的长上下文压缩与记忆蒸馏工程 2026》(读侧:压缩算子横评 / 遗忘曲线)
  19. 站内相关文章:《AI 应用的数据删除与导出工程 2026:扇出图与验证闭环》(删除扇出图方法论)
  20. 站内相关文章:《Prompt 制品化工程 2026:从版本门禁到秒级回滚》(把一次性决策变成可回归制品的方法论)

一句话摘要

当一个 AI 应用开始"记得"用户,团队真正需要工程化的不是检索,而是那条记忆凭什么被写下来——本文把记忆写侧拆成抽取、归一、闸门、消解、晋升、过期、台账七个可度量、可回归、可审计的决策点,并用现场实算的模型说明:写入闸门能把污染自强化从指数增长压回线性增长,分层 TTL 比统一 TTL 省下 48.3% 的总代价,显式纠正优先的冲突消解策略正确率是最后写入胜的 1.51 倍,而一次写入触及 11 个位置意味着一次删除有 43% 的概率留下幽灵记忆。

←返回文章列表

Related

可能也会喜欢

  • AI 应用交付物工程 2026:从工具结果到可验证产物10月31日
  • 向量数据库选型 2026:pgvector 与专用引擎的边界10月29日
  • AI 应用的数据删除与导出工程 2026:扇出图与验证闭环10月28日

Conversation

0 条

留下你的想法

加载评论中…

New comment