记忆是什么
第九篇讲过,语言模型没有眼睛,只有上下文窗口。这个局限还有一层推论:它也没有跨对话的原生记忆。每次调用 API,对模型来说都是全新的开始。你上周跟它说的事、它帮你建立的工作偏好、你反复强调的行为规则——只要不在当前的上下文窗口里,就等同于从未发生过。
这个问题对"偶尔用一次的工具"来说无关紧要,但对"24 小时在线的个人助手"来说是致命的。一个每天陪你工作、帮你处理邮件、替你监控系统的 Agent,如果每次对话都从零开始,你每次都要重新自我介绍,重新解释背景,重新建立上下文——它不是助手,是一个失忆症患者。
OpenClaw 的记忆系统是这个问题的完整工程答案。它的核心哲学用一句话概括:文件是记忆的真正容器,数据库只是检索加速器。
三层记忆的流动路径
在展开技术细节之前,先建立一个整体感:OpenClaw 的记忆按生命周期分为三层,信息在这三层之间流动。
热记忆是当前活跃的、每次执行都被完整注入上下文的内容。它的载体是第九篇介绍的 MEMORY.md 文件——跨 Session 存活的持久性知识,决策偏好、用户背景、不变的行为规约。热记忆的特点是零检索成本,每次都在眼前,但代价是直接消耗 Token 预算。因此它必须保持精简:只有真正需要永远记住的事实才值得放在这里。
温记忆是按日期组织的每日日志文件,存在 memory/YYYY-MM-DD.md 里。今天的草稿、进行中的任务、临时的上下文,记在这里。这些文件不会被自动注入,只有当 Agent 主动调用 memory_search 或 memory_get 工具时才会被检索加载。温记忆的特点是按需访问,不占固定预算,但需要 Agent 主动触发检索。
冷记忆是上下文压缩时自动提炼的内容。当对话历史因为太长触发压缩(第八篇详细讲过),压缩流程在丢弃历史之前,会先让模型把关键信息写入记忆文件,变成温记忆或热记忆。这是记忆系统与 Agent 执行流程之间最重要的联动机制——历史可以被压缩,但关键信息不应该随压缩而消失。
内置后端:SQLite 的恰当选择
OpenClaw 的默认记忆检索后端是 builtin,实现在 src/memory/manager.ts 的 MemoryIndexManager 里,底层用 SQLite 存储索引。
选择 SQLite 而不是专门的向量数据库,是一个"克制的正确"。
个人 AI 助手的部署环境极其多样:家用 Mac Mini、$6 的 VPS、树莓派。专用向量数据库(Pinecone、Weaviate、Qdrant)需要运行独立的服务进程,有端口、有连接字符串、有进程管理开销。PostgreSQL 需要 initdb、权限配置、用户管理。这些对开发者来说轻车熟路,但对"想要一个 AI 助手"的普通用户来说是不可接受的安装摩擦。
SQLite 只是一个文件。没有服务器进程,没有端口,备份就是复制文件。加上 sqlite-vec 扩展支持向量相似度搜索,加上内置的 FTS5 全文搜索模块,一个功能完整的本地 RAG 栈就在单个二进制文件里了。
索引库的位置在:
~/.openclaw/memory/<agentId>.sqlite可以通过 agents.defaults.memorySearch.store.path 自定义路径,路径里支持 {agentId} 占位符,多 Agent 部署时各自独立索引。
索引的四张表
SQLite 库里有四张核心表,共同构成检索系统的基础。
files 表是变更追踪器,记录每个已索引文件的修改时间(mtime)、文件大小和内容哈希。每次执行前,MemoryIndexManager 先扫描这张表,跳过内容没有变化的文件,只对真正有改动的文件重新分块和嵌入,大幅降低增量更新的成本。
chunks 表是检索的核心。每个文件被切分成约 400 Token 的文本块,相邻块之间有 80 Token 的重叠(overlap),防止关键信息恰好落在块的边界上被切断。每条记录存储块的文字内容、原始文件路径、行号范围、以及 JSON 序列化的向量嵌入。行号范围是检索结果引用(citations)功能的基础——返回结果时可以精确指向"来自哪个文件的第几行到第几行"。
chunks_vec 是 SQLite 的 sqlite-vec 扩展创建的虚拟表,以二进制浮点向量格式存储嵌入,支持高效的余弦相似度搜索 SQL 查询。如果 sqlite-vec 扩展不可用(比如某些特殊的 Linux 环境),这张表不会被创建,系统优雅降级到纯关键词搜索。
chunks_fts 是 SQLite 的 FTS5 虚拟表,对文字内容建立倒排索引,支持 BM25 排序的全文检索。如果 FTS5 不可用,系统优雅降级到纯向量搜索。
混合检索:向量 + BM25 的加权融合
OpenClaw 不单独依赖向量搜索,而是把向量搜索和 BM25 关键词搜索的结果融合,定义在 src/memory/hybrid-search.ts 里。
两种搜索方式各有短板。纯向量搜索擅长语义相似("如何配置服务器"能找到"修改 gateway 的运行设置"这样的内容),但对精确词汇匹配很弱——你记得某个端口号是 18789,或者某个错误码是 ERR_AUTH_EXPIRED,向量搜索不一定能把它精确找回来。纯 BM25 恰恰相反:精确词汇匹配很强,语义泛化很弱——"配置服务器"无法匹配"修改 gateway 设置"。
混合搜索的实现是:两条检索通道并行运行,各自取 maxResults × candidateMultiplier 条候选结果,然后用加权分数融合公式合并排序:
finalScore = vectorWeight × vectorScore + textWeight × bm25NormalizedScore其中 vectorWeight + textWeight 在配置解析时被归一化到 1.0,所以它们行为上就是百分比。bm25NormalizedScore 把 BM25 的原始分(越低越好)转换到 [0, 1] 区间,与向量相似度分数(越高越好)对齐,才能做有意义的加权。
默认配置下两者各占 50%,可以按需调整:
{agents: {defaults: {memorySearch: {query: {hybrid: {enabled: true,vectorWeight: 0.7,textWeight: 0.3,candidateMultiplier: 4,}}}}}}
在 vectorWeight: 0.7 的配置下,语义相关性的权重更高,适合主要靠自然语言表达的记忆内容;如果你的记忆文件里有大量代码、ID、错误字符串等精确词汇,适当提高 textWeight 能改善这类内容的召回率。
混合搜索还支持 MMR(Maximal Marginal Relevance)多样性重排:在召回的候选集里主动惩罚过于相似的结果,保证返回给模型的片段覆盖不同的信息角度,而不是返回五条几乎一模一样的内容。mmr.lambda 控制相关性与多样性之间的权衡,0.0 表示最大多样性,1.0 表示完全按相关性排序。
时间衰减(temporal decay)是另一个可选的后处理步骤:通过文件名中的日期信息(daily logs 天然具备这个属性),对较旧的记录施加轻微的分数折扣,让近期内容在召回时略占优势。衰减系数和半衰期都可以配置。
嵌入 Provider:六种选择
向量搜索的质量上限由嵌入模型决定。OpenClaw 支持六种嵌入 Provider,通过 memory.embeddingProvider 配置选择。
openai 使用 OpenAI 的 text-embedding-3-small(1536 维)或 text-embedding-3-large,质量高,但每次检索需要调用外部 API,有网络延迟和费用成本。gemini、voyage、mistral 类似,各有自己的嵌入 API 和维度规格。
ollama 通过本地运行的 Ollama 守护进程调用本地嵌入模型,完全离线,零 API 成本,适合隐私要求高的场景。
local(GGUF 格式)是最有意思的选项:不需要 Ollama,直接通过 node-llama-cpp 库在进程内加载 GGUF 格式的嵌入模型文件运行推理,第一次使用时自动从 HuggingFace 下载模型(约几百 MB),之后完全离线。嵌入维度由具体模型决定,OpenClaw 会把实际使用的维度记录在 files 表里,检测到嵌入 Provider 切换时自动重建索引,防止不同维度的向量混用导致搜索结果错误。
QMD 后端:Tobi Lütke 的礼物
内置 SQLite 后端适合使用初期,随着记忆文件积累,检索质量会逐渐出现瓶颈:词汇不匹配导致语义相关的内容被漏掉,没有重排序导致最相关的结果不在最前面。
memory.backend = "qmd" 切换到 QMD 后端,解决这些问题。
QMD(Query Markdown Documents)是 Shopify 创始人 Tobi Lütke 开发的本地 Markdown 全文搜索工具,由他在加入 OpenClaw 社区后贡献的第一个重大集成。它在 BM25 + 向量检索的基础上增加了第三层:LLM 重排序。初步检索得到的候选集,由一个本地运行的小型 GGUF 语言模型重新打分排序,把真正最相关的结果提到最前面。查询扩展(query expansion)在搜索前先用模型把查询扩展成多个语义相关的变体,覆盖更多可能的表述方式。
QMD 作为独立的 sidecar 进程运行,OpenClaw 通过子进程调用 qmd CLI 与它交互,实现在 src/memory/qmd-manager.ts 的 QmdMemoryManager 里。索引数据存在 Gateway 管理的独立 XDG home 目录下:
~/.openclaw/agents/<agentId>/qmd/├── xdg-config/ ← QMD 的配置目录├── xdg-cache/ ← 模型缓存│ └── models/ ← 符号链接到 ~/.cache/qmd/models(多 Agent 共享模型文件)└── index.sqlite ← QMD 的内部索引
QMD 支持多个独立的"集合"(collection),每个集合对应一组文件路径和 glob 匹配规则。默认会创建两个集合:memory-root(覆盖 MEMORY.md 和 memory/**/*.md)以及可选的 sessions(把最近的对话记录导出为清洁的 User/Assistant 对话片段,让 memory_search 能跨 Session 回忆近期对话)。memory.qmd.paths 配置允许添加任意外部路径,比如你的 Obsidian 笔记库或工作项目的 README 目录,都可以被 QMD 一并索引。
QmdMemoryManager 在 Gateway 启动时就开始后台运行索引更新(qmd update + qmd embed),默认每 5 分钟刷新一次(memory.qmd.update.interval),嵌入更新有 15 秒防抖延迟防止频繁写入冲突。runWithQmdEmbedLock 全局互斥锁防止多个 Agent 的嵌入任务并发运行互相竞争。
QMD 的代价是额外的运维复杂度:需要单独安装 qmd 二进制、SQLite 需要支持扩展的构建版本(macOS 上 brew install sqlite)、首次运行下载约 2GB 的 GGUF 模型。
两工具:memory_search 与 memory_get
记忆系统对 Agent 暴露两个工具,注册逻辑在 src/agents/tools/memory-tool.ts 里。如果 resolveMemorySearchConfig() 返回空(记忆搜索被禁用),两个工具都不出现在工具列表里。
memory_search 接受自然语言查询,返回最相关的记忆片段列表。每条结果包含片段文字、相关性分数、以及来源引用(Source: path#行号)。memory.citations 配置控制引用的显示模式:auto 模式下,当检测到 QMD 后端支持行号引用时显示引用,否则隐藏;on 强制显示;off 禁止显示(但 Agent 仍然拿到路径信息,可以用 memory_get 精确读取)。
memory_get 接受文件路径和可选的行号范围,直接读取指定内容。这是精确定位的工具,通常在 memory_search 找到相关片段后,用来读取完整的上下文段落。值得注意的是,如果指定的文件不存在(比如今天的 daily log 还没有被写入),memory_get 不会抛出异常,而是返回 {text: "", path} ——Agent 可以直接判断"今天还没有日志"并继续工作,不需要用 try/catch 包裹每次调用。
压缩前记忆写入:不让历史白白消失
第八篇介绍上下文压缩时提到,压缩前会触发一次记忆写入。这个机制值得在这里展开说清楚,因为它是热记忆、温记忆、冷记忆三层之间流动的关键管道。
当 Agent 执行遇到上下文溢出,触发 compactEmbeddedPiSessionDirect() 时,压缩流程做的第一件事不是丢弃历史,而是启动一次专用的 Agent 执行——用一套专门的 System Prompt,让模型扫描即将被压缩的对话历史,把其中值得长期记住的内容主动写入记忆文件:决策结果写入 MEMORY.md,今天的进展写入 memory/YYYY-MM-DD.md,临时状态写入合适的工作文件。
这个步骤完成之后,MemoryIndexManager 触发增量重建索引,确保刚写入的内容立刻进入检索系统,然后才开始真正的历史压缩。
结果是:从记忆系统的角度看,压缩前的对话历史并没有消失,它以结构化的方式沉淀到了温记忆或热记忆里。下一次对话开始时,memory_search 能找回这段历史的精华,模型看起来就像真的记住了发生过的事。
优雅降级:记忆系统从不硬失败
getMemorySearchManager() 返回的不是裸的 QmdMemoryManager 实例,而是用 FallbackMemoryManager 包裹后的对象。
FallbackMemoryManager 的行为是:第一次 search() 调用失败时,记录错误日志,然后静默降级到内置 SQLite 后端继续提供服务。这意味着 QMD 二进制崩溃、索引文件损坏、嵌入模型下载失败,都不会让 Agent 的记忆功能彻底失效——只是悄悄退化到质量稍低的内置检索,Agent 可以继续正常工作。
同样的降级逻辑贯穿整个检索栈:sqlite-vec 不可用时降级到纯 BM25;FTS5 不可用时降级到纯向量;嵌入 Provider 返回零向量时跳过向量搜索只用 BM25。每一层失败都有对应的降级路径,系统整体的鲁棒性远高于任何单一组件的可靠性。
记忆作用域:哪些 Session 能看到记忆
不是所有 Session 都有权检索记忆。memory.qmd.scope(QMD 后端)或 memorySearch.scope(内置后端)通过规则列表控制记忆检索的作用域,格式与通道系统的 dmPolicy 一脉相承:
{memory: {qmd: {scope: {default: "deny",rules: [{ action: "allow", match: { chatType: "direct" } },{ action: "allow", match: { keyPrefix: "main" } },]}}}}
这个配置的效果是:只有私信(direct)Session 和主 Session 能检索记忆,群组和定时任务 Session 无法访问。防止你在公共 Discord 群里被人套出你的个人历史。
小结
OpenClaw 的记忆系统是一个用文件作为真正容器、用 SQLite 做检索加速的本地 RAG 实现,具备以下核心特质:零服务器依赖、单文件可移植、优雅降级、三层流动(热/温/冷)、压缩前自动写入、作用域访问控制。
选择 QMD 还是内置后端,不是技术信仰之争,而是实际场景的权衡:记忆文件小且刚起步,内置混合搜索足够;运行了几个月、记忆文件积累了几千条、开始出现召回遗漏,换 QMD;介于两者之间,先启用内置的 hybrid search。
下一篇,我们转向 Skills 技能系统——那个让任何人都能用一个 Markdown 文件为 Agent 添加新能力的设计。
源码参考:src/memory/manager.ts · src/memory/qmd-manager.ts · src/memory/search-manager.ts · src/memory/hybrid-search.ts · src/memory/embeddings.ts · src/agents/tools/memory-tool.ts基于 commit bf6ec64f 版本
夜雨聆风