OpenClaw 源码深度解析(一):Gateway——为什么需要一个"中枢"

0 阅读13分钟

系列目标:读完全系列,你能基于 OpenClaw 做定制开发,也能参考它的设计从零搭建类似系统。

本篇核心问题:Gateway 是什么?为什么要有它?它是怎么一步步被设计出来的?


先从你作为用户的体验说起

假设你已经装好了 OpenClaw,你的日常使用是这样的:

  • 早上在手机 WhatsApp 问它:"帮我整理一下今天的会议纪要",它打开你电脑上的文件夹,生成文档,然后把链接发回给你
  • 下午在电脑 Slack 频道里问它:"现在服务器状态怎么样",它 SSH 进服务器跑命令,把结果返回给你
  • 同时,你打开浏览器的 Web UI,看到它正在执行的任务进度,实时滚动的日志
  • 你的 iPhone 也在旁边,能随时语音唤醒它

这里面有一件微妙的事情:这四个入口(WhatsApp、Slack、Web UI、iPhone)同时在用同一个 AI 助手,而且它们看到的状态是同步的

这就带来了一个工程问题。


问题:谁来协调这一切?

不妨想象一下,如果没有任何中枢,会发生什么:

WhatsApp 连接 → AI 进程 A
Slack 连接    → AI 进程 B
Web UI 连接   → AI 进程 C
iPhone 连接   → AI 进程 D

四个进程各自独立。在 WhatsApp 问的问题,Slack 里看不到;Web UI 看到的状态,和真实执行进度不同步;你在 Slack 说"停止",WhatsApp 那边的 AI 还在跑。

这行不通。

所有的入口必须共享同一个 AI 的同一个状态。 这意味着需要一个单一的协调中心——它连接所有的消息通道,管理唯一一个 AI 执行进程,并把状态实时同步给所有连接的客户端。

这就是 OpenClaw Gateway 存在的根本原因。


Gateway 的本质:一个控制平面

OpenClaw 的代码注释用了一个专业术语来描述 Gateway:

Gateway WebSocket control plane

控制平面(Control Plane) 是网络工程里的概念:负责"决策和协调"的那一层,而不是"传输数据"的那一层。

用更直白的话说,Gateway 干三件事:

① 消息枢纽:所有消息通道(WhatsApp、Telegram、Slack...)的消息都汇入 Gateway,Gateway 决定交给哪个 AI 会话处理,再把 AI 的回复分发出去。

② 命令中心:CLI、macOS App、Web UI、手机 App 都通过 Gateway 控制 AI——启停会话、查看状态、修改配置、触发任务。

③ 状态广播站:AI 在执行任务时,Gateway 把实时状态广播给所有连接的客户端。你在手机上问的问题,在电脑 Web UI 上也能实时看到 AI 的思考过程。

理解这三件事,Gateway 后面所有的设计决策都会变得顺理成章。


第一个设计决策:为什么用 WebSocket?

确定了"Gateway 需要实时同步状态给多个客户端"之后,接下来的问题是:用什么协议?

最常见的选项是 HTTP。但 HTTP 有一个根本性的限制:它是请求-响应模式,必须客户端先问,服务端才能答。服务端没有办法主动推送消息。

而 Gateway 有一个强烈的需求:AI 在生成回复时,要把每个字实时推给所有客户端。不是等 AI 生成完整段话再一次性发过来,而是像打字机一样,生成一个字就推一个字(这就是 LLM 的"流式输出")。

用 HTTP 实现这个需求有两种方式:

  • 长轮询(Long Polling):客户端不断问"有新内容吗",服务器有了再回答。延迟高,连接开销大。
  • SSE(Server-Sent Events):服务器可以主动推,但只能单向,客户端没办法同时发命令。

这两种都满足不了需求。OpenClaw 需要的是:客户端和服务端都能随时主动发消息,而且是持久连接,不用每次都重新握手

这正是 WebSocket 的设计目标。一旦建立连接,双方可以随时互发消息,延迟极低,也没有重复握手的开销。

普通 HTTP:
  客户端 →→→ 请求 →→→ 服务端
  客户端 ←←← 响应 ←←← 服务端
  (连接关闭,下次再来)

WebSocket:
  建立一次连接后,双方随时可以发:
  客户端 →→→ "执行这个命令" →→→ 服务端
  服务端 ←←← "AI 正在思考..." ←←← 服务端(主动推)
  服务端 ←←← "AI 说:..." ←←← 服务端(继续推)
  客户端 →→→ "停止" →→→ 服务端
  (连接一直保持)

这就是 Gateway 选择 WebSocket 作为主协议的原因——不是因为 WebSocket 时髦,而是业务需求决定的

HTTP 并没有消失。Gateway 同时监听 HTTP,用于:浏览器访问 Web UI(必须 HTTP)、Slack/Webhook 等外部回调(第三方只会发 HTTP)、OpenAI 兼容接口(方便接入现有 SDK)。但这些都是辅助场景。


第二个设计决策:连接进来之后怎么认识你?

Gateway 现在用 WebSocket 对外提供服务。连接进来的客户端可能是:

  • 你自己的 CLI(完全可信,可以做任何事)
  • 你的 Web UI(你自己用,但最好限制只读,防止误操作)
  • 你的 iPhone 节点(它能上报摄像头画面,但不应该能修改配置)
  • 一个 Webhook 调用(外部触发,权限最小)

这四种客户端需要不同的权限。 怎么区分它们?

最简单的方案是:每种客户端用不同的 Token。但这样管理成本高,而且粒度太粗——你没法做到"Web UI 可以查看会话列表,但不能删除会话"。

OpenClaw 的解法是三层认证模型,每层解决不同的问题:

第一层:你是谁?(HTTP 层 Token)

建立 WebSocket 连接的那一刻,HTTP Upgrade 请求里必须带 Token:

GET /ws HTTP/1.1
Authorization: Bearer your-token-here

这一层只判断一件事:这个 Token 是不是合法的 Gateway Token。合法就允许建立连接,不合法直接断开。这是门卫,只管"能不能进门"。

第二层:你是什么角色?(连接握手 Role)

进门之后,客户端发第一条消息——connect 消息:

{
  "method": "connect",
  "params": {
    "token": "...",
    "role": "operator",
    "clientId": "macos-app"
  }
}

这里的 role 只有两个值:

  • operator:人类操作者。CLI、macOS App、Web UI 都是 operator。
  • node:设备节点。iPhone、Android、macOS 节点模式。

两种角色能调用的方法完全隔离

// src/gateway/role-policy.ts
export function isRoleAuthorizedForMethod(role, method) {
  if (isNodeRoleMethod(method)) {
    return role === "node";   // node 专属方法:只有设备节点能调用
  }
  return role === "operator"; // 其余方法:只有人类操作者能调用
}

iPhone(node 角色)不能调用 config.apply 修改配置——即使它拿到了合法 Token,role 不对就是不行。反过来,CLI(operator 角色)也调不了 node.invoke.result(那是设备节点上报执行结果用的)。

为什么要把 role 放在 connect 消息而不是 HTTP 层?

因为 HTTP 层只是"进门",而 role 决定"进门后能去哪个房间"。把两层分开,可以用同一个 Token 连接,但根据 role 获得不同权限——这在测试和调试时非常方便。

第三层:你能做什么?(Scope 细粒度控制)

对于 operator 角色,还有更细的 scope 控制:

// src/gateway/method-scopes.ts
const READ_SCOPE  = "operator.read";   // 只读:看状态、查配置
const WRITE_SCOPE = "operator.write";  // 写操作:触发 Agent、改配置
const ADMIN_SCOPE = "operator.admin";  // 全部权限

这解决了一个实际需求:Web UI 可以对外暴露(比如给团队成员查看 AI 执行日志),但你不想让他们能触发 Agent 运行或修改配置。只要给他们的连接只分配 READ_SCOPE,就做到了权限隔离,而不需要维护多套 Token。

三层合在一起:

HTTP Token   → 你能不能连进来?
Role         → 你是人类操作者还是设备节点?
Scope        → 在你的角色范围内,你能做哪些具体操作?

第三个设计决策:connect 为什么必须是第一条消息?

现在理解了认证的三层设计,你会自然想到一个问题:

Role 和 Scope 信息在 connect 消息里,但 Token 在 HTTP 头里。为什么不把所有认证信息都放 HTTP 头里,省掉这个 connect 步骤?

因为 WebSocket 连接在 HTTP 升级之后,服务端就不知道这个连接的身份了——HTTP 头只在建立连接时传一次,之后的 WebSocket 帧里没有 HTTP 头。

所以必须在 WebSocket 层再做一次认证握手,connect 消息就是这个握手。

客户端 → 服务端: HTTP Upgrade(带 Bearer Token)
                 [第一层:能不能进门]

WebSocket 连接建立

客户端 → 服务端: { method: "connect", params: { role, scopes, clientId, ... } }
                 [第二层+第三层:进来之后是谁,能做什么]

服务端 → 客户端: { type: "hello-ok", gatewayMethods: [...], events: [...], ... }
                 [握手完成,告诉客户端这个 Gateway 支持什么]

如果 connect 之后再发一次 connect 会怎样?

// src/gateway/server-methods/connect.ts
export const connectHandlers = {
  connect: ({ respond }) => {
    respond(false, undefined, errorShape("connect is only valid as the first request"));
  },
};

直接报错。这 12 行的文件就是一个兜底——真正的 connect 处理逻辑在更底层(ws-connection/message-handler.ts),在进入 Handler 路由之前就已经处理了。正常连接中你永远不会碰到这个兜底 Handler。

hello-ok 里有什么?

服务端返回的不只是"认证成功",还有完整的能力清单

{
  type: "hello-ok",
  gatewayMethods: ["health", "agent", "sessions.list", ...],  // 这个 Gateway 支持哪些 RPC 方法
  events: ["agent", "presence", "tick", ...],                  // 会推哪些事件
  healthSnapshot: { ... },    // 当前系统健康快照
  presenceSnapshot: { ... },  // 当前在线状态快照
}

注意 gatewayMethods动态生成的

// src/gateway/server-methods-list.ts
export function listGatewayMethods(): string[] {
  const channelMethods = listChannelPlugins()
    .flatMap((plugin) => plugin.gatewayMethods ?? []);
  return Array.from(new Set([...BASE_METHODS, ...channelMethods]));
}

如果你安装了 MS Teams 插件,它可以注册自己的 RPC 方法,这个列表就会多出来。客户端在握手时就知道服务端支持什么,不用靠文档猜,也不用靠版本号判断兼容性。


第四个设计决策:90 个方法怎么管理?

Gateway 总共支持约 90 个 RPC 方法(healthagentsessions.listconfig.set...)。

这些方法怎么注册?OpenClaw 的解法出奇地简单:

// src/gateway/server-methods.ts
export const coreGatewayHandlers = {
  ...connectHandlers,    // connect
  ...healthHandlers,     // health
  ...agentHandlers,      // agent, agent.wait
  ...sessionsHandlers,   // sessions.list, sessions.patch, sessions.reset ...
  ...configHandlers,     // config.get, config.set, config.apply ...
  ...cronHandlers,       // cron.list, cron.add, cron.run ...
  ...skillsHandlers,     // skills.status, skills.install ...
  ...nodeHandlers,       // node.list, node.invoke ...
  // ... 共约 30 个 handler 组
};

这是一个扁平的 JavaScript 对象:key 是方法名字符串,value 是处理函数。没有路由树,没有中间件链,就是一个 Map。

当一条消息进来:

// 查找 handler → 调用
const handler = extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
if (!handler) { respond(error("unknown method")); return; }
handler({ req, respond, client, context });

为什么不用更"正规"的路由框架?

因为 90 个方法对路由树来说完全没必要——哈希表查找是 O(1),路由树反而引入了额外的解析开销和代码复杂度。

插件怎么扩展方法?

注意 extraHandlers?.[req.method] 在前面——插件注册的 Handler 优先级高于核心 Handler。插件只需要 export 一个同类型的对象,在加载时 spread 进去,就能注册新方法,甚至可以覆盖内置方法的行为。


第五个设计决策:如何让多个客户端实时同步?

Gateway 维护了一个所有已连接客户端的集合:

const clients = new Set<GatewayWsClient>();

当 AI 产生新的输出,Gateway 调用 broadcast 函数,向集合里的每个客户端发送事件:

broadcast("agent", {
  phase: "streaming",
  sessionKey: "agent:main:dm:alice",
  text: "正在分析你的文件...",
})

所有连接的客户端——不管是 CLI、Web UI 还是 iPhone——同时收到这条消息,实时显示 AI 的输出。

一个细节:如果客户端断线重连,怎么恢复状态?

broadcast 函数有一个 stateVersion 参数:

broadcast("presence", payload, {
  stateVersion: { presence: currentPresenceVersion }
})

每次状态变化,版本号 +1。客户端重连时,带上自己记住的最后版本号。如果服务端的版本更新了,就发送完整的状态快照而不是增量。

这解决了一个经典的分布式问题:客户端断线期间错过的状态变化,怎么补齐? 答案是:不补,直接发最新全量状态。简单可靠,不会出现漏更新导致的状态不一致。


把所有设计连起来看

现在可以画出 Gateway 的完整工作流程:

1. 用户在 WhatsApp 发消息
          ↓
2. WhatsApp 通道收到消息,通过内部事件传给 Gateway
          ↓
3. Gateway 路由层决定交给哪个 Agent 的哪个会话
(这部分是下一篇的主题:通道与路由系统)
          ↓
4. Agent 开始执行,产生流式输出
          ↓
5. Gateway 调用 broadcast("agent", { text: "..." })
          ↓
6. 所有连接的客户端同时收到:
   - Web UI 实时显示进度
   - iPhone App 显示通知
   - CLI 打印输出
          ↓
7. Agent 执行完,回复通过 Gateway 发回 WhatsApp

每一个环节的设计选择都有清晰的来由:

问题解法原因
多客户端共享同一个 AI 状态Gateway 作为单一中枢没有中枢就没法协调
需要实时双向通信WebSocketHTTP 无法服务端主动推送
不同客户端需要不同权限三层认证(Token/Role/Scope)粒度从粗到细,各层职责清晰
90 个方法的管理扁平 Handler Map简单高效,插件轻松扩展
断线重连后状态恢复版本号 + 全量快照简单可靠,不怕漏更新

启动流程:Gateway 上电时做了什么

理解了设计之后,再来看启动流程就很自然了。Gateway 的 startGatewayServer() 函数按以下顺序初始化:

① 读取配置文件,如果是旧格式就自动迁移
② 预检所有密钥引用——有一个不存在就立刻报错退出(Fail-Fast)
③ 生成或验证 Gateway Token
④ 加载所有插件(通道插件、功能插件)
⑤ 建立所有消息通道的连接(WhatsApp、Telegram、Slack...)
⑥ 挂载 WebSocket 处理器,开始监听
⑦ 启动 Cron 任务调度、心跳监控、本地网络发现

第②步的"Fail-Fast"设计值得单独说一下:如果配置里引用了一个不存在的 API Key,很多系统的处理方式是"先跑起来,用到的时候再报错"。OpenClaw 不这样——启动时就检查,发现问题就拒绝启动,明确报错

这对个人 AI 助手来说尤为重要:一个带着错误配置运行的助手,会出现"发消息没有回应"这种极难调试的问题。不如一开始就让它无法启动,错误信息清清楚楚。


小结

本篇从用户使用场景出发,推导了 Gateway 的每一个核心设计:

  • 为什么需要 Gateway:多通道、多客户端需要一个协调中枢
  • 为什么用 WebSocket:实时双向通信是刚需,HTTP 做不到
  • 为什么要 connect 握手:WebSocket 层需要自己的认证机制
  • 为什么三层认证:不同客户端信任级别不同,权限需要分层
  • 为什么用扁平 Handler Map:简单够用,插件扩展零阻力
  • 为什么版本号 + 全量快照:断线重连场景下最可靠

下一篇将进入 Gateway 内部最复杂的逻辑——通道与路由系统

WhatsApp 来一条消息,OpenClaw 怎么知道应该交给哪个 Agent?如果你配置了多个 Agent,不同的群组、不同的联系人,怎么路由到正确的地方?

这背后是一套 8 级优先级的路由规则,设计得相当精妙。


对应代码:src/gateway/ | 关键文件:server.impl.tsserver-methods.tsrole-policy.tsmethod-scopes.ts