【Agent Memory篇】04:OpenClaw的同步引擎、会话记忆与实践指南
文章目录
-
- 1. 引言:记忆同步 —— 从文件变化到索引更新的桥梁 {#1-引言}
- 2. 文件监听机制详解(chokidar Watcher) {#2-文件监听机制}
- 3. 增量同步引擎(runSync) {#3-增量同步引擎}
- 4. 会话记忆系统(Session Memory) {#4-会话记忆系统}
- 5. 同步触发时机 {#5-同步触发时机}
- 6. SQLite 只读恢复机制(Readonly Recovery) {#6-只读恢复机制}
- 7. 记忆工具(memory_search / memory_get)的实现 {#7-记忆工具实现}
- 8. 单例管理与生命周期(INDEX_CACHE) {#8-单例管理与生命周期}
- 9. QMD 后端:外部记忆后端支持 {#9-qmd-后端}
- 10. 实践指南:如何配置和优化 OpenClaw 记忆系统 {#10-实践指南}
- 11. 总结:OpenClaw 记忆系统的优势与局限 {#11-总结}
- 12. 系列总结与未来展望 {#12-系列总结}
- 13. 参考文献 {#13-参考文献}
1. 引言:记忆同步 —— 从文件变化到索引更新的桥梁 {#1-引言}
在前三篇文章中,我们详细讨论了 OpenClaw 记忆系统的架构、向量化、混合搜索等核心能力。但这一切的前提是:系统如何知道有新的记忆文件需要索引?如何自动检测文件变化?如何增量更新索引而不是每次全量重建?
这就是同步引擎的职责。
OpenClaw 采用多层触发的同步策略:
- 文件系统监听:通过 chokidar 实时监听 MEMORY.md、memory/*.md 的变化
- 会话事件监听:通过 onSessionTranscriptUpdate Hook 捕获对话记录的增量
- 定时同步:按间隔周期自动同步
- 手动触发:Agent 在 search 或其他关键操作前主动同步
- 后重新索引:模型切换、配置变更时完全重建索引
这种多触发点的设计既保证了实时性,又避免了频繁重新索引的成本。
2. 文件监听机制详解(chokidar Watcher) {#2-文件监听机制}
2.1 Chokidar 的初始化与配置
OpenClaw 在 manager-sync-ops.ts 的 ensureWatcher() 方法中初始化文件监听器:
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();
}
这一设计的目的是:
- 规范化空白:处理不同格式的文本输入(markdown、多行等)
- 保留语义:不删除实际内容,只整理格式
- 兼容多种 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 秒防抖
}
这一设计确保了:
- 实时性:消息追加时立即标记脏,无需等待文件系统事件
- 效率:防抖合并多条消息的更新
- 隔离:每个 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; // 重试也失败,抛出原始错误
}
}
}
恢复流程:
- 检测:同步时捕获到
SQLITE_READONLY错误 - 计数:递增
readonlyRecoveryAttempts - 关闭:关闭旧的数据库连接
- 重新打开:创建新的连接,可能会解决权限问题
- 重试:用新连接重新执行同步
- 统计:记录成功/失败
所有恢复统计都可通过 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_search 和 memory_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)}`);
}
}
}
生命周期总结:
- 创建:首次调用
.get()时,初始化 Embedding 提供商和数据库 - 启用:启动 watcher、interval、session listener
- 运行:处理 search、sync 等操作
- 关闭:清理资源、断开连接、移除缓存
- 全局关闭:应用退出时调用
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");
}
}
降级流程:
- QMD 进程启动失败或搜索出错
- 自动切换到内置 MemoryIndexManager
- 不中断用户体验,只有轻微的延迟
- 可通过
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_search和memory_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- 会话事件系统
相关技术
- SQLite - 嵌入式数据库
- sqlite-vec - SQLite 向量扩展
- chokidar - 文件系统监听
- BM25 - 全文搜索排序算法
- Maximum Marginal Relevance - 多样性优化
配置参考
- OpenClaw 官方文档:https://github.com/openclaw/openclaw
- Embedding 提供商文档:
本文为 OpenClaw 记忆系统系列解析第 4 篇(完结篇),共 4 篇。
作者:jianxiong | 日期:2026年3月23日
更多推荐



所有评论(0)