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

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

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


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

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

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

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

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


问题:谁来协调这一切?

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

css 复制代码
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 的设计目标。一旦建立连接,双方可以随时互发消息,延迟极低,也没有重复握手的开销。

arduino 复制代码
普通 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:

sql 复制代码
GET /ws HTTP/1.1
Authorization: Bearer your-token-here

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

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

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

json 复制代码
{
  "method": "connect",
  "params": {
    "token": "...",
    "role": "operator",
    "clientId": "macos-app"
  }
}

这里的 role 只有两个值:

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

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

typescript 复制代码
// 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 控制:

typescript 复制代码
// 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。

三层合在一起:

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

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

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

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

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

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

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

WebSocket 连接建立

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

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

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

typescript 复制代码
// 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 里有什么?

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

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

注意 gatewayMethods动态生成的

typescript 复制代码
// 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 的解法出奇地简单:

typescript 复制代码
// 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。

当一条消息进来:

typescript 复制代码
// 查找 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 维护了一个所有已连接客户端的集合:

typescript 复制代码
const clients = new Set<GatewayWsClient>();

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

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

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

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

broadcast 函数有一个 stateVersion 参数:

typescript 复制代码
broadcast("presence", payload, {
  stateVersion: { presence: currentPresenceVersion }
})

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

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


把所有设计连起来看

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

arduino 复制代码
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 作为单一中枢 没有中枢就没法协调
需要实时双向通信 WebSocket HTTP 无法服务端主动推送
不同客户端需要不同权限 三层认证(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

相关推荐
冬奇Lab2 小时前
一天一个开源项目(第36篇):EverMemOS - 跨 LLM 与平台的长时记忆 OS,让 Agent 会记忆更会推理
人工智能·开源·资讯
AngelPP5 小时前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年5 小时前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
九狼6 小时前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS6 小时前
Kimi Chat Completion API 申请及使用
前端·人工智能
天翼云开发者社区7 小时前
春节复工福利就位!天翼云息壤2500万Tokens免费送,全品类大模型一键畅玩!
人工智能·算力服务·息壤
知识浅谈7 小时前
教你如何用 Gemini 将课本图片一键转为精美 PPT
人工智能
Ray Liang8 小时前
被低估的量化版模型,小身材也能干大事
人工智能·ai·ai助手·mindx
shengjk19 小时前
NanoClaw 深度剖析:一个"AI 原生"架构的个人助手是如何运转的?
人工智能