Agent 记忆 ≠ 向量搜索:OpenClaw 如何让记忆自己决定什么值得永远记住

深入 OpenClaw 源码,拆解 Agent 长期记忆系统的设计决策:SQLite 存储、Hybrid Search、Temporal Decay、以及受认知科学启发的 Dreaming 记忆晋升机制。

"存向量、搜向量"只回答了记忆系统 1/3 的问题

面试中被问到"怎么给 AI Agent 设计长期记忆",大多数人会说:把对话存成 embedding,用向量数据库检索。这个回答不能说错,但它只覆盖了问题的中间环节——检索。它跳过了上游的"什么该记住"和下游的"记忆什么时候该被遗忘"。

一个真实的场景:你和 Agent 在周一讨论了一个 OOM bug 的排查过程,周三聊了项目的技术选型偏好,周五又回到了 OOM 的后续修复。一个月后,OOM 早已修复,但"偏好 TypeScript"这条信息可能永远有效。如果记忆系统不区分这两种信息的生命周期,要么所有记忆永不衰减(搜索结果被过期信息淹没),要么统一衰减(重要偏好一个月后消失)。

OpenClaw 的记忆系统之所以值得拆解,不是因为它在检索上有什么黑科技,而是因为它显式地解决了信息时效性差异这个问题——通过一个受认知科学启发的 "Dreaming" 系统,让记忆自动判断什么值得从短期笔记晋升为永久知识。

接下来我们从存储到检索到晋升,逐层拆开它的源码。

信息的时效性差异是 Agent 记忆的核心矛盾

在深入代码之前,需要先理解 OpenClaw 的记忆分层模型,因为后面所有的设计决策——衰减策略、晋升门槛、检索权重——都围绕这个分层展开。

OpenClaw 用文件名编码了信息的时效性:

层级 路径 衰减策略 设计意图
长期记忆 MEMORY.md 永不衰减 用户偏好、项目约定
主题记忆 memory/coding-style.md 永不衰减 按主题组织的持久知识
短期日记 memory/2026-04-01.md 指数衰减 (30 天 half-life) 当天对话摘要
会话记录 sessions/*.jsonl 可选索引 原始对话流水

分层的判定逻辑藏在一个正则里:

// extensions/memory-core/src/memory/temporal-decay.ts:15
const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;

如果文件名匹配 YYYY-MM-DD.md,它就是短期记忆,会被时间衰减;如果不匹配(包括 MEMORY.mdmemory/ 下的非日期文件),就是 evergreen 记忆,永远满权重。

这个设计的巧妙之处在于:用户不需要学习任何配置语法,文件名本身就是生命周期声明memory/2026-04-01.md 天然表达"这是 4 月 1 日的笔记",系统自动知道该让它衰减。但这个隐式约定也有代价——如果用户把重要的长期决策写进了日期文件而非 MEMORY.md,它会在一个月后悄无声息地沉底。后面的 Dreaming 系统就是为了兜住这种情况。

SQLite 一把梭:当部署简单性比检索性能更值钱

记忆系统的存储层做了一个反直觉的选择:用 SQLite 同时承担关系存储、向量索引和全文检索三个职责,而不是引入 Pinecone 或 Milvus。

判断依据很直接——Agent 记忆是个人级别的数据量。一个活跃用户的记忆文件通常在数十到数百个,分块后产生数百到数千个 chunks。这个规模下,sqlite-vec 的暴力 cosine similarity 计算和 Pinecone 的 ANN 索引在延迟上没有人能感知到的差异。但部署成本天差地别:SQLite 是一个嵌入式文件(~/.openclaw/state/memory/{agentId}.sqlite),Pinecone 是一个需要注册、付费、维护连接的云服务。

更值得注意的是 graceful degradation 的设计。sqlite-vec 是一个 C 扩展,在某些平台上可能无法加载。代码对此做了显式的 fallback:

// extensions/memory-core/src/memory/manager-search.ts:88-98
if (await params.ensureVectorReady(params.queryVec.length)) {
  // 快速路径:sqlite-vec 原生向量距离
  const rows = params.db.prepare(
    `SELECT c.id, c.text, c.source,
            vec_distance_cosine(v.embedding, ?) AS dist
       FROM ${params.vectorTable} v
       JOIN chunks c ON c.id = v.id
      WHERE c.model = ?
      ORDER BY dist ASC LIMIT ?`
  ).all(vectorToBlob(params.queryVec), params.providerModel, params.limit);
  return rows.map((row) => ({ score: 1 - row.dist, /* ... */ }));
}

ensureVectorReady 返回 false 时,系统退回到从 chunks 表加载全部 embedding 做纯 JavaScript 的 cosine 计算。性能从毫秒级退化到可能数百毫秒,但功能完全正确。这种 "宁可慢,不可断" 的策略,在面向终端用户的本地工具中比 "fast-or-fail" 更合理——用户不会因为向量搜索慢了 200ms 而注意到,但会因为记忆功能直接报错而困惑。

这个设计什么时候崩? 当记忆规模突破万级 chunks 时。fallback 路径会把全部向量加载到内存,对于 1536 维的 embedding,一万条就是 ~60MB 的 float 数组,加上解析 JSON 字符串的开销(embedding 以 JSON text 而非 binary 存储),检索延迟会从亚秒跳到秒级。但 OpenClaw 的赌注是:个人 Agent 的记忆很难自然增长到这个规模。

Hybrid Search 的 70/30 赌注:语义优先,但给精确匹配留了逃生通道

检索层同时走 vector search 和 keyword search 两条路径,然后按 70% vector + 30% keyword 的权重融合。这个比例不是随意的——它反映了一个判断:Agent 记忆的查询以自然语言为主(语义检索占优),但偶尔需要精确匹配(比如 "API_KEY" 这样的 token)。

融合的实现出人意料地简单:

// extensions/memory-core/src/memory/hybrid.ts:127-137
const merged = Array.from(byId.values()).map((entry) => {
  const score = params.vectorWeight * entry.vectorScore
              + params.textWeight * entry.textScore;
  return { path: entry.path, score, snippet: entry.snippet, /* ... */ };
});

以 chunk id 为 key 做 union——如果同一个 chunk 被两条路径同时命中,分数叠加;如果只被一条路径命中,另一侧分数为 0。这意味着双路命中的 chunk 天然获得 boost,不需要额外的 reranker。

但裸分数融合有一个问题:返回的 top-K 可能全是同一个话题的相邻 chunk(因为它们的 embedding 几乎相同)。系统用 MMR (Maximal Marginal Relevance) 解决这个问题:

// extensions/memory-core/src/memory/mmr.ts:134-136
// MMR = λ × relevance − (1−λ) × max_similarity_to_selected
export function computeMMRScore(
  relevance: number, maxSimilarity: number, lambda: number
): number {
  return lambda * relevance - (1 - lambda) * maxSimilarity;
}

每选出一个结果,下一个结果不仅要与 query 相关,还要与已选结果尽量不同。lambda 默认 0.7,偏向相关性但保留 30% 的多样性惩罚。这里的文本相似度用 Jaccard on tokenized sets 计算,并且对 CJK 做了 bigram 增强——只对原文中物理相邻的 CJK 字符生成 bigram,避免混合文本产生伪 token。

然后是 temporal decay。Hybrid merge 后的分数会乘以一个时间衰减因子:

// extensions/memory-core/src/memory/temporal-decay.ts:28-33
const lambda = Math.LN2 / params.halfLifeDays;  // λ = ln(2) / T½
return Math.exp(-lambda * clampedAge);           // e^(-λt)

默认 30 天 half-life:上月的记忆权重 50%,两月前 25%。但 evergreen 文件被豁免——isEvergreenMemoryPath() 检查文件是否匹配日期模式,不匹配则直接返回满权重。

这条 pipeline 四步串联的执行顺序值得注意:vector + keyword → 加权融合 → temporal decay → MMR。Decay 在 MMR 之前,意味着时间衰减后的低分结果不会被 MMR 的多样性机制"捞回来"。这是一个有意的优先级判断:新鲜度 > 多样性。

Dreaming:让记忆自己决定什么值得永远记住

前面的检索系统解决了"怎么搜",但没解决"什么该长期留下来"。一条短期记忆(memory/2026-04-01.md 里的某个 chunk)如果持续有价值,它应该被晋升为长期记忆(进入 MEMORY.md)。但让用户手动做这件事不现实——你不会每天翻阅自己的记忆文件然后决定哪些该"转正"。

OpenClaw 的 Dreaming 系统自动化了这个判断。思路来自认知科学的记忆巩固理论:人在睡眠中,大脑将白天频繁激活的短期记忆转录到长期存储。对应到代码:每次 memory_search 被调用,系统在返回结果的同时,默默记录每个短期 chunk 被召回的情况。

召回追踪

// extensions/memory-core/src/short-term-promotion.ts:578-606
const relevant = params.results.filter(
  (r) => r.source === "memory" && isShortTermMemoryPath(r.path),
);
// 只追踪来自日期文件的 chunk,evergreen 的不需要晋升

for (const result of relevant) {
  const existing = store.entries[key];
  store.entries[key] = {
    recallCount: (existing?.recallCount ?? 0) + 1,
    totalScore: (existing?.totalScore ?? 0) + score,
    queryHashes: mergeQueryHashes(existing?.queryHashes ?? [], queryHash),
    recallDays: mergeRecentDistinct(existing?.recallDays ?? [], todayStr, 16),
    conceptTags: deriveConceptTags({ path, snippet }),
    // ...
  };
}

每个被追踪的 chunk 积累五个维度的信号:被召回几次(recallCount)、匹配分数累和(totalScore)、触发召回的不同 query 有多少(queryHashes,去重,最多 32 个)、在哪些天被召回过(recallDays,最多 16 天)、以及自动推导的概念标签(conceptTags)。

追踪数据存在 memory/.dreams/short-term-recall.json。为什么不存在 SQLite 里?因为它的写入模式是"每次搜索追加一点",JSON 文件的 read-modify-write 在这个频率下比 SQLite 事务更简单,也更容易调试(人可以直接打开看)。

六维评分:不只是"被想起很多次"

晋升不是简单地看召回次数。系统用 6 个加权维度综合打分:

// extensions/memory-core/src/short-term-promotion.ts:36-43
export const DEFAULT_PROMOTION_WEIGHTS: PromotionWeights = {
  frequency: 0.24,       // 被召回的频率(对数缩放)
  relevance: 0.3,        // 匹配分数的平均值
  diversity: 0.15,       // 被多少不同 query 召回
  recency: 0.15,         // 最近一次被召回的时间
  consolidation: 0.1,    // 间隔重复模式的强度
  conceptual: 0.06,      // 概念标签的丰富度
};

权重分配本身就是一个设计判断。relevance(0.3)权重最高,意味着系统最看重"每次被召回时,匹配度有多高"——一条记忆如果总是以低分被召回,可能只是噪声匹配。frequency(0.24)次之,但用了对数缩放 Math.log1p(recallCount) / Math.log1p(10),防止刷召回次数——被召回 100 次和 10 次的差距远小于 1 次和 10 次的差距。

最有意思的是 consolidation(0.1),它直接借鉴了 spaced repetition 理论:

// extensions/memory-core/src/short-term-promotion.ts:280-297
function calculateConsolidationComponent(recallDays: string[]): number {
  if (recallDays.length <= 1) return recallDays.length === 1 ? 0.2 : 0;
  const parsed = recallDays.map((v) => Date.parse(`${v}T00:00:00.000Z`))
    .filter(Number.isFinite).toSorted((l, r) => l - r);
  const spanDays = (parsed.at(-1)! - parsed[0]!) / DAY_MS;
  const spacing = clampScore(Math.log1p(parsed.length - 1) / Math.log1p(4));
  const span = clampScore(spanDays / 7);
  return clampScore(0.55 * spacing + 0.45 * span);
}

这个函数的核心判断是:在不同天被想起比在同一天被想起更有价值spacing 衡量有多少不同的天(对数缩放,4 天即接近满分),span 衡量首次到末次召回的时间跨度(7 天标准化)。两者以 0.55/0.45 融合。

用具体数字走一遍:假设一个 chunk 在 4 月 1 日被召回 3 次,4 月 3 日被召回 1 次,4 月 7 日被召回 2 次。recallDays = ["2026-04-01", "2026-04-03", "2026-04-07"]spacing = log1p(2) / log1p(4) = 1.099 / 1.609 ≈ 0.68spanDays = 6span = 6/7 ≈ 0.86,最终 consolidation = 0.55 × 0.68 + 0.45 × 0.86 ≈ 0.76。相比之下,如果 6 次召回全在同一天,recallDays 只有 1 个元素,直接返回 0.2。间隔分散的记忆获得了近 4 倍的巩固分。

晋升门槛和并发安全

综合打分后,候选 chunk 需要通过三道门槛才能被晋升:分数 ≥ 0.75、被召回 ≥ 3 次、被 ≥ 2 个不同 query 召回。三个条件都是 AND 关系。

为什么要求"不同 query"?防止一个反复出现的相同问题把噪声记忆刷上去。如果用户每天问"今天天气怎么样",记忆文件里碰巧有一条天气相关的短期笔记,它会被反复召回,但 queryHashes 去重后只有 1 个,达不到门槛。

由于多个 Agent session 可能并行运行(用户开了多个终端窗口),promotion 过程需要并发安全。代码用了文件锁:

// extensions/memory-core/src/short-term-promotion.ts:499-502
lockHandle = await fs.open(lockPath, "wx"); // wx = 写+排他+不存在才创建
await lockHandle.writeFile(`${process.pid}:${Date.now()}\n`, "utf-8");

fs.openwx flag 保证创建是原子的。锁文件写入 PID 和时间戳,用于 stale lock 检测——如果锁文件超过 60 秒且持锁进程不存在(process.kill(pid, 0)ESRCH),可以安全偷锁。这比 flock 更可移植,因为 Node.js 的文件锁在 Windows 和 macOS 上行为不一致。

通过门槛的 chunk 被追加到 MEMORY.md,标注来源路径和评分元数据,正式成为永不衰减的长期记忆。

这个设计什么时候会崩?

任何设计都有它的失效条件。三个具体场景:

场景一:Embedding 维度变更。 当用户切换 embedding provider(比如从 OpenAI 的 text-embedding-3-small 1536 维换到 Gemini 的 768 维),已索引的所有向量与新 query 的维度不一致。vec_distance_cosine 会直接报错或返回无意义的结果。系统需要全量重索引,但这依赖用户手动触发 openclaw memory index。在重索引完成前,vector search 路径完全失效,检索退化为纯 FTS5 keyword search。

场景二:低频但重要的长期约定。 一个每季度才触发一次的部署流程,记录在 memory/2026-01-15.md 里。到 4 月 15 日时,temporal decay 因子 = e^(-ln2/30 × 90) ≈ 0.125。即使 vector search 给出满分 1.0,衰减后只有 0.125,几乎不可能进入 top-6。Dreaming 系统本可以拯救它——但如果这条信息在 90 天里只被召回过 1 次(上次部署时),达不到"3 次召回"的晋升门槛。这条信息会在短期记忆中悄无声息地沉底。唯一的救命稻草是用户把它手动写进 MEMORY.md

场景三:跨 workspace 记忆隔离。 每个 workspace 的记忆是独立的(数据库路径包含 agentId)。如果用户在 workspace A 积累了大量编程偏好,切换到 workspace B 后这些偏好完全不可见。这是安全性和便利性的 trade-off:隔离防止了项目间信息泄露,但也阻止了个人偏好的跨项目复用。

面试深水区

Q1: "给 Agent 设计长期记忆,你会怎么做?"

表面回答是 RAG 三件套(向量化、存储、检索)。源码级回答要抓住三个层次:分层存储(文件名编码时效性,evergreen vs dated),混合检索(vector 70% + keyword 30% → temporal decay → MMR),自动演化(Dreaming 系统追踪召回模式,6 维评分后晋升为长期记忆)。关键洞察:检索是已解决的问题,生命周期管理才是真正的设计挑战。

追问链:"temporal decay 在 MMR 之前还是之后?有什么影响?" → decay 在前,意味着旧记忆即使能提供多样性也会被压低——系统认为新鲜度比多样性更重要。如果反过来,一条 3 个月前的记忆可能因为 MMR 的多样性加成被"捞回"top-K,但它的内容很可能已经过时。

Q2: "为什么选 SQLite 而不是向量数据库?"

不是技术限制,是部署约束。个人 Agent 跑在用户本地机器上,引入外部向量数据库意味着要求用户注册服务、配置 API key、处理网络问题。SQLite 单文件,零运维,ACID 事务保证一致性。性能够用——千级 chunks 的 cosine similarity 暴力计算在毫秒级。

追问链:"sqlite-vec 加载失败怎么办?" → 代码有显式 fallback:从 chunks 表读全部 embedding 做纯 JS 的 cosine 计算。"那性能退化到什么程度?" → 万级 chunks × 1536 维 ≈ 60MB float 数组加载 + JSON 解析开销,延迟从亚秒跳到秒级。但个人记忆很少到这个规模。

Q3: "Dreaming 的 consolidation 分数具体怎么算?"

这道题直接区分"看过文档"和"看过源码"。

两个因子融合:spacing = log1p(不同天数 - 1) / log1p(4)(4 天即趋近满分),span = 首末间隔天数 / 7(7 天标准化)。最终 0.55 × spacing + 0.45 × span。核心判断:不同天被想起比同一天被想起多次更有价值。这直接来自 spaced repetition 理论——间隔回忆比集中回忆产生更强的记忆巩固效应。

追问链:"如果一条记忆在第 1 天被召回 10 次,之后再也没有呢?"recallDays 只有 1 个元素,consolidation = 0.2。即使 frequency 因为 10 次召回而较高(log1p(10)/log1p(10) = 1.0),缺乏间隔验证的记忆很难通过 0.75 的总分门槛。系统不信任"一次性爆发"。

Q4: "Hybrid Search 的 70/30 权重有什么依据?"

这是一个工程判断而非理论推导。Agent 记忆的查询以自然语言为主("我之前关于数据库的偏好是什么"),语义检索天然占优。但存在精确匹配场景("REDIS_HOST 配置在哪"),keyword search 此时必不可少。70/30 偏语义但不放弃精确。同一 chunk 双路命中时分数叠加——如果一条记忆既语义相关又精确匹配关键词,它会获得最高分,不需要额外 reranker。

追问链:"BM25 的 rank 是负数怎么处理?"bm25RankToScore 做了转换:rank < 0 时取绝对值,relevance / (1 + relevance) 映射到 (0, 1)。FTS5 的 BM25 rank 越负表示越相关,这个转换保证了与 vector score 的尺度对齐。

Q5: "并发安全的文件锁为什么不用 flock?"

Node.js 的 fs.flock 在 macOS 和 Windows 上行为不一致(macOS 的 advisory lock 可以被同一进程的另一个 fd 绕过)。OpenClaw 用 fs.open(path, "wx") 做原子创建——wx flag 在文件已存在时直接失败,不需要额外的 lock/unlock 语义。锁文件写入 PID:timestamp,stale 检测用 process.kill(pid, 0)ESRCH 表示进程不存在可以偷锁,EPERM 表示进程存在但无权 signal,保守地视为活锁不偷。

带走的工程判断

从 OpenClaw 的记忆系统中可以提取出几个可迁移到其他项目的设计原则:

1. 用命名约定编码元数据。 文件名 YYYY-MM-DD.md 同时表达了内容的创建日期和期望的衰减策略,不需要额外的配置或数据库字段。适用场景:任何需要区分信息时效性的系统。不适用:当命名约定无法表达的元数据维度超过一个时(比如同时需要编码优先级和时效性)。

2. Hybrid search 几乎总是优于单一检索。 向量检索和关键词检索的失败模式几乎不重叠:vector search 不善于精确 token 匹配,keyword search 不理解同义词。按权重线性融合是最简单的集成方式,同一条目双路命中自带 boost。适用场景:任何 RAG 系统。不适用:query 格式高度结构化(如 SQL)时,keyword search 权重应该更高。

3. 自动晋升需要跨时间验证。 单次高分匹配不足以证明长期价值。OpenClaw 要求"3 次召回 + 2 个不同 query"——这是跨时间、跨场景的双重验证。适用场景:任何需要从临时数据中提取持久知识的系统(如推荐系统的兴趣沉淀)。不适用:信息天然就是永久的场景(如知识库的事实条目),直接存长期即可。

4. 为 graceful degradation 设计,而非 fast-or-fail。 面向终端用户的本地工具,用户对"慢一点"的容忍度远高于对"直接报错"的容忍度。sqlite-vec 不可用时退回暴力计算,而非抛异常——这个判断在云端微服务中可能是错的(慢请求会拖垮整个服务),但在本地工具中是对的。