
1. 从零搭建AI工程能力为什么“会调包”远远不够很多人第一次接触AI工程是从一行model.fit()或者一个pip install transformers开始的。跑通一个Demo看着损失曲线往下掉准确率往上走就觉得自己“入门”了。但真正到了要把模型塞进一个每天要处理几十万请求的生产系统里才发现事情完全不是那么回事——显存炸了、推理延迟飙到秒级、模型版本对不上、数据管道三天两头断流。这些问题没有一个能靠调包解决。ai-engineering-from-scratch这个标题核心不在“AI”而在“engineering”和“from scratch”。它指向的是一类非常具体的需求不依赖现成的高级封装从底层把AI系统的每一个环节亲手搭一遍从而真正理解一个AI系统是怎么运转起来的。这跟“从零实现一个神经网络”还不太一样后者偏算法原理前者偏工程落地。你要关心的不只是反向传播怎么算更是数据怎么流、模型怎么存、服务怎么起、监控怎么做。这篇文章适合谁看如果你已经会用PyTorch或TensorFlow训练模型但一提到部署、服务化、性能优化就心里没底那这篇内容就是为你准备的。如果你是个后端工程师想转AI方向但被各种框架的“魔法”搞得云里雾里那也合适。甚至你是个学生课程项目跑通了但不知道工业界到底怎么干活同样能从这里找到答案。我会按照一个AI工程系统从无到有的真实搭建顺序把每个环节的“为什么”和“怎么做”都讲清楚中间穿插我自己踩过的坑和总结出来的经验。需要提前说明的是AI工程是一个非常大的领域一篇文章不可能覆盖所有细节。我会聚焦在最核心、最容易出问题、也最能体现工程思维的几个环节数据管道的构建、模型训练的可复现性、推理服务的性能优化、以及整个系统的可观测性。每个环节我都会给出可操作的方案和代码示例同时解释为什么这样选、不那样选。2. 数据管道AI工程里最容易被低估的“脏活累活”2.1 为什么数据管道值得你花50%的时间我见过太多项目模型结构调了又调超参搜了一轮又一轮最后发现瓶颈根本不在模型而在数据。训练时数据加载成了瓶颈GPU利用率常年不到30%线上推理时特征预处理逻辑和训练时不一致导致效果直接崩掉。这些问题归根结底都是数据管道没做好。数据管道在AI工程里的角色相当于一个城市的供水系统。模型是水龙头用户看到的是水龙头出水但真正决定水质和水压的是背后那套从水源到管网的复杂系统。一个健壮的数据管道需要解决几个核心问题数据从哪里来、怎么清洗和转换、怎么高效地喂给训练、怎么保证训练和推理的一致性。从零搭建的话我建议不要一上来就上Spark或者Flink这种重型武器。除非你的数据量真的到了TB级别否则用Python的multiprocessing加上合理的数据格式就能撑起相当规模的生产系统。关键是要把管道的每个阶段解耦让它们可以独立测试和替换。2.2 用Dataset和DataLoader构建可复用的数据层PyTorch的Dataset和DataLoader是两个非常值得深入理解的抽象。很多人只是照着模板写一个__getitem__就完事了但其实这里面有很多工程细节。import torch from torch.utils.data import Dataset, DataLoader import numpy as np class ProductionDataset(Dataset): def __init__(self, data_paths, transformNone, cache_size1000): self.data_paths data_paths self.transform transform self.cache {} self.cache_size cache_size def __len__(self): return len(self.data_paths) def __getitem__(self, idx): if idx in self.cache: return self.cache[idx] # 从磁盘加载单条数据 raw np.load(self.data_paths[idx]) sample self.transform(raw) if self.transform else raw # 简单的LRU缓存 if len(self.cache) self.cache_size: self.cache.pop(next(iter(self.cache))) self.cache[idx] sample return sample这段代码看起来简单但有几个工程上的考量。第一缓存机制。如果你的数据在磁盘上每次__getitem__都去读文件IO会成为瓶颈。加一个简单的内存缓存能显著提升吞吐。第二transform的幂等性。数据增强类的transform每次调用应该产生不同的结果但预处理类的transform必须保证幂等否则训练和推理会对不上。第三错误处理。生产环境里数据损坏是常态__getitem__里应该加try-except记录坏样本并返回一个安全的默认值而不是让整个训练崩掉。DataLoader这边num_workers的设置是个经验活。设得太小数据加载跟不上GPU设得太大CPU上下文切换开销反而拖慢速度。我的经验是从num_workers4开始试观察GPU利用率和CPU负载逐步调整。另外pin_memoryTrue在GPU训练时几乎总是应该开的它能加速CPU到GPU的数据传输。2.3 训练与推理的一致性特征工程的双刃剑这是AI工程里最经典的坑之一训练时用pandas做特征推理时用numpy做特征结果因为浮点数精度或者缺失值处理方式不同导致线上线下效果不一致。解决这个问题的唯一办法是把特征处理逻辑封装成独立的、可复用的模块训练和推理都调用同一份代码。class FeaturePipeline: def __init__(self, config): self.config config self.scaler None self.encoders {} def fit(self, df): # 拟合阶段只在训练数据上调用 self.scaler StandardScaler().fit(df[self.config.numeric_cols]) for col in self.config.categorical_cols: self.encoders[col] LabelEncoder().fit(df[col]) return self def transform(self, df): # 转换阶段训练和推理都调用 df df.copy() df[self.config.numeric_cols] self.scaler.transform(df[self.config.numeric_cols]) for col in self.config.categorical_cols: # 处理未见过的类别 df[col] df[col].map( lambda x: self.encoders[col].transform([x])[0] if x in self.encoders[col].classes_ else -1 ) return df def save(self, path): # 持久化推理服务加载同一份 joblib.dump({scaler: self.scaler, encoders: self.encoders}, path)这个FeaturePipeline的关键在于fit和transform的分离以及save方法。训练完成后把pipeline保存下来推理服务启动时加载同一个文件。这样就能从根源上杜绝特征不一致的问题。我强烈建议把这个pipeline的版本号和模型版本号绑定每次模型更新pipeline也跟着更新避免版本错配。注意分类特征编码时一定要处理“未见过的类别”。线上总会遇到训练集里没出现过的值如果不处理轻则报错重则静默产生错误的编码导致预测结果完全不可信。3. 模型训练的可复现性让每一次实验都有迹可循3.1 随机种子只是起点不是终点“设置随机种子就能复现”是一个流传很广的误解。在PyTorch里即使你设置了torch.manual_seed(42)如果用了CUDA、用了多线程的DataLoader、用了某些非确定性算子结果依然可能不一样。真正的可复现性需要一套组合拳。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 以下两行会降低性能但能保证确定性 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False # DataLoader的worker种子 def seed_worker(worker_id): worker_seed seed worker_id np.random.seed(worker_seed) random.seed(worker_seed) return seed_workercudnn.deterministic True会让cuDNN只使用确定性的算法代价是可能慢一些。benchmark False则是关闭自动调优因为自动调优会选择不同的算法导致结果不可复现。这两个设置在生产训练脚本里我建议都打开除非你确实需要那一点性能提升并且能接受结果波动。DataLoader的worker种子也经常被忽略。每个worker进程有自己的随机状态如果不单独设置数据增强的结果每次都会不同。上面的seed_worker函数配合DataLoader(worker_init_fnseed_worker)使用能解决这个问题。3.2 实验管理别再用文件夹名当版本号了我见过太多人用experiment_1、experiment_2_final、experiment_2_final_really这样的文件夹来管理实验。这种方式在实验数量超过20个之后就会彻底失控。你需要一个结构化的实验管理系统。最轻量的方案是用MLflow或者Weights Biases但如果想从零搭建核心需要记录这几样东西代码版本git commit hash、超参数配置、训练指标曲线、模型权重、以及环境依赖。import json import hashlib import subprocess from pathlib import Path from datetime import datetime class ExperimentTracker: def __init__(self, base_direxperiments): self.base_dir Path(base_dir) self.exp_id datetime.now().strftime(%Y%m%d_%H%M%S) self.exp_dir self.base_dir / self.exp_id self.exp_dir.mkdir(parentsTrue, exist_okTrue) self.metrics [] def log_config(self, config): # 记录git commit try: git_hash subprocess.check_output( [git, rev-parse, HEAD] ).decode().strip() except Exception: git_hash unknown config[git_hash] git_hash config[timestamp] self.exp_id with open(self.exp_dir / config.json, w) as f: json.dump(config, f, indent2) def log_metrics(self, step, metrics_dict): self.metrics.append({step: step, **metrics_dict}) with open(self.exp_dir / metrics.json, w) as f: json.dump(self.metrics, f) def log_model(self, model, optimizerNone): torch.save({ model_state: model.state_dict(), optimizer_state: optimizer.state_dict() if optimizer else None, }, self.exp_dir / checkpoint.pt)这个ExperimentTracker虽然简单但已经覆盖了实验管理的核心需求。git_hash让你能精确知道是哪份代码跑出来的结果config.json记录了所有超参数metrics.json是训练曲线checkpoint.pt是模型权重。有了这些任何一个实验结果都可以被完整复现。3.3 配置管理把超参数从代码里赶出去超参数散落在代码各处是另一个常见的工程债。今天改个学习率明天改个batch size改完就忘了改过什么。正确的做法是用配置文件来管理所有超参数代码只负责读取配置。from dataclasses import dataclass, field from typing import List import yaml dataclass class TrainConfig: learning_rate: float 1e-3 batch_size: int 32 epochs: int 10 hidden_dims: List[int] field(default_factorylambda: [256, 128]) dropout: float 0.1 seed: int 42 device: str cuda classmethod def from_yaml(cls, path): with open(path) as f: data yaml.safe_load(f) return cls(**data) def to_dict(self): return {k: v for k, v in self.__dict__.items()}用dataclass的好处是类型明确、有默认值、容易序列化。配合YAML配置文件你可以为不同的实验准备不同的配置文件比如configs/baseline.yaml、configs/large_model.yaml切换实验只需要换一个文件路径。这比在代码里改来改去要清晰得多也更容易做版本控制。提示配置文件里不要放敏感信息比如数据库密码、API密钥。这些应该通过环境变量注入配置文件只放模型和训练相关的参数。4. 推理服务从模型文件到高可用API4.1 为什么Flask不够用以及什么时候该上TorchServe很多教程教你用Flask写一个/predict接口把模型加载到全局变量里然后app.run()。这在Demo阶段没问题但生产环境会遇到几个硬伤并发能力差、没有批处理、没有模型版本管理、没有健康检查。Flask默认是单线程的虽然可以开多线程但GIL限制了CPU密集型任务的并发。对于深度学习推理来说单次推理可能耗时几十到几百毫秒如果每个请求都单独跑一次模型GPU利用率会非常低。理想的方式是动态批处理把短时间内到达的多个请求合并成一个batch一次性送进GPU这样能大幅提升吞吐。从零搭建一个支持动态批处理的推理服务核心思路是用一个队列加一个后台批处理线程import threading import queue import torch import time class BatchInferenceServer: def __init__(self, model, max_batch_size32, max_wait_ms10): self.model model self.model.eval() self.max_batch_size max_batch_size self.max_wait_ms max_wait_ms self.request_queue queue.Queue() self.result_dict {} self.lock threading.Lock() self._start_worker() def _start_worker(self): thread threading.Thread(targetself._batch_worker, daemonTrue) thread.start() def _batch_worker(self): while True: batch [] request_ids [] deadline time.time() self.max_wait_ms / 1000 # 收集请求直到达到max_batch_size或超时 while len(batch) self.max_batch_size and time.time() deadline: try: timeout max(0, deadline - time.time()) req_id, data self.request_queue.get(timeouttimeout) batch.append(data) request_ids.append(req_id) except queue.Empty: break if not batch: continue # 批处理推理 with torch.no_grad(): batch_tensor torch.stack(batch) outputs self.model(batch_tensor) # 分发结果 with self.lock: for req_id, output in zip(request_ids, outputs): self.result_dict[req_id] output.cpu().numpy() def predict(self, data, timeout5.0): req_id id(data) self.request_queue.put((req_id, data)) start time.time() while time.time() - start timeout: with self.lock: if req_id in self.result_dict: return self.result_dict.pop(req_id) time.sleep(0.001) raise TimeoutError(Inference timeout)这个实现虽然简化了很多但核心思想是完整的请求进队列后台线程按batch取出批量推理后把结果放回字典请求线程轮询等待。max_wait_ms控制延迟上限max_batch_size控制吞吐上限两者需要根据实际业务场景调优。如果业务对延迟敏感就把max_wait_ms调小如果追求吞吐就调大。4.2 模型版本管理与灰度发布生产环境的模型不是一成不变的。今天上线v1明天可能要上v2后天可能发现v2有问题要回滚到v1。如果没有版本管理这个过程会非常痛苦。一个简单的方案是用文件系统做版本管理每个版本一个目录包含模型权重和配置文件。推理服务启动时读取一个current软链接指向当前生效的版本。更新时先上传新版本到新目录然后原子性地切换软链接。import os from pathlib import Path class ModelRegistry: def __init__(self, base_dirmodel_registry): self.base_dir Path(base_dir) self.base_dir.mkdir(exist_okTrue) def register(self, version, model, config): version_dir self.base_dir / version version_dir.mkdir(exist_okTrue) torch.save(model.state_dict(), version_dir / model.pt) with open(version_dir / config.json, w) as f: json.dump(config, f) return version_dir def activate(self, version): version_dir self.base_dir / version if not version_dir.exists(): raise ValueError(fVersion {version} not found) current_link self.base_dir / current if current_link.exists() or current_link.is_symlink(): current_link.unlink() current_link.symlink_to(version_dir) def get_current(self): current_link self.base_dir / current if not current_link.exists(): raise RuntimeError(No active model version) return current_link.resolve()activate方法里的unlink加symlink_to是一个原子操作在大多数文件系统上能保证切换过程中不会出现读到半个模型的情况。灰度发布可以在此基础上做让推理服务同时加载两个版本按请求比例分流观察新版本的指标后再全量切换。4.3 推理性能优化的几个实用手段除了动态批处理还有几个手段能显著提升推理性能。量化是其中之一把FP32的权重转成INT8模型体积缩小4倍推理速度提升2-3倍精度损失通常在1%以内。PyTorch提供了torch.quantization模块动态量化对LSTM和Linear层效果很好。# 动态量化示例 quantized_model torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtypetorch.qint8 )ONNX Runtime是另一个利器。把PyTorch模型导出成ONNX格式然后用ONNX Runtime推理通常能比原生PyTorch快20%-50%尤其是在CPU上。导出时注意opset版本要匹配输入输出的动态维度要设置正确。torch.onnx.export( model, dummy_input, model.onnx, opset_version13, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}} )TensorRT则是NVIDIA GPU上的终极方案能把推理速度再提升一个档次但配置复杂度也最高而且和特定GPU架构绑定。我的建议是先用动态批处理把GPU利用率提上去如果还不够再考虑量化和ONNX Runtime最后才上TensorRT。注意量化后的模型精度一定要在验证集上重新评估。有些模型对量化很敏感尤其是那些权重分布不均匀的模型量化后精度可能掉得厉害。如果掉点超过可接受范围可以考虑只量化部分层或者用量化感知训练来恢复精度。5. 可观测性让系统在出问题时能自己“说话”5.1 日志、指标、追踪三根支柱缺一不可AI系统的可观测性和传统后端系统既有重叠也有特殊之处。重叠的是日志、指标、追踪这三大支柱特殊的是AI系统还需要监控数据分布漂移和模型预测分布。日志方面我建议用结构化的JSON日志而不是纯文本。这样方便后续用ELK或者Loki做检索和分析。每条推理日志至少包含请求ID、输入摘要比如输入的shape和统计量、输出摘要、推理耗时、模型版本。import logging import json import time class StructuredLogger: def __init__(self, name): self.logger logging.getLogger(name) handler logging.StreamHandler() handler.setFormatter(logging.Formatter(%(message)s)) self.logger.addHandler(handler) self.logger.setLevel(logging.INFO) def log_inference(self, request_id, input_tensor, output, latency_ms, model_version): record { event: inference, request_id: request_id, input_shape: list(input_tensor.shape), input_mean: float(input_tensor.mean()), input_std: float(input_tensor.std()), output_shape: list(output.shape), output_mean: float(output.mean()), latency_ms: latency_ms, model_version: model_version, timestamp: time.time() } self.logger.info(json.dumps(record))指标方面除了常规的QPS、延迟、错误率AI系统要特别关注输入数据的统计量。如果线上输入的均值突然偏移了训练分布的均值好几个标准差那大概率是上游数据出了问题模型输出不可信。这个监控能在模型效果崩掉之前就发出预警。5.2 数据漂移检测在模型失效之前发现问题数据漂移检测的核心是比较线上输入分布和训练集分布。最简单的方法是计算PSIPopulation Stability Index对每个特征分桶后计算分布差异。import numpy as np def calculate_psi(expected, actual, buckets10): def scale_range(input_arr, min_val, max_val): input_arr input_arr -(min_val) input_arr input_arr / (max_val - min_val) return input_arr breakpoints np.arange(0, buckets 1) / buckets * 100 breakpoints np.percentile(expected, breakpoints) expected_percents np.histogram(expected, breakpoints)[0] / len(expected) actual_percents np.histogram(actual, breakpoints)[0] / len(actual) # 避免除零 expected_percents np.clip(expected_percents, 1e-6, None) actual_percents np.clip(actual_percents, 1e-6, None) psi_value np.sum( (expected_percents - actual_percents) * np.log(expected_percents / actual_percents) ) return psi_valuePSI小于0.1表示分布稳定0.1到0.25表示有轻微漂移需要关注大于0.25表示显著漂移模型可能需要重新训练。这个计算应该定期比如每小时在最近的线上数据上跑一次结果推送到监控面板。5.3 从告警到自动恢复把人工干预降到最低监控的目的是发现问题但发现问题之后如果每次都要人工介入那系统的可维护性还是很差。理想情况下常见的故障应该能自动恢复。比如当检测到某个模型版本的错误率超过阈值时自动回滚到上一个稳定版本。当检测到GPU显存不足时自动降低批处理大小。当检测到数据漂移超过阈值时自动触发重新训练流程。class AutoRecovery: def __init__(self, registry, monitor, threshold0.05): self.registry registry self.monitor monitor self.threshold threshold self.error_history [] def check_and_recover(self): error_rate self.monitor.get_error_rate(window5m) self.error_history.append(error_rate) if error_rate self.threshold: # 连续3个窗口都超阈值才触发回滚 if len(self.error_history) 3 and all( e self.threshold for e in self.error_history[-3:] ): current self.registry.get_current() previous self._find_previous_version(current) if previous: self.registry.activate(previous) self.monitor.alert( fRolled back from {current} to {previous} fdue to error rate {error_rate} ) self.error_history.clear() def _find_previous_version(self, current): versions sorted([ d.name for d in self.registry.base_dir.iterdir() if d.is_dir() and d.name ! current ]) try: idx versions.index(current.name) return versions[idx - 1] if idx 0 else None except ValueError: return None这个AutoRecovery的逻辑是连续3个5分钟窗口的错误率都超过阈值才触发回滚。加这个“连续3次”的条件是为了避免因为偶发波动而误回滚。回滚之后清空历史给新版本一个干净的观察窗口。提示自动回滚虽然方便但一定要有完善的日志和告警。每次自动回滚都应该通知到相关负责人否则问题可能被掩盖永远得不到根治。6. 一些让我少走弯路的工程习惯6.1 本地能跑通不代表线上能跑通我踩过最大的坑之一是本地开发环境用Python 3.8加PyTorch 1.9线上环境用Python 3.9加PyTorch 1.12结果某些算子的行为有细微差异导致线上效果比本地差了一截。从那以后我坚持用Docker来统一开发和生产环境。Dockerfile里明确锁定所有依赖的版本包括CUDA和cuDNN的版本。FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 RUN apt-get update apt-get install -y python3.10 python3-pip COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # requirements.txt里所有包都锁定版本 # torch2.0.1 # numpy1.24.3 # ...requirements.txt里不要用或者不写版本号全部用锁定。虽然这样更新依赖麻烦一点但能避免“昨天还好好的今天突然挂了”这种问题。6.2 单元测试要覆盖数据管道和特征工程模型本身的单元测试很难写因为输出是概率性的。但数据管道和特征工程的测试非常好写而且收益极高。每个transform函数都应该有对应的测试验证输入输出shape、数值范围、边界情况。def test_feature_pipeline_unseen_category(): pipeline FeaturePipeline(config) train_df pd.DataFrame({cat: [a, b, c], num: [1.0, 2.0, 3.0]}) pipeline.fit(train_df) # 测试未见过的类别 test_df pd.DataFrame({cat: [d], num: [4.0]}) result pipeline.transform(test_df) assert result[cat].iloc[0] -1 # 应该被编码为-1 assert not result.isnull().any().any() # 不应该有NaN这个测试验证了未见类别被正确处理为-1且没有产生NaN。这类测试能在早期发现很多潜在问题比等到线上出故障再排查要划算得多。6.3 性能优化之前先测量“过早优化是万恶之源”这句话在AI工程里尤其正确。我见过有人花了一周时间优化数据加载结果发现瓶颈其实在模型的前向传播。正确的做法是先做profiling找到真正的瓶颈再动手。PyTorch自带的profiler就很好用with torch.profiler.profile( activities[ torch.profiler.ProfilerActivity.CPU, torch.profiler.ProfilerActivity.CUDA, ], scheduletorch.profiler.schedule(wait1, warmup1, active3), on_trace_readytorch.profiler.tensorboard_trace_handler(./log) ) as prof: for step, batch in enumerate(dataloader): if step 5: break train_step(batch) prof.step()跑完之后在TensorBoard里看trace哪个算子耗时最长一目了然。是数据加载慢还是某个卷积层慢还是CPU到GPU的拷贝慢清清楚楚。根据profile结果来优化才能把精力花在刀刃上。6.4 文档和注释要写“为什么”而不是“是什么”代码里的注释应该解释“为什么这样做”而不是“这行代码在做什么”。比如# 不好把输入转成float32 x x.float() # 好模型权重是float32输入如果是float64会导致类型不匹配报错 x x.float()“是什么”从代码本身就能看出来“为什么”才是需要注释的。同样README里应该写清楚这个模块的设计决策、已知限制、以及踩过的坑而不是简单地罗列API。7. 从Demo到生产一个真实的演进路径如果你现在手里有一个能跑通的Demo想把它变成生产系统我建议按这个顺序推进第一阶段可复现。把随机种子固定住把超参数抽到配置文件把实验记录结构化。这个阶段的目标是“任何人拿到你的代码和配置都能跑出一样的结果”。第二阶段可测试。给数据管道和特征工程写单元测试给推理服务写集成测试。这个阶段的目标是“改代码之后能快速知道有没有破坏现有功能”。第三阶段可观测。加上结构化日志、关键指标监控、数据漂移检测。这个阶段的目标是“系统出问题时能快速定位原因”。第四阶段可扩展。引入动态批处理、模型版本管理、自动回滚。这个阶段的目标是“系统能承受流量增长且运维成本不随规模线性增长”。每个阶段都有明确的验收标准不要跳级。我见过太多团队在可复现性都没做好的情况下就急着上自动扩缩容结果出了问题连是哪个版本、哪份配置导致的都查不出来。AI工程和传统软件工程最大的区别在于它的“正确性”是概率性的、数据依赖的、且会随时间漂移的。这意味着你不能只关注代码逻辑还要关注数据质量、分布变化、以及模型退化。ai-engineering-from-scratch这个方向的价值就在于它强迫你直面这些复杂性而不是躲在框架的抽象后面。当你亲手搭过一遍数据管道、处理过模型版本冲突、被数据漂移坑过之后你对AI系统的理解会完全不一样。