openclawmemorysource-codeimplementationsqlitevector-search

深度解析 openclaw/src/memory/ 目录下的核心实现

关键设计决策(宏观视角)

在深入源码之前,先理解 Memory 系统的三个核心设计问题。

1. Memory 如何与 Agent 集成?

答案:作为 Tool 提供给 LLM,而非自动注入。

flowchart TB
    subgraph "集成架构"
        A[Agent] -->|System Prompt 强制要求| B["先调用 memory_search"]
        B -->|Tool Call| C[memory_search]
        B -->|Tool Call| D[memory_get]
        C -->|查询| E[Memory System]
        D -->|读取| E
    end

关键决策对比

方案机制优点缺点
Tool-based (OpenClaw)Agent 显式调用✅ 可控、可观测、精确需要 AI 配合
Auto-injection系统自动注入无需 AI 参与❌ 可能注入无关内容、Token 浪费

System Prompt 强制要求 (src/agents/system-prompt.ts):

"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."

2. 什么东西写入了记忆?

答案:三层分层架构,价值递减。

flowchart TB
    subgraph "三层记忆模型"
        S[Session Memory] -->|临时| D[Daily Memory]
        D -->|短期| P[Permanent Memory]
    end
    
    S -.->|自动记录| S2[JSON Lines<br/>可能丢失]
    D -.->|Agentic Flush| D2[memory/YYYY-MM-DD.md<br/>AI 判断写入]
    P -.->|人工维护| P2[MEMORY.md<br/>长期保留]
层级存储写入方式生命周期
Sessionsessions/*.jsonl自动记录当前会话,可能丢失
Dailymemory/YYYY-MM-DD.mdAgentic FlushAI 判断写入,按日期组织
PermanentMEMORY.md人工/AI长期保留,精选知识

Agentic Flush 机制:上下文快满时,给 AI 一个特殊 Turn,让 AI 自主判断什么值得写入 memory/YYYY-MM-DD.md。如果无需写入,回复 NO_REPLY 静默跳过。

3. 聊天历史如何保留?

答案:两种方式互补。

flowchart LR
    A[聊天历史] --> B[方式1: Context Window]
    A --> C[方式2: Memory Search]
    
    B -->|Pi-mono 自动维护| D["messages 数组"]
    D -->|传入 LLM| E[自然理解]
    D -->|限制| F[受限于 context window]
    
    C -->|配置 sources| G[索引 Session JSONL]
    G -->|memory_search| H[检索增强]
    H -->|显式提供| E
  • Context Window:Pi SDK 自动维护,适合当前会话
  • Memory Search:配置 sources: ["sessions"],通过 Tool 检索历史

目录结构

src/memory/
├── manager.ts                    # MemoryIndexManager 主类
├── manager-sync-ops.ts          # 同步操作基类
├── manager-embedding-ops.ts     # 嵌入操作基类
├── manager-search.ts            # 搜索实现
├── search-manager.ts            # 后端路由
├── hybrid.ts                    # 混合搜索算法
├── mmr.ts                       # MMR 多样性重排
├── temporal-decay.ts            # 时间衰减
├── embeddings*.ts               # 各 Provider 实现
├── memory-schema.ts             # 数据库 Schema
├── qmd-manager.ts               # QMD 后端
└── types.ts                     # 类型定义

核心类分析

1. MemoryIndexManager 主类

文件: manager.ts

职责: 索引管理器的主入口,协调同步、嵌入、搜索三大功能。

类继承设计:

flowchart BT
    M[MemoryIndexManager]
    S[SearchOperations]
    E[EmbeddingOperations]
    Y[SyncOperations]
    
    M -->|extends| S
    S -->|extends| E
    E -->|extends| Y

设计意图: 通过继承链分离职责,避免”上帝类”。

// manager.ts
class MemoryIndexManager extends SearchOperations {
  // 主入口方法
  async search(query: string, options?: SearchOptions): Promise<Result[]> {
    // 1. 检查脏数据
    if (this.dirty) await this.sync();
    
    // 2. 执行搜索(在父类 SearchOperations 中实现)
    return super.search(query, options);
  }
}
 
// manager-search.ts
class SearchOperations extends EmbeddingOperations {
  async search(query: string, options?: SearchOptions) {
    // 向量搜索 + 关键词搜索 + 融合
  }
}
 
// manager-embedding-ops.ts  
class EmbeddingOperations extends SyncOperations {
  async embedChunks(chunks: Chunk[]) {
    // 批量嵌入 + 缓存
  }
}
 
// manager-sync-ops.ts
class SyncOperations {
  async sync() {
    // 文件同步 + 索引更新
  }
}

为什么这样设计?

  1. 单一职责: 每个基类只负责一个方面
  2. 可测试性: 可以单独测试每个基类
  3. 可扩展性: 新增功能只需添加基类

2. 同步机制

文件: manager-sync-ops.ts

核心方法: sync()

async sync(force: boolean = false): Promise<void> {
  // 防止并发同步
  if (this.syncing) return;
  this.syncing = true;
  
  try {
    // 判断是否需要全量重建
    if (await this.needsFullReindex() || force) {
      await this.runSafeReindex();
    } else {
      // 增量同步
      await this.syncMemoryFiles();
      await this.syncSessionFiles();
    }
  } finally {
    this.syncing = false;
    this.dirty = false;
  }
}

精妙之处:

  • syncing 标志防止并发冲突
  • 自动判断全量重建 vs 增量同步
  • finally 确保状态清理

安全重建: runSafeReindex()

private async runSafeReindex(): Promise<void> {
  const tmpPath = `${this.dbPath}.tmp-${uuid()}`;
  const backupPath = `${this.dbPath}.backup-${uuid()}`;
  
  try {
    // 1. 在临时数据库重建
    await this.rebuildInDatabase(tmpPath);
    
    // 2. 关闭原数据库连接
    await this.closeDatabase();
    
    // 3. 原子替换
    await rename(this.dbPath, backupPath);  // 旧 -> 备份
    await rename(tmpPath, this.dbPath);      // 新 -> 正式
    
    // 4. 删除备份
    await unlink(backupPath);
    
    // 5. 重新连接
    await this.openDatabase();
  } catch (err) {
    // 回滚:从备份恢复
    if (await fileExists(backupPath)) {
      await rename(backupPath, this.dbPath);
    }
    throw err;
  }
}

为什么安全?

  • 原子性: 通过文件重命名实现原子操作
  • 零停机: 重建时读旧库,完成后瞬间切换
  • 可回滚: 任何步骤失败都能恢复原状

文件监控

private setupFileWatcher(): void {
  this.watcher = chokidar.watch([
    "MEMORY.md",
    "memory.md", 
    "memory/**/*.md"
  ], {
    ignoreInitial: true,
    awaitWriteFinish: {
      stabilityThreshold: 1500,  // 1.5秒防抖
      pollInterval: 100
    }
  });
  
  this.watcher.on("change", () => this.markDirty());
}
 
private markDirty(): void {
  this.dirty = true;
  // 防抖:1.5秒后触发同步
  setTimeout(() => {
    if (this.dirty && !this.syncing) {
      this.sync();
    }
  }, 1500);
}

设计亮点:

  • chokidar 跨平台文件监控
  • awaitWriteFinish 防抖,避免文件写入过程中触发
  • setTimeout 二次防抖,批量处理文件变更

4. Memory Flush 机制(核心!)

文件: src/auto-reply/reply/memory-flush.ts

这是 OpenClaw Memory 系统最关键的设计——Agentic Flush 的完整实现。

4.1 Prompt 构造流程

基础 User Prompt(第 13-18 行):

export const DEFAULT_MEMORY_FLUSH_PROMPT = [
  "Pre-compaction memory flush.",
  "Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).",
  "IMPORTANT: If the file already exists, APPEND new content only and do not overwrite existing entries.",
  `If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");

基础 System Prompt(第 20-24 行):

export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
  "Pre-compaction memory flush turn.",
  "The session is near auto-compaction; capture durable memories to disk.",
  `You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");

4.2 Prompt 处理三步曲

Step 1: 时间戳替换resolveMemoryFlushPromptForRun

const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp);
// 添加时间线
return `${withDate}\n${timeLine}`;
// 结果示例: "Current time: 2026-01-15 14:30:00 PST"

关键细节:使用用户配置的时区,不是 UTC!从 cfg.agents.defaults.userTimezone 获取。

Step 2: NO_REPLY 强制机制ensureNoReplyHint

function ensureNoReplyHint(text: string): string {
  if (text.includes(SILENT_REPLY_TOKEN)) {
    return text;
  }
  // 如果用户自定义 prompt 忘了加 NO_REPLY,自动追加
  return `${text}\n\nIf no user-visible reply is needed, start with NO_REPLY.`;
}

Step 3: 合并 System Prompt

const flushSystemPrompt = [
  params.followupRun.run.extraSystemPrompt,
  memoryFlushSettings.systemPrompt,
].filter(Boolean).join("\n\n");

4.3 最终构造的完整 Prompt

【System Prompt】
Pre-compaction memory flush turn.
The session is near auto-compaction; capture durable memories to disk.
You may reply, but usually NO_REPLY is correct.

【User Prompt】
Pre-compaction memory flush.
Store durable memories now (use memory/2026-01-15.md; create memory/ if needed).
IMPORTANT: If the file already exists, APPEND new content only and do not overwrite existing entries.
If nothing to store, reply with NO_REPLY.
Current time: 2026-01-15 14:30:00 PST

If no user-visible reply is needed, start with NO_REPLY.

4.4 可配置项

通过 agents.defaults.compaction.memoryFlush

配置项默认值说明
enabledtrue是否启用 Flush
softThresholdTokens4000触发阈值(距离上限的 token 数)
prompt见上文覆盖 User Prompt
systemPrompt见上文覆盖 System Prompt
forceFlushTranscriptBytes2MB强制触发阈值(文件大小)

4.5 触发条件

// shouldRunMemoryFlush() 逻辑
if (totalTokens >= contextWindow - reserveTokens - softThreshold) {
  // 且本 compaction cycle 未执行过
  if (!hasAlreadyFlushedForCurrentCompaction(entry)) {
    return true;  // 触发 Flush!
  }
}

关键洞察

  • 软阈值:token 接近上限时触发(默认提前 4000 tokens)
  • 硬阈值:transcript 文件超过 2MB 强制触发
  • 防重:每个 compaction cycle 只触发一次

5. 长期记忆 MEMORY.md 管理

核心区别:与 Daily 不同,MEMORY.md 没有自动 Flush 机制

5.1 为什么没有 Flush 机制?

这是 OpenClaw 的刻意设计选择

Daily Memory:    自动 Flush → 防止信息丢失(兜底机制)
MEMORY.md:       无自动机制 → 保证记忆质量(人工精选)

设计哲学

  • Daily 是”原始记录”——防止上下文压缩导致的信息丢失
  • MEMORY.md 是”精选知识”——只有经过人工判断值得长期保留的才写入

5.2 AGENTS.md 如何指导 AI 写入

虽然没有 Flush Prompt,但 AI 通过 AGENTS.md 持续获得写入指导。

AGENTS.md 是 Bootstrap 文件src/agents/workspace.ts 第 25 行):

export const DEFAULT_AGENTS_FILENAME = "AGENTS.md";

注入 System Prompt 流程

  1. 加载 Bootstrap 文件src/agents/pi-embedded-helpers/bootstrap.ts):
export function buildBootstrapContextFiles(files: WorkspaceBootstrapFile[], ...)
  → 处理 AGENTS.md 等文件
  1. 注入 Project Contextsrc/agents/system-prompt.ts 第 612-644 行):
const contextFiles = params.contextFiles ?? [];
if (validContextFiles.length > 0) {
  lines.push("# Project Context", "");
  for (const file of validContextFiles) {
    lines.push(`## ${file.path}`, "", file.content, "");  // AGENTS.md 内容注入
  }
}

AGENTS.md 中的记忆指导docs/reference/templates/AGENTS.md):

### 🧠 MEMORY.md - Your Long-Term Memory
- **ONLY load in main session** (direct chats with your human)
- **DO NOT load in shared contexts** (security consideration)
- You can **read, edit, and update** MEMORY.md freely in main sessions
- Write significant events, thoughts, decisions, opinions, lessons learned
- This is your curated memory — the distilled essence, not raw logs
 
### 📝 Write It Down - No "Mental Notes"!
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
- When you learn a lesson → document it so future-you doesn't repeat it

5.3 写入触发方式

方式 1: 用户主动要求(主要方式)

用户: "记住我喜欢 VS Code"
用户: "把这个决策写入长期记忆"
用户: "remember this"

AI 判断:长期偏好/重要决策 → 写入 MEMORY.md

方式 2: AI 自主判断

AI: "我注意到您经常提到这个偏好,Should I save this to MEMORY.md?"
或
AI 直接执行:完成重要配置后自动写入

方式 3: 人工直接编辑

  • 用户直接编辑 MEMORY.md 文件
  • 文件监控自动索引(与 Daily 相同机制)

5.4 写入实现机制

与 Daily 相同,没有专门的 append API

// AI 的执行逻辑:
1. read file("MEMORY.md")
   → 得到现有内容
 
2. 在内存中拼接:
   existingContent + "\n" + newContent
 
3. write file("MEMORY.md", combinedContent)
   → 原子替换整个文件

关键区别

  • Daily:被 Prompt 强制要求写入(被动)
  • MEMORY.md:AI 根据 AGENTS.md 指导自主决定(主动)

5.5 记忆读取:同样通过 AGENTS.md 指导

重要澄清:OpenClaw 没有代码层面的自动读取机制!

文档中的误解

❌ 错误理解:"启动时自动读取 today + yesterday"
✅ 正确理解:"AGENTS.md 指导 AI 去读取"

AGENTS.md 中的读取指导docs/reference/templates/AGENTS.md 第 20-22 行):

## Every Session
 
Before doing anything else:
 
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context
4. **If in MAIN SESSION**: Also read `MEMORY.md`

实现机制

AGENTS.md (包含读取指导)
    ↓
buildBootstrapContextFiles() 处理
    ↓
注入 System Prompt 的 "# Project Context"
    ↓
AI 看到:"Read memory/YYYY-MM-DD.md (today + yesterday)"
    ↓
AI 自主执行:调用 read 工具读取文件

关键洞察

  • ❌ 没有代码自动读取 daily 文件
  • ❌ 没有代码自动读取 MEMORY.md
  • ✅ 完全依赖 AGENTS.md 的 Prompt 指导
  • ✅ AI 自主决定是否执行(虽然 AGENTS.md 强烈建议执行)

对比总结

操作Daily (memory/*.md)Permanent (MEMORY.md)
写入Flush Prompt 强制触发AGENTS.md 指导 + AI 自主
读取AGENTS.md 指导读取AGENTS.md 指导读取
自动性写入自动,读取非自动读写都非自动

6. 嵌入管理

文件: manager-embedding-ops.ts

批量嵌入策略

private async embedChunksWithBatch(
  chunks: Chunk[],
  provider: EmbeddingProvider
): Promise<Embedding[]> {
  // 1. 检查缓存(关键性能优化)
  const uncachedChunks: Chunk[] = [];
  for (const chunk of chunks) {
    const cached = await this.getCachedEmbedding(chunk.hash);
    if (cached) {
      embeddings.push(cached);
    } else {
      uncachedChunks.push(chunk);
    }
  }
  
  // 2. 批量嵌入
  const batchSize = this.getBatchSize(provider);
  for (let i = 0; i < uncachedChunks.length; i += batchSize) {
    const batch = uncachedChunks.slice(i, i + batchSize);
    const batchEmbeddings = await this.embedWithRetry(batch, provider);
    
    // 3. 更新缓存
    for (let j = 0; j < batch.length; j++) {
      await this.cacheEmbedding(batch[j].hash, batchEmbeddings[j]);
    }
    
    embeddings.push(...batchEmbeddings);
  }
  
  return embeddings;
}

缓存策略:

  • L1: 内存 LRU Cache(进程内)
  • L2: SQLite embedding_cache 表(持久化)
  • 基于内容 hash,内容不变永不过期

为什么重要?

  • 嵌入 API 调用昂贵($0.02/1K tokens)
  • 缓存命中率通常 > 80%
  • 成本降低 90%+

7. 搜索实现

文件: manager-search.ts, hybrid.ts

混合搜索流程

async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
  // 1. 查询向量化
  const queryVector = await this.embedQueryWithTimeout(query);
  
  // 2. 向量搜索(Top K * 4 候选)
  const vectorResults = await this.searchVector(
    queryVector, 
    options.maxResults * options.candidateMultiplier  // 4x
  );
  
  // 3. 关键词搜索(BM25)
  const keywordResults = await this.searchKeyword(query);
  
  // 4. 融合(hybrid.ts)
  const merged = mergeHybridResults(
    vectorResults, 
    keywordResults,
    options.vectorWeight,    // 默认 0.7
    options.textWeight       // 默认 0.3
  );
  
  // 5. 可选:时间衰减
  if (options.temporalDecay?.enabled) {
    applyTemporalDecay(merged, options.temporalDecay.halfLifeDays);
  }
  
  // 6. 可选:MMR 多样性重排
  if (options.mmr?.enabled) {
    return applyMMRToHybridResults(merged, queryVector, options.mmr.lambda);
  }
  
  // 7. 截断返回
  return merged.slice(0, options.maxResults);
}

候选池扩展策略:

  • 先召回 4 倍结果(默认 6 → 24)
  • 给融合算法更多选择空间
  • 最后截断到用户请求的 maxResults

数据库 Schema 设计

文件: memory-schema.ts

-- 元数据表
CREATE TABLE meta (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);
-- 存储: 模型名称、分块参数、schema 版本
 
-- 文件表
CREATE TABLE files (
  path TEXT PRIMARY KEY,
  source TEXT NOT NULL DEFAULT 'memory',
  hash TEXT NOT NULL,      -- SHA256 内容哈希
  mtime INTEGER NOT NULL,  -- 修改时间
  size INTEGER NOT NULL
);
-- 用于增量同步:对比 hash 判断文件是否变更
 
-- 块表(核心)
CREATE TABLE chunks (
  id TEXT PRIMARY KEY,
  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 向量
  updated_at INTEGER NOT NULL
);
-- 向量存储为 JSON 文本,应用层解析计算
 
-- 嵌入缓存表
CREATE TABLE embedding_cache (
  provider TEXT NOT NULL,
  model TEXT NOT NULL,
  provider_key TEXT NOT NULL,
  hash TEXT NOT NULL,
  embedding TEXT NOT NULL,
  dims INTEGER,
  updated_at INTEGER NOT NULL,
  PRIMARY KEY (provider, model, provider_key, hash)
);
-- 复合主键避免重复计算
 
-- FTS5 全文搜索虚拟表
CREATE VIRTUAL TABLE chunks_fts USING fts5(
  text, id UNINDEXED, path UNINDEXED, source UNINDEXED
);
-- 自动分词索引

设计决策:

  1. JSON 存储向量: 简单通用,不依赖 sqlite-vec 扩展
  2. hash 索引: 快速判断内容是否变更
  3. FTS5 虚拟表: SQLite 内置,无需额外依赖

关键数据流

索引创建流程

sequenceDiagram
    participant F as Markdown File
    participant C as chunkMarkdown
    participant E as embedChunks
    participant DB as SQLite
    
    F->>C: 读取文件内容
    C->>C: 按 tokens 分块(默认 400)
    C->>C: 保留段落边界,80 tokens 重叠
    C->>E: chunks[]
    
    loop 批量嵌入
        E->>E: 检查缓存
        E->>E: 调用 Provider API
        E->>E: 更新缓存
    end
    
    E->>DB: INSERT INTO chunks
    DB->>DB: FTS5 自动索引

搜索流程

sequenceDiagram
    participant U as User
    participant S as Search Manager
    participant B as Builtin Backend
    participant DB as SQLite
    
    U->>S: search(query)
    
    alt dirty
        S->>B: sync()
        B->>DB: 更新索引
    end
    
    B->>B: embedQuery(query)
    
    par 并行查询
        B->>DB: SELECT vector (topK*4)
        B->>DB: SELECT FTS5 (BM25)
    end
    
    B->>B: mergeHybridResults()
    B->>B: applyMMR()
    
    B->>S: results
    S->>U: results

设计亮点总结

  1. 分层继承: 避免上帝类,职责分离清晰
  2. 安全重建: 原子替换,失败可回滚
  3. 双重防抖: 文件监控 + setTimeout
  4. 缓存优先: 嵌入缓存是关键性能优化
  5. 候选池扩展: 4x 召回给融合算法更多选择
  6. JSON 向量: 简单通用,不依赖扩展