开源自托管 Agent 项目 Hermes Agent 的终端后端怎么选
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
这七个后端不是七套执行引擎,而是同一套 bash 执行契约的七种落地方式;真正把它们区分开的只有两件事:谁来跑那个 bash -c,以及你的文件怎么到它面前。 想清楚这两件事,选型就不再是看名字猜功能。这里说的 Hermes 指的是 Nous Research 开源的自托管 Agent 项目 hermes-agent(MIT 许可证,LICENSE 署名 Nous Research)——不是同名的 Hermes 开源模型系列,也不是任何同名商标或库。它会常驻在你的机器上、开终端跑命令、连你的聊天软件账号、往磁盘写文件,所以后端选错的代价是实打实的。
站内已有三篇讲相邻话题:容器隔离在 pi 上怎么落地 讲的是另一个项目的做法,Agent 工作区隔离 和 AI 基础设施选型 讲的是通用方法论与设施层判断。本篇不重复那些结论,只做一件事:把 hermes-agent 这个具体仓库里的执行层拆开,告诉你每条路在代码里到底做了什么、放弃了什么。
一、先看那份所有后端都得遵守的契约
tools/environments/base.py 开头的模块注释把设计讲得很直白:统一的 spawn-per-call 模型,每条命令都新起一个 bash -c 进程。那么跨命令的状态怎么保住?它在初始化时做一次「会话快照」,把环境变量、函数定义、别名 dump 到一个临时脚本里,之后每条命令执行前先 source 它,执行后再重新 dump 回去。当前目录不靠快照,而是靠命令末尾 printf 出来的一段带会话 id 的行内标记,父进程从 stdout 里把它抠出来更新 self.cwd。
这个设计的好处是后端实现面积极小。BaseEnvironment 这个抽象基类把 execute()、init_session()、_wrap_command()、_wait_for_process() 全包了,子类只需要实现 _run_bash() 和 cleanup() 两个方法——前者负责「在我的执行上下文里起一个 bash」,后者负责收摊。你要写第八个后端,本质上就是回答「怎么起 bash」这一个问题。
代价也在同一处:因为每条命令都是新进程,任何依赖 shell 进程活着的东西都不跨命令。cd 能留下来(靠标记回传),export 能留下来(靠快照),但一个交互式的 REPL、一个前台阻塞的 TUI 就留不下来。快照本身也有明确的排除项——代码里把 HERMES_SESSION_ 前缀那一族会话变量刻意从快照里剔掉了,注释写得很清楚:一个长期运行的后端要服务多个并发会话,把第一个会话的身份写进共享快照会让后面每个会话都 source 到别人的身份。
_wait_for_process() 是另一处所有后端共享的东西:轮询等待、中断检查、后台线程排空 stdout。命令被中断返回 130,超时返回 124,cd 到不存在的目录直接 exit 126。它还带一个头尾窗口的输出收集器,只在前台终端工具那条路上启用截断,内部消费者走全量捕获——因为对那些路径来说截断不是显示问题,是数据损坏。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
BaseEnvironment | 统一 execute()、会话快照、cwd 标记、超时与中断 | tools/environments/base.py | 排查「命令超时/被中断后状态不对」时 |
_ThreadedProcessHandle | 把 SDK 的阻塞调用包成类进程句柄 | tools/environments/base.py | 用 Modal / Daytona / Vercel 时看中断为什么表现不同 |
LocalEnvironment | 直接在宿主机起 bash,附带环境变量清洗 | tools/environments/local.py | 默认后端;调 Windows 的 Git Bash 问题 |
DockerEnvironment | 起长驻容器、装安全参数、跨进程复用 | tools/environments/docker.py | 配 docker_volumes / 出口代理 / 容器被回收时 |
SingularityEnvironment | apptainer/singularity 实例与 overlay 持久化 | tools/environments/singularity.py | 在没有 root、只有 HPC 环境的机器上 |
SSHEnvironment | ControlMaster 长连接 + 远端文件同步 | tools/environments/ssh.py | 想让 Agent 干活的机器不是它住的机器 |
FileSyncManager | 把宿主的凭据/技能/缓存推到远端 | tools/environments/file_sync.py | 远端后端「找不到技能文件」时 |
| 后端工厂 | 读 TERMINAL_ENV 等变量决定实例化谁 | tools/terminal_tool.py 的 _create_environment | 配了 backend 却发现没生效时 |
二、本地后端:能力最大,隔离为零
LocalEnvironment 是默认值——工厂函数读 TERMINAL_ENV,取不到就是 local。它做的事就是 subprocess.Popen 起一个 bash,start_new_session=True 让子进程自成进程组,_kill_process 覆写成对整个进程组先 SIGTERM 再 SIGKILL。这个覆写不是洁癖:基类等待循环那段注释里记着一个真实故障——本地后端把子进程扔进独立进程组后,如果 python 中途退出而没有兜住 kill,就会留下 PPID=1 的孤儿,一个 sleep 300 能活过半小时。所以基类那边也用 try/finally 保证异常退出路径上照样调一次 kill。
真正值得读的是这个文件里那一大段环境变量清洗逻辑。它从 provider 注册表和可选变量表里推导出一份阻断清单,把各家模型服务商的 API key、messaging bot token、GitHub 认证、MODAL_TOKEN_ID、DAYTONA_API_KEY、VERCEL_TOKEN 这些从终端子进程的环境里摘掉,还额外用一个谓词函数拦住那些动态命名的内部密钥(比如按任务拼出来的辅助模型 key)。虚拟环境标记 VIRTUAL_ENV、CONDA_PREFIX 也会被摘掉——注释解释了为什么:网关自己跑在 venv 里,这个值漏进去,uv/poetry 会把别的项目的依赖装到 Hermes 自己的环境里。
但清洗不等于隔离。同一个文件里有一段注释交代得很坦诚:通用的 AWS 凭据链是故意保留可继承的,理由是本地终端被定位成用户自己的可信操作 shell,Agent 拥有和你自己 shell 一样的权限就是预期姿态。另一处也保留了用户 Claude Code 安装自带的那个 OAuth token,因为摘掉它会让 Agent 起的子 CLI 掉登录、连带清掉你自己交互会话的凭据。
所以本地后端的定位很清楚:它是给你自己的开发机准备的。你的 git 凭据、SSH key、云 CLI 配置、~ 下的所有东西都在射程内。这一层的取舍如果你还没想透,配后端之前先把 Agent 权限该给多大 那套判断走一遍更划算。
Windows 上还有一层额外复杂度:_find_bash() 要在自带便携 Git、系统 Git for Windows、PATH 上的 bash 之间挑一个能真正拉起 MSYS 子进程的,还专门识别 Windows 强制 ASLR 导致的启动失败并给出按程序豁免的修复建议。路径也得在两种写法间来回翻译。这些不是可选的优化,是不做就跑不起来的部分。
三、Docker 与 Singularity:容器边界,但主机目录仍然可能在里面
DockerEnvironment 的安全参数是硬编码的一组:丢掉全部 capability,再单独加回 DAC_OVERRIDE、CHOWN、FOWNER(让 root 能写宿主用户拥有的绑定挂载目录,让 pip/npm/apt 能设文件属主),加 no-new-privileges,/tmp 和 /var/tmp 换成限定大小的 tmpfs。CPU、内存、进程数上限不是无条件加的——代码会先起一个一次性容器探测 cgroup 控制器有没有被委派下来,探测失败就降级为不加限制并打告警,因为在非特权 LXC 这类宿主上加了反而每次启动都失败。磁盘配额那一项更保守,只有 overlay2 且底层支持时才加。
容器是 docker run -d 起的,命令是 sleep infinity,本体靠 docker exec 进去跑 bash。它打了几个标签:hermes-agent=1、hermes-task-id、hermes-profile,还有一个出口姿态的指纹标签。这几个标签支撑了两件实用的事:跨进程复用(新起的 Hermes 进程按标签找到上次那个容器直接接上,容器里的 npm run dev 不会因为你 /quit 一次就死),以及启动时的孤儿回收(只清 exited 状态、只清本 profile、且要求退出时间够久)。想要严格的每进程隔离就把 docker_persist_across_processes 关掉。
持久化模式下它把 {沙箱目录}/home 绑到容器 /root、{沙箱目录}/workspace 绑到 /workspace;非持久化模式这两处换成 tmpfs,容器没了数据也没了。沙箱根目录本身可以用 TERMINAL_SANDBOX_DIR 挪走,默认落在 Hermes 的家目录下。技能目录、凭据文件、缓存目录会以只读方式挂进去——这是远端后端要靠文件同步才能达成的效果,容器直接用挂载解决了。
Docker 这条路上最值得注意的一个能力是出口代理。当配置里打开了代理,容器创建时会把一张 CA 证书只读挂进去,并塞入 HTTPS_PROXY、各语言运行时的 CA bundle 路径,以及一批代理令牌去替换真实的 provider key。也就是说沙箱里的进程拿到的不是你的真 key。代码在三处(docker_env、docker_forward_env、docker_extra_args)都做了冲突检查:如果你的配置试图覆盖这些控制项,在强制模式下直接抛错拒绝启动,而不是悄悄让隔离失效。
Singularity/Apptainer 那条路的形态不同但意图相近:instance start 起一个常驻实例,参数上带 --containall --no-home,之后所有命令走 instance://<id> 执行。持久化靠 writable overlay 目录,非持久化用 --writable-tmpfs。它会把 docker:// 形式的镜像一次性 build 成 SIF 缓存起来(带进程内锁防并发重复构建),构建失败就退回直接用原始镜像串。暂存目录的选择顺序是:TERMINAL_SCRATCH_DIR 显式指定的最优先,其次是存在且可写的 /scratch(会在下面按用户名分子目录),都没有才落回默认位置——这两处细节基本就是为 HPC 集群准备的:没有 docker daemon、没有 root、有共享 scratch 分区。你在那种机器上,这是唯一顺手的容器路。
需要说清楚的是:容器边界只在你不主动打洞时成立。docker_volumes 里写一条把宿主项目目录挂到容器里,或者打开把当前目录挂到 /workspace 的开关,那部分宿主文件就重新回到了 Agent 的可写范围。这不是 bug,是你自己做的取舍,但要知道自己做了。
四、把执行搬到远端:文件同步成了新的主角
SSH、Modal、Daytona、Vercel Sandbox 这四条路共享一个基类里的钩子:_before_execute()。基类注释把分工写清楚了——远端后端在这里触发文件同步,而绑定挂载类的后端(Docker、Singularity)和本地不需要,因为宿主文件系统本来就看得见。
同步的内容是宿主 ~/.hermes 下那一套:凭据文件、技能文件、缓存文件,由 file_sync.py 的枚举函数拼成一串「宿主路径 → 远端路径」的对。这里有个容易踩的点:远端的 home 不一定叫 /root,所以枚举时要把硬编码的前缀替换成实际探测到的容器基路径。批量传输这一步四个后端各写了一份实现,形态取决于它们手里有什么通道:SSH 有真管道,就走本地 tar c 管到远端 tar x 的单条流;Daytona 有 SDK 的多文件上传接口,一次调用批完;Vercel 用 SDK 的批量写文件;Modal 没有可用的大参数通道,只能在内存里打一个 gzip tar,base64 编码后顺着命令的 stdin 灌进去,再在远端 base64 -d | tar xzf - 解开——代码注释交代了原因,SDK 的 exec 参数有长度上限,走参数塞不进去。拆解回来时统一走远端打 tar 再下载。这里也解释了为什么远端后端的第一条命令总是明显慢一截:那不是网络抖动,是一整套凭据和技能文件在过去。之后的命令并不会重复付这笔钱——同步管理器按「远端路径 → 修改时间键」记账,只传变过的,还带一个时间间隔的限流,间隔内的调用直接返回;急着让改动生效可以用 HERMES_FORCE_FILE_SYNC 强推一次。这一层还是事务性的:上传或删除中间任何一步抛异常,记账状态整体回滚,并且刻意不推进限流时钟,好让下一次同步立刻重试而不是被限流吞掉。代价是同步的判定依据是修改时间而不是内容,你用某些工具改文件而修改时间没变,那次改动就同步不过去。
SSH 后端剩下的部分很克制:ControlMaster 长连接复用,BatchMode=yes(不弹交互式提示,认证不成就直接失败),StrictHostKeyChecking=accept-new。控制 socket 的文件名是 user@host:port 的哈希前缀——注释交代了原因,macOS 的 Unix domain socket 路径长度上限加上深层 $TMPDIR,用原始三元组当文件名会超限。清理时先 sync_back 再关连接。
Modal、Daytona、Vercel 这三条云沙箱路都没有真的子进程,所以它们用基类里的 _ThreadedProcessHandle:把 SDK 的阻塞调用扔到后台线程,用 os.pipe 伪造出一个 stdout,对外装成进程句柄。它们的 _stdin_mode 是 heredoc——因为没有管道可写,stdin 数据是被基类嵌成 shell heredoc 塞进命令字符串里的。
中断语义在这里出现了一处必须知道的差异:_ThreadedProcessHandle 的取消函数被接到了「停掉整个沙箱」上。Daytona 那边接的是 sandbox.stop(),Vercel 那边接的是停沙箱。也就是说你在云沙箱后端上打断一条命令,代价不是杀一个进程,而是整个沙箱停掉——所以 Daytona 每次执行前都会先刷新状态、发现停了就重新 start,Vercel 那边则会检查状态、进入终态就重建。Vercel 的注释还额外交代了 SDK 不提供单次执行的超时参数,超时是靠基类 kill、也就是靠停沙箱来兜的。
持久化的实现路子三家各不相同。Daytona 用命名沙箱:持久模式下先按名字 get 再 start 恢复,清理时只 stop 不删,文件系统留着;非持久模式清理时直接删。Modal 和 Vercel 走的是文件系统快照:快照 id 存在 Hermes 家目录下的一个 JSON 里,按 task 键索引,下次创建时从快照恢复,恢复失败就退回基础镜像并把这条记录删掉。Vercel 那条路还有两个约束写在代码里:容器磁盘不支持自定义,传非默认值直接抛 ValueError;SDK 自带的用量遥测在导入前就被设成关闭,注释写明这是「不经明确同意不外发遥测」的项目策略。
五、边界与代价:这套设计明确不管的事
它不管交互式程序。 spawn-per-call 模型下没有常驻 shell 进程,工具描述里直接写了不要在没有伪终端的情况下用 vim/nano 这类程序,它们会挂住。
它不管「让本地后端变安全」。 本地后端的环境变量清洗只处理 Hermes 自己管的那批密钥,不碰你的通用云凭据和 SSH key。想要边界就得换后端,而不是指望本地后端帮你收着。
它不管跨会话的身份隔离。 会话变量被刻意排除在共享快照之外,正是因为一个后端实例会同时服务多个来源的会话。这是防泄漏,不是隔离——它们跑的还是同一个终端环境。
它不保证资源限制一定生效。 cgroup 探测失败会静默降级为不加限制(有告警日志),磁盘配额在多数发行版的默认存储驱动下直接不加。你不能假设配了就一定拦得住。
云沙箱后端不保证命令级中断。 前面说过,取消就是停沙箱。一个跑一半的构建被打断,代价是整个沙箱状态的重建。
跨进程复用是双刃的。 Docker 后端按标签复用容器,好处是进程活着、启动快;反面是上一个会话留下的脏状态也活着。代码里为一种情形做了硬防护:如果你把网络关掉了,而复用到的容器是带网络起来的,它会删掉重建而不是接上——因为网络模式在容器创建后不可改。但其他形式的脏状态它不管。
六、上手与避坑清单
别只改 config.yaml 就以为后端换了。 终端工具的所有配置都从 TERMINAL_* 环境变量读,CLI、网关、PTY 启动路径各自负责把 terminal.* 桥接成环境变量。代码里为此专门加了一个兜底桥接函数,注释列了一串真实 issue:绕过所有启动器的进程(比如 hermes serve)曾经在配置明明选了 docker 的情况下静默落回本地后端,命令直接跑在你想沙箱掉的宿主上。怎么避:换后端后用一条能暴露身份的命令实测确认,别看配置文件确认。
容器后端别指望 TERMINAL_CWD 写宿主路径能用。 工厂函数里有一层判断,容器后端拿到明显是宿主路径或相对路径的 cwd 会记一条日志然后换回默认值。怎么避:容器后端就写容器内路径;确实要用宿主目录,走挂载开关,而不是指望 cwd 猜。
远端后端第一次跑,先确认技能文件到位了。 远端后端的技能和凭据靠同步过去,容器后端靠只读挂载。两条路的失败症状一样(Agent 说找不到东西),根因完全不同。怎么避:远端后端出问题先看同步那一层,别去翻技能定义本身。
打开出口代理后,不要再往 docker_env 里塞真 key。 代码明确把这种情况当冲突处理,强制模式下抛错、非强制模式下打告警并让你的值胜出——后者的效果是沙箱带着真凭据直连外网,代理形同虚设。怎么避:真要覆盖就显式关掉强制开关,让这个决定是写下来的,而不是被一条配置顺手绕过的。
Windows 上先解决 bash 能不能起来,再谈别的。 这个项目在 Windows 上要 Git for Windows,路径要在两种写法间翻译,还可能撞上系统级强制 ASLR。怎么避:报错信息里已经带了定位和修复指引,照着读,不要反复重装 Git——代码注释直接写了重装不会改变 Windows 的缓解策略设置。
别把「持久化」和「跨进程复用」当同一件事。 前者管数据留不留(绑定挂载 / overlay / 快照),后者管容器接不接上。两个开关分开,症状也分开:数据丢了查前者,状态脏了查后者。
收尾:三个问题定后端
选之前先问自己三句话。第一,Agent 要不要能碰你自己的凭据和家目录?答「不要」,本地后端就出局了。第二,你的宿主机上有没有 docker daemon 和相应权限?没有但有 HPC 环境,Singularity 那条路是为你写的。第三,你能不能接受「打断一条命令等于重建整个沙箱」?不能接受,云沙箱那三条路就要慎选。
想继续往下读代码,顺序建议是 tools/environments/base.py 先通读一遍(这是所有后端的契约),再挑你要用的那个后端文件对照看 _run_bash() 和 cleanup() 两个方法——其余部分都是基类替它做的。想横向比一比不同项目在这一层的取向差异,开源终端 Agent 选型 那篇的框架可以直接套过来用。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 自托管开源 Agent 项目 Hermes Agent 终端界面拆解 和 开源自托管 Agent 项目 Hermes Agent 常驻部署与缩容。