OpenClaw,也就是 Moltbot/clawdbot,可以理解成一个运行在多种入口上的个人 AI 助手。它既可以接入 Telegram、Slack、企业微信,也可以通过本地 CLI(命令行界面)使用;既能聊天,也能读写文件、执行命令、调用 Skills,还能通过 MCP(Model Context Protocol,模型上下文协议)连接外部工具。
它的核心架构大致可以拆成三层:
flowchart LR
U[用户] --> C1[Telegram]
U --> C2[Slack]
U --> C3[企业微信]
U --> C4[本地 CLI]
C1 --> G[Gateway<br/>统一接入层]
C2 --> G
C3 --> G
C4 --> G
G --> R[Agent Runtime<br/>智能体运行时]
R --> LLM[大语言模型 LLM]
R --> T[工具 / Skills / MCP]
R --> S[(本地磁盘存储)]
S --> M1[会话日志 JSONL]
S --> M2[长期记忆 Markdown]
S --> DB[(SQLite 记忆索引)]
这套结构里,最关键的问题不是“怎么把消息转发给模型”,而是:同一个 Agent 活跃在多个入口时,如何知道用户是谁、之前说过什么、偏好是什么、哪些任务还没完成。
很多智能体系统会把“上下文窗口”当成记忆。也就是说,每次请求都把历史对话、工具调用结果、系统提示词一股脑塞给模型,让模型靠当前输入里的历史内容“记住”用户。
这种做法很快会遇到三个限制:
| 限制 | 说明 |
|---|---|
| 容量有限 | 模型上下文窗口再大也有上限,超过后必须截断、摘要或重置 |
| 成本高 | 每次请求都要重新传输上下文,历史越长,输入 token 越贵 |
| 生命周期短 | 上下文只对当前请求有效,会话重置后早期内容很容易丢失 |
OpenClaw 的思路是把“上下文”和“记忆”分开。
上下文是模型当前工作台,决定这一轮请求能直接看到什么;记忆是持久化知识库,决定系统长期能积累什么。长期信息不必每次都塞进 prompt,而是在需要时搜索出来,再把少量相关片段放回上下文。
flowchart TB
A[用户长期交互] --> B[本地持久化记忆]
B --> C[索引构建<br/>Embedding + 全文索引]
D[当前任务] --> E[记忆搜索]
C --> E
E --> F[相关片段进入上下文]
F --> G[LLM 生成回答或执行工具]
H[完整历史全部塞上下文] -.成本高 / 容量爆炸.-> G
所以,OpenClaw 记住的不是“无限长度的原封不动聊天记录”,而是一套分层存储的信息:底层保存原始会话日志,上层沉淀成 Markdown 形式的长期记忆,再通过检索系统按需召回。
记忆不等于上下文
在智能体系统里,需要先区分两个概念。
上下文(Context) 是单次请求中发给模型的全部内容,通常包括:
- system prompt,也就是系统提示词;
- 最近几轮用户消息和助手回复;
- 工具定义;
- 工具调用结果;
- 临时任务说明;
- 被检索回来的记忆片段。
上下文的特点是临时、昂贵、受模型窗口限制。哪怕模型支持 128K 或 200K tokens,把所有历史都放进去也不现实,因为每次请求都要重新计费,而且越到后期越容易拖慢响应。
记忆(Memory) 是持久存储在磁盘上的结构化信息。它不要求每次请求都进入模型输入,而是通过搜索工具按需读取。只要磁盘够用,记忆可以跨会话保存,也可以在会话重置后继续使用。
二者的关系可以理解成:
| 概念 | 类比 | 生命周期 | 成本 | 用途 |
|---|---|---|---|---|
| 上下文 | 工作台 | 单次请求或当前会话 | 每次请求都消耗 token | 让模型处理当前任务 |
| 记忆 | 知识库 / 笔记本 | 长期保存 | 存储成本很低,检索时才消耗 token | 保存用户偏好、历史决策、任务线索 |
OpenClaw 的记忆层并不是为了让单次请求一定更便宜,而是为了让智能体能跨越上下文窗口限制,持续积累信息,并在需要时把相关内容找回来。
双源记忆架构:动态记忆和静态记忆
OpenClaw 把记忆分成两类:动态记忆和静态记忆。
| 记忆类型 | 存储格式 | 典型路径 | 产生方式 | 作用 |
|---|---|---|---|---|
| 动态记忆 | JSONL | ~/.openclaw/agents/{agentId}/sessions/*.jsonl | 系统自动追加 | 记录完整会话流水 |
| 静态记忆 | Markdown | ~/.openclaw/workspace/MEMORY.md、memory/*.md | 用户手动维护 + 系统自动生成 | 保存长期有价值的信息 |
动态记忆更像“录像带”,它把交互过程按时间记录下来;静态记忆更像“整理后的笔记”,里面是经过筛选、摘要或用户明确指定后需要长期保留的信息。
flowchart LR
A[用户与 Agent 对话] --> B[动态记忆<br/>sessions/*.jsonl]
B --> C{是否需要沉淀?}
C -->|/new 触发 Hook| D[会话摘要]
C -->|上下文压缩前| E[Memory Flush]
C -->|用户明确要求| F[手动写入]
D --> G[静态记忆<br/>memory/YYYY-MM-DD-slug.md]
E --> G
F --> H[MEMORY.md 或 memory/*.md]
G --> I[记忆索引]
H --> I
这个设计的好处是清楚的:系统不会把所有细碎对话都当成长期知识,也不会只保留摘要而丢掉原始记录。JSONL 负责完整性,Markdown 负责可检索和可长期维护。
动态记忆:会话日志如何产生
每次用户和 Agent 交互,OpenClaw 都会把消息追加写入 JSONL(JSON Lines)文件。JSONL 的特点是一行一个 JSON 对象,适合持续追加写入,也方便后续逐行解析。
一个简化后的会话日志可能是这样:
{"type":"message","message":{"role":"user","content":"帮我写一个 Python 爬虫"}}
{"type":"message","message":{"role":"assistant","content":"可以,我先确认目标网站和字段。"}}
{"type":"tool_call","tool":"bash","input":{"command":"python crawler.py"}}
系统在构造会话条目时,通常会从日志里提取用户和助手消息,把它们整理成更容易摘要或索引的文本:
async function buildSessionEntry(filePath: string) {
const raw = await fs.readFile(filePath, "utf-8");
const lines = raw.split("\n").filter(Boolean);
const messages: string[] = [];
for (const line of lines) {
const record = JSON.parse(line);
if (record.type !== "message") continue;
const role = record.message?.role;
const content = record.message?.content;
if (role === "user") {
messages.push(`User: ${content}`);
}
if (role === "assistant") {
messages.push(`Assistant: ${content}`);
}
}
const content = messages.join("\n");
return {
path: filePath,
hash: hashText(content),
content,
};
}
动态记忆的价值在于保留细节。它包含会话发生时的更多上下文,适合在生成摘要、回溯问题、调试 Agent 行为时使用。但它不是长期记忆检索的主要对象,因为原始日志通常噪声多、长度长,直接检索或塞进上下文都会带来成本。
静态记忆:长期信息如何沉淀
静态记忆使用 Markdown 文件保存,主要有两种形态:
| 文件 | 作用 | 更新方式 | 适合保存的内容 |
|---|---|---|---|
MEMORY.md | 核心长期记忆 | 用户手动维护为主,Agent 也可写入 | 用户偏好、身份信息、固定工作流程 |
memory/*.md | 按时间或主题组织的会话记忆 | 系统自动生成较多 | 某次会话的摘要、任务决策、待办事项 |
例如,MEMORY.md 里可以保存这类内容:
# 用户长期偏好
- 回复尽量简洁,先给结论,再给细节。
- 代码示例优先使用 TypeScript 和 Python。
- 默认把提醒发送到 Telegram。
memory/2026-01-10-reminders.md 则更适合保存某次会话沉淀出的任务信息:
# Session: 2026-01-10 08:00 UTC
## Summary
用户希望设置每日健身提醒,并补充了每周训练安排。
## Key Points
- 每天下午 3 点提醒健身。
- 周一练胸,周三练背,周五练腿。
- 提醒渠道优先使用 Telegram。
## Action Items
- [x] 设置每日 15:00 健身提醒。
- [ ] 后续可继续补充具体训练动作。
静态记忆的来源主要有三种。
用户手动写入
用户可以直接编辑 MEMORY.md,明确告诉 Agent 哪些信息要长期记住。对于精确信息,这是最可靠的方式,比如:
- 我的默认工作目录是 ~/workspace/acme。
- 周报固定在每周五 18:00 前发送。
- 和我讨论数据库时,优先考虑 PostgreSQL。
这类信息不应该只依赖模型自动摘要,因为自动摘要是有损的,可能遗漏数字、时间、路径等细节。
/new 触发 session-memory Hook
当用户执行 /new 重置会话时,OpenClaw 可以触发 session-memory Hook,把上一段会话提炼成 Markdown 文件。
流程通常是:
flowchart TB
A[用户执行 /new] --> B[读取最近会话日志 JSONL]
B --> C[提取最近 N 条 user / assistant 消息]
C --> D[调用 LLM 生成摘要]
D --> E[生成语义化文件名 slug]
E --> F[写入 memory/YYYY-MM-DD-slug.md]
F --> G[触发索引更新]
这里的 slug 通常来自会话内容,比如 api-design、bug-fix、reminders。文件名带日期,方便人工查看;内容用 Markdown,方便模型读取,也方便全文索引。
Memory Flush:压缩前的记忆刷新
Memory Flush 是 OpenClaw 记忆系统里非常关键的一环。
当会话历史接近压缩阈值时,系统会在真正压缩之前安排一次特殊的 Agent 回合,要求 Agent 先把值得长期保存的信息写入记忆文件。如果没有值得保存的内容,就返回一个静默标记。
简化后的提示词类似:
const MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
"Store durable memories now in memory/YYYY-MM-DD.md.",
"If nothing should be stored, reply with SILENT.",
].join(" ");
Memory Flush 解决的是一个现实问题:压缩会话历史之前,先给 Agent 一次机会,把重要信息从即将被压缩的上下文里捞出来,写入长期记忆。
但它也有明显代价。判断什么是“值得长期保存的信息”本身依赖 LLM(大语言模型),而 LLM 摘要是有损的。默认摘要往往会保留决策、待办、未解决问题、约束条件,却不一定保留所有精确数字、时间点、命令参数。
可以把这里的取舍理解成:
| 目标 | 做法 | 代价 |
|---|---|---|
| 避免上下文无限增长 | 把历史压缩成摘要 | 细节可能丢失 |
| 保留长期重要信息 | 压缩前写入 Markdown 记忆 | 需要额外一次 LLM 调用 |
| 控制检索成本 | 只索引长期记忆文件 | 原始日志默认不作为主检索对象 |
所以,OpenClaw 的长期记忆不是完整无损数据库,而是“经过筛选的长期知识”。如果某个信息必须精确保存,例如“会议是 3 月 26 日 12:00”或“服务器 IP 是 10.0.3.17”,最好明确要求 Agent 写入 MEMORY.md 或指定的 memory/*.md 文件。
Markdown 记忆如何变成可检索索引
静态记忆写入磁盘后,还需要被索引,否则 Agent 只能靠读文件名或全文扫描来找信息,效率和效果都不稳定。
OpenClaw 的索引方案比较轻量:不依赖 Elasticsearch,也不需要单独部署 Milvus,而是用 SQLite 加两个扩展能力完成。
sqlite-vec:负责向量相似度搜索;FTS5:SQLite 内置全文检索能力,负责关键词搜索。
整体流程是:
flowchart TB
A[发现 Markdown 记忆文件] --> B[计算文件 hash]
B --> C{文件是否变化?}
C -->|否| D[跳过索引]
C -->|是| E[Markdown 分块]
E --> F[计算 chunk hash]
F --> G{Embedding 缓存命中?}
G -->|命中| H[复用向量]
G -->|未命中| I[调用 Embedding Provider]
H --> J[写入 SQLite]
I --> J
J --> K[chunks 主表]
J --> L[chunks_vec 向量索引]
J --> M[chunks_fts 全文索引]
J --> N[更新 files 元数据]
文件发现与变更检测
索引任务会扫描记忆目录,找出 MEMORY.md、memory/*.md 以及配置中额外指定的 Markdown 文件。
系统不会每次都重建全部索引,而是先计算文件 hash,再和数据库里的记录比较。
async function buildFileEntry(absPath: string) {
const stat = await fs.stat(absPath);
const content = await fs.readFile(absPath, "utf-8");
return {
path: toRelativeMemoryPath(absPath),
absPath,
mtime: stat.mtimeMs,
size: stat.size,
hash: hashText(content),
};
}
判断逻辑可以简化为:
const oldRecord = db
.prepare("SELECT hash FROM files WHERE path = ? AND source = ?")
.get(entry.path, "memory");
if (oldRecord?.hash === entry.hash && !needsFullReindex) {
return; // 文件没变,不重新索引
}
await indexFile(entry);
这个设计很重要。记忆文件可能会频繁追加,如果没有 hash 判重,每次同步都要重新分块、重新向量化,成本会明显增加。
Markdown 分块
文件发生变化后,系统会把 Markdown 切成多个 chunk。默认配置类似:
| 参数 | 默认值 | 含义 |
|---|---|---|
| chunk tokens | 400 | 每个文本块大约 400 tokens |
| overlap tokens | 80 | 相邻文本块保留约 80 tokens 重叠 |
重叠的目的是避免语义被切断。比如用户偏好在上一段末尾,具体任务在下一段开头,如果完全硬切,检索时可能只召回一半信息。
一个简化版分块逻辑如下:
function chunkMarkdown(content: string, options = { tokens: 400, overlap: 80 }) {
const maxChars = options.tokens * 4;
const overlapChars = options.overlap * 4;
const lines = content.split("\n");
const chunks: Array<{
startLine: number;
endLine: number;
text: string;
hash: string;
}> = [];
let buffer: Array<{ line: string; lineNo: number }> = [];
let size = 0;
function flush() {
if (buffer.length === 0) return;
const text = buffer.map(item => item.line).join("\n");
chunks.push({
startLine: buffer[0].lineNo,
endLine: buffer[buffer.length - 1].lineNo,
text,
hash: hashText(text),
});
buffer = keepTailByChars(buffer, overlapChars);
size = buffer.reduce((sum, item) => sum + item.line.length + 1, 0);
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (size + line.length > maxChars && buffer.length > 0) {
flush();
}
buffer.push({ line, lineNo: i + 1 });
size += line.length + 1;
}
flush();
return chunks;
}
假设 memory/2026-01-10-reminders.md 被切成两个块,结果可能是:
| Chunk | 行范围 | 内容摘要 | 用途 |
|---|---|---|---|
| 0 | 1-12 | 会话标题、摘要、健身提醒关键点 | 召回“健身提醒”“下午 3 点”等信息 |
| 1 | 10-22 | Telegram 偏好、对话亮点、待办项 | 召回“提醒渠道”“后续动作”等信息 |
生成 Embedding
Embedding 是把文本转换成向量的过程。向量可以表达语义相似度,比如“我喜欢什么颜色”和“用户偏好:天空蓝”并没有完全相同的关键词,但语义上高度相关。
每个 chunk 会计算 hash,并优先查询 embedding 缓存:
const cachedVectors = loadEmbeddingCache(chunks.map(chunk => chunk.hash));
const missingChunks = chunks.filter(chunk => !cachedVectors.has(chunk.hash));
const newVectors = await embeddingProvider.embedBatch(
missingChunks.map(chunk => chunk.text)
);
saveEmbeddingCache(newVectors);
OpenClaw 可以使用不同的 Embedding Provider,例如 OpenAI、Gemini 或本地 embedding 模型。具体选型会影响检索质量、延迟和成本。
写入 SQLite
索引数据会进入几张核心表。
files 表保存文件级元数据,用来判断文件是否变化:
CREATE TABLE files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL,
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
chunks 表保存文本块的完整元数据:
CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL,
start_line INTEGER,
end_line INTEGER,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER
);
chunks_vec 是向量索引表,用于语义搜索:
CREATE VIRTUAL TABLE chunks_vec USING vec0(
id TEXT PRIMARY KEY,
embedding FLOAT[1536]
);
chunks_fts 是全文索引表,用于关键词检索:
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text,
id UNINDEXED,
path UNINDEXED,
source UNINDEXED,
model UNINDEXED,
start_line UNINDEXED,
end_line UNINDEXED,
tokenize='porter unicode61'
);
一条记忆 chunk 写入后,会同时出现在主表、向量表和全文索引表中:
| 表 | 保存内容 | 查询用途 |
|---|---|---|
chunks | 原文、路径、行号、模型、embedding JSON | 返回结果详情 |
chunks_vec | 向量 BLOB | 语义相似度搜索 |
chunks_fts | 分词后的文本倒排索引 | BM25 关键词搜索 |
files | 文件 hash、mtime、size | 增量索引判断 |
这样,一个 Markdown 文件就从“普通笔记”变成了“可语义搜索、可关键词搜索、可按行精确读取”的记忆单元。
混合检索:向量搜索 + BM25
只靠向量搜索并不够。向量适合找语义相近的内容,但对精确词、专有名词、路径、日期、缩写不一定稳定。
只靠关键词搜索也不够。关键词搜索依赖字面匹配,如果用户问“我偏好的配色是什么”,而记忆里写的是“喜欢天空蓝”,纯关键词可能召回不到。
OpenClaw 使用混合检索,把两种结果合并:
flowchart LR
Q[用户查询] --> P[清洗查询文本]
P --> V[向量搜索<br/>sqlite-vec]
P --> K[关键词搜索<br/>FTS5 / BM25]
V --> M[结果融合]
K --> M
M --> R[按综合得分排序]
R --> O[返回 Top K 片段]
典型的搜索函数可以写成这样:
async function searchMemory(query: string, options = { maxResults: 6, minScore: 0.35 }) {
const cleaned = normalizeQuery(query);
const keywordResults = await searchKeywordWithBM25(cleaned);
const queryVector = await embedQuery(cleaned);
const vectorResults = await searchVector(queryVector);
const merged = mergeResults({
vector: vectorResults,
keyword: keywordResults,
vectorWeight: 0.7,
textWeight: 0.3,
});
return merged
.filter(item => item.score >= options.minScore)
.sort((a, b) => b.score - a.score)
.slice(0, options.maxResults);
}
融合得分通常是:
finalScore = 0.7 * vectorScore + 0.3 * keywordScore;
也就是说,语义相似度占更大权重,关键词匹配负责补足精确召回能力。默认最低分数阈值可以设为 0.35,低于阈值的片段不返回,避免把无关记忆塞进上下文。
不同检索方式的适用场景可以这样理解:
| 检索方式 | 擅长 | 不擅长 |
|---|---|---|
| 向量搜索 | 语义相似、同义表达、模糊问题 | 精确日期、路径、变量名、罕见专有名词 |
| BM25 关键词搜索 | 精确词匹配、名称、编号、日期 | 用户换一种说法时可能漏召回 |
| 混合检索 | 同时兼顾语义和精确词 | 需要调权重、阈值和 Top K |
Agent 如何使用记忆工具
记忆索引建好后,Agent 并不会直接读 SQLite,而是通过工具接口和记忆系统交互。核心工具通常有两个:memory_search 和 memory_get。
memory_search:先搜索相关片段
memory_search 用于根据问题召回候选记忆。它返回的是片段列表,每个片段带路径、行号、分数和摘要。
工具定义可以简化成:
const memorySearchTool = {
name: "memory_search",
description:
"Search MEMORY.md and memory/*.md before answering questions about prior work, decisions, dates, people, preferences, or todos.",
parameters: {
query: "string",
maxResults: "number",
minScore: "number",
},
async execute(params) {
return manager.search(params.query, {
maxResults: params.maxResults ?? 6,
minScore: params.minScore ?? 0.35,
});
},
};
返回结果类似:
{
"results": [
{
"path": "memory/2026-01-10.md",
"startLine": 15,
"endLine": 20,
"score": 0.85,
"snippet": "用户提到喜欢蓝色,尤其是天空蓝。",
"source": "memory"
},
{
"path": "MEMORY.md",
"startLine": 5,
"endLine": 8,
"score": 0.72,
"snippet": "颜色偏好:蓝色系。",
"source": "memory"
}
],
"provider": "openai",
"model": "text-embedding-3-small"
}
这个结果还不是完整文件内容,而是候选片段。这样可以先控制上下文大小,避免一次性读取整份记忆文件。
memory_get:再精确读取需要的行
memory_get 用于按路径和行号读取 Markdown 里的具体内容。它通常在 memory_search 之后调用。
const memoryGetTool = {
name: "memory_get",
description:
"Read selected lines from MEMORY.md or memory/*.md after memory_search.",
parameters: {
path: "string",
from: "number",
lines: "number",
},
async execute(params) {
return manager.readFile({
relPath: params.path,
from: params.from,
lines: params.lines,
});
},
};
返回结果类似:
{
"path": "memory/2026-01-10.md",
"text": "用户提到喜欢蓝色,尤其是天空蓝。\n在 UI 选择上偏好冷色调。"
}
这种“先搜、再取”的模式很像检索增强生成(RAG):先从知识库里找相关内容,再把必要片段放进上下文,让模型基于证据回答。
一个完整的记忆召回流程
假设用户问:
我之前说过喜欢什么颜色?
Agent 不应该直接凭当前对话猜测,而应该先查记忆。
sequenceDiagram
participant U as 用户
participant A as Agent
participant MS as memory_search
participant DB as SQLite 索引
participant MG as memory_get
participant L as LLM
U->>A: 我之前说过喜欢什么颜色?
A->>MS: query = "喜欢的颜色 / 颜色偏好"
MS->>DB: 向量搜索 + BM25 搜索
DB-->>MS: 返回候选片段
MS-->>A: path、行号、score、snippet
A->>MG: 读取高分片段对应行
MG-->>A: 返回精确文本
A->>L: 当前问题 + 记忆片段
L-->>A: 生成回答
A-->>U: 你之前提到喜欢蓝色,尤其是天空蓝。
这个流程有几个关键点:
- 只有和问题相关的记忆会进入上下文;
- 搜索结果带分数,低置信度时可以提醒用户“已查过但没有找到明确记录”;
- 最终回答尽量基于
memory_get读取到的原始片段,而不是只依赖搜索摘要。
Agent 如何主动写入记忆
除了被动搜索,Agent 也可以在判断某些信息值得长期保存时主动写入 Markdown 文件。
例如用户说:
以后生成代码都优先用 TypeScript,除非我明确要求 Python。
Agent 可以把这条偏好追加到 MEMORY.md:
cat >> MEMORY.md <<'EOF'
## Coding Preferences
- 生成代码时优先使用 TypeScript;只有用户明确要求时才使用 Python。
EOF
也可以写入当天记忆文件:
await writeFile("memory/2026-02-05.md", `
# User Preference
- 用户希望默认使用 TypeScript 生成代码。
- Python 只在用户明确要求时使用。
`);
写入后,文件监听或同步机制会检测到 Markdown 变化,触发增量索引。新的偏好进入 SQLite 后,未来用户问“我默认用什么语言写代码”时就可以被检索出来。
memory_get 的安全边界
记忆读取工具必须有路径限制。否则 Agent 可能通过 memory_get 读取任意本地文件,造成安全风险。
安全策略通常包括:
- 不允许绝对路径;
- 不允许
..路径穿越; - 只允许读取
MEMORY.md、memory/*.md或配置里的额外 Markdown 文件; - 只允许
.md文件; - 不允许读取工作区外的任意文件。
简化后的校验逻辑:
function validateMemoryPath(relPath: string) {
const isRelative = relPath.length > 0 && !path.isAbsolute(relPath);
const noTraversal = !relPath.startsWith("..") && !relPath.includes("../");
const isMarkdown = relPath.endsWith(".md");
const allowed =
relPath === "MEMORY.md" ||
relPath === "memory.md" ||
relPath.startsWith("memory/") ||
isConfiguredExtraPath(relPath);
if (!isRelative || !noTraversal || !isMarkdown || !allowed) {
throw new Error("path required");
}
}
这类限制会让记忆工具保持在“读记忆”的边界内,而不是变成任意文件读取工具。
为什么有了记忆层,token 仍然可能很贵
记忆层解决的是长期信息可保存、可搜索的问题,不等于每次请求都会便宜。OpenClaw 的 token 消耗来自多个部分。
flowchart TB
A[一次 Agent 请求的 token 成本] --> B[System Prompt]
A --> C[工具 JSON Schema]
A --> D[会话历史]
A --> E[Memory Flush]
A --> F[记忆检索结果]
A --> G[工具调用链输入输出]
System Prompt 是固定开销
系统提示词每次请求都会带上,里面可能包含:
| 组成 | 说明 |
|---|---|
| 核心规则 | 安全边界、回复格式、消息路由 |
| 工具说明 | 告诉模型有哪些工具、何时使用 |
| Skills 信息 | 技能描述、位置、调用约束 |
| Bootstrap 文件 | 例如 AGENTS.md、SOUL.md、IDENTITY.md |
| Runtime 信息 | 主机、时区、模型等运行环境 |
| Sandbox 信息 | 沙箱权限和限制 |
如果 bootstrap 文件较大,系统提示词本身就可能占用大量字符。记忆层无法消除这部分成本。
工具定义每次都要发送
Agent 要能调用工具,模型就必须知道工具 schema。工具越多,schema 越大。
| 工具类型 | token 压力 |
|---|---|
| 浏览器工具 | action 多,schema 较复杂 |
| 文件读写编辑 | 单个简单,但数量多 |
| 消息工具 | 多渠道、多 action |
| cron 定时任务 | 参数结构中等 |
| memory_search / memory_get | 相对较小 |
| sessions 相关工具 | 通常包含较多字段 |
如果一个 Agent 默认启用大量工具,哪怕用户只问一个简单问题,也可能携带完整工具定义。
会话历史在压缩前仍会增长
会话压缩通常不是每一轮都触发,而是等历史累积到阈值附近再执行。
请求 1 = System Prompt + 工具定义 + 用户消息 1
请求 2 = System Prompt + 工具定义 + 用户消息 1 + 回复 1 + 用户消息 2
请求 3 = System Prompt + 工具定义 + 更长历史 + 用户消息 3
...
达到阈值 = Memory Flush + Compaction
压缩前,历史消息依然会持续增加 token。压缩后,历史被摘要替换,成本下降,但细节也可能损失。
Memory Flush 本身也是一次 LLM 调用
Memory Flush 在压缩前运行,目的是保存持久记忆。但它需要一次完整的 Agent 回合,通常也会携带系统提示词、工具定义和当前压缩前的上下文。
所以它会带来额外成本,只是这个成本换来的是长期记忆沉淀。
检索结果也会进入上下文
当 Agent 调用 memory_search 和 memory_get 后,返回片段会作为工具结果进入上下文。
例如:
用户问题:我之前说过喜欢什么颜色?
memory_search 返回:
- memory/2026-01-10.md: 用户提到喜欢蓝色,尤其是天空蓝
- MEMORY.md: 颜色偏好:蓝色系
这些结果会进入上下文,再由模型生成最终回答。
记忆检索减少了“把所有历史都塞进上下文”的成本,但相关片段本身仍然要消耗 token。
工具调用链会放大成本
一个看似简单的任务,可能会触发多次工具调用:
用户:帮我查一下天气并发到 Telegram
1. web_search("天气") -> 搜索结果进入上下文
2. memory_search("用户位置") -> 位置偏好进入上下文
3. message("telegram", ...) -> 发送结果和确认信息进入上下文
每次工具调用都有输入和输出,都会参与后续推理。工具越多,链路越长,token 成本越容易上升。
记忆层真正带来的价值
OpenClaw 的双源记忆系统可以概括成一句话:用 JSONL 保存完整会话流水,用 Markdown 保存长期知识,再用 SQLite 建立语义索引和全文索引,让 Agent 在需要时找回相关片段。
它解决的不是“单次调用绝对便宜”,而是三个更基础的问题:
| 问题 | 没有记忆层 | 有记忆层 |
|---|---|---|
| 长对话 | 历史越长越容易爆上下文 | 历史可压缩,关键信息可沉淀 |
| 跨会话延续 | 重置后容易遗忘 | Markdown 记忆长期存在 |
| 个性化偏好 | 依赖当前 prompt 临时描述 | 用户偏好可写入长期记忆并检索 |
这套设计也有边界:
| 风险 | 原因 | 应对方式 |
|---|---|---|
| 精确信息丢失 | LLM 摘要有损 | 重要时间、数字、路径应明确写入长期记忆 |
| 检索漏召回 | query、embedding、关键词都可能不匹配 | 使用混合检索,必要时调整 Top K 和阈值 |
| token 成本仍高 | system prompt、工具 schema、工具调用链无法被记忆层消除 | 精简工具、缩短 bootstrap、降低默认上下文负载 |
| 模型能力影响体验 | Agent 要会判断何时搜索、何时读取、何时写入 | 为记忆工具设置明确调用约束,选择工具调用能力稳定的模型 |
长期记忆系统的难点不在“把聊天记录存下来”,而在“什么时候写、写成什么、怎么索引、怎么召回、召回后放多少进上下文”。OpenClaw 的工程取舍比较清晰:原始日志保底,Markdown 承载长期知识,Embedding 负责语义召回,FTS5 负责精确关键词,Agent 通过 memory_search 和 memory_get 以受控方式读取记忆。
这也是个人智能体从“无状态工具”走向“可持续协作伙伴”的关键一步。