一个「好」字也要等五秒:Hermes 把三条消息投递路上的静默等待挖了出来


你在 WhatsApp 上问助手一个很短的问题:「日志目录在哪?」它回一个词:「~/.hermes/logs」。就这么一句话,你等了五秒。同一台机器上,Telegram 回同样的内容是瞬间的。另一头还有更糟的情况:你把笔记本合上带回家,路上在手机上给它派了三件事,第二天开机,那三件事从来没发生过 —— Telegram 里你的消息还在,Hermes 的日志里干干净净,一个字都没有。

这两件事都不算 bug 意义上的「失败」:一个只是慢,一个是消息被静默丢弃。9 月 19 日到 20 日合并的三个改动(#117104、#116134、#116168)处理的正是这一类「没有报错」的问题。

短回复为什么一定要等满五秒

消息平台适配器有一个「文本批处理」机制:把短时间里连续产生的几段文本攒在一起再发,免得一次对话刷出七八条气泡。问题出在默认值和判断方式上。

WhatsApp 适配器把 text_batch_delay_seconds 默认成 5.0 秒,微信是 3.0 秒;而 base.py::_text_batch_delay_for 对所有短于拆分阈值的分片直接返回这个平坦延迟 —— 也就是说,一个两个字的回复也要把五秒等满,因为「批处理等待」根本没有提前结束的机制。这一等的成本全部由用户承担。

PR #117104 把两个适配器的默认值对齐到 Telegram 的节拍:

适配器 短文本延迟(之前) 短文本延迟(现在) 拆分延迟(之前 → 现在)
WhatsApp 5.0 s 0.3 s 10.0 s → 1.0 s
微信(Weixin) 3.0 s 0.3 s 5.0 s → 1.0 s

同时给配置值加了上下限:延迟最高夹到 2.0 秒、拆分延迟最高 4.0 秒(和 Telegram 的实现一致),并且拆分延迟不允许小于延迟本身。有一点需要你自己判断:如果你以前特意把延迟配成 7 秒之类的大值,现在会被夹到 2.0 秒。

gateway:
  platforms:
    whatsapp:
      extra:
        text_batch_delay_seconds: 0.5        # 上限 2.0,下限 0
        text_batch_split_delay_seconds: 1.5  # 上限 4.0,且不小于上面那个值

把延迟设成 0 依然表示关闭批处理、每条消息立即发。

冷启动不再丢弃离线期间收到的消息

第二条更隐蔽。Telegram 适配器的 connect() 传的是 drop_pending_updates=not is_reconnect —— 只要不是「看门狗重连」,它就让 Telegram 把停摆期间排队的更新全部丢掉。

这条路径覆盖的正是最常见的场景:笔记本电脑开机、容器重启、hermes gateway restart。 你晚上在手机上给它发指令,机器那时是关着的;第二天开机,那些消息被丢掉,而且没有日志、没有错误,命令只是没执行 —— 这就是报告里说的「静默」那一半。

现在多了一个开关:

gateway:
  platforms:
    telegram:
      extra:
        drop_pending_on_cold_boot: false   # 默认 true = 保持今天的行为

设成 false 之后,第一次连接会收下 Telegram 保留的积压(受它大约 24 小时的窗口限制)。同时这个决定无论哪种结果都会写日志(Cold boot: dropping|preserving …),所以「命令没执行」这件事以后可追溯。

两处保持不变、也别指望被这个键改变:409 冲突恢复阶梯仍然会丢弃(必须丢,否则踢不掉另一个正在跑的 getUpdates 会话),看门狗重连仍然无条件保留。另外这个键对轮询和 webhook 两条启动路径都生效(旧注释说 webhook 是空操作,实际上 PTB 会把它交给 delete_webhook / set_webhook,照样影响 Telegram 侧存的更新)。

免 @ 的频道,现在也能按话题分串了

第三条是关于「免 @ 频道」和「自动分串」的互斥。free_response_channels 决定要不要 @ 才回话,auto_thread 决定对话是不是每个话题单独成串。这本来是两件独立的事,但内联默认落地之后,前者静默地把后者关掉了 —— 结果一个频道只能二选一:要么免 @(但所有话题混在一个大串里),要么分串(但必须 @ 才回话)。Discord 上的社区频道和客服频道最容易撞上这个。

现在多了一个显式开关:

# config.yaml 顶层(不在 platforms: 下面)
discord:
  free_response_auto_thread: true    # 默认 false,保持旧行为

它只影响免 @ 那条路径,而既有的优先级完全不变:no_thread_channels 仍然最优先(列进去的频道永远不分串),语音连频道仍然内联,全局 auto_thread: false 仍然整体关掉分串。对应的环境变量是 DISCORD_FREE_RESPONSE_AUTO_THREAD,YAML 配置会被桥接过去。

三件事的共同点:静默

把这三点放在一起看,它们的失败方式完全一样:没有异常、没有告警,只有「怎么没反应」。 短回复的 5 秒是一次纯粹的等待;离线消息是被主动丢弃;频道不分串是默认值互相覆盖。对使用者来说都无法从界面上看出来。所以排查这类问题时的顺序建议是:

  1. 先确认消息到没到 Hermes:看网关日志里有没有对应的入站记录;
  2. 如果到了但回得慢,看看是不是批处理延迟在起作用(把 text_batch_delay_seconds 显式写出来,别依赖默认值);
  3. 如果是冷启动后「什么都没发生」,检查 drop_pending_on_cold_boot 和那一行 Cold boot: 日志。

现在能用吗

同样要如实说明:三个改动合并于 2026 年 9 月 19 日至 20 日,最新发版是 v0.21.3(标签 v2026.9.14),所以它们在 main 上、尚未进入正式版本 —— 目前用 hermes update 拿到的还是旧行为,上述键也不会被识别。文档(messaging/whatsapp.md、messaging/weixin.md、messaging/telegram.md、messaging/discord.md)已随改动更新。

小结

三个改动加起来,改掉的是同一类习惯:默认值替用户做决定,而且做决定时不留痕迹。 延迟默认值是别人机器上合适的数字,你的机器上可能不合适;丢弃积压在某些部署里是必需的,在个人笔记本上却会吃掉你的指令;两个键各自有默认值,组合起来却互相抵消。修法都很朴素 —— 一个是把默认值对齐到已经验证过的节拍并加上限,一个是把选择权交出来并每一次都记日志,一个是补一个显式的 opt-in 而不翻默认值。

如果你在配消息平台,可以接着看 Telegram 的 inline picker 和 沙箱里的媒体投递;多机器人房间里的消息该怎么分串、怎么轮次,可以看群聊那篇。