【系列:手搓自主 AI Agent:Hermes 架构原理剖析 · 第 8 篇】

配置系统优先级链: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_firstkeep_recent_tool_resultstail_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 {}------空文件返回 Noneor {} 兜底。

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、迁移系统详解)

📥 源码获取 :如需本系列全部源码,请在以下链接克隆:

https://gitcode.com/ganxin7932508/learn-hermes-agent.git

相关推荐
circuitsosk1 小时前
跨境电商智能化实战:AI如何赋能客服自动回复、广告智能投放与供应链预测
大数据·人工智能·python·langchain·智能客服
2601_956319882 小时前
2026年零基础学量化:从看懂示例到写清条件和动作
人工智能·python
过期的秋刀鱼!2 小时前
LangChain-D1-模型的工作流程
人工智能·python·langchain
萤火工厂目视化设计2 小时前
智能制造与企业文化目视化浪潮下:中小工厂的机遇与挑战
python·制造
DeepVisionary3 小时前
从GPT-5.6 Sol的Ultrafast模式看推理速度竞赛:750 token/秒背后,Cerebras的晶圆级生意
python·自动化
leisoo80974 小时前
财报数据怎么排雷本地化Python构建财务异常预警系统
人工智能·python·算法
小柯南敲键盘4 小时前
跨马翻译:跨境电商批量图片翻译与视频字幕一站式工具
人工智能·python·音视频
卷无止境5 小时前
FastAPI Guard 全解析,从概念到工程落地的实战指南
后端·python
卷无止境5 小时前
FastAPI Users 全面解析:概念、原理与工程实战
后端·python