
OpenClaw 设计解析(六):上下文记忆
OpenClaw 设计解析(六):上下文记忆
本篇聚焦 OpenClaw 的“上下文记忆”设计:Pi 框架提供什么底座、OpenClaw 在其上做了哪些工程化增强,以及长期记忆插件如何与会话上下文配合。
很多项目一提“记忆”,第一反应是向量数据库或者 RAG。但从 OpenClaw 的源码看,它的“记忆”并不是一个单点模块,而是一个分层系统:
- 运行前注入什么:workspace 里的稳定文件、system prompt、skills、tools
- 运行中保留什么:当前 session 的消息历史、工具结果、媒体与系统事件
- 快溢出时怎么缩:history limit、tool result guard、context pruning、auto-compaction
- 真正长期保存什么:
MEMORY.md、memory/*.md、以及围绕它们建立的检索索引
所以更准确地说,OpenClaw 设计的是一套 “上下文构建 + 会话持久化 + 压缩回收 + 长期召回” 的记忆体系。
1. 先看全貌:OpenClaw 的记忆不是一层,而是四层
这四层各自解决的是不同问题:
- 稳定上下文:让 agent 每次启动都知道“自己是谁、有哪些规则、这个 workspace 是什么”
- 短期工作记忆:让同一个 session 能连续对话、连续做任务
- 窗口治理:在有限 context window 下尽量保持最近且最重要的信息
- 长期记忆召回:把不该常驻 prompt 的信息,改成需要时再检索
这也是 OpenClaw 很典型的工程思路:不是把所有记忆都塞进上下文,而是把“常驻”和“按需”拆开。
2. Pi 提供的底座:Session、持久化、自动压缩
OpenClaw 的 Agent 运行时建立在 Pi 上,跟“上下文记忆”最相关的是三件事:
2.1 createAgentSession():Pi 负责“执行循环”
OpenClaw 并不自己实现一套 LLM 循环,而是把模型、工具、session manager 交给 Pi:
1 | sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), ...) |
Pi 做的事情是:
- 管理消息序列
- 执行 tool call 循环
- 把消息历史写入 transcript
- 在上下文溢出时触发
auto_compaction
也就是说,Pi 提供“会话引擎”,OpenClaw 提供“上下文策略”。
如果把一次 session.prompt() 展开,从 OpenClaw 侧能观察到的 Pi 运行时大致是这样:
2.1.1 消息序列是怎么管理的
从 OpenClaw 的接线方式看,Pi 在 session 内部维护一份当前消息序列,OpenClaw 可以:
- 在运行前用
session.agent.replaceMessages()替换它 - 在运行中通过事件流看到 assistant / tool / compaction 的变化
- 在运行结束后直接读取
session.messages
也就是说,Pi 的 session 里既有一份“当前可见上下文数组”,又有一份落盘 transcript。OpenClaw 在真正 prompt() 前,会先做:
1 | const limited = limitHistoryTurns(validated, ...) |
所以“消息序列怎么管理”的答案不是简单的“append 到数组”,而是:
- Pi 维护当前会话消息数组
- OpenClaw 在 prompt 前可以重写这份数组
- Pi 在执行中继续向这份序列追加 assistant / toolResult / compaction 相关消息
- SessionManager 再把它们持久化到 transcript
2.1.2 tool call 循环是怎么跑起来的
从 subscribeEmbeddedPiSession() 订阅的事件类型可以反推出 Pi 的循环形态:
message_start / message_update / message_endtool_execution_start / tool_execution_endauto_compaction_start / auto_compaction_endagent_start / agent_end
这说明 Pi 的执行模型不是“模型只回答一次”,而是一个标准的 agent loop:
1 | while (true) { |
OpenClaw 在这个循环里主要做两件事:
- 提供工具实现与策略过滤
- 订阅运行时事件,把 Pi 的内部状态翻译成“用户可见回复 / typing / tool summary / compaction 通知”
所以 createAgentSession() 的价值,不只是“创建一个 session”,而是把 LLM 对话、tool dispatch、消息持久化、自动重试 几件事绑成了一个完整状态机。
2.1.3 transcript 到底是什么
这里的 transcript 不是“聊天记录字符串”,而是 Pi SessionManager 管理的 JSONL 会话文件。
OpenClaw 的参考文档里把它描述得很清楚:第一行是 session header,后面每一行是一个 entry。常见 entry 类型有:
messagecustom_messagecustomcompactionbranch_summary
所以 transcript 的本质是:
- 可追加
- 可回放
- 可 compaction
- 不是为了给人直接读的 Markdown,而是运行时状态日志
这也是为什么 OpenClaw 把“长期记忆文件”和“session transcript”分开设计。二者都能存信息,但职责完全不同。
实际落盘位置通常在:
~/.openclaw/agents/<agentId>/sessions/sessions.json:session 元数据索引~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl:真正的 transcript- Telegram topic 之类的会话还可能是
.../<sessionId>-topic-<threadId>.jsonl
这里要先强调一下:
- 上面说的
sessions.json是索引 - 下面马上要给出的 JSONL 例子是真正的 transcript
也就是说,sessions.json 负责告诉你“当前这个 sessionKey 对应哪个 sessionId / sessionFile”;而 *.jsonl 才是那段对话真正的历史。
一个简化后的索引例子大概像这样:
1 | { |
这个索引条目的意思不是“消息内容存在这里”,而是:
- 路由键
agent:main:telegram:dm:123456 - 当前指向的会话文件是
sess_abc123.jsonl - 当下一条来自这个 DM 的消息到达时,OpenClaw 会继续往这份 transcript 上追加
如果这个 session 之后发生了 /new、daily reset,或者 thread/topic fork,变化的通常不是旧 transcript 被改名重写,而是 sessions.json 里的指针改了:
1 | { |
这时:
sess_abc123.jsonl仍然是旧历史sess_def456.jsonl成为这个sessionKey当前继续写入的新 transcript
如果把一个真实 transcript 简化一下,大致会长这样:
1 | {"type":"session","version":7,"id":"sess_abc123","timestamp":"2026-03-16T09:00:00.000Z","cwd":"/workspace/project"} |
这个例子里最关键的点有 4 个:
- 它不是一个 JSON 数组,而是 JSONL
每一行都是一个独立 JSON 对象,追加和修复都更方便。 - 真正的 user / assistant / toolResult 在
message字段里
外层的type / id / parentId / timestamp更像日志 envelope。 id + parentId让 transcript 不是纯线性链表,而是可分支的树
这也是为什么 Pi 能做 branch / reset / compaction。compaction不是一条普通消息
它是一条专门的持久化摘要 entry,后续重建上下文时会参与“历史折叠”。
为了可读性,上面的示例省略了不少实际字段,比如 api、provider、model、usage 等。但只要记住这个骨架就够了:
1 | session header |
所以 transcript 本质上不是“给人看的聊天记录”,而是 Pi 用来重建 session 上下文的一份 append-only 运行日志。
如果把索引和真正 transcript 的关系压成一句话,就是:
1 | sessionKey |
这也是为什么 OpenClaw 会把 sessions.json 和 *.jsonl 分开:
sessions.json适合快速查“当前该接哪份历史”*.jsonl适合 append-only 地存“这份历史本身长什么样”
这里还有一个很容易误解的点:/new、/reset 之后,旧 transcript 不会再被当前 sessionKey 自动接上继续跑,但这不等于它立刻从磁盘上消失了。
更准确地说,reset 发生时会有两步:
sessions.json把当前sessionKey改指向新的sessionId/sessionFile- 旧 transcript 被归档成
*.jsonl.reset.<timestamp>
可以把它理解成:
例如,reset 前索引可能是:
1 | { |
reset 后会变成:
1 | { |
与此同时,旧文件通常不会直接删除,而是变成:
1 | ~/.openclaw/agents/main/sessions/sess_abc123.jsonl.reset.2026-03-16T09-40-00.000Z |
所以这里最准确的理解是:
- 对当前运行时来说:旧 transcript 已经退出主路径,后续 prompt 不会再自动重建它
- 对磁盘存档来说:旧 transcript 往往还在,只是已经变成 reset archive
这些 reset archive 后续还可能被 maintenance 清理;默认保留期跟 pruneAfter 对齐,而不是永久保存。
2.1.4 auto_compaction 的结果是什么
这个问题非常关键。auto-compaction 的结果不是“临时生成一段摘要然后继续聊”,而是:
- 把老历史总结成一个 持久化的
compactionentry - 记录 compaction point,例如
firstKeptEntryId - 后续会话不再重放被压缩掉的老消息,而是重放:
- compaction summary
firstKeptEntryId之后的较新消息
- Pi 再基于压缩后的上下文重试当前请求
也就是说,compaction 改变的不是“一次请求的 prompt”,而是这个 session 之后的历史形态。
如果只是“临时生成一段摘要继续聊”,那它应该只影响当前这一次模型请求:
- 这次请求前,临时拼一段 summary
- 请求结束后,磁盘 transcript 不变
- 下一轮
buildSessionContext()还是会把原来的老消息整段重放回来
Pi 的 auto-compaction 不是这种临时 patch。它更像是:
- 在 transcript 里追加一条
compactionentry - 记住 cut point,例如
firstKeptEntryId = m80 - 以后重建上下文时,不再原样重放
m1...m79 - 而是重放“
m1...m79的摘要 +m80之后的新消息”
可以把“压缩前 / 压缩后,未来 turn 看见什么”理解成:
1 | 压缩前: |
这里特别要注意两层对象:
- 磁盘 transcript 仍然是 append-only 日志
- 未来 prompt 的可见上下文 已经改成“summary + kept suffix”
也就是说,compaction 的持久性体现在:未来怎么重建上下文变了,而不只是“这一次 prompt 前多塞了一段字”。
这也是 OpenClaw 为什么会围绕它做那么多增强:
- compaction 前做 memory flush
- compaction 时做 safeguard
- compaction 后补 post-compaction context refresh
因为它不是一个临时优化,而是一次真正的“会话记忆重写”。
2.1.5 既然 Pi 会 auto-compaction,为什么 OpenClaw 还要自己裁剪
这里很容易误解成“Pi 没考虑上下文超限,所以 OpenClaw 只好自己补”。更准确的说法是:
- Pi 已经考虑了上下文超限
- OpenClaw 额外做的是更前置、更业务化的窗口治理
Pi 在运行时的主方案是 auto_compaction:
- 模型已经返回 context overflow 错误时,先 compact,再 retry
- 没有报错,但估算 token 已经接近
contextWindow - reserveTokens时,也会自动 compact
所以 Pi 的职责更像是:保证 session 在上下文快满或已经溢出时,还能继续跑下去。
而 OpenClaw 自己做 limitHistoryTurns()、tool-result guard、context pruning,解决的是另一类问题:
- DM 和群聊的上下文策略不同
- 某些消息平台一个 thread / topic 就是一个 session,历史天然会越积越长
- 真正把窗口打爆的常常不是对话,而是超大的工具输出、日志、网页内容
也就是说,OpenClaw 不是在重复 Pi 的 compaction,而是在做一层 “能不 compact,就先别 compact” 的前置治理。
这样做有两个工程上的好处:
- 更便宜:history limit 或 tool-result truncation 不需要额外跑一次摘要模型
- 更稳定:compaction 是持久化摘要,天然有信息损失;越晚触发,越能保住原始细节
因此两层能力的边界可以概括成:
- Pi:负责“上下文满了之后怎么自动压缩并继续执行”
- OpenClaw:负责“在消息网关这个产品里,哪些历史应该更早被裁掉,尽量减少进入 compaction 的次数”
2.2 SessionManager:Pi 负责把短期记忆落到磁盘
Pi 的 SessionManager 是整个短期记忆的核心。OpenClaw 用它读取和写入 *.jsonl transcript,而不是自己手搓一套会话格式。
从设计上看,这有两个好处:
- 会话历史是磁盘持久化的,不是进程内存里的临时数组
- compaction 是 transcript 级别的持久操作,不是一次运行内的临时摘要
OpenClaw 在 Pi 的 SessionManager 外面又包了一层 guardSessionManager(),主要做两件事:
- 给 user message 打上 provenance,避免跨 session 注入的信息混淆来源
- 在 tool result 落盘前做持久化保护与 hook 扩展
2.3 Pi 的 extension 机制:给 OpenClaw 留了“上下文改造点”
Pi 不只是提供 prompt(),还提供了 compaction 和 context 阶段的 extension hook。OpenClaw 正是靠这个能力,外挂了两类关键能力:
- **
compaction-safeguard**:增强 compaction 摘要质量 - **
context-pruning**:对老旧 tool result 做裁剪
这点很关键。OpenClaw 没有 fork 一套 Pi,而是在 Pi 的 extension API 上做“外挂增强”。这也是它工程上比较成熟的地方。
3. OpenClaw 如何组装一次运行的上下文
真正送给模型的上下文,不是直接拿 session 历史拼起来,而是经过了一整条装配链。
如果想和源码对照,这几步大致对应:
- 第 3 步:
resolveBootstrapContextForRun() - 第 4 步:
SessionManager.open() - 第 5 步:
buildEmbeddedSystemPrompt() - 第 6 步:
sanitizeSessionHistory()/validate*Turns()/limitHistoryTurns() - 第 7 步:
session.agent.replaceMessages() - 第 8 步:
before_prompt_build/before_agent_start
3.1 稳定上下文来自 workspace bootstrap files
OpenClaw 会先读 workspace 里的固定文件,再把它们注入 system prompt 的 Project Context。源码里这条链路是:
1 | const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({ |
这里最重要的不是代码,而是设计决策:
- OpenClaw 把
AGENTS.md / SOUL.md / TOOLS.md / IDENTITY.md / USER.md / HEARTBEAT.md / BOOTSTRAP.md当成稳定上下文 - 如果存在,也会把
MEMORY.md/memory.md当成 bootstrap 文件注入 - 但
memory/*.md日志不会自动全量注入,它们主要通过 memory tools 按需读取 - subagent / cron 会切到更轻量的 prompt 模式,bootstrap 文件也会收缩到一个最小允许集合,避免把主会话上下文整包带进子任务
这就形成了一个非常清晰的边界:
- 适合常驻 prompt 的内容:规则、身份、长期稳定偏好
- 适合按需召回的内容:日记、历史记录、细节证据
3.2 OpenClaw 对 bootstrap 注入有明确的 token 预算意识
OpenClaw 并不是“把文件原样全塞进去”。它会对 bootstrap 文件做双重限制:
- 单文件上限:
bootstrapMaxChars - 总注入上限:
bootstrapTotalMaxChars
超过预算会截断,并在 prompt 里留下提示标记。这一点非常工程化,因为真实项目里 TOOLS.md、MEMORY.md 很容易无限膨胀。
3.3 历史消息不会直接照搬,而是先清洗再替换
会话历史在真正参与推理前,会经过一轮 sanitation:
1 | const prior = sanitizeSessionHistory(...) |
这里的“本轮可见工作集”,可以先用一句最短的话理解:
它就是这次运行真正准备交给模型的那份 history 工作副本。
要注意它和 transcript 不是一个东西:
- transcript:磁盘上的完整会话日志
- 本轮可见工作集:OpenClaw 从 transcript 重建、清洗、裁剪后,塞回
activeSession.messages的那份内存数组
再严格一点说:
replaceMessages(limited)改的是“这轮运行的可见历史”- 当前用户这条新消息、hook 注入的 prependContext、system prompt,则会在后面一起组成最终模型输入
所以它更像是:
1 | 最终模型输入 = systemPrompt + 本轮可见工作集 + 当前用户消息 + prompt 级附加上下文 |
这一步具体做的事情,按顺序可以拆成 4 层:
- 先把 transcript 里明显不适合继续复用的内容修一遍
- 给跨 session 注入的 user message 打 provenance 标记
- 清洗历史图片块、toolCall id、签名等 provider 敏感字段
- 对不兼容的 provider 去掉旧的 thinking / reasoning block
- 把工具调用历史修成“还能继续发给下一个模型请求”的形状
- 清理非法 toolCall 输入
- 丢掉不允许的工具名
- 修复 toolCall / toolResult 配对
- 去掉重复或孤儿 toolResult
- 必要时补 synthetic error toolResult
- 把 provider 不接受的 turn 结构修正掉
- Gemini 路径会合并连续 assistant turn
- Anthropic 路径会合并连续 user turn
- OpenAI Responses 路径还会降级旧 reasoning/function-call 结构
- 最后才做场景化裁剪
- 根据
sessionKey判断这是 DM、group 还是 channel - 只保留最近 N 个 user turn
- 截断后再跑一次 tool pairing repair,防止留下孤儿 toolResult
- 根据
如果把它翻译成更偏“人话”的效果,其实就是:
- 把坏历史修合法
- 把不同 provider 不接受的结构修兼容
- 把不值得长期占窗口的旧历史裁掉
- 得到一份这轮可以放心继续推理的 history
可以用一个极简前后对比理解:
1 | 原始 transcript 重建出来的历史: |
这一步体现了 OpenClaw 在 Pi 之上的工程增强:
- 修复历史 transcript 中的坏格式
- 针对不同 provider 做 turn 校验
- 针对 DM / group / channel 应用不同 history limit
- 用
replaceMessages()把 Pi 默认历史替换成“适合本次运行的历史”
Pi 给的是“可执行会话”,OpenClaw 在上面补了一层 “面向生产环境的上下文清洗器”。
3.4 Hook 可以在 prompt 最后一步插入上下文
在真正 session.prompt() 前,OpenClaw 还会跑 before_prompt_build / before_agent_start hook:
1 | const hookResult = await resolvePromptBuildHookResult(...) |
这意味着 OpenClaw 的上下文并不是静态的。插件可以在最后一刻:
- 给用户 prompt 前面补一段上下文
- 覆盖 system prompt
- 在不修改 Pi 的情况下插入额外记忆
这就是为什么 OpenClaw 的“记忆系统”不能只看 memory 目录,它其实和 hooks、plugins、prompt builder 是绑在一起的。
4. 短期记忆:Session 就是 Agent 的工作记忆
OpenClaw 的短期记忆核心不是数据库,而是 sessionKey -> SessionEntry -> transcript JSONL。
4.1 为什么要分成 sessions.json 和 *.jsonl 两层
这是 OpenClaw 一个很值得写进文章的工程取舍:
sessions.json存的是元数据- 当前
sessionId - 最后活动时间
- token 计数
- model override
compactionCountmemoryFlushCompactionCount
- 当前
*.jsonl存的是真正消息历史- user / assistant / toolResult
- custom entry
- compaction entry
这样做的好处是:
- 元数据读写快,适合
/status之类的运维操作 - transcript 保持 append-only,更适合会话回放和 compaction
- 小修改不需要重写整个历史文件
4.2 Session 不是“全局记忆”,而是按路由隔离的
OpenClaw 在多通道、多群聊、多线程环境里工作,所以它首先要解决的是 “记忆串线” 问题,而不是“怎么多记一点”。
换句话说,OpenClaw 的第一层工程考量是:
- Discord 一个 thread 是一个 session
- Telegram 一个 topic 是一个 session
- 群聊 / 私聊 / 频道通常各有自己的 sessionKey
这样做的直接结果是:短期记忆天然带作用域。
4.3 历史长度不是无限增长,而是按场景限制
源码里 limitHistoryTurns() 会根据 sessionKey 解析出是 DM 还是群聊,再应用不同的历史长度配置。
这也不是在“重复实现 Pi 的 compaction”。Pi 的 compaction 更像是上下文接近极限时的自动摘要;而 history limit 是 OpenClaw 在业务层提前做的场景化裁剪。它是一个很重要的产品取舍:
- DM 需要连续性,所以保留最近 N 个 user turn
- 群聊/频道更容易爆 context,所以要更 aggressively 地截断
也就是说,OpenClaw 不是对所有对话一视同仁,而是让“会话记忆策略”跟“沟通场景”绑定。
5. 窗口治理:OpenClaw 不是等爆了再压缩,而是三道保险一起上
Pi 本身已经有 auto_compaction,所以“上下文超了怎么办”并不是它没考虑的问题。OpenClaw 在此基础上又堆了三层保护,核心目标不是替代 Pi,而是:
- 尽量把明显不该长期占窗口的内容提前裁掉
- 尽量推迟 compaction 发生的时机
- 真到了必须 compaction 时,再把摘要质量和恢复质量做得更稳
OpenClaw 在源码里实际上堆了三层保护:
5.1 第一层:单个大 tool result 先别把窗口打爆
OpenClaw 有一个 tool-result-context-guard,核心思路非常务实:
- 给 tool result 单独估算字符/token 占用
- 如果某个工具输出过大,先截断或替换成 placeholder
- 防止“一次超大命令输出”直接把整轮上下文吞掉
这里最容易误解的一点是:“替换成 placeholder” 不等于“框架会自动帮你再读一次更小的片段”。
更准确地说,OpenClaw 做的是三步防护:
- 写入 transcript 前先截断
- 超大的
toolResult会先被截成“保留前缀 + 提示语”的版本再落盘 - 提示语会明确告诉模型:如果还需要细节,请改用
offset/limit或指定片段重读
- 超大的
- 真正组 prompt 时再保护上下文预算
- 如果单个 tool result 仍然过大,会先变成当前轮可见的“截断版”
- 如果整轮上下文还是太大,较老的 tool result 会进一步被替换成
[compacted: tool output removed to free context]
- 如果模型调用时已经发生 overflow
- 先尝试 Pi 的
auto-compaction - 还不够时,再把 session 里的超大 tool result 回写成更短版本,然后重试 prompt
- 先尝试 Pi 的
所以对“当前这一轮模型能看到什么”来说,你可以这样理解:
- 细节确实可能丢失
- 但通常不是“一整条结果瞬间消失”
- 而是先变成“截断版”,再极端时才退化成“占位符版”
更关键的是:OpenClaw 不会自动补发一次 read(path, offset, limit)。
“重新读取指定行段”是 agent 在下一步看到这些提示后,自己做出的工具调用决策,而不是 guard 层的自动恢复动作。
可以拆成两段看:先看进入 prompt 前的预算防护,再看进入推理后的重试与补读。
换句话说,OpenClaw 的自动动作是 “压缩后重试”,不是 “自动精确重读”。
后者要等模型意识到自己缺了关键信息,再主动发起下一次更小粒度的工具调用。
这类 guard 不是“智能记忆”,但它非常重要。因为在真实工程环境里,真正杀死上下文窗口的往往不是对话,而是:
read大文件exec大量日志web_fetch长网页
5.2 第二层:老旧工具结果做 pruning,不碰真正的对话骨架
当启用 contextPruning.mode = "cache-ttl" 时,OpenClaw 会通过 Pi extension 对旧的 tool result 做裁剪。
这一层最容易误解成“把旧消息随便删掉”。实际上它做得很克制,更像是:
只处理旧的、可再获取的工具输出;尽量不碰 user / assistant 主线。
可以把它拆成 4 个问题来看。
5.2.1 什么时候会触发
它不是每一轮都跑,而是一个挂在 Pi context 阶段上的 extension:
- 只有
mode = "cache-ttl"时才启用 - 上次 prune 之后要过一段 TTL 才会再次触发
- 只有当前上下文占比达到阈值时才真正动手
默认参数大致是:
keepLastAssistants = 3softTrimRatio = 0.3hardClearRatio = 0.5softTrim.maxChars = 4000softTrim.headChars = 1500softTrim.tailChars = 1500
也就是说,它解决的不是“单次超大输出”的问题,而是:
会话跑久了以后,老旧工具输出不断堆积,慢慢挤占上下文窗口。
5.2.2 它会动哪些消息
pruning 不会从头到尾乱扫一遍,而是先划出一段“允许处理的中间区域”:
更具体地说:
- 第一条 user message 之前不动
- 这是 bootstrap 区域,通常包含 SOUL / USER / TOOLS 等初始身份上下文
- 最近几条 assistant 附近不动
keepLastAssistants用来保护最新尾部,避免把当前任务主线裁坏
- 只优先处理
toolResult- 普通 user / assistant 消息不在这一层的优先处理范围里
- 还能按工具名做 allow / deny
- 例如允许裁
read/exec,但不裁某些特殊工具
- 例如允许裁
- 带图片的 tool result 暂时跳过
- 因为图像结果很难做安全的部分裁剪
所以你可以把这一层理解成:
先圈出“老旧、居中、可重取”的工具结果,再考虑压缩它们。
5.2.3 “软裁剪”到底是什么
“软裁剪”不是把整条工具输出删掉,而是把它压成一个保留头尾的缩略版。
如果某个旧 toolResult 很长,OpenClaw 会把它改成:
1 | 前 1500 chars |
这就是软裁剪的核心含义:
- 保留开头
- 通常能看出这是哪个文件、哪个命令、哪段日志
- 保留结尾
- 通常能看出最后报错、最终状态、收尾信息
- 中间大段内容丢掉
- 因为它最占窗口,但往往信息密度最低
它对应的伪代码大致就是:
1 | if (toolResult.tooLong()) { |
所以软裁剪不是“删除”,而是:
把旧工具结果从“完整正文”降级成“带头尾线索的摘要版缓存”。
5.2.4 什么时候会“硬清空”
如果做完软裁剪之后,整轮上下文占比还是太高,才会进入第二步:硬清空。
硬清空的做法更直接:
- 不再保留头尾
- 直接把整条旧
toolResult改成一个占位符
例如:
1 | [Old tool result content cleared] |
但它也不是无脑清:
- 只清前面已经判定为“可 pruning”的那些旧 tool result
- 只有可清理内容达到一定体量时才值得做
- 一旦上下文占比降回阈值以下,就停止继续清
所以这一层的顺序其实是:
5.2.5 一个直观例子
假设上下文里有这样一段历史:
1 | user: 帮我看一下这个项目 |
pruning 之后,结果更可能变成:
1 | user: 帮我看一下这个项目 |
从这个例子可以看出,它想保住的是:
- 当前任务主线还在
- 最近几轮关键分析还在
- 很老的大输出被压缩成“可提示、可重取”的形式
这说明 OpenClaw 在上下文治理上的优先级很明确:
- 先保住对话主线
- 再压缩附属结果
- 实在不行才 compaction
5.3 第三层:真的快压缩了,先做一次“memory flush”
这是 OpenClaw 很有意思、也很“Agentic”的设计。
当系统估计当前 session 接近 compaction 阈值时,会先偷偷跑一轮 pre-compaction memory flush:
1 | if (shouldRunMemoryFlush(...)) { |
它的作用不是回复用户,而是提醒模型:
- 如果有值得长期保存的事实
- 现在就写入
memory/YYYY-MM-DD.md - 如果没东西可写,就回复
NO_REPLY
这个设计体现了一个很强的工程判断:
压缩会丢细节,所以在压缩前,先给模型一次把“耐久信息”落盘的机会。
更重要的是,OpenClaw 没有粗暴触发它,而是做了很多 gating:
- 只在接近阈值时触发
- heartbeat / CLI run 不触发
- 沙箱 workspace 只读时不触发
- 每个 compaction 周期只跑一次
- 还会读取 transcript 的最新 usage/字节大小来补齐判断
5.4 Compaction 不只是摘要,而是“增强过的摘要”
Pi 原生会做 auto-compaction,但 OpenClaw 通过 compaction-safeguard 把摘要输入增强了。
如果只说“增强了摘要”,还是太抽象。更准确地说,OpenClaw 把 compaction 变成了一条 前中后分层处理的流水线:
其中 compaction-safeguard 真正做的事情,不是简单地把“所有旧消息”扔给一个 summarizer,而是按下面几步处理:
- 先做预算判断
根据contextWindow和maxHistoryShare估算“旧历史最多还能占多少窗口”。 - 太长就先裁老 chunk
如果待总结历史太大,会先丢掉最老的 chunk,只保留更值得留在主摘要里的那部分。 - 被丢掉的 chunk 也不是直接作废
它们会再走一轮 staged summarization,先压成一个droppedSummary,再并回主摘要输入。 - 如果切在一个 turn 中间,额外总结 turn prefix
避免摘要只看见后半截上下文,丢掉“这个 turn 一开始在干什么”。 - 把关键工程事实拼到摘要后面
包括 tool failure、读过哪些文件、改过哪些文件、AGENTS.md里的关键规则。
可以把它压成接近伪代码的形式:
1 | summary = summarize(history) |
也就是说,增强内容包括:
- 最近的对话主线
- 被切掉的更老历史的二级摘要
- split turn 的前缀信息
- tool failure 摘要
- 文件读写列表
- 从
AGENTS.md提取出的关键规则(如Session Startup、Red Lines)
换句话说,OpenClaw 不满足于“把旧消息总结一下”,而是希望 compaction summary 至少保留:
- 用户原始任务脉络
- 已经失败过什么
- 改过哪些文件
- 哪些硬规则不能忘
还有一个很务实的工程取舍:如果 safeguard 自己总结失败,它会直接取消这次 compaction,而不是硬写一条可能很差的 summary。
这就是它和很多“通用 agent SDK 自动摘要”最大的不同之一。
5.5 压缩完以后,再把关键上下文拉回一点
compaction 完成后,OpenClaw 还会再做一次补救:
- 从
AGENTS.md里抽取关键 section - 生成一条
[Post-compaction context refresh] - 作为系统事件重新塞回当前 session
它做的不是“再总结一次”,而是给下一轮推理塞一个明确提醒:
- 上面的 compaction summary 只是 hint
- 不能拿它替代完整的 Session Startup
- 需要重新读取关键规则再继续行动
可以把这一步理解成:
1 | if (autoCompactionCompleted) { |
所以 post-compaction context refresh 的本质不是“补几句说明”,而是:
在一次不可避免的信息压缩之后,把最不能忘的启动规则重新拉回上下文。
这一步的意图很明显:compaction summary 只是摘要,不应该替代完整的启动规则。
6. 长期记忆:Markdown 才是真相,索引只是加速层
如果说 Session 是“工作记忆”,那 OpenClaw 的长期记忆更像“笔记本 + 检索器”。
6.1 默认设计:Markdown 是 source of truth
OpenClaw 默认长期记忆的中心不是某个 DB,而是 workspace 中的人类可编辑文件:
MEMORY.md/memory.mdmemory/YYYY-MM-DD.md
这是一种非常刻意的工程取舍:
- 易读、易改、易备份
- 可以直接放进 git
- 不把“记忆真相”锁死在向量库里
所以 OpenClaw 的检索索引从一开始就被定义成 derived index,不是 canonical source。
如果把这两类 memory 文件写得更具体一点,它们大概像这样:
1 | # MEMORY.md |
1 | # memory/2026-03-16.md |
这两者的角色是不同的:
MEMORY.md更像“整理过的长期记忆”memory/YYYY-MM-DD.md更像“当天流水账 / 工作日志”
这也是为什么模板里会建议:
- 今天和昨天的 daily note 可以作为近期上下文
- 真正长期有效的事实,要定期沉淀进
MEMORY.md
6.2 默认 memory plugin:memory-core
OpenClaw 默认启用的 memory slot 是 memory-core。它负责注册两个工具:
1 | memory_search(query, maxResults?, minScore?) |
二者分工很清楚:
- **
memory_search**:语义检索,返回 snippet + path + 行号 - **
memory_get**:精读某个文件的某一段,避免整份文件重新灌入上下文
这也是 OpenClaw 的第二个关键取舍:
长期记忆默认不常驻,而是通过工具按需召回。
如果从实现上看,memory-core 本身其实很薄:它主要就是把这两个工具注册给 agent。
1 | api.registerTool(() => [memorySearchTool, memoryGetTool], { |
真正的分工在两个工具本身:
1 | memory_search(query, maxResults?, minScore?) |
可以把它理解成:
memory_search= 先定位memory_get= 再精读
也就是说,agent 不需要先把整个 memory/ 目录塞进 prompt,而是走一条两段式链路:
这就是“按需读取”的核心。
为什么它能按需?因为 memory_search 返回的不是整份文件,而是已经切好的 chunk 命中:
pathstartLineendLinesnippetscore
比如一次搜索结果,逻辑上可能长这样:
1 | { |
然后 agent 再决定要不要调用:
1 | memory_get("memory/2026-03-16.md", 12, 8) |
只把这 8 行带回来,而不是把整份 memory/2026-03-16.md 全部注入。
6.3 内置 memory manager:SQLite + FTS + vector 混合检索
memory-core 背后默认接的是内置 MemoryIndexManager。从源码看,它并不是简单的向量搜索,而是混合检索:
先回答几个最容易混淆的问题。
6.3.1 “按 chunk 切分”是不是按 ## 标题切?
不是。当前内置实现不是按 Markdown 标题语义切,而是按“行 + 大小预算”切。
MemoryIndexManager 里的 chunkMarkdown() 做法更朴素:
- 把文件按行拆开
- 按
tokens * 4粗略估算一个字符预算 - 逐行往当前 chunk 里累加
- 一旦超预算,就 flush 成一个 chunk
- 如果配置了 overlap,还会把尾部一小段带到下一个 chunk
- 如果某一行本身就太长,还会把这一行继续切成多个 segment
所以它更接近:
1 | for (line of lines) { |
这意味着:
- 一个
##小标题有时会自然落在 chunk 边界附近 - 但系统并没有先解析 Markdown AST,再按标题树切块
一个直观例子:
1 | # 偏好 |
内置 chunker 不会直接说“# 偏好 一块、## 项目 A 一块”,而更像是:
1 | chunk 1: 第 1-18 行 |
所以返回结果里最重要的定位信息其实是:
pathstartLineendLine
而不是“命中某个 heading 节点”。
6.3.2 “写进内置索引”到底是写了什么
这里的“内置索引”本质上就是一份 SQLite 索引库,不是一个抽象概念。
同一个 chunk 会被写进几类结构里:
files- 记录文件级元数据:
path / hash / mtime / size
- 记录文件级元数据:
chunks- 记录 chunk 正文和定位信息:
path / start_line / end_line / text / embedding
- 记录 chunk 正文和定位信息:
chunks_fts- SQLite FTS5 虚拟表,给关键词搜索用
chunks_vec- 可选的向量表,给向量相似度搜索用
embedding_cache- 用
hash -> embedding做缓存,避免文件没变时重复 embedding
- 用
所以“写入索引”不是只写一份文本,而是把同一份 memory chunk 拆成“元数据 + 全文索引 + 向量索引 + 缓存”几层。
可以把一次同步理解成这样:
这一步还有两个工程取舍很关键:
- 文件变了才重建对应索引
- watcher 只会把 manager 标记成 dirty,然后在
onSearch/onSessionStart/ interval sync 时同步
- watcher 只会把 manager 标记成 dirty,然后在
- embedding 会尽量复用缓存
- 只要 chunk 的 hash 没变,就不需要再调用 embedding provider
6.3.3 关键词搜索是正则吗?
这部分最容易被误解成“是不是拿正则扫一遍全文”。不是。
这里的 FTS 是 Full-Text Search,全文检索。
它的思路更像搜索引擎,而不是正则匹配:
- 建索引时,先把每个 chunk 里的词拆出来
- 建一张“哪个词出现在哪些 chunk” 的倒排索引
- 查询时直接查这张索引,而不是每次把所有 Markdown 全文重新扫一遍
你可以把它想象成这样一张简化表:
1 | "transcript" -> chunk_12, chunk_18 |
当用户搜索:
1 | transcript reset archive |
SQLite FTS5 更像是在倒排索引里找:
1 | 哪些 chunk 同时命中了这些词? |
而不是像正则那样去问:
1 | 有没有一段文本满足 /transcript.*reset.*archive/ 这种字符模式? |
所以这里的关键词搜索:
- 不是 regex
- 不是简单
includes() - 也不是 LLM 在理解语义
- 而是标准的 FTS5 + BM25 全文检索
6.3.3.1 FTS-only 到底是什么
FTS-only 的意思非常直接:
只有全文检索,没有 embedding,也没有向量相似度搜索。
也就是说:
- 不会去生成 query embedding
- 不会查
chunks_vec - 只查
chunks_fts
这种模式通常发生在:
- 没有可用的 embedding provider
- 或者你明确只想用关键词检索
它的好处是:
- 完全不依赖 embedding API
- 没有额外 embedding 费用
- 对 ID、变量名、文件名、错误码这类精确词很有效
它的弱点也很明显:
- 对“换了说法但意思相近”的问题不如向量检索
- 更依赖关键词是否刚好命中
这里有一个很重要、也很容易被忽略的现实:
在 OpenClaw 当前默认的 FTS5 配置下,中文并不等于“自动细粒度分词”。
换句话说,它未必会把一句中文拆成你直觉里的:
1 | 科技 |
例如,长期记忆里有这样一句话:
1 | 用户喜欢科技、编程与量化交易,也对加密货币和AI工具感兴趣。平时关注效率工具、新技术趋势,偶尔研究投资策略。 |
按当前默认 FTS5 的实际行为,它更可能被索引成这些短语块:
1 | "用户喜欢科技" -> chunk_42 |
这个例子说明了两件事:
- FTS 在这里更像“短语命中”
- 搜
新技术趋势,比较可能直接命中 - 搜
偶尔研究投资策略,也比较可能命中
- 搜
- 单独搜很短的中文词,不一定稳
- 例如只搜
工具、科技、AI,按默认 FTS 行为未必单独命中
- 例如只搜
所以 FTS-only 更适合:
- 文件名
- 错误码
- 英文标识符
- 比较完整的短语
而不太适合指望它天然拥有“很强的中文语义分词能力”。
这也是为什么 OpenClaw 在有条件时会优先加上 vector/hybrid 检索,来补足 FTS 对中文自然语言召回的短板。
顺着这个结论,也能反推出一个很实用的写 memory 的建议:
1 | - 兴趣:科技、编程、量化交易、加密货币、AI 工具 |
这种结构化写法,通常比一整句自然语言更容易被 FTS-only 稳定命中。
6.3.3.2 它实际怎么搜索
这里又要分两种情况。
情况 A:hybrid 模式里的关键词分支
这时系统会把原始 query token 化,再构造成 FTS query:
1 | transcript reset archive flow |
会变成更接近:
1 | "transcript" AND "reset" AND "archive" AND "flow" |
当然,真正代码里只会保留符合 token 规则的词。核心点是:
默认是 token 级匹配,不是正则表达式匹配。
然后 SQLite 用:
MATCH找命中的 chunkbm25(...)给每个结果排序
BM25 可以粗略理解成:
- 命中的关键词越关键,分越高
- 在少量 chunk 里出现的稀有词,权重更高
- 更短、更聚焦的 chunk 往往更占优
- 更像“搜索排序”,不是“语义理解”
情况 B:真正的 FTS-only 模式
这时 OpenClaw 还会多做一步“口语 query -> 关键词”的提炼。
比如用户问:
1 | 之前讨论的那个 session reset 归档方案 |
系统不会直接把整句原样拿去搜,而是会先尽量抽出更像关键词的词,例如:
1 | ["session", "reset", "归档", "方案"] |
然后对这些词分别搜索,再把结果合并去重。
这里要特别注意:
FTS-only不是把这些词重新拼成一个巨大的AND查询,而是“每个词各搜一次,再合并结果”。
这样做的原因也很务实:
- 用户口语问题里常常带很多无效词
- 如果全都强行
AND在一起,容易一个结果都搜不到 - 分开搜再合并,更适合“之前那个方案”“昨天提过的那个问题”这类自然语言问法
流程更像这样:
所以这部分最短可以记成:
- FTS = 全文检索
- FTS-only = 只做全文检索,不做向量检索
- FTS 不是 regex
- OpenClaw 在 FTS-only 下会先把自然语言问题压成更像关键词的查询
6.3.4 向量检索是怎么做的?是不是 RAG 那套?
是广义上的 RAG retrieval 思路,但不是“让大模型现场理解全文再检索”。
它的流程是经典 embedding 检索:
- 索引阶段
- 对每个 memory chunk 生成 embedding
- 把向量存进
chunks_vec,也把 JSON 形式留在chunks
- 查询阶段
- 对用户 query 再生成一个 query embedding
- 用余弦相似度找最接近的 chunk
- 结果阶段
- 返回 chunk 的
snippet + path + line range + score
- 返回 chunk 的
这里“向量检索”用到的模型是 embedding model/provider,不是聊天大模型直接下场做排序。
源码里支持的 provider 包括:
openaigeminivoyagemistrallocal(node-llama-cpp本地 embedding)
如果 sqlite-vec 扩展可用,会直接在 SQLite 里做:
1 | ORDER BY vec_distance_cosine(v.embedding, queryVec) |
如果向量扩展不可用,代码还会退回到:
- 把 chunks 表里的 embedding 读出来
- 在 JS 里做
cosineSimilarity()
所以它是很典型的“embedding + cosine similarity”检索,不是“调用一个大模型把所有 memory 读一遍后帮你判断哪段相关”。
6.3.5 最后怎么把 FTS 和 vector 合在一起
如果 embedding provider 可用,默认不是“只看向量”或“只看关键词”,而是 hybrid search:
- 先做一遍关键词召回
- 再做一遍向量召回
- 按 chunk id 合并去重
- 用权重做混合打分
- 再做可选的 MMR 和 temporal decay
所以它的排序逻辑更像:
1 | finalScore = |
然后再额外考虑:
- MMR
- 避免返回一堆高度重复的 chunk
- temporal decay
- 让更近的 memory 适度加权
所以 memory_search() 返回的不是“最像的一篇文件”,而是:
几个综合相关度最高、又尽量不重复的 chunk 命中。
6.3.6 这和“经典 RAG”有什么异同
相同点:
- 都会先切 chunk
- 都会建 embedding
- 都会在 query 时做相似度召回
不同点:
- OpenClaw 这里是 SQLite 本地索引优先
- 不是默认把长期记忆交给外部向量库
- 它是 FTS + vector 混合
- 不是纯向量召回
- 返回结果后,agent 还要再走
memory_get- 不是直接把整份召回文档塞进 prompt
- 没有“生成式重写检索查询”的重型链路
- 更偏工程化、低延迟、可回退
所以把它理解成一句话最合适:
OpenClaw 的内置 memory manager,是一个偏工程实用主义的本地 hybrid RAG 检索器。
从工程角度看,它还有几个值得点出来的特征:
- 默认 source 是 memory 文件,session transcript 可以作为可选 source
- 支持 FTS 和 vector 混合打分
- 有 watcher,memory 文件变化后会标记 dirty
- 支持
onSessionStart、onSearch、interval 异步 sync - snippet 有长度上限,不会把整篇文档扔回上下文
这其实说明 OpenClaw 的长期记忆目标不是“最强召回率”,而是 “在 token 预算内稳定可用”。
“为什么默认不自动全量注入,而是按需读取”,根本原因也在这里:
memory/*.md天然会无限增长
每天一份日志,几个月后就可能非常大,不适合常驻 prompt。- 大部分历史在大多数回合里都用不上
真正相关的通常只是几个 chunk。 - 按需读取更省 token
搜索先返回小 snippet,只有命中后才精读。 - 按需读取更安全
不会因为默认注入,把无关但敏感的旧日志整包带进一次群聊或普通问答。
所以这条链路的本质不是“先搜一下更高级”,而是一个非常务实的窗口治理设计:
长期记忆可以很多,但每一轮真正带进上下文的,只应该是当前问题需要的那几段。
6.4 QMD 后端:外接更强检索,但保留 fallback
如果配置 memory.backend = "qmd",OpenClaw 会用 QmdMemoryManager 做检索;但它不会把系统绑死在 QMD 上,而是包了一层 fallback:
- QMD 正常时,走 QMD
- QMD 挂了,自动退回内置 SQLite manager
这个设计特别像 OpenClaw 整体的风格:新能力可以接,但基础路径必须稳定。
6.5 memory-lancedb:另一种插件式长期记忆实现
仓库里还有一个 memory-lancedb 插件,它展示了另一种思路:
- 用 LanceDB 存向量
before_agent_start自动注入相关记忆agent_end自动从用户消息里抽取可捕获内容
它的链路更接近经典“自动 recall / 自动 capture”风格:
这个插件不是默认主路径,但很适合放在文章里作为对比:
- 内置 memory-core 更偏“Markdown + 索引 + 工具调用”
- memory-lancedb 更偏“自动记忆中间件”
6.6 session-memory:会话结束/切新对话时,把旧上下文沉淀成笔记
除了 memory plugin,OpenClaw 还有一个很容易被忽略的内置 hook:session-memory。
它会在 /new 或 /reset 触发时:
- 找到刚刚结束的旧 transcript
- 读取最近若干条 user / assistant 消息
- 可选地用 LLM 生成一个 slug
- 写入
memory/YYYY-MM-DD-<slug>.md
它的价值不在于“自动摘要有多聪明”,而在于补上了一个很实用的缺口:
当用户主动切新 session 时,OpenClaw 会把上一段对话留下一份可回看的 Markdown 痕迹,而不是让它只存在于 transcript 里。
这和前面提到的 pre-compaction memory flush 形成了互补:
- memory flush 解决“快压缩了,先把耐久信息落盘”
- session-memory hook 解决“我要开新会话了,把旧上下文整理存档”
7. OpenClaw 做的几个关键工程考量
如果把源码里的实现抽象出来,我觉得 OpenClaw 关于“上下文记忆”最重要的工程判断有这几条。
7.1 不是所有记忆都应该进 prompt
OpenClaw 明确把信息分成三类:
- 必须常驻:
AGENTS.md、SOUL.md、USER.md这类稳定规则 - 当前回合需要:session transcript、最近工具结果
- 大概率用不上:历史日志、旧记忆、细碎证据
第三类改成 tool retrieval,是整个系统能长期稳定运行的前提。
7.2 长期记忆的真相必须可审计、可手改
这是 Markdown source-of-truth 路线的根本原因。
如果长期记忆只有向量库,系统虽然“智能”,但人很难检查:
- 为什么模型记住了这件事?
- 这条记忆从哪来的?
- 它现在还是不是真实?
OpenClaw 选择把长期记忆落到 Markdown,再围绕它建立索引,实际上是把“可维护性”放在了“炫技”前面。
7.3 上下文治理优先保护对话主线,不是平均裁剪
从 history limit、tool-result-context-guard、context-pruning 到 compaction-safeguard,它的策略都非常一致:
- 先保 user / assistant 主线
- 再压 tool result
- 最后才 compaction
这比“按 token 平均截断历史”要稳得多。
7.4 压缩不是终点,压缩前后都要补动作
OpenClaw 没把 compaction 看成一个黑盒摘要动作,而是加了两段前后处理:
- 压缩前:memory flush,把可持久化信息写到 memory 文件
- 压缩后:post-compaction context refresh,把关键规则补回 session
这个思路本质上是在承认一件事:
摘要一定会丢信息,所以要围着 compaction 设计一个完整生命周期。
7.5 让 Pi 负责“引擎”,让 OpenClaw 负责“策略”
最后这一点最像系统设计上的主心骨。
Pi 负责:
- session
- transcript persistence
- tool loop
- auto-compaction
OpenClaw 负责:
- 什么文件注入
- 什么历史保留
- 什么结果裁剪
- 什么时候 flush memory
- 什么时候走长期记忆检索
这种分层很重要,因为它让 OpenClaw 可以持续演进自己的上下文策略,而不需要自己重写一套 Agent runtime。
8. 小结
OpenClaw 的“上下文记忆”本质上不是一个 memory 模块,而是一整条上下文生命周期:
- 用 workspace 文件定义稳定身份与规则
- 用 Pi SessionManager 持久化短期会话
- 用 guard / pruning / compaction 控制窗口
- 用 memory tools 和 memory plugin 做长期召回
所以值得学习的地方,不是“它有记忆”,而是它把 什么该常驻、什么该压缩、什么该落盘、什么该按需召回 这几件事拆得很清楚。
- 感谢你的欣赏!



