OpenClaw 架构深度解析:Memory 模块与执行模型
March 11, 2026 · Tech Blog
本文基于 OpenClaw 源码分析,涵盖长期记忆系统的完整设计与 Node.js 单进程并发模型的工程原理。
一、OpenClaw 是什么
OpenClaw 是一个运行在你自己设备上的个人 AI 助手。它的特点是跨渠道——你的 WhatsApp、Telegram、Slack、Discord 都可以对接同一个 assistant,共享同一份记忆和上下文。
整个系统的核心是 Gateway,一个监听端口的后端服务,负责管理所有连接、session、工具执行和 agent 调度。这里说的"后端"是指运行在操作系统上的服务端程序,而不是跑在浏览器里的前端代码。虽然 OpenClaw 用 TypeScript 编写,但它运行在 Node.js 上,因此是后端。
二、Memory 模块:为什么 LLM 需要外部记忆
根本问题
LLM 本身没有持久记忆。每次对话对模型来说都是全新的,它不记得你上周说了什么。
最暴力的解法是把所有历史对话全部塞进 context window,但这有硬限制:
- Context 有上限:即使是 200K token 的大模型,几个月的对话历史也轻松超出
- 越长越慢越贵:context 越长,每次推理的延迟和成本都线性增加
- 大量内容是噪音:你真正需要的只是相关片段,不是全部历史
OpenClaw 的解法是:把记忆外化成文件,用检索代替注入。不是一次性告诉 agent 所有历史,而是让 agent 在需要的时候主动"想起来"。
三、存储层:Markdown 文件 + SQLite 双层架构
文件结构
~/.openclaw/workspace/
├── MEMORY.md # 常青记忆(永不衰减)
└── memory/
├── 2026-03-10.md # 日期日志(按天追加)
├── 2026-03-10-api-design.md # 会话快照(/new 触发)
└── projects.md # 非日期文件(常青,不衰减)
Markdown 文件是 source of truth,理由很具体:
- 用户可以直接打开编辑——"把这条记忆改掉"不需要任何特殊工具
- Git 可以追踪——记忆本身是可版本控制的
- Agent 直接用 Write/Edit 工具写,无需特殊 API
SQLite 索引层
索引存在 ~/.openclaw/memory/{agentId}.sqlite,有三张核心表:
-- 文件元数据,用 hash 判断是否需要重新索引
CREATE TABLE files (
path TEXT PRIMARY KEY,
source TEXT DEFAULT 'memory', -- 'memory' | 'sessions'
hash TEXT NOT NULL, -- SHA256
mtime INTEGER,
size INTEGER
);
-- 文本块 + 向量嵌入
CREATE TABLE chunks (
id TEXT PRIMARY KEY, -- "{path}_{chunk}_{hash}"
path TEXT,
source TEXT,
start_line INTEGER,
end_line INTEGER,
text TEXT NOT NULL, -- 原文
embedding TEXT NOT NULL, -- JSON 向量数组
model TEXT NOT NULL -- 嵌入模型名
);
-- 嵌入缓存,避免重复调 API
CREATE TABLE embedding_cache (
provider TEXT,
model TEXT,
provider_key TEXT,
hash TEXT,
embedding TEXT,
dims INTEGER,
PRIMARY KEY (provider, model, provider_key, hash)
);
-- 全文检索(BM25)
CREATE VIRTUAL TABLE chunks_fts USING fts5(text, ...);
-- 可选:sqlite-vec 向量加速
CREATE VIRTUAL TABLE chunks_vec USING vec0(embedding(1536));
SQLite 是纯加速层,是可重建的。文件删了,重新 sync 就能重建索引。这个分离给系统一个重要安全属性:索引损坏不会丢数据。
两种记忆的类比
文件组织对应人类记忆的两种形式:
| 文件类型 | 人类记忆类比 | 特征 |
|---|---|---|
memory/2026-03-10.md | Episodic memory(事件记忆) | 流水账,自动写入,会衰减 |
MEMORY.md / projects.md | Semantic memory(语义记忆) | 提炼后的知识,手动维护,永不衰减 |
四、检索管线:三路混合搜索
为什么不能只用向量检索
纯向量检索有一个致命缺陷:精确词汇匹配会失败。
比如你说"帮我找关于 FunPlus 的记忆",如果向量模型把 "FunPlus" 这个专有名词处理得不好,相关片段的相似度分数可能很低。但 BM25 会精确匹配这个词。
反过来,纯 BM25 无法理解语义——"讨论了代码架构"和"聊了系统设计"在词汇上几乎没有重叠,但语义相同。
融合公式
hybrid.ts 中的核心逻辑:
// 混合分数融合
const score = vectorWeight * vectorScore + textWeight * textScore;
// 默认 vectorWeight = 0.7,textWeight = 0.3
向量权重更高(0.7)的理由:大多数场景是模糊回忆,不是精确搜索。但 BM25 的 0.3 权重足以让专有名词、代码标识符这类词汇检索命中。
这个 0.7/0.3 的分配本质上是一个认识论假设:你更多时候是"大概记得发生过某件事",而不是"精确知道某个词出现在记忆里"。
完整检索流程
query: "上周讨论的 API 设计"
│
├──→ 向量搜索(语义相似度)
│ embedding(query) → cosine similarity → top-K
│
├──→ BM25 搜索(关键词匹配)
│ buildFtsQuery() → "上周" AND "API" AND "设计"
│ FTS5 rank → bm25RankToScore() 归一化
│
└──→ mergeHybridResults() 融合
│
│ score = 0.7 × vectorScore + 0.3 × textScore
│
├──→ Temporal Decay(时间衰减)
│ score × e^(-λ × ageDays)
│
└──→ MMR Re-ranking(多样性重排)
λ × relevance - (1-λ) × max_similarity_to_selected
五、时间衰减:整个系统最精妙的设计
算法
temporal-decay.ts 中的实现:
function applyTemporalDecayToScore({
score,
ageInDays,
halfLifeDays, // 默认 30 天
}: {
score: number;
ageInDays: number;
halfLifeDays: number;
}): number {
return score * Math.exp(-Math.LN2 / halfLifeDays * ageInDays);
}
这是经典的指数衰减,半衰期 30 天时的实际效果:
| 记忆年龄 | 分数保留比例 |
|---|---|
| 今天 | 100% |
| 7 天 | 84% |
| 30 天 | 50% |
| 90 天 | 12.5% |
豁免机制:Evergreen 文件
temporal-decay.ts:71-80 中的关键逻辑:MEMORY.md 和 memory/projects.md 这类非日期文件被识别为 "evergreen",永不衰减。
这揭示了一个设计意图:系统鼓励你把重要信息"提炼"到常青文件里。日期日志里的流水账会自然退场,经过人工整理的知识会永久保留。这形成了一个正向激励——越重要的东西,越值得手动提炼到 MEMORY.md。
六、MMR 去重:一个常被忽视的工程问题
问题
假设你连续三天的日志里都记录了"讨论了 API 设计",检索"API 设计"时,top-K 结果会被这三条几乎一模一样的片段占满,真正有用的不同角度的信息反而挤不进来。
MMR 算法
mmr.ts 实现了经典的 Carbonell & Goldstein 1998 算法:
// 迭代选择,平衡相关性与多样性
// MMR = λ × relevance - (1-λ) × max_similarity_to_already_selected
function mmrRerank(
candidates: Chunk[],
selected: Chunk[],
lambda: number // 默认 0.5,越高越偏向相关性
): Chunk {
return candidates.reduce((best, candidate) => {
const relevance = candidate.score;
const maxSimilarityToSelected = Math.max(
...selected.map(s => jaccardSimilarity(candidate.text, s.text))
);
const mmrScore = lambda * relevance - (1 - lambda) * maxSimilarityToSelected;
return mmrScore > best.mmrScore ? { ...candidate, mmrScore } : best;
});
}
每次选下一个结果时,不只看它和 query 有多相关,还要看它和已选结果有多不同。这保证了返回的 snippets 覆盖更广的信息维度。
这里用 Jaccard 相似度(词集合的交并比)而不是余弦相似度来度量"已选相似度"——Jaccard 对短文本够用,计算也更快。
七、记忆写入:三种触发方式
A. Pre-compaction Memory Flush(最精妙的自动机制)
memory-flush.ts 中的触发条件:
function shouldRunMemoryFlush({
tokenCount,
contextWindowTokens,
reserveTokensFloor = 20000, // 保留底线
softThresholdTokens = 4000, // 提前 4K tokens 触发
}: ShouldRunParams): boolean {
return tokenCount >= contextWindowTokens - reserveTokensFloor - softThresholdTokens;
}
// 当 tokenCount >= contextWindow - 24000 时触发
触发后,系统注入一个静默的 agentic turn:
// 系统注入的静默对话轮次
const flushPrompt = {
system: "Pre-compaction memory flush turn. Capture durable memories to disk.",
user: "Pre-compaction memory flush. " +
"Store durable memories only in memory/YYYY-MM-DD.md. " +
"APPEND only, never overwrite. " +
"If nothing to store, reply with NO_REPLY."
};
这个设计的精妙之处:agent 本身决定什么值得记,不是规则引擎在筛选,而是 LLM 在做判断。而且 flush 在压缩前触发,此时 agent 还能看到完整的对话上下文。
安全守卫:
// memory-flush.ts 中的 read-only 文件保护
const PROTECTED_FILES = ['MEMORY.md', 'SOUL.md', 'AGENTS.md'];
// flush 绝对不能写这些文件
// 只能 APPEND,不能覆盖
// 每个 compaction 周期只 flush 一次(memoryFlushCompactionCount 跟踪)
B. Session Memory Hook
用户执行 /new 或 /reset 时,自动把上一轮对话摘要写入 memory/YYYY-MM-DD-{slug}.md。
C. Agent 主动写入
Agent 在对话中直接用 Write/Edit 工具写 memory/*.md,完全自主。
八、两个工具的设计意图
memory-tool.ts 暴露两个工具,设计上刻意分离:
// 工具一:语义搜索,找到"在哪里"
interface MemorySearchInput {
query: string;
maxResults?: number; // 默认 6
minScore?: number; // 默认 0.35
}
interface MemorySearchResult {
path: string; // "memory/2026-03-09.md"
startLine: number; // 12
endLine: number; // 18
score: number; // 0.82
snippet: string; // 内容预览
}
// 工具二:精确读取,拉取"具体内容"
interface MemoryGetInput {
path: string; // "memory/2026-03-09.md"
from: number; // 10
lines: number; // 20
}
这是一个两阶段懒加载设计:先搜索拿到坐标(path + 行号),再按需精确读取。避免 agent 一次性把大量记忆注入 context,只取真正需要的部分。
九、执行模型:单进程多 Session
关键认知:并发 ≠ 并行
整个 Gateway 是单个 Node.js 进程,所有 session 的 agent run 都是这个进程内的 async function:
┌─ Gateway Process(单个 Node.js 进程)──────────────────────────┐
│ │
│ Event Loop │
│ ├─ Session A (discord:alice): runEmbeddedPiAgent() ← async │
│ │ └─ await LLM stream...(让出 event loop) │
│ ├─ Session B (telegram:bob): runEmbeddedPiAgent() ← async │
│ │ └─ await LLM stream...(让出 event loop) │
│ └─ Session C (slack:carol): runEmbeddedPiAgent() ← async │
│ └─ await tool execution...(让出 event loop) │
│ │
│ ACTIVE_EMBEDDED_RUNS: Map<sessionId, QueueHandle> │
└───────────────────────────────────────────────────────────────┘
用服务员比喻理解
一个服务员(Node.js 进程)同时服务 10 桌客人(10 个 session):
- 给 A 桌点完单,把单子递给厨房(发出 LLM API 请求)
- 不等厨房,立刻去 B 桌(切换到另一个 async task)
- 给 B 桌点完单,递给厨房
- 厨房叫号了(LLM 响应回来),回去 A 桌上菜
服务员始终只有一个,但 10 桌客人都在"同时"被服务。
await 是让出控制权,不是暂停进程
// src/agents/pi-embedded-runner/run.ts
export async function runEmbeddedPiAgent(
params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {
// ← 此刻 event loop 在这里
const response = await fetch(LLM_API_URL, { ... });
// ↑ await 让出 event loop!
// 等待期间,event loop 去跑 Session B 的逻辑
// LLM 回包后,event loop 回来这里继续
// 整个过程没有 child_process,没有 spawn
}
await 不是"暂停整个进程",而是"我先等着,你去干别的"。
为什么 agent 场景天然适合这个模型
一次 agent run 的时间构成:
[发 LLM 请求]──── 等待 2-5 秒 ────[收到响应]──[解析 tool call]──[执行工具]── 等待 ──[再发 LLM]...
↑ ↑
CPU 完全空闲, CPU 又空闲,
event loop 去跑别的 session 去跑别的 session
实际上有效 CPU 计算可能只占整个 agent run 的 5%,剩下 95% 都是在等 I/O(LLM API、工具执行、文件读写)。这些等待期间 event loop 可以自由调度其他 session。
Lane 系统:同一 session 串行,不同 session 并发
// src/process/command-queue.ts
type LaneState = {
lane: string; // "main" / "cron" / "session:{id}"
queue: QueueEntry[]; // 待执行任务队列
activeTaskIds: Set<number>;
maxConcurrent: number; // 每个 lane 允许的并发数
};
// 同一 session 的 lane:maxConcurrent = 1(串行)
// 不同 session 各自有 lane:互不阻塞
如果不加 lane 控制,同一个 session 的两条消息可能并发执行,导致 race condition(context window 同时被两个 run 修改)。Lane 解决了这个问题:同一 session 串行(防 race),不同 session 并发(提高吞吐)。
活跃 run 的全局追踪
// src/agents/pi-embedded-runner/runs.ts
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
type EmbeddedPiQueueHandle = {
queueMessage: (text: string) => Promise<void>; // steering:注入新消息
isStreaming: () => boolean;
isCompacting: () => boolean;
abort: () => void;
};
// 新消息进来时的处理逻辑
function handleIncomingMessage(sessionId: string, text: string) {
if (ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
// 这个 session 已有 run 在跑,注入 steering 消息或排队
ACTIVE_EMBEDDED_RUNS.get(sessionId)!.queueMessage(text);
} else {
// 新建一个 async run,挂到 event loop 上
startNewRun(sessionId, text);
}
}
十、单进程模型的边界
硬天花板
一万个 session 同时活跃意味着:
- 内存:每个 session 占 1MB heap → 10GB 内存
- Event loop:每次 LLM 回包、每次 tool 结果,都要在同一个 event loop tick 里处理。队列积压,延迟指数级上升
但这不是设计缺陷,是有意识的 scope 选择
OpenClaw 是个人助手定位。你的 WhatsApp、Telegram、Discord 同时在线,实际并发可能就 3-5 个。就算是小团队,高峰期同时活跃的 session 也很难超过几十个。
单进程对这个规模绰绰有余,而且还有好处:
| 多进程(fork per session) | 单进程多 async task | |
|---|---|---|
| 隔离单位 | OS 进程 | JS 对象 |
| 内存开销 | 每个进程独立 heap,重复加载模块 | 共享一个 heap |
| session 间通信 | IPC(复杂) | 直接内存访问 |
| 适合场景 | CPU 密集型 | I/O 密集型 |
| agent 场景 | 浪费,大量进程在 sleep | 刚好合适 |
如果真的要扩展
路径也很清晰:在 Gateway 前加一层 session 路由,按 sessionId 把请求分发到不同的 Gateway 实例。每个实例还是单进程模型,只是现在有多个实例。本质上就是从垂直扩展变成水平分片。
但这需要解决跨实例的 session 状态共享问题,复杂度跳一个量级——对个人助手来说完全不值得。
十一、Node.js 是什么
最后补充一个基础问题。
JavaScript 本来只能跑在浏览器里(前端)。Node.js 是把 V8 引擎(Chrome 的 JS 执行引擎)单独抽出来,让 JS 可以直接跑在操作系统上,于是就变成了服务端语言。
- 跑在浏览器里 = 前端
- 跑在 Node.js 上 = 后端
OpenClaw 的 Gateway 是一个后端服务,监听端口、管理连接、调 LLM API、跑 agent——全是后端的事,只不过语言碰巧是 TypeScript。
Node.js 和 Go 的并发模型差异很大:
- Go:真正的多线程(goroutine + 调度器),适合 CPU 密集型或需要真并行的场景
- Node.js:单线程事件循环,适合 I/O 密集的场景(API 代理、消息转发、agent 调度)
这也是为什么 OpenClaw 用 Node.js 而不是 Go——它的核心场景正好是 I/O 密集型的。
总结:核心设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 记忆存储格式 | Markdown 文件 | 用户可读可编辑,git 可追踪,索引可重建 |
| 索引引擎 | SQLite + FTS5 + sqlite-vec | 零依赖,单文件,嵌入式,轻量 |
| 搜索策略 | 向量(0.7)+ BM25(0.3)混合 | 语义理解 + 精确匹配互补 |
| 时间处理 | 指数衰减 + evergreen 豁免 | 新鲜信息优先,核心知识不过期 |
| 去重策略 | MMR(Jaccard) | 避免日志重复内容淹没结果 |
| 写入策略 | append-only 日志 + 手动常青 | 不丢数据,重要内容手动提炼到 MEMORY.md |
| 执行模型 | 单进程多 async task | I/O 密集场景下,event loop 自然调度,无需多进程 |
| 并发控制 | Per-session lane(串行) | 防同一 session 的 race condition |
| 目标规模 | 个人/小团队(< 50 并发) | 有意识的 scope 选择,不过度工程化 |