记忆系统总览
OpenClaw 的记忆系统不是「套壳向量库」,而是一套可插拔的记忆后端。三个内置后端——memory-lancedb、memory-wiki、active-memory——共用一份 MemorySearchManager 契约,分别满足语义搜索、结构化 vault、即时会话状态三类需求。
OpenClaw 把「记忆」拆成三层而不是一个大向量库,是因为这三类检索问题本质不同:"和这句话最像的旧记忆是什么"(语义相似)、"这个主题/这个人持久积累了什么"(结构化 vault)、"当前这轮对话里刚刚发生了什么"(即时上下文)。把它们塞进一个后端只会互相拖累,所以 OpenClaw 让三者各跑各的,统一在 memory-host-sdk 的接口之下。
三个后端都是 extensions/memory-* 下的独立插件,按 openclaw.plugin.json 声明式注册,可以单独启用/停用,也可以被第三方后端替换。记忆的自动沉淀挂在 agent 生命周期钩子 agent_end 上——一轮对话结束就把值得记的东西抽出来、嵌入、入库,全程不阻塞 agent 主流程。
语义搜索
向量嵌入检索。把文本 embed 成向量存进 LanceDB,按 L2 距离找最近邻,回答"和当前语境最像的旧记忆"。
结构化 vault
Markdown vault + 全文检索。原子写入 .md 文件,可被 Obsidian / git 直接读,回答"持久积累了什么"。
即时会话
按会话滑动窗口的活跃上下文,由 SessionMemoryState 三增量门控抽取,回答"这轮刚发生了什么"。
agentId + sessionKey 这把跨平台锚点,而不是云端账户。memory-host-sdk 契约
源码:packages/memory-host-sdk。所有记忆后端实现同一个 MemorySearchManager 接口——宿主只认接口,不关心后端是向量、vault 还是会话窗口。
export interface MemorySearchManager {
search(query: string, opts: MemorySearchOptions): Promise<MemoryHit[]>;
readFile(ref: string): Promise<string>; // 把命中条目展开成原文
status(): Promise<MemoryBackendStatus>; // 后端健康/容量上报
probeEmbeddingAvailability(): Promise<boolean>; // 嵌入端点可用?
probeVectorAvailability(): Promise<boolean>; // 向量存储可用?
}
五个方法把记忆后端的能力面收敛到最小:search() 做检索、readFile() 把命中条目展开回原文、status() 上报后端状态,两个 probe*() 探针让宿主在运行时探测嵌入与向量存储是否可用——这正是降级逻辑的支点:探针返回 false 时,宿主自动把记忆退回到纯全文检索,而不是抛错中断 agent。
检索 + 展开
search() 返回命中列表,readFile() 把某条命中一路展开成完整原文——"先摘要后展开"的两段式读取。
状态上报
后端把自己的健康度、容量、就绪状态报给宿主,用于诊断与 UI 展示。
能力探针
两个探针把"嵌入是否可用"与"向量库是否可用"分开探测,让降级可以分级——可全文不可向量时仍能跑。
MemorySearchManager 之后,"换记忆后端"就是换一个插件——LanceDB / wiki / active 三选一甚至并存,第三方也能实现同一接口接进来。宿主代码对此完全无感。memory-lancedb · 向量嵌入检索
源码:extensions/memory-lancedb/index.ts。把记忆文本嵌入成向量存进 LanceDB,按 L2 距离找最近邻,相似度用 1 / (1 + d) 归一化。自动沉淀挂在 agent_end 钩子上。
// agent_end 钩子:一轮对话结束,抽取用户消息并尝试沉淀
const vector = await embeddings.embed(text);
const existing = await db.search(vector, 1, 0.95); // 0.95 相似 = 重复
if (existing.length > 0) return; // 判重命中 → 跳过,不重复入库
await db.store({ text, vector, importance: 0.7, category });
// embedding 端点挂了 → probeEmbeddingAvailability=false
// → 退回纯全文检索,沉淀暂停,不抛错、不阻塞 agent
沉淀流程:agent_end 触发 → 抽取本轮用户消息 → embeddings.embed() 嵌入成向量 → 用 0.95 相似阈值查一次库做判重 → 命中即跳过(避免把"早上好/谢谢"这类高度重复的内容反复入库),未命中才 db.store() 落盘,带上 importance 与 category。
检索侧:LanceDB 返回 L2 距离 d,OpenClaw 用 1 / (1 + d) 把它转成 0–1 的相似度分数——距离越近分数越高,方便和其他后端的命中分数统一排序。
| 项 | 值 / 公式 | 作用 |
|---|---|---|
| 判重阈值 | 0.95 | db.search(vector, 1, 0.95) 命中即视为重复,跳过入库 |
| 相似度 | 1 / (1 + d) | 把 L2 距离 d 归一化为 0–1 分数 |
importance | 0.7 | 新记忆的默认重要度权重 |
| 沉淀时机 | agent_end | 对话结束钩子,异步沉淀不阻塞主流程 |
三步沉淀
嵌入、判重、入库三步走,把可确定的部分(哈希、查重、落盘)做稳,把 LLM/嵌入这类能容忍失败的重活放在能降级的位置。
嵌入不可用即降级
探针返回 false 时记忆退回纯全文检索,新记忆沉淀暂停;不抛错、不阻塞 agent,等端点恢复再续。
memory-wiki · Markdown vault
源码:extensions/memory-wiki。把记忆持久化成 Markdown vault——YAML frontmatter + 正文,通过 content_store 原子写入(temp → fsync → rename),配全文检索,命中后注入 prompt 段。
// 原子写入:先写临时文件、fsync、再 rename
async function writeAtomic(path: string, body: string) {
const tmp = `${path}.tmp`;
await fs.writeFile(tmp, body);
await fs.fsync(tmp); // 落盘后再换名
await fs.rename(tmp, path); // rename 原子,崩在中间也不留半截文件
}
// vault 里的一条记忆:YAML frontmatter + Markdown 正文
// ---\ntitle: ...\ntags: [...]\n---\n正文……
memory-wiki 把记忆当成文件而不是数据库行:每条记忆是一个 .md 文件,头部 YAML frontmatter 存元数据(标题、标签、时间),正文是 Markdown。写入走 content_store 的原子写入——先写 .tmp、fsync 落盘、再 rename 到位;rename 在同一文件系统上是原子操作,所以进程崩在写入中途也不会留下半截损坏文件。
检索是全文检索而非向量——这正是 lancedb 不可用时的天然降级目标。命中后,wiki 把相关条目组织成一个 prompt 段注入 system prompt,让 agent 直接读到结构化的持久记忆。
原子写入
崩溃安全的三步写盘,保证 vault 永远是一致状态,不会出现写了一半的 .md。
人类可读
每条记忆是一个 .md 文件,可以直接用 Obsidian 打开、git 版本化、手工审计与删除。
降级目标
不依赖嵌入/向量,所以是 lancedb 探针失败时的退路——向量挂了,wiki 全文检索照常工作。
git 化、可以备份、可以审计、可以删——记忆从云服务的私有 schema 抢回到了用户自己的文件路径里。active-memory · 会话间活跃上下文
按会话滑动窗口维护的即时上下文。由 SessionMemoryState 跟踪当前会话的累计 token / tool 调用数 / turn 数,三个增量同时越线才触发后台抽取——避免每隔几句话就启一次抽取作业。
type SessionMemoryState = {
tokensSinceExtract: number; // 距上次抽取累计 token
toolCallsSinceExtract: number;
turnsSinceExtract: number;
};
// 只有三个增量【同时】跨过门槛才抽取
const shouldExtract =
state.tokensSinceExtract >= 4000 && // token +4000
state.toolCallsSinceExtract >= 8 && // tool +8
state.turnsSinceExtract >= 4; // turn +4
active-memory 关心的是「当前这轮对话刚刚发生了什么」——它维护一个按会话的滑动窗口,让 agent 在多轮之间保有即时工作记忆,而不必每次都回向量库/vault 捞。但是把会话压成结构化长期记忆是一笔有成本的后台作业(要跑 LLM 抽取),所以不能每句话都做。
SessionMemoryState 用三个增量门控解决这个节流问题:跟踪距上次抽取以来累计的 token、tool 调用数、turn 数,只有这三者同时跨过门槛(token +4000、tool +8、turn +4)才触发后台抽取,把会话压成结构化记忆条目(再交给 lancedb/wiki 沉淀)。三条件取「与」,意味着既要谈得够久、又要做了够多事、还要来回够多轮,才值得动用一次抽取——一次轻量寒暄绝不会触发。
| 增量 | 门槛 | 含义 |
|---|---|---|
tokensSinceExtract | +4000 | 对话累计够长 |
toolCallsSinceExtract | +8 | 做了够多事(工具调用) |
turnsSinceExtract | +4 | 来回够多轮 |
召回与跨平台共享
记忆按 agentId + sessionKey 检索——同一用户在 Telegram、Discord、Slack… 任意平台发消息都路由回同一份记忆。这是 OpenClaw "一个助手、一套记忆、20+ 渠道" 真正成立的地方。
三个后端的检索都以 agentId + sessionKey 为键。sessionKey 是 OpenClaw 的跨平台身份锚点(如 telegram:123、discord:u_456),由 channel 层在入站时归一化产生。因为记忆挂在这把锚点上而不是某个平台账户上,所以"用户→记忆"的绑定可以跨平台共享:你在 Telegram 上告诉助手的偏好,切到 Discord 仍然记得。
每次用户发消息:先以 agentId + sessionKey 调 search() 召回相关记忆,组织成 prompt 段注入 system prompt 让 agent 读到;对话结束时 agent_end 钩子触发沉淀——抽取、嵌入、0.95 判重、入库。召回与沉淀首尾相接,构成一个跨会话、跨平台的记忆闭环。
跨平台锚点
记忆绑在 agentId + sessionKey 上而非平台账户,所以同一用户跨 20+ 渠道共享同一份记忆。
召回即注入
每条用户消息先召回相关记忆并注入 system prompt,agent 带着上下文回答。
对话即沉淀
对话结束自动沉淀,判重后入库——无需用户显式"保存记忆"。