找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1503

积分

0

好友

197

主题
发表于 昨天 07:48 | 查看: 7| 回复: 0

前两天我写了一篇《OpenClaw 是怎么工作的?一条消息的旅程讲清楚》,算是给整个系统画了一张总览图。

反响还不错,但有朋友问了一个很现实的问题:看懂了消息怎么流转,可我自己做类似系统的时候,还是到处踩坑——断线重连丢状态、重试触发重复执行、长任务把网关线程卡死。

这些问题有一个共同的根源:能跑通 ≠ 跑得稳

消息能发出去、模型能回答,这只是 L1。真正的挑战在 L2:客户端断了怎么办?重试安不安全?30 秒的作业谁在追踪?可观测性有没有契约,还是全靠翻日志拼图?

今天这篇来分析一个点:控制面为什么要做成"两阶段协议",以及 runId 为什么是整个系统的观测抓手。

太长不看版(8 条)

  • 控制面不是"让你点按钮的 UI",而是系统的协议契约:谁能连、能调哪些方法、能订阅哪些事件。
  • OpenClaw 的 WebSocket 连接必须先 connect,服务端先发 connect.challenge(nonce),客户端必须在 10 秒握手超时 内完成认证,超时直接关闭(close code 1008),这不是形式主义,是在防重放、防半连接挂起。
  • agent 设计成"两阶段"是为了把长任务变成可控作业:先 accepted(给 runId),再 streaming(事件),最后 final(结算)。源码中 respond(true, accepted) 立即返回,后台 agentCommand(...) 异步执行。
  • 幂等键不是"锦上添花",是写进 TypeBox Schema 的 required 字段。没有幂等键,你的 sendagent 请求连 AJV 校验都过不去。
  • 方法鉴权要保守:operator.read/write/admin/pairing/approvals 五个 scope 常量,node 角色只允许 3 个方法(node.invoke.resultnode.eventskills.bins),未知方法默认要求 admin
  • 去重机制分两层:context.dedupe(已完成结果缓存,跨请求)+ inflightByContext(飞行中并发合并,WeakMap 随连接断开自动 GC)。不是一层 Map 能搞定的。
  • 事件不重放意味着:客户端要学会"间隙恢复"——重连后拿 hello-ok 快照,主动拉 sessions/health,对还在跑的 run 走 agent.wait
  • 做同类系统时,最应该先抄的不是 Prompt,而是这套控制面契约:握手、鉴权、幂等、观测点。

1)不是聊天,是作业系统

很多团队做 Agent,下意识把它当"聊天"——发一条消息,等模型回一句话,完事儿。

这就像快手那篇 AI Coding 实践里说的:大部分人觉得"我已经在用 AI 了",但其实只用到了最浅的一层。Agent 系统也一样,你觉得"消息能收发了",但离生产可用还差得很远。

一旦接入真实世界,Agent 的行为模式更像"作业系统":

  • 作业可能很长(推理 + 工具调用 + 子智能体编排),30 秒是家常便饭。
  • 作业有副作用(发消息、写文件、调用 exec、触发节点能力如 camera.snapcanvas.navigate)。
  • 作业随时可能被中断(断网、客户端崩溃、服务重启、provider 限速)。
  • 并发有硬约束——同一会话必须串行(session lane maxConcurrent=1),全局受 agents.defaults.maxConcurrent 限制。

到了这一步,控制面要回答的问题就变了:不再是"消息到了没有",而是——

怎么证明作业被系统接住了?怎么追踪进度?怎么在网络抖动下不重复执行?

OpenClaw 的答案:把 agent 做成"两阶段"。

2)两阶段协议长什么样

一句话概括:先接单,再干活。

网关对客户端的"RPC 合同"可以写成这样:

connect.challenge(nonce)              ← 服务端主动推
connect(req, auth, device)            → hello-ok(methods, events, snapshot, policy)
agent(message, idempotencyKey)        → accepted(runId)
event:agent(streaming, seq)           ← 异步推送
agent(final)                          → ok/error(runId, summary)

整个协议只有 3 种帧类型(定义在 GatewayFrameSchema,TypeBox 联合类型 + discriminator: "type"):

帧类型 type 字面量 方向 用途
Request "req" 客户端→网关 方法调用
Response "res" 网关→客户端 调用结果
Event "event" 网关→客户端 异步事件推送

完整的交互时序如下:

OpenClaw控制面两阶段协议与runId流转时序图

这个设计一次性钉住了 4 个关键约束:

约束 机制 工程价值
"接住了"可证明 accepted 硬回执,立即拿到 runId 不用猜请求是否被网关丢弃
"等多久"可治理 控制面只负责受理,执行面走 lane 调度 长任务不阻塞网关线程
"重试"可安全 幂等键 + 两层 dedupe 同 key 请求不触发两次执行
"观测点"可统一 runId 串联事件流 + 工具 + 结算 排障不再拼日志猜关联

这就是工程上的本质差异:你不是在做一个聊天窗口,而是在做一个可观测、可重试的作业调度面。

到这里可能有人会想:道理我都懂,但两阶段能工作的前提是什么?是连接本身要靠谱。如果连接都不可靠,accepted 发出去客户端收不到,那一切都白搭。

所以我们得先看看 OpenClaw 怎么解决连接可靠性的问题。

3)握手为什么要 challenge——别让控制面被"半连接"拖死

OpenClaw 在 WebSocket 层做了三件事来保证连接可靠:

A. 第一帧必须是 connect

message-handler.ts 里的消息状态机只有两个阶段:

阶段 接受的帧 拒绝行为
未连接 method=connect 关闭连接,不允许其他方法
已连接 普通 request frame 交给 handleGatewayRequest

好处很朴素:不允许"未认证但已发命令"的半连接状态存在,状态机更简单,超时直接关。

B. 服务端先发 challenge(nonce)

// 握手超时:10 秒内未完成 connect 握手则关闭
const handshakeTimer = setTimeout(() => {
  ws.close(1008, "handshake timeout");
}, getHandshakeTimeoutMs());

connect.challenge 携带随机 nonce + 时间戳。非本地场景下,客户端签名必须包含该 nonce——旧签名无法复用到新连接:

// device nonce 校验
const providedNonce = connectParams.auth?.nonce;
if (providedNonce && providedNonce !== connectNonce) {
  ws.close(1008, "device nonce mismatch");
  return;
}

签名校验链覆盖完整的 payload 构建 + 验签(支持 v1/v2 格式兼容),确保 device.id 与公钥推导一致、签名时间戳未过期、nonce 匹配。

C. 认证判定有明确优先级

authorizeGatewayConnect 按固定顺序判定,先到先决:

  1. trusted-proxy 模式(验证代理来源 + 指定用户头)
  2. 限速检查(rateLimiter,设备令牌和共享密钥使用独立 scope,互不影响计数)
  3. Tailscale 身份验证(tailscaleWhois
  4. Token / password 比对

本地直连检测(isLocalDirectRequest)则有三重条件:客户端 IP 是 loopback Host 是本地域名或 .ts.net 无转发头或转发来自可信代理。

一句话:控制面认证靠协议强约束,不靠"部署时别搞错"的祈祷。

3.5)一个真实的坑:Control UI "connect failed"

我见过最容易把新人劝退的一幕:

把 Gateway 跑在一台机器上,用另一台的浏览器打开 http://<lan-ip>:18789/,UI 提示 connect faileddevice identity required

很多人第一反应是检查 token 和端口。但更常见的原因是:浏览器处在 HTTP 非安全上下文,WebCrypto API 被限制,UI 根本生成不了设备身份(device.id + publicKey + 签名),握手阶段就卡住了。

这件事暴露的是协议设计的一个深层约束:所有 WebSocket 客户端在 connect必须包含设备身份(除非显式开启 allowInsecureAuth),非本地连接必须签署 nonce。

解法很明确,都在"控制面边界"里,而不是"渠道配置"里:

  • 走 HTTPS(Tailscale Serve / 反代),让 WebCrypto 正常工作;
  • 或 loopback 本地访问(127.0.0.1);
  • 或 SSH 隧道转发。

写这段不是为了讲"怎么用",而是强调:控制面要把"远程访问的默认形态"也纳入协议设计。

连接和认证解决了"谁能进来"的问题。接下来要回答的是:进来之后,能干什么?

4)鉴权为什么这么"保守"

控制面最怕的事情很朴素:某天新增了一个方法,忘了加权限,结果默认放行了。或者某个插件注入了一个 handler,绕过了核心安全边界。

这类问题的根源都是一样的——默认策略太宽松。OpenClaw 的做法不是喊"最小权限"口号,而是代码级强制。

角色层:node 只给 3 个方法

const NODE_ROLE_METHODS = new Set([
  "node.invoke.result",  // 返回调用结果
  "node.event",          // 上报事件
  "skills.bins",         // 获取 skill 可执行文件列表
]);

node 角色命中白名单才放行,其他一律拒绝——能力宿主(camera/screen/canvas)不需要也不应该碰控制面方法。

Scope 层:9 步判定链

authorizeGatewayMethodserver-methods.ts)的完整判定逻辑:

❶ node 角色        → 只允许 NODE_ROLE_METHODS(3个)
❷ operator.admin   → 直接放行全部
❸ "exec.approvals." 前缀 → 强制 admin
❹ ADMIN_ONLY_METHODS(config.set / update.install / sessions.delete 等)→ 强制 admin
❺ APPROVAL_METHODS(3个)  → 要求 approvals 或 write
❻ PAIRING_METHODS(11个)  → 要求 pairing(write 不覆盖)
❼ READ_METHODS(15+个)    → 要求 read 或 write
❽ WRITE_METHODS(14个)    → 要求 write
❾ 未知方法                 → 要求 admin(保守默认)

最关键的是第 ❾ 条:未知方法默认不授权。这不是安全洁癖——当你有插件(extraHandlers 优先于 coreGatewayHandlers)、子系统、版本演进时,"默认放行"迟早会以最难看的方式爆炸。

还有一个容易忽略的细节:operator.write 隐含了 read 权限,但 operator.pairing独立 scope,必须显式授予。配对操作的敏感性决定了它不能被 write "顺带"覆盖。

做架构评审时,我会把这一条写成硬指标:任何新增控制面方法,必须有明确 scope 归属,否则不上线。

好了,连接可靠了,权限也管住了。下一个问题来了:客户端断线重连后重试请求,怎么保证不把同一件事做两遍?

5)幂等键:重试的底座,副作用治理的起点

自动重连一旦做了,"请求到底发出去没有"就成了薛定谔问题——客户端不确定,就会重试。

没有幂等键的重试,后果可以预见:同一条消息发两次、同一个工具执行两次、同一个外部系统被打两次写操作。然后你开始在业务层打补丁——工具层去重、数据库加唯一索引、UI 禁用按钮。都能缓解,但都不是根治。

根治方案只有一个:控制面把幂等变成契约

两层去重:不是一个 Map 能搞定的

OpenClaw 的幂等实现分两层,这是我在源码中看到最值得借鉴的设计之一:

第一层:context.dedupe(已完成结果缓存)

// 跨请求的历史缓存
const dedupeKey = `agent:${idem}`;
const cached = context.dedupe.get(dedupeKey);
if (cached) {
  respond(cached.ok, cached.payload, cached.error, { cached: true });
  return;
}

第二层:inflightByContext(飞行中并发合并)

// 外层 WeakMap 以 GatewayRequestContext 为 key,连接断开时自动 GC
const inflightByContext = new WeakMap<
  GatewayRequestContext,
  Map<string, Promise<InflightResult>>
>();

// 同 key 并发请求复用同一 Promise,只真正执行一次
const existing = inflight.get(dedupeKey);
if (existing) {
  const result = await existing;
  respond(result.ok, result.payload, result.error);
  return;
}
层次 作用 范围 生命周期
context.dedupe 已完成请求的结果缓存 跨连接生命周期 持久化层管理
inflightByContext 飞行中请求并发合并 单次连接上下文 WeakMap 随断连 GC

agent 的 dedupe 写了两次——这个细节很重要:

// 立即写入 accepted(客户端重试会拿到同一个 runId)
context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: accepted });
respond(true, accepted);    // 立刻返回

// 后台异步执行完成后,覆盖为最终结果
void (async () => {
  const result = await agentCommand(...);
  context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload });
  // 或失败
  context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: false, payload, error });
})();

前缀不同不会碰撞:send:${idem} / poll:${idem} / agent:${idem}

Schema 级强制:没有幂等键连校验都过不去

幂等键在 AgentParamsSchemaSendParamsSchema 中都是 required NonEmptyStringadditionalProperties: false。客户端不传这个字段,AJV 校验直接 400,连业务逻辑的门都进不了。

幂等键怎么设计

两条规则写死:

  1. 客户端生成,服务端只管"同 key 不重复副作用"。
  2. 跨重连保留,否则重连一次就等于换了一个请求。

实现上不需要花哨——UI 侧每次点"运行"生成一个 UUID 绑定到本地记录;消息入口侧如果能拿到上游平台的稳定 message id,直接编码进 key。不要用"时间戳 + 随机数",因为你最后解释不清哪次重试算"同一请求"。

工程方法派最怕的不是"没优化",而是"没有合同,只有习惯"。

幂等解决了"重试不重复"的问题。但还有一个问题:客户端掉线那段时间发生的事件,能补回来吗?

6)事件不重放——你需要"间隙恢复",而不是幻想永远在线

很多人默认 WebSocket 订阅就像消息队列——断线重连后能回放。但 OpenClaw 在架构文档中写得很明确:

事件不会重放;客户端必须在出现间隙时刷新。

这意味着掉线期间的事件补不回来。事件帧携带 seqstateVersion,但那是给你做间隙检测用的,不是回放用的。

因此设计控制面时,你必须同时写下两套路径:

路径 触发时机 机制
正常在线 订阅事件(agent/chat/presence/heartbeat/cron),广播有 scope 过滤 + 慢消费者保护
间隙/重连 刷新快照(hello-ok.snapshot + sessions.list + health),把状态拉齐

只做推路径的系统看起来很酷,但一旦有网络抖动就变成"偶现 bug 地狱"。

最小间隙恢复流程(可抄)

客户端恢复动作固定 3 步:

  1. 重连 → 重新 connect → 拿新的 hello-ok(含 snapshot.presence + snapshot.health + policy)。新连接会生成新的 connIdnonce
  2. 主动拉一次 sessions.list + status + health,校准 UI 状态。
  3. 对本地有 runId 记录但未收到 final 的 run,走 agent.wait 拉结果——别依赖"流式事件一定补齐"。

这三步做完,"网络抖动导致 UI 偶现卡死"会少一大半。

💡 补充:事件广播不是全量广播。敏感事件(审批/配对)只推给有对应 scope 的连接,慢消费者(bufferedAmount 超阈值)会被跳过。客户端不能假设自己收到了所有事件——间隙恢复是必需品,不是锦上添花。

以上讲的都是运行时的机制。但还有一个更前置的问题:如果客户端发过来的数据本身就是错的,后面所有机制都白搭。所以 OpenClaw 在协议层就把脏数据拦住了。

7)错误前置:脏数据不进业务层

OpenClaw 用 TypeBox + AJV(不用 zod)做协议校验,策略很简单——在业务逻辑之前,把所有不合法的东西挡在门外:

OpenClaw请求处理与校验流程图

协议版本协商是显式的:PROTOCOL_VERSION = 3,客户端 minProtocol <= 3 <= maxProtocol,不匹配直接拒绝。

错误码体系只有 5 个值:NOT_LINKEDNOT_PAIREDAGENT_TIMEOUTINVALID_REQUESTUNAVAILABLE。所有 handler 通过 errorShape(ErrorCodes.XXX, message) 统一返回,前端按 error.code 做逻辑判断,不靠解析 message 字符串。

工程启示:把格式错误拦截在协议层,业务代码可以假设入参合法。这不是多余的防守,而是降低整个系统的认知负担。

8)可抄清单:控制面的 12 条不变量

前面 7 节拆的都是具体机制。但如果你准备做一个类似系统,最好把这些机制提炼成不变量——写进设计文档,当硬约束维护,而不是当最佳实践建议。

下面 12 条,每一条都标注了 OpenClaw 中的对应实现:

连接层(1–3)

  1. 连接必须握手成功才允许调用方法——两阶段状态机,未 connect 只能 connect
  2. 握手必须有超时,超时直接关——默认 10 秒,close code 1008
  3. 非本地连接必须通过 challenge-nonce 参与认证——防重放,旧签名不可复用

协议层(4–6)

  1. 方法必须 Schema 校验——TypeBox + AJV,additionalProperties: false,拒绝未知字段
  2. 角色与 scope 表达最小权限——operator(5 scope)+ node(3 方法白名单)
  3. 未知方法默认拒绝——"unknown method requires operator.admin"

执行层(7–8)

  1. 有副作用的方法必须强制幂等键——idempotencyKey: NonEmptyString,required
  2. 长任务必须两阶段——先 accepted 后 final,dedupe 写两次

观测层(9–12)

  1. 事件必须带序号——seq + stateVersion,客户端做间隙检测
  2. 推拉双路径——推:broadcast(event);拉:hello-ok.snapshot + sessions.list + health
  3. 区分"服务存活"和"RPC 可达"——health 方法 vs heartbeat/tick 事件
  4. 排障输出必须能安全分享——5 个标准 ErrorCodes,secrets 不出现在协议层

写完这份清单你会发现,很多被归咎于"模型不稳定"的问题,其实是控制面契约没立住。

就像快手用数据证明的那个不等式——用 AI 工具 ≠ 个人提效 ≠ 组织提效。Agent 系统也一样:能跑通 ≠ 跑得稳 ≠ 可运维。中间差的不是模型能力,而是你的控制面契约有没有真正钉死。

如果你想深入了解这类分布式系统的设计模式与实现细节,欢迎在云栈社区的相关板块进行交流与探讨。




上一篇:Oracle分区索引深度解析:本地与全局索引原理及选型指南
下一篇:Kafka持久化机制深度解析:顺序写、零拷贝与ISR保障高吞吐不丢消息
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-25 09:11 , Processed in 0.460272 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表