Featured image of post cron 与 heartbeat 实测分工

cron 与 heartbeat 实测分工

heartbeat 插件和 cron 各管什么、怎么用、边界在哪。含正确调用顺序、两个常见失效模式、以及从源码里确认的判定逻辑

   
文章摘要
heartbeat 插件补的是 cron 做不了的三件事——带上下文的提醒、条件触发、静默后检查。这篇讲清两者边界、正确调用顺序,以及三个容易踩空的失效模式

Hermes 自带 cron 调度,跑定时任务够用。heartbeat 是后装的插件,补 cron 做不了的部分。

两个工具经常被混用,实际边界很清楚:按「时间驱动」还是「条件驱动」分。下面先讲机制,再讲用法,最后给迁移判断标准。

一、两者的边界

cron 与 heartbeat 的能力边界对比

场景cronheartbeat
固定周期(早报、日报、巡检)适用不适用——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)

wake 从武装到投递的四个环节,以及两个失效点

为什么顺序不能变:插件的 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 是同事,记得上下文,只在值得说话时开口,但过两周就换了个人。

晚来天欲雪,能饮一杯无