Hermes 拓扑:容器 × profile × .env¶
这张机器的个人栈跑着 5 个容器、4 个身份、1 块看板。三者分别是什么、谁决定什么,是排查问题的第一块地基。扫码新建的助手不在这组数字里,见文末。
一句话¶
profile 是"谁",容器是"在哪",gateway 是"活着的那一份"。 身份(配置/记忆/会话)跟着磁盘走;能力(挂载/环境变量)跟着容器走。
容器与身份¶
| 容器 | 启动命令 | 服务的 profile | 飞书 App(来源 env 变量) |
|---|---|---|---|
hermes |
gateway run |
default(爱玛士,总管) | FEISHU_APP_ID |
hermes-coder |
gateway run --profile coder --replace |
coder(爱码士) | CODER_FEISHU_APP_ID |
hermes-finance |
gateway run --profile finance |
finance | FINANCE_FEISHU_APP_ID |
hermes-daoyuan |
gateway run --profile daoyuan |
daoyuan(道元) | DAOYUAN_FEISHU_APP_ID |
hermes-dashboard |
dashboard --host 0.0.0.0 |
无(只读网页 UI,不跑 agent) | — |
四个 agent 容器都挂载同一个宿主目录:~/.hermes → 容器内 /opt/data(读写)。
三个概念¶
| 是什么 | 存在哪 | 换掉它会怎样 | |
|---|---|---|---|
| profile | 身份:人格、模型、技能、记忆、会话、MCP 声明 | ~/.hermes/(default)或 ~/.hermes/profiles/<name>/ |
换成另一个人 |
| 容器 | 身体:能看到哪些文件、有哪些环境变量、能连哪些网 | docker-compose.yml |
同一个人,能力变了 |
| gateway | 某个容器里跑起来的那个身份的常驻进程 | 进程(s6 管) | 这个人下线 |
实测身份隔离是真的:default 674 个会话 / coder 577 / finance 134 / daoyuan 1,四本账互不相通。
两个正交性(最容易混的地方)¶
① 任何容器都能跑任何 profile —— 四份 config.yaml 都在同一个共享目录里:
# 在爱玛士的容器里跑,跑出来的仍然是"爱码士"(但没有 gitcode 配置、没有 coder 的挂载)
docker compose exec hermes /opt/hermes/.venv/bin/hermes -p coder chat
② 同一个 profile 能被两个容器同时跑,而且不报错 —— 2026-11-11 的真实事故:hermes 容器里被手工 s6-svc -u 起了一个 gateway-coder,和 hermes-coder 容器里的同名网关并存 14 小时,两条飞书连接、双写 profiles/coder/gateway_state.json。gateway.lock 拦不住跨容器。
所以规矩是一个 profile 一个容器,靠约定不靠机制。
四份 .env,谁读谁¶
| 路径 | 谁读它 | 决定什么 |
|---|---|---|
<仓库>/.env |
docker compose |
插值 compose 里的 ${VAR} → 变成容器环境变量 |
~/.hermes/.env(容器内 /opt/data/.env) |
Hermes(default 身份) | default 的环境变量 |
~/.hermes/profiles/<name>/.env |
Hermes(-p <name> 时) |
那个身份的环境变量 |
compose 的 environment: 段 |
容器 PID 1 | 进程环境变量 |
前两份互不相干:compose 不知道 ~/.hermes/.env,Hermes 不知道仓库 .env。
优先级(hermes_cli/env_loader.py:363)¶
$HERMES_HOME/.env (override=True) ← 最高,能盖掉容器注入的值
↓
容器 / 进程 env(compose 注入)
↓
.op.env(override=False)
⚠️ 踩坑预警:因为 override=True,改了 compose 里的值但 ~/.hermes/.env 里有同名键时,你的修改不会生效,也不会报错。改完务必用下面的办法确认生效值。
# 容器注入值 vs 文件值(只输出判定,不回显密钥)
docker compose exec hermes sh -lc 'v=某变量名; \
a=$(printenv "$v"); b=$(grep -E "^$v=" /opt/data/.env | head -1 | cut -d= -f2- | tr -d "\"\r "); \
[ -z "$b" ] && echo "文件里没有 → 用容器的" || { [ "$a" = "$b" ] && echo 一致 || echo "⚠ 不一致:文件会覆盖容器"; }'
能力矩阵(2026-11-12 实测)¶
| 项 | hermes |
hermes-coder |
hermes-finance |
hermes-daoyuan |
|---|---|---|---|---|
| 真邮箱配置 / ortie token | ✅ | ❌ 空卷遮蔽 | ❌ | ❌ |
OneDrive(/opt/onedrive) |
✅ | ❌ | ❌ | ❌ |
GITCODE_TOKEN |
✅ | ✅ | ❌ | ❌ |
| gitcode 配置目录 | ❌ | ✅ | ❌ | ❌ |
SERVER_MONITORING_MCP_URL |
✅ | ✅ | ❌ | ❌ |
ZOTERO_API_KEY(写) |
✅ | ✅ | ✅ | ❌ |
PAPER_QUEUE_DAOYUAN_TOKEN |
❌ | ❌ | ❌ | ✅ |
DAILYINFO_MCP_KEY |
❌ | ❌ | ❌ | ✅ |
GH_TOKEN |
✅ | ✅ | ✅ | ✅ |
| zotero-mcp / paper-queue-mcp(网络服务) | ✅ | ✅ | ✅ | ✅ |
没有任何一个容器是超集。 这就是"派发落在哪个容器"会决定任务能不能跑通的原因。
看板与这三层的关系¶
- 板是一个文件(
~/.hermes/kanban.db),四个 profile 共读共写 —— 这是唯一的交接介质 assignee只是卡上的一个字符串,被指的 agent 全程不知情- 全机只有一个 dispatcher(60s 扫板),当前住在
hermes-coder容器的网关进程里 - worker 是该 dispatcher
fork出来的临时进程:hermes -p <assignee> chat -q …,用 assignee 的身份、住 dispatcher 的容器,干完就死 - worker 不启动容器,也不启动 gateway(容器数恒为 5,gateway 数恒为 4)
详见 docs/kanban.md。
网关隔离:一个容器一个网关¶
基础镜像的 s6 会在每个容器里、按共享卷上的 gateway_state.json,给四个 profile 都建
gateway-<profile> 服务。所以"一个容器只跑自己的 agent"不是结构保证,是
entrypoint-wrapper.sh 里的后台清扫维持出来的:等四个服务目录出现 → 非本 profile 的
s6-svc -dk(kill + disable;刻意不碰共享的 state 文件,那属于别的容器的活网关)→ 每 20s
重扫一轮(防 hydra 复活)→ 终检,最后对本 profile 补一次 s6-svc -u。
为什么这么在意:飞书 app 凭据看容器 env,不看 profile。容器里那个 gateway-finance
游荡,会拿本容器的 FEISHU_APP_ID 去连飞书 —— 于是两个网关抢同一个 app 的长连接。
2026-11-12 的两种现场¶
| 症状 | 真因 | 处置 |
|---|---|---|
某个 bot 完全没反应,且日志一条错误都没有(该 profile 的 gateway.log 冻结、无 inbound 行) |
同一 app 上曾有两条 WS:游荡被 kill 后飞书仍把事件投给已死的连接,活着的网关收不到 | 重启该 profile 的网关(s6-svc -k)即恢复 |
自家网关 exit 78,日志 Another local Hermes gateway is already using this Feishu app_id (PID …) |
游荡先抢到了本容器的 app_id,正牌启动即退 | 先 -dk 清掉外部,再 -u 拉起自己 |
触发条件很隐蔽:清扫原来只等 90s,而 stage2 的 chown -R /opt/data/home 在 virtiofs 上
能跑 10 分钟 —— 清扫等到超时、一轮没扫,却仍打印「✅ 隔离完成」。修完是 900s +
等不到就告警 + 末尾兜底拉起 own(守卫 tests/test_entrypoint_gateway_isolation.py)。
重建容器时若看到启动"Up 了但网关几分钟不起",先看 [stage2] Fixing ownership 是否还在跑,
不要急着加服务。
处置口诀¶
S6=/package/admin/s6-2.15.0.0/command/s6-svc
# 1) 看谁在跑(每容器应恰好 1 个 up,其余 down)
docker compose exec -T <容器> sh -lc 'for d in /run/service/gateway-*; do printf "%-16s %s\n" "$(basename $d)" "$(/package/admin/s6-2.15.0.0/command/s6-svstat $d)"; done'
# 2) 非本 profile 的一律 kill + disable
docker compose exec -T <容器> sh -lc "$S6 -dk /run/service/gateway-<其它>"
# 3) 自家的拉起来(若 exit 78,先确认外部已清干净)
docker compose exec -T <容器> sh -lc "$S6 -u /run/service/gateway-<own>"
重建容器前记得备份 lark-cli 的 user token(/root/.local/share/lark-cli,存容器内 keychain,
不在挂载卷上):docker compose cp <容器>:/root/.local/share/lark-cli /tmp/lark-cli-<容器>,
重建后拷回并 chown root:root + chmod 700。
排查命令¶
# 谁是 dispatcher(每个容器只会打一条,只有一条是 holding)
docker compose exec -T hermes sh -lc 'grep -i "kanban dispatcher" /opt/data/logs/gateway.log | tail -1'
docker compose exec -T hermes-coder sh -lc 'grep -i "kanban dispatcher" /opt/data/profiles/coder/logs/gateway.log | tail -1'
docker compose exec -T hermes-finance sh -lc 'grep -i "kanban dispatcher" /opt/data/profiles/finance/logs/gateway.log | tail -1'
docker compose exec -T hermes-daoyuan sh -lc 'grep -i "kanban dispatcher" /opt/data/profiles/daoyuan/logs/gateway.log | tail -1'
# 容器数 / 进程树
docker compose ps --format '{{.Name}}' | grep -c hermes
docker compose exec -T hermes-coder sh -lc 'ps -eo pid,ppid,args | grep hermes | grep -v grep'
# 每个 profile 有没有自己的 .env(决定"钥匙挂身份"还是"挂容器")
ls ~/.hermes/.env ~/.hermes/profiles/*/.env 2>/dev/null
注意:/opt/data 是共享卷,/opt/data/*/logs/gateway.log 这种通配会读到别人的日志。查某个 profile 必须指到它自己的路径。
服务定义从哪来¶
四个 hermes 服务的 compose 段不是手写的。来源是 agents/hermes/:
base.yaml:四个服务相同的字段、环境变量、挂载目录,以及被多个 agent 共用的变量组<agent>.yaml:端口、profile、飞书应用前缀、要启用的变量组、mcp列表、自己独有的环境变量、挂载。HERMES_PROFILE和FEISHU_APP_ID/FEISHU_APP_SECRET由前两项推导,不手写mcp列表同时决定容器环境变量,和start.sh读的agents/hermes/mcp.tsv。paper-queue 在表里是late:token 写入.env之后才注册- 环境变量的输出顺序由渲染器固定:公共变量、所选变量组、所选 MCP、推导出的变量、agent 自己的变量。这个顺序不进入配置哈希
- 挂载按清单顺序输出,顺序变了会重建容器
改完清单后重新生成,不要改 docker-compose.yml 里 >>> GENERATED 和 <<< GENERATED 之间的内容,也不要手改 mcp.tsv:
uv run --with pyyaml scripts/render_agents.py # 写回 compose 和 mcp.tsv
uv run --with pyyaml scripts/render_agents.py --check # 只检查有没有人改过生成结果
加一个 hermes agent:新增一个清单文件,把它的名字加进 base.yaml 的 order,再跑上面的命令。挂载顺序会进入 Docker 的配置哈希,顺序变了就会重建容器。
hermes-dashboard 不在这套清单里,仍然手写。
已知的历史包袱¶
~/.hermes/profiles/default/只有一个空的pairing/—— 废弃空壳。root profile 的 home 是~/.hermes本身,Hermes 启动时会 warn 并跳过这个目录- 2026-11-11 清理过一个手工拉起的
gateway-coder残留(双写gateway_state.json)。别再在hermes容器里用s6-svc -u /run/service/gateway-<profile>去"补一个网关" —— 想补之前先读上一节的「网关隔离」 CLAUDE.md曾说 coder 的 Zotero 查询走共享 zotero-mcp,实际hermes -p coder mcp list只有server-monitoring(coder 是拿ZOTERO_API_KEY走 API/pipeline,不是 MCP)
扫码新增的助手¶
扫码创建的 Hermes 不是 ~/.hermes/profiles/ 里的第五个身份,也不进上面这张表。每个助手有自己的目录 ~/.hermes-agents/<id>/,容器由另一份 compose 项目 hermes-bind 拉起。操作步骤、默认能力和停用方式见 扫码新增 Hermes。