做项目的时候你一定遇到过这种情况:本地跑得好好的,一部署到测试环境就报错,再到生产环境又是一堆配置问题。数据库地址不一样、API Key 不能写死、日志级别各不相同——每次切换环境都要改一堆东西,改着改着就把生产环境的密钥提交到了 GitHub。
所以真正的问题不是"怎么配",而是怎么让同一套代码在不同环境下自动加载正确的配置,同时保证敏感信息绝不进仓库。
本文围绕多环境配置管理主要介绍:
1)如何通过环境变量自动感知并切换配置文件
2)公共配置与差异配置的分层合并机制
3)敏感信息在不同环境下的差异化加载策略
4)基于 Schema 的类型安全与 Skill 化复用思路
一套可以直接搬到自己项目里的配置架构。
多环境配置不是单一问题,它实际上包含四个子问题,每个都有不同的解决思路。
问题一:环境感知。 代码运行在开发、测试还是生产环境?需要有一个明确的信号让程序知道当前身处何方。
问题二:差异配置。 不同环境的数据库地址、端口号、超时时间这些参数各不相同,怎样只维护一份公共配置,再为每个环境写差异部分?
问题三:敏感信息安全。 数据库密码、第三方密钥这类信息,在开发和测试阶段可以写在配置文件里(通过 .gitignore 忽略),但生产环境绝对不行——必须从系统环境变量注入。
问题四:合并策略。 当公共配置和环境特定配置存在相同字段时,以谁为准?需要一个确定的优先级规则。
这四个问题的解法合在一起,就是一套完整的多环境配置方案。
整个方案的入口是一个环境变量,比如 ENV。程序启动时首先读取这个变量:
import os
env = os.getenv("ENV", "development") # 默认开发环境为什么用环境变量而不是配置文件?因为环境变量是操作系统级别的信号,不依赖任何文件路径,Docker、K8s、CI/CD 都能方便地注入。它在所有环境中都能拿到,是真正的"零依赖入口"。
感知到环境之后,按照固定顺序逐级加载配置:
第一层:默认配置(default)。 三个环境通用的部分——日志格式、通用中间件开关、默认超时等。这部分写在一个 config/default.json 里,所有人共享。
第二层:环境专属配置。 根据 ENV 的值,去对应的目录找配置文件。比如 ENV=production 就读 config/production.json。这里面只写和默认配置不一样的字段。
第三层:敏感信息。 这层的加载方式因环境而异(下文详述),最终结果同样汇入同一个配置字典。
为什么要分三层而不是把所有配置都塞到一个文件里?因为当你的环境从三个变成五个、配置项从二十个变成两百个时,分层结构能让每一处修改都精确命中目标,不会牵一发而动全身。
最终的目录结构为:
agents/config/
├── __init__.py # 模块入口,导出公共 API
├── schema.py # Pydantic 数据模型定义
├── loader.py # 核心加载逻辑(5 步流程)
├── paths.py # 文件路径定位工具
├── demo.py # 完整演示代码
├── README.md # 文档
├── jsons/ # 结构化配置(可提交 Git)
│ ├── default.json # ① 公共默认配置
│ ├── test.json # ② test 环境覆盖配置
│ ├── dev.json # ③ dev 环境覆盖配置
│ └── prod.json # ④ prod 环境覆盖配置
└── envs/ # 敏感信息(不提交 Git)
├── test.env # test 环境密钥
├── dev.env # dev 环境密钥模板(参考用)
└── prod.env # prod 环境密钥模板(参考用)两层配置加载完毕后,最关键的一步是合并。
具体做法是:先加载 default 配置作为基座,再把环境专属配置叠上去。对于同名字段,环境配置覆盖默认值;对于环境配置里没有的字段,保留默认值。这就是所谓的"浅合并"或"深度合并",取决于你的需求复杂度。
一个直观的理解方式是:默认配置是地基,环境配置是装修。 地基决定房子长什么样,装修决定每个房间具体的摆设。你不能没有地基,但你也不希望每个房间的墙纸都一样。
base_config = load_default_config()
env_config = load_env_config(env) # 只含差异项
final_config = merge_config(base_config, env_config) # 假设工具函数以下是 merge 的具体逻辑
def merge_config(base: dict, override: dict) -> dict:
"""深度合并两个字典, override 中同名键覆盖 base.
规则::
- dict + dict → 递归合并
- 其他类型 → override 直接替换 base
Examples::
>>> merge_config({"a": {"x": 1}}, {"a": {"y": 2}})
{'a': {'x': 1, 'y': 2}}
"""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = merge_config(result[key], value)
else:
result[key] = value
return result这样,新增一个环境时只需要写差异文件,不需要复制粘贴整份配置。
这是整个方案中最容易出安全问题的环节,也是三种环境行为差异最大的地方。
在本地开发和测试环境中,为了方便调试,我们可以把数据库密码、API Key 写在配置文件里。关键是要把这个文件加入 .gitignore:
# .gitignore
config/.secrets.json
config/test/secrets.yml团队中每个人各自维护自己的 secrets 文件,互不干扰。CI 环境中可以通过加密存储或 CI 平台的 secret management 功能来提供这个文件。
到了生产环境,规则完全不同。不允许任何明文密钥出现在配置文件或代码中,哪怕这个文件不在 Git 仓库里。因为服务器上的文件权限可能被误改、容器镜像可能被泄露、运维脚本可能被拷贝到其他地方——风险面太广。
正确的做法是在配置 schema 中将敏感字段的值留空或标记为占位符。根据当前配置环境的值,来决定在哪里取值。
敏感变量和普通变量一起定义:
class Anthropic(Base):
"""Anthropic configuration."""
api_base: Any = Field(default=None)
api_key: str = Field(default="")
extra_headers: Any = Field(default=None)变量的加载过程:
def load_config(env: str | None = None) -> Config:
"""加载配置, 返回 Config 实例.
"""
# ── Step 1: 获取 APP_ENV ───────────────────────────────────────────
env = env or get_app_env()
logger.info(f"[{env}] 开始加载配置")
# ── Step 2: 读取公共配置 default.json ─────────────────────────────
default_path = get_json_file("default")
data = _load_json(default_path, required=False)
logger.info(f"Step2: default.json → {list(data.keys()) if data else '(空)'}")
# ── Step 3: 读取环境配置 {env}.json, 合并到公共配置 ───────────────
env_path = get_json_file(env)
env_data = _load_json(env_path, required=False)
data = merge_config(data, env_data)
logger.info(f"Step3: {env}.json merged → keys={list(data.keys())}")
# ── Step 4: 读取敏感信息 {env}.env, 合并到配置 ────────────────────
# test: .env → 嵌套 dict → merge_config (data 直接含完整密钥)
# dev/prod: 不读文件, Pydantic BaseSettings 从 os.environ 自动补充 AGENTS_*
if env in _ENVS_USE_FILE:
env_vars = load_env_file(env)
if env_vars:
secrets_dict = _env_to_nested_dict(env_vars, prefix=ENV_PREFIX)
data = merge_config(data, secrets_dict)
logger.info(f"Step4: {env}.env merged → {len(env_vars)} 个密钥")
# ── Step 5: 清理 & 校验, 返回 Config 实例 ────────────────────────────
data = _clean_data(data)
try:
config = Config.model_validate(data)
except pydantic.ValidationError as e:
raise ValueError(f"Config 校验失败 [{env}]: {e}") from e
logger.info(f"[{env}] 加载完成 — model={config.agents.defaults.model}")
return config敏感信息系统变量的定义:
# 使用 Pydantic Settings 嵌套格式:
# AGENTS_PROVIDERS__QIANFAN__API_KEY → providers.qianfan.apiKey
# 格式: AGENTS_{顶级}__{二级}__{字段名}
# === Providers API Keys ===
AGENTS_PROVIDERS__QIANFAN__API_KEY=sk-test-qianfan-456
AGENTS_PROVIDERS__OPENAI__API_KEY=sk-test-openai-xxx
AGENTS_PROVIDERS__ANTHROPIC__API_KEY=sk-test-anthropic-xxx
AGENTS_PROVIDERS__CUSTOM__API_KEY=sk-test-custom-xxx其中的前缀格式是通过 schema.py 中进行定义:
class Config(BaseSettings):
"""Root configuration.
Environment variables are automatically loaded with the ``AGENTS_`` prefix::
Nested fields use double underscore (``__``) as delimiter.
"""
agents: Agents = Field(default_factory=Agents)
api: Api = Field(default_factory=Api)
....
model_config = ConfigDict(
env_prefix="AGENTS_",
env_nested_delimiter="__",
# Allow extra fields from JSON (_comment, _envMeta, etc.)
extra="ignore",
)Config 类作为统一的配置聚合根,对外提供接口能力。
这样一套操作的好处是:
代码里没有任何硬编码的密钥,配置文件里也没有明文,唯一存放密钥的地方是操作系统的进程环境——而这是运维体系中最受控的渠道之一。
无论敏感信息来自文件还是环境变量,对外暴露的接口是一致的。调用方只需要一行代码:
config = Config.load_config() # 自动完成全部加载、合并、校验
db_url = config.database.connection_string调用方不需要知道当前是什么环境、密钥从哪来的。封装变化,暴露稳定接口——这也是面向对象设计的基本原则在配置管理中的应用。
配置文件本质上也是数据,数据就需要有结构约束。用 Python dataclass 或 Pydantic 定义配置类,可以在加载阶段就发现拼写错误、类型不匹配、缺失必填项等问题,而不是等到运行时才在某次 API 调用时炸掉。
class Base(BaseModel):
"""Base model that accepts both camelCase and snake_case keys."""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)配置类之间可以有继承关系。
比如 Agents 继承 Base 并覆盖某些默认值。这种类继承和前面说的文件层级合并是两套互补的机制:文件合并解决的是值的差异,类继承解决的是结构的差异。
注意:Base model that accepts both camelCase and snake_case keys.
两者格式都是可以接受的,但是最终输出的结果是按照camelCase 的方式,即需要使用camelCase方式进行值的获取。
当你在一两个项目中反复写类似的配置加载逻辑时,就该考虑把这个能力沉淀下来了。
具体做法是把整个配置目录(包括 loader 代码、schema 定义、示例配置文件、.gitignore 模板)打包成一个可迁移的模板。下次新项目启动时,直接把这套结构丢给 AI Agent 或者手动复制过来,填上项目特有的配置项就能跑。
这就是原文提到的 "Skill 化" —— 把一次性的实现变成可复用的能力资产。配置管理本身不是什么高深的技术,但它在每个项目中都会出现,值得投入一点时间把它做好、固化下来,然后忘掉它。
多环境配置的核心思路可以浓缩成一句话:通过环境变量定位身份,按层级加载配置,用合并策略处理冲突,根据环境等级选择敏感信息来源,最后用 Schema 兜底类型安全。
这套方案没有引入任何外部框架依赖,纯靠标准库加上一点点设计 discipline 就能搞定。如果你的项目还在用 if env == 'prod' 这种硬编码方式切配置,不妨花半小时把它重构一下,这笔投会在后续每一次部署时回报给你。