配置与 Profiles:多实例隔离的工程学
本章核心源码:
hermes_cli/config.py、hermes_cli/main.py、hermes_cli/env_loader.py、hermes_cli/profiles.py
定位:本章拆解 Hermes 的配置加载管线——从硬编码默认值到环境变量到 YAML 文件到 profile 隔离——理解一个"Run Anywhere"的 agent 如何在 $5 VPS、NixOS、Docker 和开发者笔记本上用同一套代码正确初始化。 前置依赖:第 4 章(AIAgent 初始化)。适用场景:需要理解 Hermes 配置优先级、多实例部署、或自定义配置扩展。
为什么配置系统值得单独讨论
大多数 CLI 工具的配置很简单:一个 YAML 文件加几个环境变量。但 Hermes 面对的场景远比典型 CLI 复杂:
- 多入口共享配置:CLI、Gateway、Cron 三个入口必须读到同一份配置,且 Gateway 作为 systemd 服务运行时没有用户的 shell 环境变量
- 多实例并行:一个用户可能运行两个 Hermes 实例(一个 "coder" profile 用 Claude,一个 "researcher" profile 用 GPT-5),它们的 sessions、memories、skills 必须完全隔离
- 密钥安全:API keys 不能写进 config.yaml(会被 git commit),需要独立的 .env 文件
- 环境兼容:同一份代码要在 NixOS(声明式配置)、Docker(环境变量注入)、和普通 Linux 安装(交互式 setup)中正确工作
这些需求驱动了一个比"读个 YAML"复杂得多的配置管线。
配置加载优先级
Hermes 的配置加载是一个五层叠加的过程,后面的层覆盖前面的层:
graph TB
A["1. DEFAULT_CONFIG<br/>硬编码默认值<br/>config.py:201"] --> B["2. Shell 环境变量<br/>os.environ"]
B --> C["3. ~/.hermes/.env<br/>用户密钥文件<br/>override=True"]
C --> D["4. config.yaml<br/>用户 YAML 配置<br/>deep merge"]
D --> E["5. 项目 .env<br/>开发者 fallback<br/>override=False"]
E --> F["${VAR} 展开<br/>config.py:1818"]
F --> G["最终配置对象"]
style A fill:#e8e8e8,stroke:#333
style D fill:#f96,stroke:#333,stroke-width:2px
style G fill:#9f9,stroke:#333,stroke-width:2px
第一层:硬编码默认值(config.py:201-551)
DEFAULT_CONFIG 是一个 550 行的 Python 字典,定义了 Hermes 所有配置项的默认值:
# hermes_cli/config.py:201-210
DEFAULT_CONFIG = {
"model": "",
"providers": {},
"fallback_providers": [],
"toolsets": ["hermes-cli"],
"agent": {
"max_turns": 90,
"gateway_timeout": 1800,
"tool_use_enforcement": "auto",
},
# ... 350+ 行的嵌套配置
}
这个字典的关键设计决策:每个配置项都有合理的默认值。用户可以只配置一个 model 字段和一个 API key,其他一切都有默认行为。
值得注意的嵌套结构:
| 配置节 | 行号 | 用途 |
|---|---|---|
agent | 207-219 | 编排参数(max_turns、gateway_timeout、tool_use_enforcement) |
terminal | 222-259 | 执行环境(backend、docker_image、container_cpu/memory/disk) |
browser | 261-273 | 浏览器工具(inactivity_timeout、command_timeout、camofox) |
compression | 288-296 | 上下文压缩策略(threshold、target_ratio、protect_last_n) |
auxiliary | 304-368 | 8 个辅助任务各自的 provider/model/base_url/timeout |
memory | 441-451 | 记忆系统(memory_char_limit、user_char_limit、provider) |
delegation | 453-464 | 子代理配置(独立 model/provider、max_iterations=50、reasoning_effort) |
security | 522-533 | 安全扫描(redact_secrets、tirith、website_blocklist) |
logging | 542-547 | 文件日志(level、max_size_mb、backup_count) |
第二层 + 第三层:环境变量与 .env 文件
hermes_cli/main.py 在所有模块导入之前加载环境变量(main.py:139-143):
# hermes_cli/main.py:139-143
from hermes_cli.config import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(project_env=PROJECT_ROOT / '.env')
load_hermes_dotenv() 的加载顺序有精心设计(env_loader.py:18-45):
# hermes_cli/env_loader.py:37-42
if user_env.exists():
_load_dotenv_with_fallback(user_env, override=True) # 覆盖 shell exports
if project_env_path and project_env_path.exists():
_load_dotenv_with_fallback(project_env_path, override=not loaded) # 仅填充缺失值
关键逻辑:当前 HERMES_HOME/.env 使用 override=True,默认 profile 下表现为 ~/.hermes/.env。也就是说,它会覆盖 shell 中已有的环境变量。这解决了一个实际问题:用户更新了 .env 中的 API key,但 shell 里还缓存着旧值。项目 .env 则用 override=not loaded——如果用户 .env 存在,项目 .env 只填充缺失值;如果用户 .env 不存在(开发者场景),项目 .env 也可以覆盖 shell exports。
Hermes 管理着大量的环境变量,按用途分为四类(config.py:574-1206):
| 类别 | 示例 | 数量 |
|---|---|---|
| Provider | OPENROUTER_API_KEY、ANTHROPIC_API_KEY、DEEPSEEK_API_KEY | ~25 |
| Tool | EXA_API_KEY、FAL_KEY、TAVILY_API_KEY、CAMOFOX_URL | ~15 |
| Messaging | TELEGRAM_BOT_TOKEN、DISCORD_BOT_TOKEN、SLACK_BOT_TOKEN | ~20 |
| Setting | HERMES_MAX_ITERATIONS、MESSAGING_CWD、SUDO_PASSWORD | ~8 |
第四层:config.yaml 加载与合并(config.py:1903-1927)
load_config() 是配置管线的核心函数:
# hermes_cli/config.py:1903-1927
def load_config() -> Dict[str, Any]:
config = copy.deepcopy(DEFAULT_CONFIG)
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
config = _deep_merge(config, user_config)
return _expand_env_vars(
_normalize_root_model_keys(
_normalize_max_turns_config(config)
)
)
三个关键步骤:
- Deep merge:用户配置覆盖默认值,但只覆盖用户明确设置的字段。如果用户的 config.yaml 只有
model: claude-opus-4-6,其他 350+ 个字段都保持默认 - 历史兼容性修正:
_normalize_root_model_keys()将旧版的根级provider:迁移到model.provider:;_normalize_max_turns_config()将根级max_turns:迁移到agent.max_turns: - 变量展开:
_expand_env_vars()处理 config.yaml 中的${VAR}引用
第五层:${VAR} 展开(config.py:1818-1835)
# hermes_cli/config.py:1818-1835
def _expand_env_vars(obj):
if isinstance(obj, str):
return re.sub(
r"\${([^}]+)}",
lambda m: os.environ.get(m.group(1), m.group(0)),
obj,
)
if isinstance(obj, dict):
return {k: _expand_env_vars(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_expand_env_vars(item) for item in obj]
return obj
递归遍历整个配置树,将所有 ${VAR} 替换为环境变量的值。未解析的变量保持原样(返回 m.group(0) 即原文),让调用方可以检测到未设置的变量。实际使用示例:
delegation:
base_url: "${SUBAGENT_ENDPOINT}"
api_key: "${SUBAGENT_API_KEY}"
HERMES_HOME 隔离
Hermes 选择 ~/.hermes 作为 home 目录而非 XDG 的 ~/.config/hermes,因为这个目录不仅存储配置,还包含 sessions 数据库、记忆文件、技能文件、日志等——它是一个完整的运行时隔离单元:
~/.hermes/ <-- 默认 HERMES_HOME
├── config.yaml <-- 配置
├── .env <-- API keys (0600 权限)
├── SOUL.md <-- 人格定义
├── sessions/state.db <-- SQLite 会话数据库
├── memories/ <-- MEMORY.md + USER.md
├── skills/ <-- 技能文件
├── logs/ <-- agent.log + errors.log
├── cron/ <-- 定时任务配置
├── gateway.pid <-- Gateway PID 文件
├── gateway_state.json <-- Gateway 运行时状态
├── context_length_cache.yaml <-- 模型上下文长度缓存
└── profiles/ <-- 其他 profile
├── coder/ <-- 完全独立的 HERMES_HOME
└── researcher/
ensure_hermes_home() 在首次运行时创建这个目录树并设置安全权限(config.py:185-194):
# hermes_cli/config.py:185-194
def ensure_hermes_home():
home = get_hermes_home()
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home) # 0700
for subdir in ("cron", "sessions", "logs", "memories"):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)
_ensure_default_soul_md(home) # 首次创建默认 SOUL.md
Profile 系统
Profile 创建与目录结构(profiles.py:1-80)
Profile 系统让用户运行多个完全独立的 Hermes 实例。每个 profile 是一个独立的 HERMES_HOME 目录:
# hermes_cli/profiles.py:36-45
_PROFILE_DIRS = [
"memories", "sessions", "skills", "skins",
"logs", "plans", "workspace", "cron",
]
_CLONE_CONFIG_FILES = ["config.yaml", ".env", "SOUL.md"]
_CLONE_SUBDIR_FILES = [
"memories/MEMORY.md", # 记忆文件也是 identity 的一部分
"memories/USER.md",
]
Profile 目录位于 ~/.hermes/profiles/<name>/,"default" profile 就是 ~/.hermes 本身——零迁移成本。Profile 名称受 _PROFILE_ID_RE = r"^[a-z0-9][a-z0-9_-]{0,63}$" 约束。
Profile Override 时序(main.py:83-137)
Profile 切换有一个关键的时序约束:必须在所有 Hermes 模块导入之前完成。因为许多模块在导入时就缓存了 HERMES_HOME 的值。
_apply_profile_override() 的三步逻辑:
- 命令行参数(
main.py:90-98):检查--profile/-p标志 - Sticky 默认(
main.py:100-110):读取~/.hermes/active_profile文件 - 环境变量设置(
main.py:113-124):将 profile 路径写入os.environ["HERMES_HOME"]
# hermes_cli/main.py:120-123
except Exception as exc:
# A bug in profiles.py must NEVER prevent hermes from starting
print(f"Warning: profile override failed ({exc}), using default", file=sys.stderr)
return
注意防御性处理:profiles.py 中的 bug 永远不阻止 Hermes 启动。Profile 是增值功能,不能让核心功能不可用。
安全性设计
文件权限(config.py:159-173)
# hermes_cli/config.py:159-173
def _secure_dir(path):
os.chmod(path, 0o700) # 目录:仅所有者可访问
def _secure_file(path):
os.chmod(path, 0o600) # 文件:仅所有者可读写
Managed 模式(config.py:54-133)
NixOS 和 Homebrew 管理的安装通过两个信号检测:
# hermes_cli/config.py:67-79
def get_managed_system() -> Optional[str]:
raw = os.getenv("HERMES_MANAGED", "").strip()
if raw:
return _MANAGED_SYSTEM_NAMES.get(raw.lower(), raw)
managed_marker = get_hermes_home() / ".managed"
if managed_marker.exists():
return "NixOS"
return None
HERMES_MANAGED 环境变量由 systemd service 设置,.managed marker 文件由 NixOS activation script 创建。双信号确保无论从 systemd 还是交互式 shell 启动都能正确识别 managed 模式。在 managed 模式下,hermes config edit 等修改操作会被拦截并提示用户使用包管理器。
新增配置键与 Gateway 环境桥接
v0.8.x 新增了若干配置键,以及 Gateway 将配置值桥接为环境变量的机制。
agent.restart_drain_timeout
控制 Gateway 在重启前等待正在运行的 agent 完成工作的超时秒数。默认值 60 秒(config.py:276):
agent:
restart_drain_timeout: 60 # 秒;0 = 不等待,立刻中断
Gateway 的 GatewayRunner 在收到重启信号后使用此值(gateway/run.py:1804):先等待活跃 agent 自然结束,超时后中断剩余 agent。解析由 parse_restart_drain_timeout()(gateway/restart.py:14)完成,无效值静默回退到默认值。
delegation.reasoning_effort
配置子代理的推理强度。有效值:"none"、"minimal"、"low"、"medium"、"high"、"xhigh"(hermes_constants.py:140-148):
delegation:
reasoning_effort: "low" # 子代理使用低推理强度,节省 token
解析优先级(tools/delegate_tool.py:315-332):delegation 配置覆盖 > 继承父代理的 reasoning 配置。如果值无法识别,记录 warning 并回退到父代理级别。
Gateway 配置到环境变量的桥接
Gateway 启动时将若干配置值桥接为环境变量(gateway/run.py:183-190),使不直接读取 config.yaml 的模块也能获取配置:
| 配置路径 | 环境变量 | 说明 |
|---|---|---|
agent.gateway_timeout | HERMES_AGENT_TIMEOUT | agent 执行超时 |
agent.gateway_timeout_warning | HERMES_AGENT_TIMEOUT_WARNING | 超时预警 |
agent.restart_drain_timeout | HERMES_RESTART_DRAIN_TIMEOUT | 重启排空超时 |
桥接逻辑遵循"不覆盖已有值"原则——只在环境变量尚未设置时才写入,确保运维人员通过环境变量的显式覆盖不被配置文件意外覆盖。
配置验证与迁移
结构验证(config.py:1363-1399)
validate_config_structure() 检测常见的 YAML 格式错误:
# hermes_cli/config.py:1380-1391
cp = config.get("custom_providers")
if isinstance(cp, dict):
issues.append(ConfigIssue(
"error",
"custom_providers is a dict — it must be a YAML list",
"Change to:\n custom_providers:\n - name: my-provider\n ..."
))
版本迁移(config.py:549-566)
# hermes_cli/config.py:559-566
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", ...],
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"],
}
_config_version 跟踪 schema 版本(当前为 12)。升级后只提示用户配置自上次版本以来新增的环境变量。
设计启示
Hermes 的配置系统展示了三个值得借鉴的工程模式:
- 五层叠加而非单一来源:defaults -> env -> .env -> yaml -> expansion 的分层让不同部署方式(Docker 环境变量注入、NixOS 声明式配置、开发者 .env)自然工作,不需要为每种部署写不同的加载逻辑
- Profile 即 HERMES_HOME 重定向:不是在配置中加一个
active_profile字段做逻辑隔离,而是直接重定向整个 HERMES_HOME 目录。Profile 之间是物理隔离——sessions、memories、skills 完全独立,不可能交叉污染 - 防御性加载:profile 解析失败不阻止启动,YAML 解析失败回退默认值,managed 检测用双信号——每一步都有 fallback
设计赌注回扣:本章直接服务于 Run Anywhere 赌注——五层配置优先级让 Hermes 在 Docker(环境变量注入)、NixOS(声明式配置)、$5 VPS(手动 .env)和开发者笔记本(项目 .env fallback)上用同一套代码正确初始化。Profile 隔离让同一台机器上的多个 agent 实例互不干扰。
版本演化说明
本章核心分析基于 Hermes Agent v0.8.x(2026 年 4 月)。 Profile 系统是在 v0.6.0 发布窗口进入主线的,managed install 相关能力和 profile 导入导出细节则继续演化到 v0.8.0。
DEFAULT_CONFIG的_config_version当前为 12;配置加载的五层优先级结构在最近几个 release 中保持稳定。v0.8.x 新增了agent.restart_drain_timeout(Gateway 重启排空超时)和delegation.reasoning_effort(子代理推理强度)两个配置键,以及 Gateway 配置到环境变量的桥接机制。