开源自托管 Agent 项目 Hermes Agent 起不来或断流:按这个顺序缩小范围
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
排查 Hermes Agent(NousResearch 那个开源自托管 Agent 项目,不是同名的模型系列)最容易浪费时间的地方,是把三类完全不同的症状混在一起查:命令根本起不来、起来了但流式响应半路断掉、常驻进程被外部杀掉。它们各自有独立的诊断设施,输出落在不同的位置,谁都不负责替另一类兜底。先归类,再决定读哪个文件的输出,比一头扎进日志里翻要快得多。
先做个命名消歧:这里说的 Hermes Agent 是 NousResearch 的开源自托管 Agent 项目,仓库在 https://github.com/NousResearch/hermes-agent ,LICENSE 采用 MIT 许可证、署名 Nous Research。它和 Nous Research 那套同名的开源模型系列不是一回事,也和其它若干同名商标、同名软件包无关。这个项目的形态是常驻在你自己机器上的进程:会开终端执行命令、连聊天平台账号、往磁盘写状态、访问外部模型服务。进程链比一次性跑完就退出的 CLI 长得多,所以它的故障面也更宽——很多问题不在模型侧,而在“进程/环境/连接”这三层。
站内另外几篇的分工需要说清楚:Agent 框架调试的通用方法 讲的是不分项目的调试思路,Agent 失败模式怎么分类 讲的是失败归因的框架,ECC 项目的故障排查 讲的是另一个项目的具体套路;本篇只做一件事,把上面那套思路落到 Hermes Agent 这个具体仓库的四处真实代码上——文件在哪、输出长什么样、先读哪个。
一、先把症状分成三类,再决定读哪份输出
这个仓库的规模摆在那儿:skills/ 下 14 个分类目录共 70 份 SKILL.md,optional-skills/ 下 21 个分类目录共 111 份 SKILL.md,plugins/ 有 18 个顶层插件目录,optional-mcps/ 6 个,tests/ 里以 test_ 开头的测试文件有 2499 个。这个体量意味着一件事:你不可能靠“通读代码”定位问题,只能靠定位到正确的诊断出口。
按症状分类的话,四处工具的落点是这样的:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 早期恢复 | 在 hermes_cli.main 的第三方导入之前,探测并修复被中断更新弄坏的核心包 | hermes_cli/_early_recovery.py | 上一次更新被打断,之后任何命令一启动就在 import 阶段崩 |
| 虚拟环境阻塞扫描 | 扫出还在用本安装 venv 解释器跑的进程,输出一份 JSON 给桌面端更新前置检查 | hermes_cli/_scan_venv_blockers.py,检测函数在 hermes_cli/update_cmd.py | Windows 上更新失败、或桌面端拒绝更新并让你先关应用 |
| 流诊断 | 记录每次流式尝试的上游标识、HTTP 状态、断前收到的字节与分片数、异常链 | agent/stream_diag.py | 回答说到一半没了、状态行提示重连 |
| 退出取证 | 收到停止信号时抓一份现场:信号来源、父进程、是否在 systemd 下、接管标记 | gateway/shutdown_forensics.py | 常驻服务反复退出、日志里只看到被杀没看到原因 |
顺序建议就是这张表从上往下:起不来的时候,后三处的输出根本不会产生;能起来但断流,退出取证也不会被触发。反过来先看流日志、结果发现是进程被 systemd 杀了,等于白读。
二、起不来:早期恢复为什么必须是纯标准库的
hermes 这个命令的入口是 hermes_cli.main:main。问题在于,导入 hermes_cli.main 这个模块本身就会在模块级别拉进一堆第三方包——比如经 hermes_cli.env_loader 引入的 dotenv、经 hermes_cli.config 引入的 yaml。而恰恰在“上一次更新被打断、某个核心包的导入文件被清空”这种状态下,正常启动会在导入 main.py 的过程中就崩掉。也就是说,main.py 里那套基于恢复标记的自愈逻辑,在最需要它的时候恰好是不可达的。
hermes_cli/_early_recovery.py 就是为了填这个洞:整个模块刻意只用标准库,这样在一个坏掉的虚拟环境里导入它也不会失败。hermes_cli.main 在模块体最顶部、任何第三方导入之前就调用它的 recover_if_needed()。
它的判断链条很克制。没有恢复标记时,快路径只是两次 lstat;只有当上一次更新留下的标记文件存在,并且导入探测确认某个核心包真的坏了,它才动手。探测表本身写得很直白,探的是模块加一个哨兵属性——光能 import 不算过关:
LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
("yaml", "SafeDumper"),
("dotenv", "load_dotenv"),
("click", "Command"),
("certifi", "contents"),
("rich", "print"),
("cryptography", "__version__"),
("jwt", "encode"),
)
certifi 还多一道检查:模块能干净导入、分发元数据也在,但打包的 cacert.pem 已经没了或变成断链——这种状态下每个 TLS 连接都会从 httpx / requests 深处抛出一句语焉不详的“找不到合适的 TLS CA 证书包”。属性探测在这种状态下是会通过的,所以它额外去 stat 那个 bundle 文件,小于 1 KiB 直接判为损坏(一份 PEM 证书都装不下)。
确认坏了之后的修复动作是:从 pyproject.toml 里把裸包名映射回带版本约束的规格(用 tomllib 加朴素的需求头解析,因为 packaging 自己也可能是坏的那个),先跑 ensurepip,再 pip install --force-reinstall。这里有个容易被忽略的工程细节:修复过程一个字都不往 stdout 写,全部走 stderr——因为 hermes acp 这个子命令要在 stdout 上讲 JSON-RPC,任何多余输出都会把协议弄脏。
还有三条边界值得记住。第一,命令行里出现 update 时它直接返回,避免和真正的更新流程抢标记。第二,源码树里没有 pyproject.toml 时(托管安装、Docker、从包索引装的情况)它不动手,标记不是它该处理的。第三,它从不清理标记文件——完整的标记生命周期归 main.py 里那个恢复函数,等导入成功之后再跑。它自己被包在一个大 try 里,任何失败都不阻塞启动,让 main.py 的真实报错自己浮出来。
对你的意义:如果你在一次被打断的更新之后启动,看到 stderr 上出现“核心包被中断的更新弄坏了,启动前先修”这类提示,那说明这层已经介入过一次;如果它接着告诉你自动修复不完整,它会把可以手动执行的 pip install --force-reinstall 命令连同解释器路径一起打出来,照着跑就行,不用自己猜包名。
三、Windows 上更新装不下去:谁占着那些文件
第二处工具解决的是另一个具体到平台的问题。hermes_cli/_scan_venv_blockers.py 是一个独立的可执行模块,桌面端 Electron 应用用 venv\Scripts\python.exe -m hermes_cli._scan_venv_blockers 这种方式调它,约定是 stdout 上恰好一份 JSON 文档、诊断信息只走 stderr;扫描成功(无论结论是通畅还是被阻塞)退出码为 0,非零代表探针本身挂了——比如 psutil 不可用。
它真正的检测逻辑复用了 hermes_cli/update_cmd.py 里的进程检测函数。那段代码的注释把动机写得很清楚:可执行文件层面的守卫会漏掉 Windows 上最大的一类占用者,也就是桌面端那个跑在 venv 解释器上的后端进程,以及任何直接从 venv 里的 python 起来的东西。这些进程把原生扩展文件保持在映射状态,更新过程中同步依赖会撞上拒绝访问,然后留下一个更新到一半的虚拟环境。检测规则是三层:可执行文件路径落在本 venv 目录下算命中;命令行里出现本 venv 路径也算;命令行里带 hermes_cli.main 且安装根目录出现在命令行或工作目录里同样算——后两条是为了抓住那些解释器在 venv 外、但仍从 venv 里导入并持有扩展文件的调用方式。调用进程自己和它的祖先进程始终排除在外,否则 CLI 自己就会把自己算成占用者。
值得单独说的是这里的处置态度:注释直接写了,从这儿去杀那些进程是没意义的,因为桌面应用会监管它的后端并在几秒内重启,所以调用方应该拒绝更新、让你自己去关应用。这是个很务实的取舍——工具只负责给出可归因的事实,不替你做那种会被立刻回滚的动作。
输出里的命令行做了两道脱敏。先过项目共享的通用密钥脱敏函数(强制模式);再走一轮保守的长参数处理,命中 --token、--api-key、--password、--secret、--authorization、--access-key、--private-key、--session-key 时保留参数名、把它后面的所有内容替成脱敏占位。短参数刻意不处理,因为它们含义含糊,而且往往是有用的诊断信息。如果通用脱敏函数自己抛异常,整条命令行直接换成脱敏占位——PID 和进程名仍然够你定位。JSON 的形状就三个键:
data = {"ok": True, "blocked": bool(processes), "processes": processes}
桌面端那边(apps/desktop/electron/venv-blocker-scan.ts)是严格解析:ok 不为真、blocked 不是布尔、processes 不是数组、条目字段类型不对,一律判为探针失败;甚至“说被阻塞但列表是空的”和“说没阻塞但列表非空”这两种自相矛盾的组合也判失败。它跑在子进程里并带超时,避免在一台负载高的 Windows 机器上把主进程事件循环卡住。
对你的意义:Windows 上更新失败先别急着重装。手动跑一次这个模块,看它到底列出了哪些 PID;如果条目的命令行里带着服务相关字样,那就是桌面端后端,关掉应用再更新。同时也要意识到,这条检测线是 Windows 专属的,别指望它在 Linux 或 macOS 上给你结论。
四、断流:日志里那一行 WARNING 到底写了什么
流式请求死在半路是这类项目的高频故障,而且最难归因——你只看到回答戛然而止。agent/stream_diag.py 把归因需要的东西提前收集好了。
每次尝试开始时会建一个诊断字典,字段是起始时间、首个分片到达时间、分片数、字节数、响应头、HTTP 状态。关键是响应头和状态在流刚打开、还没开始迭代分片的时候就抓一次快照,这样即使一个分片都没来就断了,元数据也还在。抓哪些头是列好的:
STREAM_DIAG_HEADERS = (
"cf-ray",
"cf-cache-status",
"x-openrouter-provider",
"x-openrouter-model",
"x-openrouter-id",
"x-request-id",
"x-vercel-id",
"via",
"server",
"x-forwarded-for",
)
第二件有价值的事是异常链展开。SDK 会把底层网络异常包成自己的连接错误类型,catch 点上你只能看到外层那个包装类的名字,而真正说明“为什么断”的是里面的远端协议错误、连接错误或读取错误。展开函数沿着 __cause__ 再 __context__ 往下走,去重、最多四层,每层消息超长就截断,最后拼成一行用箭头分隔的链。
这些信息的落点分两处,分工很清楚。日志文件里是一条结构化的 WARNING,字段包括子 Agent 标识与委派深度、提供方、base_url、异常类型、摘要、异常链、HTTP 状态、断前字节数与分片数、耗时,以及首字节时间和抓到的上游响应头。而给你看的状态行是刻意精简的:提供方、异常类名、第几次重试,加上一个“after X 秒”的后缀。这个后缀不是装饰——它区分的是“连都没连上”(接近 0 秒)和“流了几十秒才死”(更像上游空闲踢连接或代理超时)。这个模块的注释里直接给了查看方式:hermes logs --level WARNING | grep "Stream drop"。
这种“给人看的一行 + 给机器留的一行”的分法,在 Agent 可观察日志怎么设计 里是通用做法,这里是它的一个具体实现。
对你的意义:断流别只看状态行的重试提示。去日志里把那几条 WARNING 拉出来横向比对——如果每次断掉时上游标识里的边缘节点或下游提供方都是同一个,那是上游某一路的问题;如果每次都不一样,就更像你本地出口链路或代理的问题。另外,涉及模型服务商的连接与重试策略,各家规则不同且会调整,以官方最新说明为准,日志里的头字段只告诉你事实,不代表对方的承诺。
五、反复退出:退出取证与时长错配这两件事
常驻服务最难查的一类工单是“它老是自己死”。gateway/shutdown_forensics.py 针对的正是这个场景,设计约束写在模块开头:网关的停止信号处理函数是同步跑在事件循环里的,不能在里面长时间阻塞,但又确实需要一份持久的现场记录。
于是它拆成两半。同步那半是一个很快的快照函数,纯标准库、不起子进程,抓的是:信号编号与名字(区分中断和终止很重要)、自己的 PID 与父 PID、父进程和自身的精简摘要(名字、状态、uid、截断后的命令行)、是否运行在 systemd 之下(依据是环境变量 INVOCATION_ID 存在或者父 PID 等于 1)、一分钟平均负载、以及是否有调试器或跟踪工具附着(读的是进程状态里的 TracerPid,非零就把跟踪者也抓一份摘要)。它还会去 HERMES_HOME 下看两个标记文件:.gateway-takeover.json 和 .gateway-planned-stop.json,并且会判断接管标记指的到底是不是自己——如果磁盘上有一个接管标记但目标不是当前进程,那基本就是“另一个带替换参数启动的实例正在把我干掉”。
这份上下文会被渲染成一行 key=value 的日志,父进程命令行放在最后(因为它常常很长,但也是最有用的单条线索)。网关在收到信号后会把它以 WARNING 打出来,日志里的前缀是“Shutdown context:”。同时,网关那边还会区分三种情况:中断信号视为计划内停止,带替换参数的接管视为计划内接管,其余的才标成信号触发的意外退出——容器编排下发的终止、内存不足被杀、直接 kill 都落在这一类。
异步那半是一个甩出去就不管的取证子进程,用分离会话启动,输出以追加方式写进日志目录下的退出诊断文件,这样即使当前进程所在的控制组正在被拆掉,它也还能把内容刷到磁盘。它跑的是一段内联 shell:打时间戳,按 CPU 排序取前若干行进程树快照,打自身的进程树,读系统负载,再去内核环形缓冲区或用户级日志里捞最近若干行找“被杀/内存不足”的痕迹。这段脚本自己带超时,卡住的话会自我清理。注释里也解释了为什么不沿用旧写法——在一台有几百个进程的机器上,同步地走一遍进程表可能要花上秒级时间,那段时间事件循环是冻住的,适配器根本没法开始拆卸。Windows 上这半直接跳过,因为平台上没有对应的命令。
同一个文件里还有一个启动期检查,解决的是很典型的“幽灵杀进程”:如果你升级了程序但没有重新生成 systemd 单元文件,单元里的停止超时可能比配置的排空超时还短。结果就是终止信号到达、排空刚开始,systemd 就把整个控制组强杀了——而日志里只留下一句被信号 9 杀死的记录,看起来像凭空被杀。这个检查只在检测到自己确实跑在 systemd 下时才做:先从自身的控制组信息里解析出单元名,再问 systemctl 要那个单元的停止超时属性(先试用户级、再试系统级),把值换算成秒,和排空超时加上一段固定余量做比较,给出一个是否错配的结论。拿不到数据就返回空,不猜。
对你的意义:遇到反复退出,先看那一行退出上下文——信号是什么、父进程是谁、是不是在 systemd 下、负载高不高、有没有接管标记。这四五个字段基本就能把“运维在重启”“另一个实例接管”“内存不足被杀”“有人挂着调试器”区分开。分不出来再去看那份取证文件里的进程树和内核日志尾巴。把这套动作固化成常规检查项,可以参考 Agent 日常运维要看哪些指标 的做法。
六、边界与代价:它明确不管的事
这几处设施的克制程度值得说明白,否则你会对它们抱有错误期待。
早期恢复只修“让 main.py 能被导入”这一件事,修的是一张写死的核心包清单,用的是 pyproject 里的版本约束。你的可选后端、插件依赖、原生扩展缺失,它一概不管;它也不判断你的配置对不对。它还刻意不清理恢复标记,所以标记还在不代表没修好。托管安装和容器里它直接不动手。
虚拟环境阻塞扫描只在 Windows 上返回结果,其它平台恒为空——不是“没有占用”,是“没有结论”。它依赖 psutil,装不上就只能报探针失败。它只识别占用本安装 venv 的进程,别的锁文件来源(杀毒软件、文件同步、编辑器索引)它看不见。它也不解决问题,只给事实。
流诊断是尽力而为的:抓头、抓状态、抓计数的每一步都被异常吞掉,宁可少一份数据也不让诊断代码本身把请求搞崩。它不做跨请求的聚合,不判断是不是该换提供方,也不告诉你重试策略该怎么调;横向比对是你自己的活。收集的上游标识和请求 ID 会进日志文件,如果你要把日志交给别人看,这是需要过一遍的信息。
退出取证的同步快照面向 Linux 的进程信息接口,非 Linux 上很多字段直接缺失;取证子进程在 Windows 上不产生。它靠环境变量和父 PID 推断 systemd 上下文,这两条都可能被非常规启动方式绕过。它记录的是“谁发的信号”,不是“为什么那个人要发信号”——真正的动机还得你去对方的日志里找。命令行会被截断,父进程摘要也可能因为权限拿不到。
最根本的一层代价来自这个项目的形态本身:它常驻、执行命令、连你的账号、写磁盘、访问外部服务。这几处诊断设施为了可归因,会把进程命令行、父进程信息、上游请求标识写进磁盘上的日志。脱敏做了,但脱敏是尽力而为的,短参数明确不处理。日志目录的权限、备份和分享方式,属于你自己要管的部分。
七、上手与避坑清单
- 别在更新被打断后立刻删虚拟环境重建。 会踩是因为“启动就崩”看起来像环境彻底废了。实际上有那两个恢复标记在,启动路径会先做一轮定向修复;先看 stderr 上有没有修复提示和它给出的手动命令,照着跑通常比重建快得多,也不会丢你别的依赖。
- TLS 报错不要往代理和防火墙上猜。 会踩是因为报错信息来自网络库深处,看着像网络问题。实际上有一类是 CA 证书包文件本身没了或残缺——探测逻辑专门 stat 了那个文件并把小于 1 KiB 判为损坏。先确认这一项,再查网络。
- Windows 上更新失败先扫占用,别反复重试。 会踩是因为重试有时“看起来”成功,但留下一个更新到一半的环境。实际上原生扩展文件还被映射着,写入必然失败。先跑一次阻塞扫描模块看列表;看到疑似桌面端后端的条目就关应用,别去杀进程——它会被重新拉起来。
- 断流不要只看终端那行重试提示。 会踩是因为状态行刻意做得简短,只有提供方、异常类名和重试计数。完整的上游标识、HTTP 状态、断前字节数都在日志文件的 WARNING 里;不去日志就等于放弃了归因所需的一半信息。
- 别把“断在 0 秒”和“断在几十秒”当同一个问题。 会踩是因为两者的用户感受一样。看那个“after X 秒”后缀:接近 0 秒是连接建立阶段的问题,流了很久才死更像空闲超时或中间代理。这两条的排查方向完全不同。
- 服务反复退出时,先确认是不是计划内。 会踩是因为日志里的退出记录长得都差不多。区分点在退出上下文那一行以及两个标记文件——带替换参数的接管和运维发起的停止都是计划内的,只有既没标记也不是中断信号的才值得深挖。
- 升级之后重新生成 systemd 单元,别留着旧的。 会踩是因为旧单元一直“能用”。但停止超时如果小于排空超时加余量,排空会被中途强杀,日志里只剩一句被信号 9 杀死,非常容易误判成幽灵杀进程。仓库里那个启动期检查就是为这个准备的,注意它只在能确定跑在 systemd 下时才给结论。
- 在配置成 JSON-RPC 协议模式使用时,别往标准输出里加任何东西。 会踩是因为你可能顺手加一句打印来调试。那条通道上是协议数据,早期恢复的所有提示之所以都走 stderr 就是这个原因;多一行输出就够把对端解析弄崩。
收个尾
把这四处按顺序串起来其实就是一句话:进程能不能起来 → 环境有没有被别人占着 → 连接有没有断以及断在哪一层 → 进程是不是被外面杀的。每一环都有确定的输出位置,跳环去读是浪费时间。
给自己留一份自检顺序:启动失败先看 stderr 有没有修复提示、以及项目根目录里那两个恢复标记是否存在;Windows 上更新失败先跑一次阻塞扫描模块;断流去日志里捞 WARNING 并按上游标识横向比对;反复退出先读那行退出上下文再看取证文件。
接下来该读哪个文件,取决于你卡在哪一环:想弄清恢复标记的完整生命周期,去 hermes_cli/main.py 里那个中断安装恢复函数;想知道进程检测的匹配规则和拒绝更新时打给用户的话,去 hermes_cli/update_cmd.py;想看信号处理路径怎么区分计划内与意外退出,去 gateway/run.py 的停止信号处理段。这三处都能顺着本篇提到的名字直接搜到。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的三道闸门 和 开源自托管 Agent 项目 Hermes Agent 装机实录与必选配置。