一、痛点引入:为什么 AI 助手总是"失忆"?
最近有个开源项目在开发者圈子里彻底火了——Clawdbot,江湖人称"小龙虾"。
上线不到一个月,GitHub star 数飙升,X 上到处都是"这玩意儿太香了"的安利帖。
它火的原因很多:能连 WhatsApp、Telegram、Discord,能在树莓派上 24 小时运行,完全开源可自托管……但最让我惊艳的,是它的记忆能力。
你跟它聊过的事情,它真的能记住。不是那种"假装记住"的敷衍,而是像真人一样,能在合适的时候主动想起来。
这让我想起了 AI 助手的老大难问题:
你有没有遇到过这种情况——上周刚跟 AI 聊完项目架构,这周再问它,它一脸懵逼地说"我不知道你之前说过什么"。
或者更扎心的:你花了半小时教 AI 你的代码风格偏好,结果下次对话,它又开始给你写那种你最讨厌的 var 声明。
说白了,大多数 AI 助手的记忆都有两个致命问题:
- 记忆是云端的,不是你的 —— 数据存在别人的服务器上,你根本不知道它记了什么、怎么用的
- 记忆是黑盒的,不可控 —— 你没法主动管理这些记忆,想删除某条?想导出备份?想迁移到其他工具?对不起,做不到
二、方案对比:Clawdbot vs 主流 AI 记忆方案
在深入源码之前,先看看市面上的 AI 记忆方案:
| 特性 | 云端 AI 助手 | 本地 RAG 方案 | Clawdbot |
|---|---|---|---|
| 数据存储位置 | 云端服务器 | 本地向量库 | 本地 SQLite |
| 数据所有权 | 平台所有 | 用户所有 | 用户完全控制 |
| 记忆格式 | 黑盒 | 通常是 JSON | Markdown 明文 |
| 可导出/迁移 | 有限 | 需要导出工具 | 复制文件夹即可 |
| 搜索方式 | 未知 | 通常纯向量 | 向量+关键词混合 |
| 部署复杂度 | 无需部署 | 需要向量数据库 | 零依赖 |
| 隐私性 | 数据上云 | 本地但复杂 | 简单且本地 |
划重点:Clawdbot 的记忆不是存在某个云端数据库里,也不需要你搭建复杂的向量数据库,而是直接存成 Markdown 文件,放在你自己的电脑上。
这意味着:
- 你可以用任何编辑器打开、修改这些记忆
- 你可以用 Git 管理版本历史
- 你可以随时备份、迁移
- 你的隐私数据永远不会上传到任何服务器
这不就是我们一直想要的吗?
三、核心技术实现:2000 行代码的精华
好,进入正题。Clawdbot 的记忆模块主要在 src/memory/ 目录下,核心文件包括:
src/memory/
├── manager.ts # 主记忆管理器(核心,2000+ 行)
├── internal.ts # 内部工具函数
├── hybrid.ts # 混合搜索算法
├── embeddings.ts # Embedding 提供者
├── memory-schema.ts # SQLite 数据库 Schema
└── ...
3.1 架构总览
先上一张架构图,让你有个全局认识:
┌─────────────────────────────────────────────────────────────┐
│ Clawdbot Agent │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ memory_search │ │ memory_get │ Agent Tools │
│ └────────┬────────┘ └────────┬────────┘ │
└───────────┼─────────────────────┼───────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ MemoryIndexManager │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Hybrid Search │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ Vector Search │ + │ Keyword Search │ │ │
│ │ │ (sqlite-vec) │ │ (FTS5/BM25) │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────┼─────────────────────────────┐ │
│ │ Embedding Provider │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────────────────┐ │ │
│ │ │ OpenAI │ │ Gemini │ │ Local (llama.cpp) │ │ │
│ │ └─────────┘ └─────────┘ └─────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ SQLite Database │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ files │ │ chunks │ │ chunks_vec │ │ chunks_fts │ │
│ └──────────┘ └──────────┘ └────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲ ▲
│ │
┌───────────┴─────────────────────┴───────────────────────────┐
│ Memory Sources │
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
│ │ MEMORY.md │ │ memory/*.md │ │
│ │ memory.md │ │ sessions/*.jsonl │ │
│ └─────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
核心设计思路:
- 记忆以 Markdown 文件形式存储(人类可读)
- 文件被分块(chunk)后生成 Embedding 向量
- 向量存入 SQLite + sqlite-vec 支持向量搜索
- 同时维护 FTS5 全文索引支持关键词搜索
- 搜索时混合两种结果,取长补短
3.2 混合搜索:向量 + BM25 的完美结合
这是 Clawdbot 记忆模块最精妙的设计。来看 hybrid.ts 的核心代码:
// src/memory/hybrid.ts
export function mergeHybridResults(params: {
vector: HybridVectorResult[];
keyword: HybridKeywordResult[];
vectorWeight: number;
textWeight: number;
}): Array<{ path: string; score: number; snippet: string; ... }> {
const byId = new Map<string, { vectorScore: number; textScore: number; ... }>();
// 1. 先收集向量搜索结果
for (const r of params.vector) {
byId.set(r.id, {
...r,
vectorScore: r.vectorScore,
textScore: 0, // 关键词得分先设为 0
});
}
// 2. 再合并关键词搜索结果
for (const r of params.keyword) {
const existing = byId.get(r.id);
if (existing) {
existing.textScore = r.textScore; // 补充关键词得分
} else {
byId.set(r.id, { ...r, vectorScore: 0, textScore: r.textScore });
}
}
// 3. 计算混合得分并排序
const merged = Array.from(byId.values()).map((entry) => {
const score = params.vectorWeight * entry.vectorScore
+ params.textWeight * entry.textScore;
return { ...entry, score };
});
return merged.sort((a, b) => b.score - a.score);
}
为什么要混合搜索?
单纯的向量搜索有个问题:它擅长语义相似,但对精确匹配不敏感。
比如你搜"张三的手机号",向量搜索可能返回"李四的联系方式"(语义相似),但你真正想要的是包含"张三"这个精确关键词的内容。
BM25 关键词搜索正好弥补这个缺陷。两者结合:
最终得分 = vectorWeight × 向量相似度 + textWeight × BM25得分
这个坑我踩过:之前做 RAG 项目只用向量搜索,用户搜人名、搜专有名词时经常找不到。后来加上关键词搜索,召回率直接提升 30%。
3.3 Embedding 提供者:三种方案自动降级
Clawdbot 支持三种 Embedding 提供者,而且有优雅的降级机制:
// src/memory/embeddings.ts
export async function createEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<EmbeddingProviderResult> {
// 自动模式:依次尝试 local -> openai -> gemini
if (requestedProvider === "auto") {
// 1. 优先尝试本地模型(如果配置了)
if (canAutoSelectLocal(options)) {
try {
const local = await createProvider("local");
return { ...local, requestedProvider };
} catch (err) {
localError = formatLocalSetupError(err);
}
}
// 2. 依次尝试远程 API
for (const provider of ["openai", "gemini"] as const) {
try {
const result = await createProvider(provider);
return { ...result, requestedProvider };
} catch (err) {
if (isMissingApiKeyError(err)) {
missingKeyErrors.push(message);
continue; // API Key 缺失,尝试下一个
}
throw new Error(message);
}
}
}
// 指定提供者失败时,尝试 fallback
try {
const primary = await createProvider(requestedProvider);
return { ...primary, requestedProvider };
} catch (primaryErr) {
if (fallback && fallback !== "none") {
const fallbackResult = await createProvider(fallback);
return {
...fallbackResult,
fallbackFrom: requestedProvider,
fallbackReason: reason, // 记录降级原因
};
}
throw new Error(reason);
}
}
三种提供者对比:
| 提供者 | 模型 | 优点 | 缺点 |
|---|---|---|---|
| Local | embeddinggemma-300M | 完全离线、隐私优先、免费 | 需要安装 node-llama-cpp |
| OpenAI | text-embedding-3-small | 质量高、稳定 | 需要 API Key、有成本 |
| Gemini | text-embedding-004 | Google 生态、免费额度高 | 需要 API Key |
敲黑板:本地模型用的是 embeddinggemma-300M,只有 300M 参数,但效果出奇的好。如果你对隐私敏感,强烈推荐这个方案。
3.4 存储层设计:SQLite 一把梭
Clawdbot 没有用 Pinecone、Milvus 这些专业向量数据库,而是用 SQLite + sqlite-vec 扩展。
为什么这么选?
- 零依赖:不需要额外部署数据库服务
- 便携性:一个
.db文件搞定,随时备份迁移 - 性能够用:个人助手场景,几千条记忆绰绰有余
来看数据库 Schema 设计:
-- 文件索引表:追踪哪些文件被索引了
CREATE TABLE files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL, -- 'memory' | 'sessions'
hash TEXT NOT NULL, -- 文件内容 hash,用于增量更新
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
-- 文本块表:存储分块后的文本和向量
CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL,
start_line INTEGER NOT NULL, -- 原文件中的起始行
end_line INTEGER NOT NULL, -- 原文件中的结束行
hash TEXT NOT NULL,
model TEXT NOT NULL, -- 使用的 embedding 模型
text TEXT NOT NULL, -- 原文本
embedding TEXT NOT NULL, -- 向量(JSON 格式)
updated_at INTEGER NOT NULL
);
-- 向量搜索虚拟表(sqlite-vec)
CREATE VIRTUAL TABLE chunks_vec USING vec0(
id TEXT PRIMARY KEY,
embedding FLOAT[768] -- 向量维度
);
-- 全文搜索虚拟表(FTS5)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text, id, path, source, model, start_line, end_line
);
设计亮点:
start_line/end_line记录了每个 chunk 在原文件中的位置,搜索结果可以精确定位hash字段用于判断文件是否变更,实现增量更新model字段记录使用的 embedding 模型,切换模型时自动重建索引
3.5 自动同步机制:改了文件自动更新索引
这是让 Clawdbot 记忆"活"起来的关键。来看 manager.ts 中的同步逻辑:
// src/memory/manager.ts
private ensureWatcher() {
if (!this.sources.has("memory") || !this.settings.sync.watch) return;
// 监听 MEMORY.md 和 memory/ 目录
const watchPaths = [
path.join(this.workspaceDir, "MEMORY.md"),
path.join(this.workspaceDir, "memory"),
];
this.watcher = chokidar.watch(watchPaths, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.settings.sync.watchDebounceMs,
pollInterval: 100,
},
});
const markDirty = () => {
this.dirty = true;
this.scheduleWatchSync(); // 标记脏数据,安排同步
};
this.watcher.on("add", markDirty); // 新增文件
this.watcher.on("change", markDirty); // 文件修改
this.watcher.on("unlink", markDirty); // 文件删除
}
同步触发时机:
| 触发条件 | 说明 |
|---|---|
| 文件变更 | chokidar 监听,debounce 后触发 |
| 搜索前 | 如果有脏数据,先同步再搜索 |
| 会话开始 | 新会话开始时预热索引 |
| 定时任务 | 可配置间隔定时同步 |
增量更新的秘密:
// 通过 hash 判断文件是否需要重新索引
const record = this.db
.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
.get(entry.path, "memory");
if (!needsFullReindex && record?.hash === entry.hash) {
return; // hash 相同,跳过
}
// hash 不同,重新索引这个文件
await this.indexFile(entry, { source: "memory" });
这个设计太聪明了:只有文件内容真正变化时才重新计算 embedding,大大节省了 API 调用成本。
3.6 会话记忆钩子:自动保存对话历史
Clawdbot 还有个骚操作:当你执行 /new 命令开始新会话时,它会自动把上一个会话的内容保存到记忆文件。
// src/hooks/bundled/session-memory/handler.ts
const saveSessionToMemory: HookHandler = async (event) => {
// 只在 /new 命令时触发
if (event.type !== "command" || event.action !== "new") return;
// 1. 读取前一个会话的最后 15 行
const sessionContent = await getRecentSessionContent(sessionFile);
// 2. 用 LLM 生成描述性文件名
const slug = await generateSlugViaLLM({ sessionContent, cfg });
// 3. 创建记忆文件:memory/2026-01-27-fix-auth-bug.md
const filename = `${dateStr}-${slug}.md`;
const memoryFilePath = path.join(memoryDir, filename);
// 4. 写入会话元数据和对话摘要
const entry = [
`# Session: ${dateStr} ${timeStr} UTC`,
`- **Session Key**: ${event.sessionKey}`,
`- **Session ID**: ${sessionId}`,
`## Conversation Summary`,
sessionContent,
].join("\n");
await fs.writeFile(memoryFilePath, entry, "utf-8");
};
效果:每次开新会话,之前的对话自动变成可搜索的记忆。你再也不用担心"上次聊的那个方案是什么来着"了。
四、值得借鉴的 3 个设计亮点
读完这 2000 行源码,有几个设计让我印象深刻,值得在自己的项目里借鉴。
亮点 1:混合搜索解决召回率问题
问题场景:用户搜"张三的联系方式",纯向量搜索可能返回"王五的电话号码"——因为向量搜索理解的是"查找某人的联系方式"这个语义意图,而不是精确匹配"张三"这个关键词。
Clawdbot 的解法:向量搜索 + BM25 关键词搜索混合。
const score = vectorWeight * entry.vectorScore + textWeight * entry.textScore;
向量搜索负责语义理解("找联系方式相关的内容"),BM25 负责精确匹配("必须包含张三这个词")。两者加权合并,召回率能提升约 30%。
可借鉴点:如果你在做 RAG 项目,别只用向量搜索,加上关键词搜索效果会好很多。
亮点 2:基于 Hash 的 Embedding 缓存
问题场景:每次同步都重新计算所有文件的 embedding,API 成本爆炸。
Clawdbot 的解法:用内容 hash 作为缓存 key,只计算变化的部分。
// 先查缓存
const cached = this.loadEmbeddingCache(chunks.map((chunk) => chunk.hash));
// 只计算缓存里没有的
const missing = chunks.filter((chunk) => !cached.has(chunk.hash));
const newEmbeddings = await this.embedBatchWithRetry(missing.map(c => c.text));
// 写入缓存
this.upsertEmbeddingCache(newEmbeddings);
可借鉴点:任何涉及 Embedding API 调用的项目都应该加缓存。按 Clawdbot 的设计,API 成本能节省 80% 以上。
亮点 3:优雅降级保证可用性
问题场景:sqlite-vec 是 C 扩展,不同平台需要不同的编译版本,Windows 上尤其容易出问题。
Clawdbot 的解法:向量搜索不可用时,自动降级到纯关键词搜索,而不是直接报错。
private async loadVectorExtension(): Promise<boolean> {
try {
const loaded = await loadSqliteVecExtension({ db: this.db, extensionPath });
if (!loaded.ok) throw new Error(loaded.error);
this.vector.available = true;
return true;
} catch (err) {
// 向量搜索不可用,但不影响关键词搜索
this.vector.available = false;
this.vector.loadError = message;
log.warn(`sqlite-vec unavailable: ${message}`);
return false; // 返回 false 而不是抛异常
}
}
可借鉴点:依赖外部组件时,永远要有 Plan B。Clawdbot 的降级机制保证了即使向量搜索挂了,用户依然能用关键词搜索找到记忆。
五、总结
- 本地优先:记忆存成 Markdown 文件,数据完全在你手里
- 混合搜索:向量 + BM25,兼顾语义理解和精确匹配
- 多提供者:OpenAI/Gemini/本地模型,自动降级
- 增量同步:基于 hash 的缓存,省钱又高效
- 会话持久化:自动保存对话历史到记忆文件
关于我们
我们是一家面向 AEC 行业的 AI 创业公司,专注企业级AI应用系统的设计与落地。如果你在做类似的AI应用工程化,或者对垂直行业AI落地感兴趣,欢迎评论区讨论或添加联系方式沟通。
微信:Damondut
GitHub: github.com/zhuzhaoyun
本文使用 markdown.com.cn 排版