每条消息都在读配置:Hermes 把这条热路径上的锁拆掉了


你正跟 Hermes 说“把压缩阈值调高一点”,它照做了,写回 config.yaml。就在这几百毫秒里,你顺手在 Telegram 上发了条消息给自己的机器人——它没回。不是模型慢,也不是网络问题:那条消息在等一个锁。

听起来像个极端情况,但它其实每天都在发生。原因很朴素:Hermes 的大部分关键路径在读到“我该用哪个模型”“这个插件开没开”“这个超时是多少”之前,都要先读一次配置。而读配置这件事,一直是个不便宜、还要排队的操作。最近这一批改动(#118961,收编了 #115069、#115070、#117440)做的事情就是:把这条路径上不必要的重复解析和全局锁拆掉。

先说清楚原来贵在哪里

配置是 YAML,读它意味着:定位文件、解析 YAML、和默认配置做深度合并、再拷贝一份给调用方。为了避免读到写了一半的内容,这整套动作被一把全局的 _CONFIG_LOCK 保护着。

于是有了两个问题:

  1. 每次读都要重新解析。 就算内容一个字都没变,也照解析不误。有些路径更是离谱——比如流式输出时,每吐一个 reasoning token 就重读一次配置来决定要不要发增量;再比如心跳恢复时,每一行记录都重新进一次 profile 作用域。
  2. 读要排在写后面。 只要有写入方(比如 save_config())拿着锁,所有读都得等。而在网关里,这条读路径就在每一条入站消息的钩子函数上——所以一次保存,能停掉所有人的消息处理。

改动一:热路径只读一次,不再按 token 读

三条被收编的改动,各自修掉一处重复:

  • 流式发送(agent/stream_delivery.py):plugins.stream_reasoning_deltas 现在每次流只解析一次,而不是每个 reasoning token 解析一次。PR 里给出的实测是单次读取约 340 µs 降到约 23 µs。
  • 心跳恢复(gateway/run_heartbeat_restore.py):每个 profile 作用域在一次恢复扫描里只进入一次,而不是每条记录进入一次。
  • profile 配置解析(utils.load_yaml_file_readonly):按文件签名(mtime_ns、大小、inode、ctime_ns)缓存解析结果,只缓存正向结果——文件变了就重新解析,不会拿旧内容糊弄你。

改动二:命中缓存时不拿锁

这是最关键的一步。hermes_cli/config.py 里的 load_config() 现在有一个免锁的快路径:

# 命中缓存时免锁的快路径:命中约 0.024 ms;
# 被 save_config() 持有的锁挡在后面时,同样的读取实测 10010 ms,
# 在网关上这会卡住每一条入站消息的钩子路径。
# 竞争失败就落回加锁路径。

它为什么是安全的?靠的是一条明确的发布约定:缓存条目是整块元组一次性替换的,锁只用来串行化“重建”和“写入方”。读者拿到的要么是完整的旧元组,要么是完整的新元组,不会看到撕裂的中间状态;拿到之后还会再核对一次文件签名,签名对不上就当作未命中、走加锁路径。

换句话说:读不再需要排队的理由是——它不是在被保护的状态上读,而是在一个永远不会被就地修改的快照上读。竞争失败是有兜底的,最坏情况退回到原来的实现。

同一套快路径也补给了 read_raw_config() 和 read_raw_config_readonly()。

PR 里的探针数据(在作者机器上、让一个写入方持有 _CONFIG_LOCK 2 秒):

场景 改动前 改动后
缓存命中的 load_config() ~2008 ms 0.3–1.9 ms
read_raw_config() ~2001 ms 0.1–0.2 ms
源码注释里记录的最坏情况 10010 ms ~0.024 ms

另外还验证了 A → B → A 切换不同 HERMES_HOME 时没有跨 home 的缓存串味——这一点对多 profile、多网关的部署很要紧。

改动三:顺手删掉一个已经不划算的记忆化

这批改动里有个很有意思的细节:作者删掉了一处记忆化,而不是保留它。

hermes_cli/plugins.py 里原本为 hook_callback_timeout 做了一个记忆化。等 load_config 的命中变成免锁之后,这个记忆化省下的东西基本归零了——PR 里给出的数字是记忆化命中 8.4 µs,直接走 load_config_readonly() 是 9.0 µs,差 0.6 µs。但它有个真实的副作用:它绕过了 load_config 的环境快照检查,也就是说,如果你把超时写成环境变量引用(形如 `${VAR}` 那种),改了环境变量它不会跟着刷新。

所以结论是:省 0.6 µs,换来一个“改了环境变量不生效”的坑,不划算,删掉。

对你来说意味着什么

  • 网关上的消息延迟更稳。 以前“保存配置”和“收消息”之间会互相干扰,现在读不再排在写后面。
  • 不需要改任何配置。 这不是一个需要打开的开关,是默认行为。
  • 多 profile / 多网关环境更受益。 心跳恢复和 profile 作用域的重复进入,都是按 profile 数量放大的成本。

如果之前你遇到的是“机器慢、闲下来 CPU 又高”那类问题,那是另一个已修的性能面,可以看性能清理那一篇;而如果你的痛点是长任务跑着跑着卡住,长任务调参更对口。想直接看配置怎么读写,命令参考在 hermes config;把多个 profile 收敛到一个网关上之后为什么更省事,见一主机一网关。

小结

这次没有新功能,也没有新配置项,只有一句话:读配置不该排队,也不该重复解析。 命中的读取从“拿锁 + 重解析”变成“读一个不会被就地改动的快照 + 核对签名”,竞争失败就退回原来的实现。性能数字很漂亮,但真正值钱的是那个取舍——为了 0.6 微秒保留一个会让环境变量失效的记忆化,不值得,于是删掉。