深入解析Openclaw Pre-compaction Memory:Agent上下文压缩前的记忆预整理机制
2026/9/16 4:28:16 网站建设 项目流程

如果你最近在折腾 Openclaw,应该会和我一样对它内部那套记忆机制产生好奇。跑过几次长任务之后你会发现,真正决定一个 Agent 是“越用越聪明”还是“聊着聊着就失忆”的,往往不是模型本身,而是它在上下文塞满之前做了什么。我今天想聊的 Pre-compaction memory,就是这个环节里最容易被忽略、却最值得抠代码的部分。

先说清楚一件事:Pre-compaction memory 不是某个独立功能模块,而是 Openclaw 在真正执行 context compaction(上下文压缩)之前,对历史对话、工具调用结果、临时记忆做的一套“预整理”机制。它决定了哪些消息被留进压缩摘要、哪些被打进冷存储、哪些在压缩完成后还能被捞回来。如果你直接跳过这块去调 compaction 参数,大概率会踩到“压缩完变傻”“连续任务丢状态”“token 倒是省了但工具调用链断了”这些坑。

这篇东西适合两类人看:一类是在本地部署 Openclaw、想把长会话跑稳的玩家,另一类是打算基于 Openclaw 改记忆策略、甚至自己写 Agent 内存管理模块的开发者。我会从设计思路、核心数据结构、关键代码逻辑、触发链路一直讲到排查经验,尽量做到看完就能对着源码定位问题。

1. 先搞懂 Openclaw 的 Pre-compaction memory 是什么

1.1 从一次“对话突然变笨”的现场说起

我最早注意到 Pre-compaction memory,是因为一次很典型的翻车现场。当时用 Openclaw 跑一个连续抓取网页、整理资料、再输出报告的长流程,前面一个小时都很正常,突然某个节点开始忽略我之前明确说过的约束,甚至把早前已经完成的步骤又重复执行了一遍。

事后翻日志,发现系统在前一个节点触发了 context compaction,而它压缩时把我中途强调过的一条关键指令给“优化”掉了。那会儿 Openclaw 的行为还是偏粗暴:超过阈值就把头部消息直接截走,只留最近几轮。后来我顺着代码往深处翻,才看到新版本里其实已经有一套更细的预压缩设计,也就是 Pre-compaction memory 机制。简单说,它是在压缩发生之前,先把有价值的记忆做标记、快照、分层,再决定哪些进摘要,哪些进持久化存储。

从这里开始,我就把注意力从“怎么调大窗口”转移到了“压缩前到底发生了什么”。因为窗口再大也有尽头,真正影响长任务稳定性的,是压缩前如何对待已经产生的记忆。

1.2 为什么叫“压缩前”,它和 token 窗口有什么关系

要理解 Pre-compaction memory,得先理解 Openclaw 对上下文窗口的管理模型。正常运行时,所有消息会按顺序堆积在一个 context buffer 里,包含 system prompt、用户消息、Agent 的思考过程、工具调用和工具返回结果。LLM 每次请求都会把这堆东西整体发出去,所以 token 消耗和上下文窗口占用是同步增长的。

窗口是有限的,所以 Openclaw 会设定一个“警戒水位”。当已用 token 超过某个比例,比如 maxTokens 的 75%,就进入 pre-compaction 阶段。这时候系统不会立刻压缩,而是先把当前内存里的消息做一次“盘点”:哪些消息是最近必须原样保留的,哪些中间轮次可以被摘要替代,哪些历史记录可以序列化到本地存储以后按需召回。

这个“盘点”动作,就是 compact 之前的预备状态,也就是名字里 pre- 的由来。它实际上是给真正的压缩函数提供输入数据:一个经过筛选和标记的 MemoryUnit 列表、一个填充好的 CompactionContext,以及一组压缩策略参数。没有这一步,后面的 compact() 只能无差别处理,根本谈不上“智能压缩”。

1.3 这套机制解决的核心痛点

我在本地试过几种不同的 Agent 框架,也手写过简单的 memory manager,最后总结出 Openclaw 做 pre-compaction 想解决的三个核心痛点:

  • 无差别截断导致关键信息丢失。旧做法是“掐头去尾”,但 Agent 场景里用户最早提出的目标、中途纠正过的指令,往往比最近几句闲聊更重要。
  • 工具调用链断裂。很多任务是一连串 tool_call 和 tool_result 配对的,如果压缩时只留结果、不留中间状态,后续步骤就不知道上一个调用返回的变量是什么。
  • 资源浪费。不做预整理就压缩,经常会把已经在摘要里出现过的重复内容再次压进去,白白消耗 LLM 的摘要请求。

Pre-compaction memory 把“要不要留”“留多少”“留原样还是留摘要”这些决策,从压缩那一刻提前到了消息进入内存时就持续跟踪。它等于在压缩器前面加了一个调度台,让压缩不再是粗暴清理,而是有依据的“记忆归档”。

2. 核心代码骨架:想读懂先认人

2.1 相关模块与数据流总览

Openclaw 的代码里,和 Pre-compaction memory 相关的模块并不集中在一个文件里,而是分散在 memory、context、llm 这几个目录下。如果你打开源码找,会看到几个高频出现的名字:

  • MemoryManager:负责整个记忆生命周期的管理,包括消息写入、重要性打分、快照、压缩触发。
  • ContextBuilder:负责把 MemoryUnit 列表拼装成最终发送给模型的 prompt。
  • CompactionService:执行真正的压缩逻辑,但它的输入是 pre-compaction 阶段产出的 CompactionContext。
  • MemoryStore:负责持久化快照、摘要和冷数据,支持按 id 或关键词召回。

整个数据流大致是这样:每一条新消息产生后,会先经过 MemoryManager 登记,ContextBuilder 时刻统计 token 占用;当 token 占用超过阈值,MemoryManager 进入 pre-compaction 流程;它调用内部方法筛选消息、生成快照、构建压缩上下文;最后交给 CompactionService 完成摘要和存储。

这几步在代码里是清晰分开的,所以调试的时候比较容易定位问题。我一开始以为 pre-compaction 只是 compact() 函数开头几行,后来才发现它其实是 MemoryManager 里一个独立的状态阶段,有专门的日志标识和异常处理分支。

2.2 核心数据结构:MemoryUnit 与 CompactionContext

看懂代码前,先看数据。Openclaw 在 pre-compaction 里最核心的两个数据结构,一个是代表单条消息的 MemoryUnit,另一个是承载整个压缩任务的 CompactionContext。

我用代码注释的方式把关键字段整理了一下:

// 每条消息在内存中的表示 interface MemoryUnit { id: string; role: "system" | "user" | "assistant" | "tool"; content: string; createdAt: number; tokenCount: number; importanceScore: number; // 重要性打分,越高越值得保留 tags: string[]; // 例如 ["goal", "correction", "tool_result"] relatedToolCallId?: string; // 如果是 tool_result,关联对应的 tool_call metadata: Record<string, unknown>; } // 预压缩阶段产出的上下文 interface CompactionContext { snapshot: MemoryUnit[]; // 当前内存全量快照 hotWindow: MemoryUnit[]; // 最近 N 条原样保留的消息 candidates: MemoryUnit[]; // 可以被摘要替代的中间消息 coldStorageKeys: string[]; // 已序列化到冷存储的 key retentionPolicy: { maxContextTokens: number; preCompactionRatio: number; hotWindowSize: number; summaryTargetTokens: number; }; summaryModel: string; // 生成摘要用的模型 }

我第一次看到 CompactionContext 的时候,最受启发的是它把“全量快照”和“候选摘要列表”分开了。这意味着压缩前内存里所有信息都不会被立即销毁,而是有一个 snapshot 兜底。即使摘要生成失败,也还能从快照里恢复原样,而不是直接丢消息。

2.3 核心方法:snapshot、compact、restore

在 MemoryManager 里,有三个方法是我认为最值得细读的,它们的调用顺序构成了 pre-compaction 的主流程:

class MemoryManager { async preCompactIfNeeded(): Promise<CompactionContext | null> { const usage = await this.estimateTokenUsage(); const threshold = this.config.maxContextTokens * this.config.preCompactionRatio; if (usage < threshold) { return null; // 未到阈值,不进入预压缩 } // 第一步:生成全量快照,保证可恢复 const snapshot = await this.snapshotMemory(); // 第二步:划分热窗口和候选压缩区 const { hotWindow, candidates } = this.splitMemory(snapshot); // 第三步:筛选需要持久化的冷数据 const coldStorageKeys = await this.archiveColdData(candidates); return { snapshot, hotWindow, candidates, coldStorageKeys, retentionPolicy: this.config, summaryModel: this.config.summaryModel, }; } async compact(ctx: CompactionContext): Promise<MemoryUnit[]> { // 1. 对 candidates 生成摘要 const summary = await this.summarize(ctx.candidates, ctx.summaryTargetTokens); // 2. 重组消息列表:system + 摘要 + hotWindow const compacted = [ ctx.snapshot.find((m) => m.role === "system"), { role: "assistant", content: summary, tags: ["summary"] }, ...ctx.hotWindow, ]; // 3. 将 coldStorageKeys 写入 store await this.memoryStore.save(ctx.coldStorageKeys, ctx.snapshot); return compacted; } async restoreMemory(sessionId: string): Promise<void> { // 从 MemoryStore 读取快照,按需恢复 } }

这段代码是把源码里分散的逻辑做了一个线性化整理,实际实现里还会夹带很多边界处理,但主线就是这么三条:先快照、再分区、最后摘要替换。记住这三个动作,后面所有参数调优都是围绕它们展开的。

3. 关键实现细节:触发链路、快照选择与压缩策略

3.1 触发条件:什么时候进入压缩前状态

我在实际运行中观察,Openclaw 并不是等到上下文快满才开始处理,而是有一个“软阈值”和“硬阈值”的双层设计。软阈值对应 pre-compaction 的启动点,硬阈值对应真正收缩窗口的底线。

在默认配置下,软阈值通常是 maxContextTokens 的 0.75,硬阈值是 0.9。也就是说,假设你给 Openclaw 配了 128K 上下文,那么用到 96K 左右时,系统就会开始跑快照和候选筛选;而到 115K 时,即便摘要还没生成完,也必须强制压缩,否则请求就会报超窗错误。

这个双层设计很实用。软阈值给了 Pre-compaction memory 足够的时间做“精细活”,硬阈值则保证系统永远不会把窗口用完。端口实现里还有一个细节:触发前会预留一段 headroom,专门给当前正在执行的工具调用返回值。因为很多任务卡在最后一步工具返回超长内容,如果一点缓冲都不留,会直接挤爆窗口。

所以你在日志里看到类似 pre-compaction triggered, usage=98000/131072 这样的信息,不用慌,说明软阈值生效了,系统正在准备压缩。真正要担心的是日志里直接出现 force compact,那说明前一轮预压缩没来得及完成,已经到硬阈值了。

3.2 快照保留策略:哪些内容值得留下

Pre-compaction 阶段最核心的决策,是给每条消息打上“去留标签”。我在源码里看到一套基于规则+重要性打分的策略,总结下来大概是这么几类:

  1. 用户最新指令和关键约束。这类消息通常带 correction 或 goal 标签,无论多早出现都不能被摘要替代,应该原样保留或放在摘要最前面。我实际测试过,如果这类消息被压成一句话,模型后续大概率会“忘记”约束。
  2. 工具调用链中的关键状态。判断标准是一串 tool_call 是否被后续代码引用。Openclaw 会维护一个变量引用表,如果某次 tool_result 的字段在后面被读取,它就会被标记为 high importance。
  3. 可重复或可推导的中间内容。比如网页抓取的原始 HTML、超长的 API 返回 JSON,这些通常是 candidate 首选,适合被压缩成结论摘要。
  4. 纯粹的寒暄和重复尝试。比如 Agent 多次调用同一个失败接口、用户反复表达同一句话,这类内容重要性最低,基本会被直接归档到冷存储。

为了让这个决策可解释,Openclaw 还给每条 MemoryUnit 写了一个 importanceScore(0 到 1)。pre-compaction 时会先按分数降序排,再结合 token 预算决定保留边界。我测试时发现,importanceScore 的默认计算方式偏向“最近消息”和“系统消息”,所以如果你有特殊需求,比如希望“用户第一句话永远保留”,最好在记忆模块里自定义打分规则,而不是依赖默认值。

3.3 压缩算法选择:滑动窗口、摘要提取与分层衰减

真正执行压缩时,Openclaw 并不是简单地把 candidates 丢给 LLM 写一段摘要完事。它在 CompactionService 里做了三个策略:

  • 热窗口原样保留。默认保留最近 8 到 10 条消息,具体数量由 hotWindowSize 控制。这些消息不参与摘要,原封不动留在 context 里,保证模型对当前任务状态有完整感知。
  • 对候选区做分段摘要。如果候选区太长,超过 single-pass 摘要能力,Openclaw 会把消息按时间切块,每块生成一个局部摘要,再合并成全局摘要。这招在处理超长工具日志时特别有效,不会因为输入太长把摘要请求本身搞超时。
  • 冷数据带衰减权重归档。对于一些已经失去即时价值、但未来可能用到的历史记录,Openclaw 会把它们序列化到 MemoryStore,同时在摘要里只保留一个“可召回索引”,比如“关于竞品价格的数据见 cold://order/123”。这样既节省 token,又保留了后续深挖的可能。

这套组合策略本质上就是“热数据留原样、温数据出摘要、冷数据进仓库”的分层思想。我后来在自己项目里也是照着这个思路实现的一套简化版,效果比直接截断稳定得多。

4. 实操验证:从部署到观测 Pre-compaction memory

4.1 本地部署与最小复现环境

想真正读懂 Pre-compaction memory,光看代码不够,跑起来看日志才有感觉。Openclaw 的部署方式很简单,从 GitHub main 分支拉源码,按官方文档安装依赖就行。本地建议用 Node 18 以上版本,装好 pnpm 后执行安装脚本,再配置好模型 API key 基础环境变量即可。

我自己的复现环境是这样搭的:

git clone https://github.com/openclaw/openclaw.git cd openclaw pnpm install cp .env.example .env # 编辑 .env 填入模型 API key、memory 相关参数 pnpm dev

跑起来后,我会故意构造一个长任务来触发 pre-compaction,比如让 Agent 连续查询十几次天气、每次返回超长 JSON,同时中途追加一条指令“把最后结果整理成表格”。这样既能快速堆高 token,又能测试中途指令是否会在压缩后保留。

4.2 打开相关日志和指标

Openclaw 默认日志级别可能不够细,需要在环境变量里加:

LOG_LEVEL=debug MEMORY_DEBUG=true

这样运行时会在控制台看到类似下面的输出:

[memory] pre-compaction: usage=98765/131072, ratio=0.75 [memory] snapshot created, units=124, tokens=98765 [memory] hotWindow=8, candidates=96, coldStorage=20 [memory] summary generated, tokens=2150/4000 [memory] compacted context tokens=18420

这些输出就是判断 Pre-compaction memory 是否正常工作的第一手证据。我会重点关注两个指标:一个是 candidates 和 hotWindow 的比例,另一个是摘要生成后的 token 总数。如果发现 hotWindow 被压缩没了,或者摘要 token 直接打满,说明参数设置有问题。

另外,如果部署时启用了 Prometheus 指标导出,Openclaw 还会暴露 memory_pre_compaction_triggered_total、memory_compaction_duration_ms 这类指标,方便做更长时间的观测。不过本地调试用 DEBUG 日志就够了。

4.3 关键参数调整与效果对比

我把自己在项目里实际调过的几个参数整理成一张表,方便你照着实验:

参数名默认值作用我的建议
MEMORY_MAX_CONTEXT_TOKENS128000上下文硬上限根据模型实际窗口留 10% 缓冲
MEMORY_PRE_COMPACTION_RATIO0.75软阈值比例长任务场景可以降到 0.7,留更多处理时间
MEMORY_HOT_WINDOW_SIZE8最近原样保留消息数工具密集型任务调到 12,否则 6 也够
MEMORY_SUMMARY_TARGET_TOKENS4000摘要目标长度不要超过总窗口 5%,否则压缩无意义
MEMORY_SNAPSHOT_DIR./memory_snapshots快照持久化目录设置成独立磁盘,避免和日志混在一起

我最常做的一个对比测试是:在同样一个 30 轮任务里,把 preCompactionRatio 从 0.75 改成 0.85,观察任务末段是否出现“理解偏移”。实测下来,0.75 时模型能记住中途追加的约束,0.85 时偶尔会漏。原因不难理解:触发晚,留给快照和摘要的时间不够,系统只能仓促压缩,质量自然会下降。

另外,hotWindowSize 也不是越大越好。调大它,确实能保留更多近期细节,但也会挤压摘要区,导致更早的历史记忆被压缩得更狠。所以如果你发现“最近几轮很聪明,但整体任务目标丢失”,大概率是 hotWindow 太大、摘要太短。

5. 常见问题与排查技巧实录

5.1 明明还有 token,为什么提前触发压缩

有段时间我在日志里看到 pre-compaction 在 token 占用刚到 50% 时就触发了,第一反应是配置写错了。后来查代码才发现,Openclaw 的 token 估算不是简单数消息字符,它会额外计算每个 MemoryUnit 的 metadata 和 tool schema 长度。某些工具的入参 schema 很长,虽然消息体不大,但估算值时被放大了。

另外,还有一个隐藏因素:每条 tool_result 会保留一份对应的 tool_call 内容,用于配对检查。如果某个工具返回了超长 JSON,即使你只用到其中两个字段,估算时也会把完整 JSON 算进去。

解决办法是两类:一是缩短工具返回,在调用工具前对 result 做截断或字段过滤;二是在配置里调整 token 估算器的系数,让它更接近真实模型 tokenizer 的计数。我个人更推荐前者,因为截断工具返回还能降低模型误读长文本的概率。

5.2 压缩后回答质量下降,怎么定位是不是摘要的锅

压缩后质量下降是最常见的反馈,但不一定是 Pre-compaction memory 本身的 bug。我在定位时一般分三步:

第一步,关闭 pre-compaction,把同一个任务跑一遍,如果质量恢复,说明问题出在压缩阶段。第二步,打开 debug 日志,把压缩后的实际 context 内容导出来,人工检查摘要里是否丢了关键信息。第三步,如果发现摘要确实漏了,就去检查 importanceScore 的规则,看看是不是把某些关键消息错误地打成了低分。

我还遇到过一种情况:摘要本身没问题,但 hotWindow 里的最新消息和摘要之间存在“语义断层”。比如摘要里最后一句话是“用户希望输出表格”,但 hotWindow 中第一条消息是“继续”,模型就不知道继续什么。这种时候需要调整摘要生成提示词,强制要求在摘要末尾保留“当前任务状态”这一项。

5.3 重启后上下文丢失,快照没恢复

Pre-compaction 阶段生成的冷存储数据是持久化的,但默认并不会在每次启动时自动恢复。如果你重启 Openclaw 后,之前对话的“记忆”没了,先检查 memory_snapshots 目录下有没有生成最新的快照文件。有文件但没恢复,大概率是启动参数里少了恢复标志。

我踩过的坑是快照目录权限不对,导致序列化失败,但日志里只出现一行 warn,不仔细看根本发现不了。建议在部署脚本里把 MEMORY_SNAPSHOT_DIR 指向一个明确存在的目录,并定期检查磁盘剩余空间,否则快照写一半失败,恢复时只能拿到残缺数据。

5.4 内存占用越来越高,疑似泄漏

如果你长时间跑 Openclaw,发现进程内存只涨不降,除了 Node.js 本身的 GC 问题,最可能与 MemoryStore 里的冷数据缓存有关。Pre-compaction 会把快照和摘要同时缓存在内存里,目的是加速后续召回。但当任务量很大时,这个缓存可能越积越多。

我的排查思路是先看 MemoryStore 的缓存上限配置,通常有一个 MAX_CACHED_SNAPSHOTS 之类的参数。调低它可以让旧快照尽快被清理。然后再看是不是有某个超大 MemoryUnit 一直被 hotWindow 或 system prompt 引用,导致无法回收。针对这种“钉子户”,我会在业务层增加 message 合并逻辑,把连续同类型的 tool_result 直接合并成一条。

6. 最后分享一点个人实践建议

我在本地把 Openclaw 的 Pre-compaction memory 翻来覆去调了几周,最大的体会是:不要把它当成一个“默认配置就够用”的黑盒。它更像是 Agent 的长期记忆中枢,值得你根据实际任务类型去做针对性调整。如果你的任务偏短、工具调用少,默认参数完全够用;但如果像我一样经常跑长流程、批量处理多步骤任务,我强烈建议你花半天时间把跑出来的快照文件打开看一看。那份快照比任何文档都能说明问题,因为它真实记录了系统眼里“什么值得记住”。

另外一个小技巧:把 Pre-compaction 触发前后的完整 context 分别导出,放在一起对比。这样你能很快建立直觉,知道摘要到底丢掉了哪些细节、hotWindow 保留了哪些近期信号。我后来设计自己项目的记忆模块时,就是靠这份对比记录总结出了一套非常实用的分级策略。这也是我认为 Openclaw 这个设计最值得抄作业的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询