OpenClaw 源码精读(2):Channel & Routing——一条消息如何找到它的 Agent?

系列目标:读完全系列后,你能在 OpenClaw 上做二次开发,也能从零搭建类似的系统。

本文核心问题:WhatsApp 来了一条消息,OpenClaw 怎么知道该交给哪个 Agent 的哪个会话处理?


从一个具体的麻烦说起

假设你用 OpenClaw 管理以下账号:

  • WhatsApp 个人号+1-555-personal
  • WhatsApp 商务号+1-555-business
  • Telegram 机器人 A(个人助手)
  • Telegram 机器人 B(工作助手)
  • Discord 服务器(有 #general、#dev、#ops 三个频道)

你有两个 Agent:

  • personal:处理私人事务,有访问日历、联系人的权限
  • work:处理工作事务,有访问代码仓库、服务器的权限

现在:

  • WhatsApp 个人号收到消息 → 应该交给 personal
  • WhatsApp 商务号收到消息 → 应该交给 work
  • Telegram 机器人 A 收到消息 → 应该交给 personal
  • Discord #ops 频道的消息 → 应该交给 work
  • Discord #general 频道 → 应该交给 personal
  • Discord #dev 频道来自管理员角色的消息 → 应该交给专门的 devops Agent

同一个 Discord,不同频道、不同角色的人,应该路由到不同的 Agent。

这是一个真实的路由问题。OpenClaw 是如何设计它的路由系统来解决这个问题的?


第一个问题:如何统一接入不同的消息平台?

WhatsApp、Telegram、Discord、Slack、Signal、iMessage......每个平台的 API 完全不同。如果为每个平台单独写一套接入代码,那么核心路由逻辑就会陷入「if platform == 'whatsapp': ... elif platform == 'telegram': ...」的噩梦。

OpenClaw 的解法是 Channel 插件接口ChannelPlugin)------一个统一的对接协议,每个平台实现这个协议,核心路由代码只和协议打交道。

typescript 复制代码
// src/channels/plugins/types.plugin.ts
export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
  id: ChannelId;           // 平台 ID,如 "whatsapp"、"telegram"
  meta: ChannelMeta;       // 展示信息:名称、文档路径、图标
  capabilities: ChannelCapabilities;  // 支持哪些能力:群组、投票、媒体、编辑消息...

  config: ChannelConfigAdapter;    // 必须:读取/解析平台配置
  security?: ChannelSecurityAdapter; // 可选:dmPolicy、allowFrom 等安全策略
  outbound?: ChannelOutboundAdapter; // 可选:发送消息的实现
  pairing?: ChannelPairingAdapter;   // 可选:二维码配对流程
  groups?: ChannelGroupAdapter;      // 可选:群组管理
  gateway?: ChannelGatewayAdapter;   // 可选:注册额外的 Gateway RPC 方法
  agentTools?: ChannelAgentToolFactory; // 可选:提供给 Agent 的工具(如 whatsapp-login)
  // ... 更多可选 Adapter
};

这是一种分层可选协议 的设计:核心字段(idmetacapabilitiesconfig)是必须的,其余功能按需实现。一个简单的 webhook 通道可以只实现 outbound;一个功能完整的平台可以实现全部 Adapter。

这类似于 Go 的接口组合设计------每个 Adapter 是一个小接口,插件按需组合。

插件如何注册?

typescript 复制代码
// src/channels/plugins/index.ts
export function listChannelPlugins(): ChannelPlugin[] {
  const registry = requireActivePluginRegistry();
  return registry.channels
    .map((entry) => entry.plugin)
    .sort(/* 按 meta.order 排序 */);
}

插件注册表(PluginRegistry)在 Gateway 启动时初始化,支持四个来源,优先级从高到低:

  • config:配置文件指定的插件路径
  • workspace:pnpm workspace(开发模式下 extensions/*
  • global:全局 npm 安装的插件
  • bundled:随核心内置的插件

同一个 channel ID 只保留优先级最高的版本,避免冲突。


第二个问题:消息到达后,如何唯一标识一个会话?

路由的核心任务是:给定「来自 WhatsApp 商务号、发自联系人 Alice 的消息」,找到或创建对应的 AI 会话

这个「找到」是通过 SessionKey(会话键)实现的。SessionKey 是一个字符串,唯一标识一个 AI 对话上下文。

看看测试用例,从里面读出格式规律:

csharp 复制代码
agent:main:main                                    # 最简单的主会话
agent:main:direct:+15551234567                     # 按联系人隔离的 DM 会话
agent:main:whatsapp:direct:+15551234567            # 按渠道+联系人隔离
agent:main:telegram:tasks:direct:7550356539        # 按账号+渠道+联系人隔离
agent:main:discord:channel:1468834856187203680     # Discord 某个频道的会话
agent:main:discord:group:987654321                 # Discord 群组会话
agent:main:discord:channel:c1:thread:t1            # Discord 线程会话
agent:main:cron:job-1                              # 定时任务会话
agent:main:subagent:worker                         # 子 Agent 会话

格式规律:agent:<agentId>:<rest>,其中 <rest> 可以是:

  • main:主会话
  • direct:<peerId>:私信,按发送人隔离
  • <channel>:direct:<peerId>:私信,按渠道+发送人隔离
  • <channel>:<accountId>:direct:<peerId>:按账号+渠道+发送人隔离
  • <channel>:<chatType>:<peerId>:群组/频道会话

SessionKey 就是 AI 对话的坐标系。同一个 SessionKey 意味着同一段对话历史,同一个 Agent 实例。

dmScope:控制 DM 会话的隔离粒度

默认情况下,所有私信(无论来自哪个人、哪个平台)都进入同一个主会话 agent:main:main。这对「单用户单助手」场景是合理的------你自己就是唯一的使用者。

但如果你想让 OpenClaw 同时服务多个人(家庭成员、团队成员),就需要隔离不同人的对话历史。这时候用 dmScope 配置:

typescript 复制代码
// src/config/types.base.ts
export type DmScope =
  | "main"                      // 默认:所有 DM → 同一个主会话
  | "per-peer"                  // 按发送人隔离:agent:main:direct:<peerId>
  | "per-channel-peer"          // 按渠道+发送人:agent:main:<channel>:direct:<peerId>
  | "per-account-channel-peer"  // 按账号+渠道+发送人(最细粒度)

测试用例直接说明了效果:

typescript 复制代码
// src/routing/resolve-route.test.ts
test("dmScope controls direct-message session key isolation", () => {
  // per-peer 模式
  { dmScope: "per-peer", expected: "agent:main:direct:+15551234567" },
  // per-channel-peer 模式
  { dmScope: "per-channel-peer", expected: "agent:main:whatsapp:direct:+15551234567" },
})

插曲:你在不同平台上是「同一个人」吗?

有个微妙的问题:Alice 在 Telegram 的 ID 是 111111111,在 Discord 的 ID 是 222222222222222222

如果你用 per-peerper-channel-peer 隔离了会话,那 Alice 从 Telegram 发的消息和从 Discord 发的消息会进入不同的会话。但你知道 Telegram-111111111 和 Discord-222222222222222222 是同一个 Alice。

这时候用 identityLinks 配置:

yaml 复制代码
# openclaw.yml
session:
  dmScope: "per-peer"
  identityLinks:
    alice:                    # 规范名称
      - "telegram:111111111"
      - "discord:222222222222222222"

效果:

typescript 复制代码
// src/routing/resolve-route.test.ts
test("identityLinks applies to direct-message scopes", () => {
  // Telegram 消息 → alice 的会话
  { channel: "telegram", peerId: "111111111",
    expected: "agent:main:direct:alice" },
  // Discord 消息 → 同一个 alice 的会话
  { channel: "discord", peerId: "222222222222222222",
    expected: "agent:main:discord:direct:alice" },
})

identityLinks 的本质:在生成 SessionKey 之前,把平台原始 ID 替换成规范身份名称。两条来自不同平台的消息,经过身份链接后落到同一个 SessionKey,因此使用同一段对话历史。


核心问题:七级路由优先级

现在来到最复杂的部分。前面说的 dmScopeidentityLinks 只解决了「同一个 Agent 内如何隔离会话」的问题。但我们还没解决开头的那个大问题:WhatsApp 商务号的消息怎么路由到 work Agent,而不是 personal Agent?

这是通过 Binding(绑定规则) 实现的。Binding 是一个条件-结果对:

typescript 复制代码
// src/config/types.agents.ts
export type AgentBinding = {
  agentId: string;   // 路由目标 Agent
  match: {
    channel: string;          // 必须:哪个渠道
    accountId?: string;       // 可选:哪个账号("*" = 任意账号)
    peer?: { kind: ChatType; id: string }; // 可选:特定联系人/群组/频道
    guildId?: string;         // 可选:Discord 服务器 ID
    teamId?: string;          // 可选:Slack 工作区 ID
    roles?: string[];         // 可选:Discord 角色 IDs
  };
};

多条 Binding 组成一个列表(配置文件中的 bindings: [...])。当消息到达时,resolveAgentRoute 函数按照 七级优先级 从最具体到最宽泛依次匹配:

typescript 复制代码
// src/routing/resolve-route.ts  --- tiers 数组
const tiers = [
  { matchedBy: "binding.peer",        /* 1. 精确联系人/群组/频道匹配 */ },
  { matchedBy: "binding.peer.parent", /* 2. 线程父频道继承 */ },
  { matchedBy: "binding.guild+roles", /* 3. Discord 服务器 + 角色 */ },
  { matchedBy: "binding.guild",       /* 4. Discord 服务器范围 */ },
  { matchedBy: "binding.team",        /* 5. Slack 工作区范围 */ },
  { matchedBy: "binding.account",     /* 6. 账号级匹配 */ },
  { matchedBy: "binding.channel",     /* 7. 渠道级通配(accountId="*")*/ },
];
// 以上七级都没匹配 → "default":使用默认 Agent

优先级从高到低,第一个匹配的就是结果。

用真实配置验证

回到文章开头的场景,对应的 Binding 配置是这样的:

yaml 复制代码
bindings:
  # Tier 6 - account 级:WhatsApp 账号分流
  - agentId: personal
    match:
      channel: whatsapp
      accountId: "+1-555-personal"

  - agentId: work
    match:
      channel: whatsapp
      accountId: "+1-555-business"

  # Tier 1 - peer 级:Discord #ops 频道
  - agentId: work
    match:
      channel: discord
      peer: { kind: channel, id: "1111111" }   # #ops 频道 ID

  # Tier 3 - guild+roles:管理员角色 → devops Agent
  - agentId: devops
    match:
      channel: discord
      guildId: "999999"
      roles: ["admin-role-id"]

  # Tier 4 - guild 级:Discord 服务器其他情况 → personal
  - agentId: personal
    match:
      channel: discord
      guildId: "999999"

验证规则(对应测试用例中的优先级):

消息来源 匹配 Tier 路由结果
WhatsApp 个人号任意联系人 Tier 6: binding.account personal
WhatsApp 商务号任意联系人 Tier 6: binding.account work
Discord #ops 频道 Tier 1: binding.peer work
Discord #general,管理员发消息 Tier 3: binding.guild+roles devops
Discord #general,普通成员 Tier 4: binding.guild personal

注意 Discord #ops 的例子:即使消息来自管理员,也会先匹配 Tier 1(精确频道),而不是 Tier 3(角色)。更具体的规则永远优先。

线程继承(Tier 2)

Discord 的线程是一个特殊情况。假设 #parent-channel 有 Binding 指向 agent-A,但线程本身没有单独的 Binding:

typescript 复制代码
// src/routing/resolve-route.test.ts
test("thread inherits binding from parent channel when no direct match", () => {
  const route = resolveAgentRoute({
    cfg: { bindings: [{ agentId: "adecco", match: { channel: "discord", peer: { kind: "channel", id: "parent-channel-123" } } }] },
    channel: "discord",
    peer: { kind: "channel", id: "thread-456" },    // 线程本身
    parentPeer: { kind: "channel", id: "parent-channel-123" }, // 父频道
  });
  expect(route.matchedBy).toBe("binding.peer.parent");
  expect(route.agentId).toBe("adecco");
})

线程继承了父频道的 Binding------这符合直觉:你在 #ops 下开的线程,当然还是由 work Agent 处理。


路由的内部实现:缓存 + 懒加载

resolveAgentRoute 每收到一条消息就会调用一次。如果每次都遍历所有 Binding,对高并发场景(多个群组同时活跃)可能成为瓶颈。

OpenClaw 的解法是两层缓存

typescript 复制代码
// src/routing/resolve-route.ts

// WeakMap:以 Config 对象为键,Config 不变时复用缓存
const evaluatedBindingsCacheByCfg = new WeakMap<OpenClawConfig, EvaluatedBindingsCache>();

// 内层:以 "channel\taccountId" 为键,缓存过滤后的 Binding 列表
type EvaluatedBindingsCache = {
  bindingsRef: OpenClawConfig["bindings"];
  byChannelAccount: Map<string, EvaluatedBinding[]>;
};

第一次路由时,getEvaluatedBindingsForChannelAccount 遍历所有 Binding,过滤出属于当前 channel + accountId 的子集,缓存起来。后续同渠道同账号的消息直接取缓存,不再全量遍历。

当配置变更(Config 对象引用变化)时,WeakMap 的键失效,缓存自动失效。不需要显式清除------这是 WeakMap 的自动 GC 特性,优雅地解决了缓存失效问题。


把这一切串起来

从一条 WhatsApp 消息到 AI 开始思考,完整的路径是:

ini 复制代码
1. WhatsApp 渠道收到消息
         ↓
2. 渠道调用 resolveAgentRoute(cfg, channel="whatsapp", accountId="+1-555-business", peer=...)
         ↓
3. 七级优先级匹配:找到 agentId="work", matchedBy="binding.account"
         ↓
4. buildAgentSessionKey(...) 生成 SessionKey
   (dmScope=per-peer 时 → "agent:work:direct:+15551234567")
         ↓
5. Gateway 用 SessionKey 找到(或创建)对应的 Agent 会话
         ↓
6. 消息送入 Agent,AI 开始执行
         ↓
7. AI 回复通过同一个渠道发回 WhatsApp 商务号

每一步的实现都能对应到具体文件:

  • 步骤 2-3:src/routing/resolve-route.ts:resolveAgentRoute
  • 步骤 4:src/routing/session-key.ts:buildAgentPeerSessionKey
  • 步骤 5:Gateway 的 Session Manager(第三篇文章的主题)

总结

问题 解法 关键代码
不同平台如何统一接入 ChannelPlugin 对接协议 src/channels/plugins/types.plugin.ts
如何唯一标识一个 AI 会话 SessionKey 格式体系 src/routing/session-key.ts
同一 Agent 内如何隔离多用户对话 dmScope 四种模式 src/config/types.base.ts:DmScope
同一个人在不同平台如何共享对话 identityLinks 身份链接 src/routing/session-key.ts:resolveLinkedPeerId
如何把消息路由到正确的 Agent 七级优先级 Binding src/routing/resolve-route.ts:tiers
高频路由如何高效 WeakMap + channel-account 双层缓存 src/routing/resolve-route.ts:evaluatedBindingsCacheByCfg

下一篇深入 Agent 执行引擎:

Agent 收到消息之后,AI 是如何「思考」的?工具调用、沙盒执行、流式输出,pi-embedded-runner 的执行循环是如何运转的?


源码路径:src/routing/ | src/channels/plugins/ | 核心文件:resolve-route.tssession-key.tstypes.plugin.ts

相关推荐
冬奇Lab1 小时前
一天一个开源项目(第38篇):Claude Code Telegram - 用 Telegram 远程用 Claude Code,随时随地聊项目
人工智能·开源·资讯
桦说编程2 小时前
从 ForkJoinPool 的 Compensate 看并发框架的线程补偿思想
java·后端·源码阅读
格砸3 小时前
从入门到辞职|从ChatGPT到OpenClaw,跟上智能时代的进化
前端·人工智能·后端
可观测性用观测云3 小时前
可观测性 4.0:教系统如何思考
人工智能
sunny8653 小时前
Claude Code 跨会话上下文恢复:从 8 次纠正到 0 次的工程实践
人工智能·开源·github
小笼包包仔3 小时前
OpenClaw 多Agent软件开发最佳实践指南
人工智能
smallyoung4 小时前
AgenticRAG:智能体驱动的检索增强生成
人工智能
_skyming_4 小时前
OpenCode 如何做到结果不做自动质量评估,为什么结果还不错?
人工智能
南山安4 小时前
手写 Cursor 核心原理:从 Node.js 进程到智能 Agent
人工智能·agent·设计