配置系统优先级链:YAML、.env 与 Profile 的三层隔离
导读: 切换模型还要改代码?API key 和配置混在一个文件里?同一个人需要两套 Agent 人设?Hermes 配置系统用三层隔离解决这三个问题。本文拆解 41716 字节的 s11_configuration_system.py,讲清楚深度合并、环境变量展开与 Profile 切换的完整实现。
三个痛点:为什么需要配置系统
先问三个问题。
第一个:从 OpenRouter 切到 Anthropic,你要改几行代码?如果模型名、base_url 散落在 10 个文件里,改一次就是一次灾难。
第二个:API key 放在 YAML 里,然后提交到 Git 仓库。等收到泄露告警邮件,已经晚了。
第三个:白天你是写代码的 coder,晚上你是写文章的 writer。两个人设、两套记忆、两套工具集------难道要维护两个项目?
这三个问题,就是配置系统要解决的。
核心目标:把"Agent 怎么运行"从代码里抽出来,变成可声明、可合并、可隔离的外部配置。
DEFAULT_CONFIG:代码里的完整默认字典
先看默认配置长什么样。这是 s11_configuration_system.py 里的真实代码:
python
DEFAULT_CONFIG = {
"model": "anthropic/claude-sonnet-4",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "",
"fallback": {"model": "", "base_url": "", "api_key": ""},
"limits": {
"max_iterations": 30,
"max_child_iterations": 15,
"max_retries": 3,
"max_continuations": 3,
},
"compression": {
"threshold": 50000,
"protect_first": 3,
"keep_recent_tool_results": 3,
"tail_token_budget": 20000,
},
"memory": {"memory_char_limit": 2200, "user_char_limit": 1375},
"db_path": "state.db",
}
这个字典有两个作用:提供默认值 + 定义 schema。
注意几个数字------它们不是随便写的,是前面章节的呼应:
max_iterations: 30对应 s01 的主循环上限max_child_iterations: 15对应 s10 的子 Agent 深度限制compression.threshold: 50000对应 s05 的上下文压缩触发阈值memory.memory_char_limit: 2200对应 s07 的记忆窗口大小
配置系统不是凭空造的,它把前面所有机制的参数统一收编了。
_deep_merge:为什么不用 dict.update()
配置系统最核心的函数。
python
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge two dicts. Override values take precedence."""
result = base.copy()
for key, value in override.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
逻辑很直白:递归合并两个字典,override 的值优先。
但为什么不用 dict.update()?看这个场景:
用户只想改 compression.threshold,YAML 里写了:
yaml
compression:
threshold: 0.65
如果用 dict.update(),compression 整个子字典会被覆盖------protect_first、keep_recent_tool_results、tail_token_budget 全丢了。
_deep_merge 递归进入子字典,只覆盖声明了的字段。用户配了什么,就只改什么。

load_config:解析失败也不阻塞启动
python
def load_config(config_path: Path | None = None) -> dict:
if config_path is None:
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return _expand_env_vars(DEFAULT_CONFIG.copy())
try:
raw_text = config_path.read_text(encoding="utf-8")
user_config = yaml.safe_load(raw_text) or {}
except Exception:
user_config = {} # YAML 解析异常就退回默认值
merged = _deep_merge(DEFAULT_CONFIG, user_config)
return _expand_env_vars(merged)
注意三个细节:
第一,DEFAULT_CONFIG.copy()------浅拷贝。如果不 copy,同进程二次 load 会读到被污染的值。这是一个经典的 Python 坑。
第二,except Exception: user_config = {}。YAML 写坏了?退回默认值。坏配置不阻塞启动。
第三,yaml.safe_load(raw_text) or {}------空文件返回 None,or {} 兜底。
load_env:手写 .env 解析
不依赖 python-dotenv,手写一个简单解析器:
python
def load_env(env_path: Path | None = None):
if env_path is None:
env_path = HERMES_HOME / ".env"
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
两个关键点。
第一,setdefault 语义 :真实环境变量优先,.env 只做缺省值。这意味着你可以在 shell 里 export OPENAI_API_KEY=xxx,.env 里的值不会覆盖它。
第二,手写解析:不引入额外依赖。一个 20 行的函数,解决 80% 的需求。
_expand_env_vars:${VAR} 展开
config.yaml 里可以写:
yaml
api_key: ${OPENAI_API_KEY}
运行时展开:
python
def _expand_env_vars(value):
if isinstance(value, str):
def replacer(match):
var_name = match.group(1)
return os.getenv(var_name, match.group(0))
return re.sub(r'\$\{(\w+)\}', replacer, value)
elif isinstance(value, dict):
return {key: _expand_env_vars(val) for key, val in value.items()}
elif isinstance(value, list):
return [_expand_env_vars(item) for item in value]
return value
递归处理字符串、字典、列表。
关键在 replacer 里的 match.group(0)------变量不存在时保留原 ${VAR},不静默变成空串。这样调用方就知道"这个值没配好",而不是拿到一个空字符串去请求 API,然后收到一个莫名其妙的 401。
优先级链:谁覆盖谁
整个配置系统的核心规则,一句话:
命令行参数 > 环境变量 > config.yaml > 默认值(DEFAULT_CONFIG)
从下往上读:默认值是最底层兜底;config.yaml 覆盖默认值;环境变量再往上盖一层;命令行参数最高优先级。
这个设计的好处:每个环境只需要声明自己不同的部分。开发环境用默认值,测试环境用 config.yaml 覆盖几个字段,生产环境再用环境变量注入密钥。

两文件分离:config.yaml vs .env
Hermes 的一个独特设计:把配置拆成两个文件。
config.yaml:结构化行为配置。模型、限制、压缩阈值、记忆窗口------这些可以进版本控制,可以团队共享。
.env :秘密信息。API key、token------0600 权限、.gitignore、每人各自一份。
为什么要拆?
因为秘密信息和结构化配置的生命周期完全不同。config.yaml 要 review、要版本化、要团队讨论;.env 要保密、要隔离、要每人不同。混在一起,要么泄露密钥,要么无法共享。

Profile 隔离:切换目录就是切换世界
同一个人需要两套 Agent 人设,怎么办?
看这段代码:
python
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
load_env()
_config = load_config()
HERMES_HOME 环境变量指向不同目录:
~/.hermes/profiles/coder/→ coder 人设~/.hermes/profiles/writer/→ writer 人设
不需要任何条件分支。目录换了,整个世界就换了。
每个 Profile 有自己独立的 config.yaml、.env、state.db、记忆文件。模型、人设、工具集、记忆------全部隔离。
这就是配置系统的终极形态:配置不是参数,是环境。

启动接入:核心循环不知道配置来自哪里
最后看整体流程:
启动入口(CLI/Gateway)
→ load_env()
→ load_config() deep_merge
→ _expand_env_vars()
→ 构建 AIAgent 参数
→ 核心循环运行
核心循环不直接读 config.yaml,只接收参数。
这意味着什么?核心循环可以被任何入口复用------CLI、API 服务、测试脚本------配置来源可以随时替换。今天用 YAML,明天换成远程配置中心,核心循环一行不用改。
这就是依赖注入。
配置版本迁移:教学版的边界
生产级配置系统还需要处理字段改名、迁移。真实仓库文档描述了 ENV_VARS_BY_VERSION + _normalize_max_turns_config 的完整方案。
教学版的范围更克制:
- 深度合并用默认值补齐缺失字段
- 字段改名/移动需要显式迁移函数
_config_version字段追踪版本
不要过度设计。 教学版的目标是讲清楚核心机制,迁移系统点到为止。
初学者 5 错
最后总结最常见的五个坑。
1. API key 写进 config.yaml
应该在 .env 里,用 ${VAR} 引用。YAML 会进 Git 仓库,key 会泄露。
2. 直接修改 DEFAULT_CONFIG
load_config 必须 copy.deepcopy。否则同进程二次 load,读到的是被污染的值。
3. 用 dict.update() 代替深度合并
嵌套字段会丢。用户只配了一个字段,其他全没了。
4. Profile 之间共享 .env
切换 Profile 用错 key,高权限 key 暴露给低权限场景。每个 Profile 必须有独立的 .env。
5. 忘记配置迁移
字段改名/移动需要显式迁移函数。不迁移,旧配置静默失效,行为不可预期。
小结与下篇预告
配置系统是阶段 2 的收官。s07-s11,从记忆、技能到安全、委派,所有机制的参数现在都被统一收编进三层配置体系。
配置系统的本质:把变化从代码里赶出去。 代码只负责逻辑,变化交给配置。切换模型不改代码,注入密钥不进仓库,切换人设不换项目。
下一篇,第 9 篇:Gateway 与平台适配器------阶段 3 开始,让 Agent 接入真实世界。
你在自己的项目里,配置系统是怎么设计的?遇到过哪些坑?欢迎在评论区聊聊。
参考文献
- Hermes Agent 教学仓库:
agents/s11_configuration_system.py(本文代码素材,41716 字节真实可运行) - Hermes Agent 教学仓库:
docs/zh/s11-configuration-system.md(两文件分离、Profile、迁移系统详解)
📥 源码获取 :如需本系列全部源码,请在以下链接克隆: