从炼丹到实验管理:用软件工程方法论管理 AI 实验
从炼丹到实验管理用软件工程方法论管理 AI 实验一、你的实验记录还躺在聊天记录和终端历史里吗打开终端按 50 次上箭头——你能找到上周三那次跑出最佳结果的训练命令吗学习率是 3e-4 还是 5e-4那轮用了几个 GPUbatch size 是 32 还是 64炼丹这个词精准概括了 AI 工程师的工作状态半科学、半玄学、实验结果时好时坏。但问题不在于玄学而在于实验过程没有被当作软件工程产物来管理。软件工程有版本控制、CI/CD、代码审查、需求跟踪。AI 实验管理凭什么不能有见证奇迹的时刻是当你发现炼丹和软件工程可以用同一套方法论管理。二、AI 实验的数据模型与生命周期一个 AI 实验不是一个训练 run这么简单。它的数据模型至少包含五个维度erDiagram EXPERIMENT { string id PK string hypothesis 实验假设 string status planned/running/done/failed datetime created_at } CONFIG { string id PK string experiment_id FK float learning_rate int batch_size string optimizer string model_arch } RUN { string id PK string experiment_id FK int step_count float best_loss string checkpoint_path } METRIC { string id PK string run_id FK int step float train_loss float val_loss } EXPERIMENT ||--o{ CONFIG : has EXPERIMENT ||--o{ RUN : runs RUN ||--o{ METRIC : records实验的生命周期假设阶段→配置阶段→执行阶段→分析阶段→归档阶段。三、实验追踪系统的工程实现import json import uuid import hashlib from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Any from dataclasses import dataclass, field, asdict from enum import Enum class ExperimentStatus(Enum): 实验状态枚举 设计理念引入比成功/失败更丰富的状态语义。 INTERRUPTED 表示手动停止可能是有价值的中间结果 SUSPICIOUS 表示训练完成但怀疑数据有误。 不同状态在统计最佳配置时有不同的权重 PLANNED planned # 已规划尚未执行 RUNNING running # 训练中 COMPLETED completed # 正常完成 FAILED failed # 运行时错误OOM/代码bug INTERRUPTED interrupted # 被手动中断 SUSPICIOUS suspicious # 结果看起来有问题需要人工审查 dataclass class HyperParams: 超参数配置 作为独立数据类而非嵌套字典的原因是 IDE 可以提供自动补全和类型检查避免lr和learning_rate混用 导致的运行时 bug learning_rate: float 1e-3 batch_size: int 32 optimizer: str Adam epochs: int 100 warmup_steps: int 0 weight_decay: float 0.01 model_arch: str extra: Dict[str, Any] field(default_factorydict) # 扩展槽位 dataclass class ExperimentRun: 单次实验运行的完整记录 experiment_id: str run_id: str field(default_factorylambda: str(uuid.uuid4())[:8]) status: ExperimentStatus ExperimentStatus.PLANNED hypothesis: str # 实验假设 hyperparams: HyperParams field(default_factoryHyperParams) metrics: List[Dict[str, Any]] field(default_factorylist) started_at: Optional[str] None ended_at: Optional[str] None notes: str # 自由格式备注 git_commit: str # 关联的代码版本 class ExperimentTracker: 实验追踪器 核心设计决策JSON 文件存储而非数据库。 对于单机/小团队场景数据库引入了运维成本而收益有限。 JSON 文件可以被 grep、被版本控制、被任意脚本处理。 当实验规模超过 1000 个/天时再考虑迁移到数据库 def __init__(self, store_dir: str ./experiments): self.store_dir Path(store_dir) self.store_dir.mkdir(parentsTrue, exist_okTrue) self._index_path self.store_dir / index.json self._index: Dict[str, Dict] self._load_index() def _load_index(self) - Dict: 加载实验索引用于快速查找 if self._index_path.exists(): return json.loads(self._index_path.read_text()) return {} def _save_index(self): 持久化索引到磁盘 self._index_path.write_text( json.dumps(self._index, indent2, ensure_asciiFalse) ) def create_experiment(self, hypothesis: str, **hyperparams) - str: 创建新实验 为什么 experiment_id 使用 UUID 而非自增数字 分布式场景下多台机器同时创建实验时自增 ID 会冲突。 UUID 的唯一性不需要中心化协调 exp_id str(uuid.uuid4())[:12] run ExperimentRun( experiment_idexp_id, hypothesishypothesis, hyperparamsHyperParams(**{ k: v for k, v in hyperparams.items() if k in HyperParams.__dataclass_fields__ }), statusExperimentStatus.PLANNED, ) # 捕获当前 git commit确保实验与代码版本关联 try: import subprocess run.git_commit subprocess.check_output( [git, rev-parse, --short, HEAD], textTrue ).strip() except Exception: pass self._save_run(exp_id, run) self._index[exp_id] { hypothesis: hypothesis, status: run.status.value, created_at: datetime.now().isoformat(), } self._save_index() return exp_id def log_metrics(self, experiment_id: str, step: int, metrics: Dict[str, float]): 记录训练指标 追加模式而非覆盖模式指标列表随时间增长。 这对磁盘有影响每个 step 约 200 bytes 但对 1000 epochs × 10 实验的场景约 2MB完全可以接受 run self._load_run(experiment_id) if run is None: raise ValueError(f实验 {experiment_id} 不存在) entry {step: step, timestamp: datetime.now().isoformat()} entry.update(metrics) run.metrics.append(entry) run.status ExperimentStatus.RUNNING if run.started_at is None: run.started_at datetime.now().isoformat() self._save_run(experiment_id, run) def complete_experiment(self, experiment_id: str, status: ExperimentStatus ExperimentStatus.COMPLETED): 标记实验完成 run self._load_run(experiment_id) if run: run.status status run.ended_at datetime.now().isoformat() self._save_run(experiment_id, run) # 更新索引 if experiment_id in self._index: self._index[experiment_id][status] status.value self._save_index() def compare(self, exp_id_a: str, exp_id_b: str) - Dict: 对比两个实验的配置和结果差异 run_a self._load_run(exp_id_a) run_b self._load_run(exp_id_b) if not run_a or not run_b: return {error: experiment not found} # 对比最终指标 final_metrics_a run_a.metrics[-1] if run_a.metrics else {} final_metrics_b run_b.metrics[-1] if run_b.metrics else {} comparison {} for key in set(list(final_metrics_a.keys()) list(final_metrics_b.keys())): if key in (step, timestamp): continue a_val final_metrics_a.get(key, None) b_val final_metrics_b.get(key, None) if a_val is not None and b_val is not None: comparison[key] { a: a_val, b: b_val, delta: round(b_val - a_val, 6), better: B if b_val a_val else A, } return { config_a: asdict(run_a.hyperparams) if run_a else None, config_b: asdict(run_b.hyperparams) if run_b else None, metrics_comparison: comparison, } def _run_path(self, experiment_id: str) - Path: return self.store_dir / f{experiment_id}.json def _save_run(self, experiment_id: str, run: ExperimentRun): 保存实验到 JSON 文件 data asdict(run) data[status] run.status.value self._run_path(experiment_id).write_text( json.dumps(data, indent2, ensure_asciiFalse) ) def _load_run(self, experiment_id: str) - Optional[ExperimentRun]: 从 JSON 文件加载实验 path self._run_path(experiment_id) if not path.exists(): return None data json.loads(path.read_text()) data[status] ExperimentStatus(data[status]) data[hyperparams] HyperParams(**data[hyperparams]) return ExperimentRun(**data) # # 使用示例 # if __name__ __main__: tracker ExperimentTracker() exp_1 tracker.create_experiment( hypothesis提高 batch_size 到 128 应该能加速收敛, learning_rate1e-3, batch_size128, optimizerAdamW, ) # 模拟训练过程中记录指标 for step in range(10): tracker.log_metrics(exp_1, step, { train_loss: 2.5 - step * 0.2, val_accuracy: 0.1 step * 0.05, }) tracker.complete_experiment(exp_1) print(f实验 {exp_1} 完成) ## 四、实验管理的架构权衡与工程选型 **专用平台 vs 自建工具**。 MLflow、Weights Biases 是成熟的实验管理平台但它们都有锁定风险——实验数据格式一旦被平台绑定迁移成本很高。建议的策略是两层架构底层用通用数据格式JSON存储实验元数据上层可挂接任意平台做可视化。 **实验状态的语义细化**。 真实训练中会出现介于成功和失败之间的状态被手动中断的、OOM 后恢复了但 loss 不正常的、跑完了但怀疑数据有问题的。建议引入 INTERRUPTED、SUSPICIOUS、NEEDS_REVIEW 等中间态标签。一个被标为 SUSPICIOUS 的实验不应该自动参与哪个配置效果最好的统计。 **协作冲突的解决**。 多人同时修改实验参数是典型冲突场景。解决方案是**配置即代码Config as Code**所有实验参数必须定义在版本控制追踪的配置文件中不允许在启动命令中通过 argparse 覆盖关键参数。见证奇迹的时刻是你发现 git blame 可以告诉你谁在上周三把 learning rate 改成了 0.01。 ## 五、总结 AI 实验管理应该用软件工程的方法论来组织。三个核心实践 1. **每个实验必须绑定假设**记录为什么做比用了什么参数更重要——因为三个月后你只记得动机不记得配置。 2. **实验状态必须细化**三个终态不够需要中间态来区分真的失败和可能有问题。 3. **配置必须入版本控制**一行命令行参数不是可复现的实验记录一个 git 提交的 YAML 配置文件才是。