乐于分享
好东西不私藏

OpenClaw 架构:解耦与扩展

OpenClaw 架构:解耦与扩展

AI 智能体平台一旦部署到生产环境,往往会遇到严重的结构性问题。那些在笔记本电脑上运行顺畅的系统,在扩展到需要同时处理多个消息渠道、多个智能体以及大量并发用户时,可能会在运维负载下崩溃。这类失败几乎从来不是模型或提示词的问题,而是架构问题。最常见的情况是:系统在外部世界与智能体运行时之间,缺少一个单一且定义清晰的控制边界。
OpenClaw通过以 Gateway 为中心的中心辐射式架构解决这个问题。它让渠道、插件和智能体都可以独立演进,同时为安全策略执行、路由和运维控制提供一个统一入口。在部署之前理解这套架构并不是可选项:第一次配置时做出的决定,会直接决定后续系统中有多少部分能够在不重写代码的前提下被扩展、加固和调试。
Gateway 作为单一控制平面
控制平面,是指决定“什么可以发生、请求应该去哪里、谁被允许连接”的组件。它是任何分布式系统中最重要的架构接缝。在OpenClaw中,这条接缝就是 Gateway 进程。每一个入站请求,无论它来自 Telegram webhook、Discord bot 事件、WebSocket 控制 UI 连接,还是外部客户端发起的 HTTP API 调用,都会通过同一个 Gateway 边界进入系统。任何请求在到达智能体或渠道适配器之前,都必须先经过 Gateway 的认证与分发逻辑。
图 1.1 展示了渠道适配器、Gateway、PluginRegistry、智能体运行时以及共享会话状态,如何组合成一个统一的控制平面拓扑:
图 1.1:Gateway 控制平面与解耦的渠道辐条
这套设计并不是偶然形成的。当你把认证、路由和会话管理集中到单一进程中时,会获得三个在去中心化设计中很难甚至不可能实现的属性。第一,你有一个统一位置来一致地执行安全策略;不会出现某个渠道适配器绕过认证、另一个渠道适配器却执行认证的风险。第二,你有一个统一位置来保存路由状态:消息来源(渠道、账号、peer)与目标智能体之间的映射,只计算一次,并且可以在一个地方完成审计。第三,当凌晨两点出问题时,你只有一个进程需要重启、升级和观测。
这套设计也做了一个有意的取舍:因为所有请求都会流经 Gateway,如果设计不当,Gateway 就可能成为潜在瓶颈。OpenClaw的答案是尽可能让 Gateway 的请求处理路径保持轻薄。认证是快速的常量时间比较。路由则是一组针对绑定内存索引的查找,从最具体到最宽泛依次尝试,并在命中第一个匹配时停止;结果会被缓存,因此重复消息会走快速路径。Gateway 不执行推理,不管理长时间运行的工具调用,也不拥有会话状态文件系统。它会把这些职责全部委托给 Agent Runtime。第 2 章将分析完整的六阶段消息流水线,说明工作究竟被分配到哪里,以及延迟通常在哪些位置被引入。
配置网络接口
Gateway 可以通过网络访问,而少数几个配置键会精确控制它到底有多“可达”:哪些网络接口接受连接,以及调用方必须提供什么凭据。gateway.bind键控制 Gateway 监听哪些网络接口。它的取值描述了暴露范围:loopback只接受来自同一台机器的连接;lan将 Gateway 暴露给本地网络;tailnet只通过你的Tailscale私有网络暴露;auto会根据检测到的环境选择 loopback 或更宽的接口;custom则允许你指定一个显式地址。gateway.port键控制 TCP 端口,本地运行默认使用18789。在考虑任何认证之前,gateway.bindgateway.port共同定义了 Gateway 的网络暴露面。网络暴露面是指 Gateway 可被访问的接口与端口集合,这很重要,因为任何能够触达该暴露面的对象,至少都可以尝试连接并进行认证。使用bind: loopback缩小暴露面,意味着只有同主机进程才能发起尝试;因此,任何非 loopback 的绑定都必须搭配更强的认证模式。随后,gateway.auth配置块定义谁可以进入这个暴露面。
下面的openclaw.json配置块把这些键放在一起展示:也就是bind、port,以及auth 模式和 token,它们共同定义了网络暴露面以及谁可以访问它:
// openclaw.json — Gateway network and auth configuration
{
    “gateway”: {
        “mode”: “local”,
        “bind”: “loopback”,
        “port”: 18789,
        “auth”: {
            “mode”: “token”,
            “token”: “${OPENCLAW_GATEWAY_TOKEN}”,
            “rateLimit”: {
                “maxAttempts”: 5
            }
        }
    }
}
bind: loopback是单主机部署时最安全的默认值:Gateway 只接受来自同一台机器上运行进程的连接。当你需要外部客户端,比如移动端应用、CI runner 或远程控制 UI 时,可以把bind改为lantailnet,并把认证模式改为tokentrusted-proxy,但必须显式且有意识地这样做。编码在src/config/schema.help.ts中的 schema 帮助文本,会明确说明每种 bind 取值的安全契约:“除非外部客户端必须连接,否则本地安全运行时请保持loopback或auto。” 这不是泛泛而谈的建议文本;这是内置在配置 schema 中的生产姿态建议,并会作为配置 UI 中的内联字段帮助文本暴露给运维人员。
这个配置块中最重要的一行是bind: loopback。在生产环境中,如果修改这个值,却没有同时收紧auth.mode,这是OpenClaw部署被无意暴露到本地网络中的最常见方式之一。Gateway 会正常启动并提供服务,不会报错。对于“非 loopback 绑定 + 弱认证模式”的组合,系统不会给出运行时告警,因为“弱”取决于上下文,Gateway 无法知道你的网络拓扑。
gateway.mode键控制的是与bind正交的第二个轴:Gateway是运行在 local 模式下,直接管理渠道适配器和智能体运行时,还是运行在 remote 模式下,作为轻量传输中继连接到运行在其他地方的 Gateway。在标准部署模型 local 模式中,负责认证的同一个进程也拥有渠道适配器生命周期和路由逻辑。在 remote 模式中,本地只运行认证和代理逻辑,Agent Runtime 则位于另一台主机上。这种拆分是第 13 章讨论的边缘智能体部署模式的基础。
启动侧车序列
Gateway 的启动序列实现在src/gateway/server-startup.ts中,它清晰地体现了依赖顺序。侧车进程包括内部 hook、浏览器控制服务器、渠道适配器以及插件服务。它们是在 Gateway HTTP/WebSocket 监听器已经完成绑定并通过认证边界之后才启动的,而不是在此之前启动。这意味着 Gateway从不会处于“部分开放”状态:它要么呈现一个完整受认证保护的表面,要么根本不监听。startGatewaySidecars函数封装了这种顺序,以明确的顺序启动 hook handler、渠道、插件服务和 ACP 会话管理器,并为每个侧车提供错误隔离。
下面的摘录展示了这个启动序列:每个侧车都在自己的错误边界中启动,因此某个组件失败会被记录并跳过,而不是中止其余启动流程:
// src/gateway/server-startup.ts — sidecar startup with per-component isolation
if (!skipChannels) {
    try {
        await params.startChannels();
    } catch (err) {
        params.logChannels.error(
            `channel startup failed: ${String(err)}`
        );
    }
}
// Plugin services start after channels, never blocking the Gateway bind
let pluginServices: PluginServicesHandle | null = null;
try {
    pluginServices = await startPluginServices({
        registry: params.pluginRegistry,
        config: params.cfg,
        workspaceDir: params.defaultWorkspaceDir,
    });
} catch (err) {
    params.log.warn(
        `plugin services failed to start: ${String(err)}`
    );
}
请注意,每个侧车启动都被包裹在各自的try/catch中。一个连接 Telegram 失败的渠道适配器,不会阻止 Discord 适配器启动;而这两者的失败,也不会阻止 Gateway 接受控制 UI连接或提供健康检查端点。这是将 Bulkhead(舱壁)模式应用在启动层。第 3 章会更深入地讨论这个主题,但这里需要提前介绍,因为它从一开始就内嵌在 Gateway 的启动序列中。关键原则是:部分可用优于完全不可用。一个启动后成功连接了三个渠道适配器中的两个的 Gateway,比一个因为某个渠道 API 暂时不可达就拒绝启动的 Gateway 更有价值。
启动序列还会执行对长期运行部署至关重要的 housekeeping。startGatewaySidecars顶部附近的陈旧会话锁清理,会移除上一次崩溃时没有清理掉的锁文件:
// src/gateway/server-startup.ts — stale lock cleanup on startup
const SESSION_LOCK_STALE_MS = 30 * 60 * 1000; // 30 minutes
for (const sessionsDir of sessionDirs) {
    await cleanStaleLockFiles({
        sessionsDir,
        staleMs: SESSION_LOCK_STALE_MS,
        removeStale: true,
        log: {
            warn: (message) => params.log.warn(message),
        },
    });
}
30 分钟阈值是有意设置得比较保守的。只有当某个会话锁的拥有进程已经死亡至少 30 分钟时,它才会被视为陈旧。陈旧性通过 PID 存活检查检测(src/shared/pid-alive.ts中的getProcessStartTime/isPidAlive,由src/agents/session-write-lock.ts使用),因此,即便进程是被强制杀死的,只要拥有锁的进程已经消失,该锁也会被回收。这可以防止新 Gateway 实例在滚动重启场景中意外抢走仍在运行的同级实例所拥有的会话。实践中,如果你在执行零停机重启,旧实例应当在新实例获取这些锁之前终止所有正在进行中的智能体会话。第 11 章讨论的平台即服务关闭 hook,正是为了保证这一属性而设计的。
图 1.2展示了OpenClaw的高层架构,说明多个消息平台(Telegram、Discord、Slack、WhatsApp 和 Signal)如何通过渠道适配器连接到 OpenClaw Gateway:
图 1.2:OpenClaw Gateway作为中心辐射式拓扑
收到请求后,Gateway 会应用认证流水线和路由层,解析目标 Agent Runtime 会话,执行策略并相应地分发 payload。插件服务与 hook handler 作为 Gateway管理的侧车进程执行,把前处理和后处理逻辑注入请求生命周期,而不与核心分发机制耦合。AgentRuntime 会实例化并管理有状态的智能体会话,并将会话上下文持久化到耐久的共享存储中。这个挂载到所有 Gateway 实例上的共享卷,之后将支持 Gateway 层的无状态水平扩展。后文“水平扩展拓扑”一节会完整展开这个主题。
Gateway 是OpenClaw的单一控制平面:它是拥有入站连接、认证、路由和会话管理的那个进程。把这些职责集中在一个边界上,正是其余架构获得运维清晰度的原因:你只需要在一个地方推理“谁连接了进来、他可以做什么、他的请求会去哪里”。定义好这个边界之后,下一节将把视角转向外部,看各个消息渠道如何以解耦辐条的方式连接到它。
作为解耦辐条的渠道适配器
既然 Gateway 已经被确立为中心,那么辐条的设计同样重要。OpenClaw中的每个消息渠道都是一个渠道适配器,它是一个自包含插件,懂得如何与某个特定消息服务的协议对话,如何把该服务的事件转换成OpenClaw的内部消息格式,以及如何通过同一个服务把出站响应送回去。渠道适配器彼此之间没有直接依赖,也不直接依赖智能体运行时。它们只与 Gateway 对话。这意味着,如果 Slack API 发生故障,Telegram 适配器仍然会正常运行。如果你给部署添加一个新的 Matrix 扩展,它也不会影响任何其他渠道的路由或会话状态。
src/channels/registry.ts中的渠道注册表定义了内置渠道的规范列表,并清晰展示了解耦原则。九个核心渠道——Telegram、WhatsApp、Discord、IRC、Google Chat、Slack、Signal、iMessage 和 LINE——被列在一个常量元组中。每个条目携带元数据(显示标签、文档路径、系统镜像名称),但不携带实现。具体实现分别位于extensions/telegram/extensions/discord/extensions/slack/等目录中,它们作为独立的 workspace package,由插件系统加载。
下面的清单展示了渠道注册表:一个命名内置渠道的规范排序元组,以及normalizeAnyChannelId解析器。后者会通过查询实时插件注册表,而不是硬编码列表,把任意原始渠道字符串映射为已注册 ID:
// src/channels/registry.ts — canonical channel ordering and metadata
export const CHAT_CHANNEL_ORDER = [
    // registry.ts: add core channels here (order + meta + aliases),
    // then register the plugin and keep protocol IDs in sync
    “telegram”,
    “whatsapp”,
    “discord”,
    “irc”,
    “googlechat”,
    “slack”,
    “signal”,
    “imessage”,
    “line”,
] as const;
export type ChatChannelId =
    (typeof CHAT_CHANNEL_ORDER)[number];
下面的解析器会把任何原始渠道字符串转换为规范 ID。它通过查询活动插件注册表来完成,因此由扩展提供的渠道无需编辑核心代码即可被解析。
// Channel resolution honors registered extensions via the plugin registry
export function normalizeAnyChannelId(
    raw?: string | null,
): ChannelId | null {
    const key = normalizeChannelKey(raw);
    const registry = requireActivePluginRegistry();
    const hit = registry.channels.find((entry) => {
        const id = String(entry.plugin.id ?? “”)
            .trim()
            .toLowerCase();
        return (
            id === key ||
            (entry.plugin.meta.aliases ?? []).some(
                (alias) =>
                    alias.trim().toLowerCase() === key,
            )
        );
    });
    return hit?.plugin.id ?? null;
}
在前面的代码中,normalizeAnyChannelId内部调用requireActivePluginRegistry()是关键设计决策:渠道解析路径没有硬编码任何渠道实现。它查询的是实时插件注册表,这意味着新的渠道,例如extensions/zalo/中的 Zalo 扩展,可以在不修改src/channels/registry.ts中任何一行代码的情况下被添加和解析。注册表是核心与辐条之间唯一的接缝。
ChannelPlugin 接口
一个渠道适配器需要满足ChannelPlugin接口。这个接口定义了一个小而精确的契约。它通过id(规范字符串标识符)和meta(显示名称、别名、文档路径)声明自身身份;通过configsetup解析并验证自身配置;通过状态适配器报告连通性,其中probeAccount会在使用前测试账号;通过出站适配器发送出站消息,这个适配器是一个带有投递模式和分块规则的ChannelOutboundAdapter。它没有顶层的 start/stop 生命周期方法。这个接口被有意保持得很小。适配器不需要知道会话管理、路由或 hook 执行;这些职责属于 Gateway。
例如,Telegram 渠道适配器会导出一个满足该接口的单一常量。
下面是 Telegram 适配器的导出,一个单一的类型化常量。其中id字段就是注册表和路由器用于匹配的规范渠道名称:
// extensions/telegram/src/channel.ts — plugin export (excerpt)
export const telegramPlugin: ChannelPlugin<
    ResolvedTelegramAccount,
    TelegramProbe
> = {
    id: “telegram”,
    // meta, config, setup, outbound, status,
    // and other adapters are defined on this object
};
这种结构中最重要的运维属性是id: “telegram”。它是路由层、配置 schema 和 allowlist 引擎用来引用该适配器的规范标识符。对于已部署的扩展来说,这个 ID 实际上是不可变的:如果你 fork 了一个渠道适配器并修改它的id,就必须更新openclaw.json中所有引用原 ID的绑定。当你开始对渠道适配器做蓝绿部署时,这一点会变得非常重要(第 10 章会详细讨论)。
别名与发现路径
normalizeAnyChannelId中的别名机制远不只是一个方便功能。它允许同一个物理适配器通过多个逻辑名称被访问。例如,iMessage 适配器以imessage作为规范 ID,但接受imsg作为别名;IRC 适配器除了irc之外,还接受internet-relay-chat。这意味着,当适配器更新其规范 ID 时,只要旧标识符被注册为别名,使用旧标识符写成的老绑定配置仍然可以继续工作。在生产环境中,新配置应始终使用规范 ID;但在审计部署中的所有绑定配置之前,不应从现有适配器中移除别名。
除了九个核心渠道之外,extensions/目录还包含Microsoft Teams(extensions/msteams/)、Matrix(extensions/matrix/)、Zalo(extensions/zalo/)、飞书(extensions/feishu/)、Twitch(extensions/twitch/)、Nostr(extensions/nostr/)、NextcloudTalk(extensions/nextcloud-talk/)等适配器。它们都遵循同样的ChannelPlugin接口形状。因此,运行时可用渠道列表不是核心代码库的属性,而是“哪些插件被加载”的属性,而这完全由你的openclaw.json配置决定。这是一个有意的选择:它可以保持核心二进制文件小巧,并避免为你没有使用的渠道加载凭据、TLS 上下文和网络连接。
每个消息渠道都被实现为一个隔离适配器,连接到 Gateway,但不与其他渠道或智能体运行时耦合。因此,一个渠道中的故障或变更不会涟漪式影响其他渠道。正是这种隔离,让OpenClaw能够支持十几个平台,同时让每个平台都可以独立部署、独立调试。
理解了渠道如何接入之后,下一节将转向让这种扩展能力普遍成立的机制:插件注册表。
不修改核心代码的插件扩展能力
渠道适配器只是OpenClaw更广泛插件模型的一个实例。PluginRegistry是 Gateway 用来理解运行时可用能力的核心数据结构。它不仅承载渠道注册,还承载工具、hook、HTTP 路由、CLI 命令、Gateway 方法处理器和长期运行服务;实际上,它承载了 OpenClaw暴露给外部世界的每一个扩展点。设计目标是:你应该能够添加任何新能力——一个新工具、一条新路由、一个新的智能体 hook、一个新渠道——而不需要编辑核心src/目录下的任何文件。插件模型让这一点成为可能。
src/plugins/registry.ts中的PluginRegistry类型把完整的扩展表面显式呈现出来。下面的代码片段完整展示了这个类型——每一个扩展点对应一个具名列表,插件可以彼此独立地向这些列表追加内容:
// src/plugins/registry.ts — full PluginRegistry surface
export type PluginRegistry = {
    plugins: PluginRecord[];
    tools: PluginToolRegistration[];
    hooks: PluginHookRegistration[];
    typedHooks: TypedPluginHookRegistration[];
    channels: PluginChannelRegistration[];
    providers: PluginProviderRegistration[];
    gatewayHandlers: GatewayRequestHandlers;
    httpRoutes: PluginHttpRouteRegistration[];
    cliRegistrars: PluginCliRegistration[];
    services: PluginServiceRegistration[];
    commands: PluginCommandRegistration[];
    diagnostics: PluginDiagnostic[];
};
这个类型中的每个字段,都是插件可以追加内容的列表或映射,而且不需要触碰任何其他插件的状态。gatewayHandlers映射值得特别注意:它是一个从方法名到处理函数的字典,注册表会强制保证不会有两个插件注册同一个方法名。如果某个插件试图为一个已经被核心或先前加载的插件占用的方法注册 handler,注册表会在diagnostics字段中记录一个错误,并静默丢弃这次注册。这个故障安全机制可以防止冲突的 Gateway 方法注册在请求时造成不可预测的分发行为。
diagnostics字段对生产环境尤其重要:它会累积插件加载过程中的 warning 和 error,让注册失败在启动阶段就可见,而不是等到某个缺失能力被调用时才暴露出来。一个注册了未知 hook 名称的 typed hook 的插件,会得到一个level: “warn”的诊断,而不是运行时异常。一个尝试注册 HTTP 路由且其路径与不同认证级别的现有路由重叠的插件,会得到一个level: “error”的诊断。在这两种情况下,失败都会被记录,但插件加载过程会继续。部分注册模型保证一个坏插件不会阻止其余插件启动。你的生产监控流水线应当把任何level: “error”诊断当成阻塞级告警处理。
插件API 与 hook 策略执行
插件 API 由src/plugins/registry.ts中的createApi()返回,它是插件声明自身贡献内容的表面。关键方法包括registerToolregisterHookregisterHttpRouteregisterChannelregisterProviderregisterGatewayMethodregisterCliregisterServiceregisterCommand,以及 typed hook 的快捷方法on。这些方法都会写入共享注册表。调用约定是:typed hook 使用api.on(“message_received”, handler),渠道适配器使用api.registerChannel(plugin)
下面的对象是createApi返回给每个插件的内容。它上面的每个 register 方法都会写入同一个共享注册表,因此这就是插件用来声明工具、hook、路由、渠道和方法的完整表面:
// src/plugins/registry.ts — plugin API surface (createApi excerpt)
const createApi = (record, params) => ({
    id: record.id,
    config: params.config,
    runtime: registryParams.runtime,
    logger: normalizeLogger(
        registryParams.logger,
    ),
    registerTool: (tool, opts) =>
        registerTool(record, tool, opts),
    registerHook: (events, handler, opts) =>
        registerHook(record, events, handler, opts, params.config),
    registerHttpRoute: (params) => registerHttpRoute(record, params),
    registerChannel: (registration) => registerChannel(record,registration),
    registerGatewayMethod: (method, handler) =>
        registerGatewayMethod(record, method, handler),
    on: (hookName, handler, opts) =>
        registerTypedHook(
            record,
            hookName,
            handler,
            opts,
            params.hookPolicy,
        ),
});
传入createApihookPolicy参数携带allowPromptInjection标志。注册表会在允许插件注册before_prompt_buildbefore_agent_starthook 之前查看该标志。如果配置中的plugins.entries.<id>.hooks.allowPromptInjection为 false,注册表要么完全阻止 hook 注册,要么从 hook返回值中静默剥离提示词变更字段。这是插件模型与安全发生交叉的第一个位置:向智能体提示词注入内容的能力,由每个插件的配置标志治理,而不是由插件自身代码决定。第 5 章会把这个话题发展成完整的基于策略的访问控制讨论。不过,这里的架构原则值得明确指出:安全策略是在注册时执行的,而不是在调用时执行的。这意味着恶意插件不能通过延迟注册或使用动态模块加载来绕过提示词注入限制。
全局注册表单例
插件注册表的运行时单例在src/plugins/runtime.ts中管理。它使用一个globalThis符号来存储单一的RegistryState对象,确保在一个运行中进程的所有模块边界之间共享同一个注册表实例。setActivePluginRegistry函数由loadOpenClawPlugins在注册表完全构建之后调用。requireActivePluginRegistry是渠道解析代码使用的安全访问器;如果还没有注册表,它会初始化一个空注册表,从而避免测试设置期间出现空引用错误。
下面的代码展示了这个单一实例如何被锚定:注册表状态被存储在一个进程全局的 Symbol 下,而requireActivePluginRegistry会在首次访问时惰性创建一个空注册表,因此调用方永远不会碰到未初始化状态:
// src/plugins/runtime.ts — global registry state via Symbol
const REGISTRY_STATE = Symbol.for(“openclaw.pluginRegistryState”);
export function requireActivePluginRegistry():
    PluginRegistry {
    if (!state.registry) {
        state.registry = createEmptyPluginRegistry();
        state.version += 1;
    }
    return state.registry;
}
这里使用Symbol.for(“openclaw.pluginRegistryState”),而不是普通的模块级变量,是有意且对生产环境很关键的。在某些环境中,OpenClaw 包可能会被加载多份,例如某个插件捆绑了自己的 SDK 副本,或者 Jest / Vitest 在不同上下文中加载测试模块。Symbol.for能确保所有副本共享同一个全局状态对象,避免“分裂注册表”类 bug,也就是系统的一部分看到的 hook 与另一部分看到的 hook 不一致。版本计数器会由setActivePluginRegistryrequireActivePluginRegistry递增,让那些缓存注册表引用的消费者能够检测到注册表已被重建,例如配置重载之后。
总的来说,PluginRegistry让工具、hook、HTTP 路由、Gateway 方法、CLI 命令和渠道适配器都能在加载时完成注册,而不需要修改src/下的任何内容。把扩展留在核心之外,才允许团队在不 fork代码库、不破坏控制平面的情况下添加能力。掌握了注册模型之后,下一节将跟踪消息进入 Gateway 之后会发生什么:它如何被路由到正确的智能体和会话。
智能体到智能体的路由模式
路由是 Gateway 控制平面角色最具体的体现。当一条消息来自某个 Discord guild 中的某个 Discord 频道时,Gateway 必须决定哪个智能体应该处理它,以及该智能体应该恢复哪个会话。这个映射在多智能体部署中并不简单:你可能希望不同 Discord 服务器中的消息由不同智能体响应;可能希望 DM 与群组频道使用不同智能体;也可能希望基于角色路由,把拥有特定角色 ID 的 Discord成员导向某个专家智能体。OpenClaw的路由引擎通过单一的声明式绑定配置处理所有这些情况。
路由逻辑完全位于src/routing/resolve-route.ts中。其核心函数resolveAgentRoute实现了一个七层路由瀑布,这是一种按照顺序尝试一组从更具体到更宽泛的匹配谓词、并让第一个匹配胜出的模式。这七层从最具体到最宽泛依次是:peer 精确匹配、线程父级 peer 匹配、带角色的 guild 匹配、guild 匹配、team 匹配、account 匹配、channel-wide匹配。如果没有任何配置绑定匹配,则选择默认智能体。
下面是这个瀑布的压缩形式:每一层都会测试一个越来越不具体的绑定,第一条命中结果获胜,从而保持路由确定且易于追踪。
// src/routing/resolve-route.ts — seven-tier routing waterfall (condensed)
const tiers = [
    {
        matchedBy: “binding.peer”,
        // the shipped union also covers
        // binding.peer.parent,
        // binding.guild+roles,
        // binding.team,
        // binding.account,
        // and a “default” fallback
        // (resolve-route.ts:50-58)
        enabled: Boolean(peer),
        candidates: collectPeerIndexedBindings(bindingsIndex, peer),
    },
    {
        matchedBy: “binding.guild+roles”,
        enabled: Boolean(guildId && memberRoleIds.length > 0),
        candidates: guildId ? (bindingsIndex.byGuildWithRoles.get(guildId) ?? [])
            : [],
    },
    {
        matchedBy: “binding.guild”,
        enabled: Boolean(guildId),
        candidates: guildId ? (bindingsIndex.byGuild.get(guildId,) ?? []): [],
    },
];
最后两层回退到 account-wide 和 channel-wide 绑定。它们始终启用,因此每条消息在进入配置默认值之前都有一个兜底路径。
{
    matchedBy: “binding.account”,
    enabled: true,
    candidates:
        bindingsIndex.byAccount,
},
{
    matchedBy: “binding.channel”,
    enabled: true,
    candidates:
        bindingsIndex.byChannel,
},
];
随后循环会按照优先级顺序遍历这些层,返回第一个启用且其候选绑定与消息作用域匹配的层。
for (const tier of tiers) {
    const matched = tier.enabled && tier.candidates.find((c) =>
        tier.predicate(c) && matchesBindingScope( c.match, scope),
    );
    if (matched) {
        return choose(matched.binding.agentId, tier.matchedBy);
    }
}
return choose(resolveDefaultAgentId(input.cfg), “default”);
在前面的代码中,ResolvedAgentRoute上的matchedBy字段会告诉你到底是哪一层产生了匹配。在生产环境中,为每条入站消息记录这个字段,对于诊断路由意外非常有价值。比如,你预期某条消息命中binding.guild,但实际命中的是binding.channel,这意味着某个绑定配置缺失或写错了。源文件中的实际实现会在通过–verbose标志启用详细日志时输出[routing] match: matchedBy=<tier> agentId=<id>(当文件日志级别设置为 debug 时也会出现同样 trace),让你无需修改任何代码,就能得到每条消息的完整路由轨迹。
会话键与 DM 作用域
路由还会产生一个sessionKey和一个mainSessionKey。会话键是智能体会话状态存储时使用的唯一标识符。它会把智能体 ID、渠道、账号 ID 和 peer 身份编码成一个稳定的小写字符串。mainSessionKey是一种简化形式,用于“直接消息折叠”:来自同一账号的多个 peer 身份应该共享同一条对话线程,而不是为每个 DM 创建单独会话。
lastRoutePolicy字段取值为“main”“session”,用于控制最后路由更新写入哪个键。如果sessionKey等于mainSessionKey(当会话没有 peer 组件时会发生,也就是主会话),lastRoutePolicy“main”。否则,它是“session”。这个区别在实践中很重要:如果你在部署后把某个智能体的dmScope设置从“main”改为“per-peer”,那么路由层为新消息计算出来的会话键,会不同于既有对话状态已经存储使用的会话键,导致这些对话看起来像是从头开始。会话键迁移不是自动完成的,必须显式处理。这也是为什么dmScope被视为部署期决策,而不是运行时配置。
通过 ACP 进行智能体到智能体分发
路由瀑布描述的是 Gateway 如何把入站渠道消息分发给智能体。当一个智能体需要把子任务委派给另一个智能体时,会使用另一条路由路径。OpenClaw通过 Agent Client Protocol(ACP)会话实现这一点,这些会话由src/acp/control-plane/manager.ts中的AcpSessionManager管理。当编排型智能体派生一个子智能体时,ACP 会话管理器会分配一个新的会话身份。这个身份既独立于编排者的渠道会话,也独立于子智能体自身可能拥有的渠道会话。这种隔离意味着子智能体的对话不会出现在面向用户的渠道历史中,同时子智能体可以被替换或升级,而不会破坏编排者的会话连续性。
src/gateway/server-startup.tsstartGatewaySidecars末尾可见的ACP 会话启动协调路径,会处理 Gateway 在一个活跃的智能体到智能体对话中间重启的情况。reconcilePendingSessionIdentities调用会检查那些身份已经在上一个 Gateway 实例终止前提交到磁盘的 ACP会话,并把它们重新链接到新的运行时。这套机制确保生产环境中的长期多智能体工作流能够跨 Gateway 重启存活。它的失败模式(记录为 warning 而不是 error)值得被监控。
src/routing/resolve-route.ts中的七层路由瀑布,会跨 peer、guild 和角色级分发,把入站消息映射到特定智能体和会话。确定性路由让多智能体行为变得可预测、可审计,而不是不可控地“涌现”。路由确定之后,下一节将退后一步看运维问题:Gateway 如何在保留会话状态的同时实现水平扩展。
水平扩展拓扑
关于OpenClaw,一个常见误解是它天然只能单实例运行,也就是说 Gateway 是一个无法复制的有状态单例。现实要更细致一些。在设计生产扩展策略之前,理解真正的约束非常关键。
Gateway 进程本身在请求处理方面基本是无状态的。认证基于 token,不需要 Gateway 实例之间共享会话状态。路由逻辑读取配置对象,该对象在启动时从磁盘加载,并且可以通过配置变更事件重新加载。渠道适配器会维护自己与上游消息 API 的长轮询或 WebSocket 连接,但这些连接是按实例存在的;两个 Gateway 实例可以分别服务两组渠道而不冲突,前提是每个消息服务账号在同一时间只连接到一个 Gateway 实例。这通常是大多数消息 API 平台自身的要求,而不是OpenClaw的限制。
真正有状态的是智能体会话数据:对话历史、正在进行的工具调用,以及存储在OPENCLAW_STATE_DIR下的记忆 embedding。在单主机部署中,它是一个本地目录。在多实例部署中,它必须是一个共享持久卷。render.yaml中的Render部署配置直接展示了这种模式:
# render.yaml — stateful disk mount for multi-instance session sharing
envVars:
– key: OPENCLAW_STATE_DIR
value: /data/.openclaw
– key: OPENCLAW_WORKSPACE_DIR
value: /data/workspace
– key: OPENCLAW_GATEWAY_TOKEN
generateValue: true
disk:
name: openclaw-data
mountPath: /data
sizeGB: 1
OPENCLAW_GATEWAY_TOKEN上的generateValue: true意味着平台会在首次部署时生成一个加密随机 token,并且永远不会把它写入你的源代码仓库。这是正确的生产姿态:这个 token 足够稳定,不会在滚动部署期间过期;同时又由平台托管,因此不会被意外提交到源码控制系统,也不会被误分享给不应该拥有 Gateway访问权限的同事。挂载在/data的磁盘提供了共享状态目录,多个 Gateway 副本都可以读取和写入它。
多实例会话一致性
对于双实例部署,拓扑是这样的:两个实例都把同一个持久卷挂载到/data,都从同一个OPENCLAW_STATE_DIR读取状态,负载均衡器在它们之间分发入站 HTTP 流量。实例 A 写入的会话状态,在下一次请求时对实例 B 可见,因为它们共享文件系统。src/agents/session-write-lock.ts中的写锁机制确保两个实例不会同时处理同一个会话的消息;一个实例会拿到文件系统锁,另一个实例则会等待,或根据配置的锁超时时间返回背压响应。
这套架构让共享卷成为 Gateway 实例之间唯一的协调点。对于OpenClaw面向的工作负载来说,这是一种简单且有效的设计;对话型 AI 智能体通常具有每个会话低请求频率(消息间隔为数秒到数分钟)以及每次请求高处理时间(LLM 推理通常需要数秒到数十秒)的特点。在这种访问模式下,网络卷上的文件系统锁已经是完全足够的协调机制。该方案无法扩展到高频交易系统或实时游戏服务器,但对于对话型智能体来说,它避免了 Redis 或etcd这类分布式协调服务带来的运维复杂度。
图 1.3 展示了一个在两个 Gateway 实例之间分发流量的负载均衡器:
图 1.3:水平 Gateway 拓扑
两个实例都把同一个持久磁盘卷挂载到/data,用于共享会话状态。每个实例运行自己的渠道适配器连接集合。src/agents/session-write-lock.ts中的写锁层,会防止对同一个会话文件的并发写入。
绑定模式与反向代理集成
Gateway 的 bind 模式会以一种容易被忽略的方式影响水平扩展。在容器化部署中,如果负载均衡器负责终止 TLS,并把流量转发到私有端口,你需要设置bind:lan(或者允许容器通过PORT=8080绑定到0.0.0.0),并把 Gateway 的auth.mode配置为trusted-proxy。这会告诉 Gateway:从上游代理注入的 header,也就是gateway.auth.trustedProxy.userHeader配置键中指定的 header 里提取用户身份,而不是从 bearer token 中提取。
trusted-proxy认证模式由两道安全检查保护。第一,Gateway 会在信任任何身份 header 之前,验证请求确实来自已知代理 IP(gateway.trustedProxies列表)。第二,gateway.auth.trustedProxy.requiredHeaders列表允许你指定代理必须包含的额外 header(例如 HMAC 签名 header),作为真实性证据。如果任一检查失败,请求都会被拒绝。来自不可信代理 IP 的请求会以{ ok: false, reason: “trusted_proxy_untrusted_source” }失败;缺少必需 header 的请求会以 header 特定原因失败,其形式为trusted_proxy_missing_header_<name>。这两种情况都很容易在日志中检测到。第 10 章会把完整的 trusted-proxy 模式与面向公网 Gateway 部署的限流和版本策略一起讨论。
退一步看,一个无状态 Gateway 进程、保存在共享持久磁盘上的会话状态,以及基于环境变量的凭据模型,共同让多个 Gateway 实例能够在 Render 这类平台上运行在负载均衡器之后。知道哪些部分是无状态的、哪些部分必须共享,是干净扩展的部署与在负载下破坏会话的部署之间的分水岭。扩展拓扑确立之后,最后一个架构小节将讨论凭据如何安全地跨越 Gateway 边界。
Gateway 边界上的令牌交换
理解网络拓扑之后,就可以在同一个边界上讨论安全架构了。OpenClaw部署中最重要的安全模式是 Token Exchange(令牌交换):在 Gateway 边界上,用长期设备凭据(OPENCLAW_GATEWAY_TOKEN环境变量或gateway.auth.token配置值)换取一个短生命周期、作用域受限的会话 token,供智能体运行时在后续操作中使用。需要注意的是,这是一种凭据作用域收缩模式,而不是 RFC 8693 中定义的 OAuth token exchange:这里不会铸造下游 token,因此“Gateway 凭据边界”能更精确地描述这一机制。长期凭据永远不会越过 Gateway;智能体运行时收到的是一个权限已经解析完成的上下文对象。
这种分离很重要,因为长期凭据与短生命周期会话 token 的攻击面有很大差异。长期凭据如果被截获,在手动轮换之前会授予无限期访问权限。短生命周期会话 token 如果被截获,只会在当前会话持续期间授予访问权限,且不能用来创建新会话。OpenClaw的 Gateway 在架构上强制执行这种分离:gateway.auth.token只在连接建立时被消费一次,随后流入系统深处的是GatewayAuthResult,它记录认证方法以及可选的用户身份。
authorizeGatewayConnect函数
Gateway 边界上的 Token Exchange 实现在src/gateway/auth.ts中。authorizeGatewayConnect函数是中心执行点。它接收一个ResolvedGatewayAuth结构(启动时从配置和环境中解析得到)以及一个ConnectAuth对象(由连接客户端提供),并返回一个GatewayAuthResult,其中记录认证方法、解析出的用户身份以及是否触发了限流。
下面的摘录展示了该函数的 token 分支:如果客户端没有提供 token,就拒绝;如果提供了 token,就用常量时间比较进行校验;如果不匹配,则通过限流器记录失败;否则返回一个带方法标记的成功结果。(完整源代码会区分两种缺失 token 的情况:服务端gateway.auth.token未设置时是token_missing_config,客户端省略 token 时是token_missing;下面的摘录只展示客户端省略分支。)
// src/gateway/auth.ts — credential verification with rate-limit enforcement
export async function authorizeGatewayConnect(
    params: AuthorizeGatewayConnectParams,
): Promise<GatewayAuthResult> {
    // GatewayAuthResult = {
    //   ok,
    //   method,
    //   user,
    //   reason,
    //   rateLimited,
    //   retryAfterMs
    // };
    // method is:
    // none | token | password |
    // tailscale | device-token |
    // trusted-proxy
    // (auth.ts:40-49)
    const { auth, connectAuth } = params;
    if (auth.mode === “token”) {
        if (!connectAuth?.token) {return {ok: false, reason: “token_missing”};}
        if (!safeEqualSecret(connectAuth.token, auth.token)) {
            // safeEqualSecret is the
            // constant-time compare from
            // src/security/secret-equal.ts,
            // guarding the auth boundary
            // against timing side-channels
            limiter?.recordFailure(ip, rateLimitScope);
            return {ok: false, reason: “token_mismatch”};
        }
        limiter?.reset(ip, rateLimitScope);
        return {ok: true, method: “token”};
    }
    // password and trusted-proxy
    // modes follow the same pattern
}
在前面的代码中,safeEqualSecret调用值得关注。它执行的是常量时间字符串比较,可以防止计时攻击:攻击者无法通过测量失败尝试的响应时间,推断自己已经猜对了 token 的多少个字符。这是bearer-token 端点上的标准缓解措施,但必须一致使用。只要有一条代码路径退回到===比较,就会破坏该分支上的常量时间保证。safeEqualSecret函数定义在src/security/secret-equal.ts中,它是 Gateway 中凭据值唯一被批准的比较路径。
ResolvedGatewayAuth类型通过把 auth 模式配置与凭据解析分开,让 Token Exchange 的语义显式化。
下面是这个类型:mode字段记录当前使用的认证方案,modeSource记录该模式来自哪里,可选的tokenpasswordtrustedProxy字段则承载解析后的凭据材料,并且与模式本身分离:
// src/gateway/auth.ts — resolved auth mode separates config from credential
export type ResolvedGatewayAuth = {mode: ResolvedGatewayAuthMode;
    // “none” | “token” |
    // “password” |
    // “trusted-proxy”
    modeSource?: ResolvedGatewayAuthModeSource;
    // “override” |
    // “config” |
    // “token” |
    // “password” |
    // “default”
    token?: string;
    password?: string;
    allowTailscale: boolean;
    trustedProxy?: GatewayTrustedProxyConfig;
};
modeSource字段记录 auth 模式是从哪里推导出来的:显式配置、token 凭据存在、password 存在,或默认值。这是一个生产诊断辅助字段。由于 Gateway 会在启动时解析并记录modeSource,因此已解析的 auth 模式会报告在 Gateway 启动认证输出中,而不是通过某个 probe 命令呈现:它不仅能显示系统正在使用 token 模式,还能显示 token模式是从环境变量推断出来的,而不是显式配置的。这可能意味着配置漂移。某个运维人员通过环境变量设置 token,意图覆盖配置,但忘记同时显式设置gateway.auth.mode: token,就会在诊断输出中看到modeSource: “token”,这是一个清晰信号:该模式不是声明出来的,而是推断出来的。
本地凭据解析与远程凭据解析
src/gateway/credentials.ts中的凭据解析层进一步增加了一个Token Exchange 维度:它会区分本地凭据(当 Gateway 与自身 HTTP 表面对话时使用)与远程凭据(当客户端连接到运行在另一台主机上的 Gateway 时使用)。resolveGatewayCredentialsFromConfig函数会根据gateway.mode配置键以及是否存在OPENCLAW_SERVICE_KIND=gateway环境变量,在这两种模式之间选择。
图 1.4描绘了长期秘密在哪里被解析,以及Gateway在请求到达智能体运行时之前,在哪里把它们转换成更窄的身份声明:
图 1.4:凭据解析把耐久秘密保留在 Gateway 边界
在拆分拓扑部署中,例如 macOS 应用连接到运行在云 VM 上的 Gateway,应用的客户端凭据与 VM 的服务端凭据会从不同来源解析,并且可以独立轮换。应用从本地配置读取gateway.remote.url和远程 token;VM 则从环境变量读取OPENCLAW_GATEWAY_TOKEN。双方都不需要知道对方完整的凭据集合。要轮换 VM 侧凭据,需要优雅停止 Gateway,在环境变量或 secret manager 中更新OPENCLAW_GATEWAY_TOKEN,然后重启 Gateway,让它在启动时读取新值。只有在进程无法正常退出时,才使用强制 kill。
限流作为可靠性与安全控制
authorizeGatewayConnect中的限流器集成,是系统内置的防凭据暴力破解机制。当提供正确凭据时,限流器会重置该 IP 的失败计数。当提供错误凭据时,它会递增失败计数。当失败计数超过配置阈值时,同一 IP 的后续请求会收到{ ok: false, reason: “rate_limited”,rateLimited: true,retryAfterMs: N }响应,而不会继续进入凭据比较。retryAfterMs字段会告诉客户端在下一次尝试前至少等待多长时间,这是一个“带指数退避的重试”信号,行为良好的客户端应当遵守。
下面的openclaw.json配置块展示了产生这种行为的限流配置:通过在Gateway auth 边界设置尝试阈值和时间窗口来配置限流器:
// openclaw.json — rate limiting at the Gateway auth boundary [AC1.1]
{
    “gateway”: {
        “auth”: {
            “mode”: “token”,
            “token”: “${OPENCLAW_GATEWAY_TOKEN}”,
            “rateLimit”: {“maxAttempts”: 5, “windowMs”: 60000}
        }
    }
}
在这个配置中,60 秒窗口内五次认证失败,会导致同一 IP 的第六次尝试收到限流响应,不管它是否携带正确凭据。限流器的失败计数会跨单个连接重置而保留,因此它可以有效应对那些通过重连循环试图把重试流量隐藏在正常 TCP 重连模式中的攻击者。这也是一个重要的可靠性属性,而不仅仅是安全属性:如果没有限流,一个配置错误的客户端如果在紧密循环中重试认证,每分钟可能生成数千条日志,并使 Gateway 的认证路径饱和,从而降低它为合法客户端提供服务的能力。
Tailscale身份作为Token Exchange 的变体
authorizeGatewayConnect中的 Tailscale 身份路径,展示了一种专属于 Tailscale 部署的 Token Exchange 变体。当auth.allowTailscale为 true,并且请求通过 Tailscale Serve 代理到达时,Gateway 会调用resolveVerifiedTailscaleUser,后者会针对 Tailscale 控制平面执行 WhoIs 查询。这会验证tailscale-user-loginheader 中声明的身份,是否与 Tailscale 网络中连接节点 IP 地址关联的身份一致。最终结果是把 header 与一个权威的带外来源进行交叉核对,而不是单独信任 header 值。
这个模式在结构上与 Token Exchange 完全相同:长期 Tailscale machine key 由 Tailscale daemon 持有,永远不会接触 Gateway。Gateway 接收到的只是一个经过验证的用户身份声明、登录邮箱和姓名,并使用这个声明进行审计日志记录和访问决策。resolveVerifiedTailscaleUser中的不匹配保护,会捕获这种情况:受损代理在 header 中注入了与 WhoIs 响应不同的登录值。此时会产生{ ok: false, reason: “tailscale_user_mismatch” }结果,该结果会被记录,并作为认证失败处理,而不是静默放行。第 4 章会扩展这部分讨论,覆盖完整的零信任身份模型、传输层mTLS,以及 SOUL.md 完整性签名。
最终效果是:Gateway 的多模式凭据系统实现了 Token Exchange 模式,使长期设备凭据永远不会到达智能体运行时。Gateway 持有秘密,只向后转发经过验证的、短生命周期的身份声明。把耐久秘密保留在边界上,才使智能体层能够在渠道和部署数量不断增加的情况下依然保持最小权限。
总结
本章从 OpenClaw 架构的基础原则开始,也就是 Gateway 作为单一控制平面;接着讨论了作为辐条的渠道适配器设计、PluginRegistry的扩展模型、把消息映射到智能体和会话的七层路由瀑布、支持水平扩展的共享磁盘拓扑,以及把长期凭据限制在 Gateway 边界内的 Token Exchange 模式。
这些元素中的每一个都不可或缺:没有隔离渠道适配器的 Gateway,会变成一个单体,任何一个集成的演进都可能危及另一个集成;没有逐插件 hook 策略的PluginRegistry,会变成任何已加载插件都可以利用的注入面;没有确定性层级顺序的路由瀑布,会产生不可预测的智能体分发行为,而这种行为在生产中几乎无法调试;没有会话写锁的水平拓扑,会在并发负载下产生静默数据损坏,表现为看似神秘的对话上下文丢失。
需要带到后续章节中的非显而易见洞察,是这套架构中的复杂性究竟累积在哪里。Gateway 请求处理层面的运维简单性——单一进程、单一认证检查、单一路由函数——是以更复杂的插件模型为代价换来的。你激活的每一个扩展点,包括 hook、HTTP 路由、Gateway 方法,以及带策略执行的 typed hook,都会增加少量加载期复杂性;随着插件数量增长,这些复杂性会叠加。在拥有十个或十五个插件的部署中,注册表中的diagnostics字段会成为你的一线监控信号:某个插件如果在加载时未能注册 hook,不会产生运行时异常;它只会缺席,而你期待的行为将静默地不会发生。
启动侧车序列中介绍的 Bulkhead 模式,是对这个问题的部分回答。但更深层的答案,是让插件注册失败变得可观测、可告警。第 7 章会通过超时执行和失败模式配置直接处理这个问题。随着你继续学习后续章节,会反复遇到同样的动态:请求处理层的架构简单性,是通过注册层和配置层的精心设计换来的。
实施检查清单
下面的检查清单把本章架构转化为具体的部署前动作。每一项都会指出需要验证的设置,以及它可以防止的失败。你可以在 Gateway 承接真实流量之前,用这份清单确认部署是否按照本章建议完成配置。
  • Gateway控制平面:单主机部署时,将gateway.bind设置为loopback;任何非 loopback 绑定都必须在 runbook 中记录并说明理由,然后才能部署到共享网络中。
  • 渠道适配器隔离:确认每个活跃渠道适配器都是通过插件注册表(registry.channels)注册的,而不是硬编码在核心中;确认每个适配器的idopenclaw.json绑定中使用的 ID 一致,并且新配置只使用规范 ID,而不是别名。
  • 插件注册表健康状态:启动时读取registry.diagnostics中所有level: “error”条目;把它们作为阻塞问题处理,而不是 warning。注册失败意味着预期能力在运行时缺席,并且不会再有进一步错误信号。
  • 智能体路由绑定:为每个使用中的消息渠道,在openclaw.json中至少定义一个显式绑定;通过启用详细日志(–verbose)并在测试消息期间检查[routing] match:日志行,验证每个绑定匹配到的路由层。
  • 会话键稳定性:部署时记录每个智能体的dmScope设置;之后对dmScope的任何修改都应视为破坏性迁移,需要显式会话键轮换,而不是简单配置更新。
  • 水平扩展前提:如果部署超过一个 Gateway 实例,需要在持久挂载卷上配置共享的OPENCLAW_STATE_DIR;通过有意向同一会话同时发送两条消息,并确认同一时间只有一条被处理,验证会话写锁处于活动状态。
  • 边界上的TokenExchange:在每个非loopback 部署中添加gateway.auth.rateLimit配置块(只要存在该配置块就会激活限流器);在自定义任何 auth 中间件之前,通过审查src/gateway/auth.ts确认safeEqualSecret是 token 校验唯一使用的比较路径。
  • 凭据轮换姿态:确保OPENCLAW_GATEWAY_TOKEN来自 secret manager 或平台生成的 secret,而不是源代码控制;在 runbook 中记录轮换流程:停止 Gateway、更新环境变量、重启。这样在事故压力下也能执行,而不需要临场发挥。
动手项目
通过多渠道绑定测试验证你的 Gateway 拓扑。这个项目会确认你的 Gateway 能够把来自两个不同渠道的消息正确路由到两个不同智能体,从而在实践中演示中心辐射式架构。完成这个项目后,你将拥有七层路由瀑布按配置运行的直接证据,并理解如何读取路由日志输出,以诊断绑定问题。
前置条件:一个正在运行的OpenClawGateway实例,并且至少配置了一个渠道。Telegram是最简单的起点:通过@BotFather注册一个 bot,把 token 添加到openclaw.json中的channels.telegram.botToken下,并确认 bot 能响应消息。你还需要配置第二个渠道;带 bot token 的 Discord 很合适,或者也可以使用curl直接调用 Gateway 的 HTTP 端点,模拟第二个渠道。
步骤 1:向配置中添加第二个智能体
打开openclaw.json,在agents.list下添加第二个条目:
// openclaw.json — two-agent configuration for routing test
{
    “agents”: {
        “list”: [{“id”: “primary”},{“id”: “specialist”}]
    }
}
步骤 2:添加渠道作用域绑定
添加一个绑定块,把来自第二个渠道的所有消息都路由到specialist智能体:
// openclaw.json — channel-scoped routing bindings
{
    “bindings”: [
        {“match”: {“channel”: “discord”},”agentId”: “specialist”},
        {“match”: {“channel”: “telegram”},”agentId”: “primary”}
    ]
}
步骤 3:启用详细路由日志并重启
–verbose标志传给Gateway 运行命令并重启:
# Shell: restart Gateway with verbose routing enabled
pnpm openclaw gateway run \
    –bind loopback \
    –port 18789 \
    –force \
    –verbose
步骤 4:在每个渠道上发送测试消息
分别通过Telegram 和 Discord 各发送一条消息。观察 Gateway 日志写入情况。
预期结果:你应该看到两条不同的[routing] match:日志行:一条显示matchedBy=binding.channel agentId=primary,另一条显示matchedBy=binding.channel agentId=specialist。每个响应都应该通过原始渠道被送回。如果两条消息都路由到同一个智能体,请确认你的绑定中的 channel 值与src/channels/registry.ts中的规范渠道 ID 完全一致(如“telegram”“discord”等),并确认bindings配置块正确嵌套在openclaw.json顶层,而不是放在channels配置块内部。
步骤 5:验证 Token Exchange 边界
检查 Gateway 启动认证输出:它会报告已解析的 auth 模式以及其来源。如果modeSource解析为“default”,而不是“config”“token”,说明你的 auth 模式是被推断出来的,而不是显式配置的;请更新openclaw.json,显式设置gateway.auth.mode: token
延伸目标
为一个特定Discord guild ID添加第三个绑定,使用match.guildId,并确认它会在binding.channel层触发之前匹配到binding.guild层。验证方法是:从该guild内发送消息,并观察日志行显示matchedBy=binding.guild。随后临时移除guild绑定并重新发送;同一条消息现在应该回退路由到binding.channel。这个练习会让瀑布顺序变得具体可感,并让你直接体验当绑定发生重叠时,路由特异性如何表现。