Hermes Agent 实战 02|多 Profile 与超管模型:一个 Agent 安全地管十几台机器原创
# 多 Profile 与超管模型:一个 Agent 安全地管十几台机器
系列第 02 篇。上一篇讲了「一个 runtime 分裂成 12 个数字员工」,这篇讲清楚这套隔离是怎么搭起来的、那个能进所有机器的「超级管理员」到底怎么实现的,以及——为什么它不能用你以为的那种方式实现。文末给一套可以直接照抄、在你自己 agent 上复刻的最小方案。
# 1. Profile 到底是什么
在 Hermes 里,一个 Profile 就是一个完全独立的沙盒,物理上对应 ~/.hermes/profiles/<name>/ 下的一个目录:
~/.hermes/profiles/superdba/
├── config.yaml # 这个 Profile 自己的模型/终端/网关配置
├── state.db # 自己的会话与消息(和别人完全不共享)
├── .env # 自己的密钥
├── skills/ # 自己的技能集
├── logs/ # 自己的日志
└── workspace/ # SSH 后端时的工作目录
2
3
4
5
6
7
关键点:会话库是 per-Profile 的。superdba 这个 DBA 看不到 freqtrade 的任何对话,也连不上它的机器。这是一条安全边界,不是组织上的方便而已。
# 1.1 创建一个 Profile
最省事的方式是从一个已有 Profile 克隆,再改后端。我当初建 fn-windows 就是从 <INTERNAL_BRAND> 克隆的:
# 交互式:hermes 命令行里
hermes
> /profile create fn-windows --clone <INTERNAL_BRAND>
# 克隆完之后,编辑它的 config.yaml 把后端指向新机器
# ~/.hermes/profiles/fn-windows/config.yaml
2
3
4
5
6
克隆会带上源 Profile 的配置骨架(模型、技能引用、网关设置),你只需要改三处:终端后端、.env 密钥、人格(SOUL/system_prompt)。
# 2. 两种终端后端:本地 vs SSH
每个 Profile 在 config.yaml 的 terminal.backend 决定它的命令跑在哪:
# 本地后端:agent 的命令直接在本机执行
terminal:
backend: local
# SSH 后端:命令通过 SSH 在远程机器执行
terminal:
backend: ssh
# 具体连接参数由 ~/.ssh/config 的别名提供(见下文)
2
3
4
5
6
7
8
一个反直觉但很重要的点:backend: local 不代表「这台机器能从主控机本地访问」。它只代表「这个 Profile 的 agent 进程跑在它自己的宿主机上」。比如我有个 GCP 上的 Profile,它配的是 local,但对主控的 CasaOS 来说它仍然是一台远程机器——因为那个 agent 进程根本不在 CasaOS 上跑。判断「要不要 SSH」的依据是目标机器是不是主控机本身,而不是配置里写的 local。
# 3. 超管模型:能进所有机器,但不能「变身」
我想要一个 default Profile 当跨 Profile 超级管理员——一句话就能让它去任意一台机器上办事。这里有个大坑,先说结论:
超管是靠「SSH 出去」实现的,不是靠「委派/切换 Profile」实现的。
# 3.1 为什么不能用委派(delegate)
Hermes 有 delegate_task 工具可以派生子 agent,直觉上你会想:「让 default 委派一个跑在 <INTERNAL_BRAND> 身份下的子 agent 不就行了?」——不行。原因很实在:
delegate_task没有profile参数,子 agent 加载不了另一个 Profile 的终端后端和.env;- 子 agent 继承父 agent 的后端(
local),只有模型凭证能覆盖; - 更底层的原因:
run_agent.py里_hermes_home = get_hermes_home()是模块级的——一个进程一辈子只认一个 Profile。想「中途变身成另一个 Profile」得改源码。
# hermes-agent/tools/delegate_task.py —— 真实签名(节选)
@registry.register(
name="delegate_task",
description="Spawn a child agent to handle a subtask in parallel.",
parameters={
"type": "object",
"properties": {
"task": {"type": "string"}, # 子任务描述
"context": {"type": "string"}, # 显式注入的上下文(可选)
"tools": {"type": "array", "items": {"type": "string"}},
"model": {"type": "string"}, # 子 agent 用哪个模型
},
"required": ["task"],
},
)
def handle_delegate_task(args):
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注意 properties 里没有 profile 字段——这就是「跨 Profile 委派不存在」的源码级证据。子 agent 会继承父进程的环境(HERMES_HOME / active_profile / 终端后端),所以「让 default 派一个 <INTERNAL_BRAND> 身份的子 agent」技术上做不到。
所以「子 agent 用 <INTERNAL_BRAND> 的身份运行」不是一个真实存在的功能。别在这上面浪费时间。
# 3.2 真正的做法:SSH-out(纯配置)
超管的实现其实朴素得多——default 的后端是 local,要去远程机器就 ssh <别名> '...',别名全部写在 ~/.ssh/config 里。完整可用的模板:
# ~/.ssh/config —— 超管的「机器花名册」
# ⚠️ Host 别名全小写、语义化;agent 会用这些名字直接 ssh ...
Host <INTERNAL_BRAND>
HostName <IP>
User <USERNAME>
IdentityFile <SSH_KEY_PATH>
ServerAliveInterval 30
ServerAliveCountMax 3
Host fn-windows
HostName <IP>
User <USERNAME>
# 远端是 PowerShell,agent 写命令时不能直接用 bash 习惯
Host gpu-ai
HostName <IP>
Port 2223
User <USERNAME>
IdentityFile <SSH_KEY_PATH>
# 公网机:keepalive + 长超时
ServerAliveInterval 15
ServerAliveCountMax 6
Host *
# 通用兜底(避免被 SSH 第一次连接卡在「Are you sure you want to continue connecting?」)
StrictHostKeyChecking accept-new
AddKeysToAgent yes
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
配好之后,超管 agent 的「去 <INTERNAL_BRAND> 检查容器」就翻译成一条 ssh <INTERNAL_BRAND> 'docker ps'。对那些目标就是主控机本身的 Profile(如本机的 CasaOS),超管直接用本地终端管理,连 SSH 都不用。
一次性把所有别名都验证一遍(agent 第一次跑 ssh 之前你必须自己跑过):
for h in <INTERNAL_BRAND> fn-windows gpu-ai; do
echo "== $h =="
ssh -o ConnectTimeout=5 -o BatchMode=yes "$h" 'hostname; uname -r; uptime' \
|| echo " ! failed: $h"
done
2
3
4
5
-o BatchMode=yes 让它不会卡在密码/known_hosts 提示上——一次性验证哪些能用 key 直连。如果某台失败,先回到那台机器去修 SSH 服务端的 ~/.ssh/authorized_keys,别让 agent 替你 debug SSH 握手。
# 3.3 安全红线:别为了 SSH 去读 .env
这是我给自己立的硬规矩,也建议你照做:
日常 SSH 不要去读
profiles/*/.env。
端点信息全在 ~/.ssh/config 里,足够了。一旦 agent 去 cat 某个 Profile 的 .env,会把里面所有 API key / token 拉进上下文,而上下文是会落进 state.db 的——这正是 Hermes 的 read_file 密钥防护要拦的东西。只有当超管确实需要某个 App 的应用凭证时,才去读对应的 .env。
# 4. 可复现:给你自己的 Agent 搭一套最小「超管 + 分身」
不管你用的是 Hermes 还是别的 agent 框架,这套思路都能复刻。最小可用版本:
- 一个超管入口:一个能执行本地 shell、且持有一把 SSH 私钥的 agent 会话。
- 一张机器花名册:把每台目标机写进
~/.ssh/config的Host别名,统一用一把 key。先手动验证每个别名都能免密连上(参见 3.2 末尾的for h in循环)。 - 给 agent 的系统提示里写清楚边界:它对哪些机器是「本地直管」、哪些是「SSH 出去」、以及「不准为了连机器去读密钥文件」。
- 每台机器一个独立会话/记忆空间:哪怕你的框架没有 Profile 概念,也至少让每台机器的对话历史分库存放,避免上下文互相串味。
这套的好处是:机器清单的维护权在 ~/.ssh/config,不在 agent 的脑子里——加一台机器就是加一个 Host 块,agent 立刻就会用,不需要改 prompt、不需要重训。
# 4.1 Hermes 自带的 /profile 子命令清单
下面这套是当前可用的最全子命令(所有子命令都来自 hermes_cli/commands.py 的 COMMAND_REGISTRY——意味着 CLI、Telegram 菜单、Slack 路由、自动补全都用同一份):
hermes
# 在交互式 REPL 里:
> /profile list # 列出所有 Profile + 标记当前激活
> /profile create fn-windows --clone <INTERNAL_BRAND>
> /profile switch superdba # 切换当前激活(写 active_profile 文件)
> /profile delete <name> # 删一个 Profile(带二次确认)
> /profile rename <old> <new>
> /profile show <name> # 显示 config.yaml 关键块
# 一次性 CLI:
hermes profile list
hermes --p<PASSWORD> superdba "select top 10 from dbo.orders"
2
3
4
5
6
7
8
9
10
11
12
注意 COMMAND_REGISTRY 的设计:所有 /xxx 斜杠命令都进同一份字典——加命令 = 加 CommandDef + 加 cli.py 的 process_command() 处理。少改一边,自动补全和 Telegram 菜单就跟新命令脱节。这条对插件开发者尤其重要。
# 5. 这套设计最容易出事的地方
「隔离 + 穿透」的组合天生是 Bug 高发区。最典型的一个:切换 Profile 时,上一个 Profile 的 SSH 后端连接会泄漏到下一个 Profile 的会话里——我亲眼见过 superdba 的命令跑到了 safeline 的机器上。根因还是那句话:get_hermes_home() 是模块级缓存,一个进程的状态没干净地随 Profile 切换重置。这个 Bug 我修了、提了 PR、并且每次升级都要盯着它别被上游合并冲掉——完整复盘放在第 10 篇。
下一篇讲 Gateway:怎么把这些 Profile 接到 Telegram,systemd 服务和裸进程的坑,以及一个 Telegram token 撞车是怎么让我排查了半天的。