Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

配置与 Profiles:多实例隔离的工程学

本章核心源码hermes_cli/config.pyhermes_cli/main.pyhermes_cli/env_loader.pyhermes_cli/profiles.py

定位:本章拆解 Hermes 的配置加载管线——从硬编码默认值到环境变量到 YAML 文件到 profile 隔离——理解一个"Run Anywhere"的 agent 如何在 $5 VPS、NixOS、Docker 和开发者笔记本上用同一套代码正确初始化。 前置依赖:第 4 章(AIAgent 初始化)。适用场景:需要理解 Hermes 配置优先级、多实例部署、或自定义配置扩展。

为什么配置系统值得单独讨论

大多数 CLI 工具的配置很简单:一个 YAML 文件加几个环境变量。但 Hermes 面对的场景远比典型 CLI 复杂:

  1. 多入口共享配置:CLI、Gateway、Cron 三个入口必须读到同一份配置,且 Gateway 作为 systemd 服务运行时没有用户的 shell 环境变量
  2. 多实例并行:一个用户可能运行两个 Hermes 实例(一个 "coder" profile 用 Claude,一个 "researcher" profile 用 GPT-5),它们的 sessions、memories、skills 必须完全隔离
  3. 密钥安全:API keys 不能写进 config.yaml(会被 git commit),需要独立的 .env 文件
  4. 环境兼容:同一份代码要在 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,其他一切都有默认行为。

值得注意的嵌套结构:

配置节行号用途
agent207-219编排参数(max_turns、gateway_timeout、tool_use_enforcement)
terminal222-259执行环境(backend、docker_image、container_cpu/memory/disk)
browser261-273浏览器工具(inactivity_timeout、command_timeout、camofox)
compression288-296上下文压缩策略(threshold、target_ratio、protect_last_n)
auxiliary304-3688 个辅助任务各自的 provider/model/base_url/timeout
memory441-451记忆系统(memory_char_limit、user_char_limit、provider)
delegation453-464子代理配置(独立 model/provider、max_iterations=50、reasoning_effort)
security522-533安全扫描(redact_secrets、tirith、website_blocklist)
logging542-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):

类别示例数量
ProviderOPENROUTER_API_KEYANTHROPIC_API_KEYDEEPSEEK_API_KEY~25
ToolEXA_API_KEYFAL_KEYTAVILY_API_KEYCAMOFOX_URL~15
MessagingTELEGRAM_BOT_TOKENDISCORD_BOT_TOKENSLACK_BOT_TOKEN~20
SettingHERMES_MAX_ITERATIONSMESSAGING_CWDSUDO_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)
        )
    )

三个关键步骤:

  1. Deep merge:用户配置覆盖默认值,但只覆盖用户明确设置的字段。如果用户的 config.yaml 只有 model: claude-opus-4-6,其他 350+ 个字段都保持默认
  2. 历史兼容性修正_normalize_root_model_keys() 将旧版的根级 provider: 迁移到 model.provider:_normalize_max_turns_config() 将根级 max_turns: 迁移到 agent.max_turns:
  3. 变量展开_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() 的三步逻辑:

  1. 命令行参数main.py:90-98):检查 --profile/-p 标志
  2. Sticky 默认main.py:100-110):读取 ~/.hermes/active_profile 文件
  3. 环境变量设置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_timeoutHERMES_AGENT_TIMEOUTagent 执行超时
agent.gateway_timeout_warningHERMES_AGENT_TIMEOUT_WARNING超时预警
agent.restart_drain_timeoutHERMES_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 的配置系统展示了三个值得借鉴的工程模式:

  1. 五层叠加而非单一来源:defaults -> env -> .env -> yaml -> expansion 的分层让不同部署方式(Docker 环境变量注入、NixOS 声明式配置、开发者 .env)自然工作,不需要为每种部署写不同的加载逻辑
  2. Profile 即 HERMES_HOME 重定向:不是在配置中加一个 active_profile 字段做逻辑隔离,而是直接重定向整个 HERMES_HOME 目录。Profile 之间是物理隔离——sessions、memories、skills 完全独立,不可能交叉污染
  3. 防御性加载: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 配置到环境变量的桥接机制。