对 Hermes 定时任务的改造

2026-11-02

我用 Hermes 管着每天的日程提醒、邮箱扫描、ETF 日报这些东西。用久了,最烦的不是它不准,是它不像人。

这篇文章记一次改造:从发现问题,到改源码,到撞墙,最后换思路。


一、原来的设计

Hermes 的定时任务(cron job)是这么跑的:

  1. 到点,网关的 ticker 挑出到期的任务;
  2. 起一个全新的会话,把任务的提示词(有脚本的话还有脚本输出)塞进去;
  3. 新会话跑完,把结果作为一条消息投递回聊天窗口。

干净、隔离、不会互相干扰。但用起来有两条硬伤。

问题 1:没有上下文,提醒像机器人

任务的提示词是写死的一段话。比如「喝水提醒」,到点它就吐出:

该喝水啦,别等到口干才想起来,顺手来一杯 🐳

第一次看还行,第十次看就是复读机。它不知道我刚说过什么、今天在忙什么、昨天是不是已经提过一遍。

更明显的是日程提醒:它只会照着时间表念「11/7–11/8 全天 形势与政策课」,不会像我平时说话那样补一句「教室还没通知,记得看班群」。

因为它根本没有我们这段对话。

问题 2:主对话看不到定时任务的内容

这条更隐蔽。定时任务的结果是「插」进聊天的,但它执行的这一轮并不属于主对话。我在主对话里问「刚才那条提醒是什么意思」,主对话是不知道的——它没参与过那次执行。

等于有两个我:一个在后台念稿,一个在聊天里陪人说话。前台那个对后台干过什么一无所知。


二、方案 0:一次误改

在正经想办法之前,先试过一个更省事的:别让 cron 自己说话,把它的结果发给主对话,由主对话二次加工后再说出来。

听起来挺合理——主对话有上下文,让它润色一下,提醒不就自然了?

实际用起来很诡异。问题出在身份上:主对话看到的那段文字,和用户手打进来的消息走的是同一条通道,它分不清这是「系统给我的任务结果」还是「用户刚说的话」。就算加了标注,模型在长对话里也很容易忽略。

于是就出现了这种对话:

主对话:该喝水了。 我:? 主对话:已经喝完了。

它把我当成了自己提醒的对象,替我把话答了。因为它看到「提醒喝水」这条内容,第一反应是回应它,而不是转述它。

更糟的是那种:「已经做完了」——我根本没说过做了什么,它凭空替我确认完成了某件事。

这不是措辞问题,是角色混淆:同一段上下文里,说话的人和被提醒的人必须是两个,一旦它们塞进同一个通道,模型就没有依据去区分谁是谁。

这个方案直接否掉了。


三、第一次改造:把提示词注入主对话

想法很直接:别起新会话了,到点把任务提示词本身塞进当前会话,让当前会话带着完整历史去执行。

这样一来:

我在 cron/scheduler.py 里加了个开关 inject_into_session,注入的内容长这样:

【任务提示词】
<原任务 prompt>

【脚本原始输出】
<script stdout,若有>

按上面的提示词在这轮直接完成,把结果直接发给用户。

第一层坑:它其实不是原生功能

我一开始以为这是 Hermes 自带的,查了 git log -S "inject_into_session" -- cron/jobs.py,没有任何提交记录——只是工作区里一处没提交的改动。也就是说这东西升级就没了,得自己存成补丁,重打。

补丁落在 ~/.hermes/patches/cron-inject-into-session.patch。

第二层坑:匿名消息被当成陌生人丢掉

改完第一次测,任务跑了、日志写了「injected output into live session」,但聊天里什么都没收到。

翻网关日志:

Dropping message from unauthorized user in active session: user=None

注入的消息是合成的,没带 user_id,网关的安全检查把它当成了陌生人,直接丢掉。修法是:私聊场景下用 chat_id 兜底当 user_id。

第三层坑:注入进去之后,还得跳过「后台先跑一遍」

改好之后能收到了,但注入内容里带着一段「上一轮执行结果」——说明 cron 那边已经先跑过一次模型,再把结果塞给我,等于同一件事做两遍。

于是又加了个短路:纯提示词的任务,cron 侧不再跑模型,直接返回一个标记,交给当前会话执行。这里还踩了个小坑——标记一开始放错了返回值的位置(放进了「完整输出」,而不是「最终回复」),投递层拿到的内容是空的,又是什么都没发。顺序是:

return True, "", "[[INJECT_ONLY]]", None
#            ^  ^  ^
#            |  |  这个才是投递内容
#            |  完整输出文档

四、撞墙:想让它在忙的时候也能插进来

新问题来了:我正在跑一个长任务时,定时任务触发了会怎样?

答案:排队。等这一轮结束才处理。

我希望它优先——到点就打断当前这一轮,先把提醒说完。

于是顺着源码往下改:

测了三轮,全部失败。最后一轮我故意让自己跑一个 150 秒的命令,定时任务在中间触发——命令照样跑满 150 秒,注入的消息直到我这边忙完才进来。

翻代码,一共三道关卡:

  1. internal 事件先被拦:if getattr(event, "internal", False): return False,后台合成的事件在走到忙碌判断之前就被判了「不处理」。
  2. allow_gateway_control 那个分支会先排队:_queue_or_replace_pending_event(),直接进 FIFO。
  3. 最要命的一处:文本消息走的是 busy_text_mode,不是 busy_input_mode。我把前者设成 interrupt 了,后者还是 queue,于是到这里又被降级回排队:
if (
    event.message_type == MessageType.TEXT
    and busy_text_mode == "queue"
    and effective_mode != "steer"
):
    return False   # → 排队

三道都绕过之后,理论上能打断。但我在改到第三道的时候停下来了——因为我突然意识到一件事。


五、这是逆着设计走的

翻 issue 的时候没找到有人提这个需求。反而在源码注释里看到维护者写得很清楚:

Internal synthetic events must never interrupt/steer. Async-delegation completions and background-process completions re-enter the originating session as internal MessageEvents. When the session is busy, treating them like a user TEXT message means interrupt-mode aborts the active turn...

翻译成人话:后台任务完成、cron 通知这类内部事件,设计上就是绝不打切断正在跑的一轮。因为打断会连带杀掉子任务、把正在执行的命令砍成半截。宁可晚几分钟,也不能把活儿干坏。

我原来那条路,是在跟这个设计原则硬顶。

更关键的是:原生 cron 根本不存在这个问题。它另起会话跑,结果作为新消息投递,压根不碰当前会话,谈何「打断」还是「排队」——这个矛盾是我把注入机制加进去之后自己造出来的。

于是全部回退。补丁只留注入那部分,抢占相关的代码在四个文件里清成 0 行。

回退时还遇到个小插曲:代码改完了,行为没变。因为 Python 进程启动时就把代码读进内存了,改磁盘上的 .py 不影响正在跑的进程。得重启才算数。


六、换思路:独立会话 + 上下文

回退之后重新想:真正想解决的其实只有「提醒要有上下文」,而不是「注入主对话」。

那就换个做法——还是另起会话,但把上下文一起塞进去:

  1. cron 起独立会话(和原生一样,不碰主对话);
  2. 提示词里带上最近的对话内容;
  3. 结果照常投递,作为一条新消息出现在聊天里。

隔离性保住了,上下文也有了。问题 2(主对话看不到执行过程)解决不了,但那条其实没那么重要。

关于缓存的一点算术

第一反应是:另起会话能不能复用主对话的 prompt cache?毕竟各家都有前缀缓存。

结论是:理论上能,实际很难。

所以「全带上下文」的代价,等于每次任务都把整段对话按原价再算一遍。折中方案是带当天的原话 + 一份摘要。

不过对日报这种任务来说,它本来也不需要知道我上午聊了什么——不带上下文反而更省。


七、结论

这次改造最后留下的东西:

想解决的问题 做法 结果
结果发给主对话二次加工 放弃,角色混淆,自己替自己回答
提醒没上下文、像复读机 注入主对话 可用,但要自己维护补丁
主对话看不到定时任务内容 同上 部分解决
忙的时候也要准点 抢占打断 放弃,逆着设计走
既要上下文又要隔离 独立会话 + 带上下文 待做,代价是 token

最大的收获不是代码,是想明白了一件事:

遇到「框架不让我这么干」的时候,先分清是框架蠢,还是我这条路本身有问题。 这次是后者——我以为的「打断 vs 排队」,在原生设计里根本不存在,是我自己加的注入机制造出来的矛盾。

绕了一大圈,回到原点,但至少知道了原点为什么在那儿。


附:历程

随手记,遇到问题、换方案、回退都记一行,不追求文采。