拆解 OpenClaw:队列模式与并发控制
AI 正在处理你上一条消息——可能在读文件、跑命令、等模型返回——这时候你又发了一条新消息。怎么办?丢掉?排队?打断当前任务?这个看似简单的问题背后是一套完整的并发控制设计。
问题:消息撞车了
我:为什么需要队列模式?直接排队处理不就行了?
如果"排队处理"是指每条消息都触发一次独立的 agent run,那问题很大。一次 run 要带完整的 system prompt 和对话历史,每次都花几千到几万 token。你连发三条消息就是三次 run,大部分 token 花在了重复注入的上下文上。
更实际的问题是用户的意图可能跨多条消息。你先发"帮我改一下那个函数",紧接着又发"等等,先别改",再发"我想了想还是改吧,但用方案 B"。如果每条独立处理,模型先改了、又回滚了、又改了——三次 run,三次工具调用,结果可能还不对,因为它没有看到你的完整意图链。
队列模式解决的就是:当 agent 正忙的时候,新消息该怎么处理。
五种模式
我:有几种模式可选?
五种。
collect(默认)——把当前 run 期间收到的所有新消息攒起来,等 run 结束后合并成一条送给模型。你连发三条,模型一次性看到全部三条,理解完整意图后再回复。最省 token,也最不容易出错。
steer——把新消息直接注入到正在跑的 run 里。Gateway 在下一个工具调用的边界处检查队列,发现新消息就跳过当前 assistant message 里剩余的 tool call,注入新消息让模型立刻调整方向。适合需要中途纠正的场景。
followup——新消息排队等当前 run 结束,然后作为独立的新 turn 处理。和 collect 的区别是不做合并。
steer-backlog——steer 和 followup 的组合。新消息立刻注入当前 run,同时也保留一份等 run 结束后再触发一次 followup。好处是既能即时调整又不丢消息,坏处是可能产生两次回复。
interrupt——直接中断当前 run,用新消息重新开始。当前 run 的所有工作丢弃。最暴力,基本不推荐用。
graph LR
subgraph "用户发新消息(AI 正忙)"
M["新消息"]
end
M --> C["collect<br/>攒起来 → 合并处理"]
M --> S["steer<br/>注入当前 run"]
M --> F["followup<br/>排队 → 独立处理"]
M --> SB["steer-backlog<br/>注入 + 排队"]
M --> I["interrupt<br/>中断当前 run"]
Steer 的工具边界
我:steer 说"在工具边界注入",具体是什么意思?
模型在一次 run 里可能返回多个 tool call——比如先 read 文件、再 exec 命令、再 edit 另一个文件。Gateway 不会一口气全执行完,而是每执行完一个工具后检查一次队列。如果发现有新消息,就把剩余还没执行的 tool call 全部跳过,返回"Skipped due to queued user message"作为它们的工具结果,然后注入新消息让模型重新决策。
sequenceDiagram
participant User as 用户
participant GW as Gateway
participant LLM as 模型
LLM->>GW: tool_use: read("a.md"), exec("test"), edit("b.md")
Note over GW: 执行 read("a.md") ✅
User->>GW: "停,别改 b.md"
Note over GW: 工具边界 → 检查队列 → 发现新消息
Note over GW: exec("test") → Skipped ⏭️<br/>edit("b.md") → Skipped ⏭️
GW->>LLM: read 结果 +<br/>"停,别改 b.md"
Note over LLM: 看到新指令,调整方向
粒度是工具级别的,不是字符级别的。如果一个工具正在执行(比如一个跑了 30 秒的命令),它会等这个工具执行完才检查队列。正在执行的工具不会被打断。
steer 还有一个限制:如果模型当前不在调工具(纯文本生成中且没有 block streaming),就没有"边界"可以检查,steer 会 fallback 成 followup。
溢出和防抖
我:如果用户疯狂发消息,队列会爆掉吗?
有两道控制。
第一道是 debounce。debounceMs 默认 1000 毫秒——收到新消息后等 1 秒,如果这 1 秒内没有新消息就触发 followup turn,有新消息就重置计时器继续等。快速连发的消息自然被合并。这和 messages.inbound.debounceMs(消息进入 Gateway 前的合并)是不同层面的——两个可以叠加,双重防抖。
第二道是 cap。默认每个 session 最多排 20 条消息。超了就触发溢出策略 drop:
old → 丢最早的,保留最新的
new → 丢最新的,保留最早的
summarize → 把溢出的消息压缩成摘要,注入 followup
summarize 最巧妙——不真正丢消息,而是把超出 cap 的部分浓缩成几句话。用户发了 25 条但 cap 是 20,前 5 条变成摘要,后 20 条原文保留。
{
"messages": {
"queue": {
"mode": "collect",
"debounceMs": 1000,
"cap": 20,
"drop": "summarize",
"byChannel": {
"discord": "collect",
"whatsapp": "collect"
}
}
}
}
两层排队
我:这是单个 session 内的排队。不同 session 之间呢?
队列模式管的是"同一个 session 里消息怎么排队"。但还有一个全局层面:Gateway 进程对所有 session 的 agent run 有总并发上限。
graph TB
subgraph "全局并发控制"
ML["main lane<br/>上限 4"]
SL["subagent lane<br/>上限 8"]
CL["cron lane<br/>独立"]
end
subgraph "session 内排队(每个严格串行)"
S1["session A<br/>并发 1<br/>队列模式:collect"]
S2["session B<br/>并发 1<br/>队列模式:steer"]
S3["session C<br/>并发 1<br/>队列模式:collect"]
end
ML --> S1
ML --> S2
ML --> S3
每个 session 内严格串行——同一时刻只有一个 agent run 在处理某个 session 的消息,保证不会有两个 run 同时读写同一个 session 的历史文件。不同 session 可以并行,但总数受 main lane 的并发上限(默认 4)限制。如果 4 个 session 的 run 同时在跑,第 5 个 session 的消息要等其中一个结束。
子 Agent 走独立的 subagent lane(上限 8),不和主 session 抢位置。Cron 定时任务也有独立 lane。这样后台任务不会阻塞用户的实时对话。
一个容易忽略的细节:即使消息因为全局排队暂时没开始处理,Gateway 也会立刻发出 typing indicator(打字中...)。用户看到"对方正在输入"就知道消息收到了,不会以为 bot 挂了。
怎么选
我:实际用的时候怎么选模式?
看使用场景。日常聊天用 collect——你发多少条它都攒着一起处理,最省 token,最不容易出现"改了又改回来"的问题。编程任务用 steer——你看到模型在朝错误方向走,能及时喊停,它在下一个工具调用边界就会收到你的新指令。不确定选哪个就用默认的 collect,大多数场景够用。
也可以按 session 临时切换。发 /queue steer 切换当前 session 到 steer 模式,/queue reset 恢复默认。参数可以组合:/queue collect debounce:2s cap:25 drop:summarize。不影响其他 session。
整个队列系统没有外部依赖——纯 TypeScript + Promise 实现,跑在 Gateway 的 Node.js 事件循环里。没有 Redis,没有消息队列中间件,没有后台 worker 线程。对个人助手的规模来说,这样的简单实现已经足够可靠。