拆解 OpenClaw 并发模型:为什么 Subagent 默认并发比 Main 更高?

1981 字
10 分钟
拆解 OpenClaw 并发模型:为什么 Subagent 默认并发比 Main 更高?

修掉那个让并发配置始终不起作用的问题之后,OpenClaw 的并发终于开始按配置工作了。

但当我们重新检查默认配置时,又发现事情并没有那么简单。

一、sub 为什么比 main 大#

上一篇文章《追踪 OpenClaw 的一个隐藏 bug:并发配置为什么从未生效》里我们追踪了 OpenClaw 的一个并发状态隔离 bug——打包工具把一份运行时状态复制成了多份独立副本,导致 maxConcurrent 配多少都没用。我们提交了 Issue 和 PR,维护者在此基础上做了一次更全面的审计,把涉及十个模块的同类问题一起修了,随 v2026.3.12 发布。

在把 OpenClaw 版本升级完成后,我们检查了一下当前的并发配置:

agents:
defaults:
# 主 Agent 并发上限,默认 4
maxConcurrent: 10
subagents:
# 子 Agent 并发上限,默认 8
maxConcurrent: 8
cron:
# 定时任务并发上限,默认 1
maxConcurrentRuns: 5

subagents.maxConcurrent 默认是 8,主 Agent 的 maxConcurrent 默认只有 4。第一眼看到这组数字的时候,我们还以为又挖到了一个 bug——子 Agent 是从主 Agent 派生出来的,怎么并发上限反而更高?

通过对 OpenClaw 源码追完调用链之后发现,maxConcurrentsubagents.maxConcurrent 不是同一个并发池。我们一开始把它理解成”父子并发关系”,但代码里的实现并不是这么一回事:命令队列分成了三条独立的 lane(Main、Subagent、Cron),各自有自己的队列和并发上限,互不阻塞。

常见误解 vs 实际架构

二、先画个图#

先把最终追出来的结构画出来,后面的源码追踪基本都在验证这张图:

User Request
Session Lane (per-session serialization)
Global Command Lanes
┌────────────────┬────────────────┬────────────────┐
│ Main │ Subagent │ Cron │
│ default: 4 │ default: 8 │ default: 1 │
│ user messages │ background AI │ scheduled jobs │
└────────────────┴────────────────┴────────────────┘

再往下看就会发现,这里有两层控制:同一个 session 内先串行保序(比如 Discord 一个频道里的消息不会乱序处理),不同 session 之间再按 Main / Subagent / Cron 三条 lane 去抢全局并发槽位。

三条 lane 各管各的,不共享计数器。

分层架构

三、追源码#

先看配置怎么读。dist 文件里有两个函数,各自独立读各自的配置项,默认值也分开写死:

function resolveAgentMaxConcurrent(cfg) {
const raw = cfg?.agents?.defaults?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw))
return Math.max(1, Math.floor(raw));
return 4;
}
function resolveSubagentMaxConcurrent(cfg) {
const raw = cfg?.agents?.defaults?.subagents?.maxConcurrent;
if (typeof raw === "number" && Number.isFinite(raw))
return Math.max(1, Math.floor(raw));
return 8;
}

再看调用点,两个值分别塞进了不同的 CommandLane

setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(cfg));
setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(cfg));

到这里其实已经能判断:这俩参数控制的不是同一个并发池。

再往下看 setCommandLaneConcurrency 的实现。lane 的状态存储在一个 Map<string, LaneState> 里,每条 lane 独立维护自己的并发计数:

function setCommandLaneConcurrency(lane, maxConcurrent) {
const state = getOrCreateLaneState(lane);
state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent));
drainLane(state);
}

队列 pump 的核心循环只看当前 lane 自己的计数器,不跨 lane 检查:

while (state.activeTaskIds.size < state.maxConcurrent && state.queue.length > 0) {
// 从队列里取一个任务执行
}

那任务怎么路由到对应 lane 的呢?

子 Agent 运行时显式标记 lane: AGENT_LANE_SUBAGENT,普通请求不指定 lane 时 fallback 到 main,Cron 任务走 CommandLane.Cron。 源码注释写得也很直白:"session lane + global lane",执行路径是先拿 session 锁,再抢 global lane 槽位:

enqueueSession(() => enqueueGlobal(async () => { ... }))

两层都拿到了,任务再开始跑。

为了交叉验证我们的想法,我们还让 Codex 独立重新分析了一遍源码,最后落到的也是同一组函数和同一个 lane 结构。

四、这么拆的好处#

看到这里,三条 lane 的分工就比较清楚了:

  • Main:处理入站消息。用户在 Discord 或 Telegram 发了条消息,Agent 需要生成回复。用户在等,这是交互式的。
  • Subagent:子 Agent 任务。主 session 里 spawn 了一个 Codex 去分析代码,或者启动了一个子 Agent 做搜索。后台执行,不阻塞用户。
  • Cron:定时任务。心跳检查、信息采集、定期归档。按计划触发,和用户操作无关。

换个系统设计里的说法,这里做的就是 workload isolation——把三类任务拆到不同并发池里,避免它们互相抢槽位。Web 服务器把静态文件、API 请求和 WebSocket 连接分到不同线程池,道理是一样的。

如果子 Agent 和主 Agent 共享同一个池子,问题很明显:你 spawn 几个子 Agent 把池子占满了,新来的用户消息全部排队。机器人对所有人”失去响应”,直到某个子 Agent 跑完释放槽位。独立 lane 保证了不管后台跑多少子任务,用户消息的响应通道不会被堵。

这时候再回头看 sub(8) > main(4),意思就不一样了。4 个主 session 同时处理用户消息,每个都可能 spawn 1-2 个子 Agent。全局子 Agent 上限给到 8,刚好能容纳典型的并发量,不会因为槽位不够让一半主 session 的子任务排队。

五、几个容易算错的地方#

把 lane 关系理顺之后,前面几个容易误判的点也就解释得通了。

理论峰值是加法,不是乘法。 以我们当前的配置(Main=10, Subagent=8, Cron=5)为例,理论最大同时运行的 LLM 调用是 10 + 8 + 5 = 23,不是 10 × 8 + 5 = 85。很容易在这里算错,是因为 Subagent lane 只有一个全局池,不是每个 Main session 都各带一个。

配置该怎么调。 不同使用场景下侧重点不同:

  • 单人使用maxConcurrent 设 2 就够了,同时活跃的对话很少超过两个。subagents.maxConcurrent 设 4,留出每个主 session 各 spawn 1-2 个子 Agent 的空间。cron.maxConcurrentRuns 保持默认 1,任务量不大时串行足够。
  • 多人 / 多频道maxConcurrent 调到 4-6,确保不同用户的消息能并行处理。subagents.maxConcurrent 保持默认 8,和 Main 槽位的典型比例刚好匹配。cron.maxConcurrentRuns 可以调到 2,避免定时任务排队影响响应。
  • 频繁使用子 Agent(比如 ACP 模式委派编码任务):subagents.maxConcurrent 可以自由调大到 12 甚至更高,这条 lane 独立于主 Agent,不会影响用户消息的响应。
  • 大量定时任务cron.maxConcurrentRuns 调到 3-5。不过在调高之前,建议先用 staggerMs 把任务触发时间错开——我们自己就是用 staggerMs 把十几个 cron job 分散到不同时间点,减少同一时刻的并发压力。即便如此,密集时段仍然需要一定的并行能力。

以我们自己的配置为例(单人 + 多频道 + 重度 cron 用户):

agents:
defaults:
# 多频道并行,比默认 4 高
maxConcurrent: 10
subagents:
# 默认值,目前够用
maxConcurrent: 8
cron:
# 十几个定时任务,配合 staggerMs 错开触发
maxConcurrentRuns: 5

上一篇文章里的 bug 也有了更完整的解释。 之前并发 bug 导致所有 lane 的 maxConcurrent 都退化成了 1——Main、Subagent、Cron 全部变成串行。任何一个慢任务都会堵住整条 lane,后面的级联超时就是这么来的。修复之后三条 lane 各自恢复了应有的并发上限,系统恢复正常不是因为某一个配置改对了,而是因为整个队列终于按设计跑起来了。

六、结语#

一开始看到 sub=8main=4 时,我们以为又抓到了一个配置 bug。真正把调用链追完之后才发现,问题不在默认值,而在我们下意识把它理解成了”父子并发”。OpenClaw 的实现不是这一套——是 session 串行 + 三条独立 lane 的并发隔离。

搞清楚这一层以后,再去调 maxConcurrentsubagents.maxConcurrentcron.maxConcurrentRuns,就不再是碰运气了。


张昊辰 (Astralor) & 霄晗 (XiaoHan · OpenClaw Agent) · 2026.03.13

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

拆解 OpenClaw 并发模型:为什么 Subagent 默认并发比 Main 更高?
https://astralor.com/posts/openclaw-concurrency-lanes
作者
张昊辰
发布于
2026-03-13
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
张昊辰
从基础设施到 AI 产品,记录想法与进化
公告
站点正在建设中,内容持续更新中
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
10
分类
4
标签
29
总字数
44,920
运行时长
0
最后活动
0 天前

目录