夜雨聆风学习资料网

ARTICLE · 1003975

一条消息如何找到它的 Agent:OpenClaw 消息绑定与路由源码拆解

一条消息如何找到它的 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):

  1. binding.peer
    :精确 peer 命中(:743)
  2. binding.peer.parent
    :线程父级(:750)
  3. binding.peer.wildcard
    :peer 通配(:757)
  4. binding.guild+roles
    :Discord 的服务器+角色(:764)
  5. binding.guild
    :Discord 服务器(:771)
  6. binding.team
    :Slack 工作区(:778)
  7. binding.account
    :显式账号,非 *(:785)
  8. binding.channel
    :accountId:"*" 的渠道兜底(:792)
  9. default
    :实在没得选,走 resolveDefaultAgentId(:821)

代码长这样(resolve-route.ts:735-741):

const tiers: Array<{ matchedBy; enabled; scopePeer; candidates; predicate }> = [  { matchedBy: "binding.peer", enabled: Boolean(peer), ... },  { matchedBy: "binding.peer.parent", ... },  // ... 依序 9 层];for (const tier of tiers) { /* 命中即 choose(agentId, matchedBy, session) */ }return choose(resolveDefaultAgentId(input.cfg), "default");

命中后还会在 route 上留下 matchedBy: "binding.channel" 之类的标记——调试日志里能一眼看出这条消息是被哪层规则接走的,相当贴心。

账号匹配的"三档优先级",最容易踩坑

规则表里 accountId 的语义非常微妙(configured-binding-match.ts:20):

// 账号匹配优先级:未写 accountId→仅默认账号(2);"*"→通配(1);显式→精确(2)// 精确层命中永远压过通配层(exactMatch ?? wildcardMatch)

拆开说:规则里不写 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):

export type SessionBindingRecord = {  bindingId: string;          // generic:<channel␟account␟parent␟conversation>  targetSessionKey: string;   // 绑定目标的 agent 会话键  targetKind: "subagent" | "session";  conversation: ConversationRef; // {channel, accountId, conversationId, parentConversationId?}  status: "active" | "ending" | "ended";  boundAt: number;  expiresAt?: number;         // ttlMs 到期时间};

绑定键:四段用 U+241F 焊死

最妙的是绑定键(current-conversation-bindings.ts:39-47):

[channel, accountId, parentConversationId ?? "", conversationId].join("\u241f")

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):

if (dmScope === "per-account-channel-peer" && peerId) {  return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:direct:${peerId}`;}if (dmScope === "per-channel-peer" && peerId) {  return `agent:${normalizeAgentId(params.agentId)}:${channel}:direct:${peerId}`;}if (dmScope === "per-peer" && peerId) {  return `agent:${normalizeAgentId(params.agentId)}:direct:${peerId}`;}return buildAgentMainSessionKey({ agentId: params.agentId, mainKey: params.mainKey });

对照着看:

  • main
    (默认)→ agent:<agentId>:<mainKey>,全渠道共享主会话,默认值就是 agent:main:main
  • per-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 的会话。


七、调用链全景:一张图看完整条链路

把前面所有零件装起来,就是这张流转图:

渠道插件(whatsapp/telegram/discord/…)  │  入站事件 + 会话上下文 {channel, accountId, peer, parentPeer, guildId, teamId, roles}  ▼inbound-envelope.ts:60 resolveInboundRouteEnvelopeBuilder ──► route(含 sessionKey)  │                                    ▲  │                                    │ runtime.routing.resolveAgentRoute(插件 SDK 透出)  ▼                                    │get-reply.ts:277 resolveSessionAgentId  │  │   sessionKey = targetSessionKey || finalized.SessionKey  │   agentId    = 显式 ?: 解析自 sessionKey ?: fallback ?: default  ▼resolve-route.ts:614 resolveAgentRoute  │  归一化 → 路由缓存 → 取该 channel+account 的绑定索引  │  ▼ 9 级 tiers(peer→parent→wildcard→guild+roles→guild→team→account→channel→default)  │  choose(agentId, matchedBy, session) → ResolvedAgentRoute {sessionKey, mainSessionKey, ...}  ▼绑定改写(双轨,两处都产出 matchedBy:&quot;binding.channel&quot; 的新 route)  ├─ binding-routing.ts:83  resolveConfiguredBindingRoute    (静态配置命中,取状态ful 目标 sessionKey)  └─ binding-routing.ts:132 resolveRuntimeConversationBindingRoute       │  resolveByConversation(channel␟account␟parent␟conversation)       │  ├─ 无记录 → 原样返回       │  ├─ cron 会话 → 忽略       │  ├─ 命中 → touch() 续期;插件持有 → 不改写       │  └─ 其余 → 改写 sessionKey/agentId  ▼agent.run 受理(agent-scope.ts:300 resolveSessionAgentIds)  ▼出站回复 agent-delivery.ts:255 resolveAgentRoute + rebaseOutboundSessionRoute → 回复回绑定目标事件/心跳 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、续期、防火墙这些机制兜住边界——干净、克制、好调试。

最后附上实战踩坑清单,拿走不谢:

  1. 省略 accountId 只匹配默认账号;想要渠道兜底,请写 accountId:"*"。
  2. 同层多条命中,配置顺序即优先级,没有权重概念。
  3. agentId 写错不会报错,会静默回落 main——排查"消息怎么去了默认大脑"先看这里。
  4. 多绑定适配器并存时后注册者胜出,热替换语义。
  5. cron 会话是隔离区,实时渠道流量永远进不去——这是特性,不是 bug。

如果你也在做多账号、多智能体的消息系统,这套"会话键 + 双轨绑定"的模型很值得抄作业。觉得有收获的话,点个关注,下一篇继续拆 OpenClaw 的 Agent 内核。


今天的分享就到这里,如果您觉得有用的话,记得关注哦,关注后才能第一时间看到清风君的每周分享。

关注"数字化与AI之知与行"

<===> END <===>

相关学习资料