上一篇聊了一条消息从发出到收到回复的完整链路。这一篇深入三个问题: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 自动重试被打断的请求。
三层从轻到重:临时裁剪→抢救信息→永久压缩。