配置系统优先级链: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里的真实代码:
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()
配置系统最核心的函数。
def_deep_merge(base:dict,override:dict)->dict:"""Recursively merge two dicts. Override values take precedence."""result=base.copy()forkey,valueinoverride.items():if(keyinresultandisinstance(result[key],dict)andisinstance(value,dict)):result[key]=_deep_merge(result[key],value)else:result[key]=valuereturnresult逻辑很直白:递归合并两个字典,override 的值优先。
但为什么不用dict.update()?看这个场景:
用户只想改compression.threshold,YAML 里写了:
compression:threshold:0.65如果用dict.update(),compression整个子字典会被覆盖——protect_first、keep_recent_tool_results、tail_token_budget全丢了。
_deep_merge递归进入子字典,只覆盖声明了的字段。用户配了什么,就只改什么。
load_config:解析失败也不阻塞启动
defload_config(config_path:Path|None=None)->dict:ifconfig_pathisNone:config_path=HERMES_HOME/"config.yaml"ifnotconfig_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{}exceptException: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,手写一个简单解析器:
defload_env(env_path:Path|None=None):ifenv_pathisNone:env_path=HERMES_HOME/".env"ifnotenv_path.exists():returnforlineinenv_path.read_text(encoding="utf-8").splitlines():line=line.strip()ifnotlineorline.startswith("#"):continueif"="inline: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 里可以写:
api_key:${OPENAI_API_KEY}运行时展开:
def_expand_env_vars(value):ifisinstance(value,str):defreplacer(match):var_name=match.group(1)returnos.getenv(var_name,match.group(0))returnre.sub(r'\$\{(\w+)\}',replacer,value)elifisinstance(value,dict):return{key:_expand_env_vars(val)forkey,valinvalue.items()}elifisinstance(value,list):return[_expand_env_vars(item)foriteminvalue]returnvalue递归处理字符串、字典、列表。
关键在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 人设,怎么办?
看这段代码:
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、迁移系统详解)
📥源码获取:如需本系列全部源码,请在以下链接克隆:
https://gitcode.com/ganxin7932508/learn-hermes-agent.git