拆解 OpenClaw - 04:Compaction、Pruning 与向量搜索

6 阅读11分钟

前三篇覆盖了消息链路、Agent 生命周期、子 Agent 编排。这一篇聚焦一个更底层的问题:上下文窗口是有限的,对话越来越长怎么办?OpenClaw 用三套独立机制应对这个问题——Compaction 压缩历史,Pruning 裁剪工具输出,向量搜索找回旧记忆。


Compaction:把旧对话压成摘要

我:对话越来越长,上下文窗口快满了怎么办?

Compaction。当 session 的 token 估算接近模型的 context window 上限时,Gateway 自动触发一次压缩:把旧的对话消息喂给模型,让它生成一段摘要,然后用摘要替代原始消息。压缩后的 session 历史变短了,但信息没有彻底丢失——它被浓缩进了摘要。

触发条件有两种:自动触发(接近上限时 Gateway 主动发起)和手动触发(用户发 /compact 命令)。自动触发的阈值由 reserveTokensFloor 控制,默认预留 20000 token 的空间给新对话。

sequenceDiagram
    participant GW as Gateway
    participant LLM as LLM
    participant FS as 磁盘

    Note over GW: token 估算接近上限

    GW->>GW: 触发 Memory Flush(先存档)

    Note over GW: flush 完成

    GW->>LLM: "请总结以下旧对话"
    LLM-->>GW: 摘要文本

    GW->>FS: 摘要写入 session JSONL

    Note over GW: 旧消息从内存移除<br/>摘要替代它们<br/>对话继续

注意第二步:Compaction 之前,Gateway 会先触发一次 Memory Flush。这一步至关重要。

我:Memory Flush 是什么?为什么要在 Compaction 之前做?

Memory Flush 是一次静默的 agentic turn。Gateway 给模型发一条提醒:"session 快要压缩了,把你觉得需要长期保留的信息写到记忆文件里。"模型收到后,可以调用 writeedit 工具把重要的决策、偏好、上下文写进 memory/YYYY-MM-DD.mdMEMORY.md。写完后回复 NO_REPLY,用户不会看到这个过程。

为什么要先做这一步?因为 Compaction 的摘要是有损的。模型被要求"总结这些对话",它必须把所有内容压缩成一段连贯的文字,没有"选不选"的余地,只有"怎么总结"的自由度。细节必然会丢失。而 Memory Flush 是选择性的——模型自己判断什么值得长期保留,主动写到外部文件。先存档再压缩,确保重要信息不随压缩消失。

两者的关键区别:

Memory Flush    模型自己决定"存什么、不存什么、存到哪个文件"
                → 选择性的、写到外部记忆文件、跨 session 持久

Compaction      模型被要求"把这段对话总结一下"
                → 全面性的、写到 session 内部 JSONL、仅当前 session 可见

Memory Flush 影响的是长期记忆(记忆文件),Compaction 影响的是短期记忆(session 上下文)。一个是存档,一个是瘦身。

我:Compaction 后的 session 长什么样?

压缩前,session 的 messages 数组可能有几百条消息。压缩后,那些旧消息被替换成一条 compaction entry——一段摘要文本。后续的新消息照常追加在摘要后面。从模型的视角看,对话的"开头"变成了一段对之前聊天内容的总结,然后接着是最近的对话。

graph LR
    subgraph "压缩前"
        M1["消息 1"] --> M2["消息 2"] --> M3["..."] --> M50["消息 50"] --> M51["消息 51"] --> M52["消息 52"]
    end

    subgraph "压缩后"
        C["摘要:消息 1-50 的总结"] --> M51B["消息 51"] --> M52B["消息 52"]
    end

摘要写进 session 的 JSONL 文件,所以即使 Gateway 重启,压缩后的状态也能恢复。但这也意味着压缩是不可逆的——原始消息从 JSONL 中被摘要替代,没有"解压"的选项。如果压缩发生了多次,就会出现"摘要的摘要",信息逐层衰减。这是有意的取舍:对个人助手场景,最近的对话比远古的细节重要得多。

我:Memory Flush 的触发时机怎么控制?

softThresholdTokens 决定。当 session 的 token 估算超过 contextWindow - reserveTokensFloor - softThresholdTokens 时,flush 触发。默认 softThresholdTokens 是 4000,意味着在 Compaction 触发之前的一小段窗口内,先跑一次 flush。每个 compaction 周期只触发一次 flush,不会反复执行。

配置示意:

{
  "agents": {
    "defaults": {
      "compaction": {
        "reserveTokensFloor": 20000,  // Compaction 触发的预留空间
        "memoryFlush": {
          "enabled": true,
          "softThresholdTokens": 4000,  // flush 比 compaction 早 4000 token 触发
          "systemPrompt": "Session nearing compaction. Store durable memories now.",
          "prompt": "Write any lasting notes to memory/YYYY-MM-DD.md; reply with NO_REPLY."
        }
      }
    }
  }
}

还有一个限制:如果 session 的工作区是只读的(workspaceAccess: "ro""none"),flush 直接跳过——模型没法写文件,存档也就无从谈起。


Pruning:考试前擦草稿纸

我:Compaction 处理的是对话太长的问题,那 Pruning 解决什么?

不同的问题。Compaction 压缩整个对话历史,Pruning 只针对工具输出。

工具输出是上下文膨胀的主要来源。一次 exec 可能返回几万字符的日志,一次 read 可能读回整个文件。这些输出在当时有用——模型需要看到命令结果才能决定下一步。但几轮对话之后,那些旧的工具输出已经没有参考价值了,却还占着 context window 的位置。

Pruning 在每次 LLM 调用之前,把旧的 toolResult 消息裁剪或清空。用户消息和助手消息永远不动。

graph TB
    subgraph "Pruning 前的 context"
        U1["user: 帮我看下日志"] --> A1["assistant: tool_use exec"] --> T1["toolResult: 50000 字符的日志 ⚠️"] --> A2["assistant: 日志里有报错"] --> U2["user: 修一下"] --> A3["assistant: tool_use exec"] --> T2["toolResult: 修复输出"]
    end

    subgraph "Pruning 后的 context"
        U1B["user: 帮我看下日志"] --> A1B["assistant: tool_use exec"] --> T1B["toolResult: [Old tool result cleared] ✂️"] --> A2B["assistant: 日志里有报错"] --> U2B["user: 修一下"] --> A3B["assistant: tool_use exec"] --> T2B["toolResult: 修复输出(保留)"]
    end

关键区别:Pruning 只改内存中发给模型的 messages,不碰磁盘上的 JSONL。下次加载 session,原始数据还在。Compaction 改的是磁盘文件,不可逆。

我:什么时候触发?

目前只有 cache-ttl 模式,和 Anthropic 的 prompt caching 机制绑定。Anthropic 的 API 会缓存你发过的 prompt,一段时间内重复发送相同前缀可以跳过处理,省钱。但缓存有 TTL,过期后下次请求会重新缓存整个 prompt。

Pruning 的逻辑是:如果距离上次 Anthropic API 调用已经超过 TTL(默认 5 分钟),说明缓存大概率过期了,下次请求会重新缓存。那在重新缓存之前,先把旧工具输出砍掉,让需要缓存的内容更小,cacheWrite 的费用就降了。

我:具体怎么砍?

两步走。

第一步 soft-trim:对超过 50000 字符的大块工具输出,保留头部 1500 字符和尾部 1500 字符,中间替换成 ...,附一句备注说明原始长度。模型还能看到开头和结尾,知道工具做了什么,但不用承载全部内容。

第二步 hard-clear:对更老的工具输出,整条替换成 [Old tool result content cleared]。模型只知道这里曾经有个工具调用,内容已经清了。

分界线由占 context window 的比例决定:超过 30% 的部分 soft-trim,超过 50% 的部分 hard-clear。

{
  "agent": {
    "contextPruning": {
      "mode": "cache-ttl",
      "ttl": "5m",                    // 缓存过期后触发
      "keepLastAssistants": 3,         // 保护最近 3 条助手消息的工具输出
      "softTrimRatio": 0.3,            // context 的 30% 以上开始 soft-trim
      "hardClearRatio": 0.5,           // context 的 50% 以上 hard-clear
      "minPrunableToolChars": 50000,   // 低于 5 万字符的不动
      "softTrim": {
        "maxChars": 4000,
        "headChars": 1500,
        "tailChars": 1500
      },
      "hardClear": {
        "enabled": true,
        "placeholder": "[Old tool result content cleared]"
      }
    }
  }
}

三种保护机制防止误伤:最近 keepLastAssistants 条助手消息之后的工具输出不动,确保模型最近的工作上下文完整;包含图片的工具输出永远跳过;用户消息和助手消息本身永远不碰。

我:Pruning 和 Compaction 会冲突吗?

不会,完全独立。Pruning 是每次请求前的临时操作,改的是内存;Compaction 是到达阈值时的永久操作,改的是磁盘。两者可以同时启用。一个常见的运行时序是:session 运行一段时间后,Pruning 持续裁剪旧工具输出保持 context 精简;当对话本身积累到接近上限时,先 Memory Flush 存档,再 Compaction 压缩历史。

graph LR
    subgraph "每次 LLM 调用前"
        P["Pruning<br/>裁剪旧工具输出<br/>只改内存"]
    end

    subgraph "接近 context 上限时"
        MF["Memory Flush<br/>存档到记忆文件"] --> CP["Compaction<br/>压缩旧对话<br/>写入磁盘"]
    end

    P -.->|"持续运行"| MF

向量搜索:跨 session 找回旧记忆

我:Compaction 和 Pruning 都是在处理"当前 session 太长"的问题。那跨 session 呢?三个月前写的记忆怎么找回来?

这就是向量搜索要解决的事。模型每次醒来会读 MEMORY.md 和最近两天的日记,但如果你用了三个月,记忆文件有几十个,不可能全读一遍。向量搜索让模型根据当前对话的语义去匹配相关的旧记忆,即使措辞完全不同也能找到。

我:索引怎么建的?

OpenClaw 把所有 Markdown 记忆文件切成 chunk,每个大约 400 token,相邻 chunk 之间有 80 token 的重叠防止信息被切断。然后用 embedding 模型把每个 chunk 转成高维向量,存进 per-agent 的 SQLite 数据库。文件变动时自动重新索引(debounce 1.5 秒)。如果换了 embedding 模型或改了 chunking 参数,整个索引自动重建。

graph LR
    MD["Markdown 记忆文件"] -->|"切分"| CK["Chunks<br/>~400 token/块<br/>80 token 重叠"]
    CK -->|"Embedding 模型"| VEC["向量"]
    VEC -->|"存储"| DB["SQLite 数据库<br/>per-agent 隔离"]

    FS["文件系统 watcher"] -->|"文件变动"| RE["自动重新索引<br/>debounce 1.5s"]

Embedding 模型自动选择,按优先级:本地 GGUF 模型 → OpenAI → Gemini → Voyage。本地模式用 node-llama-cpp 加载约 600MB 的 GGUF 文件,完全离线。远程模式需要对应平台的 API key。

我:搜索的时候怎么匹配?

默认是纯向量搜索——算 query 向量和所有 chunk 向量的余弦相似度,取 top-K。如果装了 sqlite-vec 扩展,距离计算在数据库内部完成,不用把所有向量加载到 JS 进程里。没有这个扩展就 fallback 到 JS 层面逐一计算。

但纯向量搜索有个弱点。它擅长语义匹配——"Mac Studio 网关主机"能匹配"运行 gateway 的那台机器"。但对精确 token 很弱:搜一个 commit hash a828e60、一个变量名 memorySearch.query.hybrid、一条报错信息,向量搜索可能完全找不到。

所以 OpenClaw 支持混合搜索。

我:混合搜索怎么工作?

向量检索和 BM25 关键词检索同时跑,结果合并。两边各取 maxResults × candidateMultiplier 个候选(默认 multiplier 是 4,保证候选池足够大)。BM25 的原始分数是 rank 值(越低越好),先转成 0 到 1 的分数:

textScore = 1 / (1 + max(0, bm25Rank))

然后按权重混合:

finalScore = vectorWeight × vectorScore + textWeight × textScore

默认 vectorWeight 0.7,textWeight 0.3——语义匹配权重更高,关键词做补充。如果任何一边挂了(embedding API 限流、FTS5 创建失败),另一边照常返回结果,不会整体失败。

我:合并之后还有后处理吗?

两个可选阶段,都默认关闭。

第一个是时间衰减。日记文件越积越多,半年前写得再好的笔记不应该压过昨天的更新。衰减公式是指数衰减:

decayedScore = score × e^(-λ × ageInDays)

其中 λ = ln(2) / halfLifeDays

默认半衰期 30 天:今天的笔记 100% 分数,7 天前约 84%,30 天前 50%,90 天前 12.5%,半年前基本消失。但 MEMORY.md 和非日期命名的文件(如 memory/projects.md)永远不衰减——它们是长期参考资料,不该因为时间变旧就降权。

第二个是 MMR 重排序。搜"家庭网络配置",可能有五天的日记都提到同一个路由器设置,纯分数排名返回五条几乎一样的内容。MMR(Maximal Marginal Relevance)在选结果时,每选一条都会惩罚和已选结果太相似的候选,迫使结果覆盖不同方面。lambda 参数控制平衡:1.0 纯看相关性,0.0 纯看多样性,默认 0.7。

整条 pipeline:

graph LR
    Q["搜索 query"] --> V["向量检索<br/>Top-K × 4"]
    Q --> B["BM25 关键词检索<br/>Top-K × 4"]
    V --> M["加权合并<br/>0.7 × vector + 0.3 × text"]
    B --> M
    M --> TD["时间衰减<br/>(可选)"]
    TD --> S["排序"]
    S --> MMR["MMR 去重<br/>(可选)"]
    MMR --> R["Top-K 返回给模型"]

我:QMD 是什么?和内置的搜索有什么不同?

QMD 是一个实验性的替代后端。内置索引器是 OpenClaw 自己用 SQLite 实现的向量存储和 BM25。QMD 是一个独立的本地搜索 sidecar 程序,也做 BM25 + 向量 + reranking,但实现完全不同——用 Bun 运行,自带 GGUF 模型做本地 embedding 和 reranking,不依赖任何远程 API。

OpenClaw 通过 shell 调用 qmd searchqmd query 来检索。Gateway 启动时就初始化 QMD,按配置的间隔(默认 5 分钟)自动更新索引和 embedding。如果 QMD 进程挂了或返回了解析不了的结果,自动 fallback 回内置 SQLite 索引,不影响 memory_search 工具的正常使用。

QMD 有一个内置索引器做不到的能力:索引 session 对话记录。开启后,OpenClaw 把 session 的 JSONL 导出成可搜索的文本存到专门的 QMD collection 里。这样 memory_search 不只能搜记忆文件,还能搜历史对话——相当于给模型开了一个"回忆往期对话"的能力。

{
  "memory": {
    "backend": "qmd",
    "qmd": {
      "includeDefaultMemory": true,       // 索引 MEMORY.md + memory/*.md
      "sessions": { "enabled": true },    // 索引 session 对话记录
      "update": { "interval": "5m" },     // 每 5 分钟刷新索引
      "searchMode": "search"              // 可选 search / vsearch / query
    }
  }
}

三套机制各管一摊:Compaction 压缩历史保证 session 不会撑爆 context window,Pruning 裁剪工具输出节省缓存费用,向量搜索打通跨 session 的长期记忆。它们可以同时启用,互不干扰。

graph TB
    subgraph "上下文管理"
        PR["Pruning<br/>裁剪旧工具输出<br/>每次请求前 · 只改内存"]
        CP["Compaction<br/>压缩旧对话<br/>接近上限时 · 写入磁盘"]
    end

    subgraph "记忆检索"
        VS["向量搜索<br/>语义匹配旧记忆<br/>跨 session · 按需调用"]
    end

    PR ---|"目标:省缓存费用"| CTX["Context Window"]
    CP ---|"目标:腾出空间"| CTX
    VS ---|"目标:找回旧信息"| MEM["记忆文件"]

这套设计的哲学很一致:磁盘上的文件是唯一的真相来源。记忆是 Markdown,历史是 JSONL,索引是 SQLite。没有数据库,没有外部依赖。所有的"聪明"——压缩、裁剪、搜索——都是在这些文件之上的临时计算。文件在,一切都能恢复。