主题
第 9 章 状态恢复、异步与一致性
一个客服 Agent 在会话结束时写入「客户要求退款,工单号 RF-88213,等待财务复核」。 下一次回合它立刻召回这条记忆,却什么都没查到,于是重新问了一遍客户诉求。 第三次回合,那条记忆又出现了。 同一段对话里,同一件事被记了两次、漏了一次。
这不是写入失败,也不是召回坏了。 这是异步写入的固有形状:热路径只负责入队,抽取、冲突消解、巩固都交给后台。 队列还没落盘时读取,读到的就是落盘前的旧状态。 系统没有撒谎,它只是没有对「写完之后立刻读」这件事做出承诺。
记忆系统的持久性承诺比大多数工程师预期的要弱:它保证「已接受的写入最终会落盘」,不是「写入返回时已可读」。 这个差别在单进程、低频写入的开发环境里几乎观察不到,在生产环境里却决定了事故复盘时能否自证清白。
本章按「时序 → 屏障 → 事件 → 幂等 → 承诺边界 → 演练」展开,每节回答同一问题的不同侧面:在进程随时会死的前提下,你究竟能对读取方承诺什么。
9.1 记忆写入的两种时序
写入有两条路径,差别不在接口,而在「什么时候算完成」。
同步写入是「写完即可读」。 一次 add() 内部完成抽取、嵌入、入库、审计链追加,返回时数据已经在 SQLite 里。 它的优点是心智负担为零:不需要屏障、不需要轮询、不需要理解队列。 代价是把主循环拖住了:一次写入包含一次模型调用(单遍 ADD-only 抽取),在商用模型上通常是几百毫秒量级,每个对话回合都同步写一次,用户能感知到这段延迟。
异步写入把热路径压缩成一次入队。 memory_hot_path 的 action=enable 打开这个模式后,热路径只把写入请求放进队列,立刻返回,抽取、冲突判定、巩固、嵌入生成都在后台线程完成。 优点是主循环不再被模型延迟绑住,代价是引入了读后写不一致:入队成功不等于落盘成功。
| 维度 | 同步写入 | 异步写入 |
|---|---|---|
| 返回时的语义 | 已落盘,可读 | 已入队,未必可读 |
| 主循环延迟 | 含一次模型调用 | 仅入队开销 |
| 崩溃时的损失面 | 已返回的写入不丢 | 队列内未落盘的写入会丢 |
| 需要额外机制 | 无 | flush 屏障或事件轮询 |
| 适合的场景 | 会话结束、批量导入 | 高吞吐回合内写入 |
表 9-1 同步与异步写入的语义差异
判据不在「哪个更快」,而在「读取方能否容忍旧值」。 如果写入之后紧接着就要用这条记忆做决策,同步写入或者显式屏障是必须的。 如果写入只是「顺手记下来,下次会话再说」,异步是合理默认。
9.2 memory_hot_path 与 flush 屏障
memory_hot_path 有三个动作,分别对应「开」「看」「等」。 action=enable 打开异步写入;action=status 返回队列深度与统计,用来判断后台是否跟得上;action=flush 阻塞到队列落盘为止。
第三个动作是这一节的全部重点。 flush 是写后立刻读的一致性屏障。不 flush 就读,读到的是旧值,而且不会报错。
python
from mnemosyne import MemoryBrain
brain = MemoryBrain("./mem")
brain.ensure_init()
# 打开异步写入:热路径只入队
brain.hot_path(action="enable")
brain.retain("客户要求退款,工单号 RF-88213", fast=True)
hits = brain.recall("退款工单", k=3) # 可能为空:还没落盘
brain.hot_path(action="flush") # 一致性屏障
hits = brain.recall("退款工单", k=3) # 现在一定读得到这段代码验证的正是那个失效场景:第一次 recall 返回空不是 bug,是异步语义的直接后果;第二次 recall 之前的那行 flush,是让「写后读」重新变成可依赖承诺的唯一手段。
踩坑的典型形态是「测试里能过、生产里偶发失败」。 测试环境写入量小,后台线程几乎瞬间追上队列,recall 碰巧读到了新值;生产环境队列有积压,同样的代码就开始间歇性返回旧结果。 更麻烦的是它表现为「有时候记得有时候不记得」,很容易被误判成召回质量问题,从而去调检索参数——方向完全错了。
代价是 flush 会把异步的收益吐回去一部分。 一次 flush 的等待时间等于队列里所有待处理写入的剩余处理时间,积压时可能比同步写入更长。 所以正确的用法不是「每次读完都 flush」,而是「只在读依赖于刚才那次写时才 flush」。
9.3 操作日志与事件
异步写入要跨进程可见,靠的是持久化操作日志。 已接受的写入在日志里有记录,即使处理还没完成。
add 走 REST 时是异步的,返回的是一个 event id:POST /v3/memories/add/ 立即返回,真正的抽取与入库在后台进行。 调用方要确认结果有两条路:轮询 GET /v1/event/{id}/,看状态从 pending 走到 succeeded;或用 MCP 的 get_event_status,语义相同。 GET /v1/events/ 列出全部操作,list_events 是它的工具形态。
事件机制解决的是「跨进程可见性」,不是「读一致性」。 两者的区别值得写清楚:事件告诉你「这次写入处理完了没有」,屏障保证「这次写入现在能不能被读到」。
在生产里这两件事通常要一起用:add 拿到 event id,轮询到 succeeded,然后再 recall。 如果只轮询事件不 flush,仍有可能在一个极短窗口里读到旧状态——因为事件状态更新与索引可见性不是同一个原子操作。
代价是轮询本身有成本。 高频写入场景下每个 event 一次轮询会把 QPS 放大一倍以上,合理的做法是批量轮询 GET /v1/events/,或者干脆用 flush 代替逐个 event 跟踪。
9.4 崩溃恢复与幂等
进程被杀之后,恢复的正确性取决于两件事:已落盘的没丢,重复提交的没重。
已落盘的没丢由 SQLite 的 WAL 与事务保证。 一次写入涉及正文、结构化事实、图三元组、审计账本四张表,它们在同一个事务里提交:崩溃发生在提交前,整个事务回滚;发生在提交后,数据完整。 审计账本是 SHA-256 链,这一点尤其重要——半条链写入会让后续所有 verify_integrity 失败。
重复提交的没重要靠幂等标识,而不是靠调用方自律。 触发场景很多:客户端超时重试、消息队列至少一次投递、运维手动重放。 没有去重,同一段对话会被抽取两次,产生两条几乎一样的记忆。 去重靠两层判断:内容指纹(完全相同的正文直接判定重复)与相似度(语义接近但不完全相同的,交给 dedup 与 consolidate 处理)。
retain_batch 的批处理边界需要单独说。 它比逐条写入快得多(官方说明约 15 倍),但它的原子性边界是「每条独立」,不是「整批原子」:一批里第 7 条失败,前 6 条已经写入。 所以批量导入的恢复逻辑必须按条记录进度,不能假设「这批要么全成要么全败」。
代价是去重本身有成本。 内容指纹很便宜,相似度判断要计算嵌入并比较,在万条量级上是一次可观的计算。 而且去重是近似的:阈值定得松会漏掉真正的重复,定得紧会误杀「看起来像但确实不同」的两条记忆。
9.5 一致性的分级承诺
把承诺写成分级,比写成「支持事务」有用得多。
| 承诺 | 是否成立 | 边界 |
|---|---|---|
| 单次写入原子 | 成立 | 四张表同一事务,含审计链追加 |
| 已提交写入不丢 | 成立 | WAL 落盘后;未 flush 的队列内容不在此列 |
| 写后立刻读 | 不成立(异步模式) | 需 flush 或事件轮询 |
| 跨作用域聚合读原子 | 不成立 | 物理隔离下是 N 个独立文件、N 次事务 |
| 并发读写互不阻塞 | 成立 | WAL 模式下写不阻塞读,但写与写仍串行 |
表 9-2 一致性承诺的成立范围与边界
两行「不成立」值得展开。
跨作用域聚合读不是事务性的。 每个作用域是独立 SQLite 文件,一次跨租户统计要依次打开 N 个文件。 在遍历过程中,另一个进程可能正在写入其中一个文件。 结果是这次聚合看到的不是任何一个时间点的全局快照,而是「各文件各自某一时刻」的拼接。 对运营报表这通常无所谓;对账务类统计就是错的。
WAL 下写与写仍然串行。 WAL 让读不阻塞写,也让写不阻塞读,但 SQLite 同一时刻只允许一个写事务。 这意味着写入并发度不会随 CPU 核数增长。 单机上万个作用域分摊下来,每个作用域的写入频率很低,所以这不是问题。 但如果某个热点作用域要承受高并发写入,SQLite 会成为硬瓶颈——这一点在第 11 章的容量判断里会再出现。
代价是这套承诺需要被写进文档,而不是留给使用者猜。 一个没有写清楚的「最终一致」,在事故复盘时等于没有承诺。
横向对照可以看清这条边界的普遍性。 截至 2026 年 10 月,LangMem 把写入分成「有意识形成」(热路径)与「潜意识形成」(后台)两类,持久化依赖 LangGraph 的 BaseStore。 InMemoryStore 重启即丢,生产要换成 AsyncPostgresStore 这类持久化实现。 两边的结构是一样的:热路径追求低延迟,后台追求完整处理,中间那层不一致必须由使用者显式处理。
9.6 恢复演练
「杀掉进程不丢记忆」这句话必须能被验证,否则它只是设计意图。 下面这份清单可以逐条执行,每条都有明确的通过标准。
- 强杀写入:写入 100 条后立即
kill -9,重启后recall应返回全部 100 条。若返回少于 100,说明有写入没有真正提交就被当成成功。 - 队列丢失面:开启异步写入,入队 50 条后立刻强杀。重启后应少于 50 条,且缺失的条目能在操作日志里被识别为未完成。若一条不缺,说明
enable实际是同步语义,文档与实现对不上。 - 重复提交:同一段对话连续
add三次,dedup应报告 2 条重复。若报告 0 条,说明幂等标识没生效。 - 审计链完整:强杀后执行
verify_integrity(),应返回valid=true。若first_broken_at有值,说明存在半条链写入。 - 不重复计费:对比强杀前后的
stats写入计数与模型调用计数。模型调用次数应小于等于写入次数,重复提交不应产生额外的抽取调用。
最后一条最容易被忽略,也最贵。 异步重试如果没做幂等,一次崩溃恢复可能让同一段对话被抽取两次,两次都是真实的模型调用。 账单上看到的是「写入量翻倍」,而不是「故障」。
9.7 本章小结
本章确立三条判断。
第一,写入的两种时序不是性能选项,而是语义选项。 同步写入承诺「返回即可读」,异步写入承诺「返回即已接受」。 选择哪一个取决于读取方能否容忍旧值,而不取决于哪个更快。 这条判断的失效条件是「写入与读取永远不在同一回合」——在纯离线批处理里,两者没有区别。
第二,flush 是异步模式下唯一可靠的写后读屏障,事件轮询只能确认「处理完了」,不能保证「现在读得到」。 代价是 flush 会等待整个队列,积压时比同步写入更慢,所以它应当只在读依赖于刚才那次写时使用。 这条判断在纯同步模式下不适用,此时屏障是多余的。
第三,一致性的承诺必须分级陈述。 单次写入原子、已提交不丢,这两条成立。 写后立刻读、跨作用域聚合原子,这两条不成立,需要显式机制或明确放弃。 把「最终一致」四个字当承诺用,等于在事故复盘时放弃自证能力。
一个本章回避的问题是:如果多个进程同时写入同一个作用域,幂等判断由谁来做。 单进程内可以用内存态去重,多进程只能靠存储层的唯一约束或内容指纹。 而当写入方是不同 Agent、属于不同用户时,问题就不只是去重,而是「谁有权写」。 第 10 章从这里继续。