1. OpenClaw 会话管理模块到底在管什么
OpenClaw 的会话管理模块,说白了就是负责“把用户和 AI 助手之间的每一轮对话,安全、可追溯地存下来,并且能在需要的时候原样读回来”。它不是一个简单的save/load工具函数,而是一套带缓存、带锁、带维护、带归档的完整存储子系统。如果你正在做二次开发,或者线上遇到了“会话莫名其妙丢了”“转录文件对不上”“并发写入把 sessions.json 写坏了”这类问题,那这个模块就是你必须要啃下来的部分。
它主要解决四件事。第一是元数据管理:每个会话的 sessionId、路由信息、显示名称、Token 统计、ACP 状态,统一放在sessions.json里。第二是对话历史存储:完整消息记录按 JSONL 格式逐行追加到独立转录文件,一行一个 JSON 对象,天然适合流式追加。第三是并发控制:多个请求同时想改同一个 store 时,用内存队列串行化,避免文件写坏。第四是存储维护:条目修剪、数量上限、文件轮转、磁盘配额、转录归档,全部在写盘前自动跑一遍。
适合谁看?需要给 OpenClaw 加自定义会话字段的工程师、排查会话丢失的运维、想理解 ACP 协议和持久化如何配合的架构同学。下面我会按“目录结构 → SessionEntry 字段 → JSONL 落盘 → 完整回放验证”的顺序拆,每一步都给可复制的代码和命令。
先给一个整体认知:这个模块采用双层存储架构——元数据层(sessions.json)+ 转录层({sessionId}.jsonl),再加一层运行时内存层(ACP Session Store)。三层职责分离,是理解后面所有细节的前提。
2. 模块目录树与 SessionEntry 字段对照表
2.1 可复制的模块目录树
先把源码结构贴出来,你可以直接对照自己拉下来的仓库。核心都在src/config/sessions/下:
src/ ├── config/sessions/ │ ├── store.ts # 存储核心:写入/锁/归档/更新 │ ├── store-cache.ts # 对象缓存 + 序列化缓存(双缓存) │ ├── store-load.ts # 反序列化与加载 │ ├── store-lock-state.ts # 内存锁队列状态 LOCK_QUEUES │ ├── store-migrations.ts # 向后兼容字段迁移 │ ├── store-maintenance.ts # 修剪/上限/轮转/维护配置 │ ├── store-read.ts # 只读视图(zod schema 验证) │ ├── disk-budget.ts # 磁盘配额强制执行 │ ├── transcript.ts # 转录文件追加 │ ├── transcript-mirror.ts # 转录媒体 URL 镜像解析 │ ├── transcript.runtime.ts # 转录运行时(动态导入边界) │ ├── artifacts.ts # 归档文件名格式与检测 │ ├── paths.ts # 路径解析 │ ├── types.ts # SessionEntry 等类型定义 │ ├── metadata.ts # 元数据派生 │ ├── main-session.ts # 主会话键解析 │ ├── reset.ts # 会话重置类型分类 │ ├── reset-policy.ts # 日常重置策略 │ ├── session-file.ts # 转录文件路径持久化 │ ├── session-key.ts # 会话键推导 │ ├── delivery-info.ts # 投递信息合并 │ ├── group.ts # 群组会话特殊处理 │ ├── thread-info.ts # 线程信息解析 │ ├── targets.ts # 多 Agent 存储目标发现 │ └── cache-fields.ts # 缓存字段常量 ├── gateway/ │ ├── session-utils.ts # 主要会话工具函数(30+ 函数) │ └── session-utils.fs.ts # 文件系统操作(转录读取) ├── sessions/ # 独立会话工具库 │ ├── session-key-utils.ts │ ├── session-label.ts # 会话标签解析(最大 255 字符) │ ├── session-id.ts # Session ID 格式检测 │ ├── session-lifecycle-events.ts # 生命周期事件总线 │ ├── transcript-events.ts # 转录更新事件总线 │ └── model-overrides.ts # 模型覆盖 └── acp/ └── session.ts # ACP 内存会话存储这个结构最值得注意的一点是:存储层已经从单一大文件拆成了职责明确的多个模块。store.ts只做编排,缓存、加载、锁、迁移、维护、配额各自独立。你排查问题时,先定位是哪个子模块,比在一个几千行的文件里翻要快得多。
2.2 SessionEntry 字段对照表
SessionEntry是整个模块的核心数据结构,定义在src/config/sessions/types.ts。它已经从早期的小结构扩展成了覆盖标识、路由、子 Agent、运行控制、模型覆盖、Token 统计、上下文压缩、心跳、ACP 元数据九大类的大对象。下面按功能分组给你对照表:
| 分组 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 标识 | sessionId | string | 会话唯一 ID |
| 标识 | updatedAt | number | 必填,最后更新时间戳 |
| 标识 | sessionFile | string? | 转录文件路径 |
| 标识 | label | string? | 会话标签,最大 255 字符 |
| 路由 | lastChannel | SessionChannelId? | 最后使用的频道 |
| 路由 | lastTo | string? | 最后投递目标 |
| 路由 | deliveryContext | DeliveryContext? | 投递上下文 |
| 群组 | chatType | SessionChatType? | direct/group/thread |
| 群组 | groupId | string? | 群组 ID |
| 子 Agent | spawnedBy | string? | 由哪个会话派生 |
| 子 Agent | parentSessionKey | string? | 父会话键 |
| 子 Agent | spawnDepth | number? | 嵌套深度,0=主 |
| 子 Agent | status | string? | running/done/failed/killed/timeout |
| 运行控制 | queueMode | string? | steer/followup/collect 等 |
| 运行控制 | sendPolicy | string? | allow/deny |
| 模型覆盖 | providerOverride | string? | 提供商覆盖 |
| 模型覆盖 | modelOverride | string? | 模型覆盖 |
| Token | inputTokens/outputTokens/totalTokens | number? | Token 统计 |
| Token | estimatedCostUsd | number? | 预估费用 |
| 压缩 | compactionCount | number? | 压缩次数 |
| 压缩 | compactionCheckpoints | SessionCompactionCheckpoint[]? | 压缩检查点 |
| 心跳 | lastHeartbeatText | string? | 最后心跳文本 |
| ACP | acp | SessionAcpMeta? | 持久化 ACP 状态 |
其中acp字段是新增的关键设计。它把 ACP 运行时状态持久化到sessions.json,和内存里的AcpSessionStore形成互补。SessionAcpMeta的结构如下:
export type SessionAcpMeta = { backend: string; agent: string; runtimeSessionName: string; identity?: SessionAcpIdentity; mode: "persistent" | "oneshot"; runtimeOptions?: AcpSessionRuntimeOptions; cwd?: string; state: "idle" | "running" | "error"; lastActivityAt: number; lastError?: string; };这里有个坑我踩过:通用 mutator 在更新 store 时,很容易不小心把acp字段整个覆盖掉。所以store.ts在写入前会先collectAcpMetadataSnapshot(store)做快照,mutator 执行完再preserveExistingAcpMetadata恢复。如果你自己写扩展,记得别在 mutator 里手动删acp,否则会被保护逻辑“救回来”,反而让你以为没生效。
3. 可复制配置:JSONL 落盘与双缓存写入
3.1 sessions.json 的真实结构
先看元数据文件长什么样。下面是一个包含 ACP 元数据和 Token 统计的真实示例,你可以直接拿去对照自己的文件:
{ "telegram:123456789": { "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "sessionFile": "/path/to/.openclaw/sessions/a1b2c3d4/2024-01-01T00-00-00-000Z_a1b2c3d4.jsonl", "displayName": "用户对话", "label": "重要客户", "lastChannel": "telegram", "lastTo": "123456789", "chatType": "direct", "updatedAt": 1704067800000, "totalTokens": 12500, "totalTokensFresh": true, "compactionCount": 2, "acp": { "backend": "acp-backend", "agent": "agent-id", "runtimeSessionName": "session-name", "mode": "persistent", "state": "idle", "lastActivityAt": 1704067800000 } } }注意updatedAt现在是必填字段,早期版本是可选的。如果你在做迁移,务必保证每个 entry 都有这个值,否则维护阶段的pruneStaleEntries会把它当成异常数据处理。
3.2 JSONL 转录文件格式
转录文件放在~/.openclaw/sessions/{sessionId}/下,文件名格式是{timestamp}_{sessionId}.jsonl。第一行是会话头,之后每行一条消息:
{"type":"session","version":"1.0","id":"a1b2c3d4","timestamp":"2024-01-01T00:00:00.000Z","cwd":"/workspace"} {"role":"user","content":[{"type":"text","text":"你好"}]} {"role":"assistant","content":[{"type":"text","text":"你好!"}],"api":"openai-responses","provider":"openai","model":"gpt-4o","usage":{"input":10,"output":15,"cacheRead":0},"idempotencyKey":"turn-abc123"}idempotencyKey是新增的幂等键。追加消息前会先调transcriptHasIdempotencyKey检查,如果这个 key 已经存在就跳过,避免重试导致消息重复落盘。这个设计在网关重试场景下非常关键。
3.3 双缓存配置
缓存层在store-cache.ts,两个 Map 各管一摊:
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>(); const SESSION_STORE_SERIALIZED_CACHE = new Map<string, string>(); const DEFAULT_SESSION_STORE_TTL_MS = 45_000;对象缓存存反序列化后的Record<string, SessionEntry>,序列化缓存存最近一次写入的 JSON 字符串。写盘前先比对序列化结果,如果完全相同就直接跳过磁盘 I/O,同时更新两个缓存保持一致。TTL 可以通过环境变量调整:
export OPENCLAW_SESSION_CACHE_TTL_MS=600003.4 队列锁与原子写入
并发控制从旧的“轮询文件锁”换成了内存 Promise 队列。每个storePath对应一个串行队列,任务依次执行,最小持锁 5000ms:
const LOCK_QUEUES: Map<string, SessionStoreLockQueue> = new Map(); type SessionStoreLockQueue = { running: boolean; drainPromise: Promise<void> | null; pending: SessionStoreLockTask[]; };写盘用writeTextAtomic,先写临时文件再rename()原子替换,权限0o600。Windows 上 rename 可能被读者持锁阻塞,所以最多重试 5 次,退避间隔50ms * (i+1):
await writeTextAtomic(storePath, serialized, { mode: 0o600 });3.5 存储维护配置
维护流程在saveSessionStoreUnlocked里自动执行,顺序是:修剪过期条目 → 限制条目总数 → 归档被删转录 → 清理过期归档 → 轮转 sessions.json → 强制执行磁盘配额。磁盘配额配置:
{ maxDiskBytes?: number; // 触发清理的阈值 highWaterBytes?: number; // 清理目标,降到此值以下 }维护模式有两种:"enforce"执行全部维护(默认),"warn"只记录警告不删数据。调试阶段建议先用warn观察,确认清理逻辑符合预期再切enforce。
4. 验证请求:会话创建到落盘回放全流程
4.1 会话加载流程验证
加载入口是loadSessionStore(storePath, opts),走双缓存:
export function loadSessionStore( storePath: string, opts: LoadSessionStoreOptions = {} ): Record<string, SessionEntry>流程是:先查对象缓存,验证 TTL(45 秒)和文件 mtime,未修改就返回structuredClone副本;缓存未命中则从磁盘读、JSON.parse、跑迁移、归一化,然后更新两个缓存。你可以写个脚本验证缓存命中:
import { loadSessionStore } from "./src/config/sessions/store"; const storePath = `${process.env.HOME}/.openclaw/sessions.json`; // 第一次加载,走磁盘 const t1 = Date.now(); const store1 = loadSessionStore(storePath); console.log("首次加载耗时:", Date.now() - t1, "ms"); // 第二次加载,应命中缓存 const t2 = Date.now(); const store2 = loadSessionStore(storePath); console.log("缓存加载耗时:", Date.now() - t2, "ms"); console.log("会话数量:", Object.keys(store1).length);实测下来,缓存命中的加载耗时通常在 1ms 以内,而首次磁盘加载在几十毫秒量级,差距非常明显。
4.2 会话写入流程验证
写入入口是updateSessionStore,带队列锁:
export async function updateSessionStore<T>( storePath: string, mutator: (store: Record<string, SessionEntry>) => Promise<T> | T, opts?: SaveSessionStoreOptions ): Promise<T>完整流程是:加锁 → 强制重新加载(skipCache: true,避免脏读)→ 快照 ACP 元数据 → 执行 mutator → 恢复被误删的 ACP 元数据 → 保存(含维护)→ 释放锁。验证脚本:
import { updateSessionStore } from "./src/config/sessions/store"; const storePath = `${process.env.HOME}/.openclaw/sessions.json`; const sessionKey = "telegram:123456789"; await updateSessionStore(storePath, (store) => { const entry = store[sessionKey]; if (entry) { entry.label = "验证标签"; entry.updatedAt = Date.now(); } return entry; }); console.log("写入完成");4.3 消息追加与幂等验证
追加助手消息用appendAssistantMessageToSessionTranscript,带幂等键的精确版是appendExactAssistantMessageToSessionTranscript。流程是:加载 store → 解析转录文件路径 → 确保会话头存在 → 检查幂等键 → 追加 JSONL 行 → 广播更新事件。
import { appendAssistantMessageToSessionTranscript } from "./src/config/sessions/transcript"; const result = await appendAssistantMessageToSessionTranscript({ sessionKey: "telegram:123456789", text: "这是一条验证消息", storePath: `${process.env.HOME}/.openclaw/sessions.json`, }); if (result.ok) { console.log("写入转录文件:", result.sessionFile); } else { console.error("写入失败:", result.reason); }4.4 落盘回放验证脚本
最后一步,把转录文件读回来验证。JSONL 每行一个 JSON,逐行解析即可:
import fs from "node:fs"; import readline from "node:readline"; async function replayTranscript(sessionFile: string) { const stream = fs.createReadStream(sessionFile); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); let lineNo = 0; for await (const line of rl) { lineNo++; if (!line.trim()) continue; try { const obj = JSON.parse(line); if (obj.type === "session") { console.log(`[头] sessionId=${obj.id} version=${obj.version}`); } else { const text = obj.content?.[0]?.text ?? ""; console.log(`[${lineNo}] ${obj.role}: ${text}`); } } catch (e) { console.error(`第 ${lineNo} 行解析失败:`, e); } } } replayTranscript("/path/to/.openclaw/sessions/a1b2c3d4/2024-01-01T00-00-00-000Z_a1b2c3d4.jsonl");跑通这个脚本,你就能确认“会话创建 → 元数据写入 → 消息追加 → 落盘 → 回放”整条链路是通的。如果中间任何一步断了,对照下一节的报错排查。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
5.1 401 未授权
如果你在调用模型接口时遇到 401,先确认 API Key 是否正确配置。TaoToken 的接入需要三件套齐全:Base URL、Key、Model ID。Base URL 用https://taotoken.net/api,Key 在控制台的 API Keys 页面生成。检查你的配置里这三项是否都填了,尤其是 Model ID 别写成展示名。
# 验证 Key 是否生效 curl https://taotoken.net/api/v1/models \ -H "Authorization: Bearer $TAOTOKEN_API_KEY"如果返回模型列表,说明 Key 没问题;如果还是 401,去控制台确认 Key 是否被禁用或额度耗尽。
5.2 local proxy failed
这个报错通常出现在本地网关转发环节。先检查你的网关进程是否正常监听,再确认sessions.json的路径权限。原子写入用的是0o600,如果目录权限不对,写临时文件会失败。排查命令:
ls -la ~/.openclaw/ ls -la ~/.openclaw/sessions/确认sessions.json和sessions/目录都属于当前用户。如果之前用 root 跑过,文件属主会变成 root,普通用户就写不进去了。
5.3 reading choices 报错
reading choices一般出现在解析模型响应时。如果你用的是 OpenAI Responses API 格式,响应结构里output数组的每一项类型要匹配。检查你的转录文件里api字段是否和实际调用一致:
{"role":"assistant","content":[{"type":"text","text":"..."}],"api":"openai-responses","provider":"openai","model":"gpt-4o"}如果api写成了openai-chat但实际返回的是 responses 格式,解析就会失败。统一改成openai-responses再试。
5.4 OAuth 相关报错
OAuth 报错多半是 token 过期或回调地址不匹配。检查你的authProfileOverride字段是否指向了正确的 profile。如果用了authProfileOverrideSource: "auto",系统会自动选择,但自动选择失败时会回退到默认 profile,可能不是你想要的。手动指定:
entry.authProfileOverride = "your-profile-id"; entry.authProfileOverrideSource = "user";5.5 会话丢失排查清单
会话丢失是最头疼的问题,按这个顺序查:第一,看sessions.json里 entry 是否还在,如果没了,可能是维护阶段被pruneStaleEntries删了,检查updatedAt是否过期;第二,看转录文件是否被归档成了.archived,归档文件名格式是{sessionId}.{yyyyMMddHHmmss}.{reason}.archived;第三,看磁盘配额是否触发了清理,检查maxDiskBytes配置;第四,看是否有并发写入把文件写坏,检查日志里有没有writeTextAtomic重试记录。
6. 把会话管理接进你的开发流
如果你只是排查问题,上面几节够用了。但如果你要长期做 OpenClaw 的二次开发,建议把会话管理相关的调试能力固化下来。我自己的做法是写一个小的 CLI 工具,封装loadSessionStore、updateSessionStore、replayTranscript三个函数,遇到问题直接跑命令看状态,比翻日志快得多。
对于需要长期跑 Agent 任务的场景,会话数量会快速增长,维护策略的配置就很重要。pruneAfterMs、maxEntries、rotateBytes、maxDiskBytes这几个参数要根据你的实际写入频率调。写入频繁的场景,rotateBytes别设太小,否则 sessions.json 频繁轮转,反而增加 I/O。
如果你在接入模型时想先验证会话链路是否通,可以先用模型对话页面发一条消息,确认能正常返回,再回到本地跑回放脚本对照。这样能把“模型侧问题”和“存储侧问题”快速分开。
需要生成 API Key 或查看接入文档的话,直接去控制台的 API Keys 页面和接入文档页,里面有完整的 Base URL、Key、Model ID 三件套说明。长期跑编码类 Agent 任务的话,Coding Plan 的额度模型更适合高频会话场景,可以按需选。