OpenClaw 会话模型讲清楚:消息落到哪个 session、主会话汇进来什么、状态怎么不串

2026-08-17

用 OpenClaw 一段时间后,大部分困惑都能归到同一类问题上:这条消息到底进了哪个会话?

手机上说的事换电脑还记不记得;同事在群里 @ 了它,你私聊问”刚才群里说了啥”却答不上来;定时任务做的事私聊完全不知情;把一个子会话手动接管改了几句,父 agent 还照旧假设往下干。这些看着零散,其实都是会话模型的表现。

OpenClaw 的官方文档把这件事拆成三层:消息路由(一条入站消息落到哪个 session key)、主会话(个人 agent 的默认形态,一条滚动对话)、会话状态感知(别人动了这个会话,谁该被告知、怎么补齐)。这三层的配置分散在 session.dmScopesession.groupScopesession.resetsession.maintenance 几个字段和一套 session 工具里。下面按文档把它们串一遍。整体分层(网关 / Agent / 节点 / 渠道)可以先看 OpenClaw 架构总览,这里只谈会话这一层。

先记住一句话:会话状态归网关所有

文档写得很直接:所有会话状态由 gateway 持有,UI 客户端是向网关查询会话数据的。所以你在 Control UI 看到的列表、终端里看到的上下文、编码 harness 里接上的那段对话,本质是同一份网关侧数据的不同视图,不是各自存一份。

这一点决定了后面所有排查的方向——会话不对劲,查的是网关,不是客户端。

一条消息进来,先决定落到哪个 session

文档给了一张路由默认行为表:

来源默认行为
私聊(Direct messages)默认共享同一个会话
群聊(Group chats)默认按群隔离
房间 / 频道(Rooms/channels)默认按房间隔离
定时任务(Cron jobs)每次运行一个全新会话
Webhook按 hook 隔离

这张表能解释开头那几个现象:私聊跨渠道共享,所以手机和电脑上下文一致;群聊默认隔离,私聊里问”群里说了啥”不是天然就知道;cron 每跑一次都是新会话,任务过程不会自动沉进你的私聊上下文。

主会话:默认所有私聊都收进这一条

个人 agent 的默认形态叫 main session(主会话)。文档说明它其实就是一个普通会话,默认 key 是 agent:<agentId>:main,例如 agent:main:main,最后一段可以用 session.mainKey 改。特殊的地方在于默认的 DM scope 把所有直接消息都收进它,而且系统其它部分把它当作这个 agent 的根。

按文档,会汇进主会话的有三类东西:

  • 群里的动静。 群和房间会话默认是隔离的,但在默认 DM scope 下,主会话会自动”看着”它们。这些活动会以紧凑通知的形式排队,按会话合并而不是每条消息唤醒一次;agent 下次运行时(你发下一条消息,或者一次计划内的 heartbeat)才会看到。默认的 tree 可见性下,主会话可以对同一 agent 的每个会话使用 session 工具,系统提示里会列出它在看哪些群。
  • 后台工作。 子 agent 和派生出来的会话把结果汇报回启动它们的那个会话,所以从主会话发起的活,结果回到主会话。
  • 心跳。 计划内的 heartbeat 就是打向主会话的,这也是”你没说话它也能意识到排队通知”的原因。

滚动对话受模型上下文窗口限制,连续性靠外面几层兜:MEMORY.md 会加载进每个新会话,日记 memory/YYYY-MM-DD.md 按需检索、/new/reset 后近期日记重新预热,压缩之前 agent 先把耐久事实刷进日记。记忆这几层怎么组织,见 OpenClaw 记忆架构

多个人能私聊你的 agent,默认是不隔离的

这是文档专门加了警告框的一条:默认所有私聊共用一个会话,单人使用没问题,但如果不止一个人能给你的 agent 发消息,必须开 DM 隔离——不开的话,所有人共享同一段对话上下文,Alice 的私聊内容对 Bob 是可见的。

{
  session: {
    dmScope: "per-channel-peer", // isolate by channel + sender
  },
}

session.dmScope 四个取值:

取值行为
main(默认)所有私聊共享主会话
per-peer按发送者隔离,跨渠道
per-channel-peer按渠道 + 发送者隔离(文档标注为推荐)
per-account-channel-peer按账号 + 渠道 + 发送者隔离

同一个人从多个渠道找你的 agent,文档给的办法是用 session.identityLinks 把这些身份映射到一个规范 peer id,让它们共享会话。配完用 openclaw security audit 核一遍;它检测到多个私聊发送者时也会主动建议开隔离。

要注意隔离是有连带效应的:开了隔离型 scope 之后,主会话对群的观察会被关掉,跨会话记忆召回也默认关闭。也就是说”多人可用”和”一条滚动主线什么都知道”这两件事,在默认设计里是互斥的,得自己选。多 agent、多用户下的会话切分见 OpenClaw 多用户与用户模型

群和房间:groupScope,以及按路由覆盖

session.groupScope 控制非直接 peer 把对话上下文存到哪:

取值行为
per-group(默认)每个群 / 房间 / 频道留在各自的渠道级会话里
main把群、房间、频道都路由进该 agent 的主会话

不想全局放开、只想让某一个团队房间并入主对话时,用路由绑定覆盖:

{
  bindings: [
    {
      agentId: "main",
      match: {
        channel: "slack",
        peer: { kind: "channel", id: "C0123TEAM" },
      },
      session: { groupScope: "main" },
    },
  ],
}

有的服务商把房间归类为 group,那就用 peer.kind: "group"。绑定级覆盖优先于全局 session.groupScope。文档特别强调:这个设置只改会话 key 的选择,私聊路由、@ 提及门控、投递上下文、回复回源房间这些都不变。排查时别把”上下文并入主会话”和”回复跑错地方”混为一谈,后者不归它管。

会话什么时候会滚掉:三种 reset

默认行为是不自动重置:会话一直复用,sessionId 不变,靠压缩来管理不断增长的活跃上下文。自动重置是可选的:

  • mode: "none"(默认)——不自动重置,压缩负责控住上下文。
  • mode: "daily"——在网关主机本地的某个小时开新会话,session.reset.atHour 默认 4,取值 0-23。判定依据是当前 sessionId 什么时候开始,不是后来的元数据写入。
  • mode: "idle"——空闲 session.reset.idleMinutes 分钟后开新会话。判定依据是最后一次真实的用户 / 渠道交互,heartbeat、cron、exec 这些系统事件不会续命。
  • 手动重置——聊天里打 /new/reset/new <model> 还会顺带切模型。

daily 和 idle 同时配的时候,谁先到期谁生效。全局配完还能按聊天类型或渠道覆盖:

{
  session: {
    reset: { mode: "daily", atHour: 4 },
    resetByType: {
      group: { mode: "idle", idleMinutes: 120 },
      thread: { mode: "daily", atHour: 6 },
    },
    resetByChannel: {
      discord: { mode: "idle", idleMinutes: 10080 },
    },
  },
}

resetByType 支持 directgroupthread 三种。旧配置里的 dm 条目和 session.idleMinutes 会由 doctor 迁移成 directsession.reset.idleMinutes,这两种退役写法 schema 直接拒收——升级后启动报配置错误,先往这里看。

另一条容易被忽略的行为:重置滚动会话时,老会话里排队的系统事件通知会被丢弃,免得陈旧的后台更新被塞进新会话的第一条提示。所以”重置之后群里那些通知不见了”是设计如此。压缩这条线怎么工作,见 OpenClaw 上下文压缩与会话修剪

状态存在哪、怎么被清理

文档列出的三个位置:

内容路径
运行期会话行~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite
归档的转录文件~/.openclaw/agents/<agentId>/sessions/
旧版行迁移来源~/.openclaw/agents/<agentId>/sessions/sessions.json

SQLite 里的会话行有三个时间戳,排查重置问题时务必分清:sessionStartedAt 是当前 sessionId 的开始时间,daily 重置用它;lastInteractionAt 是最后一次延长空闲寿命的用户 / 渠道交互;updatedAt 只是最后一次行变更,适合用来列表和修剪,对 daily/idle 的新鲜度判定不具权威性。老装机迁移时,网关启动和 openclaw doctor --fix 会把旧的 sessions.json 行和热转录 JSONL 历史导入 SQLite;缺 sessionStartedAt 的行从旧转录的会话头里解析。想要显式的检查证据,用 openclaw doctor --session-sqlite inspect --session-sqlite-all-agents

存储上限由 session.maintenance 管,文档给出的默认值是:

{
  session: {
    maintenance: {
      mode: "enforce", // "enforce" applies cleanup; "warn" only reports
      pruneAfter: "30d",
      maxEntries: 500,
      preserveRecent: "7d", // optional; false or omitted disables
    },
  },
}

maxEntries 统计的是每一条存活会话行。归档或置顶的会话、活跃 / 已准入的工作、锁定模型的会话、耐久的外部对话指针受保护、不会被自动驱逐,但仍然占额度。清理只从最旧的无保护行删起——所以总数长期高于上限是可能出现的正常结果,此时该做的是取消归档、取消置顶、等活跃工作跑完,或显式删掉不要的会话,而不是反复调大上限。清理可先用 openclaw sessions cleanup --dry-run 预览,立刻套用上限则是 openclaw sessions cleanup --enforce

另外,如果你曾经开过 DM 隔离又把 session.dmScope 改回 main,会留下按 peer 建 key 的陈旧私聊行,用 openclaw sessions cleanup --dry-run --fix-dm-scope 预览、同参数应用即可退役它们。

日常查看会话的几个入口:

命令显示内容
openclaw status会话存储路径与近期活动
openclaw sessions --json所有会话(可用 --active <minutes> 过滤)
聊天里 /status上下文用量、模型、各种开关
聊天里 /context list系统提示里有什么

别人动了这个会话,谁该知道

多会话协作时——管理者往下派活、有人直接跳进 worker 会话插手、两个 agent 通过 sessions_send 协调——每个会话对别人状态的假设,会在别人介入的一瞬间过期。OpenClaw 为此有一套 session state awareness,由三块组成:耐久信号日志、watcher 游标、用 session_statuschangesSince 做对账。

信号日志记在共享状态库的 session_state_events 里,事件只带元数据和一行摘要,从不带消息内容。文档给出的事件类型与是否通知 watcher:

事件记录时机是否通知
human_direct_message人类直接向被观察会话发了一轮
upstream_missing被接管会话的上游源消失
goal_changed会话目标状态被创建 / 更新 / 清除
child_spawned创建了子 agent 或 ACP 子会话否(用于播种游标)
run_completed子运行成功结束否(仅记录)
run_failed子运行失败、超时或被取消否(仅记录)
compacted会话历史被压缩否(仅记录)
adopted目录中的会话被接管进 OpenClaw否(仅记录)

会话的 state version 就是它日志里的最高序号,存在一个能扛住修剪的耐久 per-session 头里。watcher 游标有两种来源:会话派生子 agent / ACP 子会话时父方游标自动播种在子会话的派生版本上(父方不需要手动订阅);或者显式在 sessions_send 上传 watch: true,发送成功后发送方被登记为实际收到消息那个会话的 watcher,登记起点是目标当前的 state version,历史不会补发通知。

通知这块是刻意防刷屏的:一个 watcher / 目标对最多有一条待处理通知,通知文本在待处理期间逐字节稳定、系统事件队列按它去重,所以同一目标连着变二十次,watcher 的提示里也只多一行;游标在通知入队时冻结已通知位置,后续事件只推进物料水位;watcher 消费掉通知后游标前进,若期间又有新事件则只再开一条;watcher 不会因为自己造成的事件被通知。主会话的 watcher 还会被 heartbeat 立即唤醒,嵌套子 agent 的 watcher 则在下一轮拿到通知。

对账用 session_statuschangesSince: <version>,返回该版本之后的类型化事件(最多 200 条),且不推进任何游标。返回里的 historyGap: true 表示你要的版本早于保留的历史,这时应该用 sessions_historysession_status 整体刷新状态,而不是把这次响应当成精确增量。

有意留白的地方:incognito 与 v1 限制

隐身会话(incognito)只在 Control UI 新建线程时可以打开,开了之后该线程的会话条目、转录和压缩状态放在进程内存里而不落盘,网关重启即消失,也不跑自动记忆刷写、重置或删除时不产生转录归档。但文档把它的边界说得很清楚:incognito 不限制 agent 的正常工具——一次明确的”帮我记下来”,或任何工具触发的文件写入,照样能把数据持久化到隐身会话存储之外;你配的模型服务商仍然处理这些消息,诊断日志照旧,OpenClaw 仍会记录不含内容的审计元数据(例如 HMAC 引用)。多用户网关上,隐身线程只对 admin 级连接可见,也不会出现在别的会话的 agent 会话工具或转录搜索里,但这防的是存储和其它经网关的用户,防不了网关所有者或进程操作者,他们始终能观察到活跃会话。

状态感知这一层,文档自己列了一串 v1 限制,选型时值得先看清楚:通知投递假设只有一个网关进程持有共享状态库,多网关能共享耐久日志和 changesSince,但 v1 不跨进程推送通知;压缩事件只覆盖内嵌运行时的压缩方,纯原生 harness 的压缩没有完整记录;单条超过每轮 1 MiB 扫描上限的本地 Claude 或 Pi JSONL 记录会卡住该会话的游标;配对节点的 Claude 检查每轮只分类最近 50 条转录条目;目录里未被接管的会话在 v1 中不进入感知层。历史本身也有上限——保留 30 天、50000 行,而且记录是尽力而为的,追加失败只记日志、不会让原始那一轮失败,所以 stateVersion 是信号日志的头,不是事务级的变更捕获版本。

还有一类问题这套机制不负责:session_state_events 只记谁动了、动了哪类,不记内容;想跨会话找回”当时到底说了什么”,那是会话搜索和记忆召回的活。跨会话记忆召回本身还有前置条件——memory.search.rememberAcrossConversations 打开后,它做的是在同一 agent 的其它私有会话上加一步检索,并不合并转录;默认 session.groupScope: "per-group" 下,群和频道在两个方向上都保持隔离,既不作为私有召回来源,那边的回复也拿不到私有转录上下文。文档也写明,这个开关不改变会话 key、DM scope、路由、投递和 tools.sessions.visibility

真要动会话配置,建议顺序是:先用 openclaw sessions --json 看清现在有哪些 key,再决定 dmScope/groupScope,最后才碰 resetmaintenance——前两个改的是”消息去哪”,后两个改的是”东西留多久”,混在一起调很难判断是哪处生效。

延伸阅读


本文依据 OpenClaw 官方仓库(github.com/openclaw/openclawdocs/ 下的官方文档整理,核对日 2026-08-17。 我们没有安装或运行过 OpenClaw,因此不涉及界面外观、操作手感与实测耗时的任何描述; 文中的默认值、命令与配置项均为文档口径,不构成对实际运行结果的保证。 该项目迭代很快,请以仓库最新内容为准。接入即时通讯平台前,请自行确认所在平台的规则与合规要求。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。