文章目录


在这里插入图片描述

1. 引言:记忆同步 —— 从文件变化到索引更新的桥梁 {#1-引言}

在前三篇文章中,我们详细讨论了 OpenClaw 记忆系统的架构、向量化、混合搜索等核心能力。但这一切的前提是:系统如何知道有新的记忆文件需要索引?如何自动检测文件变化?如何增量更新索引而不是每次全量重建?

这就是同步引擎的职责。

OpenClaw 采用多层触发的同步策略:

  • 文件系统监听:通过 chokidar 实时监听 MEMORY.md、memory/*.md 的变化
  • 会话事件监听:通过 onSessionTranscriptUpdate Hook 捕获对话记录的增量
  • 定时同步:按间隔周期自动同步
  • 手动触发:Agent 在 search 或其他关键操作前主动同步
  • 后重新索引:模型切换、配置变更时完全重建索引

这种多触发点的设计既保证了实时性,又避免了频繁重新索引的成本。


2. 文件监听机制详解(chokidar Watcher) {#2-文件监听机制}

2.1 Chokidar 的初始化与配置

OpenClaw 在 manager-sync-ops.tsensureWatcher() 方法中初始化文件监听器:

protected ensureWatcher() {
  if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) {
    return;
  }
  
  const watchPaths = new Set<string>([
    path.join(this.workspaceDir, "MEMORY.md"),
    path.join(this.workspaceDir, "memory.md"),
    path.join(this.workspaceDir, "memory", "**", "*.md"),
  ]);
  
  // 支持额外路径
  const additionalPaths = normalizeExtraMemoryPaths(
    this.workspaceDir,
    this.settings.extraPaths
  );
  for (const entry of additionalPaths) {
    // ... 处理额外路径
  }
  
  this.watcher = chokidar.watch(Array.from(watchPaths), {
    ignoreInitial: true,
    ignored: (watchPath) => shouldIgnoreMemoryWatchPath(String(watchPath)),
    awaitWriteFinish: {
      stabilityThreshold: this.settings.sync.watchDebounceMs,
      pollInterval: 100,
    },
  });
}

关键配置参数解析

参数 含义 作用
ignoreInitial: true 初始扫描时不触发事件 启动时不会对现存文件重复索引
ignored 忽略规则函数 过滤掉 .git、node_modules 等目录
awaitWriteFinish 等待写入完成 防止文件还在写入时就触发同步
stabilityThreshold 稳定阈值(ms) 默认 500ms,文件 500ms 内未变化才认为写入完成
pollInterval 轮询间隔(ms) 100ms 检查一次文件变化

2.2 忽略规则(shouldIgnoreMemoryWatchPath)

OpenClaw 定义了一组需要忽略的目录名:

const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
  ".git",
  "node_modules",
  ".pnpm-store",
  ".venv",
  "venv",
  ".tox",
  "__pycache__",
]);

function shouldIgnoreMemoryWatchPath(watchPath: string): boolean {
  const normalized = path.normalize(watchPath);
  const parts = normalized.split(path.sep)
    .map((segment) => segment.trim().toLowerCase());
  return parts.some((segment) => IGNORED_MEMORY_WATCH_DIR_NAMES.has(segment));
}

这一设计避免了:

  • 监听源代码目录导致的性能损耗
  • 第三方依赖变化触发的虚假同步
  • 虚拟环境目录的数据泄露

2.3 事件处理与防抖(Debounce)

当文件发生 add、change、unlink 时:

const markDirty = () => {
  this.dirty = true;
  this.scheduleWatchSync();
};

this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);

核心机制是防抖

private scheduleWatchSync() {
  if (!this.sources.has("memory") || !this.settings.sync.watch) {
    return;
  }
  
  if (this.watchTimer) {
    clearTimeout(this.watchTimer);
  }
  
  this.watchTimer = setTimeout(() => {
    this.watchTimer = null;
    void this.sync({ reason: "watch" }).catch((err) => {
      log.warn(`memory sync failed (watch): ${String(err)}`);
    });
  }, this.settings.sync.watchDebounceMs);
}

防抖的好处

  • 用户一次保存可能触发多个文件事件,防抖合并为一次同步
  • 避免频繁的向量化和索引操作
  • 特别重要:写入较大文件时,chokidar 可能多次触发 change 事件

2.4 额外路径(extraPaths)支持

用户可以在配置中指定额外的记忆路径(比如云同步目录、第三方知识库):

const additionalPaths = normalizeExtraMemoryPaths(
  this.workspaceDir,
  this.settings.extraPaths
);

for (const entry of additionalPaths) {
  try {
    const stat = fsSync.lstatSync(entry);
    if (stat.isSymbolicLink()) continue; // 忽略符号链接
    
    if (stat.isDirectory()) {
      watchPaths.add(path.join(entry, "**", "*.md"));
      // 多模态支持
      if (this.settings.multimodal.enabled) {
        for (const modality of this.settings.multimodal.modalities) {
          for (const extension of getMemoryMultimodalExtensions(modality)) {
            watchPaths.add(
              path.join(entry, "**", buildCaseInsensitiveExtensionGlob(extension))
            );
          }
        }
      }
      continue;
    }
    
    if (stat.isFile() && 
        (entry.toLowerCase().endsWith(".md") ||
         classifyMemoryMultimodalPath(entry, this.settings.multimodal) !== null)) {
      watchPaths.add(entry);
    }
  } catch {
    // 跳过不存在或不可读的路径
  }
}

这一设计支持:

  • 用户在不同位置维护记忆文件
  • 多模态记忆(图片、音频等)的自动检测
  • 符号链接检测防止循环

3. 增量同步引擎(runSync) {#3-增量同步引擎}

3.1 核心流程概述

runSync() 是记忆同步的核心方法,实现了增量同步的完整逻辑:

protected async runSync(params?: {
  reason?: string;
  force?: boolean;
  sessionFiles?: string[];
  progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void> {
  // 1. 加载向量扩展
  const vectorReady = await this.ensureVectorReady();
  
  // 2. 读取元数据(provider、model、hash 等)
  const meta = this.readMeta();
  const configuredSources = this.resolveConfiguredSourcesForMeta();
  const configuredScopeHash = this.resolveConfiguredScopeHash();
  
  // 3. 判断是否需要全量重新索引
  const needsFullReindex = 
    (params?.force && !hasTargetSessionFiles) ||
    !meta ||
    (this.provider && meta.model !== this.provider.model) ||
    (this.provider && meta.provider !== this.provider.id) ||
    meta.providerKey !== this.providerKey ||
    this.metaSourcesDiffer(meta, configuredSources) ||
    meta.scopeHash !== configuredScopeHash ||
    meta.chunkTokens !== this.settings.chunking.tokens ||
    meta.chunkOverlap !== this.settings.chunking.overlap ||
    (vectorReady && !meta?.vectorDims);
  
  // 4. 执行同步
  if (needsFullReindex) {
    await this.runSafeReindex({ reason: params?.reason, ... });
  } else {
    // 增量同步
    const shouldSyncMemory = this.sources.has("memory") && 
      (this.dirty || params?.force);
    const shouldSyncSessions = this.shouldSyncSessions(params, needsFullReindex);
    
    if (shouldSyncMemory) {
      await this.syncMemoryFiles({ needsFullReindex: false, ... });
      this.dirty = false;
    }
    
    if (shouldSyncSessions) {
      await this.syncSessionFiles({ needsFullReindex: false, ... });
      this.sessionsDirty = false;
      this.sessionsDirtyFiles.clear();
    }
  }
}

3.2 Hash 比对与脏标记

增量同步的关键是识别有变化的文件。OpenClaw 使用两层机制:

第一层:文件级 Hash(用于全量扫描)

private async syncMemoryFiles(params: {
  needsFullReindex: boolean;
  progress?: MemorySyncProgressState;
}) {
  const files = await listMemoryFiles(
    this.workspaceDir,
    this.settings.extraPaths,
    this.settings.multimodal,
  );
  
  const fileEntries = (
    await runWithConcurrency(
      files.map(file => async () =>
        await buildFileEntry(file, this.workspaceDir, this.settings.multimodal)
      ),
      this.getIndexConcurrency(),
    )
  ).filter((entry): entry is MemoryFileEntry => entry !== null);
  
  const tasks = fileEntries.map((entry) => async () => {
    // 查询数据库中现存的 hash
    const record = this.db
      .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
      .get(entry.path, "memory") as { hash: string } | undefined;
    
    // hash 相同 = 文件未变化,跳过
    if (!params.needsFullReindex && record?.hash === entry.hash) {
      return;
    }
    
    // hash 不同 = 文件已变化,需要重新索引
    await this.indexFile(entry, { source: "memory" });
  });
  
  await runWithConcurrency(tasks, this.getIndexConcurrency());
}

Hash 计算方式:

export function hashText(value: string): string {
  return crypto.createHash("sha256").update(value).digest("hex");
}

// 对于 Markdown 文件
const hash = hashText(content);

// 对于多模态文件(如图片)
const dataHash = crypto.createHash("sha256").update(buffer).digest("hex");
const chunkHash = hashText(JSON.stringify({
  path: normalizedPath,
  contentText,
  mimeType,
  dataHash,
}));

第二层:脏标记(用于实时监听)

chokidar 监听器直接标记 this.dirty = true,然后防抖式触发同步。这比重新计算所有文件的 hash 快得多。

3.3 文件发现(listMemoryFiles)

listMemoryFiles 函数遍历整个内存目录结构:

export async function listMemoryFiles(
  workspaceDir: string,
  extraPaths?: string[],
  multimodal?: MemoryMultimodalSettings,
): Promise<string[]> {
  const result: string[] = [];
  
  // 1. 检查 MEMORY.md 和 memory.md(任意一个存在即可)
  await addMarkdownFile(path.join(workspaceDir, "MEMORY.md"));
  await addMarkdownFile(path.join(workspaceDir, "memory.md"));
  
  // 2. 递归遍历 memory/ 目录
  try {
    const dirStat = await fs.lstat(path.join(workspaceDir, "memory"));
    if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {
      await walkDir(path.join(workspaceDir, "memory"), result, multimodal);
    }
  } catch {}
  
  // 3. 处理额外路径
  const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
  for (const inputPath of normalizedExtraPaths) {
    const stat = await fs.lstat(inputPath);
    if (stat.isDirectory()) {
      await walkDir(inputPath, result, multimodal);
    } else if (stat.isFile() && isAllowedMemoryFilePath(inputPath, multimodal)) {
      result.push(inputPath);
    }
  }
  
  // 4. 去重(防止符号链接导致重复)
  return deduplicateByRealpath(result);
}

3.4 索引更新流程

索引更新通过 indexFile() 方法完成,这是在 MemoryManagerEmbeddingOps 中实现的复杂逻辑:

// 核心步骤:
// 1. 从文件读取内容(或使用提供的内容)
// 2. 分块(chunkMarkdown)
// 3. 向量化(embedChunk)
// 4. 插入数据库(INSERT INTO chunks...)
// 5. 更新文件记录(INSERT INTO files...)
// 6. 更新全文索引(INSERT INTO chunks_fts...)

每个块存储:

  • id:唯一标识符(path + startLine + endLine)
  • path:相对路径
  • source:来源(memory 或 sessions)
  • startLine/endLine:在文件中的行号范围
  • text:实际文本内容
  • embedding:向量(如果启用向量搜索)
  • hash:内容 hash(用于去重)

3.5 陈旧文件清理(Stale Cleanup)

同步完成后,OpenClaw 清理已删除文件的索引记录:

const activePaths = new Set(fileEntries.map((entry) => entry.path));

const staleRows = this.db
  .prepare(`SELECT path FROM files WHERE source = ?`)
  .all("memory") as Array<{ path: string }>;

for (const stale of staleRows) {
  if (activePaths.has(stale.path)) {
    continue; // 文件仍存在,保留
  }
  
  // 删除此文件的所有记录
  this.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`)
    .run(stale.path, "memory");
  this.db.prepare(`DELETE FROM chunks WHERE path = ? AND source = ?`)
    .run(stale.path, "memory");
  this.db.prepare(`DELETE FROM chunks_vec WHERE id IN (...)`)
    .run(...);
  // FTS 清理...
}

4. 会话记忆系统(Session Memory) {#4-会话记忆系统}

4.1 JSONL Transcript 文件结构

OpenClaw 的会话记录采用行分隔 JSON(JSONL)格式,每行一个事件:

{"type":"message","timestamp":"2024-03-23T10:00:00Z","message":{"role":"user","content":"你好","id":"msg-001"}}
{"type":"message","timestamp":"2024-03-23T10:00:05Z","message":{"role":"assistant","content":"你好!有什么可以帮你的吗?","id":"msg-002"}}
{"type":"message","timestamp":"2024-03-23T10:00:10Z","message":{"role":"user","content":"讲解 OpenClaw 的记忆系统","id":"msg-003"}}
{"type":"command","timestamp":"2024-03-23T10:00:15Z","command":"search","args":{"query":"memory"}}

OpenClaw 在索引时只处理 type=message 且 role=user|assistant 的记录,其他事件(command、status 等)被忽略。

4.2 extractSessionText 文本提取

会话文本从结构化消息中提取:

export function extractSessionText(content: unknown): string | null {
  // content 可以是 string(简单文本)或 array(块结构)
  if (typeof content === "string") {
    return normalizeSessionText(content);
  }
  
  if (!Array.isArray(content)) {
    return null;
  }
  
  const parts: string[] = [];
  for (const block of content) {
    if (!block || typeof block !== "object") {
      continue;
    }
    
    const record = block as { type?: unknown; text?: unknown };
    if (record.type !== "text" || typeof record.text !== "string") {
      continue;
    }
    
    const normalized = normalizeSessionText(record.text);
    if (normalized) {
      parts.push(normalized);
    }
  }
  
  return parts.length > 0 ? parts.join(" ") : null;
}

function normalizeSessionText(value: string): string {
  return value
    .replace(/\s*\n+\s*/g, " ")  // 多个换行 → 单个空格
    .replace(/\s+/g, " ")         // 多个空格 → 单个空格
    .trim();
}

这一设计的目的是:

  1. 规范化空白:处理不同格式的文本输入(markdown、多行等)
  2. 保留语义:不删除实际内容,只整理格式
  3. 兼容多种 content 格式:既支持纯字符串,也支持块结构(文本 + 图片等)

4.3 会话入口构建(buildSessionEntry)

export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
  const stat = await fs.stat(absPath);
  const raw = await fs.readFile(absPath, "utf-8");
  const lines = raw.split("\n");
  
  const collected: string[] = [];
  const lineMap: number[] = [];
  
  for (let jsonlIdx = 0; jsonlIdx < lines.length; jsonlIdx++) {
    const line = lines[jsonlIdx];
    if (!line.trim()) {
      continue; // 跳过空行
    }
    
    let record: unknown;
    try {
      record = JSON.parse(line);
    } catch {
      continue; // 跳过无效 JSON
    }
    
    // 只处理消息类型
    if ((record as { type?: unknown }).type !== "message") {
      continue;
    }
    
    const message = (record as { message?: unknown }).message as
      | { role?: unknown; content?: unknown }
      | undefined;
    
    if (!message || typeof message.role !== "string") {
      continue;
    }
    
    // 只处理用户和助手的消息
    if (message.role !== "user" && message.role !== "assistant") {
      continue;
    }
    
    const text = extractSessionText(message.content);
    if (!text) {
      continue;
    }
    
    // 敏感信息脱敏
    const safe = redactSensitiveText(text, { mode: "tools" });
    const label = message.role === "user" ? "User" : "Assistant";
    
    collected.push(`${label}: ${safe}`);
    lineMap.push(jsonlIdx + 1); // 记录原始 JSONL 行号(1-indexed)
  }
  
  const content = collected.join("\n");
  return {
    path: sessionPathForFile(absPath),
    absPath,
    mtimeMs: stat.mtimeMs,
    size: stat.size,
    hash: hashText(content + "\n" + lineMap.join(",")),
    content,
    lineMap, // 关键:用于后续重映射 chunk 行号
  };
}

lineMap 的作用

由于 OpenClaw 将会话消息展平为纯文本后才分块,chunk 的 startLine/endLine 引用的是展平后的行号,而不是原始 JSONL 文件的行号。lineMap 提供了映射关系:

export function remapChunkLines(chunks: MemoryChunk[], lineMap: number[] | undefined): void {
  if (!lineMap || lineMap.length === 0) {
    return;
  }
  
  for (const chunk of chunks) {
    // startLine/endLine 是 1-indexed
    chunk.startLine = lineMap[chunk.startLine - 1] ?? chunk.startLine;
    chunk.endLine = lineMap[chunk.endLine - 1] ?? chunk.endLine;
  }
}

这样用户搜索到会话片段时,能准确定位到原始 JSONL 文件的行号。

4.4 增量检测(deltaBytes / deltaMessages)

会话文件可能很大,不能每次都重新读取和索引。OpenClaw 实现了增量检测

private async updateSessionDelta(sessionFile: string): Promise<{
  deltaBytes: number;
  deltaMessages: number;
  pendingBytes: number;
  pendingMessages: number;
} | null> {
  const thresholds = this.settings.sync.sessions;
  if (!thresholds) {
    return null;
  }
  
  const stat = await fs.stat(sessionFile);
  const size = stat.size;
  
  let state = this.sessionDeltas.get(sessionFile);
  if (!state) {
    state = { lastSize: 0, pendingBytes: 0, pendingMessages: 0 };
    this.sessionDeltas.set(sessionFile, state);
  }
  
  const deltaBytes = Math.max(0, size - state.lastSize);
  
  if (deltaBytes === 0 && size === state.lastSize) {
    // 文件未变化
    return {
      deltaBytes: thresholds.deltaBytes,
      deltaMessages: thresholds.deltaMessages,
      pendingBytes: state.pendingBytes,
      pendingMessages: state.pendingMessages,
    };
  }
  
  if (size < state.lastSize) {
    // 文件被截断(旋转)
    state.lastSize = size;
    state.pendingBytes += size;
    // 需要重新计算消息数...
  } else {
    // 文件增长
    state.pendingBytes += deltaBytes;
    
    // 只在超过字节阈值或消息阈值时才索引
    const shouldCountMessages =
      thresholds.deltaMessages > 0 &&
      (thresholds.deltaBytes <= 0 || state.pendingBytes < thresholds.deltaBytes);
    
    if (shouldCountMessages) {
      state.pendingMessages += await this.countNewlines(
        sessionFile,
        state.lastSize,
        size
      );
    }
    
    state.lastSize = size;
  }
  
  this.sessionDeltas.set(sessionFile, state);
  return {
    deltaBytes: thresholds.deltaBytes,
    deltaMessages: thresholds.deltaMessages,
    pendingBytes: state.pendingBytes,
    pendingMessages: state.pendingMessages,
  };
}

关键概念

  • deltaBytes:自上次索引以来新增的字节数
  • deltaMessages:自上次索引以来新增的消息数(通过计数换行符近似)
  • 阈值触发:当 pendingBytes ≥ deltaBytes pendingMessages ≥ deltaMessages 时,触发增量同步

这一设计允许用户权衡:

  • 频繁同步(低延迟,高开销)vs 延迟同步(高延迟,低开销)
  • 配置示例:sync.sessions.deltaBytes = 10000, deltaMessages = 20 表示新增 10KB 或 20 条消息后同步

4.5 会话事件监听(onSessionTranscriptUpdate)

OpenClaw 在 sessions/transcript-events.ts 中定义了会话事件系统:

export type SessionTranscriptUpdate = {
  sessionFile: string;
  sessionKey?: string;
  message?: unknown;
  messageId?: string;
};

type SessionTranscriptListener = (update: SessionTranscriptUpdate) => void;

const SESSION_TRANSCRIPT_LISTENERS = new Set<SessionTranscriptListener>();

export function onSessionTranscriptUpdate(
  listener: SessionTranscriptListener
): () => void {
  SESSION_TRANSCRIPT_LISTENERS.add(listener);
  return () => {
    SESSION_TRANSCRIPT_LISTENERS.delete(listener);
  };
}

export function emitSessionTranscriptUpdate(
  update: string | SessionTranscriptUpdate
): void {
  // ... 标准化更新对象
  for (const listener of SESSION_TRANSCRIPT_LISTENERS) {
    try {
      listener(nextUpdate);
    } catch {
      /* ignore */
    }
  }
}

内存管理器订阅此事件:

protected ensureSessionListener() {
  if (!this.sources.has("sessions") || this.sessionUnsubscribe) {
    return;
  }
  
  this.sessionUnsubscribe = onSessionTranscriptUpdate((update) => {
    if (this.closed) {
      return;
    }
    
    const sessionFile = update.sessionFile;
    if (!this.isSessionFileForAgent(sessionFile)) {
      return; // 忽略其他 agent 的会话
    }
    
    this.scheduleSessionDirty(sessionFile);
  });
}

private scheduleSessionDirty(sessionFile: string) {
  this.sessionPendingFiles.add(sessionFile);
  
  if (this.sessionWatchTimer) {
    return; // 已有待处理的防抖,不重复安排
  }
  
  this.sessionWatchTimer = setTimeout(() => {
    this.sessionWatchTimer = null;
    void this.processSessionDeltaBatch().catch((err) => {
      log.warn(`memory session delta failed: ${String(err)}`);
    });
  }, SESSION_DIRTY_DEBOUNCE_MS); // 5 秒防抖
}

这一设计确保了:

  1. 实时性:消息追加时立即标记脏,无需等待文件系统事件
  2. 效率:防抖合并多条消息的更新
  3. 隔离:每个 agent 只处理自己的会话

5. 同步触发时机 {#5-同步触发时机}

OpenClaw 定义了六种同步触发场景:

触发点 reason 值 条件 用途
启动时 “session-start” sync.onSessionStart 配置开启 新会话时预热索引
搜索前 “search” sync.onSearch 且有脏标记 搜索前确保数据最新
文件变化 “watch” chokidar 监听触发 实时捕获内存文件变化
定时同步 “interval” sync.intervalMinutes 配置 周期性保持同步
会话增量 “session-delta” 会话消息超过阈值 实时索引对话
后重新索引 “post-compaction” sync() with sessionFiles 参数 模型/配置变更时完全重建

5.1 会话启动同步

async warmSession(sessionKey?: string): Promise<void> {
  if (!this.settings.sync.onSessionStart) {
    return;
  }
  
  const key = sessionKey?.trim() || "";
  if (key && this.sessionWarm.has(key)) {
    return; // 已预热,无需再次
  }
  
  void this.sync({ reason: "session-start" }).catch((err) => {
    log.warn(`memory sync failed (session-start): ${String(err)}`);
  });
  
  if (key) {
    this.sessionWarm.add(key);
  }
}

这在 Agent 初始化新会话时调用,确保内存索引已准备就绪。

5.2 搜索前同步

async search(query: string, opts?: {...}): Promise<MemorySearchResult[]> {
  void this.warmSession(opts?.sessionKey);
  
  if (this.settings.sync.onSearch && (this.dirty || this.sessionsDirty)) {
    void this.sync({ reason: "search" }).catch((err) => {
      log.warn(`memory sync failed (search): ${String(err)}`);
    });
  }
  
  // ... 执行搜索
}

这保证了搜索时索引始终是最新的。

5.3 定时同步

protected ensureIntervalSync() {
  const minutes = this.settings.sync.intervalMinutes;
  if (!minutes || minutes <= 0 || this.intervalTimer) {
    return;
  }
  
  const ms = minutes * 60 * 1000;
  this.intervalTimer = setInterval(() => {
    void this.sync({ reason: "interval" }).catch((err) => {
      log.warn(`memory sync failed (interval): ${String(err)}`);
    });
  }, ms);
}

用户可以配置自动同步间隔(如每 5 分钟)。

5.4 有目标的会话同步(Post-Compaction)

if (hasTargetSessionFiles && targetSessionFiles && this.sources.has("sessions")) {
  try {
    await this.syncSessionFiles({
      needsFullReindex: false,
      targetSessionFiles: Array.from(targetSessionFiles),
      progress: progress ?? undefined,
    });
    this.clearSyncedSessionFiles(targetSessionFiles);
  } catch (err) {
    // ...
  }
  return; // 不执行常规同步
}

这用于模型或配置变更后的目标化重新索引,只更新指定的会话文件而不影响整个索引。


6. SQLite 只读恢复机制(Readonly Recovery) {#6-只读恢复机制}

6.1 问题背景

SQLite 在某些场景下会变为只读状态:

  • 文件权限变化(如云同步应用临时锁定文件)
  • 磁盘满或权限不足
  • 多进程竞争(虽然 SQLite 有 locking 机制,但仍可能出现问题)
  • WAL 模式下日志文件损坏

OpenClaw 实现了自动恢复机制。

6.2 错误检测

private isReadonlyDbError(err: unknown): boolean {
  const readonlyPattern =
    /attempt to write a readonly database|database is read-only|SQLITE_READONLY/i;
  const messages = new Set<string>();
  
  // 收集所有可能的错误消息
  const pushValue = (value: unknown): void => {
    if (typeof value !== "string") {
      return;
    }
    const normalized = value.trim();
    if (!normalized) {
      return;
    }
    messages.add(normalized);
  };
  
  pushValue(err instanceof Error ? err.message : String(err));
  if (err && typeof err === "object") {
    const record = err as Record<string, unknown>;
    pushValue(record.message);
    pushValue(record.code);
    pushValue(record.name);
    if (record.cause && typeof record.cause === "object") {
      const cause = record.cause as Record<string, unknown>;
      pushValue(cause.message);
      pushValue(cause.code);
      pushValue(cause.name);
    }
  }
  
  return [...messages].some((value) => readonlyPattern.test(value));
}

关键点:

  • 匹配多种可能的错误信息
  • 递归检查嵌套的 cause 属性
  • 忽略大小写和前后空白

6.3 连接重建与重试

private async runSyncWithReadonlyRecovery(params?: {
  reason?: string;
  force?: boolean;
  sessionFiles?: string[];
  progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void> {
  try {
    await this.runSync(params);
    return;
  } catch (err) {
    if (!this.isReadonlyDbError(err) || this.closed) {
      throw err; // 不是只读错误,直接抛出
    }
    
    const reason = this.extractErrorReason(err);
    this.readonlyRecoveryAttempts += 1;
    this.readonlyRecoveryLastError = reason;
    
    log.warn(`memory sync readonly handle detected; reopening sqlite connection`, { reason });
    
    try {
      this.db.close();
    } catch {}
    
    // 重新打开数据库连接
    this.db = this.openDatabase();
    this.vectorReady = null;
    this.vector.available = null;
    this.vector.loadError = undefined;
    this.ensureSchema();
    
    const meta = this.readMeta();
    this.vector.dims = meta?.vectorDims;
    
    try {
      // 重试同步
      await this.runSync(params);
      this.readonlyRecoverySuccesses += 1;
    } catch (retryErr) {
      this.readonlyRecoveryFailures += 1;
      throw retryErr; // 重试也失败,抛出原始错误
    }
  }
}

恢复流程

  1. 检测:同步时捕获到 SQLITE_READONLY 错误
  2. 计数:递增 readonlyRecoveryAttempts
  3. 关闭:关闭旧的数据库连接
  4. 重新打开:创建新的连接,可能会解决权限问题
  5. 重试:用新连接重新执行同步
  6. 统计:记录成功/失败

所有恢复统计都可通过 status() 查看:

readonlyRecovery: {
  attempts: this.readonlyRecoveryAttempts,
  successes: this.readonlyRecoverySuccesses,
  failures: this.readonlyRecoveryFailures,
  lastError: this.readonlyRecoveryLastError,
}

6.4 繁忙超时配置

打开数据库时设置 busy_timeout,允许其他进程释放锁:

private openDatabaseAtPath(dbPath: string): DatabaseSync {
  const dir = path.dirname(dbPath);
  ensureDir(dir);
  const { DatabaseSync } = requireNodeSqlite();
  const db = new DatabaseSync(dbPath, { allowExtension: this.settings.store.vector.enabled });
  
  // 5 秒内重试,而非立即失败
  db.exec("PRAGMA busy_timeout = 5000");
  
  return db;
}

7. 记忆工具(memory_search / memory_get)的实现 {#7-记忆工具实现}

7.1 创建记忆工具

OpenClaw 为 Agent 提供两个内置工具:memory_searchmemory_get

export function createMemorySearchTool(options: {
  config?: OpenClawConfig;
  agentSessionKey?: string;
}): AnyAgentTool | null {
  return createMemoryTool({
    options,
    label: "Memory Search",
    name: "memory_search",
    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; " +
      "returns top snippets with path + lines. " +
      "If response has disabled=true, memory retrieval is unavailable.",
    parameters: MemorySearchSchema, // { query, maxResults?, minScore? }
    execute:
      ({ cfg, agentId }) =>
      async (_toolCallId, params) => {
        // ... 实现
      },
  });
}

export function createMemoryGetTool(options: {
  config?: OpenClawConfig;
  agentSessionKey?: string;
}): AnyAgentTool | null {
  return createMemoryTool({
    options,
    label: "Memory Get",
    name: "memory_get",
    description:
      "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; " +
      "use after memory_search to pull only the needed lines and keep context small.",
    parameters: MemoryGetSchema, // { path, from?, lines? }
    execute:
      ({ cfg, agentId }) =>
      async (_toolCallId, params) => {
        // ... 实现
      },
  });
}

7.2 memory_search 的执行逻辑

execute:
  ({ cfg, agentId }) =>
  async (_toolCallId, params) => {
    const query = readStringParam(params, "query", { required: true });
    const maxResults = readNumberParam(params, "maxResults");
    const minScore = readNumberParam(params, "minScore");
    
    // 获取内存管理器
    const memory = await getMemoryManagerContext({ cfg, agentId });
    if ("error" in memory) {
      return jsonResult(buildMemorySearchUnavailableResult(memory.error));
    }
    
    try {
      // 执行搜索
      const citationsMode = resolveMemoryCitationsMode(cfg);
      const includeCitations = shouldIncludeCitations({
        mode: citationsMode,
        sessionKey: options.agentSessionKey,
      });
      
      const rawResults = await memory.manager.search(query, {
        maxResults,
        minScore,
        sessionKey: options.agentSessionKey,
      });
      
      // 装饰结果(添加引用)
      const decorated = decorateCitations(rawResults, includeCitations);
      
      // 对于 QMD 后端,按注入字符数限制结果
      const status = memory.manager.status();
      const resolved = resolveMemoryBackendConfig({ cfg, agentId });
      const results =
        status.backend === "qmd"
          ? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
          : decorated;
      
      return jsonResult({
        results,
        provider: status.provider,
        model: status.model,
        fallback: status.fallback,
        citations: citationsMode,
        mode: (status.custom as { searchMode?: string } | undefined)?.searchMode,
      });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return jsonResult(buildMemorySearchUnavailableResult(message));
    }
  }

7.3 引用模式(Citations)

OpenClaw 支持三种引用模式:

function resolveMemoryCitationsMode(cfg: OpenClawConfig): MemoryCitationsMode {
  const mode = cfg.memory?.citations;
  if (mode === "on" || mode === "off" || mode === "auto") {
    return mode;
  }
  return "auto"; // 默认
}

function shouldIncludeCitations(params: {
  mode: MemoryCitationsMode;
  sessionKey?: string;
}): boolean {
  if (params.mode === "on") {
    return true;
  }
  if (params.mode === "off") {
    return false;
  }
  // auto: 直接对话包含引用,群组/频道隐藏引用
  const chatType = deriveChatTypeFromSessionKey(params.sessionKey);
  return chatType === "direct";
}

function decorateCitations(results: MemorySearchResult[], include: boolean): MemorySearchResult[] {
  if (!include) {
    return results.map((entry) => ({ ...entry, citation: undefined }));
  }
  
  return results.map((entry) => {
    const citation = formatCitation(entry);
    const snippet = `${entry.snippet.trim()}\n\nSource: ${citation}`;
    return { ...entry, citation, snippet };
  });
}

function formatCitation(entry: MemorySearchResult): string {
  const lineRange =
    entry.startLine === entry.endLine
      ? `#L${entry.startLine}`
      : `#L${entry.startLine}-L${entry.endLine}`;
  return `${entry.path}${lineRange}`;
}

三种模式

  • on:始终显示引用(格式:memory/2024-01-15.md#L42-L45
  • off:完全隐藏引用
  • auto(默认):直接对话中显示,群组隐藏(避免喧宾夺主)

7.4 memory_get 的执行逻辑

execute:
  ({ cfg, agentId }) =>
  async (_toolCallId, params) => {
    const relPath = readStringParam(params, "path", { required: true });
    const from = readNumberParam(params, "from", { integer: true });
    const lines = readNumberParam(params, "lines", { integer: true });
    
    // 获取内存管理器
    const memory = await getMemoryManagerContext({ cfg, agentId });
    if ("error" in memory) {
      return jsonResult({
        path: relPath,
        text: "",
        disabled: true,
        error: memory.error,
      });
    }
    
    try {
      // 读取文件指定范围
      const result = await memory.manager.readFile({
        relPath,
        from: from ?? undefined,
        lines: lines ?? undefined,
      });
      
      return jsonResult(result);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return jsonResult({
        path: relPath,
        text: "",
        disabled: true,
        error: message,
      });
    }
  }

7.5 安全路径验证

readFile() 中,OpenClaw 验证路径的合法性:

async readFile(params: {
  relPath: string;
  from?: number;
  lines?: number;
}): Promise<{ text: string; path: string }> {
  const rawPath = params.relPath.trim();
  if (!rawPath) {
    throw new Error("path required");
  }
  
  // 解析为绝对路径
  const absPath = path.isAbsolute(rawPath)
    ? path.resolve(rawPath)
    : path.resolve(this.workspaceDir, rawPath);
  
  const relPath = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/");
  
  // 检查是否在工作区内
  const inWorkspace =
    relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath);
  
  // 检查是否是允许的记忆路径
  const allowedWorkspace = inWorkspace && isMemoryPath(relPath);
  
  // 检查额外路径
  let allowedAdditional = false;
  if (!allowedWorkspace && this.settings.extraPaths.length > 0) {
    const additionalPaths = normalizeExtraMemoryPaths(
      this.workspaceDir,
      this.settings.extraPaths,
    );
    
    for (const additionalPath of additionalPaths) {
      const stat = await fs.lstat(additionalPath);
      if (stat.isSymbolicLink()) {
        continue;
      }
      
      if (stat.isDirectory()) {
        // 额外路径是目录,检查文件是否在其下
        if (absPath === additionalPath || 
            absPath.startsWith(`${additionalPath}${path.sep}`)) {
          allowedAdditional = true;
          break;
        }
        continue;
      }
      
      if (stat.isFile()) {
        // 额外路径是文件,精确匹配
        if (absPath === additionalPath && absPath.endsWith(".md")) {
          allowedAdditional = true;
          break;
        }
      }
    }
  }
  
  if (!allowedWorkspace && !allowedAdditional) {
    throw new Error("path required"); // 拒绝访问
  }
  
  if (!absPath.endsWith(".md")) {
    throw new Error("path required"); // 只允许 .md 文件
  }
  
  // 最后读取文件
  const statResult = await statRegularFile(absPath);
  if (statResult.missing) {
    return { text: "", path: relPath };
  }
  
  let content: string;
  try {
    content = await fs.readFile(absPath, "utf-8");
  } catch (err) {
    if (isFileMissingError(err)) {
      return { text: "", path: relPath };
    }
    throw err;
  }
  
  // 支持行号范围限制
  if (!params.from && !params.lines) {
    return { text: content, path: relPath };
  }
  
  const lines = content.split("\n");
  const start = Math.max(1, params.from ?? 1);
  const count = Math.max(1, params.lines ?? lines.length);
  const slice = lines.slice(start - 1, start - 1 + count);
  
  return { text: slice.join("\n"), path: relPath };
}

安全检查清单

✓ 路径必须在工作区或配置的额外路径内
✓ 拒绝 .. 目录穿越
✓ 只允许 .md 文件
✓ 忽略符号链接(防止逃逸)
✓ 缺失文件返回空而非错误(优雅降级)


8. 单例管理与生命周期(INDEX_CACHE) {#8-单例管理与生命周期}

8.1 INDEX_CACHE 的作用

OpenClaw 使用两层缓存来管理 MemoryIndexManager 的生命周期:

const INDEX_CACHE = new Map<string, MemoryIndexManager>();
const INDEX_CACHE_PENDING = new Map<string, Promise<MemoryIndexManager>>();
  • INDEX_CACHE:已初始化的管理器缓存
  • INDEX_CACHE_PENDING:正在初始化的 Promise(防止并发重复初始化)

8.2 单例获取逻辑

static async get(params: {
  cfg: OpenClawConfig;
  agentId: string;
  purpose?: "default" | "status";
}): Promise<MemoryIndexManager | null> {
  const { cfg, agentId } = params;
  const settings = resolveMemorySearchConfig(cfg, agentId);
  if (!settings) {
    return null; // 未配置记忆搜索
  }
  
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
  
  // 缓存键:包括 agentId、工作区路径和完整配置(防止配置漂移)
  const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}`;
  
  // 1. 检查已初始化的缓存
  const existing = INDEX_CACHE.get(key);
  if (existing) {
    return existing;
  }
  
  // 2. 检查正在初始化的 Promise
  const pending = INDEX_CACHE_PENDING.get(key);
  if (pending) {
    return pending; // 等待现有初始化完成
  }
  
  // 3. 启动新的初始化
  const createPromise = (async () => {
    // 创建 Embedding 提供商
    const providerResult = await createEmbeddingProvider({
      config: cfg,
      agentDir: resolveAgentDir(cfg, agentId),
      provider: settings.provider,
      remote: settings.remote,
      model: settings.model,
      outputDimensionality: settings.outputDimensionality,
      fallback: settings.fallback,
      local: settings.local,
    });
    
    // 双重检查(防止竞态)
    const refreshed = INDEX_CACHE.get(key);
    if (refreshed) {
      return refreshed;
    }
    
    // 创建管理器
    const manager = new MemoryIndexManager({
      cacheKey: key,
      cfg,
      agentId,
      workspaceDir,
      settings,
      providerResult,
      purpose: params.purpose,
    });
    
    // 存入缓存
    INDEX_CACHE.set(key, manager);
    return manager;
  })();
  
  // 4. 记录待处理 Promise
  INDEX_CACHE_PENDING.set(key, createPromise);
  
  try {
    return await createPromise;
  } finally {
    // 清理待处理记录
    if (INDEX_CACHE_PENDING.get(key) === createPromise) {
      INDEX_CACHE_PENDING.delete(key);
    }
  }
}

8.3 生命周期管理

async close(): Promise<void> {
  if (this.closed) {
    return; // 已关闭,无需重复
  }
  
  this.closed = true;
  
  // 取消所有待处理的操作
  const pendingSync = this.syncing;
  if (this.watchTimer) {
    clearTimeout(this.watchTimer);
    this.watchTimer = null;
  }
  if (this.sessionWatchTimer) {
    clearTimeout(this.sessionWatchTimer);
    this.sessionWatchTimer = null;
  }
  if (this.intervalTimer) {
    clearInterval(this.intervalTimer);
    this.intervalTimer = null;
  }
  
  // 关闭文件监听器
  if (this.watcher) {
    await this.watcher.close();
    this.watcher = null;
  }
  
  // 取消事件订阅
  if (this.sessionUnsubscribe) {
    this.sessionUnsubscribe();
    this.sessionUnsubscribe = null;
  }
  
  // 等待待处理的同步完成
  if (pendingSync) {
    try {
      await pendingSync;
    } catch {}
  }
  
  // 关闭数据库
  this.db.close();
  
  // 从缓存移除
  INDEX_CACHE.delete(this.cacheKey);
}

export async function closeAllMemoryIndexManagers(): Promise<void> {
  const pending = Array.from(INDEX_CACHE_PENDING.values());
  if (pending.length > 0) {
    await Promise.allSettled(pending);
  }
  
  const managers = Array.from(INDEX_CACHE.values());
  INDEX_CACHE.clear();
  
  for (const manager of managers) {
    try {
      await manager.close();
    } catch (err) {
      log.warn(`failed to close memory index manager: ${String(err)}`);
    }
  }
}

生命周期总结

  1. 创建:首次调用 .get() 时,初始化 Embedding 提供商和数据库
  2. 启用:启动 watcher、interval、session listener
  3. 运行:处理 search、sync 等操作
  4. 关闭:清理资源、断开连接、移除缓存
  5. 全局关闭:应用退出时调用 closeAllMemoryIndexManagers()

9. QMD 后端:外部记忆后端支持 {#9-qmd-后端}

9.1 QMD 是什么

QMD(Query Memory Daemon)是一个外部记忆进程,可以通过更复杂的搜索算法(如重排序、多步检索等)提供更高质量的搜索结果。OpenClaw 支持作为备选后端。

9.2 后端选择逻辑

export async function getMemorySearchManager(params: {
  cfg: OpenClawConfig;
  agentId: string;
  purpose?: "default" | "status";
}): Promise<MemorySearchManagerResult> {
  const resolved = resolveMemoryBackendConfig(params);
  
  if (resolved.backend === "qmd" && resolved.qmd) {
    // 尝试使用 QMD 后端
    try {
      const { QmdMemoryManager } = await import("./qmd-manager.js");
      const primary = await QmdMemoryManager.create({
        cfg: params.cfg,
        agentId: params.agentId,
        resolved,
        mode: statusOnly ? "status" : "full",
      });
      
      if (primary) {
        // 包装为有降级功能的管理器
        const wrapper = new FallbackMemoryManager(
          {
            primary,
            fallbackFactory: async () => {
              // 如果 QMD 失败,自动回退到内置索引
              const { MemoryIndexManager } = await loadManagerRuntime();
              return await MemoryIndexManager.get(params);
            },
          },
          () => {
            if (cacheKey) {
              QMD_MANAGER_CACHE.delete(cacheKey);
            }
          },
        );
        
        return { manager: wrapper };
      }
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      log.warn(`qmd memory unavailable; falling back to builtin: ${message}`);
    }
  }
  
  // 使用内置索引
  try {
    const { MemoryIndexManager } = await loadManagerRuntime();
    const manager = await MemoryIndexManager.get(params);
    return { manager };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return { manager: null, error: message };
  }
}

9.3 自动降级机制

class FallbackMemoryManager implements MemorySearchManager {
  private fallback: MemorySearchManager | null = null;
  private primaryFailed = false;
  
  async search(query: string, opts?: {...}): Promise<MemorySearchResult[]> {
    if (!this.primaryFailed) {
      try {
        return await this.deps.primary.search(query, opts);
      } catch (err) {
        // QMD 失败,切换到内置索引
        this.primaryFailed = true;
        this.lastError = err instanceof Error ? err.message : String(err);
        log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`);
        
        await this.deps.primary.close?.().catch(() => {});
        this.evictCacheEntry();
      }
    }
    
    const fallback = await this.ensureFallback();
    if (fallback) {
      return await fallback.search(query, opts);
    }
    
    throw new Error(this.lastError ?? "memory search unavailable");
  }
}

降级流程

  1. QMD 进程启动失败或搜索出错
  2. 自动切换到内置 MemoryIndexManager
  3. 不中断用户体验,只有轻微的延迟
  4. 可通过 status() 查看降级状态

9.4 QMD 配置

在 openclaw.json 中配置:

{
  "memory": {
    "backend": "qmd",
    "qmd": {
      "command": "qmd",
      "searchMode": "search",
      "update": {
        "interval": "5m",
        "onBoot": true,
        "waitForBootSync": false
      },
      "limits": {
        "maxResults": 6,
        "maxSnippetChars": 700,
        "maxInjectedChars": 4000,
        "timeoutMs": 4000
      },
      "sessions": {
        "enabled": true,
        "exportDir": "~/memory-exports",
        "retentionDays": 30
      }
    }
  }
}

10. 实践指南:如何配置和优化 OpenClaw 记忆系统 {#10-实践指南}

10.1 配置 Embedding 提供商

10.1.1 OpenAI(推荐用于云部署)
{
  "memory": {
    "provider": "openai",
    "model": "text-embedding-3-small",
    "remote": {
      "openAi": {
        "apiKey": "${OPENAI_API_KEY}",
        "baseUrl": "https://api.openai.com/v1"
      }
    }
  }
}

成本text-embedding-3-small 约 $0.02 / 1M tokens
维度:1536
优点:质量最好,广泛支持
缺点:需要网络,API 调用延迟

10.1.2 Ollama(推荐用于本地部署)
{
  "memory": {
    "provider": "ollama",
    "model": "nomic-embed-text",
    "local": {
      "ollama": {
        "baseUrl": "http://localhost:11434"
      }
    }
  }
}

成本:免费
维度:768
优点:完全离线,隐私友好,无额外成本
缺点:本地 GPU 资源消耗,质量略低于商业模型

启动 Ollama

ollama pull nomic-embed-text
ollama serve
10.1.3 本地 sentence-transformers
{
  "memory": {
    "provider": "local",
    "model": "sentence-transformers/all-MiniLM-L6-v2"
  }
}

成本:免费
维度:384
优点:最小化资源占用
缺点:质量最低

10.1.4 Gemini(推荐用于成本敏感场景)
{
  "memory": {
    "provider": "gemini",
    "model": "embedding-001",
    "remote": {
      "gemini": {
        "apiKey": "${GOOGLE_API_KEY}"
      }
    }
  }
}

成本:$0.025 / 1K 嵌入
维度:768
优点:成本低廉
缺点:维度较低,质量一般

10.1.5 多提供商故障转移
{
  "memory": {
    "provider": "openai",
    "fallback": "ollama",
    "remote": {
      "openAi": { "apiKey": "${OPENAI_API_KEY}" }
    },
    "local": {
      "ollama": { "baseUrl": "http://localhost:11434" }
    }
  }
}

当 OpenAI 失败时,自动切换到本地 Ollama。

10.2 调整分块参数

分块大小直接影响搜索质量和成本:

{
  "memory": {
    "chunking": {
      "tokens": 256,
      "overlap": 32
    }
  }
}

参数解析

  • tokens:每块最大 token 数

    • 128:细粒度,更多块,更高搜索精度,更高索引成本
    • 256:平衡(推荐)
    • 512:粗粒度,更少块,更低成本,搜索精度下降
  • overlap:相邻块的重叠 token 数

    • 0:无重叠,可能漏掉边界信息
    • 32:合理重叠(推荐)
    • 64:高重叠,确保连贯性但增加块数

调优建议

  • 小型 RAG(<100 文件):tokens=512, overlap=64
  • 大型 RAG(>1000 文件):tokens=256, overlap=32
  • 强调精度:tokens=128, overlap=64

10.3 开启 Hybrid Search 和 MMR

混合搜索结合向量和全文,提高召回率:

{
  "memory": {
    "query": {
      "hybrid": {
        "enabled": true,
        "vectorWeight": 0.7,
        "textWeight": 0.3,
        "candidateMultiplier": 3,
        "mmr": {
          "enabled": true,
          "lambda": 0.5
        }
      }
    }
  }
}

参数解析

  • vectorWeight:向量搜索权重(0.0-1.0)

    • 0.7(推荐):更信任语义匹配
    • 0.5:平衡向量和关键词
    • 0.3:更信任精确匹配
  • textWeight:全文搜索权重

    • 自动补充为 1.0 - vectorWeight
  • candidateMultiplier:召回候选倍数

    • 2:快速,可能漏掉相关结果
    • 3:平衡(推荐)
    • 5:更全面但更慢
  • mmr.lambda:多样性调节

    • 0.0:最多样(可能过度分散)
    • 0.5:平衡(推荐)
    • 1.0:最相关(可能过于相似)

10.4 启用时序衰减

对旧记忆进行衰减,优先最新内容:

{
  "memory": {
    "query": {
      "hybrid": {
        "temporalDecay": {
          "enabled": true,
          "halfLifeDays": 90
        }
      }
    }
  }
}

参数解析

  • halfLifeDays:半衰期(天)
    • 30:1 个月后降权 50%(适合快速变化的内容)
    • 90:3 个月后降权 50%(通用,推荐)
    • 365:1 年后降权 50%(适合长期参考)

数学模型

score = base_score * exp(-ln(2) * days_old / halfLifeDays)

例如:90 天半衰,180 天前的记忆得分为原来的 25%。

10.5 会话记忆的开启与配置

捕获对话上下文,让 Agent 记住用户偏好:

{
  "memory": {
    "sources": ["memory", "sessions"],
    "sync": {
      "sessions": {
        "deltaBytes": 10000,
        "deltaMessages": 20,
        "onSessionStart": true
      }
    }
  }
}

参数解析

  • sources:记忆来源

    • ["memory"]:仅 MEMORY.md 和 memory/*.md
    • ["memory", "sessions"]:包括会话记录(推荐)
  • deltaBytes:新增字节阈值

    • 5000:频繁同步(延迟低,成本高)
    • 10000:平衡(推荐)
    • 50000:延迟同步(成本低,数据陈旧)
  • deltaMessages:新增消息阈值

    • 10:频繁索引(不推荐)
    • 20:平衡(推荐)
    • 50:延迟索引
  • onSessionStart:新会话时预热索引

    • true:确保索引就绪(推荐)
    • false:延迟加载

10.6 性能优化建议

10.6.1 使用缓存
{
  "memory": {
    "cache": {
      "enabled": true,
      "maxEntries": 10000
    }
  }
}

缓存 embedding 结果,避免重复向量化。对于稳定的记忆文件,缓存命中率通常 >90%。

10.6.2 调整并发
{
  "memory": {
    "indexing": {
      "concurrency": 4
    }
  }
}
  • 高并发(8+):充分利用多核 CPU,但可能过载
  • 中等并发(4):平衡吞吐量和响应性(推荐)
  • 低并发(1):低延迟,低吞吐量
10.6.3 定时同步间隔
{
  "memory": {
    "sync": {
      "intervalMinutes": 15,
      "watchDebounceMs": 500
    }
  }
}
  • intervalMinutes:定时同步间隔

    • 5:频繁同步(适合高频更新的记忆)
    • 15:平衡(推荐)
    • 60:低频同步(仅在搜索时同步)
  • watchDebounceMs:文件变化防抖时间

    • 200:快速响应(CPU 高频轮询)
    • 500:平衡(推荐)
    • 1000:延迟响应(减少 CPU)
10.6.4 启用批处理(OpenAI/Gemini)
{
  "memory": {
    "remote": {
      "batch": {
        "enabled": true,
        "wait": true,
        "concurrency": 2,
        "pollIntervalMs": 2000,
        "timeoutMinutes": 60
      }
    }
  }
}

批处理可降低 API 成本(OpenAI 便宜 50%),但延迟较高。适合离线索引,不适合实时搜索。

10.7 完整配置示例

生产环境(云 + 本地混合)
{
  "memory": {
    "backend": "builtin",
    "citations": "auto",
    "provider": "openai",
    "fallback": "ollama",
    "model": "text-embedding-3-small",
    "sources": ["memory", "sessions"],
    "chunking": {
      "tokens": 256,
      "overlap": 32
    },
    "cache": {
      "enabled": true,
      "maxEntries": 10000
    },
    "store": {
      "path": "~/.openclaw/memory.db",
      "vector": {
        "enabled": true
      }
    },
    "query": {
      "minScore": 0.3,
      "maxResults": 6,
      "hybrid": {
        "enabled": true,
        "vectorWeight": 0.7,
        "textWeight": 0.3,
        "candidateMultiplier": 3,
        "mmr": {
          "enabled": true,
          "lambda": 0.5
        },
        "temporalDecay": {
          "enabled": true,
          "halfLifeDays": 90
        }
      }
    },
    "sync": {
      "watch": true,
      "watchDebounceMs": 500,
      "onSessionStart": true,
      "onSearch": true,
      "intervalMinutes": 15,
      "sessions": {
        "deltaBytes": 10000,
        "deltaMessages": 20
      }
    },
    "remote": {
      "openAi": {
        "apiKey": "${OPENAI_API_KEY}"
      },
      "batch": {
        "enabled": true,
        "wait": true,
        "concurrency": 2
      }
    },
    "local": {
      "ollama": {
        "baseUrl": "http://localhost:11434"
      }
    }
  }
}
本地隐私模式(完全离线)
{
  "memory": {
    "backend": "builtin",
    "provider": "ollama",
    "model": "nomic-embed-text",
    "sources": ["memory", "sessions"],
    "chunking": {
      "tokens": 512,
      "overlap": 64
    },
    "cache": {
      "enabled": true,
      "maxEntries": 5000
    },
    "query": {
      "hybrid": {
        "enabled": true,
        "vectorWeight": 0.5,
        "textWeight": 0.5
      }
    },
    "sync": {
      "watch": true,
      "onSearch": true,
      "intervalMinutes": 60,
      "sessions": {
        "deltaBytes": 50000,
        "deltaMessages": 50
      }
    },
    "local": {
      "ollama": {
        "baseUrl": "http://localhost:11434"
      }
    }
  }
}
成本最优模式(免费本地)
{
  "memory": {
    "provider": "local",
    "model": "sentence-transformers/all-MiniLM-L6-v2",
    "sources": ["memory"],
    "chunking": {
      "tokens": 512,
      "overlap": 0
    },
    "cache": {
      "enabled": true,
      "maxEntries": 2000
    },
    "query": {
      "hybrid": {
        "enabled": true,
        "vectorWeight": 0.3,
        "textWeight": 0.7,
        "candidateMultiplier": 2
      }
    },
    "sync": {
      "watch": true,
      "intervalMinutes": 60
    }
  }
}

11. 总结:OpenClaw 记忆系统的优势与局限 {#11-总结}

11.1 核心优势

✅ 隐私优先
  • 本地存储:所有数据存储在本地 SQLite,不上传云端
  • 离线可用:支持完全离线(Ollama + 本地模型)
  • 可控脱敏:内置敏感信息脱敏,支持自定义规则
✅ 性能卓越
  • 增量同步:Hash 比对和脏标记防止重复索引
  • 混合搜索:结合向量和全文,兼顾精准度和召回率
  • 缓存加速:Embedding 缓存 hit rate >90%
  • 防抖优化:减少频繁的向量化和数据库操作
✅ 架构灵活
  • 多提供商支持:OpenAI、Ollama、本地、Gemini 等
  • 自动降级:提供商失败时自动切换到备选方案
  • QMD 支持:可集成外部高级搜索引擎
  • 渐进增强:FTS-only 模式支持,无 Embedding 也能用
✅ 用户友好
  • 自动监听:文件变化自动索引,无需手动触发
  • 会话记忆:自动捕获对话,构建长期记忆
  • 安全工具memory_searchmemory_get 路径验证严格
  • 详细状态status() 接口提供完整的诊断信息

11.2 已知局限

⚠️ 可扩展性瓶颈
  • 单进程:不支持分布式索引,单机 SQLite 并发有限
  • 存储成本:向量化后数据量 3-5 倍增长,大型库(>10GB)可能导致索引文件过大

缓解方案

  • 对旧记忆定期归档
  • 配置 QMD 后端以外包高级搜索
  • 使用较小的向量维度(768 vs 1536)
⚠️ 质量依赖模型
  • Embedding 质量不均:本地模型(384 维)远劣于 text-embedding-3-small(1536 维)
  • 领域特异性:通用模型对专业领域效果有限

缓解方案

  • 使用商业模型(OpenAI、Gemini)
  • 针对特定领域微调本地模型(需专业技能)
  • 优化分块参数以补偿模型不足
⚠️ 同步延迟
  • 文件监听延迟:chokidar 轮询 + 防抖,最坏延迟 500ms+
  • 会话增量延迟:小文件可能延迟数秒才被索引

缓解方案

  • 降低 watchDebounceMs(代价:CPU 消耗增加)
  • 降低 deltaBytes/deltaMessages 阈值(代价:频繁同步)
  • 手动调用 sync() 以立即同步
⚠️ 配置复杂性
  • 参数众多:30+ 个配置项,初学者容易配置不当
  • 调优困难:最优参数高度依赖使用场景

缓解方案

  • 使用预设配置(本文提供的三个示例)
  • 参考日志和 status 输出进行调优
  • 逐步实验(先用默认,再微调)

11.3 设计权衡

方面 选择 理由
存储 SQLite(非专业向量库) 隐私、可携带、简化依赖
索引方式 增量同步 平衡成本和实时性
搜索模式 混合(向量 + FTS) 综合精准度和召回
多提供商 支持故障转移 提高可靠性
Embedding 缓存 降低 API 成本 90%+

12. 系列总结与未来展望 {#12-系列总结}

12.1 四篇文章的完整脉络

第一篇:整体架构与设计哲学

  • 为什么选择"文件 + 向量 + 全文"三位一体的方案
  • 从文件到索引的完整数据流
  • 核心类继承体系和配置系统

第二篇:Embedding 生态详解

  • 多 Embedding 提供商的集成与比较
  • 模型选择的权衡(成本、质量、维度)
  • 批处理、缓存、fallback 机制

第三篇:混合搜索与实战应用

  • 向量搜索原理和性能优化
  • 全文搜索(FTS)的兼容性
  • 混合搜索的权重调节和 MMR 多样性
  • 时序衰减的时间偏差处理

第四篇(本篇):同步引擎、会话记忆与实践指南

  • 文件监听和增量同步的实现细节
  • 会话记忆系统如何捕获对话上下文
  • 记忆工具如何安全地为 Agent 赋能
  • 从配置到优化的完整实践指南

12.2 未来可能的改进方向

短期(0-6 个月)
  • 分布式索引:支持多机 SQLite 同步,应对超大规模记忆
  • 增强的 session 记忆:自动摘要、关键词提取,减少冗余
  • 性能分析工具:内置性能分析器,帮助用户识别瓶颈
  • Web UI:可视化索引状态、搜索测试、配置管理
中期(6-18 个月)
  • 图谱记忆:支持关系图(如"张三 → 认识 → 李四"),增强语义理解
  • 多模态增强:支持语音、视频记忆的深度索引
  • 主动检索:Agent 主动搜索相关记忆而非被动等待
  • 隐私保险:内置加密存储、访问控制、审计日志
长期(18+ 个月)
  • 推理记忆:基于记忆的推理和总结(如"你上次说的关于 X 的观点是…")
  • 跨代理知识共享:多个 Agent 安全共享记忆库
  • 自适应索引:根据使用模式自动调整参数
  • 云端同步:可选的隐私保护型云备份(端到端加密)

13. 参考文献 {#13-参考文献}

源代码文件

  • src/memory/manager-sync-ops.ts - 同步操作基类
  • src/memory/session-files.ts - 会话文件处理
  • src/memory/internal.ts - 内部工具函数
  • src/memory/fs-utils.ts - 文件系统工具
  • src/memory/manager.ts - 完整内存管理器
  • src/memory/search-manager.ts - 搜索管理器和后端选择
  • src/memory/index.ts - 导出接口
  • src/memory/backend-config.ts - QMD 配置解析
  • src/agents/tools/memory-tool.ts - memory_search 和 memory_get 工具
  • src/hooks/bundled/session-memory/handler.ts - 会话内存钩子
  • src/sessions/transcript-events.ts - 会话事件系统

相关技术

配置参考


本文为 OpenClaw 记忆系统系列解析第 4 篇(完结篇),共 4 篇

作者:jianxiong | 日期:2026年3月23日

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐