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.mdEpisodic memory(事件记忆)流水账,自动写入,会衰减
MEMORY.md / projects.mdSemantic 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.mdmemory/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):

  1. 给 A 桌点完单,把单子递给厨房(发出 LLM API 请求)
  2. 不等厨房,立刻去 B 桌(切换到另一个 async task)
  3. 给 B 桌点完单,递给厨房
  4. 厨房叫号了(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 taskI/O 密集场景下,event loop 自然调度,无需多进程
并发控制Per-session lane(串行)防同一 session 的 race condition
目标规模个人/小团队(< 50 并发)有意识的 scope 选择,不过度工程化