主题
OpenClaw 源码解读(八)记忆系统
本文基于 OpenClaw 2026.3.2 源码,深入解读记忆系统(Memory System)的架构设计与实现细节。
一、模块概览
记忆系统是 OpenClaw 的"长期记忆大脑",赋予 AI 助手跨会话持久记忆能力。它能自动索引用户的笔记文件和历史会话,通过向量检索 + 全文检索的混合方式进行语义搜索,让 AI 在对话中能"回忆起"之前讨论过的内容。
1.1 核心源码分布
| 目录/文件 | 行数(约) | 职责 |
|---|---|---|
src/memory/manager.ts | ~786 | 核心管理器(单例缓存、搜索入口、生命周期管理) |
src/memory/manager-sync-ops.ts | ~1240 | 同步操作基类(文件索引、Session 增量同步、Watcher) |
src/memory/manager-embedding-ops.ts | ~807 | Embedding 操作层(批量嵌入、缓存、多 Provider 批处理) |
src/memory/manager-search.ts | ~191 | 搜索引擎(向量搜索 + 关键词搜索) |
src/memory/hybrid.ts | ~149 | 混合检索融合(向量 + BM25 分数加权融合) |
src/memory/mmr.ts | ~214 | MMR 多样性重排序(Maximal Marginal Relevance) |
src/memory/temporal-decay.ts | ~167 | 时间衰减(指数衰减,半衰期驱动) |
src/memory/query-expansion.ts | ~806 | 查询扩展(多语言停用词 + 关键词提取) |
src/memory/embeddings.ts | ~306 | Embedding Provider 工厂(6 种 Provider 创建与回退) |
src/memory/embeddings-openai.ts | ~100+ | OpenAI Embedding 实现 |
src/memory/embeddings-gemini.ts | ~100+ | Gemini Embedding 实现 |
src/memory/embeddings-voyage.ts | ~100+ | Voyage AI Embedding 实现 |
src/memory/embeddings-mistral.ts | ~100+ | Mistral Embedding 实现 |
src/memory/embeddings-ollama.ts | ~100+ | Ollama 本地 Embedding 实现 |
src/memory/batch-openai.ts | ~200+ | OpenAI 批量 Embedding API |
src/memory/batch-gemini.ts | ~200+ | Gemini 批量 Embedding API |
src/memory/batch-voyage.ts | ~200+ | Voyage 批量 Embedding API |
src/memory/memory-schema.ts | ~96 | SQLite Schema 定义(4 张表 + FTS5 虚拟表) |
src/memory/internal.ts | ~330 | 核心工具函数(分块、哈希、余弦相似度) |
src/memory/session-files.ts | ~131 | 会话文件处理(JSONL 解析 + 脱敏) |
src/memory/sqlite.ts | ~19 | Node.js SQLite 模块加载 |
src/memory/sqlite-vec.ts | ~24 | sqlite-vec 向量扩展加载 |
src/memory/types.ts | ~80 | 类型定义(接口契约) |
1.2 系统全景
┌─────────────────────────────────────────────────────────────────────────┐
│ 记忆系统全景架构 │
│ │
│ ┌─── 数据源 ─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 📄 MEMORY.md / memory/*.md 📝 sessions/*.jsonl │ │
│ │ (用户显式记忆笔记) (会话历史转录) │ │
│ │ │ │
│ └───────────┬────────────────────────────┬────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─── 索引管线 ──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ listMemoryFiles() ──→ buildFileEntry() ──→ chunkMarkdown() │ │
│ │ listSessionFiles() ──→ buildSessionEntry() ──→ chunkMarkdown() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ enforceEmbeddingMaxInputTokens() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─── Embedding Provider ─────────────────────────────────────┐ │ │
│ │ │ OpenAI │ Gemini │ Voyage │ Mistral │ Ollama │ Local(GGUF)│ │ │
│ │ │ ├── Batch API(大批量异步) │ │ │
│ │ │ └── 在线 API(小批量同步 + 重试) │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ SQLite 持久化存储 │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ meta 表 │ 索引元数据(model、provider、版本) │ │ │
│ │ │ files 表 │ 已索引文件记录(path、hash、mtime) │ │ │
│ │ │ chunks 表 │ 文本块(id、text、embedding、行号) │ │ │
│ │ │ chunks_vec │ sqlite-vec 向量索引(ANN 近似搜索) │ │ │
│ │ │ chunks_fts │ FTS5 全文索引(BM25 关键词搜索) │ │ │
│ │ │ embedding_cache│ Embedding 缓存(避免重复计算) │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─── 检索管线 ──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ search(query) │ │
│ │ │ │ │
│ │ ├── embedQueryWithTimeout() ──→ searchVector() │ │
│ │ │ (语义向量检索) │ │
│ │ │ │ │
│ │ ├── searchKeyword() via FTS5 ──→ BM25 评分 │ │
│ │ │ (关键词全文检索) │ │
│ │ │ │ │
│ │ └── mergeHybridResults() │ │
│ │ │ │ │
│ │ ├── 加权融合 (vectorWeight * vec + textWeight * text) │ │
│ │ ├── applyTemporalDecayToHybridResults() (时间衰减) │ │
│ │ └── applyMMRToHybridResults() (多样性重排序) │ │
│ │ │ │
│ │ ──→ 返回 MemorySearchResult[] │ │
│ └────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘二、类继承体系
记忆系统采用三层抽象类继承设计,逐层分离关注点:
MemoryManagerSyncOps (抽象基类)
│ - 文件系统操作、文件监听、Session 增量同步
│ - 数据库打开/Schema 初始化
│ - SQLite-vec 向量扩展加载
│ - 安全重建索引(temp-db swap)
│ - Meta 元数据管理
│
└──→ MemoryManagerEmbeddingOps (抽象中间类)
│ - Embedding 批处理与重试
│ - Embedding 缓存(load/upsert/prune)
│ - 多 Provider 批量 API(OpenAI/Gemini/Voyage)
│ - 文件索引(chunk → embed → 写入 DB)
│ - Batch 失败计数与自动降级
│
└──→ MemoryIndexManager (具体实现类)
- 搜索入口 search()
- 混合检索编排
- 单例缓存 + 工厂方法 get()
- 生命周期管理 close()
- 状态查询 status()
- 文件读取 readFile()2.1 为什么这样分层?
记忆系统/01-infographic-class-hierarchy-1775150705744.png)
- SyncOps 层:纯文件 I/O + SQLite 操作,不涉及 Embedding。即使 Embedding Provider 不可用(FTS-only 模式),文件监听和同步逻辑仍然工作。
- EmbeddingOps 层:所有与 Embedding 相关的复杂逻辑(批处理、缓存、重试、Provider 切换)集中在此,与 IO 层解耦。
- Manager 层:面向消费者的公共 API,编排搜索流程,管理实例生命周期。
三、数据库 Schema
文件 src/memory/memory-schema.ts 定义了 6 张表/索引:
3.1 meta 表 — 索引元数据
sql
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);存储 memory_index_meta_v1 键,JSON 值包含:
model— Embedding 模型名(如text-embedding-3-small)provider— Provider 标识(如openai)providerKey— Provider 配置的哈希(用于检测配置变更)sources— 数据源列表(["memory", "sessions"])chunkTokens/chunkOverlap— 分块参数vectorDims— 向量维度
当以上任何参数变更时,系统会触发完整重建索引。
3.2 files 表 — 文件追踪
sql
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);记录每个已索引文件的 SHA-256 哈希。增量同步时比较 hash 决定是否需要重新索引。
3.3 chunks 表 — 文本块
sql
CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY, -- SHA-256(source:path:startLine:endLine:hash:model)
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL, -- JSON 序列化的 float[]
updated_at INTEGER NOT NULL
);3.4 chunks_vec — 向量索引(sqlite-vec)
sql
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
id TEXT PRIMARY KEY,
embedding FLOAT[{dimensions}]
);基于 sqlite-vec 扩展,提供 ANN(近似最近邻)向量搜索,使用 vec_distance_cosine() 计算余弦距离。
3.5 chunks_fts — 全文索引(FTS5)
sql
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
text,
id UNINDEXED,
path UNINDEXED,
source UNINDEXED,
model UNINDEXED,
start_line UNINDEXED,
end_line UNINDEXED
);基于 SQLite FTS5 扩展,使用 BM25 算法进行关键词评分。只有 text 列被索引,其余列用 UNINDEXED 标记以节省空间。
3.6 embedding_cache — Embedding 缓存
sql
CREATE TABLE IF NOT EXISTS embedding_cache (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL, -- 文本内容的 SHA-256
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);避免对相同文本重复调用 Embedding API。缓存键是 (provider, model, provider_key, text_hash) 四元组。
记忆系统/02-infographic-db-schema-1775150706529.png)
四、Embedding Provider 体系
4.1 Provider 接口
typescript
// src/memory/embeddings.ts
export type EmbeddingProvider = {
id: string; // "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"
model: string; // 如 "text-embedding-3-small"
maxInputTokens?: number; // 输入 token 上限
embedQuery: (text: string) => Promise<number[]>; // 单条查询嵌入
embedBatch: (texts: string[]) => Promise<number[][]>; // 批量嵌入
};4.2 六种 Provider
| Provider | 默认模型 | 特点 |
|---|---|---|
| openai | text-embedding-3-small | 支持 Batch API,兼容 OpenAI 协议的任意端点 |
| gemini | Gemini embedding 模型 | 支持 Batch API,Google 云端 |
| voyage | Voyage AI 模型 | 支持 Batch API,专注 Embedding |
| mistral | Mistral embedding 模型 | HTTP API |
| ollama | Ollama 本地模型 | 本地运行,无需 API Key |
| local | embeddinggemma-300m-qat-Q8_0.gguf | 基于 node-llama-cpp,纯本地 GGUF 模型 |
4.3 Auto 模式选择策略
当 provider === "auto" 时:
1. 检查是否有本地 GGUF 模型文件 → 优先使用 local
2. 依次尝试 openai → gemini → voyage → mistral
3. 如果所有远程 Provider 都缺少 API Key → 降级到 FTS-only 模式(provider = null)4.4 Fallback 降级
如果主 Provider 在索引过程中抛出 Embedding 相关错误:
typescript
// manager-sync-ops.ts
private shouldFallbackOnError(message: string): boolean {
return /embedding|embeddings|batch/i.test(message);
}系统会自动切换到配置的 fallback Provider,并触发完整重建索引。
4.5 Embedding 归一化
所有 Embedding 向量在返回前都经过 L2 归一化:
typescript
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) return sanitized;
return sanitized.map((value) => value / magnitude);
}归一化后余弦相似度等价于内积,简化了后续计算。
记忆系统/03-infographic-embedding-providers-1775150707456.png)
五、索引管线详解
5.1 同步触发时机
系统通过多种触发器保持索引最新:
| 触发器 | 触发条件 | 代码位置 |
|---|---|---|
| 文件监听 | memory/ 下 .md 文件的 add/change/unlink | ensureWatcher() — chokidar |
| 会话增量 | Session JSONL 文件更新超过阈值 | ensureSessionListener() — 事件订阅 |
| 定时轮询 | 每 N 分钟自动同步 | ensureIntervalSync() |
| 搜索触发 | 搜索时如果 dirty 则触发同步 | search() 中的 onSearch 检查 |
| 会话启动 | 新会话开始时 | warmSession() |
| 强制同步 | CLI openclaw memory sync --force | 手动 |
5.2 文件发现
记忆系统/04-infographic-indexing-pipeline-1775150708282.png)
typescript
// internal.ts
export async function listMemoryFiles(workspaceDir: string, extraPaths?: string[]) {
// 扫描以下位置:
// 1. ${workspaceDir}/MEMORY.md
// 2. ${workspaceDir}/memory.md
// 3. ${workspaceDir}/memory/**/*.md(递归)
// 4. extraPaths 中配置的额外目录/文件
//
// 安全措施:跳过符号链接、去重(realpath 去重)
}5.3 Markdown 分块算法
typescript
// internal.ts
export function chunkMarkdown(content: string, chunking: { tokens: number; overlap: number }) {
// 参数:
// tokens = 目标 chunk 大小(token 数,1 token ≈ 4 chars)
// overlap = 重叠 token 数
//
// 算法:
// maxChars = Math.max(32, tokens * 4)
// overlapChars = Math.max(0, overlap * 4)
//
// 逐行累积到当前 chunk,当超过 maxChars 时:
// 1. flush() — 保存当前 chunk(记录 startLine、endLine、text、hash)
// 2. carryOverlap() — 从当前 chunk 尾部保留 overlapChars 的行
//
// 超长行会被按 maxChars 分段,确保不会产生超大 chunk
}重叠机制的意义:确保语义在 chunk 边界不会断裂。如果一段关键信息恰好在边界处,前后两个 chunk 都能包含它。
5.4 会话文件处理
typescript
// session-files.ts
export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
// 1. 读取 JSONL 文件(每行一个 JSON 记录)
// 2. 过滤出 type === "message" 的记录
// 3. 只保留 role === "user" 或 "assistant" 的消息
// 4. 提取文本内容(支持 string 和 content blocks 格式)
// 5. 文本正规化(合并换行/空格)
// 6. 敏感信息脱敏(redactSensitiveText)
// 7. 格式化为 "User: xxx\nAssistant: yyy"
// 8. 生成 lineMap(内容行 → 原始 JSONL 行号映射)
}lineMap 的设计精妙之处:session 文件是 JSONL 格式,但索引时把消息内容提取拼接成纯文本再分块。chunk 的 startLine/endLine 原本指向拼接后的文本行号,通过 remapChunkLines() 将其映射回原始 JSONL 文件行号,方便调试和定位。
5.5 增量同步 vs 全量重建
增量同步(默认路径):
对每个文件:
比较 files 表中的 hash 与当前文件 hash
如果相同 → 跳过
如果不同 → 重新 indexFile()
清理过期记录:
files 表中存在但文件系统中不存在的 → 删除对应的 files、chunks、chunks_vec、chunks_fts全量重建索引(Safe Reindex)触发条件:
typescript
const needsFullReindex =
params?.force || // 手动强制
!meta || // 首次索引
meta.model !== provider.model || // 模型变更
meta.provider !== provider.id || // Provider 变更
meta.providerKey !== providerKey || // API 配置变更
metaSourcesDiffer(meta, ...) || // 数据源变更
meta.chunkTokens !== settings.chunking.tokens || // 分块参数变更
meta.chunkOverlap !== settings.chunking.overlap ||
(vectorReady && !meta?.vectorDims); // 向量表维度缺失5.6 安全重建索引(Temp-DB Swap)
全量重建采用原子交换策略,确保索引一致性:
1. 创建临时数据库 ${dbPath}.tmp-${uuid}
2. 初始化 Schema + 种子(从旧 DB 迁移 embedding_cache)
3. 在临时 DB 上执行所有索引操作
4. 关闭旧 DB 和临时 DB
5. 原子交换文件:
a. 旧 DB → 备份路径
b. 临时 DB → 目标路径
c. 如果交换失败 → 从备份恢复
6. 删除备份
7. 重新打开目标 DB如果中间任何步骤失败,restoreOriginalState() 会恢复到交换前状态。这是事务安全的索引重建方案。
5.7 会话增量同步机制
会话同步采用字节/消息双阈值 + 防抖策略:
Session JSONL 更新事件
│
▼
scheduleSessionDirty() ─── 5 秒防抖 ───→ processSessionDeltaBatch()
│
▼
updateSessionDelta()
│ 统计文件增长的字节数和新行数(消息数)
│
▼
是否达到阈值?(deltaBytes 或 deltaMessages)
│
├── 否 → 累积等待
└── 是 → sync({ reason: "session-delta" })countNewlines() 方法用分块读取(64KB 缓冲区)统计新增行数,避免大文件整体读入内存。
记忆系统/05-infographic-sync-strategies-1775150709171.png)
六、搜索管线详解
6.1 搜索入口
typescript
// manager.ts
async search(query: string, opts?): Promise<MemorySearchResult[]> {
// 1. 可选:warmSession() 触发会话同步
// 2. 可选:dirty 时触发搜索同步
// 3. 根据模式分流:
// a. FTS-only 模式(无 Embedding Provider)→ 纯关键词搜索
// b. Vector-only 模式(FTS 不可用)→ 纯向量搜索
// c. Hybrid 模式 → 向量 + 关键词混合搜索
}6.2 向量搜索
typescript
// manager-search.ts
export async function searchVector(params) {
// 优先路径:sqlite-vec 可用
// 使用 vec_distance_cosine() 计算余弦距离
// score = 1 - distance(距离越小,分数越高)
//
// 降级路径:sqlite-vec 不可用
// 从 chunks 表加载所有 embedding
// 在内存中逐一计算 cosineSimilarity()
// 排序取 Top-K
}两条路径的性能差异:
- sqlite-vec:底层 C 实现的 ANN 索引,时间复杂度接近 O(log n)
- 降级路径:O(n) 暴力扫描,但保证在 sqlite-vec 不可用时仍能工作
6.3 关键词搜索
typescript
// manager-search.ts
export async function searchKeyword(params) {
// 1. buildFtsQuery() 将用户查询转为 FTS5 MATCH 表达式
// "hello world" → '"hello" AND "world"'
//
// 2. FTS5 MATCH 查询 + bm25() 排名
//
// 3. bm25RankToScore() 将 BM25 rank 转为 [0,1] 分数
// score = 1 / (1 + rank)
}6.4 FTS-only 模式(无 Embedding Provider)
记忆系统/06-infographic-search-pipeline-1775150710021.png)
当所有 Embedding Provider 都不可用时(如缺少 API Key),系统降级到纯 FTS 搜索:
typescript
// manager.ts - search() 中的 FTS-only 分支
if (!this.provider) {
// 1. extractKeywords() 提取关键词
// "that thing we discussed about the API" → ["discussed", "API"]
// 2. 对每个关键词分别执行 searchKeyword()
// 3. 合并去重(保留最高分)
// 4. 排序 + minScore 过滤 + maxResults 截断
}6.5 混合检索融合
typescript
// hybrid.ts
export async function mergeHybridResults(params) {
// Step 1: 按 chunk ID 合并向量和关键词结果
// 每个结果获得 (vectorScore, textScore) 二元组
// 只出现在一种结果中的条目,另一分数为 0
// Step 2: 加权融合
// score = vectorWeight * vectorScore + textWeight * textScore
// 默认:vectorWeight = 0.7, textWeight = 0.3
// Step 3: 时间衰减(可选)
// applyTemporalDecayToHybridResults()
// Step 4: MMR 多样性重排序(可选)
// applyMMRToHybridResults()
}6.6 时间衰减算法
typescript
// temporal-decay.ts
export function applyTemporalDecayToScore(params) {
// 指数衰减公式:score' = score × e^(-λ × age)
// 其中 λ = ln(2) / halfLifeDays
//
// 默认半衰期 30 天 → 30 天前的记忆分数衰减到原来的 50%
}时间戳来源的优先级:
- 文件路径中的日期:
memory/2025-01-15.md→ 2025-01-15 - 常青文件不衰减:
MEMORY.md、memory.md、memory/topics.md等被认为是长期知识 - 文件系统 mtime:最后修改时间
typescript
function isEvergreenMemoryPath(filePath: string): boolean {
// MEMORY.md, memory.md → 常青
// memory/任何非日期文件.md → 常青
// memory/2025-01-15.md → 非常青(有日期)
}6.7 MMR 多样性重排序
typescript
// mmr.ts — Carbonell & Goldstein (1998)
//
// MMR = λ × relevance - (1-λ) × max_similarity_to_selected
//
// 算法流程:
// 1. 将所有候选分数归一化到 [0, 1]
// 2. 预计算所有候选的 token 集合(用于 Jaccard 相似度)
// 3. 贪心迭代选择:
// a. 对每个未选候选,计算 MMR 分数
// b. 选择 MMR 最高的候选加入已选集合
// c. 重复直到所有候选都被选择为什么需要 MMR? 纯相似度排序可能返回大量内容高度重叠的结果。MMR 通过惩罚与已选结果相似的候选,确保返回结果的多样性。
相似度计算使用 Jaccard 相似度(而非余弦相似度),因为此时操作的是文本片段而非向量:
typescript
export function jaccardSimilarity(setA: Set<string>, setB: Set<string>): number {
// |A ∩ B| / |A ∪ B|
}七、查询扩展(Query Expansion)
7.1 多语言停用词
query-expansion.ts 包含 8 种语言的停用词表:
| 语言 | 变量 | 特殊处理 |
|---|---|---|
| 英语 | STOP_WORDS_EN | 包含时间词 (yesterday, today) |
| 西班牙语 | STOP_WORDS_ES | — |
| 葡萄牙语 | STOP_WORDS_PT | — |
| 阿拉伯语 | STOP_WORDS_AR | — |
| 中文 | STOP_WORDS_ZH | 字符级分词 + bigram |
| 韩语 | STOP_WORDS_KO | 尾缀助词剥离 |
| 日语 | STOP_WORDS_JA | 混合脚本(汉字/假名/ASCII)分割 |
| — | — | — |
7.2 CJK 分词策略
由于没有引入专门的分词器(如 jieba),系统对 CJK 文本采用字符 n-gram 策略:
typescript
// 中文:单字 + 双字
// "讨论方案" → ["讨", "论", "方", "案", "讨论", "论方", "方案"]
// 日文:分离不同脚本
// "APIのバグ修正" → ["api", "バグ", "修正", "修", "正"]
// 韩文:剥离助词
// "API를" → ["api를", "api"] (strip 를)
// "논의" → 保留原词(2 音节以上有效)7.3 关键词提取流程
typescript
export function extractKeywords(query: string): string[] {
// 1. tokenize() — 分词
// 2. 过滤停用词(8 种语言)
// 3. 过滤无效关键词(太短、纯数字、纯标点)
// 4. 去重
//
// 示例:
// "that thing we discussed about the API" → ["discussed", "api"]
// "之前讨论的那个方案" → ["讨论", "方案", "讨", "论", "方", "案"]
}记忆系统/07-infographic-query-expansion-1775150710721.png)
八、Embedding 批处理与缓存
8.1 批处理策略
typescript
// manager-embedding-ops.ts
private buildEmbeddingBatches(chunks: MemoryChunk[]): MemoryChunk[][] {
// 按 EMBEDDING_BATCH_MAX_TOKENS = 8000 分批
// 估算每个 chunk 的 UTF-8 字节数作为 token 近似
// 超大单个 chunk → 独占一个 batch
}8.2 三级 Batch API
对于 OpenAI、Gemini、Voyage 三家 Provider,支持异步批量 API:
embedChunksWithBatch()
│
├── OpenAI → embedChunksWithOpenAiBatch() → runOpenAiEmbeddingBatches()
├── Gemini → embedChunksWithGeminiBatch() → runGeminiEmbeddingBatches()
└── Voyage → embedChunksWithVoyageBatch() → runVoyageEmbeddingBatches()Batch API 支持异步轮询,适合大量文件首次索引场景。
8.3 Batch 失败处理
batchFailureCount = 0
│
batch 调用失败
│
▼
recordBatchFailure()
│ batchFailureCount += 1
│
▼
batchFailureCount >= BATCH_FAILURE_LIMIT (2) ?
│
├── 是 → 自动禁用 batch(batch.enabled = false)
│ 后续使用同步 embedBatchWithRetry()
│
└── 否 → 本次降级到同步 API,batch 保持启用8.4 Embedding 缓存机制
embedChunksInBatches(chunks)
│
▼
collectCachedEmbeddings(chunks)
│ 对每个 chunk.hash 查询 embedding_cache 表
│
├── 缓存命中 → 直接使用
└── 缓存未命中 → 调用 Provider API
│
▼
upsertEmbeddingCache() ← 写入缓存缓存键为 (provider, model, provider_key, text_hash) 四元组,确保切换 Provider 或 API 配置后缓存自动失效。
缓存还有 LRU 淘汰机制:
typescript
pruneEmbeddingCacheIfNeeded() {
// 如果缓存条目数 > maxEntries
// 按 updated_at 升序删除最旧的 excess 条
}8.5 重试策略
typescript
// 指数退避 + 抖动
embedBatchWithRetry(texts) {
// 最大重试 3 次
// 基础延迟 500ms,最大延迟 8000ms
// 每次 delay *= 2,加 20% 随机抖动
// 只对限流错误重试(429、rate_limit、5xx、cloudflare)
}记忆系统/08-infographic-batch-cache-1775150711642.png)
九、单例管理与生命周期
9.1 单例缓存
typescript
// manager.ts
const INDEX_CACHE = new Map<string, MemoryIndexManager>();
const INDEX_CACHE_PENDING = new Map<string, Promise<MemoryIndexManager>>();
static async get(params) {
// 缓存键 = agentId:workspaceDir:JSON(settings)
// 1. 检查 INDEX_CACHE → 命中返回
// 2. 检查 INDEX_CACHE_PENDING → 避免并发创建
// 3. 创建新实例 → 写入两个 Cache
}每个 Agent 的记忆系统只有一个实例,避免多个 WebSocket 连接同时打开同一个 SQLite 数据库。
9.2 构造函数做了什么?
typescript
private constructor(params) {
// 1. 打开 SQLite 数据库
// 2. 计算 providerKey(配置指纹)
// 3. 初始化 Schema(ensureSchema)
// 4. 恢复向量维度(从 meta 表)
// 5. 启动文件监听器(chokidar)
// 6. 启动 Session 事件监听器
// 7. 启动定时同步
// 8. 标记 dirty(需要首次同步)
// 9. 解析 batch 配置
}9.3 关闭流程
typescript
async close() {
// 1. 标记 closed = true
// 2. 清除所有定时器(watch、session、interval)
// 3. 关闭文件监听器
// 4. 取消 Session 事件订阅
// 5. 等待进行中的 sync 完成
// 6. 关闭 SQLite 连接
// 7. 从 INDEX_CACHE 中移除
}9.4 只读恢复
在某些场景下(如 NFS 或 Docker 卷),SQLite 可能突然变为只读模式。系统有自动恢复机制:
typescript
private async runSyncWithReadonlyRecovery(params) {
try {
await this.runSync(params);
} catch (err) {
if (this.isReadonlyDbError(err)) {
// 1. 关闭当前连接
// 2. 重新打开数据库
// 3. 重新初始化 Schema
// 4. 重新尝试同步
// 5. 记录恢复统计
}
}
}记忆系统/09-infographic-lifecycle-1775150712434.png)
十、数据流全景图
用户创建/修改 MEMORY.md
│
▼
chokidar 检测变更 ──→ markDirty() ──→ scheduleWatchSync() (防抖)
│
▼
sync({ reason: "watch" })
│
├── 检查是否需要全量重建(meta 参数比对)
│ ├── 是 → runSafeReindex()(temp-db 原子交换)
│ └── 否 → 增量同步
│
▼
syncMemoryFiles() / syncSessionFiles()
│
├── listMemoryFiles() → 发现文件
├── buildFileEntry() → 计算 hash
├── hash 比对 → 跳过未变更文件
│
▼
indexFile(entry, { source: "memory" })
│
├── readFile → chunkMarkdown() → chunks[]
├── enforceEmbeddingMaxInputTokens() → 截断过长 chunk
│
▼
embedChunksInBatches(chunks) / embedChunksWithBatch(chunks)
│
├── loadEmbeddingCache() → 缓存命中的直接用
├── buildEmbeddingBatches() → 8000 token 分批
├── embedBatchWithRetry() → Provider API 调用(含重试)
├── upsertEmbeddingCache() → 写入缓存
│
▼
写入 SQLite:
├── DELETE 旧的 chunks / chunks_vec / chunks_fts
├── INSERT INTO chunks (id, text, embedding, ...)
├── INSERT INTO chunks_vec (id, embedding_blob)
└── INSERT INTO chunks_fts (text, id, path, ...)
──────────── 搜索时 ────────────
用户问 "上周讨论的 API 设计"
│
▼
search(query)
│
├── warmSession() → 可能触发同步
│
├── FTS-only 模式?
│ └── extractKeywords() → searchKeyword() × N → 合并去重
│
├── embedQueryWithTimeout() → queryVec
│
├── searchVector(queryVec) → 向量结果(余弦距离)
│
├── searchKeyword(query) → 关键词结果(BM25)
│
▼
mergeHybridResults()
│
├── 按 ID 合并(向量分数 + 文本分数)
├── 加权融合:score = 0.7 × vec + 0.3 × text
├── 时间衰减:score × e^(-λ × age)
├── MMR 重排序:平衡相关性与多样性
│
▼
过滤 minScore → 截断 maxResults → 返回 MemorySearchResult[]记忆系统/10-infographic-data-flow-1775150713410.png)
十一、关键设计决策与权衡
11.1 为什么选择 SQLite 而非专门的向量数据库?
优势:
- 零外部依赖:Node.js 22+ 内置
node:sqlite,不需要运行额外的数据库进程 - 单文件部署:整个索引就是一个
.db文件,方便备份和迁移 - 事务原子性:利用 SQLite 的 ACID 保证索引一致性
- FTS5 内置:全文搜索不需要额外的搜索引擎
权衡:
- 向量搜索性能不如 Pinecone/Qdrant 等专用方案
- 通过
sqlite-vec扩展弥补,对于个人助手的数据规模足够
11.2 为什么 Embedding 存两份?
chunks.embedding(JSON 文本)和 chunks_vec(二进制向量表)存了同一份 Embedding,原因:
chunks_vec依赖sqlite-vec扩展,可能加载失败- 降级路径需要从
chunks.embedding解析向量做内存计算 embedding_cache是第三份"缓存",用于跨索引重建复用
11.3 混合检索的权重设计
默认 vectorWeight=0.7, textWeight=0.3 的设计考量:
- 语义搜索(向量)擅长理解意图("API 设计" ≈ "接口架构")
- 关键词搜索擅长精确匹配(搜索 "React" 就要包含 "React")
- 0.3 的文本权重确保精确匹配不会被语义淹没
- 代码中还有一个"宽松降级":如果混合分数都低于 minScore,但关键词结果存在,放宽阈值到
Math.min(minScore, textWeight)
11.4 Session 增量同步的精细控制
不是每收到一条消息就索引,而是通过双阈值控制:
deltaBytes:累积字节增量达到阈值deltaMessages:累积消息条数达到阈值
加上 5 秒防抖,避免高频对话时过于频繁地触发索引。这是一个吞吐量 vs 实时性的平衡。
记忆系统/11-infographic-design-tradeoffs-1775150714214.png)
十二、与其他模块的交互
┌─── Agent 系统 ───┐
│ │
│ Agent 运行时 │──→ MemorySearchManager.search() ──→ 注入 context
│ (Pi Agent Core) │
│ │──→ MemorySearchManager.readFile() ──→ 工具调用
│ │
│ memory-search.ts │──→ resolveMemorySearchConfig() ──→ 解析配置
└───────────────────┘
│
▼
┌─── 配置系统 ───┐
│ │
│ config.json │──→ agents.defaults.memorySearch.* ──→ 提供配置
│ (JSON5) │ provider, model, sources, chunking, hybrid, ...
└─────────────────┘
│
▼
┌─── Gateway ───┐
│ │
│ 状态查询 │──→ MemoryIndexManager.status() ──→ 显示在 status 面板
│ CLI 命令 │──→ sync({ force: true }) ──→ 手动触发同步
└────────────────┘
│
▼
┌─── 会话系统 ───┐
│ │
│ transcript- │──→ onSessionTranscriptUpdate() ──→ 触发 session 索引
│ events.ts │
└─────────────────┘记忆系统/12-infographic-module-interactions-1775150714988.png)
十三、配置参考
记忆系统的配置位于 agents.defaults.memorySearch(或 per-agent override):
json5
{
"agents": {
"defaults": {
"memorySearch": {
"provider": "auto", // "auto" | "openai" | "gemini" | "local" | ...
"model": "", // 默认由 provider 决定
"fallback": "none", // 备选 provider
"sources": ["memory", "sessions"],
"extraPaths": [], // 额外索引路径
"chunking": {
"tokens": 400, // 每个 chunk 的目标 token 数
"overlap": 50 // 重叠 token 数
},
"query": {
"maxResults": 10,
"minScore": 0.35,
"hybrid": {
"enabled": true,
"vectorWeight": 0.7,
"textWeight": 0.3,
"candidateMultiplier": 3, // 过采样倍率
"mmr": {
"enabled": false,
"lambda": 0.7
},
"temporalDecay": {
"enabled": false,
"halfLifeDays": 30
}
}
},
"sync": {
"watch": true, // 文件监听
"watchDebounceMs": 2000,
"intervalMinutes": 0, // 定时轮询(0=禁用)
"onSearch": true, // 搜索时同步
"onSessionStart": true,
"sessions": {
"deltaBytes": 4096, // Session 增量字节阈值
"deltaMessages": 10 // Session 增量消息阈值
}
},
"store": {
"path": "~/.openclaw/agents/{agentId}/memory.db",
"vector": {
"enabled": true,
"extensionPath": ""
}
},
"cache": {
"enabled": true,
"maxEntries": 10000
},
"remote": {
"batch": {
"enabled": false,
"wait": true,
"concurrency": 2,
"pollIntervalMs": 2000,
"timeoutMinutes": 60
}
},
"local": {
"modelPath": "",
"modelCacheDir": ""
}
}
}
}
}十四、总结
OpenClaw 的记忆系统是一个工程完成度极高的检索增强生成(RAG)实现,有以下亮点:
- 混合检索:向量 + BM25 双引擎,比纯向量检索更稳健
- 优雅降级:Provider 不可用 → FTS-only;sqlite-vec 不可用 → 内存向量计算;Batch 失败 → 同步 API
- 增量索引:基于 hash 的增量同步 + Session 增量阈值,避免不必要的重建
- 安全重建:temp-db 原子交换,保证索引一致性
- 多语言支持:8 种语言的停用词 + CJK 分词策略
- 可观测性:完善的 status() 状态查询和日志子系统
- 6 种 Embedding Provider:从纯本地到云端全覆盖,自动选择 + 自动回退
整个设计遵循"个人助手优先"理念:单文件 SQLite 存储、零外部服务依赖、本地可运行,同时通过 Batch API 和缓存优化支持大规模数据。