4. 配置管理:YAML/JSON配置文件、超参数管理、环境变量管理
配置管理这事儿,看着简单,但坑是真不少。我见过太多团队,代码写得漂漂亮亮,结果配置乱成一锅粥——训练跑一半发现路径写死了,换台机器就得改代码,超参数散落在各个脚本里,环境变量全靠口口相传。说白了,配置管理做不好,流水线就是个定时炸弹。
这一章,我就把我在多个项目中沉淀下来的配置管理套路,掰开揉碎了讲给你听。
4.1 配置文件格式选型:YAML vs JSON
先解决最基础的问题:用 YAML 还是 JSON?
我个人习惯用 YAML。为什么?因为它可读性更好。你想想看,一个训练配置动辄几十个参数,JSON 那堆花括号和逗号,看久了眼睛疼。YAML 用缩进表示层级,清爽多了。
核心原则:YAML 用于人工编写和阅读的配置文件,JSON 用于程序间数据交换和序列化存储。
来看个实际对比。下面是一个训练配置,分别用 YAML 和 JSON 表示:
# config.yaml
project:
name: "bert-finetune"
version: "1.2.0"
training:
batch_size: 32
epochs: 10
learning_rate: 0.0001
optimizer: "adamw"
scheduler:
type: "cosine"
warmup_steps: 500
data:
train_path: "./data/train.csv"
val_path: "./data/val.csv"
num_workers: 4
logging:
log_interval: 50
save_interval: 500
tensorboard: true
// config.json
{
"project": {
"name": "bert-finetune",
"version": "1.2.0"
},
"training": {
"batch_size": 32,
"epochs": 10,
"learning_rate": 0.0001,
"optimizer": "adamw",
"scheduler": {
"type": "cosine",
"warmup_steps": 500
}
},
"data": {
"train_path": "./data/train.csv",
"val_path": "./data/val.csv",
"num_workers": 4
},
"logging": {
"log_interval": 50,
"save_interval": 500,
"tensorboard": true
}
}
看出来了吧?YAML 少了那些语法噪音,结构一目了然。但 JSON 也有它的优势——几乎所有编程语言都原生支持,解析速度更快,而且不会出现 YAML 那种缩进歧义问题。
我的建议:团队内部用 YAML,对外接口用 JSON。比如模型服务的请求响应用 JSON,训练脚本的配置文件用 YAML。这样两边都舒服。
4.2 超参数管理:从硬编码到分层配置
超参数管理,是配置管理里最核心也最容易出问题的地方。我记得刚带团队那会儿,有个同学把学习率直接写在训练脚本里,每次调参都要改代码、重新提交。后来我们统一用配置文件管理超参数,效率提升了一大截。
我推荐的分层配置策略是这样的:
- 默认配置层:模型架构、优化器类型等基本不变的参数
- 实验配置层:学习率、batch size 等需要频繁调整的参数
- 运行时覆盖层:通过命令行参数临时覆盖的配置
来看一个实际的分层配置实现:
# default_config.yaml
model:
architecture: "bert-base"
hidden_size: 768
num_layers: 12
dropout: 0.1
training:
optimizer: "adamw"
scheduler: "cosine"
warmup_ratio: 0.1
weight_decay: 0.01
# experiment_config.yaml
training:
learning_rate: 2e-5
batch_size: 16
epochs: 3
data:
max_length: 512
train_file: "data/train_v2.csv"
# 运行时覆盖(命令行)
# python train.py --training.learning_rate 1e-5 --training.batch_size 32
在代码里,我习惯用这样的方式合并配置:
import yaml
from argparse import ArgumentParser
from deepmerge import always_merger
def load_config():
# 1. 加载默认配置
with open("default_config.yaml") as f:
config = yaml.safe_load(f)
# 2. 加载实验配置(覆盖默认值)
with open("experiment_config.yaml") as f:
experiment = yaml.safe_load(f)
config = always_merger.merge(config, experiment)
# 3. 解析命令行参数(最高优先级)
parser = ArgumentParser()
parser.add_argument("--training.learning_rate", type=float)
parser.add_argument("--training.batch_size", type=int)
args = parser.parse_args()
# 将命令行参数转换为嵌套字典并合并
cli_overrides = {}
for key, value in vars(args).items():
if value is not None:
keys = key.split(".")
current = cli_overrides
for k in keys[:-1]:
current = current.setdefault(k, {})
current[keys[-1]] = value
config = always_merger.merge(config, cli_overrides)
return config
注意:合并配置时一定要用深合并(deep merge),而不是简单的字典更新。否则嵌套的配置项会被整个覆盖掉,而不是只覆盖指定的字段。我曾经因为这个 bug 浪费了一整天。
4.3 环境变量管理:敏感信息与运行时环境
环境变量这事儿,说白了就是「不要把密码写在代码里」。我见过有人把数据库密码、 直接写在配置文件里,然后不小心提交到 Git 仓库——那场面,简直是灾难。
我的做法是:
- 敏感信息(密码、密钥、)一律通过环境变量传入
- 环境差异(开发/测试/生产路径、GPU 编号)也走环境变量
- 非敏感配置(学习率、batch size)走配置文件
来看一个实际的环境变量管理方案:
# .env 文件(不提交到 Git)
# 敏感信息
DB_PASSWORD=your_db_password_here
=sk-xxxxxxxxxxxxxxxx
WANDB_=your_wandb_key
# 环境差异
DATA_ROOT=/mnt/data/production
CUDA_VISIBLE_DEVICES=0,1,2,3
LOG_LEVEL=INFO
# .env.example(提交到 Git,作为模板)
DB_PASSWORD=your_db_password_here
=your__here
WANDB_=your_wandb_key
DATA_ROOT=/mnt/data/development
CUDA_VISIBLE_DEVICES=0
LOG_LEVEL=DEBUG
在代码里加载环境变量,我推荐用 python-dotenv:
import os
from dotenv import load_dotenv
# 加载 .env 文件(开发环境)
load_dotenv()
# 生产环境通过容器或 CI/CD 注入环境变量
db_password = os.getenv("DB_PASSWORD")
data_root = os.getenv("DATA_ROOT", "/mnt/data/default")
gpu_ids = os.getenv("CUDA_VISIBLE_DEVICES", "0").split(",")
一个小技巧:在 CI/CD 流水线里,把环境变量统一放在 GitLab CI/CD Variables 或 GitHub Secrets 里,不要在 .env 文件里写死。这样既安全又灵活。
4.4 配置管理的完整架构
好了,前面讲了三个维度:配置文件、超参数、环境变量。它们不是孤立的,需要组合成一个完整的配置管理体系。下面这张图,就是我常用的配置管理架构:
这个架构的核心思想是:配置来源分离,合并逻辑统一,验证前置,审计留痕。每个环节都有明确的职责,出了问题也能快速定位。
4.5 配置版本控制与审计
配置改来改去,怎么知道谁改了、改了什么、为什么改?这就是配置审计要解决的问题。
我的做法是:
- 配置文件进 Git:所有 YAML/JSON 配置文件都纳入版本控制,每次修改都有记录
- 实验配置打 Tag:每次训练实验,把完整的配置快照保存到 MLflow 或 W&B
- 环境变量不提交:.env 文件加入 .gitignore,只提交 .env.example 作为模板
最佳实践:在训练脚本启动时,把最终合并后的完整配置打印到日志里,并保存一份到实验跟踪系统。这样即使后来配置改了,也能回溯当时训练用的确切参数。
来看一个配置审计的代码片段:
import json
import logging
from datetime import datetime
def log_config(config, experiment_id):
"""记录配置快照"""
logger = logging.getLogger(__name__)
# 打印到日志
logger.info("=" * 60)
logger.info(f"Experiment ID: {experiment_id}")
logger.info(f"Timestamp: {datetime.now().isoformat()}")
logger.info("Training Configuration:")
logger.info(json.dumps(config, indent=2, ensure_ascii=False))
logger.info("=" * 60)
# 保存到文件(可选)
with open(f"configs/{experiment_id}_config.json", "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
我曾经踩过的坑:有一次,团队成员在实验配置里把学习率写成了 2e-5,但默认配置里是 1e-4。因为合并逻辑写的是浅合并,结果实验配置覆盖了整个 training 字段,把默认的优化器设置也冲掉了。模型训练了三天,结果全废了。从那以后,我强制要求所有配置合并必须用深合并,并且在验证阶段检查关键字段是否存在。
4.6 配置管理清单
最后,给你一份配置管理的检查清单,每次搭建新流水线时对照着过一遍:
| 检查项 | 说明 | 优先级 |
|---|---|---|
| 配置文件格式 | YAML 用于人工编写,JSON 用于程序交换 | 高 |
| 分层配置 | 默认配置 → 实验配置 → 命令行覆盖 | 高 |
| 深合并 | 使用 deepmerge 库,避免浅覆盖 | 高 |
| 环境变量管理 | 敏感信息走环境变量,.env 不提交 Git | 高 |
| 配置验证 | 类型检查、范围校验、必填项检查 | 中 |
| 配置审计 | 配置快照保存到实验跟踪系统 | 中 |
| 版本控制 | 配置文件进 Git,环境变量模板进 Git | 高 |
配置管理这事儿,看着琐碎,但做好了能省下大把排查问题的时间。你想想看,训练跑崩了,第一件事就是查配置——如果配置管理得清清楚楚,几分钟就能定位问题;如果配置一团乱麻,可能得花半天去猜「当时用的是哪个参数」。嗯,该怎么做,你应该心里有数了。