ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI工程从零开始:从环境配置到模型部署的完整实战指南

AI工程从零开始:从环境配置到模型部署的完整实战指南 我对“AI工程从零开始”这个选题一直有执念因为市面上太多教程都在教你怎么调库却很少有人讲清楚一套代码从能跑到能用的完整链路。这个项目我反复推倒重来了三版最后沉淀下来的内容不只是教你跑通一个模型而是带你走一遍数据、训练、调优、部署的完整旅程。如果你是个想入行AI工程却总在环境配置和抽象概念里打转的人这篇文章就是为你准备的。1. 为什么“从零开始”比直接套框架更有价值1.1 先谈动机我见过太多“只会调用、不懂拆解”的工程师过去几年面试过不少候选人简历上写着熟练使用PyTorch、TensorFlow但一问到“你的模型为什么选这个损失函数”“数据增强怎么设计才能不破坏标签分布”“推理延迟为什么是200毫秒而不是50毫秒”很多人就卡住了。这不能怪他们因为多数教程的路径是“装个环境 → 下载预训练模型 → 跑个demo”这条路径最快但会留下认知盲区。这个项目我取名ai-engineering-from-scratch就是为了补上这块短板。它不追求用最快的速度跑出一个SOTA模型而追求把工程链路中的每一个环节都拆开给读者看。从机器上怎么装Python环境开始到亲手实现一个数据管线、训练一个可用的视觉模型、把它封装成API、最后监控线上推理的延迟和漂移全程没有黑盒。如果你是以下三类人这个项目会特别对胃口刚转行AI的工程师有编程基础但没做过完整项目做算法研究但工程能力偏弱想补上部署和监控这课被各种库的抽象层绕晕了想知道底层到底发生了什么。1.2 这个项目和普通“教程仓库”的差异在哪先说结论我刻意避开了“一键式”脚本。很多仓库会把所有东西封装成一个run.py跑完就结束观察不到中间状态。ai-engineering-from-scratch则按阶段拆成独立模块00-environment环境安装和验证脚本含CUDA版本与PyTorch的匹配检查01-data原始数据下载、采样、清洗、转换全流程02-model从零实现一个轻量模型结构不依赖预训练权重03-training完整的训练循环、日志记录、Checkpoint管理04-evaluation精度、混淆矩阵、单类性能、推理延迟统计05-deployment为模型写一个HTTP服务接口并做压测与监控。每一层都留有手工操作的余地。比如在数据模块里我不会直接给你一个处理好的.npy文件而是让你亲手经历“下载原始图片 → 发现类别不平衡 → 决定采样策略 → 重新组织目录结构”这个过程。这些决策点才是工程经验的体现。提示初学者往往觉得“能跑起来”就是胜利但工程思维的核心是“能复现、能定位、能改进”。前者靠运气后者靠结构。2. 环境基建从一台“干净”的机器到可复现的训练环境2.1 硬件与操作系统的现实选择先说硬件。我做这个项目时用的是一张6GB显存的显卡这是为了故意模拟“大多数人手头只有一台普通游戏本”的情况。如果显存不够很多模型结构你必须重新设计——这恰恰是好事因为生产环境里的资源约束永远比实验室里更苛刻。操作系统方面主流程是Linux环境Ubuntu 20.04/22.04但我也在macOS和Windows WSL2上跑通过。如果你用Windows我强烈建议不要直接在原生Windows上装CUDA驱动再配环境太容易出岔子。用WSL2会省心很多文件系统、GPU透传、网络配置都比较成熟。2.2 Python环境管理的坑anaconda、uv、pyenv该选谁Python环境管理是一个“看起来不重要踩坑后很痛苦”的主题。我推荐用uv因为它的解析速度快、依赖锁文件可复现而且对虚拟环境的隔离做得干净。命令行如下所示uv venv ai-eng --python 3.11 source ai-eng/bin/activate uv pip install torch --index-url https://download.pytorch.org/whl/cu121这里有个关键点不要用pip install torch默认源安装它很可能会装上CPU版。必须到PyTorch官网根据你的CUDA版本选择对应的安装命令。如果你用的是NVIDIA显卡先运行nvidia-smi看驱动支持的CUDA版本再决定安装哪个wheel包。2.3 验证CUDA可用性的最小步骤装完之后别急着开始写代码先跑三段验证import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果第二行是False大概率是驱动和PyTorch版本不匹配或者装了CPU版。如果第三行报错检查nvcc -V和nvidia-smi显示的版本差异以实际驱动为准。我最初在这个环节耗时三小时原因是没意识到“驱动支持的CUDA版本”和“PyTorch要求的CUDA版本”是两码事。比如驱动支持CUDA 12.4你装cu121的PyTorch也能跑反之则不行。理解了这层关系环境问题就解决了一大半。2.4 固定依赖版本复现是所有工程的地基环境搭好后第一时间导出锁文件uv pip freeze requirements.lock不要只用requirements.txt记录顶层依赖要把全部传递依赖固定住。这样半年后回来还能复现出完全一样的环境。在AI工程里“当初明明能跑现在跑不了”是这个领域最常见的灾难锁文件是唯一的解药。3. 数据工程拥抱原始数据、组织标签与写Dataset类3.1 别用“网上有人传好的清理版数据”自己走一遍处理流程数据环节最大的误区是拿来主义。很多开源数据集已经被组织成漂亮的目录结构直接torchvision.datasets.ImageFolder加载就能训练。这会让你错过最关键的工程环节如何从“原始状态”变成“模型能吃的样子”。我在项目里选了一个公开的中小规模图片分类数据集但故意保留了它的原始压缩包形态。那是一个目录里塞了数万张图片、文件命名混乱、部分图片损坏、标签通过单独的CSV文件给出的状态。第一步就是把这些东西整理成稳定的三板斧结构data/ train/ class_a/ class_b/ val/ class_a/ class_b/ test/这里必须强调一点训练集、验证集、测试集的划分要在“清洗之前”还是“清洗之后”我的经验是先按原始唯一ID做划分再做清洗和增强避免同一张图片的不同增强版本同时出现在训练集和验证集里造成“数据泄漏”。这种泄漏会让验证分数虚高上线后才发现真实效果差一大截。3.2 一个更接近生产环境的Dataset类PyTorch的Dataset类看似简单但很多人对它“返回什么”理解得不够灵活。我写的Dataset不直接返回(image_tensor, label)而是返回字典结构class ProductDataset(Dataset): def __init__(self, samples, image_dir, transformNone, return_pathFalse): self.samples samples self.image_dir image_dir self.transform transform self.return_path return_path def __len__(self): return len(self.samples) def __getitem__(self, idx): image_id, label self.samples[idx] image_path self.image_dir / f{image_id}.jpg # 显式处理缺图情况 if not image_path.exists(): return self.__getitem__((idx 1) % len(self.samples)) image Image.open(image_path).convert(RGB) if self.transform: image self.transform(image) return { image: image, label: label, path: str(image_path), }返回path的好处你平时可能感受不到但当你发现某个样本loss异常高、需要回溯检查原始图片时这个字段能省下大量排查时间。生产环境的数据管线永远要留“追溯能力”这是数据工程思维和数据算法思维的最大区别。3.3 数据增强怎么“增”才不改变语义数据增强是初学者最容易玩过火的地方。很多人直接把RandomResizedCrop、RandomHorizontalFlip、ColorJitter全堆上去结果模型在验证集上表现不错上线后却对真实场景毫无泛化能力。问题就出在增强策略偏离了目标域的语义。我在项目里对增强策略做了“域一致性”约束例如原始场景是商品拍摄图那翻转就要慎重——很多商品文字会因翻转变成反字颜色扰动幅度也要小否则会影响品牌色的特征。用一个中心思想来指导增强后的样本必须仍然被人类无歧义地识别为原类别。违反这条原则的增强直接舍弃。3.4 标签不平衡问题不是“加权”那么简单的实际数据里类别不平衡是常态而不是例外。我在项目里统计后发现其中一类样本数量只有最丰富类的1/25最开始试了WeightedRandomSampler但发现它只解决了“采样频率”问题没解决“特征多样性不足”的问题。最终方案是靠“过采样适度的针对性增强”。比如稀少类别不做大幅翻转但做局部缩放和轻微平移迫使模型学到更鲁棒的特征。同时在损失函数里调整pos_weight时不是拍脑袋给一个大数字而是按“目标覆盖度/当前类别召回率”动态调整。这些细节在论文里往往只有一句话但工程上每一步都需要实证。4. 模型与训练亲手实现一个轻量CNN并跑出可用精度4.1 模型结构设计为什么我不用预训练模型虽然用了迁移学习可以快速达到不错的效果但那个过程的“工程含量”很低。所以主体训练我用了自己实现的轻量卷积结构这样每一层的输入输出尺寸、参数数量、感受野变化都是可计算、可追踪的。模型结构控制在约三百万参数设计要点是三层卷积块加一个全局平均池化加分类头所有卷积层的stride/padding都要算清楚。下面的代码是这个项目里我自己实现的核心模块它比较土但胜在每一行改动都能感受到效果import torch.nn as nn class SimpleBlock(nn.Module): def __init__(self, in_c, out_c, stride1, use_bnTrue): super().__init__() self.conv nn.Conv2d(in_c, out_c, kernel_size3, stridestride, padding1, biasFalse) self.bn nn.BatchNorm2d(out_c) if use_bn else nn.Identity() self.act nn.ReLU(inplaceTrue) def forward(self, x): return self.act(self.bn(self.conv(x))) class TinyNet(nn.Module): def __init__(self, num_classes10, width32): super().__init__() self.features nn.Sequential( SimpleBlock(3, width, stride1), SimpleBlock(width, width*2, stride2), SimpleBlock(width*2, width*4, stride2), SimpleBlock(width*4, width*8, stride2), ) self.global_pool nn.AdaptiveAvgPool2d(1) self.classifier nn.Linear(width*8, num_classes) def forward(self, x): x self.features(x) x self.global_pool(x) x torch.flatten(x, 1) return self.classifier(x)为什么不用残差连接为了让学生更容易观察“梯度消失”和“层数加深后的精度退化”现象。这个选择是故意为之的。以后你上手ResNet时才会理解加法分支的价值。同样地BatchNorm在推理阶段的行为和训练阶段不一样这也是一个必须通过手写代码才能理解的坎。4.2 训练循环里的那些“隐藏逻辑”训练循环看起来简单但里面藏着工程级别的门道。我按下面的顺序组织训练代码每个epoch开头打乱数据设置num_workers和pin_memory前向计算loss后先optimizer.zero_grad()防止梯度累加loss.item()之后再backward()避免释放计算图时报错每隔N步打印损失不是print到控制台而是写入结构化日志JSON Lines每个epoch结束跑验证集记录Top-1、Top-5、每类别的recall和precision。关于model.train()和model.eval()很多刚入门的人会忘记切换模式。eval()模式下BatchNorm会使用累计的running mean和running var而Dropout会失效。如果漏了这步验证效果会非常不稳定。4.3 优化器、学习率和Batch Size的联动关系我在项目里对比了SGD和AdamW。在这个小型任务里SGD配合余弦退火能达到更好的泛化性能但需要手工调学习率。AdamW几乎不用调参就能收敛但最终精度略低而且更容易过拟合。究其原因是自适应学习率方法对每个参数做了归一化影响了泛化界。我给出了一个经验法则batch_size翻倍时学习率最好也相应调整线性缩放法则。比如batch size 32时学习率0.01batch size 128时学习率大概调到0.020.04而不是原封不动。使用混合精度训练时loss缩放也需要小心我通常是先试torch.cuda.amp.GradScaler的默认配置如果出现NaN再配dynamic_loss_scale关闭动态缩放。这里贴一段完整的训练循环包含混合精度与梯度裁剪scaler torch.cuda.amp.GradScaler() for epoch in range(epochs): model.train() for batch in dataloader: images batch[image].to(device) labels batch[label].to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): logits model(images) loss criterion(logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) scaler.step(optimizer) scaler.update()梯度裁剪不是必须的但在小模型大学习率场景下能有效防止个别样本把梯度带偏。上线模型时这种稳定性比极限精度更重要。5. 排查链路loss不降、显存溢出、过拟合的完整定位过程5.1 loss不降先怀疑代码再怀疑模型别急着调参训练第一天最可能遇到的就是loss死活不降。这时候最忌讳的是马上去调学习率。我的排查顺序是用一个固定batch过一遍模型确认前向反向能跑通把模型换成“常数模型”做对照预测每个类别的先验概率算一下base loss应该是多少如果训练loss高于base loss一定是模型输出分布不对检查最后的激活函数和损失函数是否匹配如果训练loss接近base loss但验证loss不降再考虑模型容量和特征提取能力。这个项目里有一类样本特别容易出现loss不降——图片尺寸被resize得太小五官细节都丢了。这个问题不靠调参解决靠检查预处理流程解决。很多“奇怪的不收敛”其实都是数据问题提前看一眼增强后保存的样本图能救你半天时间。5.2 显存溢出不只有OOM这一种解法我还特意在项目里留了一个“显存溢出”的坑让读者体会到两种类型一次性申请过大显存和逐步累积泄漏。CUDA out of memory比较好办把batch size降下来或者临时把图片resize换小。但有一种显存溢出特别隐蔽——你在验证集中没有包torch.no_grad()验证阶段也在构建计算图每个epoch结束后显存占用会缓慢爬升最后在第n个epoch突然崩掉。排查方法记录每个epoch的torch.cuda.memory_reserved()和torch.cuda.memory_allocated()。如果后一个数字每个epoch都在涨多半是验证集没有关闭梯度或者代码里存了不该存的中间Tensor。此外把不用的变量显式del并调用torch.cuda.empty_cache()在绝大多数场景里没必要它反而会拖慢性能。5.3 过拟合训练集loss降到0.1但验证集很差过拟合在视觉小模型里太常见了。我用一个专门章节展示如何系统对抗最优先做的是降低模型容量或接入Dropout而不是急着上数据增强记录每一个epoch的训练loss和验证loss差值差值拉大说明过拟合开始早停Early Stopping应该以验证loss连续N个epoch不改善为准则而不是固定epoch如果数据少Label Smoothing会有奇效。比如让目标不是硬one-hot编码而是1 - epsilon给真实类、epsilon/(num_classes - 1)分给其余类。这会防止模型对训练集过于自信间接提升泛化。另外过拟合的模型往往会学“背景模式”而不是“前景对象”。如果你想验证用Grad-CAM看激活区域如果激活点在背景上说明模型学偏了需要调整数据裁剪策略让前景占据更多像素比例。5.4 验证集上的“幻觉指标”单次验证波动太大怎么办小数据集上验证集的指标波动很大有可能这次验证acc是0.91下次变成0.94。为了不让调参决策被随机性带偏我做了一个朴素但实用的方案用固定随机种子的验证加载顺序同时对验证集做多次重复推理取平均。这不是标准的统计学方案但在工程实践中很有效。具体实现是验证时在DataLoader里设置shuffleFalse固定worker_init_fn的随机种子。这样每次验证都在同一批图片上计算变化只来自模型的权重。如果你的验证集足够大单次评估就够了如果不够大就把验证过程重复三次对logits求平均再做softmax。6. 评估与部署从验证指标到HTTP服务的关键一跳6.1 不只看总体准确率混淆矩阵和单类报告要打印出来在项目进入部署阶段前我要求所有参与者先打印一份完整的classification report。很多团队只看总准确率这是上线后事故高发的根源。有的类别数据量少即使总准确率有95%该类的召回可能只有61%。做医疗、质检这类业务时单类召回往往比总体准确率重要得多。我在评估脚本里输出以下指标并保存成JSON文件方便后面追踪总体Top-1、Top-5准确率每个类别的Precision、Recall、F1混淆矩阵保存成图片每个类别“最容易混淆成谁”的前三名样本级置信度分布。6.2 模型导出PyTorch模型不适合直接裸奔训练完成的model.pt文件包含完整的计算图和参数但直接暴露给Web服务有风险。生产环境标准做法是模型导出为TorchScript或ONNX。我做了两者对比导出格式推理框架优势劣势TorchScriptLibTorch / PyTorch和PyTorch生态无缝兼容动态处理方便版本耦合部署包偏大ONNXONNX Runtime / TensorRT跨语言、跨平台能上设备端部分算子转换需要踩坑我在项目里导出了ONNX格式然后用ONNX Runtime跑推理。转换过程中遇到的最大坑是AdaptiveAvgPool2d在动态输入尺寸下的算子转换问题后来把输入固定为(3, 224, 224)并显式指定shape之后才解决。这也提醒我们导出模型时最好固定输入尺寸不但在转换时省事在部署时也能用上TensorRT的静态优化。6.3 封装一个适合生产起步的推理服务部署服务的时候我用FastAPI而不是Flask它有更好的异步支持、请求体验和可视化的/docs界面。服务端核心代码逻辑如下import onnxruntime as ort import numpy as np class InferenceService: def __init__(self, onnx_path, providers[CPUExecutionProvider]): self.session ort.InferenceSession(onnx_path, providersproviders) self.input_name self.session.get_inputs()[0].name def preprocess(self, image_bytes: bytes) - np.ndarray: # 字节流转RGB数组、resize、归一化 ... def predict(self, image_bytes: bytes) - dict: tensor self.preprocess(image_bytes) logits self.session.run(None, {self.input_name: tensor})[0] probs softmax(logits) top_idx int(np.argmax(probs)) return {label: top_idx, confidence: float(probs[top_idx])}这里要注意providers参数的书写如果你装了GPU版ONNX Runtime建议写成[CUDAExecutionProvider, CPUExecutionProvider]。CUDA在列表前方这样在有GPU的机器上自动走GPU没有就回退CPU。部署容器直接开放8000端口给一个简单的健康检查路径/health返回模型版本信息。这个“版本信息”很重要模型迭代后你要能在线上快速确认当前跑的是哪个版本。6.4 压测和延迟监控上线前必须知道它能扛多少并发没有压测就上线的模型服务遇到流量波峰一定会手忙脚乱。我的压测逻辑分三档单请求延迟确认P50、P95、P99延迟并发10路观察是否有排队CPU/GPU占用率并发50路找出系统开始出现错误或延迟陡增的临界点。我推荐用locust或者简单的wrk做压测。在CPU部署、输入图片尺寸224的条件下我这个三百万参数的模型单请求CPU推理约80到120毫秒GPU推理约20到40毫秒。如果你的模型比这个大却只有一台普通服务器就要认真考虑模型蒸馏或TensorRT加速了。另一个常被忽略的问题是监控数据漂移。我做了最简单的版本把线上输入图片的灰度均值、方差、尺寸分布记录成日志定期和训练集的统计值做对比。如果差异超过阈值触发告警。这种方案不完美但比完全不监控好很多实现成本也很低。6.5 回滚预案新模型上线必须保留旧模型的服务入口我经历过一次惨痛的教训新模型验证集精度更高但上线后发现某种光线条件下效果断崖式下跌。还好当时保留了上一版模型的容器镜像用一套路由规则做灰度切换一小时内就回滚了。现在的原则是新模型先跑灰度比例5%观察小时级错误率和平均置信度再逐步放开。这个流程最简单却最有用。7. 下一步扩展这套从零搭建的路子能平移到语音和文本任务吗7.1 把“数据、模型、评估、上线”这套脚手架搬到NLP场景做完这个项目后你会发现许多套路并不局限于图像。做文本分类时数据集组织方式变成“文本文件 标签CSV”模型从卷积换成Embedding 浅层编码器但训练循环、检查点管理、服务封装这些代码几乎可以原样复用。我也确实这么试过。从视觉切到文本任务时只花了一个周末就搭出原型原因就在于工程骨架是稳固的。数据清洗策略需要重新思考但“怀疑先于调参”的排查思路完全一致。你掌握的真正通用的东西是这一整套工程方法而不是某个框架的API。7.2 需要注意的“边界”任务不同评估指标和数据策略差异很大跨任务迁移不是自动成立的。视觉里实效显著的随机翻转在文本领域就不存在对应操作NLP里常用的label smoothing和temperature scaling虽然好使但青春期的“预处理/增强策略”需要完全重新设计。文本任务要特别小心“标签泄漏”比如拿全文做关键词过滤时过滤规则可能间接把标签信息泄露进输入。在图像任务里类别不平衡用重采样就能解决大半在信息抽取任务里实体类别不平衡则需要结合损失函数和阈值调整单纯重采样效果有限。所以这套从零搭建的方法论是可迁移的但细节必须回到数据本身去重新推演。7.3 下一步自动化实验管理与模型注册当你开始做大量消融实验时实验记录会变得混乱。“这个模型效果不错”和“为什么效果不错”是完全两回事。用MLflow或WB记录每个实验的配置、指标、代码版本、数据集版本是迟早要做的事。我在项目的进阶分支里加了MLflow集成mlflow run . --env-file .env -P epochs30 -P lr0.001每个实验会生成独立运行ID自动把参数、指标、模型产物归档。三个月后回头对比实验不用靠脑子和Excel表。这是从“个人项目”走向“工程体系”的关键一步。8. 最后留个工具箱帮你抄作业的常用命令和几处心态建议8.1 项目最常用的命令清单如果你想复现这个项目下面这些命令按顺序执行就行。需要注意的是不同的环境里包版本差异可能很大所以锁文件必须优先度最高。# 环境创建 uv venv ai-eng --python 3.11 source ai-eng/bin/activate # 安装依赖 uv pip install -r requirements.lock # 下载并整理数据 python 01-data/download_data.py python 01-data/build_dataset.py # 训练模型 python 03-training/train.py --config configs/baseline.yaml # 评估模型 python 04-evaluation/evaluate.py --ckpt checkpoints/baseline/best.pt # 导出ONNX python 05-deployment/export_onnx.py --ckpt checkpoints/baseline/best.pt # 启动推理服务 uvicorn 05-deployment/server:app --host 0.0.0.0 --port 80008.2 我踩了十几遍的一个“低级”错误训练脚本每次启动前我建议先确认数据文件夹下的图片总数和CSV里的样本数对得上。我经常因为操作系统里的“文件同步未完成”或Windows的OneDrive同步引起缺图导致DataLoader跳过样本但总样本数变少后模型精度悄悄下降你完全察觉不到。后来我在Dataset构造函数里加了一个总样本数的断言低于预期就直接抛出异常。这类防御式编程在长期维护时非常香。8.3 谈一点心态从零开始做项目最大的障碍不是技术这个项目做到后期我最深的感受是工程能力的瓶颈从来不是某一个具体API不会用而是你愿不愿意在一个地方卡住然后自己动手翻源码、写探针脚本、逐步逼近问题的根源。很多人学AI工程时习惯是“看会了就等于会了”但实际动手时才发现自己连transforms.Normalize的参数是怎么算出来的都解释不清。我强烈建议你把代码中每个“魔法数字”都当成敌人去追问mean和std为什么是这个值为什么学习率用1e-3而不是1e-1为什么卷积层的padding是1这些问题追到底知识才会真正长在你自己身上。如果你和我一样不想做只会调库的“炼丹师”那就从今天开始把环境打碎重装一遍、亲手断言一下每个Bug、把服务压测到崩溃一次。这个过程很痛苦但每一个坑都会在后面的工程里以“经验”的方式回报给你。
返回列表