← 文章

OpenClaw

我都说“先别改”了,OpenClaw 为什么还在继续?

一句“先别改”,为什么没能立刻停下助手?从同会话排队、共享并发额度到 steering 与超时,看看 OpenClaw 怎样安排忙碌中的新消息。

继续前几篇的假设场景。会议纪要已经读完,待办清单也保存好了。你让 OpenClaw 把第二项再拆细一点,顺便更新文件。

看着它开始忙,你突然想起还有一处没有确认,于是赶紧补了一句:

先别改文件,给我看看方案就行。

聊天窗口显示消息已经收到,但片刻之后,文件还是变了。

这时最自然的反应是:它怎么不听话?可在讨论模型是否理解之前,还得先确认一件事——你的这句话,究竟什么时候进入了它正在执行的流程。

消息抵达服务、进入当前任务、被下一次模型调用读到,以及正在运行的工具真的停下,是几个不同的时刻。本篇就沿着这句“先别改”,看看 OpenClaw 如何安排忙碌中的新消息。

本文继续使用 OpenClaw v2026.5.22 和 Pi 0.75.4 的源码,聚焦普通自动回复与内置 Pi 执行路径。示例不是一次真实事故记录,也不把这个版本的行为当作所有新版运行时的通用规则。本版本队列说明

一句话进来,未必就要再启动一个助手

如果每收到一条消息,都立即启动一次独立的 Agent 运行,事情很快就会乱起来。

第一轮还在根据旧要求修改清单,第二轮已经读到了新要求。它们可能各自读取同一份历史,又各自追加消息、调用工具。最后你看到的顺序,也许取决于哪个网络请求先返回,而不是你先说了哪句话。

把整个服务限制成一次只处理一个请求,又会走向另一头:你这边等一个慢工具,另一段完全无关的对话也得跟着等。

OpenClaw 把这两个问题分开了。消息策略先决定“这句话怎么参与对话”;运行调度再决定“这次运行什么时候能开始”。有时新消息会被送进正在进行的运行,根本不需要创建第二轮独立运行。自动回复入口

这个区分很重要。只盯着底层队列长度,并不能解释用户的所有感受。队列里没有第二个运行,不代表第二句话丢了;它可能已经作为 steering 消息,等待当前运行在合适的位置读取。

同一段对话排队,不同对话可以一起做

先假设这条新消息需要启动一次独立运行。

在所选的 runEmbeddedPiAgent 默认调度路径中,代码先进入 session:<key> 这样的会话 lane,然后在里面申请一个全局工作 lane 的执行位置。常规前台运行默认使用 main;其他工作类型还可能有自己的 lane。两层调度入口

会话 lane 回答的是:“这段对话前面的运行结束了吗?”全局 lane 回答的是:“这一类工作现在还有并发额度吗?”

假设全局额度为 2,A、B、C 三段对话都有任务。A1 和 B1 可以同时执行,C1 等待额度。A 对话又来了 A2,它先在 A 自己的 lane 后面等着,不会立刻跑进全局队列,抢走一个位置再干等 A1。

A、B、C 三个会话各有自己的运行 lane,再共享并发为二的全局 lane;A2 留在会话队列,C1 等待全局额度
图示的是正常调度路径。会话顺序与整体并发额度,分别由两层约束表达。

这样组织的一个好处,是把“同一段历史不要被两轮普通运行同时推进”和“机器可以同时服务多段对话”放在不同层处理。这是我对结构的理解,不是给项目作者补写设计宣言。

还有一个不该忽略的细节:这里所谓全局,是某个进程内工作 lane 的共享额度,不是整个部署唯一的一把锁。代码还有 croncron-nestedsubagent 等名字。所选版本甚至把 cron 内层执行映射到专门的 cron-nested,避免外层已经占着 cron 位置,内层又等同一个额度。lane 名称与映射

因此,不能看到 main 的限制,就断言所有后台工作加起来也一定不超过这个数。普通运行的串行说明,还需要与后面提到的超时和恢复边界一起理解。

你希望它“接着听”,还是“下一轮再说”?

回到“先别改文件”。同一个忙碌会话收到新消息时,这个版本提供四种主要处理方式。

模式 新消息如何参与 需要注意的边界
steer 运行能接收时,送入当前运行 不会直接中断正在执行的工具
followup 当前运行结束后,作为后续请求处理 原任务会先继续往下走
collect 等待安静窗口,把兼容的排队消息合成后续一轮 不同回复渠道或线程可能需要分别处理
interrupt 请求中止当前运行,再处理新消息 取消不等于回滚已发生的动作

这些模式表达的是不同的交互意图。你连续补充“按负责人分组”“再加截止时间”,可能适合收集后一起处理;你想尽快改变正在进行的推理方向,则更接近 steering。若要求替换当前任务,interrupt 才是在请求中断。模式定义

在本版本的设置解析器里,默认模式是 steer。覆盖顺序从内联设置、会话保存设置,到渠道配置、全局配置,最后才落到默认值。不能拿另一个版本的默认行为,或者某段对话以前保存过的设置,猜现在一定会怎样。设置解析

配置名称也容易给人过强的暗示。选择 steer,代表程序尝试把输入交给当前运行,不代表模型已经理解并执行了新要求。当前运行没有处在可接收状态时,宿主还要走等待或后续处理路径。自动回复入口会检查是否正在 streaming,并等待注入结果,确认接受后才从这条路径返回。

“先别改”要等到哪个时刻才会被听见?

这一步最接近用户的直觉,也最容易让直觉出错。

在本文锁定的 Pi 循环里,模型给出带工具调用的助手消息后,执行器先处理这一批工具调用,收集结果,再发出本轮结束事件。随后,在正常继续路径上,它取出等待中的 steering 消息,交给下一次模型调用。Pi 执行循环

所以,如果你的“先别改”是在文件工具已经执行时到达,它不会穿过当前调用栈,把一个正在写入的动作自动撤销。它能影响的是后面的模型决策:例如下一轮不要再改其他文件,先解释已经做了什么。

固定版本 Pi 在当前工具批次与结果处理之后读取 steering 消息,下一次模型调用才看见用户的改口;这不等于中断或回滚工具
“接收了新消息”和“下一次决策看见了新消息”之间,可能隔着一批尚未完成的工具调用。

这个边界不是为了故意忽略用户。工具调用和结果需要保持对应,运行也需要一个明确的位置接入新输入。但它确实带来体验代价:一个较慢的工具批次,会让你觉得自己已经喊停,助手却还在忙。

更准确的提示应该是“已收到,将在下一次决策时参考”,而不是毫无条件地显示“已停止”。这是我会采用的产品表达建议,不是声称 OpenClaw 当前界面已经这样提示。

这里还要区分两个都叫 follow-up 的东西。OpenClaw 的 followup 模式,是宿主安排当前运行结束后的后续请求;Pi 循环内部也有 getFollowUpMessages(),用于循环准备停下时检查是否还有输入。名字接近,不代表它们是同一个队列,更不能把内部一次循环继续,直接当作宿主启动了一次新运行。steering 边界说明

排队也不总是严格先来后到

读队列文档,很容易留下一个印象:每条 lane 都是 FIFO,谁先到谁先执行。

但在这个源码快照里,底层入队还有 foregroundnormalbackground 三档优先级。比较顺序是先看优先级,再看入队序号。同一优先级保持先后顺序,高优先级可以排到尚未开始的低优先级任务前面。入队排序

运行入口把 usermanual 触发映射到前台,把 cronheartbeat 等触发映射到后台。这解释了为什么一个更晚到达的用户请求,可能先于仍在排队的后台工作开始;但它不意味着可以抢占已经运行中的任务。触发来源映射

我用原始调度模块做了一个可控实验:先把某条测试 lane 的并发额度设为 0,让任务只入队不启动;依次放入后台、普通、前台一、前台二,再把额度恢复为 1。实际顺序是前台一、前台二、普通、后台。另一个实验让任务先运行起来,再放入前台请求,后者仍然必须等待。

这类差异值得直接看代码确认。文档里的 FIFO 可以概括同级排序,却不足以表达整个调度规则。持续前台流量是否会让后台工作长期等候,也是应进一步压测的问题;本期没有跑出饥饿时间或吞吐量数据。

队列空出来了,不代表旧工作已经消失

正常任务成功或失败时,调度器都会完成对应的活动任务记账,并继续推动后面的任务。一个任务抛错,不应该让整条 lane 永久堵住。

清空等待队列则是另一件事。clearCommandLane() 移除的是尚未开始的条目,并向这些调用者返回明确的清空错误。已经运行中的任务仍然在运行。把“清空队列”当成“所有事情都停了”,会误判状态。清空与恢复入口

超时更值得仔细看。底层可配置的任务超时使用 Promise.race:超时后拒绝等待,并释放调度位置,让后续工作有机会继续。但原来的任务函数可能还在收尾,也可能稍后才返回。源码为此处理了迟到的拒绝;它没有靠这个竞速操作把任务函数杀死。超时实现

我用一个由测试控制何时完成的 Promise 验证了这个边界:超时错误已经返回,后面的任务已经开始,旧函数仍可在随后完成。这证明的是底层队列的语义,不是已经复现了完整 OpenClaw 中两个模型同时写坏会话的故障。上层还有取消信号、运行注册和写入锁,必须继续沿调用链判断。

会话运行 lane 和会话文件锁也不能互相替代。前者协调进程内运行,后者围绕特定写入阶段保护记录;所选实现还有释放、重新获取和清理阶段的处理,并不是整个模型调用期间始终握着同一把文件锁。会话写入锁适配

如果自己做一个助手,我会先把哪几件事写清楚?

这次共完成 25 项局部检查,直接运行原始队列和 lane 模块,仅替换诊断日志出口。检查覆盖同会话等待、不同会话并行、共享额度、优先级、失败释放、清空、超时及关闭排水时拒绝新任务。双层调度实验使用与源码相同形状的测试包装,没有启动完整 Gateway、真实模型或渠道;steering 部分依据固定版本源码与文档核对,未做端到端注入实验。

如果从零做一个小型助手,我会分别定义消息策略、运行调度和取消协议。消息策略决定补充信息放哪里,调度器负责顺序与额度,工具适配器则明确动作是否可取消、结果如何确认。不要用一个含糊的“忙碌”布尔值,把这些责任全塞在一起。

这套队列状态保存在进程的全局单例中,方便同进程不同打包模块共享;它不是一个持久消息代理,也不天然协调多台机器。需要多实例执行时,必须另外设计归属、持久化和恢复契约。进程内状态

回到那句“先别改”。一个可信的助手不仅要接受改口,还应能说明:新要求已经排在哪里,当前动作能否停止,哪些结果已经发生。下一篇我们再看另一半体验:它忙碌时陆续出现的文字,是模型原始输出,还是经过渠道适配之后,才变成你看到的那条消息?

继续阅读