继续用前两篇的现场,但把问题换一下。Alice 先从 Telegram 私聊 Agent,后来又从 Slack 私聊同一个 Agent;她还在一个 Discord 项目群里 @ 它,并在群里开了 thread。如果我们只按“用户显示名 Alice”找历史,会把私人对话、团队群组和 thread 全部混在一起。如果完全按渠道隔离,她换到 Slack 后又像第一次见面。

这不是 prompt 能修好的问题。模型拿到历史以前,runtime 必须先计算一条稳定、可解释、能落盘、能控制并发的状态地址。OpenClaw 把这份结果称为 route,其中最重要的字段是 sessionKey。它既是 session store 的 key,也是 embedded run 的 session lane key。

可是 route 只回答“消息属于哪儿”。当 Alice 在 Agent 跑工具时补一句“别改生成文件”,系统还得判断新消息在时间上属于当前 turn 还是下一 turn。这就是 queue mode 的职责。路由和队列分别控制空间与时间,缺一边都会让 transcript 失去因果顺序。

阅读契约。读完后你应该能区分:binding 决定目标 Agent,DM scope 决定私聊怎样折叠,identityLinks 只规范化已知同一人的 peer identity,sessionKey 是稳定地址,sessionId 是该地址下当前会话实例,transcript 是实例的历史;还应该能说清 steer、followup、collect、interrupt 对当前运行分别做了什么。

证据边界。本文固定在提交 c549250,以 resolve-route.ts、auto-reply queue 源码与官方 SessionCommand queue 文档为证。跨会话 memory 检索不改变 session key,本篇不把它当路由;第四篇再讨论。

一、route 是状态归属结果,不是一次发送地址

消息渠道里的“reply to”回答结果发回哪里,route 则回答谁拥有这次运行和历史。两者通常相关,却不能合并。Channel docking 可以把当前 direct-chat session 的回复路线移到另一个已链接渠道而不创建新 session;反过来,群组与私聊即使最终都回到同一 app,也应该保持不同 history owner。

ResolvedAgentRoute 同时返回 agentId、channel、accountId、有效 dmScope、sessionKeymainSessionKey、lastRoutePolicy 与 matchedBy。其中 matchedBy 是非常实用的诊断字段:它告诉你这次选择来自精确 peer、parent peer、wildcard、guild+roles、guild、team、account、channel 还是 default。

因此排查“路由错了”不能只打印最终 agentId。没有 matchedBy,就无法区分配置没有匹配、被更具体 binding 抢先匹配,还是 thread 继承了 parent peer 的 binding。

二、binding 先选择 Agent,session policy 再构造 key

binding 是配置层的路由规则。它可以按 channel、account、peer、guild、roles 或 team 把消息送到某个 agent。源码预先把 binding 按 channel/account 建索引,再按优先级寻找 peer、guild+roles、guild、team、account、channel 与 default。这既减少每条消息全表扫描,也把“更具体规则优先”固化为可测试顺序。

匹配得到 agentId 后,buildAgentSessionKey 才把 agent、main key、channel、account、peer kind/id、dmScope 与 identityLinks 交给 session-key builder。模型 provider 不在参数里,因为切换模型不应该把同一段对话悄悄变成新会话。

export function buildAgentSessionKey(params: {
  agentId: string;
  channel: string;
  accountId?: string | null;
  peer?: RoutePeer | null;
  dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
  identityLinks?: Record<string, string[]>;
}): string

这条边界可以迁移到任何多入口 Agent:先用 binding 选择逻辑 owner,再用会话策略确定状态地址。把 agent selection 与 session isolation 塞进一个字符串拼接函数,会让权限审计和迁移都变得困难。

三、DM scope 是隐私策略,不只是体验设置

OpenClaw 默认 dmScope: "main",让所有 DM 渠道折叠到 Agent 的 main session。这对只有一个受信用户的 personal agent 很顺滑:从 Telegram 换到 Slack 仍能接上。但在多人可私聊的 Gateway 上,这个默认会让 Alice 和 Bob 共享 transcript。

dmScope折叠维度适用与风险
main所有 DM → main session单用户连续性最好;多用户会泄露彼此上下文。
per-peer按 canonical peer,跨渠道共享需要可靠 identityLinks;同一人跨渠道连续。
per-channel-peerchannel + peer多人部署推荐,渠道身份不自动互信。
per-account-channel-peeraccount + channel + peer多账号隔离最强,连续性最少。

所以 dmScope 的选择不是“要不要记住我”这么简单,而是“哪些外部身份被允许共同读取一份私人 history”。OpenClaw 的安全审计会检查这类设置,原因就在这里。

四、identityLinks 只解决同一人,不解决同一权限

identityLinks 可以把 Telegram 的 alice 与 Slack 的 alice-work 映射到 canonical peer,从而在 per-peer 策略下共享 session。它是显式配置的身份规范化桥,不是模糊匹配,也不应该根据显示名自动合并。

链接身份也不等于链接所有会话。群组、room、channel 仍保持隔离;跨会话 rememberAcrossConversations 只是把其他私人 transcript 的相关片段作为检索材料,不会修改 key,也不会把历史合并。身份、会话和检索是三个层级。

五、thread 是路由维度,也是 binding 继承边界

thread 不能只当消息 metadata。Discord、Slack 等平台里,同一群组下不同 thread 往往对应不同任务,必须有独立 session。与此同时,thread 可能需要继承 parent peer 的 Agent binding,所以 ResolveAgentRouteInput 同时带 peer 与 parentPeer。

源码的 matchedBybinding.peer.parent 保留独立结果,正是为了让这种继承可观察。否则同一个 Agent 被选中时,你无法知道是 thread 自己有规则,还是继承了父会话。继承规则应参与诊断输出,而不能只藏在 fallback 分支里。

六、sessionKey 是地址,sessionId 是当前实例

这是整篇最重要的区分。sessionKey 是路由得到的稳定 store/concurrency address;sessionId 是这个 key 当前绑定的会话实例。用户执行 /new/reset,或 daily/idle policy 到期时,key 可以保持不变,而 sessionId 滚成新值。

为什么要两层?如果 reset 连 key 一起换,渠道 route 很难再找到“当前对话”;如果只清空 transcript 不换 sessionId,后台 followup、approval 或恢复 claim 又无法判断自己属于 reset 前还是 reset 后。稳定地址让新消息继续找到入口,实例 id 让异步结果钉在正确时代。

七、四种时间语义:steer、followup、collect、interrupt

OpenClaw 新消息到达 active run 时的四种 queue mode:steer 等当前工具完成后在下一次 LLM 前注入,followup 排到下一 turn,collect 在 quiet window 后合并,interrupt 中止当前 run 再执行最新消息;global main lane 由 maxConcurrent 控制

当 session 没有 active run,新消息可以直接开始。当已有 run 时,四种 mode 不是不同性能参数,而是不同因果语义:

  • steer:默认模式。当前 assistant turn 的工具调用先完整执行,然后 pending messages 在下一次 LLM 前注入 active runtime。steer 不会把正在运行的 tool 从中间切断;runtime 不支持 steering 时退化为 followup。
  • followup:不改变当前 run,把每条消息排成之后的独立 Agent turn。
  • collect:同样不 steer,但在 quiet window 内把同 route 的多条消息合成一个 followup;若目标 channel/thread 不同,就分开 drain,保留投递归属。
  • interrupt:abort 当前 session run,然后让最新消息开始。它适合“停下,方向错了”,而不是普通补充信息。

resolveQueueSettings 的优先级也体现了 session 语义:inline → sessionEntry 持久 override → channel config → global config → 默认 steer。用户对当前 session 的明确选择高于部署默认。

八、为什么 steer 必须等当前 tool boundary

假设 Agent 正在执行“删除旧构建产物”,用户补充“保留昨天的压缩包”。在 tool 已经开始后把一句新消息硬塞进同一次 tool call,模型并没有机会修改已经发出的参数;强行终止工具又可能留下半完成副作用。OpenClaw 因此在当前 assistant turn 完成工具调用后、下一次 LLM 前注入 steering。

这个边界保证 transcript 仍能表示真实顺序:assistant 提议工具 → tool result → 用户 steering → 下一次 assistant。模型看到补充信息的时间点与外部世界实际接受它的时间点一致。若用户真的要立即停止,就选择 interrupt,让 abort 成为显式生命周期事件。

九、session lane 与 global lane 解决不同冲突

每个 embedded run 先进入 session:<key> lane,保证同一会话最多一个 active run;随后进入 global main lane,用 agents.defaults.maxConcurrent 限制跨 session 并发。把两层合成一个全局 mutex 会让 Alice 的长任务阻塞所有人;只做全局 cap 则允许同一 transcript 的两个 writer 并发。

queue mode 决定“忙碌 session 的新消息怎么办”,lane 决定“哪些 run 可以同时执行”。它们不能互相替代。collect 仍需 session lane;maxConcurrent=1 也不自动等于 steer,因为它只让第二个 run 等待,并没有把消息注入当前 runtime。

十、排队中的 turn 也需要取消身份

followup/collect 内容还没成为 active run 时,不能只存在一个匿名数组里。Gateway 为每个 client runId 保留 cancel identity,直到内容执行、丢弃或被 overflow summary 吸收。chat.abort 带 runId 时能取消指定 queued turn;按 session abort 时则先取消获准的 queued work,再处理 active run,避免 queue drain 在停止过程中又提升一项工作。

这揭示一条经常被忽略的规则:等待执行也是一种需要授权的所有权状态。如果 queued item 没有 requester/session identity,多用户 session 就无法安全地只取消自己的请求。

十一、key 不变,session 滚动,旧异步结果必须过期

OpenClaw session 生命周期:稳定 sessionKey 下的 sessionId A 经 /new、/reset、daily 或 idle reset 变为 sessionId B,旧 transcript 归档,绑定 A 的 stale queued notice 被丢弃;incognito 单独只存进程内且无磁盘 transcript

SessionEntry 用不同时间戳服务不同策略:sessionStartedAt 标记当前 sessionId 何时开始,daily reset 看它;lastInteractionAt 只由真实 user/channel interaction 推进,idle reset 看它;updatedAt 表示 store row 最近一次变化,heartbeat、cron 或 bookkeeping 也可能更新,不能拿它延长会话寿命。

reset 时,旧 transcript 可以归档,新 sessionId 获得新 transcript。绑定旧 sessionId 的 system notice、approval followup 或 detached completion 必须检查 rebind;若 key 已经指向 B,就丢掉属于 A 的陈旧结果。只比较 key 会把上一段对话的后台结果投进新对话。

incognito 是另一种 storage mode:session row、transcript 与 compaction state 只在进程内,Gateway 重启后消失,不写磁盘 transcript。它不限制工具写文件,也不阻止 model provider 处理消息,所以“无 session 落盘”不等于“无外部副作用”或“端到端隐私模式”。

十二、transcript 是因果账本,不是任意可编辑数组

路由与队列最终保护的是 transcript 的因果顺序。用户消息、assistant tool call、tool result、steering message 和最终回答必须按发生顺序持久化;reset、compaction 和 repair 可能重写结构,但写入仍由 session owner 串行化。

因此不要让 channel plugin 各自 append 自定义 JSONL,也不要让两个运行同时把“最后一条消息”当作自己的 parent。当前 OpenClaw 把 runtime session row 与热 transcript 存入每 Agent 的 openclaw-agent.sqlite,旧 JSON/JSONL 是迁移来源或归档形态,不是新运行状态的并行真相。

十三、常见故障应该按空间、时间、实例三层排

症状先查什么不要先改什么
同一个用户“失忆”route inputs、matchedBy、dmScope、identityLinks、sessionKey。不要先加 memory 或扩大 prompt。
不同用户看到同一历史多人 DM 是否仍用 main,binding/identityLinks 是否误合并。不要靠 system prompt 要模型保密。
补充信息没有影响当前工具queue mode、tool boundary、runtime 是否接受 steer。不要假设 steer 会取消 in-flight tool。
reset 后收到旧任务结果followup 是否钉住旧 sessionId,rebind 检查是否生效。不要只按 sessionKey 投递。
所有聊天互相阻塞global maxConcurrent 与 lane 配置。不要取消 session serialization。

十四、从路由与会话带走六条规则

  1. 路由决定空间归属,队列决定时间归属。二者共同定义 transcript 因果顺序。
  2. 选择 Agent 与构造 session key 分层。binding 解决逻辑 owner,dmScope/identityLinks 解决会话隔离。
  3. 隐私必须编码进 key。模型提示不能补救错误的多人 DM 合并。
  4. 稳定地址与滚动实例分开。sessionKey 便于路由,sessionId 让异步结果过期。
  5. steer 在安全 runtime boundary 注入。补充消息不能重写已经发出的工具副作用。
  6. 排队项也有 owner。取消、drop 与 overflow summary 都要保留 requester/run identity。

下一篇进入 session 内部,拆开 workspace、bootstrap、system prompt、history、compaction 和 memory。那时会看到:路由保证“哪段历史属于你”,上下文引擎还要决定“这段历史里哪些材料在本轮真的给模型看”。

参考源码与文档