Hermes 自带 cron 调度,跑定时任务够用。heartbeat 是后装的插件,补 cron 做不了的部分。
两个工具经常被混用,实际边界很清楚:按「时间驱动」还是「条件驱动」分。下面先讲机制,再讲用法,最后给迁移判断标准。
一、两者的边界
| 场景 | cron | heartbeat |
|---|---|---|
| 固定周期(早报、日报、巡检) | 适用 | 不适用——14 天过期 |
| 长期监控(更新检查、空间告警) | 适用——永久有效 | 不适用——同上 |
| 一次性提醒(几小时后) | 可用但重 | 适用 |
| 需要上下文(“上次说的那件事”) | 做不到 | 适用 |
| 条件触发(文件变了才叫) | 做不到 | 适用 |
| 静默后检查(三天没说话问一句) | 做不到 | 适用 |
判断口诀:到点就跑的给 cron,等条件成立才动的给 heartbeat。
二、heartbeat 的三个核心概念
wake(唤醒)
在指定时间,于同一个对话里插入一轮模型调用。不是一个新会话,也不起子进程。
创建方式 /pulse add in 2h 检查部署状态
或由 agent 调 pulse 工具创建
本质 到点时,在那个聊天里多跑一轮
watcher(观察器)
盯一个外部状态,变化时才唤醒。这是它比 cron 省的核心。
file 文件变了,或追加的内容命中正则
process 进程退出
url GET 响应变了
command 只读命令的输出变了
event kanban 任务完成、子代理结束、API 报错
判定不调模型。没变化就是零 token,插件内部每 5 秒 tick 一次比较。
respond(结论)
每轮唤醒结束前必须调用,告诉插件这轮跑出了什么。
{"action":"respond","outcome":"done","notify":true,"summary":"简短摘要"}
outcome no_change / progress / done / blocked / needs_attention
notify true = 这轮回复发出去;false = 静默
summary 只进 ledger 审计,不进消息
三、正确调用顺序(关键)
这是最容易出错的地方。
① 做完 note 里要求的事(查数据、跑命令)
② 把汇报写成这一轮最后的回复正文 ← 消息内容在这
③ 最后一步调 respond(notify=true)
为什么顺序不能变:插件的 transform_llm_output 钩子只决定「这轮的最终输出发不发」,它不会替你生成内容。
def transform_llm_output(self, response_text='', ...):
if silence:
return '[SILENT]' # 换掉输出,平台吞掉
return None # 原样发送
return None 就是原样发。所以正文为空时,发出去的也是空。
四、三个失效模式
失效一:respond 放在最后,但整轮没有正文
# 错误
查数据 → 查数据 → respond(notify=true)
# 结果:delivered 0,用户什么都没收到
因为那一轮的文本是空的,钩子批准了一个空消息。
正确:
查数据
# ---- 这里写完整汇报正文 ----
respond(notify=true)
失效二:输出里写了 [SILENT]
silence = (not o.notify) or (
o.outcome is None and response_text.strip().casefold() in BARE_SILENCE)
没调 respond(outcome is None)+ 输出命中 silence 标记 → 判定静默。
[SILENT] 是插件内部的控制标记,不要手写。文档明确写了这一条。
失效三:summary 与 24 小时内的记录重复
if o.notify and o.summary.strip() and o.summary_hash:
silence = silence or any(同 hash 已投递记录在 24h 内)
summary 完全相同时自动静默防重复。 所以摘要要带具体数字或时间,不能用套话模板。
五、唤醒不会打断对话
到点时如果 chat 正忙,唤醒会等。
实测:14:30 到点的唤醒,因为用户持续在发言,一直到 14:31:44——用户停下的那一刻才注入。
行为 等待空隙,不插队
代价 可能晚几分钟
好处 不会在对话中间硬插一句
这是设计行为,不是故障。
六、迁移判断
值得迁的:高频轮询
fast-checks 一类,每 5 分钟跑一次
一天 288 次 fork
换成 watcher → 288 次 tick(同进程,不 fork)
不值得迁的:低频 + 长期
每晚跑一次的更新检查
→ watcher 14 天就过期,续期比 cron 麻烦
→ 保留 cron,优化脚本本身更实际
脚本层面的优化示例(判断要不要 fetch 全量):
# 原来:每次都 fetch + 比对日志
ssh host 'cd repo && git fetch && git log HEAD..origin/main'
# 改后:先比 HEAD hash,一样就退出
ssh host '
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git ls-remote <repo> HEAD | cut -f1)
[ "$LOCAL" = "$REMOTE" ] && exit 0
git fetch && git log HEAD..origin/main
'
几十字节换一次完整 fetch。
七、资源对比
cron 脚本类 每次触发 fork 一个进程,几十毫秒到几秒
no_agent 任务零 token
heartbeat 无独立进程,poller 在网关内部
每 5 秒 tick,开销 KB 级
watcher 判断不调模型
实测环境:20 个 cron 启用任务里 18 个是脚本类;网关整体 1689 MB / CPU 4%(含记忆、插件、会话、MCP,不只是插件)。
结论:低频脚本任务用 cron 就够了。真正值得换的是每次触发都要 fork 进程的高频轮询——这才是资源差别所在,token 不是重点。
八、寿命限制
watcher 默认最长 14 天
到期后 status 直接变 expired,所有待触发记录取消,无提示
源码逻辑:
if w.expires_at is not None and w.expires_at <= now:
s.cas_wake(w.id, 'active', status='expired', status_reason='expired')
self.cancel_occurrences(w.id, 'expired')
两种处理:
# 一劳永逸:改全局默认
plugins:
entries:
heartbeat2:
settings:
max_lifetime_days: 365
# 或者:每次创建时显式给
"until": {"expires": "in 340d", "max_fires": 100}
长期监控建议直接改 settings,否则到期后是静默失效,很难发现。
九、速查表
建一次性提醒 pulse at "in 2h" / "2026-11-15T20:00"
建条件监控 pulse watcher(file/process/url/command)
查所有唤醒 /pulse list
看状态与配额 /pulse
暂停/恢复/取消 /pulse pause|resume|cancel <id>
关掉这类提醒 /pulse off
唤醒轮次必须 先写正文,最后 respond(notify=true)
绝不能写 [SILENT]
summary 要写 具体数字,不要套话
一句话总结:cron 是闹钟,到点就响,能响很多年;heartbeat 是同事,记得上下文,只在值得说话时开口,但过两周就换了个人。
