代码库设计与模式 好的代码库设计,是把研究原型和生产级软件区分开来的东西。本文件覆盖项目结构、整洁代码原则、与 ML 相关的设计模式、配置管理、日志、API 设计,以及打包。 大多数 ML 代码都起步于一个 Jupyter notebook。这个 notebook 长大、被复制、被修改、被分享,最后变成一团由全局变量、死单元格和魔法数字组成的、无法维护的乱麻。代码库设计(codebase design)就是组织代码的纪律,让项目在长大的过程中依然能被理解、能被修改。 这不是为了守规矩而守规矩。它是为了缩短"我想改 X"和"X 已经改好并跑起来"之间的时间。在一个设计良好的代码库里,这段时间是几分钟。在一个设计糟糕的代码库里,是几天几夜在无文档的意大利面条代码里考古。
好的代码库设计,是把研究原型和生产级软件区分开来的东西。本文件覆盖项目结构、整洁代码原则、与 ML 相关的设计模式、配置管理、日志、API 设计,以及打包。
大多数 ML 代码都起步于一个 Jupyter notebook。这个 notebook 长大、被复制、被修改、被分享,最后变成一团由全局变量、死单元格和魔法数字组成的、无法维护的乱麻。代码库设计(codebase design)就是组织代码的纪律,让项目在长大的过程中依然能被理解、能被修改。
这不是为了守规矩而守规矩。它是为了缩短"我想改 X"和"X 已经改好并跑起来"之间的时间。在一个设计良好的代码库里,这段时间是几分钟。在一个设计糟糕的代码库里,是几天几夜在无文档的意大利面条代码里考古。
my_project/ ├── src/my_project/ # 源代码(可导入的包) │ ├── __init__.py │ ├── data/ # 数据加载与预处理 │ │ ├── __init__.py │ │ ├── dataset.py │ │ └── transforms.py │ ├── models/ # 模型架构 │ │ ├── __init__.py │ │ ├── transformer.py │ │ └── layers.py │ ├── training/ # 训练循环、优化器 │ │ ├── __init__.py │ │ ├── trainer.py │ │ └── losses.py │ └── utils/ # 共享工具 │ ├── __init__.py │ └── logging.py ├── configs/ # 配置文件 │ ├── base.yaml │ └── experiment_1.yaml ├── scripts/ # 入口(训练、评估、服务) │ ├── train.py │ ├── evaluate.py │ └── serve.py ├── tests/ # 测试文件(与 src/ 结构对应) │ ├── test_dataset.py │ ├── test_model.py │ └── test_trainer.py ├── notebooks/ # 仅用于探索(不是生产代码) ├── pyproject.toml # 项目元数据和依赖 ├── README.md ├── .gitignore └── Dockerfile
src/ 布局:把源代码放在 src/my_project/ 下,可以防止你从当前目录意外导入(这种意外导入会掩盖那些本该在生产中暴露的导入错误)。开发时用 pip install -e . 安装。
单仓(monorepo)vs 多仓(multi-repo):单仓把所有相关项目放在一个仓库里(跨项目改动更容易、共享 CI)。多仓给每个项目一个独立仓库(边界更清晰、版本独立)。大多数 ML 团队从单仓起步,需要时再拆分。
脚本 vs 库:把入口(train.py、evaluate.py)放在 scripts/。把可复用的逻辑放在 src/。一个训练脚本应该只有约 50 行:解析配置、构建数据集、构建模型、构建训练器、训练。所有的复杂度都住在库里面。
x 的变量需要你读周围的代码才能理解。一个叫 learning_rate 的变量则是自解释的。# 差 def proc(d, n, lr): for i in range(n): for k, v in d.items(): v -= lr * g[k] # 好 def update_parameters(parameters, num_steps, learning_rate): for step in range(num_steps): for name, param in parameters.items(): param -= learning_rate * gradients[name]
单一职责原则(Single Responsibility Principle):每个函数/类只做一件事。一个叫 load_data_and_train_model 的函数做了两件事,应该拆开。这让每一块都能被独立测试、复用、理解。
DRY(Don't Repeat Yourself,不要重复自己)——但不要过早。如果你把同一段代码复制粘贴了三次,就把它抽成一个函数。但不要为你只用过一次的代码创建抽象。过早的抽象比重复更糟:它在没有验证收益的情况下增加了复杂度。
# 过早抽象(只有一个用例,过度设计) class AbstractDataTransformPipelineFactory: ... # 刚刚好(直接、清晰,在三个地方用到) def normalise_image(image, mean, std): return (image - mean) / std
# 差 if len(batch) > 32: split_batch(batch, 32) # 好 MAX_BATCH_SIZE = 32 if len(batch) > MAX_BATCH_SIZE: split_batch(batch, MAX_BATCH_SIZE)
设计模式是针对常见问题的可复用解决方案。下面这些是和 ML 代码库最相关的:
工厂模式(factory pattern):在不指定确切类的情况下创建对象。当你的配置写着 model: "transformer" 而你需要实例化正确的类时很有用:
MODEL_REGISTRY = { "transformer": TransformerModel, "cnn": CNNModel, "mlp": MLPModel, } def build_model(config): model_cls = MODEL_REGISTRY[config["model"]] return model_cls(**config["model_params"])
这把训练脚本和具体的模型实现解耦。新增一个模型意味着在注册表里加一行,而不是去改训练循环。
策略模式(strategy pattern):在运行时切换算法。对损失函数、优化器、调度器很有用:
LOSS_FUNCTIONS = { "mse": nn.MSELoss, "cross_entropy": nn.CrossEntropyLoss, "focal": FocalLoss, } loss_fn = LOSS_FUNCTIONS[config["loss"]]()
class EarlyStopping: def __init__(self, patience=5): self.patience = patience self.best_loss = float('inf') self.counter = 0 def on_epoch_end(self, epoch, val_loss): if val_loss < self.best_loss: self.best_loss = val_loss self.counter = 0 else: self.counter += 1 if self.counter >= self.patience: return "stop"
# 差:硬编码的依赖 class Trainer: def __init__(self): self.logger = WandbLogger() # 没有 W&B 就没法测试 # 好:注入的依赖 class Trainer: def __init__(self, logger): self.logger = logger # 可以注入任何 logger,包括 mock
把超参数、文件路径和模型设置硬编码进代码,会让实验无法复现,也让修改变得痛苦。把配置外置到文件里。
YAML 是 ML 配置最常见的格式:
# configs/experiment_1.yaml model: name: transformer d_model: 512 n_heads: 8 n_layers: 6 training: batch_size: 64 learning_rate: 3e-4 max_epochs: 100 early_stopping_patience: 10 data: train_path: /data/train.parquet val_path: /data/val.parquet max_seq_length: 512
Hydra(Facebook 出品)是一个配置框架,支持组合(把基础配置和实验特定的覆盖合并)、命令行覆盖(python train.py training.lr=1e-3)以及多次运行(对超参数做 sweep)。
argparse 对只有几个参数的脚本来说更简单:
import argparse parser = argparse.ArgumentParser() parser.add_argument("--lr", type=float, default=3e-4) parser.add_argument("--batch-size", type=int, default=64) parser.add_argument("--config", type=str, default="configs/base.yaml") args = parser.parse_args()
print 是用来调试的。**日志(logging)**才是为生产准备的:import logging logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) logger.debug("Batch loaded: %d samples", len(batch)) # 噪声大,用于调试 logger.info("Epoch %d: loss=%.4f, lr=%.6f", epoch, loss, lr) # 正常运行 logger.warning("GPU memory >90%%, consider reducing batch size") logger.error("Failed to load checkpoint: %s", path) # 可恢复的错误 logger.critical("CUDA out of memory, aborting") # 致命错误
为什么不用 print:日志支持级别(在生产中过滤掉 debug 信息)、格式化(时间戳、模块名)和处理器(写文件、发给监控系统),而且不需要改日志调用代码。
**结构化日志(structured logging)**输出机器可解析的格式(JSON),同时带人类可读的信息。这样就能针对特定字段做搜索和告警:
logger.info("training_step", extra={ "epoch": 5, "step": 1200, "loss": 0.0342, "lr": 2.1e-4 })
如果你的模型要被其他服务使用(一个 Web 应用、一个移动应用、另一条 ML 管道),它就需要一个 API(Application Programming Interface,应用编程接口)。
REST API 使用 HTTP 方法:GET 读取、POST 创建/预测、PUT 更新、DELETE 删除。端点遵循基于资源的命名:
POST /api/v1/predict # 发送输入,得到预测 GET /api/v1/models # 列出可用模型 GET /api/v1/models/{id} # 获取模型详情 POST /api/v1/models/{id}/predict # 用某个特定模型做预测
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str confidence: float @app.post("/predict", response_model=PredictResponse) async def predict(request: PredictRequest): result = model.predict(request.text) return PredictResponse(label=result.label, confidence=result.score)
FastAPI 自动生成 API 文档(在 /docs 的 Swagger UI),用 Pydantic 模型校验输入/输出,并支持 async 以获得高吞吐。
gRPC 在内部服务间通信时比 REST 更快。它使用 Protocol Buffers(二进制序列化,比 JSON 更小更快),并支持流式传输。被 TensorFlow Serving、Triton Inference Server 和许多微服务架构采用。
# pyproject.toml [project] name = "my-ml-project" version = "0.1.0" requires-python = ">=3.10" dependencies = [ "torch>=2.0", "jax>=0.4", "pydantic>=2.0", ] [project.optional-dependencies] dev = ["pytest", "ruff", "mypy"] [build-system] requires = ["setuptools>=64"] build-backend = "setuptools.backends._legacy:_Backend"
pip install -e ".[dev]" # 以可编辑模式安装,并带上开发依赖
可编辑安装(editable install,-e):你对源代码的改动会立刻生效,无需重新安装。开发时不可或缺。
锁定依赖(pinning dependencies):用带精确版本的 requirements.txt(torch==2.2.1,而不是 torch>=2.0)来保证可复现性。用 pip freeze > requirements.txt 来捕获你当前的环境。要更精细的依赖管理,可以使用 uv、poetry 或 pip-tools。
AI 编程助手(Claude Code、GitHub Copilot、Cursor 等)已经是专业工程流程的一部分。用得好,它们能极大地加速开发。用得不好,它们会引入隐蔽的 bug、侵蚀你对自己代码库的理解,并制造一种虚假的生产力感。
正确的心智模型是:助手是一个速度快但没经验的结对程序员。它能很快写出代码,懂语法和标准模式,读过的文档比你一辈子读的都多。但它不理解你这套特定的系统、你的业务约束、你的边界情况,也不理解你设计决策背后的为什么。你是资深工程师,助手是新人。你负责指挥、评审,并承担责任。
样板代码和脚手架:生成 Dockerfile、CI 配置、测试夹具(test fixture)、数据类定义、argparse 设置。这些都遵循成熟的模式,手写很无聊。让助手生成,然后评审正确性。
写测试:描述函数的行为,助手生成测试用例。它经常能抓到你可能漏掉的边界情况(空输入、负值、Unicode)。一定要读生成的测试——它们验证的是你的假设,而不只是你的代码。
重构:"把这块抽成一个函数"、"把这个类改成 dataclass"、"给这个模块加类型注解"。意图清晰、出错风险低的机械化变换。
探索和原型:"写个快速脚本 benchmark 推理延迟"或"给我看看怎么用 HuggingFace 的 tokeniser API"。助手能比读文档更快地给你一个能跑的起点。
文档和 docstring:助手能根据你的代码结构生成文档。要评审准确性,但苦力活被自动化了。
调试辅助:贴一段错误回溯(traceback),问它诊断。助手常常能定位根因并给出修复建议,尤其是对常见问题(形状不匹配、导入错误、CUDA 显存溢出)。
新颖的架构决策:如果你在设计一条新的训练管道,助手会给一个泛泛的答案。它不知道你的数据约束、延迟要求或团队专长。用助手来实现你已经想清楚的设计。
安全关键的代码:认证、加密、输入净化。助手可能写出看起来正确但有隐蔽漏洞的代码(SQL 注入、不安全的默认值、时序攻击)。安全代码应该由理解威胁模型的人写,并由另一个人评审。
性能关键的内层循环:助手会写出正确但天真的代码。对 GPU kernel、内存敏感的数据结构或延迟敏感的服务路径,你需要理解硬件约束(第 13 章、第 16 章),并刻意地做优化。
你不理解的代码:如果助手生成了 200 行,而你没法解释每一行在干什么,那就别提交。你现在在维护你不懂的代码,等它出问题时(一定会出),你没法调试。这是最常见、也最危险的失败模式。
提交前一定要读每一行生成的代码。这不是可选项。助手的代码是草稿,不是成品。把它当作来自同事的拉取请求一样对待:批判性地评审。
要检查什么:
助手产出的质量,和你的指令质量成正比。含糊的提示词得到含糊的代码。
差:"写个数据加载器"
好:"写一个 PyTorch DataLoader,处理一个有 'text' 和 'label' 两列的 CSV 文件。用 HuggingFace 的 'bert-base-uncased' tokeniser 做分词,max_length=512。返回 input_ids、attention_mask 和 label 作为 tensor。处理 CSV 中 label 列缺失的情况——跳过那些行。"
提供上下文:告诉助手你的项目结构、现有代码、约束和约定。上下文越多,产出越好。
指定约束:"只用标准库"、"必须能在 Python 3.10 上跑"、"不要用全局变量"、"遵循 src/models/transformer.py 里现有的模式"。
要求解释:"实现 X 并解释关键的设计决策。"这迫使助手讲清楚它的推理,让你更容易发现错误的假设。
你现有的质量基础设施(第 04 节)抓住助手的错误和抓住人的错误一样有效:
"助手写代码" + "质量门验证它"这个组合,比单独任何一个都更高效。助手快但粗心,质量门彻底但不会写代码。两者结合,你同时得到速度和正确性。
编程助手最大的风险是生产力的错觉。你能在 10 分钟内生成 500 行代码。但如果你因为不理解这 500 行而花 2 小时调试,那你比自己用 30 分钟写 200 行还要慢。
用助手获得真正生产力的方式来自:
从编程助手里获益最多的工程师,正是那些本来就很会写代码的人。助手放大你已有的技能,它不替代技能。理解数据结构、算法、系统设计和软件工程(也就是整章内容),才是让你能有效指挥助手、批判性评估其产出的根基。