← 文章

OpenClaw

OpenClaw 说“收到了”,为什么还不算完成?

请求成功、任务被接受、执行完成,为什么是三件不同的事?沿着 OpenClaw Gateway 的源码,理解双阶段响应、幂等重试、等待超时与事件恢复。

给 Agent 发了一条消息,接口很快返回成功,页面上的加载动画也消失了。过了几秒,模型才开始输出;再过一会儿,工具还在运行。到底是哪一步理解错了?

问题往往不在模型,而在我们对“成功”的定义。消息送到了、任务接下了、执行结束了,是三个不同的时刻。普通查询接口可以把它们挤进一个响应,长时间运行的 Agent 却很难这样做。

上一篇讨论了 OpenClaw 怎样决定消息进入哪个会话。这次沿 Gateway 往下走:客户端发出请求后,怎样知道它已经被接受,怎样观察过程,又凭什么认定它完成了?本文以 v2026.5.22 的实现为依据,源码链接固定到同一 commit。

WebSocket 连上以后,还不能直接聊天

WebSocket 给双方一条可以持续收发数据的连接,但不会自动定义身份、权限、请求关联或任务完成条件。这些都是 Gateway 协议自己的责任。

实际入口是 attachGatewayWsConnectionHandler 注册的连接回调。服务端先发 connect.challenge,客户端取得 nonce 后发送 connect 请求,声明协议版本范围、客户端信息以及角色和权限范围。认证等检查通过后,服务端返回 hello-ok,里面包含能力列表、状态快照和连接策略。

这里“第一个请求必须是 connect”,说的是客户端发出的请求,不是双方线路上的第一个数据帧;服务端的 challenge 会先到。若没有完成握手就发送业务方法,消息处理器会拒绝它。连接建立成功与应用层准备就绪,不能共用一个布尔值。

握手完成也不是拿到全部能力。后续请求仍经过方法级角色和 scope 检查,再交给具体 handler。分层的好处在于:连接层回答“你是谁、这条连接能协商什么”,方法层继续回答“这次操作是否允许”。本文只确认这道边界,不展开设备配对与完整授权模型。

三种帧,解决三个不同的问题

协议定义把顶层消息分成 reqresevent

关键字段 回答的问题
req idmethodparams 客户端希望执行什么操作?
res idokpayloaderror 这次请求得到了什么响应?
event eventpayload、可选 seq 系统里发生了什么变化?

这种划分允许一条连接同时承载多个请求,也允许服务端在没有对应新请求时推送事件。不能把“收到任何消息”当作当前请求成功,更不能用最近的一条响应去结束最近的一项任务。

还要把三个标识分开:请求 id 关联一次 RPC 的收发,runId 标识一次执行,sessionKey 指向共享会话上下文。同一个会话可以先后运行很多次,同一次执行也可以被不同请求查询。它们可能出现在相邻字段里,却不该承担同一种职责。

Node 客户端为每次 request()生成新 UUID,并把等待中的 Promise 放进 pending。响应按 id 查回等待者,事件走另一个入口。这层关联是并发 RPC 能成立的基础。

为什么同一个请求会收到两次响应

最有意思的是 agent 方法。经过参数校验、会话准备等步骤后,它先注册运行和取消控制信息,保存进行中的去重记录,再回复 status: "accepted"。随后才调度实际执行。

顺序很重要。若先告诉调用方“接下了”,再登记正在运行的任务,调用方紧接着发出的取消请求就可能找不到目标。源码把登记放在确认之前,避免这个窗口。接受请求的代码还在确认之后安排了一个短暂的异步让出,给确认帧发送和紧随其后的查询留下处理机会;这不是网络送达保证。

实际执行进入 agentCommandFromIngress。它的 Promise 完成后,dispatchAgentRunFromGateway保存终态,再通过原来的 respond 返回第二个 res。因为这个闭包绑定着原请求的 id,两次响应的请求 ID 相同。

agent RPC 先确认接受,执行完成后用同一个请求 ID 返回终态
图中 terminal 表示终态结果,并非字面状态值;这张图描述 agent RPC 路径。

这条设计把“快速确认”和“慢任务完成”拆开,同时复用一套响应关联机制。但协议支持两次响应,不代表每个客户端都会等第二次。

handleMessage里,普通调用在第一条响应到来时就移除 pending 并兑现 Promise;后续同 ID 响应找不到等待者,会被忽略。只有设置 expectFinal: true,遇到 accepted 才保留等待者,并通过 onAccepted 通知一次,继续等后面的结果。

还有一个容易让人掉坑的细节:这个分支只特殊处理 accepted。重试得到 in_flight 时,即使设置了 expectFinal,Promise 仍会返回。Promise 已返回,不等于执行已完成。 接口接入方仍然要检查业务状态。

重试到底是在查询,还是又下了一单

假设服务端已经接受任务,但确认帧没被客户端看到。直接重发,看起来是补救网络故障,实际却可能重复调用模型、重复执行工具。

OpenClaw 在 agent handler中使用调用方提供的 idempotencyKey,并在这条路径上让 runId 等于这个值。去重键使用 agent: 前缀。再次提交相同键,如果原任务还处于已接受状态,返回已有运行的 in_flight;如果已有终态,就返回缓存结果。

这说明请求 ID 与幂等键必须分开。重试是一次新的 RPC,可以有新的请求 ID,但若仍然代表同一次业务意图,就需要保留幂等键。换一个键再发,服务端就有理由把它看成另一次运行。反过来,不同业务操作也不能为了方便复用同一个键。

这套机制不是永久性的“恰好执行一次”。此处是内存去重缓存,维护逻辑会清理过期和超出容量的记录,并对活动运行以及特定待登记记录做保留。不能把一个有生命周期的缓存,当成跨重启、跨所有故障场景都成立的事务保证。

如果已经知道 runId,想确认完成情况,更明确的动作是 agent.wait,而不是不断换键重新提交任务。

等不到结果,不代表任务停了

agent.wait先查可用的终态快照;没有就同时等待两条路径:Agent 生命周期记录,以及 Gateway 去重记录中的终态。任一路径给出有效结果后,清理另一条等待路径。

这一步看似只是等一个 Promise,真正难的是认定“哪个结果属于现在”。同一个 run ID 下可能存在不同来源、不同时间的记录。比如较新的 Agent 还在执行,较旧的 chat 记录却写着完成;直接拿到任何成功记录就结束等待,会把过去当成现在。

readTerminalSnapshotFromGatewayDedupe处理了这类优先级:acceptedstartedin_flight 都不是终态;存在较新的进行中 Agent 记录时,旧 chat 终态不能抢先结束等待。RPC 取消形成的特定终态也受到保护,迟到的成功写入不能随意覆盖它。

最需要记住的是超时的含义。客户端本地 RPC 等待超时、agent.wait 的观察期限到达、Agent 自己执行超时,是不同层次的问题。等待器到期可以只结束观察,并不顺便取消那次执行。同样,ok: true 也可能携带 status: "timeout":请求被正常处理,与任务是否成功,是两个维度。

客户端的等待预算也值得单独配置。源码中,普通请求采用默认 RPC 超时,而开启 expectFinal 且没有明确指定超时时,默认不设置这层等待计时器。这有助于避免长任务被普通 RPC 的短预算截断,但接入方也不能据此认为自己已经拥有完整的运行期限控制。

更合理的做法是分别决定:用户愿意在当前界面等多久,后台任务最多可以执行多久,以及断线后要保留哪些标识来继续观察。三者可以有不同的时限。本地取消等待的信号只会清理这个请求的等待者;若要改变服务端运行,仍需要走明确的取消路径。

做产品时,可以把“暂时没有等到结果”显示成待确认状态,再继续查询;若用户明确要求停止,再发取消操作。把这两种意图合并,很容易一边提示失败,一边让后台继续工作,或者在重试时多下几次单。

重试保留幂等键,观察使用 agent.wait,停止执行需要显式取消

聊天窗口走的是另一条完成路径

前面讲的是 agent RPC,不能直接照搬到网页聊天。该版本的 chat.send会先返回 status: "started",网页随后处理 chat 事件中的 deltafinalabortederror

handleChatEvent会核对会话和当前 run ID。收到另一个运行的最终消息,不应该顺手清掉本次运行的加载状态。这里的难点已经从“请求有没有返回”,变成了“这个变化究竟属于哪个执行”。

事件也不是可靠日志的同义词。广播器按连接维护外层 seq,权限过滤发生在分配这个序号之前,因此某个客户端看不到其他权限范围的事件,不会仅因此出现缺号。定向发送的事件则可以没有这个外层序号。

遇到慢消费者,行为不同:允许丢弃的广播会消耗序号后跳过发送;下一次消息到来,客户端便能观察到间隙。不允许丢弃时,超出缓冲限制会关闭连接。这样把慢客户端的资源压力限制在连接边界,却把恢复责任留给了客户端。

onGap 只是在报告“观察到缺口”,不是自动把缺失内容重放回来。外层连接序号也不等于事件 payload 里的运行序号。客户端需要结合业务状态重新查询或加载历史,不能看到一个递增数字,就假定系统提供了完整的断线续传契约。

我实际验证了什么

这次做了 26 项局部源码检查:直接运行原始终态等待模块及依赖;提取未改动的客户端消息处理方法,用固定帧和验证替身检查状态转换;执行原广播器主体,以模拟 socket 检查过滤、丢帧与慢消费者分支。

检查确认了几个反直觉行为:默认请求在 accepted 返回;expectFinal 只对 accepted 保留等待;旧 chat 终态不能覆盖更新的进行中 Agent;等待到期不会修改运行记录;丢弃广播会留下可观察的序号间隙。全部通过,但这不是上游完整测试,也没有运行真实握手、模型、网络重连或完整权限链。

接入一个 Agent,先画出“完成”的边界

如果从零做一个客户端,我会把连接状态、RPC 等待状态和运行状态分开保存。第一版就保留请求 ID、运行 ID、幂等键的对应关系;每次重试先判断业务意图有没有变化;把接受、进行中、成功、失败、取消和等待期限到达放进明确的状态转换里。

OpenClaw 值得借鉴的是这三处设计:快速确认与终态分离,重试围绕业务身份去重,事件只提供观察而不替代权威状态。代价也很具体:客户端要理解多种状态;终态来源分散会增加一致性推理难度;没有明确恢复策略时,连接正常也不代表界面状态正确。

我会优先为接入层补一张按方法划分的状态表,写清首次响应、后续完成信号、超时与取消语义,再考虑更复杂的重试策略。对于长期运行的 Agent,最重要的并不是尽快把按钮变回可点击,而是让用户知道:请求已送达,任务仍在做,还是确实已经结束。

继续阅读