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 优化:

3. Cron 和 Heartbeat 的协作

这两个系统是配合工作的:

所以 heartbeat 不只是"定时检查",它还是 cron/exec 事件的投递通道

4. 和上一篇 Cron 报告的关系

Cron 报告里的 requestHeartbeatNow() 调用最终走的就是这里的 heartbeat-wake.tsheartbeat-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