OpenClaw 设计解析(六):上下文记忆

本篇聚焦 OpenClaw 的“上下文记忆”设计:Pi 框架提供什么底座、OpenClaw 在其上做了哪些工程化增强,以及长期记忆插件如何与会话上下文配合。

很多项目一提“记忆”,第一反应是向量数据库或者 RAG。但从 OpenClaw 的源码看,它的“记忆”并不是一个单点模块,而是一个分层系统

  • 运行前注入什么:workspace 里的稳定文件、system prompt、skills、tools
  • 运行中保留什么:当前 session 的消息历史、工具结果、媒体与系统事件
  • 快溢出时怎么缩:history limit、tool result guard、context pruning、auto-compaction
  • 真正长期保存什么MEMORY.mdmemory/*.md、以及围绕它们建立的检索索引

所以更准确地说,OpenClaw 设计的是一套 “上下文构建 + 会话持久化 + 压缩回收 + 长期召回” 的记忆体系。


1. 先看全貌:OpenClaw 的记忆不是一层,而是四层

flowchart TD U["用户输入<br/>定时任务 / 心跳"] --> R["定位会话<br/>sessionKey / sessionId / workspace"] R --> A["层 1: 稳定上下文<br/>AGENTS / SOUL / TOOLS<br/>USER / MEMORY"] R --> B["层 2: 短期工作记忆<br/>Pi SessionManager<br/>JSONL 会话历史"] R --> C["层 3: 窗口治理<br/>history limit / tool guard<br/>pruning / compaction"] R --> D["层 4: 长期记忆召回<br/>memory_search / memory_get<br/>SQLite / QMD / LanceDB"] A --> P["最终上下文<br/>prompt / context"] B --> P C --> P D --> P P --> LLM["LLM<br/>+ 工具调用循环"] LLM --> W["写回 transcript<br/>memory 文件 / 索引"] classDef overview font-size:15px,font-weight:bold; class U,R,A,B,C,D,P,LLM,W overview;

这四层各自解决的是不同问题:

  • 稳定上下文:让 agent 每次启动都知道“自己是谁、有哪些规则、这个 workspace 是什么”
  • 短期工作记忆:让同一个 session 能连续对话、连续做任务
  • 窗口治理:在有限 context window 下尽量保持最近且最重要的信息
  • 长期记忆召回:把不该常驻 prompt 的信息,改成需要时再检索

这也是 OpenClaw 很典型的工程思路:不是把所有记忆都塞进上下文,而是把“常驻”和“按需”拆开。


2. Pi 提供的底座:Session、持久化、自动压缩

OpenClaw 的 Agent 运行时建立在 Pi 上,跟“上下文记忆”最相关的是三件事:

graph LR subgraph Pi["Pi 框架底座"] P1["createAgentSession()<br/>驱动 LLM / tool loop"] P2["SessionManager<br/>JSONL 会话持久化"] P3["auto_compaction + extension hooks<br/>自动压缩 / 上下文扩展点"] end subgraph OC["OpenClaw 在上层做的事"] O1["workspace 文件注入"] O2["history 清洗与裁剪"] O3["compaction safeguard / pruning"] O4["memory tools / memory plugins"] end P1 --> O1 P2 --> O2 P3 --> O3 P1 --> O4

2.1 createAgentSession():Pi 负责“执行循环”

OpenClaw 并不自己实现一套 LLM 循环,而是把模型、工具、session manager 交给 Pi:

1
2
3
4
5
6
7
8
9
10
11
12
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), ...)

const { session } = await createAgentSession({
model: params.model,
tools: builtInTools,
customTools: allCustomTools,
sessionManager,
settingsManager,
resourceLoader,
})

await session.prompt(effectivePrompt)

Pi 做的事情是:

  • 管理消息序列
  • 执行 tool call 循环
  • 把消息历史写入 transcript
  • 在上下文溢出时触发 auto_compaction

也就是说,Pi 提供“会话引擎”,OpenClaw 提供“上下文策略”。

如果把一次 session.prompt() 展开,从 OpenClaw 侧能观察到的 Pi 运行时大致是这样:

sequenceDiagram participant U as OpenClaw participant S as AgentSession participant M as Model participant T as Tool participant SM as SessionManager U->>S: session.prompt("用户消息") S->>SM: 追加 user message S->>M: 发送 system prompt + history + 当前 user turn M-->>S: assistant 文本 / toolCall alt 普通回答 S->>SM: 追加 assistant message S-->>U: message_start / update / end else 需要调用工具 S-->>U: tool_execution_start S->>T: execute(toolName, args) T-->>S: tool result S->>SM: 追加 toolResult message S-->>U: tool_execution_end S->>M: 带着 toolResult 继续下一轮 end opt 上下文过长 S-->>U: auto_compaction_start S->>SM: 写入 compaction entry S-->>U: auto_compaction_end S->>M: 用压缩后的历史重试 end

2.1.1 消息序列是怎么管理的

从 OpenClaw 的接线方式看,Pi 在 session 内部维护一份当前消息序列,OpenClaw 可以:

  • 在运行前用 session.agent.replaceMessages() 替换它
  • 在运行中通过事件流看到 assistant / tool / compaction 的变化
  • 在运行结束后直接读取 session.messages

也就是说,Pi 的 session 里既有一份“当前可见上下文数组”,又有一份落盘 transcript。OpenClaw 在真正 prompt() 前,会先做:

1
2
3
const limited = limitHistoryTurns(validated, ...)
activeSession.agent.replaceMessages(limited)
await activeSession.prompt(effectivePrompt)

所以“消息序列怎么管理”的答案不是简单的“append 到数组”,而是:

  1. Pi 维护当前会话消息数组
  2. OpenClaw 在 prompt 前可以重写这份数组
  3. Pi 在执行中继续向这份序列追加 assistant / toolResult / compaction 相关消息
  4. SessionManager 再把它们持久化到 transcript

2.1.2 tool call 循环是怎么跑起来的

subscribeEmbeddedPiSession() 订阅的事件类型可以反推出 Pi 的循环形态:

  • message_start / message_update / message_end
  • tool_execution_start / tool_execution_end
  • auto_compaction_start / auto_compaction_end
  • agent_start / agent_end

这说明 Pi 的执行模型不是“模型只回答一次”,而是一个标准的 agent loop:

1
2
3
4
5
6
7
8
9
10
11
while (true) {
response = askModel(messages, tools)

if (response.includesToolCall) {
result = runTool(response.toolName, response.args)
messages.push(toolResult(result))
continue
}

break
}

OpenClaw 在这个循环里主要做两件事:

  • 提供工具实现与策略过滤
  • 订阅运行时事件,把 Pi 的内部状态翻译成“用户可见回复 / typing / tool summary / compaction 通知”

所以 createAgentSession() 的价值,不只是“创建一个 session”,而是把 LLM 对话、tool dispatch、消息持久化、自动重试 几件事绑成了一个完整状态机。

2.1.3 transcript 到底是什么

这里的 transcript 不是“聊天记录字符串”,而是 Pi SessionManager 管理的 JSONL 会话文件

OpenClaw 的参考文档里把它描述得很清楚:第一行是 session header,后面每一行是一个 entry。常见 entry 类型有:

  • message
  • custom_message
  • custom
  • compaction
  • branch_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
2
3
4
5
6
7
8
9
10
{
"agent:main:telegram:dm:123456": {
"sessionId": "sess_abc123",
"sessionFile": "~/.openclaw/agents/main/sessions/sess_abc123.jsonl",
"updatedAt": 1773653400000,
"chatType": "direct",
"displayName": "Telegram DM 123456",
"compactionCount": 1
}
}

这个索引条目的意思不是“消息内容存在这里”,而是:

  • 路由键 agent:main:telegram:dm:123456
  • 当前指向的会话文件是 sess_abc123.jsonl
  • 当下一条来自这个 DM 的消息到达时,OpenClaw 会继续往这份 transcript 上追加

如果这个 session 之后发生了 /new、daily reset,或者 thread/topic fork,变化的通常不是旧 transcript 被改名重写,而是 sessions.json 里的指针改了

1
2
3
4
5
6
7
{
"agent:main:telegram:dm:123456": {
"sessionId": "sess_def456",
"sessionFile": "~/.openclaw/agents/main/sessions/sess_def456.jsonl",
"updatedAt": 1773660000000
}
}

这时:

  • sess_abc123.jsonl 仍然是旧历史
  • sess_def456.jsonl 成为这个 sessionKey 当前继续写入的新 transcript

如果把一个真实 transcript 简化一下,大致会长这样:

1
2
3
4
5
6
7
{"type":"session","version":7,"id":"sess_abc123","timestamp":"2026-03-16T09:00:00.000Z","cwd":"/workspace/project"}
{"type":"message","id":"m1","parentId":null,"timestamp":"2026-03-16T09:00:03.000Z","message":{"role":"user","content":"帮我排查一下为什么服务 500 了"}}
{"type":"message","id":"m2","parentId":"m1","timestamp":"2026-03-16T09:00:05.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"logs/server.log"}}]}}
{"type":"message","id":"m3","parentId":"m2","timestamp":"2026-03-16T09:00:05.200Z","message":{"role":"toolResult","toolCallId":"call_1","toolName":"read","content":[{"type":"text","text":"...日志内容..."}]}}
{"type":"message","id":"m4","parentId":"m3","timestamp":"2026-03-16T09:00:07.000Z","message":{"role":"assistant","content":"看起来是数据库连接超时。"}}
{"type":"compaction","id":"comp_1","timestamp":"2026-03-16T09:30:00.000Z","summary":"用户在排查服务 500,已确认数据库连接超时,最近改动集中在 API 层。","firstKeptEntryId":"m20","tokensBefore":182340}
{"type":"message","id":"m21","parentId":"m20","timestamp":"2026-03-16T09:31:10.000Z","message":{"role":"user","content":"继续,把连接池配置也检查一下"}}

这个例子里最关键的点有 4 个:

  1. 它不是一个 JSON 数组,而是 JSONL
    每一行都是一个独立 JSON 对象,追加和修复都更方便。
  2. 真正的 user / assistant / toolResult 在 message 字段里
    外层的 type / id / parentId / timestamp 更像日志 envelope。
  3. id + parentId 让 transcript 不是纯线性链表,而是可分支的树
    这也是为什么 Pi 能做 branch / reset / compaction。
  4. compaction 不是一条普通消息
    它是一条专门的持久化摘要 entry,后续重建上下文时会参与“历史折叠”。

为了可读性,上面的示例省略了不少实际字段,比如 apiprovidermodelusage 等。但只要记住这个骨架就够了:

1
2
3
4
5
6
7
session header
-> message(user)
-> message(assistant/toolCall)
-> message(toolResult)
-> message(assistant)
-> compaction?
-> more messages...

所以 transcript 本质上不是“给人看的聊天记录”,而是 Pi 用来重建 session 上下文的一份 append-only 运行日志

如果把索引和真正 transcript 的关系压成一句话,就是:

1
2
3
4
5
sessionKey
-> sessions.json[sessionKey]
-> sessionId / sessionFile
-> 对应的 <sessionId>.jsonl
-> Pi 用它重建当前 session context

这也是为什么 OpenClaw 会把 sessions.json*.jsonl 分开:

  • sessions.json 适合快速查“当前该接哪份历史”
  • *.jsonl 适合 append-only 地存“这份历史本身长什么样”

这里还有一个很容易误解的点:/new/reset 之后,旧 transcript 不会再被当前 sessionKey 自动接上继续跑,但这不等于它立刻从磁盘上消失了。

更准确地说,reset 发生时会有两步:

  1. sessions.json 把当前 sessionKey 改指向新的 sessionId/sessionFile
  2. 旧 transcript 被归档成 *.jsonl.reset.<timestamp>

可以把它理解成:

flowchart LR K["sessionKey<br/>agent:main:telegram:dm:123456"] --> S1["sessions.json<br/>sessionId=sess_abc123"] S1 --> T1["sess_abc123.jsonl<br/>旧 transcript"] R["/new or /reset"] --> S2["sessions.json<br/>sessionId=sess_def456"] R --> A["sess_abc123.jsonl.reset.2026-03-16T09-40-00.000Z<br/>归档 transcript"] S2 --> T2["sess_def456.jsonl<br/>新 transcript"]

例如,reset 前索引可能是:

1
2
3
4
5
6
{
"agent:main:telegram:dm:123456": {
"sessionId": "sess_abc123",
"sessionFile": "~/.openclaw/agents/main/sessions/sess_abc123.jsonl"
}
}

reset 后会变成:

1
2
3
4
5
6
{
"agent:main:telegram:dm:123456": {
"sessionId": "sess_def456",
"sessionFile": "~/.openclaw/agents/main/sessions/sess_def456.jsonl"
}
}

与此同时,旧文件通常不会直接删除,而是变成:

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 的结果不是“临时生成一段摘要然后继续聊”,而是:

  1. 把老历史总结成一个 持久化的 compaction entry
  2. 记录 compaction point,例如 firstKeptEntryId
  3. 后续会话不再重放被压缩掉的老消息,而是重放:
    • compaction summary
    • firstKeptEntryId 之后的较新消息
  4. Pi 再基于压缩后的上下文重试当前请求

也就是说,compaction 改变的不是“一次请求的 prompt”,而是这个 session 之后的历史形态

如果只是“临时生成一段摘要继续聊”,那它应该只影响当前这一次模型请求

  • 这次请求前,临时拼一段 summary
  • 请求结束后,磁盘 transcript 不变
  • 下一轮 buildSessionContext() 还是会把原来的老消息整段重放回来

Pi 的 auto-compaction 不是这种临时 patch。它更像是:

  • 在 transcript 里追加一条 compaction entry
  • 记住 cut point,例如 firstKeptEntryId = m80
  • 以后重建上下文时,不再原样重放 m1...m79
  • 而是重放“m1...m79 的摘要 + m80 之后的新消息”

可以把“压缩前 / 压缩后,未来 turn 看见什么”理解成:

1
2
3
4
5
压缩前:
m1 + m2 + ... + m79 + m80 + ... + m100

压缩后,未来 turn 可见:
compactionSummary(m1...m79) + m80 + ... + m100

这里特别要注意两层对象:

  • 磁盘 transcript 仍然是 append-only 日志
  • 未来 prompt 的可见上下文 已经改成“summary + kept suffix”

也就是说,compaction 的持久性体现在:未来怎么重建上下文变了,而不只是“这一次 prompt 前多塞了一段字”。

flowchart LR A["旧 transcript<br/>m1 ... m79 m80 ... m100"] --> B["Pi auto-compaction"] B --> C["追加 compaction entry<br/>summary + firstKeptEntryId=m80"] C --> D["未来 buildSessionContext()"] D --> E["送给模型的上下文<br/>summary(m1...m79) + m80...m100"]

这也是 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” 的前置治理。

这样做有两个工程上的好处:

  1. 更便宜:history limit 或 tool-result truncation 不需要额外跑一次摘要模型
  2. 更稳定: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 历史拼起来,而是经过了一整条装配链。

flowchart TD M["1. 收到用户消息"] --> S["2. 定位会话<br/>sessionKey / sessionId / workspace"] S --> B S --> H subgraph Stable["稳定上下文"] direction TB B["3. 读取 workspace 上下文<br/>规则 / 身份 / 偏好 / bootstrap files"] P["5. 生成 system prompt"] B --> P end subgraph History["短期历史"] direction TB H["4. 读取 transcript 历史"] C["6. 清洗成本轮可见工作集<br/>修格式 / provider 校验 / 限制长度"] X["7. 替换 session 当前 messages"] H --> C --> X end P --> K["8. hooks 可再补上下文<br/>prependContext / 覆盖 system prompt"] X --> K K --> F["9. 交给 Pi 开始<br/>prompt / tool loop"] classDef assembly font-size:15px,font-weight:bold; class M,S,B,H,P,C,X,K,F assembly;

如果想和源码对照,这几步大致对应:

  • 第 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
2
3
4
5
6
7
8
9
10
11
12
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
workspaceDir: effectiveWorkspace,
sessionKey: params.sessionKey,
config: params.config,
})

const systemPrompt = buildEmbeddedSystemPrompt({
contextFiles,
skillsPrompt,
tools,
runtimeInfo,
})

这里最重要的不是代码,而是设计决策:

  • 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.mdMEMORY.md 很容易无限膨胀。

3.3 历史消息不会直接照搬,而是先清洗再替换

会话历史在真正参与推理前,会经过一轮 sanitation:

1
2
3
4
const prior = sanitizeSessionHistory(...)
const validated = validateGeminiTurns(validateAnthropicTurns(prior))
const truncated = limitHistoryTurns(validated, getHistoryLimitFromSessionKey(...))
activeSession.agent.replaceMessages(truncated)

这里的“本轮可见工作集”,可以先用一句最短的话理解:

它就是这次运行真正准备交给模型的那份 history 工作副本。

要注意它和 transcript 不是一个东西:

  • transcript:磁盘上的完整会话日志
  • 本轮可见工作集:OpenClaw 从 transcript 重建、清洗、裁剪后,塞回 activeSession.messages 的那份内存数组

再严格一点说:

  • replaceMessages(limited) 改的是“这轮运行的可见历史”
  • 当前用户这条新消息、hook 注入的 prependContext、system prompt,则会在后面一起组成最终模型输入

所以它更像是:

1
最终模型输入 = systemPrompt + 本轮可见工作集 + 当前用户消息 + prompt 级附加上下文

这一步具体做的事情,按顺序可以拆成 4 层:

  1. 先把 transcript 里明显不适合继续复用的内容修一遍
    • 给跨 session 注入的 user message 打 provenance 标记
    • 清洗历史图片块、toolCall id、签名等 provider 敏感字段
    • 对不兼容的 provider 去掉旧的 thinking / reasoning block
  2. 把工具调用历史修成“还能继续发给下一个模型请求”的形状
    • 清理非法 toolCall 输入
    • 丢掉不允许的工具名
    • 修复 toolCall / toolResult 配对
    • 去掉重复或孤儿 toolResult
    • 必要时补 synthetic error toolResult
  3. 把 provider 不接受的 turn 结构修正掉
    • Gemini 路径会合并连续 assistant turn
    • Anthropic 路径会合并连续 user turn
    • OpenAI Responses 路径还会降级旧 reasoning/function-call 结构
  4. 最后才做场景化裁剪
    • 根据 sessionKey 判断这是 DM、group 还是 channel
    • 只保留最近 N 个 user turn
    • 截断后再跑一次 tool pairing repair,防止留下孤儿 toolResult

如果把它翻译成更偏“人话”的效果,其实就是:

  • 把坏历史修合法
  • 把不同 provider 不接受的结构修兼容
  • 把不值得长期占窗口的旧历史裁掉
  • 得到一份这轮可以放心继续推理的 history

可以用一个极简前后对比理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
原始 transcript 重建出来的历史:
user
assistant(toolCall call_1)
toolResult(call_1)
assistant
user
toolResult(call_older) // 截断后留下的孤儿

清洗后的本轮可见工作集:
user
assistant(toolCall call_1)
toolResult(call_1)
assistant
user

这一步体现了 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
2
3
4
5
6
7
8
9
const hookResult = await resolvePromptBuildHookResult(...)

if (hookResult?.prependContext) {
effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`
}

if (hookResult?.systemPrompt) {
applySystemPromptOverrideToSession(activeSession, hookResult.systemPrompt)
}

这意味着 OpenClaw 的上下文并不是静态的。插件可以在最后一刻:

  • 给用户 prompt 前面补一段上下文
  • 覆盖 system prompt
  • 在不修改 Pi 的情况下插入额外记忆

这就是为什么 OpenClaw 的“记忆系统”不能只看 memory 目录,它其实和 hooks、plugins、prompt builder 是绑在一起的。


4. 短期记忆:Session 就是 Agent 的工作记忆

OpenClaw 的短期记忆核心不是数据库,而是 sessionKey -> SessionEntry -> transcript JSONL

flowchart LR K["sessionKey<br/>对话隔离粒度"] --> E["sessions.json 中的 SessionEntry<br/>当前 sessionId / tokens / compactionCount"] E --> T["<sessionId>.jsonl<br/>真实会话历史"] T --> L["Pi SessionManager.buildSessionContext()"] L --> C["本次运行看到的上下文"]

4.1 为什么要分成 sessions.json*.jsonl 两层

这是 OpenClaw 一个很值得写进文章的工程取舍:

  • sessions.json 存的是元数据
    • 当前 sessionId
    • 最后活动时间
    • token 计数
    • model override
    • compactionCount
    • memoryFlushCompactionCount
  • *.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 在源码里实际上堆了三层保护:

flowchart LR A["工具输出<br/>历史增长"] --> B["第一道<br/>history limit<br/>+ tool result guard"] B --> C["第二道<br/>context pruning<br/>软裁剪 / 硬清空旧 tool result"] C --> D{"仍会溢出?"} D -->|是| E["第三道<br/>auto-compaction"] E --> F["compaction safeguard<br/>增强摘要质量"] E --> G["post-compaction<br/>context refresh"] classDef readable font-size:14px,font-weight:bold; class A,B,C,D,E,F,G readable;

5.1 第一层:单个大 tool result 先别把窗口打爆

OpenClaw 有一个 tool-result-context-guard,核心思路非常务实:

  • 给 tool result 单独估算字符/token 占用
  • 如果某个工具输出过大,先截断或替换成 placeholder
  • 防止“一次超大命令输出”直接把整轮上下文吞掉

这里最容易误解的一点是:“替换成 placeholder” 不等于“框架会自动帮你再读一次更小的片段”。

更准确地说,OpenClaw 做的是三步防护:

  1. 写入 transcript 前先截断
    • 超大的 toolResult 会先被截成“保留前缀 + 提示语”的版本再落盘
    • 提示语会明确告诉模型:如果还需要细节,请改用 offset/limit 或指定片段重读
  2. 真正组 prompt 时再保护上下文预算
    • 如果单个 tool result 仍然过大,会先变成当前轮可见的“截断版”
    • 如果整轮上下文还是太大,较老的 tool result 会进一步被替换成
      [compacted: tool output removed to free context]
  3. 如果模型调用时已经发生 overflow
    • 先尝试 Pi 的 auto-compaction
    • 还不够时,再把 session 里的超大 tool result 回写成更短版本,然后重试 prompt

所以对“当前这一轮模型能看到什么”来说,你可以这样理解:

  • 细节确实可能丢失
  • 但通常不是“一整条结果瞬间消失”
  • 而是先变成“截断版”,再极端时才退化成“占位符版”

更关键的是:OpenClaw 不会自动补发一次 read(path, offset, limit)
“重新读取指定行段”是 agent 在下一步看到这些提示后,自己做出的工具调用决策,而不是 guard 层的自动恢复动作。

可以拆成两段看:先看进入 prompt 前的预算防护,再看进入推理后的重试与补读。

flowchart LR A["读大文件"] --> B["结果过大"] --> C["先截断落盘"] --> D{"预算够吗?"} D -->|够| E["带截断版进 prompt"] D -->|不够| F["旧结果改成 placeholder<br/>再进 prompt"]
flowchart LR A["开始推理"] --> B{"仍 overflow?"} B -->|是| C["先 auto-compaction"] --> D{"还不够?"} D -->|是| E["再缩短 tool result"] --> F["继续 / 重试"] D -->|否| F B -->|否| F F --> G{"模型还要细节?"} G -->|否| H["继续任务"] G -->|是| I["agent 自己重读小片段"]

换句话说,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 = 3
  • softTrimRatio = 0.3
  • hardClearRatio = 0.5
  • softTrim.maxChars = 4000
  • softTrim.headChars = 1500
  • softTrim.tailChars = 1500

也就是说,它解决的不是“单次超大输出”的问题,而是:

会话跑久了以后,老旧工具输出不断堆积,慢慢挤占上下文窗口。

5.2.2 它会动哪些消息

pruning 不会从头到尾乱扫一遍,而是先划出一段“允许处理的中间区域”:

flowchart LR A["bootstrap 区域<br/>第一条 user 之前"] --> B["旧历史中段<br/>可考虑 pruning"] B --> C["最近 N 条 assistant 之后的尾部<br/>受保护"]

更具体地说:

  • 第一条 user message 之前不动
    • 这是 bootstrap 区域,通常包含 SOUL / USER / TOOLS 等初始身份上下文
  • 最近几条 assistant 附近不动
    • keepLastAssistants 用来保护最新尾部,避免把当前任务主线裁坏
  • 只优先处理 toolResult
    • 普通 user / assistant 消息不在这一层的优先处理范围里
  • 还能按工具名做 allow / deny
    • 例如允许裁 read / exec,但不裁某些特殊工具
  • 带图片的 tool result 暂时跳过
    • 因为图像结果很难做安全的部分裁剪

所以你可以把这一层理解成:

先圈出“老旧、居中、可重取”的工具结果,再考虑压缩它们。

5.2.3 “软裁剪”到底是什么

“软裁剪”不是把整条工具输出删掉,而是把它压成一个保留头尾的缩略版

如果某个旧 toolResult 很长,OpenClaw 会把它改成:

1
2
3
4
5
前 1500 chars
...
后 1500 chars

[Tool result trimmed: kept first 1500 chars and last 1500 chars of 28000 chars.]

这就是软裁剪的核心含义:

  • 保留开头
    • 通常能看出这是哪个文件、哪个命令、哪段日志
  • 保留结尾
    • 通常能看出最后报错、最终状态、收尾信息
  • 中间大段内容丢掉
    • 因为它最占窗口,但往往信息密度最低

它对应的伪代码大致就是:

1
2
3
4
5
6
7
if (toolResult.tooLong()) {
toolResult =
head(toolResult, 1500) +
"\n...\n" +
tail(toolResult, 1500) +
"\n[Tool result trimmed ...]"
}

所以软裁剪不是“删除”,而是:

把旧工具结果从“完整正文”降级成“带头尾线索的摘要版缓存”。

5.2.4 什么时候会“硬清空”

如果做完软裁剪之后,整轮上下文占比还是太高,才会进入第二步:硬清空

硬清空的做法更直接:

  • 不再保留头尾
  • 直接把整条旧 toolResult 改成一个占位符

例如:

1
[Old tool result content cleared]

但它也不是无脑清:

  • 只清前面已经判定为“可 pruning”的那些旧 tool result
  • 只有可清理内容达到一定体量时才值得做
  • 一旦上下文占比降回阈值以下,就停止继续清

所以这一层的顺序其实是:

flowchart LR A["进入 context"] --> B{"TTL 到?"} B -->|否| Z["跳过 pruning"] B -->|是| C["计算占比"] --> D{"超 softTrim?"} D -->|否| Z D -->|是| E["找旧 toolResult"] --> F["先软裁剪"] --> G{"超 hardClear?"} G -->|否| H["返回裁剪后的 messages"] G -->|是| I["更老结果改 placeholder"] --> J["达到预算即停止"]

5.2.5 一个直观例子

假设上下文里有这样一段历史:

1
2
3
4
5
6
7
8
user: 帮我看一下这个项目
assistant: 我先读配置和日志
toolResult(read): package.json 全文,22000 chars
toolResult(exec): 构建日志,18000 chars
user: 那现在为什么启动失败?
assistant: 我再看最后的错误
toolResult(read): 最近 200 行日志
assistant: 正在分析

pruning 之后,结果更可能变成:

1
2
3
4
5
6
7
8
9
user: 帮我看一下这个项目
assistant: 我先读配置和日志
toolResult(read): package.json 前 1500 chars ... 后 1500 chars
[Tool result trimmed ...]
toolResult(exec): [Old tool result content cleared]
user: 那现在为什么启动失败?
assistant: 我再看最后的错误
toolResult(read): 最近 200 行日志
assistant: 正在分析

从这个例子可以看出,它想保住的是:

  1. 当前任务主线还在
  2. 最近几轮关键分析还在
  3. 很老的大输出被压缩成“可提示、可重取”的形式

这说明 OpenClaw 在上下文治理上的优先级很明确:

  1. 先保住对话主线
  2. 再压缩附属结果
  3. 实在不行才 compaction

5.3 第三层:真的快压缩了,先做一次“memory flush”

这是 OpenClaw 很有意思、也很“Agentic”的设计。

当系统估计当前 session 接近 compaction 阈值时,会先偷偷跑一轮 pre-compaction memory flush

1
2
3
4
5
6
if (shouldRunMemoryFlush(...)) {
await runEmbeddedPiAgent({
prompt: "Pre-compaction memory flush...",
extraSystemPrompt: memoryFlushSystemPrompt,
})
}

它的作用不是回复用户,而是提醒模型:

  • 如果有值得长期保存的事实
  • 现在就写入 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 变成了一条 前中后分层处理的流水线

flowchart LR A["接近阈值"] --> B["先 flush durable memory"] B --> C["Pi compact()"] C --> D["增强 summary"] D --> E["写 compaction entry"] E --> F["刷新上下文"] F --> G["下一轮 prompt"]

其中 compaction-safeguard 真正做的事情,不是简单地把“所有旧消息”扔给一个 summarizer,而是按下面几步处理:

  1. 先做预算判断
    根据 contextWindowmaxHistoryShare 估算“旧历史最多还能占多少窗口”。
  2. 太长就先裁老 chunk
    如果待总结历史太大,会先丢掉最老的 chunk,只保留更值得留在主摘要里的那部分。
  3. 被丢掉的 chunk 也不是直接作废
    它们会再走一轮 staged summarization,先压成一个 droppedSummary,再并回主摘要输入。
  4. 如果切在一个 turn 中间,额外总结 turn prefix
    避免摘要只看见后半截上下文,丢掉“这个 turn 一开始在干什么”。
  5. 把关键工程事实拼到摘要后面
    包括 tool failure、读过哪些文件、改过哪些文件、AGENTS.md 里的关键规则。

可以把它压成接近伪代码的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
summary = summarize(history)

if (droppedOldChunks) {
summary = summarize(droppedOldChunks) + summary
}

if (splitTurn) {
summary += summarize(turnPrefix)
}

summary += toolFailures
summary += fileOperations
summary += workspaceCriticalRules

也就是说,增强内容包括:

  • 最近的对话主线
  • 被切掉的更老历史的二级摘要
  • split turn 的前缀信息
  • tool failure 摘要
  • 文件读写列表
  • AGENTS.md 提取出的关键规则(如 Session StartupRed 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
2
3
4
if (autoCompactionCompleted) {
const refresh = readPostCompactionContext(workspaceDir)
enqueueSystemEvent(refresh, { sessionKey })
}

所以 post-compaction context refresh 的本质不是“补几句说明”,而是:

在一次不可避免的信息压缩之后,把最不能忘的启动规则重新拉回上下文。

这一步的意图很明显:compaction summary 只是摘要,不应该替代完整的启动规则。


6. 长期记忆:Markdown 才是真相,索引只是加速层

如果说 Session 是“工作记忆”,那 OpenClaw 的长期记忆更像“笔记本 + 检索器”。

6.1 默认设计:Markdown 是 source of truth

OpenClaw 默认长期记忆的中心不是某个 DB,而是 workspace 中的人类可编辑文件:

  • MEMORY.md / memory.md
  • memory/YYYY-MM-DD.md

这是一种非常刻意的工程取舍:

  • 易读、易改、易备份
  • 可以直接放进 git
  • 不把“记忆真相”锁死在向量库里

所以 OpenClaw 的检索索引从一开始就被定义成 derived index,不是 canonical source。

如果把这两类 memory 文件写得更具体一点,它们大概像这样:

1
2
3
4
5
6
7
8
9
10
11
# MEMORY.md

## 用户偏好

- 用户更喜欢先看结论,再看细节
- 讨论代码时默认用中文

## 长期项目事实

- 主仓库是 openclaw
- 常用分支策略是 main + feature branch
1
2
3
4
5
# memory/2026-03-16.md

- 今天在整理 OpenClaw 上下文记忆设计的博客
- 重点卡在 Pi 的 createAgentSession 和 compaction 生命周期
- 需要把 transcript / sessions.json / reset archive 的关系画清楚

这两者的角色是不同的:

  • MEMORY.md 更像“整理过的长期记忆”
  • memory/YYYY-MM-DD.md 更像“当天流水账 / 工作日志”

这也是为什么模板里会建议:

  • 今天和昨天的 daily note 可以作为近期上下文
  • 真正长期有效的事实,要定期沉淀进 MEMORY.md

6.2 默认 memory plugin:memory-core

OpenClaw 默认启用的 memory slot 是 memory-core。它负责注册两个工具:

1
2
memory_search(query, maxResults?, minScore?)
memory_get(path, from?, lines?)

二者分工很清楚:

  • **memory_search**:语义检索,返回 snippet + path + 行号
  • **memory_get**:精读某个文件的某一段,避免整份文件重新灌入上下文

这也是 OpenClaw 的第二个关键取舍:

长期记忆默认不常驻,而是通过工具按需召回。

如果从实现上看,memory-core 本身其实很薄:它主要就是把这两个工具注册给 agent。

1
2
3
api.registerTool(() => [memorySearchTool, memoryGetTool], {
names: ["memory_search", "memory_get"],
})

真正的分工在两个工具本身:

1
2
3
4
5
memory_search(query, maxResults?, minScore?)
-> 返回相关 snippet + path + startLine/endLine + score

memory_get(path, from?, lines?)
-> 读取指定 Markdown 文件的指定行段

可以把它理解成:

  • memory_search = 先定位
  • memory_get = 再精读

也就是说,agent 不需要先把整个 memory/ 目录塞进 prompt,而是走一条两段式链路:

flowchart LR Q["用户问题"] --> S["memory_search<br/>先找最相关的几个 chunk"] S --> R["返回 snippet + path#line"] R --> G["memory_get<br/>只读需要展开的文件/行段"] G --> A["把真正需要的那几段内容带回当前上下文"]

这就是“按需读取”的核心。

为什么它能按需?因为 memory_search 返回的不是整份文件,而是已经切好的 chunk 命中

  • path
  • startLine
  • endLine
  • snippet
  • score

比如一次搜索结果,逻辑上可能长这样:

1
2
3
4
5
6
7
8
9
10
11
{
"results": [
{
"path": "memory/2026-03-16.md",
"startLine": 12,
"endLine": 18,
"snippet": "需要把 transcript / sessions.json / reset archive 的关系画清楚",
"score": 0.84
}
]
}

然后 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。从源码看,它并不是简单的向量搜索,而是混合检索:

flowchart LR F["MEMORY.md / memory/*.md"] --> I["MemoryIndexManager"] I --> C["按行预算切成 chunk"] C --> S["写入 SQLite 主表"] C --> K["写入 FTS5 倒排索引"] C --> V["写入向量表 / embedding cache"] K --> M["Hybrid merge / MMR / temporal decay"] V --> M M --> R["snippet + score + path#line"]

先回答几个最容易混淆的问题。

6.3.1 “按 chunk 切分”是不是按 ## 标题切?

不是。当前内置实现不是按 Markdown 标题语义切,而是按“行 + 大小预算”切。

MemoryIndexManager 里的 chunkMarkdown() 做法更朴素:

  • 把文件按行拆开
  • tokens * 4 粗略估算一个字符预算
  • 逐行往当前 chunk 里累加
  • 一旦超预算,就 flush 成一个 chunk
  • 如果配置了 overlap,还会把尾部一小段带到下一个 chunk
  • 如果某一行本身就太长,还会把这一行继续切成多个 segment

所以它更接近:

1
2
3
4
5
6
7
for (line of lines) {
if (currentChunk + line > maxChars) {
flush(currentChunk)
carryOverlap()
}
currentChunk.push(line)
}

这意味着:

  • 一个 ## 小标题有时会自然落在 chunk 边界附近
  • 但系统并没有先解析 Markdown AST,再按标题树切块

一个直观例子:

1
2
3
4
5
6
7
# 偏好
喜欢简洁回答

## 项目 A
今天修了 session reset 逻辑
补了 transcript 图示
...

内置 chunker 不会直接说“# 偏好 一块、## 项目 A 一块”,而更像是:

1
2
3
chunk 1: 第 1-18 行
chunk 2: 第 17-32 行 // 如果配置了 overlap,会有一点重叠
chunk 3: 第 33-48 行

所以返回结果里最重要的定位信息其实是:

  • path
  • startLine
  • endLine

而不是“命中某个 heading 节点”。

6.3.2 “写进内置索引”到底是写了什么

这里的“内置索引”本质上就是一份 SQLite 索引库,不是一个抽象概念。
同一个 chunk 会被写进几类结构里:

  • files
    • 记录文件级元数据:path / hash / mtime / size
  • chunks
    • 记录 chunk 正文和定位信息:path / start_line / end_line / text / embedding
  • chunks_fts
    • SQLite FTS5 虚拟表,给关键词搜索用
  • chunks_vec
    • 可选的向量表,给向量相似度搜索用
  • embedding_cache
    • hash -> embedding 做缓存,避免文件没变时重复 embedding

所以“写入索引”不是只写一份文本,而是把同一份 memory chunk 拆成“元数据 + 全文索引 + 向量索引 + 缓存”几层。

可以把一次同步理解成这样:

flowchart LR A["扫描 memory"] --> B["算文件 hash / mtime"] --> C["切 chunk"] C --> D["算 chunk hash"] --> E["生成 embedding"] --> F["写 files / chunks / cache"] F --> G["更新 FTS / vec"]

这一步还有两个工程取舍很关键:

  • 文件变了才重建对应索引
    • watcher 只会把 manager 标记成 dirty,然后在 onSearch / onSessionStart / interval sync 时同步
  • embedding 会尽量复用缓存
    • 只要 chunk 的 hash 没变,就不需要再调用 embedding provider

6.3.3 关键词搜索是正则吗?

这部分最容易被误解成“是不是拿正则扫一遍全文”。不是。

这里的 FTS 是 Full-Text Search,全文检索
它的思路更像搜索引擎,而不是正则匹配:

  • 建索引时,先把每个 chunk 里的词拆出来
  • 建一张“哪个词出现在哪些 chunk” 的倒排索引
  • 查询时直接查这张索引,而不是每次把所有 Markdown 全文重新扫一遍

你可以把它想象成这样一张简化表:

1
2
3
"transcript" -> chunk_12, chunk_18
"reset" -> chunk_18, chunk_27
"archive" -> 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
2
3
4
5
6
科技
编程
量化交易
加密货币
AI
工具

例如,长期记忆里有这样一句话:

1
用户喜欢科技、编程与量化交易,也对加密货币和AI工具感兴趣。平时关注效率工具、新技术趋势,偶尔研究投资策略。

按当前默认 FTS5 的实际行为,它更可能被索引成这些短语块:

1
2
3
4
5
6
"用户喜欢科技"               -> chunk_42
"编程与量化交易" -> chunk_42
"也对加密货币和ai工具感兴趣" -> chunk_42
"平时关注效率工具" -> chunk_42
"新技术趋势" -> chunk_42
"偶尔研究投资策略" -> chunk_42

这个例子说明了两件事:

  1. FTS 在这里更像“短语命中”
    • 新技术趋势,比较可能直接命中
    • 偶尔研究投资策略,也比较可能命中
  2. 单独搜很短的中文词,不一定稳
    • 例如只搜 工具科技AI,按默认 FTS 行为未必单独命中

所以 FTS-only 更适合:

  • 文件名
  • 错误码
  • 英文标识符
  • 比较完整的短语

而不太适合指望它天然拥有“很强的中文语义分词能力”。

这也是为什么 OpenClaw 在有条件时会优先加上 vector/hybrid 检索,来补足 FTS 对中文自然语言召回的短板。

顺着这个结论,也能反推出一个很实用的写 memory 的建议:

1
2
3
- 兴趣:科技、编程、量化交易、加密货币、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 找命中的 chunk
  • bm25(...) 给每个结果排序

BM25 可以粗略理解成:

  • 命中的关键词越关键,分越高
  • 在少量 chunk 里出现的稀有词,权重更高
  • 更短、更聚焦的 chunk 往往更占优
  • 更像“搜索排序”,不是“语义理解”

情况 B:真正的 FTS-only 模式

这时 OpenClaw 还会多做一步“口语 query -> 关键词”的提炼。

比如用户问:

1
之前讨论的那个 session reset 归档方案

系统不会直接把整句原样拿去搜,而是会先尽量抽出更像关键词的词,例如:

1
["session", "reset", "归档", "方案"]

然后对这些词分别搜索,再把结果合并去重。

这里要特别注意:

FTS-only 不是把这些词重新拼成一个巨大的 AND 查询,而是“每个词各搜一次,再合并结果”。

这样做的原因也很务实:

  • 用户口语问题里常常带很多无效词
  • 如果全都强行 AND 在一起,容易一个结果都搜不到
  • 分开搜再合并,更适合“之前那个方案”“昨天提过的那个问题”这类自然语言问法

流程更像这样:

flowchart LR A["用户 query"] --> B{"有 embedding?"} B -->|有| C["构造 FTS query"] B -->|没有| D["抽关键词"] --> E["逐词 FTS"] --> F["合并去重"] C --> G["FTS5 MATCH"] F --> G G --> H["BM25"] --> I["返回 snippet + line + score"]

所以这部分最短可以记成:

  • FTS = 全文检索
  • FTS-only = 只做全文检索,不做向量检索
  • FTS 不是 regex
  • OpenClaw 在 FTS-only 下会先把自然语言问题压成更像关键词的查询

6.3.4 向量检索是怎么做的?是不是 RAG 那套?

是广义上的 RAG retrieval 思路,但不是“让大模型现场理解全文再检索”。

它的流程是经典 embedding 检索:

  1. 索引阶段
    • 对每个 memory chunk 生成 embedding
    • 把向量存进 chunks_vec,也把 JSON 形式留在 chunks
  2. 查询阶段
    • 对用户 query 再生成一个 query embedding
    • 用余弦相似度找最接近的 chunk
  3. 结果阶段
    • 返回 chunk 的 snippet + path + line range + score

这里“向量检索”用到的模型是 embedding model/provider,不是聊天大模型直接下场做排序。
源码里支持的 provider 包括:

  • openai
  • gemini
  • voyage
  • mistral
  • localnode-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

  1. 先做一遍关键词召回
  2. 再做一遍向量召回
  3. 按 chunk id 合并去重
  4. 用权重做混合打分
  5. 再做可选的 MMR 和 temporal decay

所以它的排序逻辑更像:

1
2
3
finalScore =
vectorWeight * vectorScore +
textWeight * textScore

然后再额外考虑:

  • 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
  • 支持 onSessionStartonSearch、interval 异步 sync
  • snippet 有长度上限,不会把整篇文档扔回上下文

这其实说明 OpenClaw 的长期记忆目标不是“最强召回率”,而是 “在 token 预算内稳定可用”

“为什么默认不自动全量注入,而是按需读取”,根本原因也在这里:

  1. memory/*.md 天然会无限增长
    每天一份日志,几个月后就可能非常大,不适合常驻 prompt。
  2. 大部分历史在大多数回合里都用不上
    真正相关的通常只是几个 chunk。
  3. 按需读取更省 token
    搜索先返回小 snippet,只有命中后才精读。
  4. 按需读取更安全
    不会因为默认注入,把无关但敏感的旧日志整包带进一次群聊或普通问答。

所以这条链路的本质不是“先搜一下更高级”,而是一个非常务实的窗口治理设计:

长期记忆可以很多,但每一轮真正带进上下文的,只应该是当前问题需要的那几段。

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”风格:

flowchart LR A["before_agent_start"] --> B["向量检索相关记忆"] B --> C["prependContext 注入 prompt"] D["agent_end"] --> E["筛选用户消息里值得存的内容"] E --> F["embedding + 去重 + 写入 LanceDB"]

这个插件不是默认主路径,但很适合放在文章里作为对比:

  • 内置 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.mdSOUL.mdUSER.md 这类稳定规则
  • 当前回合需要:session transcript、最近工具结果
  • 大概率用不上:历史日志、旧记忆、细碎证据

第三类改成 tool retrieval,是整个系统能长期稳定运行的前提。

7.2 长期记忆的真相必须可审计、可手改

这是 Markdown source-of-truth 路线的根本原因。

如果长期记忆只有向量库,系统虽然“智能”,但人很难检查:

  • 为什么模型记住了这件事?
  • 这条记忆从哪来的?
  • 它现在还是不是真实?

OpenClaw 选择把长期记忆落到 Markdown,再围绕它建立索引,实际上是把“可维护性”放在了“炫技”前面。

7.3 上下文治理优先保护对话主线,不是平均裁剪

history limittool-result-context-guardcontext-pruningcompaction-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 模块,而是一整条上下文生命周期:

  1. 用 workspace 文件定义稳定身份与规则
  2. 用 Pi SessionManager 持久化短期会话
  3. 用 guard / pruning / compaction 控制窗口
  4. 用 memory tools 和 memory plugin 做长期召回

所以值得学习的地方,不是“它有记忆”,而是它把 什么该常驻、什么该压缩、什么该落盘、什么该按需召回 这几件事拆得很清楚。