ARTICLE · 1003975
一条消息如何找到它的 Agent:OpenClaw 消息绑定与路由源码拆解
摘要:两个 WhatsApp 号码、两个互相隔离的 Agent,一条消息进来该怎么"认亲"?OpenClaw 用「会话键 + 绑定」两级机制完成渠道→Agent 映射。本文从源码拆解九级路由、SQLite 运行时绑定与隔离原理。

一、引子:两个号码、两个 Agent,一条消息的"寻亲记"
先摆个场景。你跑着一个 OpenClaw 网关,挂了两个 WhatsApp 号码:一个是客服专线,一个是私人号。背后是两个完全隔离的 Agent:main 管日常,support 管订单。某天下午,两条消息几乎同时到达——
客服号:"订单 #88321 卡在支付了,帮我查一下。" 私人号:"哥们,周末爬山不?"
问题来了:网关怎么知道第一条要进 support 的脑子、第二条留在 main?更细一层,客服号上一百个人在问,谁的对话进谁的会话桶,凭什么不串台?
这就是本文要拆的事:消息绑定与路由。答案浓缩成一句话——OpenClaw 用「会话键(session key)+ 绑定(binding)」两级机制完成渠道→Agent 映射。本文基于 v2026.7.1(commit 0790d9f)源码,把这条链路从入口到出口捋一遍。
二、先认识五个关键词
- Channel
:渠道插件,WhatsApp / Telegram / Discord / Slack……每条消息都带着"我从哪个渠道来"。 - AccountId
:渠道内的账号实例。多账号 = 多登录,比如两个 WhatsApp 号码就是两个 accountId。 - AgentId
:一个"大脑",对应隔离的 workspace + session store。 - SessionKey
:会话桶的钥匙,用来存上下文、控并发。 - Binding
:映射规则, (channel, accountId, peer, guild/team) → agentId。
记住一句话:绑定是双轨制——一条是静态配置绑定(cfg.bindings[] 编译成规则,九级优先级匹配),另一条是运行时会话绑定(SQLite 持久化的记录,按会话键精确命中)。两条轨道最终在 binding-routing.ts 汇合,统一改写 sessionKey / agentId。
下面一层一层看。
三、第一层:九级路由,配置绑定怎么"按图索骥"
所有入站消息都会走到 resolveAgentRoute()(src/routing/resolve-route.ts:614)。它的活很简单: 输入归一化后的 (channel, accountId, peer, guildId, teamId, roles),输出"消息该给谁"。 但选择的过程很讲究——九级优先级,逐层下探(resolve-route.ts:735-800):
binding.peer:精确 peer 命中( :743)binding.peer.parent:线程父级( :750)binding.peer.wildcard:peer 通配( :757)binding.guild+roles:Discord 的服务器+角色( :764)binding.guild:Discord 服务器( :771)binding.team:Slack 工作区( :778)binding.account:显式账号,非 *(:785)binding.channel: accountId:"*"的渠道兜底(:792)default:实在没得选,走 resolveDefaultAgentId(:821)
代码长这样(resolve-route.ts:735-741):
命中后还会在 route 上留下 matchedBy: "binding.channel" 之类的标记——调试日志里能一眼看出这条消息是被哪层规则接走的,相当贴心。
账号匹配的"三档优先级",最容易踩坑
规则表里 accountId 的语义非常微妙(configured-binding-match.ts:20):
拆开说:规则里不写 accountId,只匹配默认账号(不匹配直接跳过);写 "*" 是通配层;写具体账号是精确层。精确层永远压过通配层,杜绝"通配误伤精确"。
两个坑请划线:
省略 accountId≠ 匹配所有账号。想要"这个渠道兜底"?请老老实实写accountId:"*"。同一层多条规则命中,取配置顺序第一个( tier.candidates.find)——没有权重概念,顺序即优先级,别指望它能智能排序。
另外,如果命中的目标带"状态ful"语义(比如 ACP 接管会话),路由会先做就绪检查,30 秒超时后返回有界失败而不是无限阻塞(binding-routing.ts:193)。宁可快速说"不行",也不吊着你。
四、第二层:运行时绑定,SQLite 里"钉"住的会话
静态配置解决的是"规则映射",但现实里有些绑定是运行时才诞生的:subagent 被指派去处理某个会话、ACP 状态ful 目标接管对话…… 这些动态记录落在 SQLite 表 current_conversation_bindings(current-conversation-bindings.ts:33),一条记录长这样(session-binding.types.ts:37-44):
绑定键:四段用 U+241F 焊死
最妙的是绑定键(current-conversation-bindings.ts:39-47):
channel ␟ accountId ␟ parentConversationId ␟ conversationId,四段用 U+241F(信息分隔符)连起来。accountId 是第二段——多账号天然隔离,两个号码的会话在键层面就不可能撞车;线程会话靠 parentConversationId 段区分,父子分得清清楚楚。
生命周期:双写、TTL、续期
- 双写
:内存 Map( bindingsByConversationKey)负责快速读,SQLite 负责落盘持久化,重启不丢。 - TTL 惰性过期
: bind时传ttlMs→ 算出expiresAt;查询命中时顺手pruneExpiredBinding清理(ttlMs未传即永不过期;ttlMs=0表示立即过期)。不扫表、不建定时任务,懒但高效。 - 路由命中自动续期
: resolveRuntimeConversationBindingRoute(binding-routing.ts:132)每次命中都touch(bindingId)——只要会话还活跃,绑定就不会过期。
还有两道防火墙:
- cron 会话隔离
:如果绑定目标是 cron run session key,直接忽略( binding-routing.ts:141-146注释原话:"cron 会话隔离且短命,绝不把实时渠道流量路由进去")。定时任务的地盘,实时消息不许进。 - 插件持有豁免
: isPluginOwnedRuntimeBindingRecord判断插件自己持有的记录,只观察不改写——交接工作由持有插件负责,路由层不越权。
适配器按 (channel, accountId) 注册,多适配器并存时 registrations.at(-1) 取最后一个生效——热替换语义,后注册者胜出。
五、隔离的落点:dmScope 四档 + agent 前缀
规则选对了 Agent,接下来要把会话"钉"进正确的桶。这个桶就是 session key,由 buildAgentPeerSessionKey()(session-key.ts:222)渲染。DM 场景下,隔离粒度有四档(dmScope):
对照着看:
main(默认)→ agent:<agentId>:<mainKey>,全渠道共享主会话,默认值就是agent:main:mainper-peer→ agent:<agentId>:direct:<peerId>,一人一个会话per-channel-peer→ agent:<agentId>:<channel>:direct:<peerId>,同一个人换渠道也分开per-account-channel-peer→ agent:<agentId>:<channel>:<accountId>:direct:<peerId>,账号都进 key——最细粒度
群/频道则是 agent:<agentId>:<channel>:group|channel:<peerId>,线程追加 :thread:<id>,Telegram 主题内嵌 :topic:<id>。
两个有意思的细节:
- identityLinks
:非 main 作用域下,跨渠道的同一人会被归一化到同一个会话——你在 WhatsApp 和 Telegram 上跟我聊,我能认出是同一个人。 - 大小写保留特例
:Matrix 房间 id(内嵌 :server)和 Signal 群组 id 大小写敏感,归一化时原样保留(issue #75670 / #82853)——"opaque 外部 ID 不能被 lowercasing 破坏"。
所以隔离的完整答案是这样拼出来的:agent:<agentId>: 前缀隔离智能体(每个 agent 还有独立的 workspace / session store,落在 ~/.openclaw/agents/<agentId>/sessions),dmScope 决定 DM 是共享主会话还是逐人/逐账号隔离,accountId 进绑定键 + 会话键天然隔离多账号。
最后留个心眼的坑:normalizeAgentId 会把非法 agentId 静默回落到 main、超长截断 64 字符——配置里写错 agentId 不会报错,消息会悄悄跑去默认大脑。排查时别忽略了这一点。
六、回复怎么回去?出站路由的 rebase 魔法
映射只解决"进来找谁",回复还得"原路回去"。出站侧在 agent-delivery.ts:255 投递前重新执行一次 resolveAgentRoute + 绑定改写; 如果 binding 的 dmScope 和全局 dmScope 不一致、但 agentId 一致,就调用 rebaseOutboundSessionRoute(route, bindingRoute.sessionKey), 把回复 rebase 回绑定作用域的会话桶——"回复路由复用绑定"就是这么实现的。
配套的还有 deriveLastRoutePolicy(resolve-route.ts:74):sessionKey === mainSessionKey 时策略是 "main",否则是 "session";回复侧据此决定要不要更新主会话的 lastRoute——DM 主会话不会被非 owner 覆盖,免得别人一聊就把你的主会话状态带歪。
事件唤醒和心跳走的是另一条线:event-session-routing.ts:138 同样调 resolveAgentRoute,把事件准确唤醒到目标 agent 的会话。
七、调用链全景:一张图看完整条链路
把前面所有零件装起来,就是这张流转图:

关键调用点再点一次名:入站信封构建 inbound-envelope.ts:60 → 回复路径 agent 判定 get-reply.ts:277 → 核心路由 resolve-route.ts:614 → 两路绑定改写 binding-routing.ts:83 / :132 → agent 受理 agent-scope.ts:300。 每一跳都职责单一,串起来就是一条清晰的流水线。
八、结语:双轨制的设计哲学 + 踩坑清单
回头看,这套系统的设计哲学其实是把"规则"和"事实"分开:静态配置绑定描述"应该怎样"(九级优先级的规则表),运行时会话绑定记录"现在怎样"(SQLite 里的既成事实),两轨在 binding-routing.ts 汇合后统一改写路由。规则层可预测、事实层可持久化,中间用 TTL、续期、防火墙这些机制兜住边界——干净、克制、好调试。
最后附上实战踩坑清单,拿走不谢:
省略 accountId只匹配默认账号;想要渠道兜底,请写accountId:"*"。同层多条命中,配置顺序即优先级,没有权重概念。 agentId 写错不会报错,会静默回落 main——排查"消息怎么去了默认大脑"先看这里。 多绑定适配器并存时后注册者胜出,热替换语义。 cron 会话是隔离区,实时渠道流量永远进不去——这是特性,不是 bug。
如果你也在做多账号、多智能体的消息系统,这套"会话键 + 双轨绑定"的模型很值得抄作业。觉得有收获的话,点个关注,下一篇继续拆 OpenClaw 的 Agent 内核。
今天的分享就到这里,如果您觉得有用的话,记得关注哦,关注后才能第一时间看到清风君的每周分享。
关注"数字化与AI之知与行"

<===> END <===>
