Agent 怎么被唤起的:OpenClaw 的事件队列与心跳消费机制
你跟 Agent 说”20 分钟后提醒我开会”。20 分钟后,Agent 真的给你发了消息。
这中间发生了什么?Agent 没有一个进程在那里倒计时。它大部分时间是不活跃的。那到了时间,谁叫醒它,怎么叫醒它,叫醒之后它怎么知道该干嘛?
只是简单的cron+调API?
定时任务到期了,要让 Agent 处理。最简单的实现是:直接调一次模型 API,把事件内容发给 Agent 让它立即处理。
单个事件时这样没问题。但通知来源可能有好多个——后台命令跑完了、外部 webhook 回调进来了、子 Agent 执行完了、用户自己主动触发。当多个来源在短时间内同时触发时,“每个事件直接调一次模型”就不行了。
举个具体场景:3 秒内,定时任务到期了、后台命令也跑完了、webhook 也回来了。如果每个都直接调模型,会发生什么?
第一,三次并行调用会争抢同一个 session。Agent 的对话历史、正在执行的工具、当前上下文都是共享状态。三个调用同时读写同一个 session,就像多线程同时写同一个变量没加锁,对同时读到的内容会存在不稳定性。
第二,三次调用其实可以是一次。这 3 件事完全可以打包成一个 prompt,让 Agent 一次性看到“有 3 件事”并统一处理。分成 3 次调用,每次都要带上完整的 system prompt + 对话历史作为上下文,多交了两份 token 费用,而且 Agent 无法统一规划响应。
第三,每个生产者都得处理“Agent 正忙怎么办”的问题。如果生产者直接调模型,它就必须知道:Agent 在忙吗?模型 API 限流了吗?目标 session 存在吗?这些检查逻辑每个生产者都要重复实现一遍——而它们本质上是消费端的职责。
这三个问题的根源是同一个:生产者和消费者没有解耦。解决方案是:生产者只管“把消息放进一个队列”,消费者定时把队列里累积的所有消息一次性取出、打包成一个 prompt、只调一次模型。这就是 OpenClaw 的事件队列与心跳消费机制。
下面这张图展示了完整的数据流–从生产者到队列到消费者的全景。后续每一节会逐一拆解图上的每个部分。

生产者:谁往队列里写
根据源码,以下几种情况都会往事件队列里写消息:
– wake(定时任务 systemEvent 到期、Agent 手动调 cron tool 的 wake 动作)–最常见的入口
– exec 完成事件 (后台命令跑完,系统自动塞一条 “exec completed (id, code 0)”)
– 外部 hook(webhook 回调触发)
– subagent 完成事件 (子 Agent 跑完了,通知父 session)
它们都做同一件事:把一条纯文本写入目标 session 的队列。生产者不关心 Agent 在不在忙、模型是否可用、session 是否存在–它只管入队。
其中 `wake`(`src/cron/service/wake.ts`)是最典型的生产者,因为它同时做了入队和通知两步:
export function wake(state, opts) {// 第一步:文本入队state.deps.enqueueSystemEvent(text, enqueueOpts);// 第二步(可选):通知消费者来取if (opts.mode === "now") {state.deps.requestHeartbeat({ source: "manual", intent: "immediate", reason: "wake" });}}
第一步是生产–往目标 session 的队列写入文本。第二步是通知–告诉消费者”现在就来取”。
通知这一步不是必须的。因为消费者(心跳循环)本身每 30 分钟会自动轮询一次队列–即使没人通知它,队列里的消息最终也会被消费,只是最多晚 30 分钟。所以 wake 提供了两种 mode,让调用方决定”我这个事到底急不急”:
– `mode: “now”` → 入队 + 立即通知消费者(用于紧急事件,比如用户手动触发的提醒)
– `mode: “next-heartbeat”` → 入队 + 不通知,等消费者自然轮询时处理(用于不紧急的通知)
但源码揭示了一个重要细节。先解释一个前置概念:一个 Agent 可以有多个 session–主 session 是默认对话(你直接和 Agent 聊天用的),非主 session 是其他对话(比如接入 Telegram 某个群聊、Discord 某个 channel)。心跳循环默认只轮询主 session 的队列。
这就导致了一个问题:如果生产者往一个非主 session 的队列里塞了消息,又不主动通知心跳去取,这条消息会永远无人消费。所以 wake 的源码里做了特殊处理:
常规心跳只轮询主 session 的队列。往非主 session 塞了消息又不主动通知,这条消息会永远无人消费。所以对非主 session,不管调用方选的是 `now` 还是 `next-heartbeat`,系统都会立即通知心跳循环去那个特定 session 取消息。
// src/cron/service/wake.ts} else if (sessionKey) {// next-heartbeat + sessionKey still needs a targeted immediate wake.// Reasons:// 1. The regularly-scheduled heartbeat fires for the agent's main// session, not the supplied sessionKey, so it never peeks the queue// we just enqueued - the event would sit stranded indefinitely.state.deps.requestHeartbeat({ source: "manual", intent: "immediate", ... });}
队列:消息存在哪
事件队列实现在 `src/infra/system-events.ts`。源码开头的注释直接说明了它的定位:
// src/infra/system-events.ts// Lightweight in-memory queue for human-readable system events that should be// prefixed to the next prompt. We intentionally avoid persistence to keep// events ephemeral. Events are session-scoped and require an explicit key.
“intentionally avoid persistence”–刻意不持久化。队列只存在内存里,不写磁盘。进程一重启,还没被消费的事件就丢了。源码用 “intentionally” 这个词强调这是设计选择。
为什么敢丢?因为队列里放的是通知 (“该查邮件了””命令跑完了”),不是待完成的工作。丢了一条”该查邮件了”,最坏的结果是 Agent 这次没查。下个心跳周期或下次定时任务到期,还会再来一条。
而真正不能丢的工作(比如”每天 9 点帮我整理知识库”),走的是 agentTurn 路径–cron job 定义持久化在 SQLite 里,进程重启后从磁盘恢复,检测错过的任务并补执行。两层设计的分工很明确: 轻量通知走内存队列(可丢),重要任务走磁盘持久化(不丢)。
不持久化的好处是简单快速–入队就是往数组 push 一个对象,没有磁盘 IO,没有序列化开销。对于”通知”这个性质来说,这个 tradeoff 是划算的。
消费者: 怎么取出来处理
消费者是心跳循环 (heartbeat runner)–一个内置在 Gateway 启动流程中的定时循环。它不是用户创建的定时任务,而是基础设施:Gateway 启动时自动运行,不能被增删或暂停。
每隔 30 分钟(可配置),心跳循环触发一次。触发时做的事是发起一次完整的 LLM 调用–不是脚本执行。流程如下:
drain 事件队列 → 事件分类 → 构建 prompt → 选模型 → LLM 推理 → 处理输出 → 投递
drain:取出目标 session 队列中的全部事件,清空队列。
事件分类 (`heartbeat-events-filter.ts`): exec 完成事件有特殊格式(`exec completed (id, code 0) :: output…`)需要解析为结构化信息;cron 事件需要标记来源。不是所有文本都直接丢进 prompt。
构建 prompt: 发给模型的 prompt 由三部分拼成:
1. 事件文本–队列里取出的内容,作为 `System:` 行注入
2. 心跳指令–默认是:
Read HEARTBEAT.md if it exists (workspace context). Follow it strictly.Do not infer or repeat old tasks from prior chats.If nothing needs attention, reply HEARTBEAT_OK.
3. HEARTBEAT.md 内容(如果存在)–用户配置的周期性任务清单。Agent 收到这个 prompt 后,拥有完整的推理能力–它可以调用工具、读文件、发消息。
这也是为什么即使队列为空也要调一次模型–因为 Agent 可能在 HEARTBEAT.md 里配置了周期性任务(“每 2 小时检查邮件”),它需要看到当前时间然后自己判断该不该执行。、
输出处理:经过 `heartbeat-visibility.ts` 过滤–`showOk: false`(默认配置)意味着 Agent 回复 “HEARTBEAT_OK”(没事)时不发给用户。只有实质性输出才投递到消息通道。
执行完成后,runner 更新冷却状态、推算下一次心跳时间、重新 arm 全局 timer。循环回到等待。
消费者的保护机制
上面讲的是”正常流程”–生产者入队,消费者取出处理。但实际运行中,消费者不能无条件地响应每一次通知。源码中有几层保护机制,防止消费者被压垂或陷入无限循环:
– 250ms 合并窗口 (`heartbeat-wake.ts`):通知请求不是立即转发,而是累积 250ms 后批量处理。短时间内多个事件只触发一次模型调用。
– 优先级覆盖: 同一目标的多个通知按优先级合并(用户手动触发 > 普通事件 > 定时轮询 > 重试),不会重复执行。
– 忙时重试:如果心跳 runner 正在执行(模型推理未完成),新的通知不会丢弃,1 秒后自动重试。
– 防循环限流 (`heartbeat-cooldown.ts`):防止 Agent 工具触发 wake → wake 触发 Agent → Agent 又触发 wake 的无限递归。会有三个限制:未到计划时间不执行、距上次执行不足 30 秒不执行、60 秒内超过 5 次强制推迟。用户手动触发不受限流影响。
– 多 Agent 相位分散 (`heartbeat-schedule.ts`):多个 Agent 的心跳时间用 SHA256 哈希确定性地分散,避免同时触发抢占资源。
与定时任务的关系
这套事件队列 + 心跳消费机制是 OpenClaw 定时任务系统的 轻量执行路径。定时任务的 systemEvent 模式到期时做的事就是调一次 `wake()`–入队 + 通知心跳。cron 只负责”什么时候 wake”, 消费和推理完全交给心跳 runner。
另一条路径 agentTurn 不同,它会直接创建独立 session 完整执行。
事件队列 + 心跳负责”告诉 Agent 发生了什么,让它自己决定怎么做”。agentTurn 负责”让 Agent 自己去把事情做完”。两者对应不同的能力需求, 共用同一个调度引擎触发。
下一篇我们将再拆解OpenClaw的定时机制agentTurn。
夜雨聆风