1. 会话管理为什么是智能体平台的"地下工程"
如果你去问用户"会话管理是什么",他大概率会一脸茫然。但如果他某天晚上发现,自己跟智能助手聊了两个小时的内容第二天全被遗忘,或者换个设备继续对话时,助手完全不记得上一句说了什么,他一定会愤怒地吐槽一句"这什么破系统"。会话管理就是这种藏在暗处、平时没人关注、一旦出问题就会被无限放大的"地下工程"。
我在设计 openclaw 这个智能体平台的会话模块时,对这种感受尤其深。因为 openclaw 本身定位是"多智能体协作运行平台",每个智能体都有自己的上下文窗口,用户可以在一个会话里同时调度多个智能体;而会话管理要做的,就是让这一切看起来像"只有一个连贯的对话"——无论背后经过了多少次请求转发、多少次工具调用、多少次模型重新生成。
会话管理在 openclaw 里具体要管什么?我用一句话概括:管住用户状态,保证对话连续性,让数据在"内存、缓存、数据库"三层之间正确流转。拆开来看,它至少要解决四个核心问题:
- 状态怎么存:用户的身份、偏好、对话上下文、临时操作,分别放在哪一层存储,过期时间怎么定。
- 状态怎么恢复:用户重新登录、换设备、会话过期之后,如何把之前的关键信息捞回来。
- 状态怎么过期:清理掉无用会话,又不能误杀正在活跃的会话,这个度极难把握。
- 多轮对话怎么串:每次模型调用需要的历史上下文,如何从持久化数据中高效组装出来,同时不被超长历史拖垮性能。
这四个问题不解决,上面那些功能再花哨也是空中楼阁。这篇就把我在 openclaw 会话管理模块上的完整设计思路、落地代码和踩坑经历讲透,给正在做类似平台的读者一个可参考的蓝本。
2. 用户状态拆解:先分清楚要管什么,再谈怎么持久化
2.1 把状态分成四类,存储策略立刻清晰了
很多新手做会话管理,上来就建一张巨大的 session 表,把所有字段塞进去,然后往里堆 JSON。这样短时间能跑通,但一旦量上来,必然出现性能问题、字段失控、序列化兼容性灾难。我建议第一步不是设计表,而是把用户状态拆成四类。
| 状态类型 | 典型数据 | 热度 | 合理存储 | 合理TTL |
|---|---|---|---|---|
| 认证态 | 会话令牌、用户ID、角色、权限范围 | 热 | Redis | 分钟到小时级 |
| 上下文态 | 对话历史、当前意图、工具调用结果、上下文快照 | 热/温 | Redis + 数据库 | 随会话生命周期 |
| 偏好态 | 语言设置、默认模型、UI偏好、通知配置 | 冷 | 数据库 | 长期保留 |
| 临时态 | 验证码、防重令牌、未提交草稿、分页游标 | 微热 | Redis | 分钟到小时级 |
为什么要这样分?因为四类状态的访问频率、丢失容忍度、恢复成本完全不同。认证态丢了,用户重新登录就行;上下文态丢了,用户之前的对话就断了,体验断崖式下跌;偏好态丢了,用户顶多重新设置一次;临时态丢了,用户重新操作一次的事,影响最小。
搞清楚分类,后面的存储设计其实就顺理成章了。我在 openclaw 里的做法是:认证态和临时态只进 Redis,不碰数据库;偏好态只进数据库,不走缓存;上下文态两头都占,热数据在 Redis,冷数据落库,两级配合。这样各归各位,不会出现"一个状态到底该放哪"的纠结。
2.2 状态粒度与作用域:全局、会话级、消息级
状态分类解决的是"放哪一层"的问题,状态粒度解决的是"同一层里怎么切分"的问题。我最初犯过一个错误:把整个用户的上下文态都当作一个"大口袋",session 恢复时一股脑全塞给模型。结果就是当用户开了三个不同主题的会话时,模型把三个主题的内容混在一起,生成结果越来越飘。
后来我把状态粒度分了三级:
- 全局级:用户基础信息、默认配置,所有会话共享,比如用户的姓名、时区、默认模型。
- 会话级:当前会话的对话历史、用户在此会话表达的偏好、会话内产生的临时变量。这是会话管理的核心作用域。
- 消息级:单条消息的 token 数、生成耗时、附件引用、工具调用参数等。这部分通常只做记录和统计,不直接参与上下文组装。
这一步拆完,我在 openclaw 中的上下文组装逻辑就变得很清晰了:默认配置从全局状态取,当前多轮历史从会话级状态取,单条消息细节按需查消息表。组装时遵循"会话级覆盖全局级、消息级依附会话级"的优先级,不会再有跨界串味的问题。
3. 持久化数据设计:Redis兜热,关系库兜底,中间不留真空
3.1 存储选型:为什么不只用一个Redis
做会话管理,很多人第一反应是"全放 Redis,反正都能持久化"。这话对一半。Redis 做 RDB/AOF 持久化之后,确实能扛重启丢数据的问题,但它在查询灵活性、容量成本、数据一致性保障上,远不如关系库适合处理"历史会话归档"和"按条件检索"这类任务。
反过来,如果全放 MySQL,多轮对话每来一轮都写一次库,热点会话的读压力会非常夸张;会话一多,全表扫描查询"活跃会话列表"会越来越慢。
所以我在 openclaw 里的方案是组合:Redis 管热,数据库管冷,中间用明确的迁移策略衔接。具体分工如下:
- Redis 保存活跃会话的完整上下文快照、认证态、临时态,所有 TTL 由统一策略下发。
- MySQL 保存会话元数据、全部消息记录、归档后的会话摘要和配置字段。
- 消息记录不参与模型实时推理,只用于审计、统计、归档恢复,因此即使查询相对慢也不影响主链路。
3.2 表结构与Key设计:给出一份可以落地的方案
先看关系库那边的表设计。我建表的原则就一条:会话主体和消息记录分开存,元数据字段扁平化,动态内容走 JSON 列,这样既方便建立索引加速查询,又保留扩展弹性。
CREATE TABLE sessions ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_key VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1-活跃 2-空闲 3-已归档 4-已废弃', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, archived_at DATETIME NULL, meta_json JSON NULL COMMENT '偏好态与会话级配置', UNIQUE KEY uk_session_key (session_key), KEY idx_user_status (user_id, status) );CREATE TABLE messages ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_key VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL COMMENT 'system/user/assistant/tool', content MEDIUMTEXT NOT NULL, token_count INT NOT NULL DEFAULT 0, tool_name VARCHAR(64) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_session_time (session_key, created_at) );Redis 那边的 Key 设计,我采用分层命名,一眼能看出用途:
openclaw:auth:{token} -> 认证态,过期时间跟随令牌有效期 openclaw:ctx:{session_key} -> 会话上下文快照,主热数据 openclaw:lock:{session_key} -> 会话分布式锁,防止并发写覆盖 openclaw:tmp:{request_id} -> 临时态,如防重令牌、分页游标其中openclaw:ctx:{session_key}这个 Key 是整个会话模块的核心,里面存储的是"最近 N 轮消息 + 会话级 meta 的合并快照"。每次多轮对话结束后,我会把这个 Key 整体刷新一次,确保 hot path 上读到的永远是完整可用的上下文。
3.3 序列化策略:JSON起步,压缩跟进
序列化方案我踩过一个小坑:上线初期,上下文序列化直接用 Python 的json.dumps,紧凑且人类可读,排查问题特别方便。但跑了一段时间发现,当单条消息带有大量工具调用参数时,JSON 里冗长的字段名会白白消耗大量 Redis 内存,存 100 条消息就多出 20% 到 30% 的存储开销。
我的处理方法是分两档:默认走 JSON,超过阈值自动切 MessagePack 压缩。序列化字段里显式带上版本号,确保老数据能兼容解码。像这样:
import msgpack import json def _serialize_ctx(payload: dict) -> bytes: if len(json.dumps(payload)) > 64 * 1024: return b"msgpack:" + msgpack.packb(payload, use_bin_type=True) return b"json:" + json.dumps(payload).encode("utf-8")解码时根据前缀做分发,逻辑不复杂,但能省不少内存。这里我特别提醒一句:序列化格式的兼容性字段一定要前置,别等线上跑了大半年才开始加版本号,到时候老数据读不出来就麻烦了。
4. 会话生命周期管理:创建、活跃、空闲、归档、销毁
4.1 会话创建与ID生成:为什么不用自增ID
会话 ID 看似小事,其实牵扯到安全性和分布式环境的一致性问题。一开始我用过数据库自增主键对外暴露,很快发现两个问题:一是 ID 规律太明显,用户能猜出平台大概有多少会话;二是多实例部署时,自增 ID 依赖单库生成,容易成为瓶颈。
后来我改用双层设计:对外暴露的session_key是 32 位随机字符串(基于 UUID4 去掉横杠),内部主键继续用自增 ID。session_key不具备可预测性,作为所有跨服务调用的会话标识;内部 ID 只用于数据库关联查询,不对外透出。创建会话时,初始化认证态和上下文快照的 TTL,并写一条初始的 system 消息作为上下文起点。
创建动作本身要保证幂等。openclaw 里有个场景:前端网络抖动导致同一创建请求发两遍,后端必须确保用户只产生一个会话。我的方案是让客户端在请求头里带一个幂等键,Redis 里以幂等键为 Key 做 SETNX,成功则继续创建,失败则直接返回已有会话。
4.2 活跃度判定与分层TTL策略
会话不设 TTL 会怎样?Redis 里的上下文快照越来越多,内存被休眠会话占满,最终把活跃会话挤出内存。我见过有些平台就是这样烂掉的:用户量一大,Redis 内存告急,运维只能手动清 Key,误杀一片活跃会话。
我的解法是把 TTL 做成三层动态递减:
| 会话状态 | Redis TTL | 触发动作 |
|---|---|---|
| 活跃会话 | 2小时 | 每次交互自动续期,重置为2小时 |
| 空闲会话 | 2小时到24小时之间 | 定期扫描发现 TTL 低于阈值时,将上下文沉淀为历史归档并转冷 |
| 待清理会话 | 24小时后 | 从 Redis 删除快照,仅保留数据库记录 |
这个策略的关键在于"每次交互自动续期"这一动作必须和消息写入放在同一事务边界内。我是在写完消息、刷新上下文快照之后,再对openclaw:ctx:{session_key}执行 EXPIRE 重置。这样即使中途崩溃,最多丢失本次续期,不会出现"消息写进去了但 TTL 没续上"的不一致。
4.3 归档与恢复:冷数据如何回到上下文
当会话进入空闲待清理状态,我不会直接把数据丢掉,而是做"归档"。归档动作包含:把最近 N 轮原始消息从消息表生成一份摘要(可以由模型做压缩,也可以在系统层做规则抽取),把会话 meta 写入sessions.meta_json,最后把 Redis 里的上下文快照删除。
归档之后,如果用户又回来继续聊,情况就变成:Redis 里没有快照了,但数据库里有消息记录。恢复逻辑分两层:
- 快速恢复:先取最近 20 条消息 + 归档摘要,组装成一个"轻量上下文",让模型快速接续,加载时间控制在 50ms 内。
- 完整恢复:如果用户明确要求加载完整历史,再从消息表全量读取,重新构建完整上下文。完整恢复性能较差,所以我会在前端加一个"继续对话需要加载历史"的提示,让用户知道背后发生了什么。
这一步我花了不少心思。因为恢复逻辑如果处理不好,用户点开一个旧会话,可能等两三秒才出现消息,体验上就像系统卡死了一样。快速恢复那套"先给摘要、再补全量"的方案,实测能把感知等待时间降到几乎为零。
5. 实战落地:openclaw会话管理模块的一次完整实现
5.1 接口设计:最小可用集合
理论说完了,该上代码。我先给出一个精简版 SessionManager 的核心骨架。这个版本我刻意控制在最小可用集合,但每一步都对应前面聊到的设计逻辑。
class SessionManager: def __init__(self, redis, db, config): self.redis = redis self.db = db self.cfg = config def create_session(self, user_id: int, meta: dict | None = None) -> str: session_key = uuid4().hex stmt = ("INSERT INTO sessions (session_key, user_id, status, meta_json) " "VALUES (%s, %s, 1, %s)") self.db.execute(stmt, (session_key, user_id, json.dumps(meta or {}))) self.redis.setex(f"openclaw:ctx:{session_key}", self.cfg.active_ttl, self._initial_ctx()) return session_key def append_message(self, session_key: str, role: str, content: str, token_count: int) -> None: stmt = ("INSERT INTO messages " "(session_key, role, content, token_count) " "VALUES (%s, %s, %s, %s)") self.db.execute(stmt, (session_key, role, content, token_count)) # 刷新Redis快照与TTL,具体由 _refresh_ctx 实现 self._refresh_ctx(session_key) self.redis.expire(f"openclaw:ctx:{session_key}", self.cfg.active_ttl) def get_context(self, session_key: str, max_tokens: int = 8000) -> list[dict]: raw = self.redis.get(f"openclaw:ctx:{session_key}") if raw is not None: return self._decode_ctx(raw)[-max_tokens:] return self._restore_from_db(session_key, fast_mode=True) def touch(self, session_key: str) -> None: self.redis.expire(f"openclaw:ctx:{session_key}", self.cfg.active_ttl) def archive(self, session_key: str) -> None: messages = self._load_recent_messages(session_key, limit=50) summary = self._build_summary(messages) self.db.execute( "UPDATE sessions SET status=3, archived_at=NOW(), " "meta_json=JSON_SET(meta_json, '$.summary', %s) " "WHERE session_key=%s", (summary, session_key), ) self.redis.delete(f"openclaw:ctx:{session_key}")这段代码里,create_session同时扣住 Redis 和数据库两个动作。有人会问:为什么不先写数据库再写 Redis,或者干脆用事务?我当时的设计逻辑是:只要消息落库成功、上下文快照刷新成功,两个操作各自都有重试机制兜底;如果为了强一致上分布式事务,反而会把热路径拖慢,得不偿失。对于会话管理这个场景,最终一致性完全够用。
5.2 多轮对话与上下文窗口联动
有了基础接口,真正的挑战是"上下文窗口怎么组装"。模型的输入长度是有限的,假设窗口上限是 8000 token,那每次调用前必须把会话历史裁剪到窗口内。最愚蠢的做法是直接截断最早的消息——这样模型会因为丢失开头设定而突然"失忆"。
openclaw 里我的做法是分级压缩:
- 窗口内优先放全量 system 消息和最近三轮消息,这部分是对话连续性的关键。
- 更早的历史按轮次做摘要压缩,压缩算法可以是"保留每轮的意图 + 结论 + 工具调用结果"。
- 如果摘要加起来仍然超窗,就只保留更近的摘要,最早的部分直接丢弃。
代码上通过get_context里的max_tokens参数控制,内部逻辑不再展开,但思路就是上面这样。这个"摘要 + 最近全量"的混合策略,是我在多个实际测试里对比出来的最优解:既不会让模型丢失关键设定,也不至于因为塞入太多远历史导致无效 token 占用。
5.3 并发安全与幂等性设计
会话模块最容易翻车的就是并发。我遇到过两个典型场景:
- 用户双开两个标签页,同时发两条消息,两条请求同时读到同一个上下文快照,各自追加后写回,后写的把先写的覆盖了。
- 前端重试导致同一条消息被写入两次,上下文里出现两条一模一样的 user 消息。
第一个问题的解法是 Redis 分布式锁:每次追加消息前,先获取openclaw:lock:{session_key},拿到锁才读改写。不过纯分布式锁会有性能损耗,openclaw 实际用的是 Lua 脚本把"读上下文、追加消息、trim 窗口"三步原子化,效率高很多。
第二个问题用幂等表解决。每条客户端消息带上client_msg_id,消息表加一列client_msg_id并建唯一索引,重复插入直接报错后吞掉。这样哪怕请求被重试十次,也只落库一条。
6. 踩坑实录:并发覆盖、会话漂移与误删数据
6.1 并发写入覆盖:Lua脚本原子化上下文追加
我先说并发覆盖。最初版本我用的是"先读 Redis -> 追加消息 -> 再写回"的三步逻辑,本地测试一切正常,因为本地环境请求量小。等接上真实网关,用户稍微多点,偶发性丢消息就出现了。
我排查了半天,最后抓到一个典型调用链:
- 请求A读取上下文,得到 10 条消息
- 请求B读取上下文,也得到 10 条消息
- 请求A追加第 11 条,写回
- 请求B追加自己的第 11 条,写回,把 A 的更新覆盖掉了
这就是经典的读改写竞态。解法我刚提了,用 Lua 脚本把所有操作塞进 Redis 服务端原子执行。这里给出核心脚本片段,极大减少网络 round trip:
-- KEYS[1]: openclaw:ctx:{session_key} -- ARGV[1]: 新消息的序列化内容 -- ARGV[2]: 窗口内最大消息条数 local ctx = cjson.decode(redis.call('GET', KEYS[1]) or '[]') table.insert(ctx, cjson.decode(ARGV[1])) if #ctx > tonumber(ARGV[2]) then ctx = {table.unpack(ctx, #ctx - tonumber(ARGV[2]) + 1)} end redis.call('SET', KEYS[1], cjson.encode(ctx)) redis.call('EXPIRE', KEYS[1], ARGV[3]) return #ctx这个脚本把"读、改、写、续期"四件事直接原子化了,线上部署后并发丢消息的问题彻底消失。我建议所有做会话管理的人都认真考虑这条路线。
6.2 会话漂移:共享存储不背锅,本地缓存才是
第二个坑是会话漂移。有一次用户反馈:同一台电脑上,一会儿助手记得前面的对话,一会儿完全不记得。我们的存储层明明已经用 Redis 共享了,为什么会漂移?
排查到最后发现,问题出在我为性能加的那层本地进程内缓存。为了让高频读取不频繁穿透 Redis,我在服务实例里放了内存映射表,记录session_key -> context。当用户请求被负载均衡路由到不同实例时,每个实例维护各自的本地缓存,就出现了"这个实例记得,那个实例不记得"。
解法不难:给本地缓存加一个极短的 TTL(比如 10 秒),读时发现本地没有或已过期,再回源 Redis;写时强制回源 Redis 并失效本地缓存。那之后,漂移问题就再没出现过。这个坑给我最大的教训是:分布式环境下,任何本地状态都必须当作"只读优化项"来看待,绝不能当成可信数据源。
6.3 清理任务误删活跃会话:双条件校验与软删除
第三个坑是定时的会话清理任务误删活跃会话。当时我写了一个定时任务:扫描 Redis 里 TTL 小于 10 分钟的会话,将其归档。听起来没问题,直到有一天用户投诉,刚才还在对话的会话突然就失忆了,而且不止一个用户遇到。
后来定位到原因:清理任务读取 TTL 之后、执行归档之前,用户恰好又发了一条新消息。新消息续期动作发生在归档之后,Redis 里刚续期的上下文快照又被归档逻辑删掉了。用户看到的结果就是"上一秒还在聊天,下一秒记录全没了"。
修复方案做了两件事:
- 双条件校验:清理前不但看 TTL,还要再 GET 一次快照,比对快照的最后修改时间,如果发现清理任务扫描之后被更新过,就放弃本次归档。
- 软删除优先:归档动作默认走
mark_archived标记,而不是真正 DELETE;确认没有活跃请求再异步真正删除。
双条件校验和软删除配合之后,误删问题基本被根绝。这个策略也延续到其他清理类任务里,成了 openclaw 所有后台任务的安全通用规范。
7. 写在最后的几句实在话
做了这一整套会话管理之后,我最深的感受是:这玩意儿没有银弹,全是在边界条件和性能损耗之间反复权衡。Redis 快照不能无限大,就得设计上下文压缩;上下文压缩做重了就拖慢热路径,就得做分级摘要;分级摘要不清不楚,就得靠 JSON 里的版本字段兜着走。每一步都是拿真实流量逼出来的。
如果要给后来者一条可复制的经验,我会说:先把状态分类想清楚,再动存储设计;先能跑通"创建->对话->续期->归档"的完整闭环,再去优化并发和缓存;上线之前,务必用真实的并发脚本打一遍上下文覆盖场景,因为这种问题在单元测试阶段几乎不可能暴露,只有真实流量才会逼它现形。
最后再分享一个小技巧:给会话模块单独加一套完整的可观测性字段,比如每次 append_message 的耗时、上下文快照的大小、TTL 续期成功率。这些指标平时不起眼,但一旦出问题,它们能帮你把定位时间从小时级压缩到分钟级。会话管理不会给你带来高光时刻,但它是整个智能体平台稳定性的地基;地基不牢,上面怎么装修都白搭。