Day 3 | OpenClaw Agent 运行时:会话管理与上下文持久化

系列:《从 0 到 1 拆解 AI Agent 框架:OpenClaw 技术深度解析》


前言

如果说 Gateway 是 AI Agent 框架的"神经网络",负责消息的传递与路由,那么 Agent 运行时(Runtime)就是这个框架的"大脑"——它决定了 AI 如何记忆过去、理解现在、响应未来。

一个生产级 AI Agent 必须解决三个核心问题:

  1. 会话(Session)是什么? 如何定义一次对话的边界?
  2. 上下文如何持久化? 进程重启后,对话历史怎么恢复?
  3. 如何在有限的上下文窗口内,塞进最有价值的信息?

本文将深入 OpenClaw 的 Agent 运行时,逐一拆解这三个问题的工程实现。


一、Session:对话的"容器"

1.1 什么是 Session?

在 OpenClaw 中,Session 是一次完整对话的抽象单元。它不仅仅是消息列表,还包含:

  • 身份信息:这个 Session 属于哪个 Agent?从哪个渠道发起?
  • 上下文状态:当前对话历史、工具调用记录、系统提示词
  • 元数据:创建时间、最后活跃时间、消息数量
  • 配置:使用哪个模型?思维链是否开启?

可以把 Session 理解为一个"对话房间":用户和 AI 在这个房间里交流,房间有自己的记忆和规则,不同房间互不干扰。

1.2 Session Key:唯一身份标识

每个 Session 都有一个全局唯一的 sessionKey,格式如下:

{agentId}:{channelId}:{userId}

例如:

main-agent:telegram:user_12345678
main-agent:discord:guild_abc:channel_xyz
main-agent:webchat:session_abcdef

这个设计的精妙之处在于:Key 本身编码了路由信息。从 Key 就能知道这条消息从哪个平台来、属于哪个 Agent,路由层无需额外查表。

1.3 Session 的生命周期

创建 → 激活 → 持续对话 → 超时休眠 → 按需唤醒 → 归档

OpenClaw 的 Session 是懒加载的:首次收到消息时创建,长时间不活跃后从内存中卸载(但历史记录保留在磁盘),下次消息到来时再从磁盘恢复。

这个设计对资源效率至关重要——一个多渠道 Agent 可能同时维护数百个 Session,不可能全部常驻内存。


二、上下文持久化:JSONL 文件存储

2.1 为什么选择 JSONL?

上下文持久化是 AI Agent 最容易"踩坑"的地方。常见方案对比:

方案 优点 缺点
SQLite 查询灵活,支持索引 Schema 升级麻烦,消息格式嵌套复杂
Redis 读写极快 重启丢失,需额外持久化配置
PostgreSQL 强一致性,功能完备 部署复杂,运维成本高
JSONL 文件 简单、可读、无依赖、原生支持追加写 查询慢,不适合大规模检索

OpenClaw 选择 JSONL(JSON Lines) 格式:每行一条消息,文件路径即 Session ID。

~/.openclaw/sessions/
  main-agent:telegram:user_12345678.jsonl
  main-agent:webchat:session_abcdef.jsonl

每行格式示例:

{"role":"user","content":"帮我分析一下这段代码","timestamp":1709123456789}
{"role":"assistant","content":"好的,这段代码实现了...","timestamp":1709123460123,"model":"claude-sonnet-4"}
{"role":"tool","name":"read","input":{"path":"main.py"},"output":"def main():...","timestamp":1709123461000}

2.2 追加写的原子性保证

JSONL 最大的优势是天然支持追加写

// 每次新消息,只需在文件末尾追加一行
await fs.appendFile(sessionPath, JSON.stringify(message) + '\n');

追加写(append)是操作系统级别的原子操作,不会出现写一半导致文件损坏的情况。相比覆盖写整个文件,性能和可靠性都更好。

2.3 会话恢复:从磁盘重建内存状态

当 Session 从磁盘"唤醒"时,需要重建完整的对话上下文:

async function loadSession(sessionKey: string): Promise<Session> {
  const filePath = getSessionPath(sessionKey);
  
  if (!existsSync(filePath)) {
    return createNewSession(sessionKey);
  }
  
  // 逐行读取,重建消息列表
  const lines = readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
  const messages = lines.map(line => JSON.parse(line));
  
  return {
    key: sessionKey,
    messages,
    createdAt: messages[0]?.timestamp,
    lastActiveAt: messages[messages.length - 1]?.timestamp,
  };
}

这个过程几乎是零成本的——读文件、解析 JSON、构建内存对象,不需要任何网络请求或外部依赖。


三、上下文窗口管理:如何在有限空间里塞进最有价值的信息

这是 Agent 运行时最核心也最复杂的问题。

3.1 上下文窗口的本质限制

大语言模型的上下文窗口是有限的(通常 128K~200K tokens),而一次长期对话很容易超出这个限制。更关键的是:token 越多,推理越慢,成本越高

朴素做法是"只保留最近 N 条消息",但这会丢失重要的早期上下文(比如用户在第1条消息里说的需求)。

3.2 OpenClaw 的分层上下文策略

OpenClaw 采用分层上下文设计,按优先级组装发送给模型的消息列表:

[系统提示词]          ← 最高优先级,永远存在
[记忆文件内容]        ← 用户的长期记忆(MEMORY.md 等)
[工作区文件]          ← 项目上下文(按需注入)
[压缩摘要]            ← 历史对话的摘要(可选)
[最近 N 条消息]       ← 最新的对话历史

这个设计的核心思想是:不同信息有不同的"半衰期"。系统提示词永远有效,最近的消息最相关,中间的历史可以用摘要替代。

3.3 上下文压缩(Compaction)

当对话历史超过一定阈值时,OpenClaw 会触发上下文压缩

  1. 取出最近 K 条消息之前的所有历史
  2. 调用模型生成一段摘要:“以上对话的关键信息是……”
  3. 将摘要替换原始历史消息,存入 Session
  4. 后续对话携带摘要 + 最新消息,而不是完整历史
原始:[msg1, msg2, msg3, ..., msg100, msg101, msg102]
压缩后:[摘要(msg1-msg90), msg91, msg92, ..., msg102]

压缩的触发条件通常是:

  • 消息数量超过阈值(如 80 条)
  • 估算 token 数超过窗口限制的 70%

3.4 Token 估算:不依赖 Tokenizer

精确计算 token 数需要调用模型的 tokenizer,开销不小。OpenClaw 使用启发式估算

function estimateTokens(text: string): number {
  // 英文:约 4 字符/token
  // 中文:约 1.5 字符/token(每个汉字约 1-2 token)
  const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
  const otherChars = text.length - chineseChars;
  return Math.ceil(chineseChars / 1.5 + otherChars / 4);
}

估算值偏保守(略高于实际),宁可早压缩也不超窗口——这是一个合理的工程取舍。


四、Sub-Agent:Session 的隔离沙箱

OpenClaw 支持 Sub-Agent——在主 Session 内启动一个隔离的子会话来完成特定任务。

4.1 为什么需要 Sub-Agent?

想象一个场景:用户让 Agent “帮我重构这个项目的代码”。这个任务可能需要:

  • 读取几十个文件
  • 多轮迭代修改
  • 运行测试验证

如果在主 Session 里做,会迅速消耗上下文窗口,还会让对话历史变得杂乱。

Sub-Agent 的解法是:开一个新的 Session 专门干这件事,主 Session 只关注任务结果。

4.2 Sub-Agent 的生命周期

主 Session 调用 sessions_spawn()
    → 创建子 Session(独立 sessionKey)
    → 子 Agent 执行任务(有自己的上下文、工具调用)
    → 任务完成,推送结果给主 Session
    → 子 Session 按需保留或删除

子 Session 的 JSONL 文件独立存储,不污染主 Session 的历史。这是"关注点分离"原则在 AI Agent 架构中的直接体现。


五、工程细节:我踩过的坑

5.1 并发写入的竞态问题

多个请求同时向同一个 Session 追加消息时,可能出现:

进程A: 读取文件末尾位置 → 写入消息A
进程B: 读取文件末尾位置 → 写入消息B(覆盖了消息A!)

解决方案:Session 级别的写入锁。每个 Session 维护一个 Promise 队列,写操作串行化执行。

class SessionWriter {
  private writeQueue: Promise<void> = Promise.resolve();
  
  async append(message: Message): Promise<void> {
    this.writeQueue = this.writeQueue.then(() => 
      fs.appendFile(this.path, JSON.stringify(message) + '\n')
    );
    return this.writeQueue;
  }
}

5.2 JSONL 文件损坏恢复

进程崩溃可能导致最后一行 JSON 不完整(写到一半):

{"role":"user","content":"你好"}
{"role":"assistant","content":"你好!有什么  ← 截断了

恢复策略:加载时跳过解析失败的行,并在日志中记录警告。宁可丢失最后一条消息,也不让整个 Session 崩溃。

5.3 Session 缓存的内存管理

内存中维护 Session 缓存需要设置上限,避免 OOM。OpenClaw 使用 LRU(最近最少使用) 缓存:

  • 缓存上限:N 个 Session(可配置)
  • 当缓存满时,淘汰最久未访问的 Session
  • 被淘汰的 Session 数据仍在磁盘,下次访问时重新加载

六、设计哲学:简单是最大的优雅

回顾 OpenClaw 的 Session 设计,有几个一以贯之的原则:

零依赖原则:JSONL 文件不需要数据库、不需要 Redis,任何环境都能跑。对于一个面向个人开发者的工具,降低部署门槛比极致性能更重要。

可观测原则:Session 文件是纯文本,随时可以打开看内容。调试时直接 cat session.jsonl 就能看到完整对话历史,比查数据库方便太多。

渐进式复杂度:小对话用简单的追加写,大对话用压缩,极大对话用 Sub-Agent 分流。复杂度随需求增长,不强迫用户从一开始就承受高复杂度。


小结

本文拆解了 OpenClaw Agent 运行时的三个核心机制:

机制 实现方案 核心思想
Session 管理 sessionKey 编码路由信息,懒加载+LRU缓存 身份即路由
上下文持久化 JSONL 追加写,逐行解析恢复 简单可靠
上下文窗口管理 分层优先级 + 压缩摘要 有限空间,最大价值
Sub-Agent 独立 Session 隔离 关注点分离

下一篇,我们将聚焦流式输出——从模型吐出第一个 token,到用户在手机上看到文字,中间发生了什么?


作者:一个在折腾 AI Agent 框架的工程师
系列索引:[Day 1 架构概览] | [Day 2 Gateway] | Day 3 Agent 运行时 | Day 4 流式输出 → 敬请期待

如果这篇文章对你有帮助,欢迎点赞收藏 🎯

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐