glmos-code-explain


一、系统概述

OpenClaw 的记忆系统以​纯 Markdown 文件为源数据​,以 ​SQLite(含 FTS5 全文索引 + sqlite-vec 向量索引)为检索引擎​,通过工具调用(Tool Call)将检索结果注入对话上下文。整套系统实现了"人类可读存储 + 机器高效检索"的双轨架构。

核心组件关系如下:

Markdown 文件(源数据)
  ├── memory/YYYY-MM-DD.md    每日记忆
  └── MEMORY.md               长期记忆
         │
         │  chunkMarkdown() 分块
         ▼
SQLite 数据库(索引)
  ├── chunks          文本分块 + 向量(主表)
  ├── chunks_fts      FTS5 全文索引(BM25)
  ├── chunks_vec      sqlite-vec 向量索引(余弦相似度)
  └── files           文件元数据(hash/mtime)
         │
         │  memory_search / memory_get 工具
         ▼
LLM 上下文(tool_result 消息)

二、记忆写入

2.1 每日记忆(memory/YYYY-MM-DD.md)

触发机制一:Memory Flush(自动写入)

触发条件: 每轮对话前,系统检查 Session Token 数是否超过阈值:

触发阈值 = contextWindow - reserveTokensFloor(20000) - softThresholdTokens(4000)

默认以 128K 上下文窗口为例,阈值 ≈ 104,000 tokens。

另有兜底机制:Session 文本总大小超过 2MB 时强制触发(无需等到 token 超限)。

执行过程: 系统发起一个静默的 Agentic Turn,注入专用提示词:

"Pre-compaction memory flush. Store durable memories only in memory/YYYY-MM-DD.md 
(create memory/ if needed). If memory/YYYY-MM-DD.md already exists, APPEND new 
content only and do not overwrite existing entries. Treat workspace bootstrap/
reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only 
during this flush; never overwrite, replace, or edit them. If nothing to store, 
reply with NO_REPLY."

关键约束:

  • 每次 Compaction 周期内只触发一次(通过 memoryFlushCompactionCount 跟踪)
  • 仅追加(Append-only),禁止覆盖已有内容
  • 禁止创建带时间戳变体的文件名(如 2026-03-13-1430.md)
  • MEMORY.md、SOUL.md、AGENTS.md 等 bootstrap 文件在 Flush 期间为只读

文件路径生成逻辑(resolveMemoryFlushRelativePathForRun()):

// 根据用户时区生成日期戳,例如 Asia/Shanghai 下的 "memory/2026-03-13.md"
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
return `memory/${dateStamp}.md`;

触发机制二:Session Memory Hook

触发条件: 用户执行 /new 或 /reset 命令结束当前会话时。

执行过程: Hook 读取本次 Session 的历史消息,让 LLM 生成摘要并追加到当天的 memory/YYYY-MM-DD-.md。


2.2 长期记忆(MEMORY.md)

MEMORY.md 没有自动写入机制,完全依赖以下两种方式更新:

  1. 用户显式要求:"记住这件事" → LLM 调用标准 write 工具更新
  2. LLM 主动判断:LLM 认为某些内容值得永久保留时,自行使用文件写入工具

与每日记忆的核心区别:

维度 memory/YYYY-MM-DD.md MEMORY.md
写入触发 自动(Token 阈值/2MB/Session 结束) 手动(用户要求或 LLM 主动)
写入模式 仅追加 自由读写
加载范围 所有会话 仅主会话(不在群组/频道加载,防止泄露)
内容定位 原始日志、当日上下文 精炼的持久知识、重要决策
索引参与 ✅ 参与混合检索 ✅ 参与混合检索

三、记忆的文件格式

3.1 每日记忆格式示例

# 2026-03-13

## 上午工作

用户深入研究了 OpenClaw 的记忆系统架构:
- Memory Flush 触发机制(Token 阈值 + 2MB 兜底)
- 混合检索算法:Hybrid Search = Vector(0.7) + BM25(0.3)
- SQLite-vec 同步时机:Watcher/Session Start/Search Dirty/Interval/Delta 五条路径

## 用户偏好

- 回复语言:中文优先,技术术语保留英文
- 文档风格:需要代码引用和具体示例

## 待办

- [ ] 补充记忆格式调研报告

3.2 长期记忆格式示例

# Long-Term Memory

## 用户信息

- 姓名:Peter
- 时区:Asia/Shanghai
- 主要项目:OpenClaw AI Agent 基础设施

## 重要决策

### 2026-02-15:采用 Hybrid Search
决定向量检索权重 0.7,BM25 权重 0.3,启用 MMR 去重 + 时间衰减(半衰期 30 天)

## 用户偏好

- 代码风格:严格 TypeScript,禁用 any
- 提交方式:使用 scripts/committer 创建原子提交

四、记忆的索引存储格式

4.1 分块(Chunking)

记忆文件由 chunkMarkdown() 函数按行累积分块:

// src/memory/internal.ts
const maxChars = Math.max(32, chunking.tokens * 4);   // 约 2048 字符/块
const overlapChars = Math.max(0, chunking.overlap * 4); // 约 200 字符重叠

// 超过 maxChars 时 flush 当前块,然后保留末尾 overlapChars 作为下一块的上下文

示例:memory/2026-03-13.md(9 行)被分为 2 个 chunk:

Chunk 1: 第 1-5 行(# 2026-03-13 + ## 上午工作 + 内容)
Chunk 2: 第 4-9 行(含 overlapChars 重叠 + ## 用户偏好 + 内容)

4.2 SQLite 数据库表结构

-- 文件元数据表
CREATE TABLE files (
  path    TEXT PRIMARY KEY,          -- "memory/2026-03-13.md"
  source  TEXT NOT NULL,             -- "memory" | "sessions"
  hash    TEXT NOT NULL,             -- 文件内容 SHA256,用于增量同步判断
  mtime   INTEGER NOT NULL,          -- 文件修改时间(Unix ms)
  size    INTEGER NOT NULL           -- 文件大小(字节)
);

-- 文本分块主表(含向量)
CREATE TABLE chunks (
  id          TEXT PRIMARY KEY,      -- hash("memory:path:startLine:endLine:chunkHash:model")
  path        TEXT NOT NULL,         -- 所属文件路径
  source      TEXT NOT NULL,         -- "memory" | "sessions"
  start_line  INTEGER NOT NULL,      -- 起始行号(1-indexed)
  end_line    INTEGER NOT NULL,      -- 结束行号(1-indexed)
  hash        TEXT NOT NULL,         -- 块文本哈希(用于增量更新)
  model       TEXT NOT NULL,         -- Embedding 模型名(如 "text-embedding-3-small")
  text        TEXT NOT NULL,         -- 原始文本内容(最多 ~2048 字符)
  embedding   TEXT NOT NULL,         -- 向量 JSON 字符串(如 "[0.234,-0.567,...]")
  updated_at  INTEGER NOT NULL       -- 索引时间戳
);

-- FTS5 全文搜索虚表
CREATE VIRTUAL TABLE chunks_fts USING fts5(
  text,                              -- 被索引的文本(唯一被全文检索的字段)
  id          UNINDEXED,             -- chunk ID(不建倒排索引)
  path        UNINDEXED,
  source      UNINDEXED,
  model       UNINDEXED,
  start_line  UNINDEXED,
  end_line    UNINDEXED
);

-- sqlite-vec 向量检索虚表
-- 通过 ensureVectorReady() 动态创建,存储 Float32 向量 blob
CREATE VIRTUAL TABLE chunks_vec USING vec0(
  embedding float[{dims}]
);

4.3 三张表的写入关系

三张表同源、同步写入,均来自 indexFile() 方法的同一次循环:

memory/*.md / MEMORY.md
       │
       │ chunkMarkdown() 分块(按字符数累积 + overlap)
       ▼
  chunks[]  ──┬──→ INSERT INTO chunks      (主表:text + embedding JSON)
              ├──→ INSERT INTO chunks_vec  (向量表:Float32 blob)
              └──→ INSERT INTO chunks_fts  (FTS 表:text 字段建倒排索引)

重要约束: 以上写入只在有 Embedding Provider 时发生。indexFile() 开头有硬判断:

// src/memory/manager-embedding-ops.ts
if (!this.provider) {
  log.debug("Skipping embedding indexing in FTS-only mode");
  return;  // 直接返回,三张表均不写入
}

即:若未配置任何 Embedding Provider(OpenAI / Gemini / Voyage / Mistral / Ollama / 本地模型),chunks、chunks_fts、chunks_vec 三表均为空,混合检索无法工作。


五、索引同步时机(五条路径)

索引同步由 MemoryManagerSyncOps 统一管理,共有 5 条触发路径:

# 路径名 触发时机 说明
1 Watcher(文件监听) 内存文件被修改(Chokidar 事件) 去抖 1 秒后触发,设dirty=true
2 Session Start(会话启动) 每个 Session 首次使用时 warmSession()触发一次同步,后续同 Session 不重复
3 Search Dirty(检索前同步) 调用memory_search且dirty=true 异步触发,不阻塞本次搜索
4 Interval(定时同步) 每隔固定时间(默认 5 分钟) 兜底机制,防止 Watcher 遗漏
5 Session Delta(增量同步) Session 文件新增内容超过阈值 用于 Session Transcript 的增量索引

关键行为: 同步是非阻塞的——当 memory_search 被调用时,若检测到 dirty,会在后台触发同步,但本次搜索直接使用当前(可能过时的)索引结果,不等待同步完成。


六、记忆检索

6.1 检索触发

检索由 Agent 的系统提示强制驱动。系统提示(system-prompt.ts)注入了一条强制召回指令(Memory Recall),要求 LLM 在回答关于"历史工作/决策/日期/人物/偏好/待办"相关问题前,必须先调用 memory_search 工具。

const lines = [
  "## Memory Recall",
  "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
];
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; ..."

6.2 两个检索工具

memory_search — 语义搜索

// 输入
{
  query: string,       // 自然语言查询,例如 "Rod 的工作时间安排"
  maxResults?: number, // 默认 5(来自配置)
  minScore?: number    // 默认 0.35(来自配置)
}

执行混合检索流程(Hybrid Search):

查询文本
  │
  ├──→ [BM25 全文检索]
  │      buildFtsQuery("Rod 工作时间") → "Rod" AND "工作" AND "时间"
  │      SELECT ... FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY bm25()
  │      结果:keywordResults(含 textScore)
  │
  └──→ [向量语义检索]
         embedQuery("Rod 工作时间") → [0.23, -0.45, ...](查询向量)
         SELECT ... FROM chunks_vec JOIN chunks ORDER BY vec_distance_cosine()
         结果:vectorResults(含 vectorScore = 1 - cosine_distance)
         │
         ▼
  [RRF 加权融合(mergeHybridResults)]
    merged_score = vectorWeight(0.7) × vectorScore + textWeight(0.3) × textScore
         │
         ▼
  [时间衰减(Temporal Decay,可选)]
    decayed_score = merged_score × e^(-λ × ageInDays)
    λ = ln(2) / halfLifeDays(30)
    → 今天:×1.00,7天前:×0.85,30天前:×0.50,90天前:×0.12
    → 常绿文件(MEMORY.md、memory/projects.md 等非日期文件)不衰减
         │
         ▼
  [MMR 多样性重排(可选)]
    移除内容高度相似的冗余片段,保证结果多样性
         │
         ▼
  过滤 score < minScore(0.35),截取前 maxResults(5) 条

BM25 查询构造逻辑:

// src/memory/hybrid.ts
export function buildFtsQuery(raw: string): string | null {
  const tokens = raw.match(/[\p{L}\p{N}_]+/gu)?.filter(Boolean) ?? [];
  // "Rod 工作时间" → ["Rod", "工作", "时间"]
  const quoted = tokens.map((t) => `"${t.replaceAll('"', "")}"`);
  return quoted.join(" AND ");
  // → '"Rod" AND "工作" AND "时间"'
}

BM25 分数转换:

// BM25 rank 为负数(越小越相关),转换为 0-1 的正向分数
export function bm25RankToScore(rank: number): number {
  if (rank < 0) {
    const relevance = -rank;
    return relevance / (1 + relevance); // 映射到 (0, 1)
  }
  return 1 / (1 + rank);
}

memory_get — 精确读取

// 输入
{
  path: string,    // 文件相对路径,例如 "memory/2026-03-13.md"
  from?: number,   // 起始行号(1-indexed)
  lines?: number   // 读取行数
}

// 返回
{
  text: string,    // 文件内容(按行截取)
  path: string     // 实际读取路径
}
// 注:文件不存在时返回 { text: "", path } 而非抛出错误

6.3 检索结果格式

memory_search 返回的完整 JSON(注入为 tool_result 消息):

{
  "results": [
    {
      "path": "memory/2026-03-13.md",
      "startLine": 3,
      "endLine": 7,
      "score": 0.87,
      "snippet": "## 上午工作\n\n用户深入研究了 OpenClaw 的记忆系统架构...\n(最多 700 字符)",
      "source": "memory",
      "citation": "memory/2026-03-13.md#L3-L7"
    },
    {
      "path": "MEMORY.md",
      "startLine": 12,
      "endLine": 15,
      "score": 0.73,
      "snippet": "### 重要决策\n\n#### 2026-02-15:采用 Hybrid Search...",
      "source": "memory",
      "citation": "MEMORY.md#L12-L15"
    }
  ],
  "provider": "openai",
  "model": "text-embedding-3-small",
  "fallback": null,
  "citations": "auto",
  "mode": "hybrid"
}

字段说明:

  • snippet:截断至 700 字符(SNIPPET_MAX_CHARS)
  • citation:格式为 path#L{start}-L{end},仅在 Direct Chat(auto 模式)时附加,群组/频道中自动移除
  • mode:有 Embedding Provider 时为 "hybrid",无 Provider 时为 "fts-only"(但实际上 FTS 表也为空,结果为空)

6.4 检索结果如何进入上下文

检索结果不会注入到系统提示(System Prompt)中,而是以标准的 Tool Result 消息形式进入对话历史:

用户消息: "Rod 的工作时间是什么?"
     │
     ▼
LLM(被系统提示驱动)发起工具调用:
  { "name": "memory_search", "input": { "query": "Rod 工作时间" } }
     │
     ▼
系统执行混合检索 → 返回 tool_result:
  { "results": [ { "snippet": "Rod 工作 Mon-Fri...", "score": 0.92 } ] }
     │
     ▼
LLM 读取 tool_result 中的 snippet → 生成最终回复

这种设计的优势是:只有 LLM 主动查询时才消耗 Token,且每次查询的范围精确可控(不像把全部记忆塞进 System Prompt)。


七、数据源范围与边界

7.1 默认检索范围(MemorySource)

type MemorySource = "memory" | "sessions";
  • memory(默认启用):工作区内的所有 *.md 文件,即 memory/YYYY-MM-DD.md、MEMORY.md,以及 memorySearch.extraPaths 指定的额外路径
  • sessions(可选启用):历史 Session 的 JSONL 对话文件(~/.openclaw/agents//sessions/*.jsonl),经过展平处理后与记忆文件一同建索引

listMemoryFiles() 扫描的文件范围:

// 工作区下所有 .md 文件(排除 .memory/ 等隐藏目录)
const IGNORED_WATCH_DIR_NAMES = new Set([".memory", ".git", "node_modules", ...]);

7.2 文件类型支持

  • Markdown 文本文件:所有 .md 文件,分块后建索引
  • 多模态文件(可选):配置 memorySearch.multimodal=true 后,图片等二进制文件以 {text描述 + base64数据} 形式作为 Embedding 输入,整个文件作为一个 chunk

八、关键限制与注意事项

8.1 Embedding Provider 是核心依赖

整个检索体系的前提是必须有可用的 Embedding Provider。系统自动选择顺序:

本地模型(node-llama-cpp)→ OpenAI → Gemini → Voyage → Mistral
→ 全部失败 → FTS-only 模式(但实际 FTS 表也为空,等效于无记忆检索)

8.2 时间衰减不影响常绿文件

MEMORY.md 和非日期命名的记忆文件(如 memory/projects.md)永远不会被时间衰减降权,始终以原始分数参与排序。只有 memory/YYYY-MM-DD.md 格式的日期文件才会随时间衰减。

8.3 FTS 查询使用 AND 逻辑

BM25 全文查询采用 AND 语义(所有词必须同时出现),对长句查询可能导致 0 结果。系统有降级保护:当 Hybrid 模式下严格过滤后无结果但关键词结果存在时,会以 min(minScore, textWeight) 放宽阈值再次过滤。

8.4 同步是非阻塞的

memory_search 触发的同步为后台异步执行,不阻塞本次检索。这意味着刚写入的记忆在 Watcher 去抖(1 秒)+ 同步完成之前,对当次检索不可见。


九、完整数据流总览

┌─────────────────────────────────────────────────────┐
│                   记忆写入路径                        │
│                                                       │
│  Token 超限 / 2MB 超限                                │
│     └──→ Memory Flush(静默 Agentic Turn)            │
│            └──→ LLM 写 memory/YYYY-MM-DD.md(追加)   │
│                                                       │
│  /new / /reset 命令                                   │
│     └──→ Session Memory Hook                          │
│            └──→ LLM 摘要写 memory/YYYY-MM-DD.md       │
│                                                       │
│  用户明确要求 / LLM 主动判断                           │
│     └──→ LLM 用 write 工具更新 MEMORY.md              │
└─────────────────────────────────────────────────────┘
                          │
                  Chokidar 文件监听
                 (去抖 1s → dirty=true)
                          │
                          ▼
┌─────────────────────────────────────────────────────┐
│                   索引同步路径                        │
│                                                       │
│  Session Start → warmSession() → sync()              │
│  Search Dirty  → sync()(后台异步)                   │
│  Interval      → 每 5 分钟 sync()                    │
│  Session Delta → 对话增量超阈值 → sync()              │
│                                                       │
│  sync() 调用链:                                      │
│    listMemoryFiles() 扫描所有 .md                     │
│    → buildFileEntry() 计算 hash                       │
│    → hash 变化 → indexFile()                          │
│        → chunkMarkdown()(按字符分块+overlap)        │
│        → embedBatch()(调用 Embedding API)           │
│        → INSERT INTO chunks(text + embedding)       │
│        → INSERT INTO chunks_vec(Float32 向量)       │
│        → INSERT INTO chunks_fts(BM25 全文索引)      │
└─────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────┐
│                   记忆检索路径                        │
│                                                       │
│  系统提示注入 Memory Recall 强制指令                  │
│     └──→ LLM 调用 memory_search(query)               │
│            │                                          │
│            ├──→ BM25 全文检索                        │
│            │     chunks_fts MATCH "词1" AND "词2"    │
│            │     → keywordResults(含 textScore)     │
│            │                                          │
│            ├──→ 向量语义检索                          │
│            │     embedQuery() → 查询向量              │
│            │     vec_distance_cosine() 在 chunks_vec  │
│            │     → vectorResults(含 vectorScore)    │
│            │                                          │
│            └──→ 融合 & 精排                           │
│                  RRF 加权:0.7×vector + 0.3×BM25     │
│                  时间衰减(可选,半衰期 30 天)         │
│                  MMR 多样性重排(可选)                │
│                  → 过滤 score < minScore(0.35)        │
│                  → 截取 top-N(5)                      │
│                          │                            │
│                          ▼                            │
│            tool_result → LLM 上下文(对话消息)        │
│            { results: [{snippet, path, score, ...}] } │
└─────────────────────────────────────────────────────┘

十、配置参考

{
  agents: {
    defaults: {
      // 记忆工作区路径
      workspace: "~/.openclaw/workspace",
  
      // Memory Flush 配置
      compaction: {
        reserveTokensFloor: 20000,
        memoryFlush: {
          enabled: true,
          softThresholdTokens: 4000,        // 触发软阈值
          forceFlushTranscriptBytes: "2mb", // 强制触发阈值
          prompt: "Write any lasting notes to memory/YYYY-MM-DD.md...",
          systemPrompt: "Session nearing compaction. Store durable memories now."
        }
      },
  
      // 记忆检索配置
      memorySearch: {
        provider: "auto",                  // auto | openai | gemini | voyage | mistral | ollama | local
        model: "text-embedding-3-small",   // Embedding 模型
        sources: ["memory"],               // 可加 "sessions"
        query: {
          maxResults: 5,
          minScore: 0.35,
          hybrid: {
            enabled: true,
            vectorWeight: 0.7,
            textWeight: 0.3,
            candidateMultiplier: 10,       // 候选集 = maxResults × 10
            mmr: { enabled: false, lambda: 0.5 },
            temporalDecay: { enabled: false, halfLifeDays: 30 }
          }
        },
        chunking: {
          tokens: 512,                     // 每块约 512 tokens(×4 = 2048 字符)
          overlap: 50                      // 相邻块重叠约 50 tokens
        },
        store: {
          vector: { enabled: true }
        },
        sync: {
          onSessionStart: true,
          onSearch: true,
          intervalMs: 300000               // 5 分钟定时同步
        }
      }
    }
  }
}