OpenClaw Heartbeat 心跳机制源码深度解析:AI Agent 如何"自己醒来"
> 源码: github.com/openclaw/openclaw (src/infra/heartbeat-*, src/auto-reply/heartbeat.ts)
> 代码规模: 核心 ~1,500 行 TypeScript,涉及 ~15 个文件
> 研究方式: 直接 clone 源码逐文件阅读
> 研究时间: 2026-03-28
🎯 一句话版本
Heartbeat 是 OpenClaw 让 AI Agent "自主巡逻"的机制——每隔 30 分钟自动唤醒 LLM,让它检查 HEARTBEAT.md 里有没有待办事项。没事就回 HEARTBEAT_OK(静默丢弃,不打扰用户),有事就把结果投递到 Discord/Telegram 等渠道。它还是 Cron 系统事件、后台命令完成通知的投递通道。
🏗️ 两层架构
┌─────────────────────────────────────────────────────────────┐
│ 上层:HeartbeatRunner(heartbeat-runner.ts) │
│ "做什么":调用 LLM、处理回复、投递结果 │
│ │
│ ┌──────────────┐ ┌────────────┐ ┌───────────────────┐ │
│ │ runHeartbeat │ │ Preflight │ │ 回复处理 + 投递 │ │
│ │ Once() │→ │ 门控检查 │→ │ strip OK / 去重 │ │
│ └──────────────┘ └────────────┘ └───────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 底层:HeartbeatWake(heartbeat-wake.ts) │
│ "何时醒":合并去重、优先级排序、调度执行 │
│ │
│ ┌────────────────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ requestHeartbeat │→ │ 合并排队 │→ │ setTimeout │ │
│ │ Now() │ │ 250ms │ │ + handler() │ │
│ └────────────────────┘ └──────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘
⏰ 五种触发方式
| 触发方式 | 触发时机 | 优先级 |
|---|---|---|
| **定时间隔** | 默认每 30 分钟 | INTERVAL(1) |
| **Cron 事件** | cron job 注入 systemEvent 后 | DEFAULT(2) |
| **Exec 完成** | 后台命令执行完毕 | DEFAULT(2) |
| **手动 Wake** | 用户/系统主动唤醒 | ACTION(3) |
| **Hook 事件** | webhook/Gmail 等外部触发 | ACTION(3) |
优先级用于合并:250ms 内多个唤醒请求只执行优先级最高的那个。
🔄 完整执行流程
触发(定时/Cron/Exec/Wake/Hook)
↓
requestHeartbeatNow()
→ 入队 pendingWakes(按 agentId+sessionKey 去重)
→ schedule(250ms) // 合并延迟
↓
250ms 后 setTimeout 触发
↓
runHeartbeatOnce() — 核心函数
│
├─ 1. 检查开关:heartbeat 是否启用?
├─ 2. 活动时段:现在是否在 activeHours 内?
├─ 3. 队列检查:主队列是否空闲?(有用户消息在处理 → 跳过)
│
├─ 4. Preflight 门控:
│ ├─ HEARTBEAT.md 存在且为空 → 跳过(省 token)
│ ├─ Cron/Exec/Wake 事件 → 绕过文件门控(必须执行)
│ └─ 检查系统事件队列(有 cron 事件?有 exec 完成?)
│
├─ 5. 构建 Prompt:
│ ├─ 普通心跳 → "Read HEARTBEAT.md. If nothing, reply HEARTBEAT_OK."
│ ├─ Cron 事件 → "A scheduled reminder has been triggered: [内容]"
│ └─ Exec 完成 → "An async command has completed. Relay to user."
│
├─ 6. 调用 LLM(getReplyFromConfig)
│
├─ 7. 处理回复:
│ ├─ 包含 HEARTBEAT_OK → stripHeartbeatToken()
│ │ ├─ 剩余文本 ≤ 300 字符 → shouldSkip = true(静默)
│ │ └─ 有实质内容 → 继续投递
│ ├─ 24h 内相同文本 → 跳过(防唠叨)
│ └─ 裁剪 transcript(删除无信息量的心跳对话)
│
└─ 8. 投递到渠道(Discord/Telegram/WhatsApp/...)
📝 HEARTBEAT.md 门控机制
这是最节省 token 的设计之一:
function isHeartbeatContentEffectivelyEmpty(content: string): boolean {
// 逐行检查:
// - 空行 → 跳过
// - 纯标题行(# Header)→ 跳过
// - 空列表项(- [ ])→ 跳过
// - 有其他任何内容 → 不为空
}
| HEARTBEAT.md 状态 | 普通心跳 | Cron/Exec/Wake 事件 |
|---|---|---|
| 不存在 | 正常执行(LLM 自己判断) | 正常执行 |
| 存在但只有注释/标题 | **跳过(省 token)** | 正常执行(绕过门控) |
| 有实际内容 | 正常执行 | 正常执行 |
关键:Cron 事件触发时,即使 HEARTBEAT.md 为空也必须执行——因为要投递提醒内容。
🔇 HEARTBEAT_OK 的精妙处理
LLM 回复 HEARTBEAT_OK 意味着"没什么需要汇报的"。但处理这个简单的 token 涉及大量细节:
Token 剥离
// 支持各种格式的 HEARTBEAT_OK:
"HEARTBEAT_OK" → 静默
"HEARTBEAT_OK." → 静默(允许末尾标点)
"**HEARTBEAT_OK**" → 静默(Markdown 包裹)
"<b>HEARTBEAT_OK</b>" → 静默(HTML 包裹)
"HEARTBEAT_OK — 一切正常" → 剩余文本 ≤ 300字符 → 静默
"HEARTBEAT_OK 但有一个紧急事项..." → 剩余文本 > 300字符 → 投递
Transcript 裁剪
// 心跳前记录 transcript 文件大小
const preHeartbeatSize = stat.size;
// LLM 执行后(产生了 user+assistant turn)
// 如果回复是 HEARTBEAT_OK → 截断回到之前的大小
await fs.truncate(transcriptPath, preHeartbeatSize);
为什么? 如果不裁剪,每 30 分钟的心跳会产生一轮无意义的"心跳 prompt → HEARTBEAT_OK"对话,污染上下文窗口。日积月累,token 消耗显著增加。
去重(防唠叨)
// 记录上次发送的心跳文本
entry.lastHeartbeatText = normalized.text;
entry.lastHeartbeatSentAt = startedAt;
// 24 小时内完全相同的文本 → 跳过
const isDuplicate =
text === prevHeartbeatText &&
now - prevHeartbeatAt < 24 * 60 * 60 * 1000;
🌙 活动时段(Quiet Hours)
# 配置示例
heartbeat:
activeHours:
start: "09:00"
end: "22:00"
timezone: "Asia/Shanghai" # 或 "user" / "local"
// 支持跨午夜:
// start: "22:00", end: "06:00" → 22:00-次日06:00 活动
if (endMin > startMin) {
return currentMin >= startMin && currentMin < endMin;
}
return currentMin >= startMin || currentMin < endMin; // 跨午夜
深夜不打扰用户——但 Cron 事件绕过活动时段限制吗?
不,Cron 事件走的是 cron 调度器自己的执行路径(main 模式注入 systemEvent → requestHeartbeatNow()),活动时段检查在 runHeartbeatOnce() 里,所以也受活动时段限制。如果你设了一个凌晨 3 点的提醒但活动时段到 22:00 结束,提醒会在下次活动时段开始时触发。
🔀 Wake 层的合并与优先级
250ms 内可能收到多个唤醒请求:
// 同一个 agentId+sessionKey 的请求会合并,保留优先级最高的
const REASON_PRIORITY = {
RETRY: 0, // 重试(最低)
INTERVAL: 1, // 定时心跳
DEFAULT: 2, // Cron/Exec 事件
ACTION: 3, // 手动唤醒(最高)
};
Busy 处理:如果主队列正在处理用户消息(requests-in-flight),心跳跳过并在 1 秒后重试——用户消息永远优先于心跳。
🏠 Isolated Session 模式
heartbeat:
isolatedSession: true # 每次心跳创建全新 session
| 默认模式 | Isolated 模式 | |
|---|---|---|
| Session | 复用主会话 | 每次 forceNew |
| 上下文 | 完整对话历史 | 空(仅 workspace 文件) |
| Token 消耗 | 高(可能 100K+ tokens) | 低(仅 prompt + 文件) |
| 适合 | 需要对话上下文的任务 | 纯文件检查型任务 |
这是一个重要的 token 优化——如果你的心跳只需要读 HEARTBEAT.md,不需要知道之前聊了什么,用 isolated 模式可以省大量 token。
📡 投递系统
目标解析
heartbeat.target → resolveHeartbeatDeliveryTarget()
├─ "none" → 不投递(纯内部执行)
├─ "last" → 投递到最后活跃的渠道/对话
├─ "discord" → 投递到 Discord
├─ "telegram:-1001234567890" → 投递到指定 Telegram 群
└─ ...
可见性控制
const visibility = {
showOk: false, // 是否显示 HEARTBEAT_OK
showAlerts: true, // 是否显示告警/有内容的回复
useIndicator: true, // 是否使用状态指示器(如 WhatsApp 在线状态)
};
渠道就绪检查
// WhatsApp 等渠道需要确认连接状态
if (heartbeatPlugin?.heartbeat?.checkReady) {
const readiness = await heartbeatPlugin.heartbeat.checkReady({ cfg, accountId });
if (!readiness.ok) return; // 渠道未就绪,跳过
}
📊 事件类型与 Prompt 模板
不同触发原因使用不同的 prompt,让 LLM 知道该做什么:
| 触发类型 | Prompt 模板 |
|---|---|
| 普通心跳 | "Read HEARTBEAT.md if it exists. If nothing needs attention, reply HEARTBEAT_OK." |
| Cron 提醒 | "A scheduled reminder has been triggered. The reminder content is: [实际内容]. Please relay this reminder to the user." |
| Exec 完成 | "An async command you ran earlier has completed. The result is shown in the system messages above. Please relay the output to the user." |
注意 Cron 提醒的 prompt 变化:直接把提醒内容嵌入 prompt(而不是说"看系统消息"),因为系统消息可能不在 LLM 上下文中。
💡 与我们的关联
1. 这就是我们的"巡逻机制"
每 30 分钟心跳一次 = 我们的 Agent 每半小时"巡逻"一次 HEARTBEAT.md。如果你在里面写了任务,Agent 会在下一次巡逻时发现并处理。
2. Token 优化实践
OpenClaw 在心跳上做了极致的 token 优化:
- HEARTBEAT.md 为空 → 完全跳过 LLM 调用(零 token)
- HEARTBEAT_OK → 裁剪 transcript(不污染上下文)
- Isolated 模式 → 不发送对话历史(省 100K+ tokens)
- 24h 去重 → 不重复投递(省一次 LLM 调用 + 投递)
3. Cron 和 Heartbeat 的协作
这两个系统是配合工作的:
- Cron 的
main模式 → 注入 systemEvent → 触发 heartbeat → LLM 处理并回复 - Cron 的
isolated模式 → 完全绕过 heartbeat,独立执行
所以 heartbeat 不只是"定时检查",它还是 cron/exec 事件的投递通道。
4. 和上一篇 Cron 报告的关系
Cron 报告里的 requestHeartbeatNow() 调用最终走的就是这里的 heartbeat-wake.ts → heartbeat-runner.ts 流程。两篇报告合在一起就是 OpenClaw 完整的"自主行动"机制。
⚠️ 限制与注意
| 限制 | 说明 |
|---|---|
| 活动时段 | Cron main 模式的提醒也受活动时段限制 |
| Token 消耗 | 非 isolated 模式下每次心跳发送完整上下文,可能很贵 |
| 延迟 | 合并延迟 250ms + 定时间隔最大 30m = 响应不是即时的 |
| 单线程 | 心跳执行期间如果有用户消息进来,心跳让路 |
📊 评分
| 维度 | 评分(/10) |
|---|---|
| 代码质量 | 9.0 — 精细的 edge case 处理(token 剥离、transcript 裁剪、去重) |
| 架构设计 | 8.5 — 两层分离(wake + runner),职责清晰 |
| Token 优化 | 9.5 — 文件门控 + transcript 裁剪 + isolated 模式,极致省 token |
| 用户体验 | 8.5 — 活动时段 + 去重 + 静默 OK,不打扰用户 |
| 可配置性 | 8.5 — 间隔/prompt/目标/模型/活动时段全部可配 |
| **综合** | **8.5** |
报告由深度研究助手自动生成 | 2026-03-28
来源: 直接源码阅读 github.com/openclaw/openclaw