OpenClaw 设计与实现
第12章 定时任务与自动化
第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 主动性的三个维度
实现主动性需要大多数框架完全缺乏的基础设施:
- 时间感知:在特定时间或间隔触发动作的能力。
- 条件判断:判断是否值得行动——不是每次都要做事,有时"一切正常"也是有价值的信息。
- 打扰智能:判断何时值得打扰人类、何时应该保持沉默——凌晨三点的非紧急通知不是贴心,而是骚扰。
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 onlyHEARTBEAT.mdfrom workspace bootstrap files.
isolatedSession: The heartbeat only sees its bootstrap context (HEARTBEAT.mdwhenlightContextis 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 的经典问题。
这条旁路的两个好处:
- 零 token 成本:不调用模型 API。
/status这种高频查询不产生任何 API 费用。 - 亚秒响应:不等待模型推理。延迟从数秒降到毫秒。
设计上的洞察是:不是所有交互都需要 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 能力的同时控制其风险?如何防止提示注入?如何让人始终掌握最终决策权?答案在纵深防御中。
思考题
- 概念理解:OpenClaw 的心跳机制为什么不直接使用系统级 cron,而是在应用层实现自己的调度器?应用层调度在 Agent 场景下有什么独特优势?
- 实践应用:设计一个自动化工作流:Agent 每天早上 9 点检查 GitHub Issues,生成日报,并发送到 Slack 频道。你会使用 OpenClaw 的哪些定时任务原语?如何处理任务执行超时的情况?
- 开放讨论:从"被动响应"到"主动行动",Agent 正在从工具变成同事。你认为"主动 Agent"的边界应该在哪里?Agent 应该被允许在没有人类确认的情况下主动发起哪些类型的动作?
📚 推荐阅读
- OpenClaw CHANGELOG — Cron 相关修复的发版记录(本章引用的 issue 编号见
src/cron/service/源码注释,CHANGELOG 中不一定逐条出现) - croner — OpenClaw 实际使用的 cron 表达式解析库(
package.json中croner: ^10.0.1) - Proactive Agent: Shifting LLM Agents from Reactive Responses to Active Assistance(Yaxi Lu 等,2024)— 主动 Agent 的学术前沿研究
- Temporal.io — 工作流编排引擎,理解更复杂的自动化场景