系列目标:读完全系列后,你能在 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 频道来自管理员角色的消息 → 应该交给专门的
devopsAgent
同一个 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
};
这是一种分层可选协议 的设计:核心字段(id、meta、capabilities、config)是必须的,其余功能按需实现。一个简单的 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-peer 或 per-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,因此使用同一段对话历史。
核心问题:七级路由优先级
现在来到最复杂的部分。前面说的 dmScope 和 identityLinks 只解决了「同一个 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.ts、session-key.ts、types.plugin.ts