OpenClaw 设计与实现

第12章 定时任务与自动化

作者 杨艺韬 · 14,028 字 · 发布于 · 更新于

第12章 定时任务与自动化

演示 Agent 与生产 Agent 的区别往往不是智能——而是主动性。一个不会自己巡逻的保安,和一个等你喊了才来的消防员,本质上一样不可靠。

本章要点

  • 理解被动 Agent 与主动 Agent 的哲学分野
  • 掌握三种自动化机制:Cron 定时触发、心跳周期检查、事件钩子响应
  • 理解三种机制的协同选择策略与适用场景
  • 思考主动 Agent 的伦理考量与安全边界

前两章赋予了 Agent 行动的能力:工具系统是软件层的双手,Node 系统是物理层的感官。但这些能力都有一个共同的前提——需要有人发起请求。如果没有人说话,Agent 就安静地等着。

一个真正有用的助手,不应该只会接球,还要会主动传球。

12.1 被动 Agent 与主动 Agent 的哲学分野

12.1.1 问题的本质

大多数 Agent 框架将 Agent 设计为响应式系统:用户提问,Agent 回答。一问一答,周而复始。这种设计背后的假设是——Agent 是工具,工具等待使用。

但想象一个真正优秀的助手会做什么——早上八点,你还没醒,它已经检查了收件箱中的紧急邮件、扫描了 GitHub 上的关键 Issue、审查了服务器指标,并在 Telegram 中留下一条精炼的摘要:"一切正常,但 staging 磁盘使用率达到 87%——建议今天清理。"

这不是被动响应,而是主动巡逻。就像一位尽职的夜间保安,不需要有人报警才巡楼,而是按时按路线主动巡查,发现异常才叫醒你。

被动的 Agent 是秘书——电话响了才接。主动的 Agent 是管家——你醒来时咖啡已经泡好,日程已经整理完毕,车已经预热。

🔥 深度洞察:主动性是智能的试金石

从认知科学的角度看,被动响应与主动行动之间的鸿沟,恰恰是反射弧与前额叶皮层的区别。膝跳反射是被动的——刺激来了,反应就来。但人类的真正智能体现在"预判"——看到乌云就带伞,不需要等到淋湿。这种预判能力在军事中叫做"OODA 循环"(观察-定向-决策-行动),其核心洞察是:谁能更快完成循环,谁就掌握主动权。OpenClaw 的 Cron/心跳/钩子三件套,本质上就是 Agent 的 OODA 循环基础设施——Cron 是定时"观察",心跳是条件"定向",钩子是事件"决策"。没有这套基础设施,Agent 永远在被动反应;有了它,Agent 开始真正"思考"时间维度上的策略。

12.1.2 "如果不这样设计会怎样?"

假设 OpenClaw 只有响应式能力,没有任何自动化机制。运营者想实现"每天早上 9 点发送待办总结",唯一的办法是:

方案 A:外部 Cron + API 调用。在系统 crontab 中添加一条规则,调用 OpenClaw 的 HTTP API 发送消息。问题:系统 cron 不了解 Agent 的上下文(当前模型、活跃会话、技能配置);每次调用都是无状态的"冷启动";需要管理两套配置(系统 cron + OpenClaw)。

方案 B:持久脚本。写一个 Python 脚本,循环等待到 9 点然后调用 API。问题:又一个需要部署、监控和维护的进程;崩溃了谁来重启它?

方案 C:运营者手动触发。每天早上手动问 Agent。问题:这正是我们试图自动化的事情——让人去触发自动化本身就是反模式。

三种方案都不令人满意,因为它们把"何时行动"的决策放在了 Agent 之外。Agent 知道自己有哪些技能、连接了哪些通道、当前用什么模型——它是最适合做调度决策的实体。定时任务应该是 Agent 的内建能力,而非外部附加物。

12.1.3 主动性的三个维度

实现主动性需要大多数框架完全缺乏的基础设施:

  1. 时间感知:在特定时间或间隔触发动作的能力。
  2. 条件判断:判断是否值得行动——不是每次都要做事,有时"一切正常"也是有价值的信息。
  3. 打扰智能:判断何时值得打扰人类、何时应该保持沉默——凌晨三点的非紧急通知不是贴心,而是骚扰。

OpenClaw 提供三种互补机制来满足这些维度:Cron 调度(精确时间触发)、心跳(周期性条件检查)和自动回复钩子(事件触发)。三者各有侧重,协同构成 Agent 主动性的完整基础设施。

关键概念:主动 Agent(Proactive Agent) 主动 Agent 不仅响应用户消息,还能在无人触发的情况下自主执行任务——定时巡检、定期报告、条件触发通知。OpenClaw 通过内置的 Cron 调度器、心跳机制和事件钩子三种机制实现主动性,让 Agent 从"等待指令的秘书"进化为"主动巡逻的管家"。

graph TB
    subgraph "图 12-1:主动性的三种机制"
        Cron["Cron 调度<br/>⏰ 精确时间触发<br/>'周一早上9点'"]
        Heartbeat["心跳<br/>💓 条件周期检查<br/>'每30分钟看看有没有事'"]
        Hooks["事件钩子<br/>⚡ 事件触发<br/>'有人@我时'"]
    end
    Cron -.-> |"互补"| Heartbeat
    Heartbeat -.-> |"互补"| Hooks
    style Cron fill:#1f618d,color:#fff
    style Heartbeat fill:#1e8449,color:#fff
    style Hooks fill:#c0392b,color:#fff

12.2 Cron:精确的时间触发

12.2.1 设计哲学:为什么不用系统 Cron?

OpenClaw 的 Cron 系统(src/cron/service.ts)运行在网关进程内部,使用 Node.js 定时器而非系统级 cron。这个选择看似违反"不要重新发明轮子"的原则——系统 cron 已经存在了 50 年,久经考验。为什么要自己实现?

原因1:上下文访问。系统 cron 只能启动外部命令。OpenClaw 的 Cron 作业需要完全访问 Agent 上下文——当前会话、加载的技能、可用的模型、活跃的通道。这些上下文在网关进程内部,外部 cron 无法访问。

原因2:管理面统一。作业通过 cron 工具和 openclaw CLI 增删改查,与其他 Agent 资源共用一套操作方式。如果定时任务在系统 crontab 中,运营者需要维护两套东西,且语法、部署流程和故障排查方式完全不同。

原因3:跨平台一致。系统 cron 在 Linux、macOS 和 Windows 上实现不同。OpenClaw 需要在所有平台上提供一致的行为。

⚠️ 注意:Cron 任务和心跳任务共享同一个 Agent 上下文(模型、工具、安全策略)。sessionTarget 为 "isolated" 的 Cron 作业跑在独立的 Session 里,对话历史不会与用户的主对话混在一起;但 sessionTarget: "main" 的作业和默认配置下的心跳都落在主会话上(见 12.2.4、12.3.3)。另外要注意——如果 Cron 任务触发了大量 LLM 调用,它会消耗与用户对话相同的 API 配额。

原因4:即时生效。作业存在 cron.store 指向的作业存储里,增删改之后调度器立即 armTimer() 重算下一次唤醒,无需重启网关。

权衡:网关停止时 Cron 作业也停止。系统 cron 不受应用状态影响——即使网关崩溃,cron 仍然准时触发。但对于 Agent 任务,网关不运行意味着 Agent 本身不可用,触发一个无法执行的任务没有意义。

⚠️ 常见陷阱:Cron 表达式的时区问题

作业没写 tz 时,OpenClaw 的 Cron 表达式按网关进程所在的时区解释(src/cron/schedule.ts 的 resolveCronTimezone() 回落到 Intl.DateTimeFormat().resolvedOptions().timeZone)。如果你的服务器设置为 UTC,而你期望"每天早上 9 点北京时间"执行:

// ❌ 错误:未写 tz 且网关进程时区为 UTC 时,这是 UTC 9:00(北京 17:00)
{ "schedule": { "kind": "cron", "expr": "0 9 * * *" } }

// ✅ 方法 1:逐作业写明时区(CLI 对应 --tz)
{ "schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" } }
// ✅ 方法 2:给网关进程设置 TZ=Asia/Shanghai(如在 systemd unit 中)
// ✅ 方法 3:计算 UTC 偏移 → 北京 9:00 = UTC 1:00
{ "schedule": { "kind": "cron", "expr": "0 1 * * *" } }

最稳妥的做法是逐作业写明 tz,既不依赖服务器时区,也避免心算转换。

⚠️ 常见陷阱:Cron 任务和用户对话共享 API 配额

Cron 任务使用独立的 Session,但共享相同的 Auth Profile 和 API 密钥。如果你配置了 10 个 Cron 任务每小时执行一次,高峰期可能与用户对话争抢 API 配额,导致用户对话触发 429 限流。建议:

  • 将 Cron 任务的模型设置为低成本模型(如 claude-haiku)
  • 错开 Cron 任务的执行时间,避免同时触发
  • 需要隔离配额时,让作业用 agentId 指向一个单独配置凭据的 Agent(作业本身没有逐作业的 Auth Profile 字段;另注意同一组织下的多个 Key 通常共享限额,见第 4 章)

12.2.2 调度循环的设计

核心调度循环(src/cron/service/timer.ts)的设计追求正确性而非效率——对于每分钟只有几个事件的调度系统,正确性远比纳秒级性能重要。

// src/cron/service/timer.ts:596 — armTimer(节选)
export function armTimer(state: CronServiceState) {
  if (state.timer) clearTimeout(state.timer);
  state.timer = null;
  if (!state.deps.cronEnabled) return;

  const nextAt = nextWakeAtMs(state);        // 所有 enabled 作业里最小的 nextRunAtMs
  if (!nextAt) return;                        // 没有待跑作业 → 干脆不设定时器

  const delay = Math.max(nextAt - state.deps.nowMs(), 0);
  // 地板:delay === 0 时强制垫一个最小间隔,防止 setTimeout(0) 热循环
  const flooredDelay = delay === 0 ? MIN_REFIRE_GAP_MS : delay;   // 2_000
  // 天花板:至少每分钟醒一次,抵抗时钟漂移与进程被挂起后的墙钟跳变
  const clampedDelay = Math.min(flooredDelay, MAX_TIMER_DELAY_MS); // 60_000

  state.timer = setTimeout(() => {
    void onTimer(state).catch((err) => {
      state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
    });
  }, clampedDelay);
}

这段不到二十行的代码里有四处值得细看的设计,前三处都对应着真实的故障:

单定时器策略。不为每个作业设独立定时器,只维护一个指向"最近的下一次触发"的定时器(nextWakeAtMs() 取所有 enabled 作业中最小的 nextRunAtMs)。作业数量增长时定时器数量不变。

地板 MIN_REFIRE_GAP_MS = 2_000。源码注释详细记录了这个值存在的原因:某个作业带着一个卡住的 runningAtMs 标记和一个已过期的 nextRunAtMs 时,findDueJobs 会因为 runningAtMs 跳过它,而维护逻辑又刻意不推进这个过期的 nextRunAtMs(issue #13992 的决定)——于是 onTimer 的 finally 块以 delay === 0 重新 armTimer,形成"饱和事件循环 + 把日志文件写到容量上限"的热循环。一个防御性的地板值,背后是一整条故障链。

天花板 MAX_TIMER_DELAY_MS = 60_000。即使下一个作业在 6 小时后,定时器也最多睡 60 秒就醒一次。注释给的理由是"抵抗调度漂移,并在进程被暂停或墙钟跳变后快速恢复"——笔记本合盖、虚拟机挂起、NTP 校时,都会让一个长睡的 setTimeout 醒来时早已错过时机。

回调刻意不写成 async。注释解释得很坦率:Vitest 的假定时器辅助函数会 await 异步回调,这会卡住那些模拟长任务的测试;运行时行为不变。这是一处为可测试性做的让步——值得注意的是作者把理由写进了注释,否则后人极可能"顺手优化"成 async。

此外,onTimer 在发现 state.running 为真(上一个作业还在跑)时,会用固定的 MAX_TIMER_DELAY_MS 重新武装定时器再返回。注释说明了不这么做的后果:一个超过 60 秒的长任务会让钳制后的定时器在 running 期间触发,早退分支直接返回而不留定时器,调度器就此静默死亡,直到下次网关重启。

时区处理:CronSchedule 的 cron 分支带一个可选的 tz 字段({ kind: "cron"; expr: string; tz?: string; staggerMs?: number }),逐作业指定时区,不指定则按进程时区解释。另有一个 staggerMs 提供确定性的错峰窗口(0 表示严格按点执行)——避免整点时刻所有作业一拥而上。

12.2.3 重启恢复:错过的作业怎么办?

这是任何非系统级调度器必须回答的关键问题:如果网关在凌晨 2 点到 5 点停机,而有一个 3 点的作业,启动时应该补执行吗?

图 12-2:补偿执行策略

stateDiagram-v2
    [*] --> Running: 网关启动
    Running --> Stopped: 网关停止
    Stopped --> Recovery: 网关重启
    Recovery --> Running: 恢复完成
    note right of Recovery
        1. 加载持久化状态
        2. 检测错过的作业
        3. 每个作业至多补跑一次(最多 5 个立即跑,其余错峰)
        4. 重新计算未来调度
    end note

OpenClaw 的答案是"启动追赶"(runMissedJobs() → planStartupCatchup(),src/cron/service/timer.ts)。这套逻辑有三道闸门,每一道都值得单独说:

闸门一:只补"最近一次"错过的槽位,不重放历史。启动时判定"错过"有两条路:一是持久化的 nextRunAtMs 已经落在过去;二是(仅限 cron 表达式作业)computeJobPreviousRunAtMs(job, nowMs) > job.state.lastRunAtMs——拿"按表达式算出的上一个应触发时刻"和"实际最后跑过的时刻"比。无论走哪条路,一个作业在启动时最多补跑一次:停机 24 小时的每小时作业只补一次,跑完再从当前时刻重算下一次。24 小时前的监控数据早就没有意义了。追赶是恢复到当前状态,重放是重现历史——这里选的是前者。

第二条路还有一个前置条件很关键:

const lastRunAtMs = job.state.lastRunAtMs;
if (typeof lastRunAtMs !== "number" || !Number.isFinite(lastRunAtMs)) {
  // Only replay a "missed slot" when there is concrete run history.
  return false;
}

没有运行记录的作业不走"按表达式反推"这条路。刚创建的作业不会仅因为"它的上一个理论槽位在过去"就在启动时立刻炸一次——新建作业等的是它自己持久化的 nextRunAtMs。

闸门二:尊重错误退避窗口。若作业上次失败且仍在 errorBackoffMs(consecutiveErrors) 的退避窗口内,重启不会绕过退避提前重试。注释写得很明白:"Respect active retry backoff windows on restart, but allow missed-slot replay once the backoff window has elapsed." ——重启不是重置失败计数的后门。这一条挡住了"一直失败的作业在网关频繁重启时被反复触发"的雪崩。

闸门三:限量 + 错峰。

const DEFAULT_MISSED_JOB_STAGGER_MS = 5_000;
const DEFAULT_MAX_MISSED_JOBS_PER_RESTART = 5;

启动时最多立即补跑 5 个(按 nextRunAtMs 升序,最该跑的先跑),其余被推迟并逐步重新排期,彼此间隔 5 秒。日志会明确记一条 "cron: staggering missed jobs to prevent gateway overload"。这个上限来自 issue #18892——源码注释直接把 issue 链接贴在字段上。长时间停机后一口气跑完所有积压作业,本身就是一次自我 DDoS。

12.2.4 逐作业模型选择

逐作业模型选择是一个容易低估的功能。先说清楚作业住在哪里:Cron 作业不写在配置文件里,而是存在 cron.store 指向的作业存储中(CronStoreFile = { version: 1; jobs: CronJob[] }),通过 cron 工具或 CLI 增删改。CronConfig(src/config/types.cron.ts)里只有 enabled / store / maxConcurrentRuns / retry / failureAlert / sessionRetention 等全局项。

一个作业的形状(cron 工具的 add 动作,schema 见 src/agents/tools/cron-tool.ts):

{
  "name": "daily-report",
  "schedule": { "kind": "cron", "expr": "0 9 * * 1-5", "tz": "Asia/Shanghai" },
  "sessionTarget": "isolated",
  "payload": {
    "kind": "agentTurn",
    "message": "总结昨天的活动和待办任务",
    "model": "anthropic/claude-sonnet-4-20250514",   // 摘要需要高质量模型
    "fallbacks": ["openai/gpt-4o"],                   // 逐作业降级链,覆盖全局设置
  },
  "delivery": { "mode": "announce", "channel": "telegram", "to": "<chatId>" },
}
{
  "name": "health-check",
  "schedule": { "kind": "every", "everyMs": 900000 },     // 每 15 分钟
  "sessionTarget": "isolated",
  "payload": {
    "kind": "agentTurn",
    "message": "检查 https://example.com 是否响应。如果宕机则告警。",
    "model": "openai/gpt-4o-mini",       // 健康检查不需要强模型
    "lightContext": true,                 // 轻量引导上下文,进一步省 token
  },
}

这里有几处结构性的约束值得记住(工具描述里标为 CRITICAL CONSTRAINTS):

  • sessionTarget: "main" 必须配 payload.kind: "systemEvent" —— 主会话作业只能往会话里注入一条系统事件,再由调度器唤醒一次心跳去处理,不能自己直接发起一轮 Agent 推理。
  • sessionTarget 为 "isolated" / "current" / "session:<id>" 时必须配 payload.kind: "agentTurn"。
  • 省略 sessionTarget 时按 payload 反推:systemEvent → main,agentTurn → isolated。

这个约束组合背后是一条清晰的设计线:主会话是用户的地盘,定时任务不能在里面自说自话地跑一整轮推理——它只能留一张纸条(系统事件),然后请求心跳(src/cron/service/timer.ts:入队后调用 requestHeartbeatNow();wakeMode 默认 "now" 时直接 runHeartbeatOnce()),由那一轮心跳决定怎么处理。要让作业自己跑推理,去开一个隔离会话。

三种调度类型也各有位置:{ kind: "at", at: "<ISO-8601>" } 一次性、{ kind: "every", everyMs } 固定间隔、{ kind: "cron", expr, tz?, staggerMs? } 表达式。工具描述特意注明:"ISO timestamps without an explicit timezone are treated as UTC."

这种设计的成本影响很直接。旗舰模型与小模型的单价往往相差悬殊,而一个每 15 分钟触发的作业一个月要跑近三千次——对高频作业来说,模型选择不是性能参数,而是成本参数。除了 payload.model,还可以配 payload.fallbacks(逐作业降级链,覆盖 Agent/全局设置)和 payload.lightContext(轻量引导上下文)。

大多数 Agent 框架不支持逐作业模型选择——它们假设一个 Agent 始终使用同一个模型。OpenClaw 认识到不同任务有不同的智能需求和成本预算,让运营者为每个作业选择最合适的模型。

12.2.5 Cron 表达式速查与实用示例

对于不熟悉 Cron 表达式的读者,以下是 OpenClaw 中最常用的调度模式:

┌───────────── 分钟 (0-59)
│ ┌───────────── 小时 (0-23)
│ │ ┌───────────── 日期 (1-31)
│ │ │ ┌───────────── 月份 (1-12)
│ │ │ │ ┌───────────── 星期几 (0-7, 0和7都是周日)
│ │ │ │ │
* * * * *

日常运维场景:

表达式 含义 典型用途
0 9 * * 1-5 工作日早上 9:00 日报、待办总结
0 9 * * 1 每周一早上 9:00 周报
0 1 1 * * 每月 1 日凌晨 1:00 月度报告、数据归档
*/15 * * * * 每 15 分钟 健康检查、服务监控
0 */2 * * * 每 2 小时 数据同步、缓存刷新
0 0 * * * 每天午夜 日志轮转、磁盘清理
30 8 * * 1-5 工作日早上 8:30 早会提醒
0 22 * * 0 每周日晚上 10:00 下周计划生成

进阶模式:

// 通过 cron 工具的 add 动作创建,三个独立作业(此处并列只为对照)
{
  // 每 5 分钟检查一次,但只在工作时间(避免深夜 token 浪费)
  "name": "business-hours-check",
  "schedule": { "kind": "cron", "expr": "*/5 9-18 * * 1-5" },
  "sessionTarget": "isolated",
  "payload": { "kind": "agentTurn", "message": "检查 API 响应时间", "model": "openai/gpt-4o-mini" },
}
{
  // 每月最后一天 17:00(croner 10.0.1 支持 L;实测下次触发为 10-31、11-30、12-31)
  "name": "monthly-close",
  "schedule": { "kind": "cron", "expr": "0 17 L * *" },
  "sessionTarget": "isolated",
  "payload": { "kind": "agentTurn", "message": "生成本月财务摘要" },
}
{
  // 每季度第一天
  "name": "quarterly-review",
  "schedule": { "kind": "cron", "expr": "0 10 1 1,4,7,10 *" },
  "sessionTarget": "isolated",
  "payload": { "kind": "agentTurn", "message": "生成季度 OKR 回顾" },
}

💡 提示:OpenClaw 用 croner(v2026.3.31 依赖版本 ^10.0.1,见 package.json;引入点 src/cron/schedule.ts 的 import { Cron } from "croner")解析表达式,支持标准五字段和扩展的六字段格式(第一个字段为秒)。L(最后一天)之类的扩展语法是否可用取决于 croner 本身,OpenClaw 不额外实现。还要注意:OpenClaw 构造 Cron 时只传了 timezone 与 catch,因此沿用 croner 的默认——"日期"和"星期几"两个字段同时受限时按或组合(与传统 cron 一致)。像 0 17 L * 1-5 这样的写法并不是"每月最后一个工作日",而是"每月最后一天 或 每个工作日"的 17:00(用 croner 10.0.1 实测,从 2026-10-01 起前四次触发是 10 月 1、2、5、6 日)。

另外提醒:src/cron/schedule.ts 里带着一段绕过 croner 年份回退 bug 的补丁(注释:"Workaround for croner year-rollback bug: some timezone/date combinations…")。这类"第三方库的边角 bug"是自建调度器绕不开的税——上一节说的"不要重新发明轮子",代价就是要替轮子打补丁。

12.2.6 源码细节:定时器的防御性设计

armTimer / onTimer 都在 src/cron/service/timer.ts(ops.ts 只是调用方)。这个文件是防御性编程的教科书——12.2.2 已经讲了定时器的地板与天花板,这里补三处。

整点作业错开(src/cron/stagger.ts)。大量作业配成"每小时整点"(0 * * * *)时,同时触发会造成 API 调用风暴。系统检测这种模式并给一个默认 5 分钟的错峰窗口:

// src/cron/stagger.ts(节选;resolveCronStaggerMs 的参数类型与 expr 校验从简)
export const DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000; // 5 分钟

export function isRecurringTopOfHourCronExpr(expr: string) {
  const fields = parseCronFields(expr);
  if (fields.length === 5) {                       // "0 * * * *"
    const [minuteField, hourField] = fields;
    return minuteField === "0" && hourField.includes("*");
  }
  if (fields.length === 6) {                       // "0 0 * * * *"(带秒字段)
    const [secondField, minuteField, hourField] = fields;
    return secondField === "0" && minuteField === "0" && hourField.includes("*");
  }
  return false;
}

export function resolveCronStaggerMs(schedule): number {
  const explicit = normalizeCronStaggerMs(schedule.staggerMs);
  if (explicit !== undefined) return explicit;     // 显式配置优先,配 0 就是严格按点
  return resolveDefaultCronStaggerMs(schedule.expr) ?? 0;
}

注意 staggerMs 是可显式覆盖的,配成 0 即恢复严格按点执行。默认加错峰、允许显式关掉——把"多数人不知道自己需要"的保护做成默认值,同时给知道自己在做什么的人留出口。

指数退避的失败恢复:

const DEFAULT_BACKOFF_SCHEDULE_MS = [
  30_000,      // 第 1 次错误 →  30 秒
  60_000,      // 第 2 次错误 →   1 分钟
  5 * 60_000,  // 第 3 次错误 →   5 分钟
  15 * 60_000, // 第 4 次错误 →  15 分钟
  60 * 60_000, // 第 5 次及以后 → 60 分钟
];

errorBackoffMs() 用 Math.min(consecutiveErrors - 1, scheduleMs.length - 1) 取下标,所以第 5 次之后恒定 60 分钟。这个策略平衡了"快速恢复"(首次失败仅等 30 秒)和"避免风暴"(持续失败时大幅降低重试频率)。对周期作业,失败后的下一次运行取"正常下一槽位"与"结束时刻 + 退避"中较晚的那个,这张表不可配置;cron.retry.backoffMs 只作用于下面说的一次性作业重试(默认取这张表的前三档 30 秒 / 1 分钟 / 5 分钟)。

瞬时错误的分类重试(仅一次性作业):CronRetryOn 把可重试的错误限定为五类——"rate_limit" | "overloaded" | "network" | "timeout" | "server_error",靠 TRANSIENT_PATTERNS 里的正则从错误文本里认出来。一次性(kind: "at")作业默认最多重试 3 次(DEFAULT_MAX_TRANSIENT_RETRIES,issue #24355)。只对"重试有意义的错误"重试——提示词写错了重试一百次也还是错的。

12.3 心跳:智能周期检查

"心跳"是所有需要"判断是否有事要做"的系统的通用机制——从操作系统的 idle loop,到数据库的 background worker,再到 Kubernetes 的 reconcile loop,它们在精神上都是"心跳"。心跳的核心价值不是"频繁执行"、而是"保持感知":让系统在"无事发生"的时候仍然对外部世界保持觉察,以便在"事情发生时"能及时响应。这种"持续感知 + 按需响应"的模式,是所有"长时间运行的进程"都需要设计的架构要素。对于 Agent 系统而言,心跳的特殊性在于——"感知"这件事本身可能就涉及 LLM 调用(比如"总结最近 30 分钟的日志里有没有异常"),因此必须在"感知频率"和"感知成本"之间找到合适的平衡。OpenClaw 在这里的旋钮都是显式配置:固定间隔 every、活跃时段 activeHours,以及砍掉会话历史的 isolatedSession / lightContext(见 12.3.3)。

12.3.1 心跳的本质:Cron 的补充而非替代

初看之下,心跳和 Cron 解决同一个问题——定期执行某些操作。但它们的执行模型有根本差异:

维度 Cron 心跳
时间精度 精确("周一早上 9 点") 近似("大约每 30 分钟")
执行保证 总是触发,总是执行 触发后 Agent 决定是否行动
多任务 每个调度一个作业 将多个检查合并到一次执行
跳过逻辑 不可跳过 可返回 HEARTBEAT_OK(无事可做)
Token 成本 每次完整模型调用 无事时只需一次轻量推理;HEARTBEAT.md 实际为空时直接跳过、不调模型
用途 报告、备份、定期操作 监控、告警、条件响应

心跳的关键创新是"Agent 有权什么都不做"。Cron 作业的语义是命令式的——"做这件事"。心跳的语义是条件式的——"看看有没有事需要做,没有就什么都不做"。这种条件执行在监控场景下可以节省大量 token。

12.3.2 HEARTBEAT.md:Agent 的巡检清单

心跳的工作原理:在配置的间隔内,网关向 Agent 发送特殊心跳消息。Agent 读取工作区中的 HEARTBEAT.md,评估每个项目,然后采取行动或回复 HEARTBEAT_OK:

<!-- HEARTBEAT.md -->
## 检查(轮换,每次心跳 2-3 项)
- [ ] 邮件:检查紧急未读(VIP 列表中的发件人)
- [ ] 日历:未来 4 小时的事件
- [ ] GitHub:需要审查的开放 PR
- [ ] 磁盘:检查 / 和 /home 使用率
- [ ] 服务:检查 https://api.example.com 健康状态

## 规则
- 23:00-08:00(中国时间)跳过所有检查
- 邮件:仅当发件人在 VIP 列表时标记
- 磁盘:任一分区超过 85% 才告警

这种设计将"检查什么"的知识以声明式方式存储在工作区文件中——运营者可以用编辑器修改,无需重启网关。Agent 负责"如何检查"的执行逻辑。

12.3.3 心跳的可配项:固定间隔 + 活跃时段

需要先纠正一个常见误解:v2026.3.31 的心跳没有"自适应频率"——没有按系统负载、错误率或"连续无事"自动伸缩间隔的机制。真实的配置面是 agents.defaults.heartbeat(src/config/types.agent-defaults.ts,以下为节选,注释译为中文,省略了 to / accountId 等投递细项):

heartbeat?: {
  
  every?: string;
  
  activeHours?: {
    start?: string;                              // 24 小时制 HH:MM,含
    end?: string;                                // 24 小时制 HH:MM,不含;"24:00" 表示到当天结束
    timezone?: string;                           // "user" | "local" | IANA 时区 id,默认 "user"
  };
  model?: string;                                // 心跳专用模型覆盖
  session?: string;                              // "main" 或显式会话键
  target?: "last" | "none" | ChannelId;          // 投递目标(未设置时按 "none",即不对外投递)
  directPolicy?: "allow" | "block";              // DM 投递策略,默认 allow
  prompt?: string;                               // 覆盖默认心跳提示词
  ackMaxChars?: number;                          // HEARTBEAT_OK 之后允许的最大字符数(实际默认 300,见下文)
  lightContext?: boolean;                        // 轻量引导上下文
  isolatedSession?: boolean;                     // 隔离会话,不带历史
  suppressToolErrorWarnings?: boolean;
  includeReasoning?: boolean;
};

"夜里别打扰我"不是靠自适应算法,而是靠 activeHours 这个显式窗口——用配置表达意图,比让系统去猜要可靠得多。

真正的省钱旋钮是另外两个,注释写得很直白:

lightContext: Lightweight mode keeps only HEARTBEAT.md from workspace bootstrap files.

isolatedSession: The heartbeat only sees its bootstrap context (HEARTBEAT.md when lightContext is also enabled). Dramatically reduces per-heartbeat token cost by avoiding the full session transcript.

这两个开关指向同一个洞察:周期性任务的 token 大头往往不是提示词本身,而是被一起塞进去的会话历史。一次心跳的提示词可能只有几百 token,但如果它跑在主会话里、带上几万 token 的对话记录,成本就完全由历史长度决定,与心跳本身无关。isolatedSession 把这条尾巴直接砍掉。

ackMaxChars 是个容易忽略但很实用的细节,而且它的默认值又是一处注释与实现打架(和第 6/10/13 章讲的 tools.exec.security 同款):src/config/types.agent-defaults.ts:251 的 doc comment 写 default: 30,但真正兜底的常量是 src/auto-reply/heartbeat.ts:9 的 DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300——src/infra/heartbeat-runner.ts:162-169 的 resolveHeartbeatAckMaxChars() 与 src/infra/heartbeat-summary.ts:88、:106 的回落用的都是它。以实现为准:默认 300。它的作用是——模型在回复开头或结尾放了 HEARTBEAT_OK、除此之外只多说几个字(比如 HEARTBEAT_OK (all good))仍然算作"无事",不会被当成告警投递出去;但如果后面跟了一大段文字,说明模型其实有话要说,就按告警处理。给模型的"格式遵从度"留一点公差,比要求它字节级精确输出要务实。

12.3.4 心跳的提示词契约

心跳没有独立的状态文件(不存在 heartbeat-state.json 之类的东西)。它的全部机制就是一句提示词加一个约定的应答词:

// src/auto-reply/heartbeat.ts:7
"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."

// src/auto-reply/tokens.ts
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";

系统提示词那边也有对应的说明(src/agents/system-prompt.ts):

OpenClaw treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it). If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.

注意 "Do not infer or repeat old tasks from prior chats" 这半句——它防的是一个很具体的失效模式:模型看到会话历史里有人说过"记得检查磁盘",就在每次心跳里都去检查一遍,哪怕 HEARTBEAT.md 里根本没这一项。提示词里的否定句往往是踩过坑的证据。

所以"检查什么、怎么轮换、什么时候跳过"这些逻辑,全部由运营者写在 HEARTBEAT.md 里,由模型自己遵守——系统只检查这个文件是否"实际为空"(只有空行、Markdown 标题和空的列表项时跳过本次心跳、不调模型),不解析其中的条目,也不替模型记账(src/agents/workspace.ts:DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";判空见 src/auto-reply/heartbeat.ts 的 isHeartbeatContentEffectivelyEmpty())。这是一个相当激进的设计选择:把状态管理的责任推给模型和文件本身,换来的是零额外机制、运营者用编辑器就能改。代价则是没有强制力——如果模型某次没遵守轮换约定,系统不会发现。

这也解释了为什么 12.3.2 那份 HEARTBEAT.md 示例里的"轮换 2-3 项""23:00-08:00 跳过"是写给模型看的约定,而不是系统会强制执行的规则。唯一由系统强制的时间窗是配置里的 activeHours。

一个典型工作日的心跳时序大致是这样(every: 30m,activeHours: 08:00-23:00,并已把 target 设为某个通道——默认的 "none" 不对外投递):

08:00  [#1] 模型按 HEARTBEAT.md 轮换检查 → 发现 VIP 邮件 + 10:00 会议 → 回复告警文本 → 投递
08:30  [#2] 无异常 → 回复 HEARTBEAT_OK → 系统识别为 ack,丢弃,不投递
09:00  [#3] 无异常 → HEARTBEAT_OK
09:30  [#4] 会议 30min 后 → 回复提醒文本 → 投递
...
12:30  [#N] 磁盘 87% → 回复告警 → 投递
23:00       超出 activeHours 窗口 → 系统根本不发起心跳(零成本)

三个观察:

  • 间隔始终是配置里那个 every,不会因为连续 OK 而自动拉长——想省钱得自己把 every 调大,或者开 isolatedSession。
  • "无事"的成本不是零,而是"一次轻量推理"——模型仍然要被调用一次才能回 HEARTBEAT_OK。真正零成本的只有 activeHours 之外的时段,以及 HEARTBEAT.md 实际为空而被跳过的那几轮。
  • 投递与否由回复内容决定(HEARTBEAT_OK 出现在开头或结尾、剩余文字不超过 ackMaxChars → 丢弃),而不是由系统判断"有没有异常"。

12.3.5 心跳机制的独特价值:为什么不是"低配版 Cron"

初学者常将心跳视为"频率更灵活的 Cron"——这是对心跳最深的误解。心跳与 Cron 的核心区别不在于调度灵活性,而在于认知模型:

Cron 是无状态命令:"不管发生了什么,在这个时间点执行这个任务。"它不关心上次执行的结果,不关心系统当前状态,到点就执行。

心跳是有条件巡检:"看看有没有需要我关注的事情。如果没有,回一句 HEARTBEAT_OK 就完事。"判断权在模型手里,系统只负责按时唤起和识别应答词。

这种区别在 token 经济学上体现得最明显。假设你要监控 5 个服务:

  • 用 Cron 实现:5 个独立作业各自按点触发,每次都是一轮完整的 Agent 推理与投递——即使 99% 的时间一切正常,成本照付。
  • 用心跳实现:一次心跳把 5 项一起看(HEARTBEAT.md 里可以让模型轮换),正常时只回一个 HEARTBEAT_OK 就被丢弃;配上 isolatedSession + lightContext,每次的输入就只有心跳提示词加一份 HEARTBEAT.md。

具体能省多少取决于会话历史长度、模型定价和检查项复杂度,本专栏不给一个凭空的倍数——但方向是确定的:Cron 的成本与作业数量成正比,心跳的成本与"有事发生的频率"相关。

心跳的另一个独特价值是跨检查项关联。Cron 的每个作业独立执行,互不知晓。心跳的 Agent 在一次执行中看到所有检查项,可以做关联分析:

  • "磁盘使用率从昨天的 75% 涨到 85%,且 error.log 大小翻倍 → 可能是日志泄漏,而非正常增长"
  • "API 延迟升高 + CPU 使用率正常 → 可能是下游服务问题,而非本机性能问题"

这种关联分析是 Cron 的独立作业模型无法实现的。

12.3.6 心跳 vs. 外部监控系统

一个合理的问题:为什么不用 Datadog、Grafana 或 Prometheus 做监控,心跳做 Agent 特有的事情?

答案是心跳不是监控系统的替代——它们互补。传统监控系统擅长收集和可视化指标,但不具备"智能判断"能力。心跳的优势在于 Agent 可以综合多个信号做出判断:磁盘使用率 85% 本身不紧急,但如果日历显示今天有大型数据导入任务,Agent 可以提前预警。这种跨数据源的关联判断,是传统监控系统需要复杂规则引擎才能实现的。

12.4 事件钩子:响应式自动化

"事件钩子"是软件架构里最古老也最强大的扩展机制之一。从 Emacs 的 hook,到 Rails 的 callback、React 的 useEffect,再到 Kubernetes 的 admission webhook——"在某个事件发生时插入自定义逻辑"这个模式,在不同层次不同领域反复出现。它的魅力在于"关注点分离":核心逻辑专注于"发生了什么"、扩展逻辑专注于"响应什么",两者通过事件这个中间层解耦,各自都能独立演化。对于 Agent 系统,事件钩子的重要性被进一步放大,因为 Agent 的"响应性"本身就是一种核心能力。用户发送消息是事件,工具执行完成是事件,外部通知到达是事件,定时任务触发也是事件——几乎 Agent 的所有行为都可以用"事件 → 响应"的范式来建模。OpenClaw 的钩子系统把这种范式作为一等公民,让插件开发者可以用统一的心智模型来思考所有扩展点。这种"事件驱动 + 插件可扩展"的架构,是业界最成熟也最可靠的扩展方式之一。

12.4.1 三种钩子交互模式

钩子(src/plugins/hooks.ts)是 Agent 自动化的第三根支柱。与 Cron(时间触发)和心跳(间隔触发)不同,钩子是事件触发的——当系统中发生特定事件时自动响应。

钩子系统支持三种交互模式,对应三种不同的事件处理需求,在源码里是三个不同的 runner 函数(src/plugins/hooks.ts):

通知钩子 runVoidHook:触发即忘。注意它是并行的——hooks.map(...) 之后 await Promise.all(promises),每个 handler 的异常单独捕获。用于日志、指标这类不影响事件流的副作用。

事件 ⇉ 处理器A ‖ 处理器B ‖ 处理器C(并行,互不影响,异常各自吞掉)

转换钩子 runModifyingHook:顺序执行,按 priority 从高到低,结果按 policy.mergeResults 合并;policy.shouldStop 满足时提前终止。用于提示词构建、模型选择这类"多方各出一份意见再合成"的场景。

事件 → 处理器A(改) → 处理器B(再改) → …(可提前 break)

认领钩子 runClaimingHook:顺序执行,第一个返回 { handled: true } 的处理器接管,后续不再执行。用于 inbound_claim 这类"谁先认领谁负责"的场景。

事件 → 处理器A(未认领) → 处理器B(handled: true!) → 处理器C(不执行)

三者的并行/顺序之分不是随意的:通知型钩子彼此无关,并行最快;转换型和认领型都依赖"谁先谁后",必须顺序执行。

12.4.2 为什么需要三种模式?

一种更简单的设计是只有一种钩子——"收到事件,执行处理器"。为什么要区分三种模式?

因为事件处理的语义不同。考虑这三个场景:

场景1:消息到达时记录到日志。这是纯副作用——不影响消息的处理流程。如果记录失败,消息应该继续处理。→ 通知钩子。

场景2:回复发出前自动翻译为英文(message_sending 钩子可以改写 content)。这修改了消息本身——通道最终收到的是翻译后的文本。→ 转换钩子。

场景3:消息包含"余额查询"关键词时直接返回余额,不需要 LLM 推理。这截断了正常的处理流程——不需要模型调用,直接响应(inbound_claim 认领后自行处理)。→ 认领钩子。

如果只有一种钩子,每个处理器都需要自己处理"我是否应该修改数据"和"我是否应该终止管线"的逻辑,这会把模式选择的复杂性从框架推给使用者。三种明确的模式让每个处理器的职责清晰。

12.4.3 钩子与自动回复

"LLM 旁路"在 OpenClaw 里不是一份可配置的正则规则表(没有 auto-reply.rules 这样的配置项),而是一套内建的控制命令注册表(src/auto-reply/commands-registry.shared.ts)。入站文本先过 hasControlCommand():

// src/auto-reply/command-detection.ts:22(节选)
const normalizedBody = normalizeCommandBody(trimmed, options);
const lowered = normalizedBody.toLowerCase();
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
  for (const alias of command.textAliases) {
    const normalized = alias.trim().toLowerCase();
    if (!normalized) continue;
    if (lowered === normalized) return true;                 // 精确匹配
    if (command.acceptsArgs && lowered.startsWith(normalized)) { /* 后跟空白才算 */ }
  }
}

命中就直接走命令处理路径,完全不进 LLM。v2026.3.31 内建了 42 条这样的命令(口径:commands-registry.shared.ts 中 defineChatCommand({...}) 的调用次数),包括 /help、/status、/model、/models、/tools、/reset、/new、/compact、/think、/usage、/queue、/stop、/kill 等;此外每个声明了 capabilities.nativeCommands 的通道还会自动生成一条 /dock-<channel> 命令(defineDockCommand)——命令表本身是随插件注册动态生长的,不是一张写死的清单。

注意 acceptsArgs 那个分支的细节:只有命令声明了接受参数、且别名后面紧跟空白字符时,前缀匹配才成立。这挡住了 /models 被误判成 /model 的经典问题。

这条旁路的两个好处:

  1. 零 token 成本:不调用模型 API。/status 这种高频查询不产生任何 API 费用。
  2. 亚秒响应:不等待模型推理。延迟从数秒降到毫秒。

设计上的洞察是:不是所有交互都需要 AI 智能。但请注意 OpenClaw 的取舍——它没有把这条旁路开放成"用户自定义正则规则",而是限定在内建命令与插件注册命令上。理由不难推测:一份用户自由编写的正则表会悄悄拦截本该交给 Agent 的消息,而且出问题时极难排查。旁路要窄,且要可枚举。

12.5 三种机制的协同与选择

12.5.1 何时用哪种机制?

图 12-3:自动化机制选择

flowchart TD
    Start["需要自动化"] --> Q1{"是否有明确的<br/>触发时间点?"}
    Q1 -->|"是"| Cron["使用 Cron<br/>(精确调度)"]
    Q1 -->|"否"| Q2{"是否由外部<br/>事件触发?"}
    Q2 -->|"是"| Hook["使用钩子<br/>(事件响应)"]
    Q2 -->|"否"| Q3{"是否需要<br/>条件判断?"}
    Q3 -->|"是"| Heartbeat["使用心跳<br/>(智能巡检)"]
    Q3 -->|"否"| Cron2["使用 Cron<br/>(定期执行)"]
    style Cron fill:#1f618d,color:#fff
    style Heartbeat fill:#1e8449,color:#fff
    style Hook fill:#c0392b,color:#fff
    style Cron2 fill:#1f618d,color:#fff

12.5.2 组合使用的实际模式

实际生产中,三种机制经常组合使用:

模式1:Cron 触发 + 心跳跟进。Cron 在每天早上 9 点触发日报。如果日报中发现异常指标,需要更密集的观察——注意这不能靠"心跳间隔自动缩短"实现(12.3.3 已说明 every 是固定值,v2026.3.31 没有自适应频率)。正确做法是让那一轮 Agent 顺手用 cron 工具建一个 { kind: "every", everyMs: 900000 } 的临时作业,异常消除后再把它删掉——把"变频"表达成"建一个临时作业",而不是指望调度器自己伸缩。

模式2:钩子触发 + Cron 兜底。消息到达时钩子检查是否需要紧急处理。但如果通道断线导致消息丢失,每小时的 Cron 作业会扫描未处理的消息队列作为兜底。

模式3:心跳发现 + Cron 执行。心跳每 30 分钟检查磁盘使用率。如果超过阈值,不立即清理(清理可能影响在线服务),而是创建一个凌晨 3 点的 Cron 作业来执行清理。

12.5.3 成本模型

三种机制的成本特征截然不同:

机制 每次触发的成本构成 谁决定成本
Cron(agentTurn,隔离会话) 提示词 + 引导上下文 + 一轮完整推理 作业数量 × 触发频率
Cron(systemEvent,主会话) 入队一条系统事件,作业本身不跑推理,随即唤醒一次主会话心跳 转移到被唤醒的那一轮心跳(含主会话历史)
心跳(回 HEARTBEAT_OK) 心跳提示词 + 引导上下文(+ 会话历史,除非开 isolatedSession) 会话历史长度
心跳(有事处理) 同上 + 检查动作的工具输出 + 回复 检查项复杂度
控制命令旁路 不调用模型 零
activeHours 之外 不发起心跳 零

这张表刻意不给具体的 token 数与美元金额——它们完全取决于你的模型定价、会话历史长度和检查项复杂度,任何"每月 $X"的断言都会误导人。真正该记住的是这几条结构性事实:

  • systemEvent 型 Cron 作业自己不烧 token,它往主会话里塞一条事件,并立即请求一次心跳来处理——所以每次触发仍然对应一轮(跑在主会话、带会话历史的)心跳推理,只是这笔账记在心跳头上。它的价值在于让提醒进入主会话的上下文,而不是省钱。
  • 心跳的成本大头常常是会话历史而非提示词本身。isolatedSession: true 是最有效的单个开关。
  • 唯一真正零成本的时段是 activeHours 之外——"无事可做"仍然要跑一次推理才能知道无事可做。

12.6 实战推演:Cron + 心跳协同的全栈监控

让我们构建一个真实的监控场景,展示三种自动化机制如何协同工作——而非各自为政。

12.6.1 场景:个人 SaaS 产品的全方位守护

你运营着一个小型 SaaS 产品,需要:

  • 每天早上 9 点收到一份运营日报(收入、用户、错误率)
  • 网站宕机时 5 分钟内收到告警
  • 用户发来"紧急"标记的支持邮件时立即通知

12.6.2 三种机制的配置

Cron 作业(通过 cron 工具的 add 动作创建,存进作业存储,不写配置文件):

{
  "name": "daily-report",
  "schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" },
  "sessionTarget": "isolated",
  "payload": {
    "kind": "agentTurn",
    "model": "anthropic/claude-sonnet-4-20250514",
    "message": "生成今日运营日报:\n1. 检查昨日收入\n2. 检查昨日 UV/PV\n3. 统计最近 24 小时错误数\n4. 格式化为简洁报告",
  },
  "delivery": { "mode": "announce", "channel": "telegram", "to": "<chatId>" },
}

心跳配置(这一部分确实写在配置文件里):

// ~/.openclaw/openclaw.json(按 JSON5 解析)
{
  "agents": {
    "defaults": {
      "heartbeat": {
        "every": "5m",
        "activeHours": { "start": "07:00", "end": "23:00", "timezone": "user" },
        "model": "openai/gpt-4o-mini",   // 巡检不需要强模型
        "isolatedSession": true,          // 不带会话历史
        "lightContext": true,             // 引导上下文只保留 HEARTBEAT.md
        "target": "telegram",
      },
    },
  },
}
<!-- HEARTBEAT.md — 心跳巡检清单 -->
## 检查项(每次心跳轮换 1-2 项)
- [ ] 网站健康: curl -s -o /dev/null -w "%{http_code}" https://myapp.com → 非 200 则告警
- [ ] API 延迟: curl -w "%{time_total}" https://api.myapp.com/health → 超过 3s 则告警
- [ ] 磁盘: df -h / → 超过 85% 则告警
- [ ] 证书: openssl s_client -connect myapp.com:443 2>/dev/null | openssl x509 -noout -enddate → 30 天内过期则告警

## 规则
- 只检查最关键项时优先网站健康
- 同一项连续告警时在告警文本里注明"持续 N 次",方便我判断严重程度

提醒:上面"规则"一节里的内容是写给模型的约定,系统不解析、也不强制。真正由系统保证的时间窗只有配置里的 activeHours;"心跳间隔自动伸缩"这件事 v2026.3.31 并不支持——every 是一个固定值。

12.6.3 一天中的实际运行时序

07:00 [心跳#1] 早间首检: 网站健康 ✅, API 延迟 1.2s ✅ → HEARTBEAT_OK
07:05 [心跳#2] 轮换: 磁盘 78% ✅, 证书 45天后过期 ✅ → HEARTBEAT_OK
...
09:00 [Cron] 日报触发 → Agent 执行 4 步数据收集 → 生成报告
      📊 "昨日收入 $127, UV 3,421, 错误 12 条(↓ vs 前日 18 条), 服务稳定"
09:05 [心跳#3] 网站健康 ✅ → HEARTBEAT_OK
...
14:23 [心跳#8] 网站健康 → HTTP 503! ⚠️
      → 回复告警文本(不含 HEARTBEAT_OK)→ 系统投递: "🚨 myapp.com 返回 503"
      → 注意: 间隔仍然是配置里的 5min,不会自动变快
14:28 [心跳#9] 网站健康 → HTTP 503! ⚠️(模型可在文本里注明"连续第 2 次")
14:33 [心跳#10] 网站健康 → HTTP 200 ✅ 恢复
      → 回复恢复通知: "✅ myapp.com 已恢复"
      → 若想要"故障期间查得更勤",正确做法是让这次心跳顺手用 cron 工具
        创建一个 { kind: "every", everyMs: 60000 } 的临时作业,恢复后再删掉
...
15:30 [控制命令旁路] 用户发 /status → hasControlCommand() 命中 → 不进 LLM,直接返回状态

12.6.4 成本分析

这套配置的成本结构(不给具体金额——它取决于你选的模型和实际检查内容):

机制 日触发次数 成本主导因素
Cron 日报 1 一轮完整推理 + 数据收集的工具输出
心跳(activeHours 07:00–23:00,every: 5m) 约 192 心跳提示词 + HEARTBEAT.md;因为开了 isolatedSession,不含会话历史
心跳(有事处理) 视故障频率 额外的工具输出与告警文本
控制命令旁路 任意 零
23:00–07:00 0 零(窗口外不发起)

想压成本,按影响从大到小依次是:开 isolatedSession(砍掉会话历史)→ 收窄 activeHours → 把 every 调大 → 给心跳单独配一个便宜模型(heartbeat.model)。

这套东西的真正价值不在便宜,而在于这个"监控系统"会思考:它不只是比阈值,还能关联多个信号("磁盘快满了 + 今天有数据导入 = 提前预警")——这是传统监控需要复杂规则引擎才能表达的判断。

Cron 是闹钟——到点就响,不问青红皂白。心跳是保安——定时巡逻,有事才报告。钩子是门铃——有人按就响,没人按就安静。三者协同,构成了一个 24/7 永不疲倦的守护体系。而这个体系最精妙的地方在于:它 99% 的时间在"什么都不做"——这恰恰证明了系统运行正常。

12.7 主动 Agent 的伦理考量

12.7.1 打扰权与沉默权

一个有主动性的 Agent 面临一个微妙的伦理问题:它应该在什么时候打扰人类?

过度通知是一种对注意力的侵犯。运营者的注意力是有限资源——每一次通知都消耗一小部分。如果 Agent 每 15 分钟发送"一切正常"的消息,它不是在提供价值,而是在制造噪音。

OpenClaw 对这个问题的系统级回应是心跳的 activeHours——运营者在配置里声明窗口之外不发起心跳,不只是节省 token,更是声明"这段时间不要打扰我"。像 12.3.2 示例那样在 HEARTBEAT.md 里写"23:00-08:00 跳过所有检查",则只是给模型的约定:心跳照样发起、照样调用模型,能省下的只是检查动作本身。

12.7.2 自主与受控的平衡

更深层的问题是:Agent 应该多自主?

极端1:完全自主。Agent 自行决定何时检查、何时通知、何时行动。问题:可能在不恰当的时机执行不恰当的操作。

极端2:完全受控。每个操作都需要人类确认。问题:这就不是自动化了,只是多了一步确认。

OpenClaw 的设计哲学是"自动化常规操作,升级异常情况"。Cron 和心跳自动执行常规检查;发现异常时通知人类做决策。这不是技术限制——而是有意的设计选择,反映了"Agent 是放大器而非替代者"的信念(详见第18章)。

12.8 历史演进

12.8.1 源码注释与 CHANGELOG 里能查到的节点

源码注释和仓库 CHANGELOG.md 里保留了不少与本章直接相关的线索,比编年叙事更可靠。几个代表(issue 编号来自源码注释):

  • 启动追赶的限流(issue #18892):src/cron/service/state.ts 里 maxMissedJobsPerRestart 与 missedJobStaggerMs 两个字段的注释直接贴着这个 issue 链接,CHANGELOG.md 对应条目是 "Cron/restart catch-up staggering"(贡献者 PR #18925)。它们的存在说明"网关重启后一口气跑完所有积压作业"确实在生产里发生过。
  • 定时器热循环:armTimer() 里给 MIN_REFIRE_GAP_MS 地板的那段长注释详细复盘了故障链——卡住的 runningAtMs + 过期的 nextRunAtMs(按 #13992 的决定不在维护时推进)→ setTimeout(0) 无限循环 → 事件循环饱和 + 日志写满;常量自身的注释则指向另一个自触发循环问题 #17821。长任务期间定时器"静默死亡"的修复在 onTimer() 注释里指向 #12025。
  • 一次性作业的瞬时错误重试(issue #24355):DEFAULT_MAX_TRANSIENT_RETRIES = 3。
  • 源码里还散落着 service.issue-17852-daily-skip.test.ts、service.issue-19676-at-reschedule.test.ts、service.issue-22895-every-next-run.test.ts、service.issue-35195-backup-timing.test.ts 这一串以 issue 编号命名的回归测试——每一个文件名都是一次调度 bug 的墓碑。

12.8.2 从测试文件名读历史

src/cron/ 下的测试文件命名方式本身就是一份演进记录。除了上面那批 issue-NNNNN 回归测试,还有一批描述性命名的:service.armtimer-tight-loop.test.ts(紧循环)、service.prevents-duplicate-timers.test.ts(重复定时器)、service.rearm-timer-when-running.test.ts(长任务期间重新武装)、service.restart-catchup.test.ts(重启追赶)、service.session-reaper-in-finally.test.ts(会话回收必须在 finally 里)、service.jobs.top-of-hour-stagger.test.ts(整点错峰)。

把这些文件名连起来读,就能看出一个内建调度器的真实难点分布:几乎全部集中在定时器生命周期和进程重启边界上,而不是"怎么解析 cron 表达式"。表达式解析交给了 croner,剩下的才是自己要扛的。这对任何打算自建调度器的人都是一个警示——真正的复杂度不在你以为的那个地方。

12.9 框架对比

特性 OpenClaw LangChain AutoGPT CrewAI Dify
时间调度 ✅ 完整 Cron ❌ ❌ ❌ ✅ 基础
心跳监控 ✅ 条件执行 + 活跃时段 ❌ ❌ ❌ ❌
事件钩子 ✅ 三模式管线 通过回调 ❌ 通过回调 ✅ 基础
错过作业恢复 ✅ 启动追赶 — — — ❌
逐作业模型选择 ✅ — — — ✅
条件执行 ✅ HEARTBEAT_OK — — — ❌

大多数框架将 Agent 视为库——应用代码调用它们。这种范式下,"Agent 主动做事"不在设计目标中。OpenClaw 将 Agent 视为服务——持久运行、自主行动。主动性是服务的天然属性。

12.10 关键源码文件

文件 用途
src/cron/service.ts CronService 类(对外门面)
src/cron/service/timer.ts 调度器的心脏:armTimer / onTimer / runMissedJobs / 退避策略
src/cron/service/jobs.ts nextWakeAtMs / createJob 等作业级操作
src/cron/service/ops.ts 对外操作(start、stop、add、run、list 等)
src/cron/service/state.ts 调度器状态与依赖注入(missedJobStaggerMs 等旋钮)
src/cron/schedule.ts croner 封装与下次运行时刻计算
src/cron/stagger.ts 整点错峰
src/cron/store.ts 作业持久化
src/cron/types.ts CronJob / CronSchedule / CronPayload / CronDelivery 类型定义
src/agents/tools/cron-tool.ts cron 工具(作业的增删改查入口,含完整 schema 说明)
src/config/types.cron.ts CronConfig(全局项,不含逐作业定义)
src/auto-reply/heartbeat.ts 心跳提示词与 HEARTBEAT.md 空内容判定
src/auto-reply/tokens.ts HEARTBEAT_TOKEN = "HEARTBEAT_OK"
src/infra/heartbeat-runner.ts 心跳执行循环
src/config/types.agent-defaults.ts heartbeat 配置块(every / activeHours / isolatedSession 等)
src/plugins/hooks.ts 事件钩子的三种 runner
src/auto-reply/commands-registry.shared.ts 内建控制命令注册表(LLM 旁路)

12.11 本章小结

OpenClaw 的自动化基础设施将 Agent 从被动响应者转变为主动运营者。Cron 调度提供精确的时间触发动作和重启恢复。心跳提供条件执行的周期巡检——固定间隔加活跃时段窗口,"无事"时一句 HEARTBEAT_OK 即被丢弃。事件驱动钩子实现由系统事件触发的响应式自动化。三种机制互补——Cron 是命令式的("做这件事"),心跳是条件式的("看看有没有事要做"),钩子是响应式的("发生了某事时做这件事")。

核心洞察:主动性不是一个功能——它是基础设施需求。一个真正有用的 Agent 需要与生产系统相同的时间感知:在预定时间行动的能力、以间隔轮询变化的能力、在事件发生时做出反应的能力。OpenClaw 将这三者作为一等原语提供,认识到演示 Agent 与生产 Agent 的区别往往不是智能,而是主动性。被动的 Agent 永远只是工具;主动的 Agent 才是真正的助手。

更深层来看,主动性揭示了 Agent 系统独有的设计维度:"何时行动"与"如何行动"同等重要。传统软件的"何时"由用户触发决定,清晰、确定、被动。Agent 系统的"何时"是系统自身的判断——及时性、成本、打扰程度、自主性,四个维度交织博弈。这些权衡在传统软件设计中几乎不存在,却决定了 Agent 究竟是工具,还是伙伴。

被动的 Agent 永远只是工具;主动的 Agent 才是真正的助手。 扳手不会自己检查螺丝是否松了;管家会。主动性是 Agent 从"工具"蜕变为"助手"的分水岭——这一章讲的不是技术,是 Agent 的"职业素养"。

工具、设备连接、自动化——前三章赋予了 Agent 越来越强大的行动能力。但能力越大,风险越大。下一章,我们将正面迎战 Agent 系统中最棘手的工程问题:安全与权限。如何在赋予 Agent 能力的同时控制其风险?如何防止提示注入?如何让人始终掌握最终决策权?答案在纵深防御中。

思考题

  1. 概念理解:OpenClaw 的心跳机制为什么不直接使用系统级 cron,而是在应用层实现自己的调度器?应用层调度在 Agent 场景下有什么独特优势?
  2. 实践应用:设计一个自动化工作流:Agent 每天早上 9 点检查 GitHub Issues,生成日报,并发送到 Slack 频道。你会使用 OpenClaw 的哪些定时任务原语?如何处理任务执行超时的情况?
  3. 开放讨论:从"被动响应"到"主动行动",Agent 正在从工具变成同事。你认为"主动 Agent"的边界应该在哪里?Agent 应该被允许在没有人类确认的情况下主动发起哪些类型的动作?

📚 推荐阅读