一段 16KB 的脚本让网关卡死 23 秒:危险命令检测的性能复盘


你贴给 Hermes 一段几百行的脚本——一个把文档文本拼进 JSON 负载的 Python heredoc——让它跑起来。终端没有反应。过一会儿,你在 Telegram 上问它进度,机器人不回。打开日志,看到的是这样一组让人一头雾水的行:deadline (15s) expired but the event loop has not processed the expiry、the loop thread appears BLOCKED in a synchronous call,然后是关闭看门狗退出,接着进程重启,然后它又去重试同一条命令,一切重来。看起来像网络问题或消息平台故障,实际上问题出在一件跟“安全”有关的小事上:危险命令检测。

症状:不是“慢”,是事件循环被按住不放

在真正执行一条命令之前,Hermes 要先判断它危不危险——这决定了它是可以放行、需要问你、还是必须硬拦。这套检测跑在 Python 的正则引擎里,而且持有 GIL。命令越长,它占用的时间越长;而被占住的不是某条命令,是整个事件循环。

一位用户带着完整的复现材料报告了这个情况(#113535,2026 年 9 月 16 日,环境是官方镜像 0.20.6),后果链条是这样串起来的:

  1. 检测在长命令上耗时到分钟级 → 事件循环饿死;
  2. Telegram 轮询超时,telegram-init 的 15 秒 deadline 到期,但循环处理不了这个到期事件;
  3. 关闭看门狗判定进程失联,退出码 75,网关重启;
  4. 重启后 agent 面对同一条未完成的命令,再次发起同样的检测——于是循环往复。

最后这条尤其值得注意:这类“卡住→重启→重试→再卡住”的形态,跟消息平台本身出问题长得一模一样,很容易让人往错误方向排查。

根因:一条正则从 O(n²) 起手,再乘上“变体爆炸”

报告者的分析非常干脆,问题由两个因素叠加:

第一,一条未锚定的整串前瞻(lookahead)。 launchctl 网关生命周期规则写成了两个独立的整串 lookahead:

(r'(?=[\s\S]*\blaunchctl\s+(?:stop|kickstart|bootout|unload|kill|disable|remove)\b)'
 r'(?=[\s\S]*\b(?:hermes|ai\.hermes)\b)', "stop/restart hermes launchd service ...")

意图是“既出现 launchctl 的停止类动词、又出现 hermes/ai.hermes 这两个词”就命中,不要求先后顺序。但两个 lookahead 都没有锚定,re.search 会从每一个起始偏移重试它们,而每次重试都要扫到字符串末尾——单次搜索就是 O(n²)。实测:对 60,018 个字符的字符串,这一个模式单次搜索要 16.5 秒;加上锚点后是 0.001 秒。

第二,“命令词变体”随引号数量线性爆炸。 _command_detection_variants 会为每一个带引号或转义形式的命令词生成一个与原文等长的变体,以便解混淆后匹配。一段引号密集的 heredoc("key": "value" 这种 JSON 内容)有几百个引号词:230 行就能产出 233 个变体。而检测要对每个变体跑一遍全部模式——两次检测(hardline 与 dangerous)叠加,总复杂度达到 O(n³)。

报告者给出的实测曲线:

命令长度 变体数 总正则耗时
3,000 字符 41 2.7 秒
6,000 字符 82 27.8 秒
15,369 字符 201 约 7 分钟(工具在 420 秒时超时)

也就是说,这不是“稍微有点慢”,而是长度翻倍、耗时翻四倍以上的指数式劣化。

修复:一处锚点 + 一个合并变体,判定集合不变

2026 年 9 月 17 日合并的 PR #114208 用两个改动拆掉了爆炸:

  • 给 launchctl 的 lookahead 加 \A 锚点。关键在于这不改变语义:在偏移 0 处求值的 lookahead 本来就能看到整个字符串,所以匹配集合完全一致——token 顺序颠倒的写法照拦,多行输入照拦。只是搜索从 O(n²) 变成了 O(n)。
  • 变体从“每个命令词一个”改成一个合并变体。所有命令词先统一解混淆,再作为一个变体参与检测;跨度来源可能乱序(循环体出现在关键字之后)也可能嵌套(反引号里的命令),因此替换按排序应用,与已应用跨度重叠的部分按原逻辑保留自己的逐词变体;重叠跨度等到下一轮(每个嵌套层级一个合并变体),所以嵌套替换脚本也不会再炸一次。在报告者的输入上,变体数从 923 降到 4。

修完的效果:一个 16 KB / 460 行的 heredoc,从约 23 秒(7.8 秒 hardline + 15 秒 dangerous)降到约 0.4 秒,判定结果完全一致。hardline 那条通道保持无界、语义不变——一段良性的 heredoc 既不会被拦,也不会被拉出来问你,这一点在修复前后没有区别。

这个“判定集合不变”是整件事里最值得强调的部分。审批门禁是安全边界,不能靠放宽规则来提速——把一条本该拦下的命令漏过去,比卡 23 秒糟糕得多。所以正确的修法是消除算法上的浪费,而不是砍掉检测本身。

顺便说说:这条规则为什么写得这么“宽”

看一眼源码里的注释就明白这条规则的保守不是没来由的:早期版本要求“hermes 出现在动词之后”,结果一个从前面定义好的列表里拼 label 的 for 循环(for item in 'ai.hermes...'; do launchctl bootout "$label")绕过了检测,在零审批的情况下重启了 4 个网关。对于审批门禁来说,宁可多问一次是正确取向——多一个提示很便宜,放过一次则可能是把正在跑的 agent 全杀掉。

也正因如此,这次修复只动性能、不动宽度,这才是它值得信任的地方。

你在什么时候会遇到它

  • 贴长脚本:几百行的 heredoc、内联大量 JSON/文档文本的命令,都会命中这条路径。日常短命令不受影响。
  • agent 跑在网关上:因为检测持有 GIL,受害的不只是当前命令,还有同一进程里的消息通道、心跳和看门狗。CLI 里你只是“等一下”,网关上是“机器人失联 + 重启循环”。
  • 看日志的关键词:loop thread appears BLOCKED in a synchronous call、deadline ... expired、watchdog 退出码 75。看到这几个词组合出现时,先怀疑同步阻塞,而不是网络。

版本状态

这个修复于 2026 年 9 月 17 日合并进 main。报告者已经确认,出问题的那条正则在 v2026.9.14(也就是目前的正式版 v0.21.3)里原样存在——所以你现在用的发布版仍然会卡,修复尚未进入任何 tag。

hermes --version   # 若为 v0.21.3 或更早,长 heredoc 仍有此问题
hermes update      # 等下一个 tag 落地后即为已修复

在升级之前,如果你需要让 agent 跑很长的脚本,可以把它写成文件再让 agent 执行那个文件,而不是把整段内容贴进命令行——这条路径不经过“整串扫描”,是最简单的绕法。

延伸阅读:想了解审批门禁的整体设计,无人值守下的审批讲的是模式与策略;SSH 配置改动为什么需要审批是一条真实规则从事故到落地的过程;审批的 dry-run 测试可以帮你确认自己的规则会不会误伤;而如果你好奇网关在卡死时是怎么被守护者拉起来的,网关循环看门狗是同一主题的运维侧。

小结

  • 症状:长 heredoc 让网关事件循环被同步正则按住,表现为 Telegram 失联、telegram-init deadline 过期、看门狗退出 75、重启后重试同一命令形成循环。
  • 根因:launchctl 规则的两个未锚定整串 lookahead 让单次搜索 O(n²)(60K 字符实测 16.5 秒);_command_detection_variants 对每个引号词生成等长变体,使总复杂度到 O(n³)(6,000 字符就有 82 个变体、27.8 秒)。
  • 修复(PR #114208,2026-09-17):\A 锚点 + 单个合并变体,变体数 923 → 4,16 KB heredoc 从 23 秒降到 0.4 秒,判定结果一致。
  • 状态:发布版(v0.21.3)仍受影响,修复在 main 上、尚未进 tag。