OpenClaw 单对话连续性与 Memory 深度解析

0 阅读6分钟

很多人第一次用龙虾时都会有一个直觉:

“聊久了,它应该会忘吧?”

这个直觉没错。模型的上下文窗口是有限的,不可能把几万句对话永远都放在眼前。

但你会看到系统表现得像这样:

  • 不切 thread,也能连续推进一个长期任务
  • 记得你之前的偏好、决定和待办
  • 对话很长后也不会突然“失忆”

这篇文章讲清楚它背后的工程逻辑。

先讲结论:它靠的不是“超大脑子”,而是“分层记忆”

一个能长期连续工作的 AI 助手,通常不是在“硬记全部聊天记录”。

它更像一个做事很靠谱的人:

  • 脑子里保留当前任务所需的短期信息
  • 把长期重要信息记到笔记本
  • 需要时再去查笔记,而不是靠猜

所以关键不是“记住一切”,而是“该记哪里、什么时候记、怎么取回来”。

1 为什么“一个对话一直聊”不会乱

要做到这件事,第一步不是 memory,而是“会话归属稳定”。

你可以把它理解成:

  • 每条新消息进来,系统都要先回答“这条消息属于哪条连续会话?”
  • 只要这个归属规则稳定,用户就会感受到“我一直在同一个对话里”

如果归属不稳定,会发生什么?

  • 今天这句进 A 会话
  • 明天那句进 B 会话
  • 用户感觉就是“它忽然不记得了”

所以,连续性首先是路由问题,不是模型智商问题。

2 长对话为什么不会把模型撑爆

即使会话归属稳定,也还有第二个难题: 上下文窗口会满。

成熟系统会做三件事:

  1. 限制最近历史 :只保留与当前问题最相关的“最近若干轮”。

  2. 自动压缩旧历史(compaction):把很长的旧对话压成“结构化摘要”,保留关键决策和状态。

  3. 失败兜底:如果压缩中断或超时,回退到安全快照,不让会话进入半坏状态。

这和人工作很像:

  • 桌上只放当前要处理的文件
  • 老文件归档成摘要
  • 归档出错就先回到上一个可用版本

3 真正关键:Memory 不是“备份聊天记录”

很多人把 memory 理解成“把聊天全存起来”。

这不够。

真正可用的 memory 系统至少要回答 4 个问题:

  1. 存什么
  2. 什么时候存
  3. 怎么找
  4. 找到后给模型喂多少

1 存什么

不是所有对话都值得永久存。

通常要存的是:

  • 稳定偏好(口味、风格、边界)
  • 已确认决策(做过什么决定、为什么)
  • 长周期任务状态(进行到哪一步)
  • 关键事实(日期、人物、账号约束)

2 什么时候存

好的系统会在“即将压缩上下文”前触发一次静默写入(memory flush):

  • 先把耐久信息落盘
  • 再去压缩历史

这样就不会因为压缩导致关键信息漂掉。

3 怎么找

常见做法是“混合检索”:

  • 关键词检索(你说了某个明确词)
  • 语义检索(你换了说法但意思相近)

然后把结果融合排序,优先给出最相关片段。

4 找到后喂多少

成熟系统不会把整本记忆库都喂给模型。

而是两步:

  • 先 search,拿候选
  • 再 get,只读取必要行

这样上下文干净,成本低,稳定性高。

4 一个通俗例子:为什么它看起来“真的记得你”

假设你连续三周都在推进“装修计划”。

第一周你说:

  • 不要开放式厨房
  • 预算上限 20 万

第二周你说:

  • 客厅采光优先
  • 工期希望 3 个月内

第三周你问:

“按我们之前的原则,周末我要见设计师,该先确认哪三件事?”

如果系统只靠当前窗口,它可能忘掉第一周。

如果它有分层记忆:

  • 会话里保留最近讨论
  • 长期记忆里有你前两周沉淀的偏好与约束
  • 回答前先检索再取关键片段

最终回答会更像“基于你的长期上下文”,而不是一次性临场发挥。

5 一个点看懂工程质量

为什么 memory flush 不会乱触发,也不会重复触发。

很多系统的问题不是“没有 memory flush”,而是“flush 触发太随意”,结果变成:

  • 该写入时没写
  • 不该写入时反复写
  • 一次 compaction 周期里重复写同样内容

OpenClaw 在这个点上做得很精细,核心是三段式控制。

1 先算阈值,只在快到上限时触发

src/auto-reply/reply/memory-flush.ts 里,触发条件本质是:

//当 totalTokens >= threshold 时,才考虑 flush
threshold = contextWindow - reserveTokensFloor - softThresholdTokens

这让 flush 从“拍脑袋触发”变成“窗口压力驱动”。

2 再做幂等控制,一轮 compaction 只 flush 一次

同一文件里还有一个关键判断(shouldRunMemoryFlush):

  • 当前会话有 compactionCount
  • 上一次 flush 记录了 memoryFlushCompactionCount
  • 如果两者相等,说明本轮已经 flush 过了,直接跳过

这一步非常工程化。它不是靠“感觉上不会重复”,而是靠状态位严格去重。

3 触发位置放在主回合之前,保证先落盘再压缩

src/auto-reply/reply/agent-runner.ts 中,runMemoryFlushIfNeeded() 被放在主回合执行前。 src/auto-reply/reply/agent-runner-memory.ts 里会进一步检查:

  • 不是 heartbeat
  • 不是某些不适合的 provider 模式
  • 工作区可写(只读沙箱不写)

通过后才跑 flush 回合,并在结束后把这两个字段写回 session store:

  • memoryFlushAt
  • memoryFlushCompactionCount

对应会话结构字段定义在 src/config/sessions/types.ts

它把“写记忆”做成了可证明的状态机,而不是一段“偶尔执行的辅助逻辑”。

这就是为什么它在长对话压力下还能稳定,不会一边压缩一边把记忆策略搞乱。

6 这套设计的代价与边界

这套方案很强,但不是魔法。

优点

  • 单对话体验稳定
  • 长任务可持续
  • 对“历史事实”更不容易胡编

代价

  • 系统更复杂(路由、索引、压缩、检索都要配合)
  • 记忆质量取决于写入质量
  • 参数没调好会影响召回质量或成本

现实边界

  • 记忆不是 100% 真相机,仍需要来源校验
  • 高风险场景要保留“我不确定”与“可追溯引用”机制

六 如果你在设计类似系统,最值得抓住的三件事

  1. 先保证会话归属稳定
  • 不要一上来就追求复杂 memory,先让“同一个人同一类对话”稳定落在同一会话。
  1. 把长期记忆外置
  • 不要指望模型窗口长期记住一切。把耐久信息写进可检索存储。
  1. 强制“先检索再回答”
  • 在系统策略层明确约束:涉及历史事实必须先查 memory。
  • 这是把“看起来聪明”变成“工程上可靠”的分水岭。

结语

一个真正能长期协作的 AI,对外看起来像“记性很好”。

但从工程上看,它做的是更朴素、也更难的一件事:

把“记住”拆成可管理的流程。

  • 谁的会话
  • 当前保留什么
  • 长期写入什么
  • 回答前查什么

当这四件事同时做好,用户才会得到那种自然体验:

“我没有切 thread,但它一直跟得上我。”