开源自托管 Agent 项目 Hermes Agent 常驻部署与缩容

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

把这个项目部起来的难点不在启动命令,而在于它默认就是一棵被监督的进程树:你以为自己在跑一个容器进程,实际跑起来的是 s6-overlay 的 /init 作为 PID 1,加上一组常驻服务、三个开机初始化脚本和一个只读的安装树。 这里说的是 GitHub 上的 NousResearch/hermes-agent 仓库——一个 MIT 许可、由 Nous Research 署名的自托管 Agent 项目,不是同一家发布的 Hermes 开源模型系列,也不是其他同名的库或商标。搞清这一点很重要,因为搜到的一半资料讲的是模型权重,跟你要部的这个东西没关系。

一、先看清你要常驻的到底是多大一坨东西

在决定要不要让它长期占着一台机器之前,值得先数一下仓库里有什么。skills/ 下有 14 个分类目录、共 70 份 SKILL.mdoptional-skills/ 下有 21 个分类目录、共 111 份 SKILL.mdplugins/ 有 18 个顶层插件目录;optional-mcps/ 有 6 个;tests/ 里以 test_ 开头的测试文件有 2499 个。这些数字任何人 clone 下来自己数得出来,我列在这里只想说明一件事:它不是一个几百行的守护脚本,而是一个把技能库、插件、消息平台适配、看板调度、浏览器工具都放在同一个进程生命周期里的东西。常驻它,等于常驻这一整片能力面。

站内已经有几篇相邻的文章,分工不同:常驻 Agent 的日常运维 讲的是通用运维方法论,跟具体项目无关;pi 的 server 进程模型pi 的容器隔离 讲的是另一个项目的做法。本篇不重复方法论,只做一件事——把这个具体仓库里已经写好的编排文件和运行代码读开,让你知道敲下 docker compose up 之后到底发生了什么,以及后面每天要盯什么。

二、Dockerfile 已经替你做完的决定

Dockerfile 是多阶段的,第一阶段完全用来自己编译 SQLite。注释写得很直白:基底所用 Debian 稳定版附带的那个 SQLite 含有上游 WAL 重置的损坏缺陷,而该发行版当时也没有提供修好的回补包,所以镜像宁可自己拉源码、校验 SHA256、带一串 SQLITE_ENABLE_FTS5 / FTS3 / RTREE / SESSION 之类的编译开关构建出共享库,然后把它放到 /usr/local/lib 并用 ldconfig 覆盖发行版自带的那个。构建过程里还跑了一段 Python 内联自检:版本低于阈值直接失败,再建一张 fts5(content, tokenize='trigram') 虚拟表插一行查一次,查不中也失败。

这一段值得你留意,因为它暗示了这个 Agent 把 SQLite 当成主力状态存储——会话、检索、进程注册表都压在上面。一个数据库层的写入缺陷对常驻 Agent 是致命的,所以维护者选择把风险挪到构建期。

后面的阶段更常规但同样有信息量:uvuvx 从官方 uv 镜像拷进来;nodenpmcorepack 从上游官方 Node 镜像拷进来(注释解释了为什么不用 Debian 自带的那个:发行版捆绑的 Node 大版本已经停止维护,而等下一个 Debian 大版本太久,所以宁可从上游镜像拷一份还在维护的 LTS,并且刻意选了更老的 glibc 基底以保证拷过来的二进制在运行时能跑);运行时基底还是同一个 Debian 稳定版镜像。系统包一层装完,里面有 ripgrepffmpeggitopenssh-clientdocker-cliprocps、编译工具链和 libolm-dev。再往下 npx playwright install --with-deps chromium --only-shell 带三次重试。

也就是说:这个镜像自带一个浏览器内核、一个媒体处理工具、一套编译工具链、Git 和 SSH 客户端、以及 Docker 客户端。它不小,冷构建也不快——Python 依赖那层的注释特意提到,之前依赖安装排在源码拷贝之后,导致每次只改源码的提交都要重做几分钟的依赖工作,所以现在先单独拷 pyproject.tomluv.lock 来吃缓存。

安全设计上有三条是明写在环境变量里的:

  • HERMES_HOME=/opt/data,同时 VOLUME [ "/opt/data" ],所有可变状态都在这个卷上。
  • /opt/hermes(安装树、虚拟环境、前端产物、node_modules)由 root 拥有且对运行用户不可写,配合 PYTHONDONTWRITEBYTECODE=1,目的是让 Agent 会话没法改自己跑的代码。
  • HERMES_DISABLE_LAZY_INSTALLS=1HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages。可选后端的 SDK 没被烘进镜像,需要时装到数据卷上这个目录里,注释说明该目录被追加到 sys.path 末尾——所以它只能新增模块,不能覆盖或降级核心模块。

运行用户是构建时 useradd -u 10000 -m -d /opt/data hermes 建的。这个 UID 10000 后面会反复出现。

三、进程到底怎么起来:/init、三个初始化脚本、你的 CMD

镜像的入口是 ENTRYPOINT [ "/init", "/opt/hermes/docker/main-wrapper.sh" ]CMD 为空。这个拆法有意为之:/init 是 s6-overlay 的 PID 1,它先搭好监督树、跑完 /etc/cont-init.d/*、启动 s6-rc 声明的服务,然后把剩下的 argv 当”主程序”执行,并且继承容器的标准输入输出——这就是交互式使用还能工作的原因。因为包装脚本挂在 ENTRYPOINT 上,你传的参数会自动被追加到它后面,--version 这类带前导横线的参数也不会被 /init 的 POSIX shell 抢走。

main-wrapper.sh 的路由逻辑只有三条:没参数就执行 hermes;第一个参数是可执行文件就直接执行它;其他情况当作 hermes 的子命令透传。它自己不做业务,但做了几件容易被忽略的事:shebang 用 #!/command/with-contenv sh,因为 /init 会清掉环境变量,with-contenv 才能从 /run/s6/container_environment 把它们读回来;把 HOME 改成 /opt/data,避免依赖库往 /root 写东西;最后用 s6-setuidgid hermes 降权。

开机脚本按字典序跑,一共三个:/etc/cont-init.d/01-hermes-setup(转发到 docker/stage2-hook.sh)、015-supervise-perms02-reconcile-profiles

stage2-hook.sh 是这套编排里最值得完整读一遍的文件,它以 root 身份做了这些事:

PUID / PGID 当作 HERMES_UID / HERMES_GID 的别名接受,校验必须是纯数字且落在非 root 区间,然后 usermod / groupmod 改掉 hermes 用户的 UID/GID。数据卷的所有权修复是定向的——脚本注释明确说不能 chown -R 整个 $HERMES_HOME,因为它常常是宿主的绑定挂载,里面可能有无关文件;所以它只 chown 顶层目录本身,加上一个白名单子目录列表(cronsessionslogshooksmemoriesskillsskinsplansworkspacehomeprofilespairingplatforms/pairinglazy-packages),另外还有一份顶层状态文件的白名单(auth.json.envstate.db 及其 WAL 伴生文件、gateway.pidgateway.lockgateway_state.jsonprocesses.jsonactive_profile 等)。所有 chown 之前都会先检查路径上有没有符号链接,有就跳过并打警告。

如果你把宿主的 Docker socket 挂进来,脚本会 stat 出它的 GID,在 /etc/group 里补一个包含 hermes 的条目。注释解释得很清楚:光用 docker run --group-add 不够,因为 s6-setuidgid 会调 initgroups()/etc/group 重建附加组列表,内核授予的那个组会在降权时被静默抹掉。

首次启动时它从模板种下三个文件:.env(来自 .env.example)、config.yaml(来自 cli-config.yaml.example)、SOUL.md(来自 docker/SOUL.md)。.env 的权限每次开机都会重新收紧到 600。如果 config.yaml 已存在,会跑一遍配置模式迁移脚本。最后同步内置技能,并在 Playwright 的浏览器目录里按文件名精确找出 Chromium 二进制(注释吐槽过早期版本用 grep -Ei 'chrome|chromium' 会匹配到 .so 文件),把路径写进 /run/s6/container_environment 供后续服务读取。

静态声明的 s6 服务只有两个,都是 longrunmain-hermesrun 脚本内容是 exec sleep infinity——注释坦白说这是个占位,因为 s6-rc 要求 user bundle 至少有一个服务,不能是空的。真正干活的是 dashboard,但它有个开关:HERMES_DASHBOARD 不为真时 run 直接 exit 0,配套的 finish 脚本返回 125(s6 的”永久失败,不要重启”标记),于是槽位如实显示为 down,而不是在紧循环里反复重启。

docker-compose.yml 走的是另一条路——它起两个容器,都用同一个镜像、都挂 ~/.hermes:/opt/data、都是 network_mode: hostrestart: unless-stopped,一个 command: ["gateway", "run"],另一个 command: ["dashboard", "--host", "127.0.0.1", "--no-open"]

gateway run 是不是就在前台跑网关?不是。hermes_cli/gateway.py 里有个重定向:在容器内且 s6 是 PID 1 时,裸 gateway run 会被改派给服务管理器去启动一个受监督的 longrun,然后当前这个 CMD 进程 os.execvp("sleep", ["sleep", "infinity"]) 变成心跳(找不到 sleep 时退化为进程内阻塞 + SIGTERM 处理)。它会往 stderr 打一段说明,告诉你现在跑在 s6 监督下、崩溃会自动重启、日志由 s6-log 同时送到 docker logs${HERMES_HOME}/logs/gateways/<profile>/current。想要旧的前台语义,用 --no-superviseHERMES_GATEWAY_NO_SUPERVISE=1;被监督的那个子进程靠 HERMES_S6_SUPERVISED_CHILD 短路这个重定向,否则就会无限递归。

组成部分它负责什么对应仓库位置你什么时候会碰到它
/init 作为 PID 1搭监督树、跑开机脚本、执行主程序、回收僵尸进程DockerfileENTRYPOINT一旦自定义 entrypoint 就得保证它还在链首
参数路由包装脚本无参跑默认命令、可执行文件直通、其余当子命令;降权docker/main-wrapper.shdocker run <镜像> chat … 这类调用
开机初始化钩子UID/GID 重映射、定向 chown、配置种子、技能同步docker/stage2-hook.sh权限出问题、首次启动、绑定挂载
配置档位重建从持久卷重建 tmpfs 上的按档位网关服务槽docker/cont-init.d/02-reconcile-profiles容器重启后网关槽位为何是 down
占位主服务满足 s6-rc 对 user bundle 的非空要求docker/s6-rc.d/main-hermes/run排查”为什么有个 sleep 进程”
面板服务按环境开关决定起不起,用 125 表达永久失败docker/s6-rc.d/dashboard/runfinish面板打不开、槽位状态与预期不符
网关运行体长期驻留的主循环 + 一批被监督的后台观察器gateway/run.py内存、重启、后台任务丢失
空闲判定纯函数决定要不要进入休眠、判定是否空闲gateway/scale_to_zero.py想让它闲时缩下去

02-reconcile-profiles 这个脚本解释了一个很容易踩的现象:/run/service/ 在 tmpfs 上,容器一重启就没了,而配置档位目录在持久卷的 $HERMES_HOME/profiles/ 下。所以它会遍历持久档位、重建服务槽,但只自动拉起上次记录状态为 running 的那些。空白卷上根本没有状态文件,于是全新容器起来后网关是 down 的,要么从面板启动,要么在首次启动前设 HERMES_GATEWAY_BOOTSTRAP_STATE=running 让开机脚本先种下状态文件。这个脚本还会把 /run/service 及 s6-svscan 的控制 FIFO 改成 hermes 可写,否则运行时注册档位那条路整条是哑的。

四、空闲时怎么缩:三个前提缺一不可

gateway/scale_to_zero.py 全是纯函数,很好读,也很好测。开关只有一个来源:环境变量 HERMES_SCALE_TO_ZERO,取值在真值集合里才算开,缺省是关。空闲时长走配置文件里 gateway.scale_to_zero.idle_timeout_minutes 这一项,默认 5 分钟;parse_idle_timeout_seconds 对非数字和非正值都回落到默认,注释说明零或负数会让网关立刻休眠,那不可能是任何人的本意。

should_arm 要求三件事同时成立才启动空闲观察器:开关打开、消息侧只有中继或完全没有直连平台、并且注册了唤醒地址。第二条的理由在 messaging_is_relay_only_or_absent 的注释里:直连的聊天平台握着一条活的 socket,本身就没法缩到零。第三条更硬——一个被挂起却没有可达唤醒目标的实例是个黑洞。

is_idle 是三个条件的合取:没有正在跑的 Agent 轮次、没有活跃的后台工作、并且距上次入站超过了空闲窗口。run.py 里那个查后台工作的方法把三处都翻了一遍:运行器自己的后台任务集合、异步委派的活跃计数、以及进程注册表的活跃进程和待处理的完成观察器。理由很实在——后台跑的委派任务和后台终端不会被”正在跑的轮次”计数覆盖,挂起时会丢。

观察器本体是 30 秒一轮的循环。判定空闲后,它把运行时状态标成 draining,然后调中继适配器的 go_dormant()。这里有个区分值得抄进笔记:go_dormant() 只关 socket、保留重连监督器,它既不是断连也不是停机/重启的排空路径,进程仍然活着。之后设一个不短于 60 秒的冷却时间,避免唤醒后刚排空积压又立刻被判空闲。它也刻意不去标记”待恢复”,注释说明因为挂起保留内存,没必要重放。

如果开关开了却没 arm,它会打一条 INFO,把另外两个前提的实际取值写出来:消息侧当前启用了哪些平台、唤醒地址是 set 还是 MISSING。没开开关而没 arm 是正常情况,日志刻意保持安静。这个取舍很务实——把”为什么它不休眠”从一次进容器排查降级成一次日志检索,正是 Agent 可观测日志 那类设计要解决的问题。

对你意味着什么?如果你的目标是”部到自己的一台小机器上常驻”,这套机制大概帮不上忙。它假定下面有个能挂起机器的平台,自己只负责让中继侧安静下来、把决定权交出去。在你自己的机器上,进程照样驻留、内存照样占着,省下的只是网络连接。

五、边界与代价:它明确不管的那些事

只读安装树的代价是灵活性。你不能在容器里往虚拟环境里装东西,可选依赖只能落到数据卷上那个追加在 sys.path 末尾的目录,也就是只能新增、不能替换。想换掉某个核心依赖的版本,你得重新构建镜像。

--user 这条路被明确堵死了。开机脚本和包装脚本各有一段检查:当前 UID 既不是 0 也不是 hermes 用户时,直接打印一段说明并 exit 1。理由是启动引导(UID 重映射、卷所有权、配置种子)需要 root,而安装树故意不可写,一个任意的 --user UID 修不了这些,最终会在别处以权限错误崩掉——所以宁可在最前面失败得干脆些。

常驻进程的内存是一项持续开销,而且项目自己承认这点。gateway/run.py 顶部把每会话的 Agent 缓存上限设成 128、闲置一小时逐出,注释列出了每个 Agent 实例都握着模型客户端、工具模式、记忆提供方。gateway/memory_monitor.py 更直接:它每 5 分钟打一行以 [MEMORY] 开头的结构化日志,模块开头的说明写着”慢泄漏在单行日志里看不见,只能看 RSS 几小时的曲线”。也就是说,看 RSS 曲线本身就被设计成了你的日常工作之一。

监督会掩盖崩溃,这既是好事也是负担。_spawn_supervised 最多重启 5 次,但连续计数有个 300 秒的健康窗口——跑够这么久再崩就当作一次孤立故障、计数归零,理由是长期运行的守护进程几天内偶发几次崩溃不该被永久放弃。另一层是 gateway/restart_loop_guard.py:默认 60 秒窗口内 3 次带”重启中断会话”的启动就算触发,触发后跳过自动恢复那个会话,让网关照常服务新消息但不再重放那段害死自己的逻辑。状态存在 <HERMES_HOME>/gateway/restart_loop.json,读写失败一律按不触发处理——注释的原话是坏掉的断路器绝不能卡死一个健康的网关。这套设计的另一面是:真出问题时,日志里可能只有几次静默重启,你得主动去看。

面板存 API 密钥——这是 docker-compose.yml 安全注释里的原话,紧跟着一句是不要用 --insecure --host 0.0.0.0 把它挂到局域网上。dashboard/run 脚本的注释接着补了操作细节:非回环绑定会自动启用认证闸门,且必须注册一个认证提供方(脚本给了两条不需要额外基建的路:一组用户名密码环境变量,或者一个 OAuth 客户端 ID),否则启动直接失败;HERMES_DASHBOARD_INSECURE 已经不再关闸,只会打印四行提示让你迁到真正的认证。要远程访问,注释给的做法是 SSH 端口转发(ssh -L 9119:localhost:9119)或者放在带认证的反向代理后面。OpenAI 兼容的 API 服务默认关闭,要开必须同时给出主机和密钥两项。

还有几处代价是编排文件里摆明的、但很容易被顺手接受下来:network_mode: host 意味着容器没有网络命名空间隔离;把 Docker socket 挂进来意味着容器里的 Agent 能操作宿主的 Docker;镜像自带 Git、SSH 客户端和一个浏览器内核,意味着这个进程有能力开终端、写磁盘、访问外部服务。这些都是它作为”能干活的 Agent”的前提,但也确实是你在自己机器上要接受的攻击面。相关的权限收口思路可以看 最小权限设计

它明确不管的:不替你做备份轮转(开机只是 mkdir 出一个备份目录);不提供反向代理和证书;不做多机编排;空闲缩容不等于在单机上省资源。什么场景不适用?想要强隔离或多租户的、宿主磁盘紧张的、以及不愿意让 Agent 拿到终端和凭据的场景——这三条不是配置能调好的,是设计取向决定的。

六、上手与避坑清单

--user $(id -u):$(id -g) 起容器。 为什么会踩:这是容器界的通用习惯,也是这个项目在换成 s6 之前的常见写法。怎么避:改成不加 --user,以 root 启动并传 HERMES_UID / HERMES_GID(NAS 用户可用 PUID / PGID 别名),开机脚本会重映射用户并定向 chown 数据卷,结果和你想要的一样。

覆盖 entrypoint 想直接 exec 自己的命令。 为什么会踩:很多人的 compose 模板里习惯性写 entrypoint:。怎么避:保证 /init 仍是链条里第一个命令,否则开机脚本和监督树全被跳过,网关不会正常工作。镜像里那个 /usr/bin/tini 已经是一个剥掉 tini 参数再转发到 /init 的兼容垫片,专门给还引用旧入口的编排模板兜底。

docker compose up 之后以为网关就在跑。 为什么会踩:容器确实起来了、日志也在滚,但滚的是那条重定向说明和心跳。怎么避:知道空白卷上档位重建器只把上次状态为 running 的槽位拉起来,首次部署要么从面板启动,要么在第一次启动前设 HERMES_GATEWAY_BOOTSTRAP_STATE=running

顺手把面板绑到 0.0.0.0 以便手机上看。 为什么会踩:默认只绑 127.0.0.1,看不到就想改。怎么避:走 SSH 隧道或带认证的反向代理;真要非回环绑定,就老实配上密码或 OAuth 认证提供方,因为闸门不通过时它是启动失败而不是降级放行。

docker exec 直接跑命令。 为什么会踩:docker exec 默认是 root,写出来的 auth.json.env、配对审批文件会变成 root 所有、运行用户读不到,症状往往是”审批了却仍未授权”或网关反复重启。项目为此做了两层缓解:PATH 最前面有一个降权垫片,以及开机脚本每次都重置若干目录和文件的所有权。怎么避:别把自愈当保证,exec 时显式指定用户,或者改完之后确认一次所有权。

指望打开空闲缩容就能省下机器。 为什么会踩:名字听起来就是省钱开关。怎么避:先确认三个前提是否都成立(开关、只有中继或无直连平台、注册了唤醒地址),再明白它只让中继侧休眠、进程仍在——单机自托管场景下它省的不是内存。

低估首次构建的时间和磁盘。 为什么会踩:看到 build: . 就以为是几分钟的事。实际要自编译 SQLite、拉两个上游镜像的二进制、跑 npm 安装、装 Chromium、再做一次完整的 Python 依赖解析。怎么避:优先用已发布的镜像;确实要本地构建就留足时间和缓存,别在业务窗口里做。

收个尾

如果你打算真上,建议按这个顺序自检:数据卷是不是独立的、宿主端属主对不对;面板绑在哪、认证配了没;Docker socket 到底要不要挂进去;.env 里放了哪些凭据、这台机器丢了会怎样;日志去哪儿看(docker logs${HERMES_HOME}/logs/gateways/<profile>/current 两处);以及 RSS 曲线由谁定期看一眼。

接下来最该读的文件是 docker/stage2-hook.sh——它的注释密度远高于代码密度,几乎每一段都对应一个真实踩过的坑,读完你会对这套编排的边界有相当具体的感觉。然后是 hermes_cli/container_boot.py(档位重建到底怎么决策),以及 gateway/restart_loop_guard.py(崩溃循环怎么被掐断)。至于要不要让它长期驻在你那台机器上,读完这三个文件你自己会有答案。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的终端后端怎么选用聊天软件指挥开源自托管 Agent 项目 Hermes Agent

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。