继续用前两篇的现场,但把问题换一下。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 源码与官方 Session、Command queue 文档为证。跨会话 memory 检索不改变 session key,本篇不把它当路由;第四篇再讨论。
一、route 是状态归属结果,不是一次发送地址
消息渠道里的“reply to”回答结果发回哪里,route 则回答谁拥有这次运行和历史。两者通常相关,却不能合并。Channel docking 可以把当前 direct-chat session 的回复路线移到另一个已链接渠道而不创建新 session;反过来,群组与私聊即使最终都回到同一 app,也应该保持不同 history owner。
ResolvedAgentRoute 同时返回 agentId、channel、accountId、有效 dmScope、sessionKey、mainSessionKey、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-peer | channel + peer | 多人部署推荐,渠道身份不自动互信。 |
per-account-channel-peer | account + 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。
源码的 matchedBy 为 binding.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

当 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 滚动,旧异步结果必须过期

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。 |
十四、从路由与会话带走六条规则
- 路由决定空间归属,队列决定时间归属。二者共同定义 transcript 因果顺序。
- 选择 Agent 与构造 session key 分层。binding 解决逻辑 owner,dmScope/identityLinks 解决会话隔离。
- 隐私必须编码进 key。模型提示不能补救错误的多人 DM 合并。
- 稳定地址与滚动实例分开。sessionKey 便于路由,sessionId 让异步结果过期。
- steer 在安全 runtime boundary 注入。补充消息不能重写已经发出的工具副作用。
- 排队项也有 owner。取消、drop 与 overflow summary 都要保留 requester/run identity。
下一篇进入 session 内部,拆开 workspace、bootstrap、system prompt、history、compaction 和 memory。那时会看到:路由保证“哪段历史属于你”,上下文引擎还要决定“这段历史里哪些材料在本轮真的给模型看”。
参考源码与文档
- resolve-route.ts:binding 匹配、route result、session key 构造。
- session-key.ts:Agent session key 解析、主会话与特殊 key。
- queue/settings.ts:queue mode、debounce、cap、drop 优先级。
- get-reply-run-admission.ts:active run、steer/followup 与 interrupt admission。
- Session management、Command queue、Steering queue:官方语义。
