OpenClaw 把 Context 管理抽象成了可插拔的 Context Engine,为什么要做这层抽象?这个设计能支持哪些不同的策略?

魏远标 Lv7

OpenClaw 把 Context 管理抽象成了可插拔的 Context Engine,为什么要做这层抽象?这个设计能支持哪些不同的策略?

参考答案

做这层抽象的根本原因是 Context 管理没有万能方案

通俗理解:Context Engine 就像手机的存储管理。有的人喜欢自动清理旧照片,有的人喜欢只删大文件,有的人喜欢全部存云端按需下载。

不同的应用场景、不同的模型上下文窗口大小、不同的任务类型,最优的 Context 策略差异巨大。

把 Context 管理抽象成接口(定义好”做什么”),让策略实现(”怎么做”)可以独立替换,既方便内部迭代,也方便社区扩展。

这就是经典的策略模式

ContextEngine 接口定义在 src/context-engine/types.ts,覆盖了完整的生命周期:

这套接口覆盖了上下文管理的完整生命周期,核心有三大操作加上若干生命周期钩子:

阶段 方法 做什么
初始化 bootstrap 会话首次创建时做初始化(比如导入历史)
存消息 ingest / ingestBatch 新消息进来时怎么存、要不要做额外处理(比如向量化)
挑消息 assemble 发给模型前,在 token 预算内挑出最合适的一组消息
压消息 compact 历史太长时怎么压缩(摘要、裁剪、归档…)
轮后处理 afterTurn 每轮对话结束后的收尾工作(持久化、触发后台压缩等)
子 Agent prepareSubagentSpawn / onSubagentEnded 管理子 Agent 的上下文隔离与回收
销毁 dispose 释放引擎持有的资源

核心调度逻辑只依赖这套接口,压根不关心背后的具体实现。通过 registerContextEngine(id, factory) 注册新引擎,在配置里通过 plugins.slots.contextEngine 一行就能切换,完全不用动核心代码。

这就是典型的策略模式 + 插件化

有了这层抽象,至少能支撑以下几种完全不同的策略方向:

  1. 默认的 legacy 策略:全部塞进去,塞不下了线性压缩最早的消息,简单粗暴但够用
  2. 基于检索的 RAG 策略:消息入库时向量化,组装时按语义相关性捞历史,适合长对话多话题场景
  3. 分层存储策略:类似冷热分离,最近几轮放内存、摘要放本地、更早的扔云端,按需拉取
  4. 任务感知策略:根据当前任务类型(写代码 vs 闲聊)动态决定保留哪些历史,同样的 token 预算质量更高
  5. 自定义压缩策略:通过 ownsCompaction 标记接管压缩,可以实现树状摘要、按话题分支压缩等高级方式

扩展知识

内置的 legacy 引擎

当前 OpenClaw 默认内置了一个 legacy 引擎,代码在 src/context-engine/legacy.ts。它的实现非常直白:ingest 是 no-op,因为消息持久化由 SessionManager 负责;assemble 直接透传消息列表,不做任何筛选;compact 委托给 compactEmbeddedPiSessionDirect() 做线性压缩。

这个引擎能跑,但策略非常粗糙,基本就是”全部塞进去,塞不下了就压缩最早的”。对于简单的短对话够用,但一旦对话超过几十轮,或者涉及多个不同话题的长期交互,效果就明显不行了。

可以实现的高级策略

Context Engine 抽象真正的价值在于它能支撑完全不同的上下文管理思路:

基于检索的 Context Engine

这种引擎走 RAG 的路子。ingest 的时候把每条消息向量化,存进向量库。到 assemble 阶段,不是按时间顺序把最近的消息拼进去,而是根据当前 query 做语义检索,把最相关的历史片段捞出来。对于那种跨好几天、中间换过好几个话题的长对话,这种方式比线性截断有效得多。

分层存储引擎

思路类似数据库的冷热分离。热数据就是最近 3-5 轮对话,直接放内存;温数据是最近几个 compaction 周期的摘要,放本地文件;冷数据是更早的历史,可以扔到外部存储甚至云端。assemble 的时候按需从不同层拉取,既能保证最近的上下文完整,又不会因为历史太长撑爆内存。

任务感知引擎

根据当前任务类型动态调整 Context 组装策略。比如检测到用户在写代码,就优先保留代码相关的历史、文件路径、报错信息;检测到是闲聊,就优先保留情感偏好和个人信息。同样的 token 预算,塞进去的内容质量完全不一样。

自定义 Compaction 引擎

接口里有个 ownsCompaction: true 的标记,设了这个标记的引擎可以完全接管压缩策略。默认的线性压缩是把最早的消息一坨压成摘要,但你可以换成树状摘要,把对话按话题分支组织,每个分支独立压缩,保留更多结构化信息。

插件注册机制

整个切换过程对核心代码零侵入。写一个新引擎只需要实现 ContextEngine 接口,然后调用 registerContextEngine("my-rag-engine", factory) 注册进去。

配置文件里把 plugins.slots.contextEngine 指向你的引擎 ID 就行了。这跟 Webpack 的 plugin 体系、VS Code 的扩展机制是一个思路,都是约定好接口,实现随便换。

面试官追问

提问:legacy 引擎的 ingest 是 no-op,那消息是谁在管?如果换成 RAG 引擎,这块职责怎么迁移?

回答:legacy 引擎的消息持久化是 SessionManager 在做,ingest 啥也不干纯粹是因为职责没划到 Context Engine 这边。换成 RAG 引擎的话,ingest 就得真正接管消息的处理了,至少要做向量化和入库。迁移的关键是 SessionManager 得把”写消息”这个动作让出来,或者两边做好协调,不能重复写。实际上这也是为什么要抽象成接口的原因之一,职责边界可以随着引擎实现灵活调整。

提问:assemble 的时候有个 token 预算参数,如果预算给得很紧,不同引擎的降级策略会有什么差异?

回答:legacy 引擎很暴力,直接从最早的消息开始砍,砍到塞得下为止。RAG 引擎好一些,它本来就是按相关性排序的,预算紧就少捞几条,质量衰减比较平滑。分层存储引擎会优先砍冷数据层,保住热数据。任务感知引擎最灵活,它可以根据任务权重动态决定哪些类型的历史先丢掉,比如写代码的时候闲聊记录优先级最低,第一个被砍。

提问:ownsCompaction 这个标记具体怎么生效的?不设这个标记的话压缩是谁触发的?

回答:OpenClaw 的压缩触发分两层。不设 ownsCompaction 的话,底层的 Pi runtime 有内置的 auto-compaction,它自己监控 token 用量,超过阈值就自动压缩,外层 Runner 不需要手动介入。设了 ownsCompaction: true 之后,Pi 的内置 auto-compaction 会被禁用,改由引擎自己通过 afterTurn 等生命周期钩子决定什么时候压、怎么压。但不管设没设这个标记,Runner 还有一层溢出压缩兜底,当上下文真的塞不下时,Runner 会直接调 contextEngine.compact() 做紧急压缩。简单说就是:ownsCompaction 控制的是”日常谁来管压缩”,但”快炸了”的时候 Runner 一定会兜底。这种设计对有自己一套存储和索引体系的引擎特别重要,因为通用的压缩逻辑不了解引擎内部的数据组织方式,日常由引擎自主管理更合理。