芥末
发布于 2026-03-19 / 0 阅读
0
0

OpenClaw 双源记忆系统:从存储结构到检索调用

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.mdmemory/*.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-designbug-fixreminders。文件名带日期,方便人工查看;内容用 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.mdmemory/*.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 tokens400每个文本块大约 400 tokens
overlap tokens80相邻文本块保留约 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行范围内容摘要用途
01-12会话标题、摘要、健身提醒关键点召回“健身提醒”“下午 3 点”等信息
110-22Telegram 偏好、对话亮点、待办项召回“提醒渠道”“后续动作”等信息

生成 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_searchmemory_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: 你之前提到喜欢蓝色,尤其是天空蓝。

这个流程有几个关键点:

  1. 只有和问题相关的记忆会进入上下文;
  2. 搜索结果带分数,低置信度时可以提醒用户“已查过但没有找到明确记录”;
  3. 最终回答尽量基于 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.mdmemory/*.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.mdSOUL.mdIDENTITY.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_searchmemory_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_searchmemory_get 以受控方式读取记忆。

这也是个人智能体从“无状态工具”走向“可持续协作伙伴”的关键一步。


评论