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 超参数管理:从硬编码到分层配置

超参数管理,是配置管理里最核心也最容易出问题的地方。我记得刚带团队那会儿,有个同学把学习率直接写在训练脚本里,每次调参都要改代码、重新提交。后来我们统一用配置文件管理超参数,效率提升了一大截。

我推荐的分层配置策略是这样的:

  1. 默认配置层:模型架构、优化器类型等基本不变的参数
  2. 实验配置层:学习率、batch size 等需要频繁调整的参数
  3. 运行时覆盖层:通过命令行参数临时覆盖的配置

来看一个实际的分层配置实现:

# 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 配置管理的完整架构

好了,前面讲了三个维度:配置文件、超参数、环境变量。它们不是孤立的,需要组合成一个完整的配置管理体系。下面这张图,就是我常用的配置管理架构:

配置管理体系架构 配置来源 YAML 配置文件 默认配置 + 实验配置 环境变量 敏感信息 + 环境差异 命令行参数 运行时临时覆盖 配置合并引擎 深合并(deep merge)· 优先级:命令行 > 环境变量 > 配置文件 配置验证 类型检查 · 范围校验 · 必填项检查 配置使用 训练脚本 · 数据加载 · 模型初始化 · 日志记录 · 模型保存 配置审计与版本控制(Git + MLflow)

这个架构的核心思想是:配置来源分离,合并逻辑统一,验证前置,审计留痕。每个环节都有明确的职责,出了问题也能快速定位。

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 高

配置管理这事儿,看着琐碎,但做好了能省下大把排查问题的时间。你想想看,训练跑崩了,第一件事就是查配置——如果配置管理得清清楚楚,几分钟就能定位问题;如果配置一团乱麻,可能得花半天去猜「当时用的是哪个参数」。嗯,该怎么做,你应该心里有数了。