拆解 OpenClaw - 02:Agent、会话与记忆系统

0 阅读4分钟

上一篇聊了一条消息从发出到收到回复的完整链路。这一篇深入三个问题:Agent 到底是什么,会话如何隔离,以及一个"每次都失忆"的大模型是怎么表现得像有记忆的。


我:Agent 执行完任务后还存在吗?还是像线程池一样在后台等着?

都不是。Agent 不是一个持久运行的实体,它更像一个函数——被调用时才存在,执行完就没了。

永久存在的只有 Gateway 进程本身:Node.js 事件循环、Discord WebSocket 连接、队列系统、磁盘上的配置和会话文件。每次你发消息,Gateway 调用 runEmbeddedPiAgent() 这个异步函数,从磁盘加载会话历史,组装请求,调大模型,执行工具,写回历史,函数返回——agent "死了"。下次你再发消息,重新走一遍同样的流程。

graph LR
    A[消息到达] --> B[Gateway 入队]
    B --> C["runEmbeddedPiAgent()<br/>Agent 出生"]
    C --> D[读取 session JSONL]
    D --> E[组装请求 & 调用 LLM]
    E --> F[ReAct 循环]
    F --> G[写入 session JSONL]
    G --> H["函数返回<br/>Agent 死亡"]
    H --> I[Gateway 发送回复]

没有任何 agent 在后台"活着"等你说话。记忆全靠文件。每次都是从头加载历史,对模型来说看起来像"记得之前的对话",实际上是每次都把完整历史重新发一遍。

我:那 Gateway 怎么同时处理这么多请求?

Node.js 的单线程加事件循环模型。所有 I/O 操作——等大模型 API 返回、读写文件、发 Discord 消息——都是非阻塞的。等 Claude 返回的 10 秒钟里,事件循环在处理其他频道的消息。看起来在"同时"处理多个请求,实际上单线程在它们之间快速切换。对于 I/O 密集型场景,这比多线程更高效,也不需要锁。

并发通过一个 Promise 队列控制:

graph TD
    subgraph "全局队列"
        direction TB
        ML["main lane<br/>并发上限 4"]
        SL["subagent lane<br/>并发上限 8"]
        CL["cron lane"]
    end

    subgraph "session 子队列(每个串行)"
        S1["session: #general<br/>并发 1"]
        S2["session: #coding<br/>并发 1"]
        S3["session: spawn-abc<br/>并发 1"]
    end

    ML --> S1
    ML --> S2
    SL --> S3

不同 session 的请求可以并行排队,同一个 session 的请求严格排序。这保证了同一个会话里不会有两个 agent run 同时读写历史文件,避免竞态条件。

我:如果电脑关机了或者 Gateway 退出了,重启后能继续中断的任务吗?

不能。Gateway 进程死亡后,内存中的 messages 数组、正在进行的 HTTP 请求、队列状态全部丢失。磁盘上的 session JSONL 文件还在,但 JSONL 只在 agent run 成功结束时才完整写入。中断的那次 run 就像没发生过。

进程崩溃时:
  内存(丢失)              磁盘(保留)
  ├─ messages 数组          ├─ session JSONL(上次成功 run 的状态)
  ├─ HTTP 连接              ├─ 配置文件
  ├─ 队列状态               ├─ 记忆文件(.md)
  └─ 工具执行的中间状态       └─ bootstrap 文件

这是有意的设计取舍。中间状态很难完美持久化——工具执行到一半,有的完成了有的没有,很多操作不是幂等的,重来一遍可能有副作用。对个人助手场景来说,Gateway 挂了的概率低,代价也不大,重新 @ 一次就行。OpenClaw 保证数据不丢,但不保证任务不断。

我:记忆系统具体怎么工作的?写入和读取分别是什么机制?

OpenClaw 的记忆分三层,对应不同的时间尺度和访问方式:

graph TB
    subgraph "永久记忆(每次请求都注入 system prompt)"
        I["IDENTITY.md<br/>AI 的名字和性格"]
        U["USER.md<br/>用户的称呼和偏好"]
        S["SOUL.md<br/>人格和语气"]
        A["AGENTS.md<br/>操作手册"]
    end

    subgraph "长期记忆(按需搜索,不自动加载)"
        M["MEMORY.md<br/>策展的重要记忆"]
        D["memory/YYYY-MM-DD.md<br/>每日原始记录"]
    end

    subgraph "短期记忆(session 对话历史)"
        J["session JSONL<br/>完整的 messages 数组"]
    end

    I & U & S & A -->|"每次都消耗 token<br/>单文件上限 20000 字符"| SP["System Prompt"]
    M & D -->|"memory_search 语义搜索<br/>memory_get 精确读取"| TR["工具调用时才消耗 token"]
    J -->|"每次从磁盘加载<br/>最大的 token 消费者"| MS["Messages 数组"]

永久记忆每次 API 请求都完整注入到系统提示里,模型天然"知道"这些内容,不需要搜索。代价是每次都消耗 token,所以文件必须精简。

长期记忆不自动注入,通过 memory_search 工具按需搜索。搜索是语义级别的——文本被切成片段,通过 embedding API 转成向量,存入 SQLite 索引,查询时做余弦相似度匹配。不是关键词匹配,是理解意思的搜索。

短期记忆就是 session 的 messages 数组,也是最大的 token 消费者。

记忆的读写是不对称的。读用专用工具(memory_search + memory_get),写用通用文件工具(Write、Edit)。模型怎么知道该往哪个文件写?全靠 AGENTS.md 里的自然语言指引——"日常记录写 memory/YYYY-MM-DD.md,重要决策写 MEMORY.md"。没有硬编码逻辑,系统提示就是用自然语言写的程序。

我:对话越来越长,context window 快满了怎么办?

三层压缩,逐步升级:

graph TD
    START["对话越来越长"] --> P

    subgraph P["第一层:Session Pruning"]
        P1["裁剪旧的 tool_result"]
        P2["soft-trim:保留头尾各 1500 字符"]
        P3["hard-clear:替换为占位符"]
        P4["只改内存,不改磁盘"]
    end

    P -->|"还不够"| MF

    subgraph MF["第二层:Memory Flush"]
        MF1["Gateway 静默提醒模型"]
        MF2["模型把重要信息写入 memory 文件"]
        MF3["回复 NO_REPLY,用户看不到"]
    end

    MF --> C

    subgraph C["第三层:Compaction"]
        C1["用 LLM 总结旧对话为摘要"]
        C2["摘要 + 最近几轮 = 新 messages"]
        C3["写入 JSONL,永久性的"]
        C4["用压缩后的 messages 重试请求"]
    end

    C --> END["context 恢复正常,继续对话"]

第一层 Pruning 针对的是工具返回内容。工具返回往往很大(比如读了一个 2 万字的文件),pruning 把旧的工具结果截短或清空。只改内存中的 messages,不动磁盘上的 JSONL,是完全可逆的。

第二层 Memory Flush 发生在 compaction 即将触发之前。Gateway 偷偷给模型发一轮提醒:"会话即将压缩,把重要信息存到记忆文件里。"模型会把值得保留的内容写到 memory 文件,然后回复 NO_REPLY。用户完全看不到这个过程。

第三层 Compaction 是最重的操作。用模型本身把旧对话总结成一段摘要,替换掉原始的长对话。这个操作会写入 JSONL 文件,是不可逆的。压缩后 Gateway 用新的 messages 自动重试被打断的请求。

三层从轻到重:临时裁剪→抢救信息→永久压缩。