OpenClaw 记忆系统
glmos-code-explain
一、系统概述
OpenClaw 的记忆系统以纯 Markdown 文件为源数据,以 SQLite(含 FTS5 全文索引 + sqlite-vec 向量索引)为检索引擎,通过工具调用(Tool Call)将检索结果注入对话上下文。整套系统实现了"人类可读存储 + 机器高效检索"的双轨架构。
核心组件关系如下:
Markdown 文件(源数据)
├── memory/YYYY-MM-DD.md 每日记忆
└── MEMORY.md 长期记忆
│
│ chunkMarkdown() 分块
▼
SQLite 数据库(索引)
├── chunks 文本分块 + 向量(主表)
├── chunks_fts FTS5 全文索引(BM25)
├── chunks_vec sqlite-vec 向量索引(余弦相似度)
└── files 文件元数据(hash/mtime)
│
│ memory_search / memory_get 工具
▼
LLM 上下文(tool_result 消息)
二、记忆写入
2.1 每日记忆(memory/YYYY-MM-DD.md)
触发机制一:Memory Flush(自动写入)
触发条件: 每轮对话前,系统检查 Session Token 数是否超过阈值:
触发阈值 = contextWindow - reserveTokensFloor(20000) - softThresholdTokens(4000)
默认以 128K 上下文窗口为例,阈值 ≈ 104,000 tokens。
另有兜底机制:Session 文本总大小超过 2MB 时强制触发(无需等到 token 超限)。
执行过程: 系统发起一个静默的 Agentic Turn,注入专用提示词:
"Pre-compaction memory flush. Store durable memories only in memory/YYYY-MM-DD.md
(create memory/ if needed). If memory/YYYY-MM-DD.md already exists, APPEND new
content only and do not overwrite existing entries. Treat workspace bootstrap/
reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only
during this flush; never overwrite, replace, or edit them. If nothing to store,
reply with NO_REPLY."
关键约束:
- 每次 Compaction 周期内只触发一次(通过 memoryFlushCompactionCount 跟踪)
- 仅追加(Append-only),禁止覆盖已有内容
- 禁止创建带时间戳变体的文件名(如 2026-03-13-1430.md)
- MEMORY.md、SOUL.md、AGENTS.md 等 bootstrap 文件在 Flush 期间为只读
文件路径生成逻辑(resolveMemoryFlushRelativePathForRun()):
// 根据用户时区生成日期戳,例如 Asia/Shanghai 下的 "memory/2026-03-13.md"
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
return `memory/${dateStamp}.md`;
触发机制二:Session Memory Hook
触发条件: 用户执行 /new 或 /reset 命令结束当前会话时。
执行过程: Hook 读取本次 Session 的历史消息,让 LLM 生成摘要并追加到当天的 memory/YYYY-MM-DD-
2.2 长期记忆(MEMORY.md)
MEMORY.md 没有自动写入机制,完全依赖以下两种方式更新:
- 用户显式要求:"记住这件事" → LLM 调用标准 write 工具更新
- LLM 主动判断:LLM 认为某些内容值得永久保留时,自行使用文件写入工具
与每日记忆的核心区别:
| 维度 | memory/YYYY-MM-DD.md | MEMORY.md |
|---|---|---|
| 写入触发 | 自动(Token 阈值/2MB/Session 结束) | 手动(用户要求或 LLM 主动) |
| 写入模式 | 仅追加 | 自由读写 |
| 加载范围 | 所有会话 | 仅主会话(不在群组/频道加载,防止泄露) |
| 内容定位 | 原始日志、当日上下文 | 精炼的持久知识、重要决策 |
| 索引参与 | ✅ 参与混合检索 | ✅ 参与混合检索 |
三、记忆的文件格式
3.1 每日记忆格式示例
# 2026-03-13
## 上午工作
用户深入研究了 OpenClaw 的记忆系统架构:
- Memory Flush 触发机制(Token 阈值 + 2MB 兜底)
- 混合检索算法:Hybrid Search = Vector(0.7) + BM25(0.3)
- SQLite-vec 同步时机:Watcher/Session Start/Search Dirty/Interval/Delta 五条路径
## 用户偏好
- 回复语言:中文优先,技术术语保留英文
- 文档风格:需要代码引用和具体示例
## 待办
- [ ] 补充记忆格式调研报告
3.2 长期记忆格式示例
# Long-Term Memory
## 用户信息
- 姓名:Peter
- 时区:Asia/Shanghai
- 主要项目:OpenClaw AI Agent 基础设施
## 重要决策
### 2026-02-15:采用 Hybrid Search
决定向量检索权重 0.7,BM25 权重 0.3,启用 MMR 去重 + 时间衰减(半衰期 30 天)
## 用户偏好
- 代码风格:严格 TypeScript,禁用 any
- 提交方式:使用 scripts/committer 创建原子提交
四、记忆的索引存储格式
4.1 分块(Chunking)
记忆文件由 chunkMarkdown() 函数按行累积分块:
// src/memory/internal.ts
const maxChars = Math.max(32, chunking.tokens * 4); // 约 2048 字符/块
const overlapChars = Math.max(0, chunking.overlap * 4); // 约 200 字符重叠
// 超过 maxChars 时 flush 当前块,然后保留末尾 overlapChars 作为下一块的上下文
示例:memory/2026-03-13.md(9 行)被分为 2 个 chunk:
Chunk 1: 第 1-5 行(# 2026-03-13 + ## 上午工作 + 内容)
Chunk 2: 第 4-9 行(含 overlapChars 重叠 + ## 用户偏好 + 内容)
4.2 SQLite 数据库表结构
-- 文件元数据表
CREATE TABLE files (
path TEXT PRIMARY KEY, -- "memory/2026-03-13.md"
source TEXT NOT NULL, -- "memory" | "sessions"
hash TEXT NOT NULL, -- 文件内容 SHA256,用于增量同步判断
mtime INTEGER NOT NULL, -- 文件修改时间(Unix ms)
size INTEGER NOT NULL -- 文件大小(字节)
);
-- 文本分块主表(含向量)
CREATE TABLE chunks (
id TEXT PRIMARY KEY, -- hash("memory:path:startLine:endLine:chunkHash:model")
path TEXT NOT NULL, -- 所属文件路径
source TEXT NOT NULL, -- "memory" | "sessions"
start_line INTEGER NOT NULL, -- 起始行号(1-indexed)
end_line INTEGER NOT NULL, -- 结束行号(1-indexed)
hash TEXT NOT NULL, -- 块文本哈希(用于增量更新)
model TEXT NOT NULL, -- Embedding 模型名(如 "text-embedding-3-small")
text TEXT NOT NULL, -- 原始文本内容(最多 ~2048 字符)
embedding TEXT NOT NULL, -- 向量 JSON 字符串(如 "[0.234,-0.567,...]")
updated_at INTEGER NOT NULL -- 索引时间戳
);
-- FTS5 全文搜索虚表
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text, -- 被索引的文本(唯一被全文检索的字段)
id UNINDEXED, -- chunk ID(不建倒排索引)
path UNINDEXED,
source UNINDEXED,
model UNINDEXED,
start_line UNINDEXED,
end_line UNINDEXED
);
-- sqlite-vec 向量检索虚表
-- 通过 ensureVectorReady() 动态创建,存储 Float32 向量 blob
CREATE VIRTUAL TABLE chunks_vec USING vec0(
embedding float[{dims}]
);
4.3 三张表的写入关系
三张表同源、同步写入,均来自 indexFile() 方法的同一次循环:
memory/*.md / MEMORY.md
│
│ chunkMarkdown() 分块(按字符数累积 + overlap)
▼
chunks[] ──┬──→ INSERT INTO chunks (主表:text + embedding JSON)
├──→ INSERT INTO chunks_vec (向量表:Float32 blob)
└──→ INSERT INTO chunks_fts (FTS 表:text 字段建倒排索引)
重要约束: 以上写入只在有 Embedding Provider 时发生。indexFile() 开头有硬判断:
// src/memory/manager-embedding-ops.ts
if (!this.provider) {
log.debug("Skipping embedding indexing in FTS-only mode");
return; // 直接返回,三张表均不写入
}
即:若未配置任何 Embedding Provider(OpenAI / Gemini / Voyage / Mistral / Ollama / 本地模型),chunks、chunks_fts、chunks_vec 三表均为空,混合检索无法工作。
五、索引同步时机(五条路径)
索引同步由 MemoryManagerSyncOps 统一管理,共有 5 条触发路径:
| # | 路径名 | 触发时机 | 说明 |
|---|---|---|---|
| 1 | Watcher(文件监听) | 内存文件被修改(Chokidar 事件) | 去抖 1 秒后触发,设dirty=true |
| 2 | Session Start(会话启动) | 每个 Session 首次使用时 | warmSession()触发一次同步,后续同 Session 不重复 |
| 3 | Search Dirty(检索前同步) | 调用memory_search且dirty=true | 异步触发,不阻塞本次搜索 |
| 4 | Interval(定时同步) | 每隔固定时间(默认 5 分钟) | 兜底机制,防止 Watcher 遗漏 |
| 5 | Session Delta(增量同步) | Session 文件新增内容超过阈值 | 用于 Session Transcript 的增量索引 |
关键行为: 同步是非阻塞的——当 memory_search 被调用时,若检测到 dirty,会在后台触发同步,但本次搜索直接使用当前(可能过时的)索引结果,不等待同步完成。
六、记忆检索
6.1 检索触发
检索由 Agent 的系统提示强制驱动。系统提示(system-prompt.ts)注入了一条强制召回指令(Memory Recall),要求 LLM 在回答关于"历史工作/决策/日期/人物/偏好/待办"相关问题前,必须先调用 memory_search 工具。
const lines = [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
];
description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; ..."
6.2 两个检索工具
memory_search — 语义搜索
// 输入
{
query: string, // 自然语言查询,例如 "Rod 的工作时间安排"
maxResults?: number, // 默认 5(来自配置)
minScore?: number // 默认 0.35(来自配置)
}
执行混合检索流程(Hybrid Search):
查询文本
│
├──→ [BM25 全文检索]
│ buildFtsQuery("Rod 工作时间") → "Rod" AND "工作" AND "时间"
│ SELECT ... FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY bm25()
│ 结果:keywordResults(含 textScore)
│
└──→ [向量语义检索]
embedQuery("Rod 工作时间") → [0.23, -0.45, ...](查询向量)
SELECT ... FROM chunks_vec JOIN chunks ORDER BY vec_distance_cosine()
结果:vectorResults(含 vectorScore = 1 - cosine_distance)
│
▼
[RRF 加权融合(mergeHybridResults)]
merged_score = vectorWeight(0.7) × vectorScore + textWeight(0.3) × textScore
│
▼
[时间衰减(Temporal Decay,可选)]
decayed_score = merged_score × e^(-λ × ageInDays)
λ = ln(2) / halfLifeDays(30)
→ 今天:×1.00,7天前:×0.85,30天前:×0.50,90天前:×0.12
→ 常绿文件(MEMORY.md、memory/projects.md 等非日期文件)不衰减
│
▼
[MMR 多样性重排(可选)]
移除内容高度相似的冗余片段,保证结果多样性
│
▼
过滤 score < minScore(0.35),截取前 maxResults(5) 条
BM25 查询构造逻辑:
// src/memory/hybrid.ts
export function buildFtsQuery(raw: string): string | null {
const tokens = raw.match(/[\p{L}\p{N}_]+/gu)?.filter(Boolean) ?? [];
// "Rod 工作时间" → ["Rod", "工作", "时间"]
const quoted = tokens.map((t) => `"${t.replaceAll('"', "")}"`);
return quoted.join(" AND ");
// → '"Rod" AND "工作" AND "时间"'
}
BM25 分数转换:
// BM25 rank 为负数(越小越相关),转换为 0-1 的正向分数
export function bm25RankToScore(rank: number): number {
if (rank < 0) {
const relevance = -rank;
return relevance / (1 + relevance); // 映射到 (0, 1)
}
return 1 / (1 + rank);
}
memory_get — 精确读取
// 输入
{
path: string, // 文件相对路径,例如 "memory/2026-03-13.md"
from?: number, // 起始行号(1-indexed)
lines?: number // 读取行数
}
// 返回
{
text: string, // 文件内容(按行截取)
path: string // 实际读取路径
}
// 注:文件不存在时返回 { text: "", path } 而非抛出错误
6.3 检索结果格式
memory_search 返回的完整 JSON(注入为 tool_result 消息):
{
"results": [
{
"path": "memory/2026-03-13.md",
"startLine": 3,
"endLine": 7,
"score": 0.87,
"snippet": "## 上午工作\n\n用户深入研究了 OpenClaw 的记忆系统架构...\n(最多 700 字符)",
"source": "memory",
"citation": "memory/2026-03-13.md#L3-L7"
},
{
"path": "MEMORY.md",
"startLine": 12,
"endLine": 15,
"score": 0.73,
"snippet": "### 重要决策\n\n#### 2026-02-15:采用 Hybrid Search...",
"source": "memory",
"citation": "MEMORY.md#L12-L15"
}
],
"provider": "openai",
"model": "text-embedding-3-small",
"fallback": null,
"citations": "auto",
"mode": "hybrid"
}
字段说明:
- snippet:截断至 700 字符(SNIPPET_MAX_CHARS)
- citation:格式为 path#L{start}-L{end},仅在 Direct Chat(auto 模式)时附加,群组/频道中自动移除
- mode:有 Embedding Provider 时为 "hybrid",无 Provider 时为 "fts-only"(但实际上 FTS 表也为空,结果为空)
6.4 检索结果如何进入上下文
检索结果不会注入到系统提示(System Prompt)中,而是以标准的 Tool Result 消息形式进入对话历史:
用户消息: "Rod 的工作时间是什么?"
│
▼
LLM(被系统提示驱动)发起工具调用:
{ "name": "memory_search", "input": { "query": "Rod 工作时间" } }
│
▼
系统执行混合检索 → 返回 tool_result:
{ "results": [ { "snippet": "Rod 工作 Mon-Fri...", "score": 0.92 } ] }
│
▼
LLM 读取 tool_result 中的 snippet → 生成最终回复
这种设计的优势是:只有 LLM 主动查询时才消耗 Token,且每次查询的范围精确可控(不像把全部记忆塞进 System Prompt)。
七、数据源范围与边界
7.1 默认检索范围(MemorySource)
type MemorySource = "memory" | "sessions";
- memory(默认启用):工作区内的所有 *.md 文件,即 memory/YYYY-MM-DD.md、MEMORY.md,以及 memorySearch.extraPaths 指定的额外路径
- sessions(可选启用):历史 Session 的 JSONL 对话文件(~/.openclaw/agents/
/sessions/*.jsonl),经过展平处理后与记忆文件一同建索引
listMemoryFiles() 扫描的文件范围:
// 工作区下所有 .md 文件(排除 .memory/ 等隐藏目录)
const IGNORED_WATCH_DIR_NAMES = new Set([".memory", ".git", "node_modules", ...]);
7.2 文件类型支持
- Markdown 文本文件:所有 .md 文件,分块后建索引
- 多模态文件(可选):配置 memorySearch.multimodal=true 后,图片等二进制文件以 {text描述 + base64数据} 形式作为 Embedding 输入,整个文件作为一个 chunk
八、关键限制与注意事项
8.1 Embedding Provider 是核心依赖
整个检索体系的前提是必须有可用的 Embedding Provider。系统自动选择顺序:
本地模型(node-llama-cpp)→ OpenAI → Gemini → Voyage → Mistral
→ 全部失败 → FTS-only 模式(但实际 FTS 表也为空,等效于无记忆检索)
8.2 时间衰减不影响常绿文件
MEMORY.md 和非日期命名的记忆文件(如 memory/projects.md)永远不会被时间衰减降权,始终以原始分数参与排序。只有 memory/YYYY-MM-DD.md 格式的日期文件才会随时间衰减。
8.3 FTS 查询使用 AND 逻辑
BM25 全文查询采用 AND 语义(所有词必须同时出现),对长句查询可能导致 0 结果。系统有降级保护:当 Hybrid 模式下严格过滤后无结果但关键词结果存在时,会以 min(minScore, textWeight) 放宽阈值再次过滤。
8.4 同步是非阻塞的
memory_search 触发的同步为后台异步执行,不阻塞本次检索。这意味着刚写入的记忆在 Watcher 去抖(1 秒)+ 同步完成之前,对当次检索不可见。
九、完整数据流总览
┌─────────────────────────────────────────────────────┐
│ 记忆写入路径 │
│ │
│ Token 超限 / 2MB 超限 │
│ └──→ Memory Flush(静默 Agentic Turn) │
│ └──→ LLM 写 memory/YYYY-MM-DD.md(追加) │
│ │
│ /new / /reset 命令 │
│ └──→ Session Memory Hook │
│ └──→ LLM 摘要写 memory/YYYY-MM-DD.md │
│ │
│ 用户明确要求 / LLM 主动判断 │
│ └──→ LLM 用 write 工具更新 MEMORY.md │
└─────────────────────────────────────────────────────┘
│
Chokidar 文件监听
(去抖 1s → dirty=true)
│
▼
┌─────────────────────────────────────────────────────┐
│ 索引同步路径 │
│ │
│ Session Start → warmSession() → sync() │
│ Search Dirty → sync()(后台异步) │
│ Interval → 每 5 分钟 sync() │
│ Session Delta → 对话增量超阈值 → sync() │
│ │
│ sync() 调用链: │
│ listMemoryFiles() 扫描所有 .md │
│ → buildFileEntry() 计算 hash │
│ → hash 变化 → indexFile() │
│ → chunkMarkdown()(按字符分块+overlap) │
│ → embedBatch()(调用 Embedding API) │
│ → INSERT INTO chunks(text + embedding) │
│ → INSERT INTO chunks_vec(Float32 向量) │
│ → INSERT INTO chunks_fts(BM25 全文索引) │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 记忆检索路径 │
│ │
│ 系统提示注入 Memory Recall 强制指令 │
│ └──→ LLM 调用 memory_search(query) │
│ │ │
│ ├──→ BM25 全文检索 │
│ │ chunks_fts MATCH "词1" AND "词2" │
│ │ → keywordResults(含 textScore) │
│ │ │
│ ├──→ 向量语义检索 │
│ │ embedQuery() → 查询向量 │
│ │ vec_distance_cosine() 在 chunks_vec │
│ │ → vectorResults(含 vectorScore) │
│ │ │
│ └──→ 融合 & 精排 │
│ RRF 加权:0.7×vector + 0.3×BM25 │
│ 时间衰减(可选,半衰期 30 天) │
│ MMR 多样性重排(可选) │
│ → 过滤 score < minScore(0.35) │
│ → 截取 top-N(5) │
│ │ │
│ ▼ │
│ tool_result → LLM 上下文(对话消息) │
│ { results: [{snippet, path, score, ...}] } │
└─────────────────────────────────────────────────────┘
十、配置参考
{
agents: {
defaults: {
// 记忆工作区路径
workspace: "~/.openclaw/workspace",
// Memory Flush 配置
compaction: {
reserveTokensFloor: 20000,
memoryFlush: {
enabled: true,
softThresholdTokens: 4000, // 触发软阈值
forceFlushTranscriptBytes: "2mb", // 强制触发阈值
prompt: "Write any lasting notes to memory/YYYY-MM-DD.md...",
systemPrompt: "Session nearing compaction. Store durable memories now."
}
},
// 记忆检索配置
memorySearch: {
provider: "auto", // auto | openai | gemini | voyage | mistral | ollama | local
model: "text-embedding-3-small", // Embedding 模型
sources: ["memory"], // 可加 "sessions"
query: {
maxResults: 5,
minScore: 0.35,
hybrid: {
enabled: true,
vectorWeight: 0.7,
textWeight: 0.3,
candidateMultiplier: 10, // 候选集 = maxResults × 10
mmr: { enabled: false, lambda: 0.5 },
temporalDecay: { enabled: false, halfLifeDays: 30 }
}
},
chunking: {
tokens: 512, // 每块约 512 tokens(×4 = 2048 字符)
overlap: 50 // 相邻块重叠约 50 tokens
},
store: {
vector: { enabled: true }
},
sync: {
onSessionStart: true,
onSearch: true,
intervalMs: 300000 // 5 分钟定时同步
}
}
}
}
}