OpenClaw 如何决定一条消息该进入哪个会话?
从一个“串台”问题开始
设想一个助手同时接入 Telegram 和 Discord。我先在 Telegram 私聊它讨论工作,再切到 Discord 问“刚才说到哪里了”。它应该记得前文吗?如果同事也能私聊这个助手,同事的消息又应该进入哪个上下文?
这两个问题表面上都与“记忆”有关,但在模型读取历史之前,系统已经做出了一次更基础的决定:这条消息属于哪个会话。选错上下文,模型再聪明也只能处理一份混在一起的历史。
OpenClaw 这一层值得研究,因为它没有用一个用户 ID 同时代表发送者、Agent 和会话。源码把“由谁处理”和“与谁共享上下文”分开回答。本文只追踪这段路由,不展开模型调用、向量记忆或沙箱实现。读完之后,应该能解释一条消息为什么进入某个会话,而不只是知道有一个 dmScope 配置。
先把四个概念拆开
channel 是接入渠道,例如 Telegram;accountId 是这个渠道内配置的账号实例,例如工作机器人与个人机器人;agentId 是被选中的 Agent;sessionKey 是后续定位会话上下文和协调执行使用的键。
这里最容易误解的是账号:它不等于发消息的人。假设两个机器人都收到用户 42 的私聊,发送者相同,接入账号却不同。另一个容易误解的地方是 Agent:一个 Agent 可以处理多个会话,切换会话不必新建 Agent。
路由返回类型除了这些字段,还携带 matchedBy、mainSessionKey 和 lastRoutePolicy。也就是说,结果包含命中原因和后续路由提示,而不是只返回一个字符串。
flowchart TD
A[Telegram 消息处理器] --> B[组装消息上下文]
B --> C[Telegram 会话路由]
C --> D[通用规则选择 Agent]
D --> E[生成基础 sessionKey]
E --> F[渠道覆盖与绑定处理]
F --> G[账号回退与线程处理]
G --> H[携带最终路由的上下文]
H --> I[后续消息派发]
这张图有意把通用规则放在中间。真实渠道可以继续修正结果,不能看到一个公共函数就把它当作整条消息路径。
沿着真实调用点走一遍
本期从 Telegram 的消息处理函数开始,而不是从一个名字像入口的文件猜起。createTelegramMessageProcessor返回实际处理消息的异步函数。它先调用 buildTelegramMessageContext;拿不到有效上下文就返回,只有拿到上下文才继续 dispatchTelegramMessage。
上下文构建读取新配置,然后调用 resolveTelegramConversationRoute。这个函数把平台事实整理为通用输入:渠道、账号、私聊或群聊 peer,以及 topic 对应的父级 peer。它通过插件 SDK 调用核心的 resolveAgentRoute,没有让核心直接依赖 Telegram 的消息对象。
接下来,resolveAgentRoute匹配配置规则,选出 Agent,再调用 buildAgentSessionKey,最终落到 buildAgentPeerSessionKey 生成会话键。输出包含 Agent、会话键和匹配原因,交回渠道层。
返回后并非万事结束。Telegram 还处理 topic Agent 覆盖、配置绑定、运行时会话绑定。上下文构建随后计算基础会话键,必要时增加私聊线程后缀,重新计算 lastRoutePolicy,把最终路由传给入站上下文组装函数。本文核验的闭合链路到“路由结果进入后续派发”为止,不把它描述成已经跑通一次真实模型回复。
第一个设计:Agent 路由是一套确定性的优先级
最朴素的实现是从头扫描一组规则,第一个符合的就生效。但这样一来,排在前面的宽泛规则可能吞掉后面的具体规则。OpenClaw 先按语义划分优先级,再在对应候选集合中寻找匹配。
该版本的 tiers顺序是:精确 peer、父级 peer、同类型通配 peer、guild 加角色、guild、team、账号、渠道;均未命中则使用默认 Agent。匹配到 peer 并不意味着忽略其他约束,规则中的 guild、team、roles 仍需要通过相应检查。角色集合的匹配是“至少一个配置角色命中”,不是要求用户拥有所有列出的角色。
一个 thread 没有专门配置时,可以继承父 channel 的 Agent;给 thread 增加更具体的绑定后,它又能脱离父级选择。上游相关测试覆盖了这两种情况。这里继承的是处理者,不意味着父子会话必须共享全部历史。
还有个细节:这个版本绑定里省略 accountId,在通用匹配层只匹配默认账号,不等于所有账号;跨账号通配需要 *。这一点有独立测试,不能凭字段“没填”就推断为不限制。
这些行为体现了规则引擎的一个取舍:显式优先级让结果更可解释,但也增加了理解成本。值得保留 matchedBy,因为“回复错了人”的排查,首先需要知道到底哪条层级规则生效。
第二个设计:会话键表达上下文共享范围
选好 Agent 后,还要决定历史放在哪个桶里。buildAgentPeerSessionKey对私聊提供四种范围。假设 Agent 为 main,渠道为 telegram,接入账号为 work,发送者 ID 为 42:
| dmScope | 生成的 sessionKey |
|---|---|
main |
agent:main:main |
per-peer |
agent:main:direct:42 |
per-channel-peer |
agent:main:telegram:direct:42 |
per-account-channel-peer |
agent:main:telegram:work:direct:42 |
每多一个维度,都在细分上下文。默认的 main 适合围绕单一拥有者维持连续对话,但接入多人时,默认共享会话就不再等于私聊隔离。per-peer 去掉渠道维度,意味着不同平台上恰好相同的原始 ID 也可能生成同一个键,系统不会自动替我们证明它们属于同一个自然人。
identityLinks 处理的是显式身份关联。把 telegram:42 和 discord:99 映射到 alice 后,两边可以先得到同一个规范身份,再生成会话键。注意顺序:它替换的是 peer 身份,不会删除配置要求保留的渠道或账号维度。使用 per-peer 时,两边可以汇聚为 agent:main:direct:alice;使用 per-channel-peer 时,渠道仍然留在键里,两边并不会因此自动共用一个会话。
这个区分可以迁移到客服、工单和协作产品:身份关联回答“是不是同一个人”,上下文范围回答“这些谈话是否应该共享”,两者不能互相替代。关联配置也不等于身份认证,平台发送者的可信性仍由入口处理保证。
群聊走另一条分支。通用键包含 Agent、渠道、peer 类型与 peer ID,没有套用私聊的 dmScope,也没有自动包含账号。与此同时,buildGroupHistoryKey生成的群历史键却包含账号。名字相近的两个键服务不同用途,不应看到“history”就把它当成 Agent 会话的同义词。
第三个设计:渠道拥有自己的最后一段语义
通用规则能复用,不代表所有平台都应该被压成同一种行为。Telegram 的 resolveTelegramConversationBaseSessionKey有一条明确规则:非默认账号的私聊,如果只落到了默认 Agent 回退,会重新按账号、渠道、peer 生成会话键。
而上下文构建对同类情况下的群聊直接返回空上下文,要求显式绑定。处理器收到空上下文后停止派发。因此,不能仅凭通用函数的默认值,就断言所有 Telegram 入站消息都会进入主会话。
我的理解是,这样把渠道特有的账号与 topic 语义留在渠道层,避免通用路由塞满平台分支。代价同样清楚:调试不能只打印通用函数的中间结果,必须观察渠道处理后的最终 sessionKey。如果平台实现继续变复杂,初始路由、覆盖原因和最终路由之间就需要更清晰的追踪信息。
会话分桶也不是完整的工具权限控制。该版本渠道路由文档说明,外部私聊即便共享主会话历史,工具与沙箱策略仍可能使用另行派生的运行时键。本文没有深入那条权限链,因此不会把“sessionKey 相同”写成“所有执行权限相同”。
这不是只靠阅读得出的结论
本次研究直接运行了固定 commit 中的会话键函数及其依赖,使用 Node 24 的 TypeScript 支持,只适配 .js 导入到对应 .ts 文件的解析,没有重写函数逻辑。15 个断言全部通过。
验证包括四种私聊范围、不同发送者共享 main、相同原始 ID 跨渠道在 per-peer 下汇聚、显式身份关联、关联后保留渠道维度、群键与群历史键的账号差异、线程后缀,以及 Signal 不透明群 ID 的大小写保留。这组验证支持文章中的键值示例;它不是上游完整测试套件,也没有模拟真实 Telegram、调用模型或验证端到端权限。
还有一个值得记录的阅读习惯:同一 commit 的文档优先级列表漏写了通配 peer,而源码的 tiers 和上游测试里已经存在这个分支。于是我把文章顺序建立在实现上,而不是直接复制文档。这个差异只对本次历史快照成立,不能据此声称最新文档仍有同样问题。
一个容易被忽略的边界输入
实验里还有一个反例:直接调用底层会话键构造函数,选择最细的 per-account-channel-peer,但把 peer ID 留空,结果仍然回退到 Agent 主会话。原因是细分分支除了判断配置,还要求有效的 peer ID。配置更严格,不意味着任何输入都能自动得到更严格的分桶。
这不等于已经发现一个外部消息绕过隔离的漏洞。上层 buildAgentSessionKey 对存在但缺少有效 ID 的 peer 还有规范化处理,渠道入口也有自己的输入约束。本次没有构造能穿过整条入口链的恶意消息,所以证据只能支持底层函数的回退行为。
它提醒我,阅读安全相关代码时必须分清三件事:局部函数允许什么、调用者保证什么、外部用户实际能控制什么。只读一层就宣布安全或不安全,都可能跳过真正决定结果的那段代码。
如果让我从零设计
第一版我会先写一个纯路由函数,输入规范化的消息来源与配置,输出 Agent、会话键和命中原因;把平台账号策略放在适配层,并用矩阵测试覆盖同人不同渠道、同渠道不同账号、群聊与线程。不必一开始就具备完整的多层缓存,但必须先把共享和隔离的语义写清楚。
规模上来后,重复扫描配置才值得优化。这个版本已经先按渠道和账号整理绑定,再建立 peer、guild 等索引,还维护有上限的结果缓存。源码中的结果缓存上限为 4000,超限时清空并保留当前结果,并非逐项 LRU。存在身份关联配置或开启详细日志时,结果缓存被绕过;绑定索引缓存与结果缓存是不同层次,不能笼统说“关闭了所有缓存”。
我会重点关注两个后续风险。其一,缓存有效性依赖若干配置对象的引用是否变化,调用方若原地改配置,需要额外验证是否破坏这个约定;这只是需要验证的风险,不是本次已经复现的故障。其二,平台层反复覆盖路由会增加解释成本,适合提供按步骤展示决策的诊断工具。优化缓存与诊断时,都不能改变用户依赖的规则优先级。
这篇源码阅读带给我的核心经验,是把三个问题分别建模:谁来处理、哪些对话共享上下文、这次执行具有什么权限。OpenClaw 的路由与会话键解决了前两个问题的重要部分,后一个问题需要继续沿工具策略和运行时边界追踪。下一篇将转向 Gateway 的请求与事件协议,看看路由确定以后,一次执行如何被客户端观察。

