ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零手搓AI工程:数据管线、训练循环与推理服务实战

从零手搓AI工程:数据管线、训练循环与推理服务实战 1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始接触这个方向的时候也是这么想的直到有一次线上推理服务在高峰期直接雪崩日志里全是显存溢出的报错我才意识到——只会调包的人永远不知道系统在什么边界条件下会崩。ai-engineering-from-scratch这个项目标题本身就说明了一件事它要解决的不是“怎么用现成的框架跑一个模型”而是“从零开始把AI工程这条链路里的每一块砖都亲手砌一遍”。这跟“从零实现一个操作系统”或者“从零写一个数据库”是同一类思路——你不是为了造一个能替代PyTorch的东西而是为了在造的过程中把那些平时被封装层遮住的细节全部暴露出来。这篇文章适合三类人看。第一类是有一定Python基础、想往AI工程方向转但不知道从哪下手的开发者第二类是用过一些高层框架、但遇到性能问题或者诡异Bug时完全不知道从哪排查的工程师第三类是做后端或者数据方向、想理解AI系统到底跟普通服务有什么本质区别的技术人。我会围绕数据管线、模型训练循环、推理服务、性能调优这几个核心环节把“从零手搓”这件事拆开讲透每一步都告诉你为什么这么做、不这么做会怎样。需要提前说明的是这里说的“从零”不是让你用汇编去写矩阵乘法而是指不依赖那些把一切都帮你封装好的高层抽象用最基础的库比如NumPy级别的工具把核心逻辑自己实现一遍。这个过程中你会被迫理解张量的内存布局、梯度是怎么传的、批处理为什么会影响收敛、推理服务的瓶颈到底在哪。这些东西调包的时候你永远学不到。2. 数据管线AI工程里最容易被低估的脏活2.1 为什么数据加载会成为训练瓶颈大部分人第一次自己写训练循环的时候都会把注意力放在模型结构上觉得网络设计才是核心。但实际跑起来你会发现GPU利用率经常在30%以下晃悠训练一个epoch要等好久。这时候你去看监控发现GPU大部分时间在等数据。这就是数据管线的问题。在高层框架里你写一个DataLoader就完事了框架帮你处理了多进程加载、预取、打乱、批处理。但当你从零实现的时候你必须自己回答几个问题数据怎么读进内存读进来之后怎么转换成模型能吃的张量多个worker之间怎么协调预取几个batch才够我自己的经验是对于一个中等规模的图像分类任务如果数据管线没做好GPU利用率能从85%掉到20%以下。这不是夸张是实测数据。原因很简单磁盘IO的速度和GPU计算的速度差了两三个数量级你不把数据加载和计算重叠起来GPU就是在空转烧钱。2.2 手写一个带预取的数据加载器从零实现数据管线的核心思路是“生产者-消费者”模型。主训练进程是消费者它需要batch的时候直接从队列里拿后台有若干个worker进程是生产者它们负责从磁盘读数据、做预处理、组装成batch然后塞进队列。这里有几个关键决策点。第一worker的数量怎么定不是越多越好。如果你的数据在机械硬盘上开太多worker反而会因为磁头频繁寻道导致整体吞吐下降。一般来说worker数量设为CPU核心数的70%到80%比较稳妥留一些核心给主进程和其他系统任务。第二预取队列的长度怎么定队列太短生产者来不及填消费者会饿死队列太长内存占用会飙升而且如果数据有随机性比如随机裁剪太长的队列意味着你预取的数据可能已经过时了。我的经验是预取2到4个batch的量比较合适具体取决于单个batch的大小和预处理耗时。第三数据打乱怎么做很多人以为打乱就是在epoch开始的时候把索引shuffle一下。但如果你用了预取队列实际训练时看到的顺序和你在主进程里shuffle的顺序可能不一致。正确的做法是在worker内部维护一个局部的shuffle缓冲区或者用带种子的随机采样器保证可复现性。# 一个简化的预取数据加载器核心逻辑 import multiprocessing as mp import numpy as np class PrefetchLoader: def __init__(self, dataset, batch_size, num_workers4, prefetch2): self.queue mp.Queue(maxsizeprefetch) self.workers [] # 启动worker进程每个负责一部分数据的读取和预处理 for i in range(num_workers): p mp.Process(targetself._worker_loop, args(dataset, batch_size, i)) p.start() self.workers.append(p) def _worker_loop(self, dataset, batch_size, worker_id): # worker内部持续生产batch并放入队列 indices list(range(worker_id, len(dataset), self.num_workers)) for start in range(0, len(indices), batch_size): batch_idx indices[start:startbatch_size] batch_data [dataset[i] for i in batch_idx] self.queue.put(batch_data)上面这段代码省略了很多细节但核心结构就是这样。实际写的时候你还要处理worker异常退出、队列满时的阻塞、epoch结束时的信号传递等问题。每一个问题在高层框架里都是自动处理的但自己写的时候你必须想清楚。2.3 数据预处理里那些坑预处理看起来简单无非是归一化、resize、augmentation。但自己实现的时候有几个坑几乎每个人都会踩。第一个坑是归一化的顺序。很多人先把数据转成float再除以255然后做减均值除方差。但如果你在uint8阶段就做resize插值方式选得不对会引入量化误差。正确的顺序应该是先转float再做几何变换最后做数值归一化。这个顺序在高层框架里是固定的但自己写的时候很容易搞反。第二个坑是augmentation的随机性控制。训练的时候需要随机增强来提升泛化但验证的时候必须用确定性的预处理。如果你在验证集上也用了随机裁剪那你的验证指标就会抖动根本没法判断模型到底有没有变好。我见过有人因为这个原因白白多跑了好几天的实验。第三个坑是内存布局。图像数据默认是HWC高、宽、通道但大多数计算库期望的是CHW。这个转换如果在每个batch里都做累积起来是很可观的开销。更好的做法是在数据集初始化的时候就统一转好或者用内存映射的方式按需转换。3. 训练循环从反向传播到梯度累积的完整拆解3.1 手写反向传播到底在写什么当你用PyTorch的时候loss.backward()一行就搞定了梯度计算。但这一行背后发生了什么从零实现的时候你需要自己实现计算图、自己实现链式法则、自己管理中间变量的生命周期。最基础的做法是数值梯度对每个参数稍微扰动一下看loss怎么变。这个方法实现简单但计算量是参数量的两倍实际训练根本用不了。真正可行的是解析梯度也就是手动推导每个操作的导数公式然后在反向传播的时候按链式法则乘起来。以一个简单的全连接层为例前向是y xW b反向的时候你需要计算三个东西loss对W的梯度、loss对b的梯度、loss对x的梯度传给上一层。这三个梯度的公式分别是x^T * grad_y、sum(grad_y)、grad_y * W^T。看起来简单但当你把几十层堆起来之后任何一个地方的维度对不上整个训练就会崩。我的建议是从零实现的时候不要一上来就搞很深的网络。先用一个两层的小网络把整个流程跑通确认梯度计算正确可以用数值梯度做校验然后再逐步加深。这个校验步骤非常关键我见过太多人因为梯度算错训练了半天loss不降还以为是学习率的问题。3.2 梯度累积解决显存不够的问题实际做AI工程的时候你经常会遇到一个尴尬的情况想要的batch size是64但显存只够放16。这时候梯度累积就是救命稻草。原理很简单你不是每个batch都更新参数而是累积几个batch的梯度之后再更新一次。这样等效的batch size就是micro_batch_size * accumulation_steps。但这里有一个细节很多人会忽略损失函数需要除以累积步数否则梯度会等比放大等效于把学习率调大了。# 梯度累积的核心逻辑 accumulation_steps 4 optimizer.zero_grad() for i, batch in enumerate(dataloader): outputs model(batch) loss criterion(outputs, targets) # 关键损失除以累积步数 loss loss / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()还有一个坑是BatchNorm。如果你用了BatchNorm梯度累积的时候统计量是在micro batch上算的和真正的大batch统计量不一样。这会导致训练和推理的行为不一致。解决办法要么是改用GroupNorm或者LayerNorm要么是在累积期间用同步的BatchNorm但实现起来很复杂。我个人的建议是如果显存实在不够优先考虑用梯度检查点gradient checkpointing来换显存而不是梯度累积因为前者不会改变统计量的计算方式。3.3 学习率调度与早停的手动实现学习率调度看起来是个小事情但它对最终效果的影响可能比模型结构还大。从零实现的时候你需要自己维护一个优化器状态在每个step或者每个epoch之后调整学习率。最常见的几种调度策略阶梯下降每隔几个epoch降一次、余弦退火按余弦曲线平滑下降、热重启周期性把学习率拉回去再降。每种策略适合的场景不一样。阶梯下降适合训练周期明确的任务余弦退火适合需要精细收敛的场景热重启适合探索多个局部最优。早停的逻辑更简单维护一个验证集上的最佳指标如果连续N个epoch没有提升就停止训练。但这里有一个工程上的细节保存最佳模型的时候不要只保存参数还要保存优化器状态和当前的epoch数。否则你恢复训练的时候优化器的动量信息丢了模型可能会震荡。我自己的习惯是每验证一次就存一个checkpoint文件名里带上epoch和验证指标。这样即使训练中断了也能从最近的一个好状态恢复。磁盘空间换训练时间这笔账怎么算都划算。4. 推理服务从单机脚本到可上线的API4.1 模型导出与格式选择训练完的模型不能直接扔到线上用。你需要把它导出成一个独立的、不依赖训练代码的格式。常见的选择有ONNX、TorchScript、SavedModel等。每种格式的适用场景不一样。ONNX的好处是跨框架你可以在PyTorch里训练然后用ONNX Runtime在C环境里推理。但ONNX对动态控制流的支持有限如果你的模型里有if-else或者循环导出的时候可能会出问题。TorchScript是PyTorch原生的对动态图的支持更好但只能在PyTorch生态里用。我自己的经验是如果推理环境是Python直接用TorchScript最省事如果推理环境是C或者需要极致性能ONNX加TensorRT的组合更合适。但不管选哪种导出之后一定要做数值一致性校验同样的输入训练框架的输出和推理引擎的输出差异不能超过一个很小的阈值比如1e-4。我见过有人导出之后没校验上线了才发现输出完全不对原因是某个算子在导出时被优化掉了。4.2 批处理与动态形状的处理线上推理和离线推理最大的区别是离线的时候你可以把batch凑得很大线上的时候请求是一个一个来的。如果你每个请求都单独跑一次模型GPU利用率会低得可怜。解决办法是批处理把短时间内到达的多个请求攒成一个batch一起送进模型。但这里有一个权衡攒的时间越长batch越大吞吐越高但单个请求的延迟也越大。你需要根据业务对延迟的容忍度来定这个窗口。另一个问题是动态形状。线上的输入可能长度不一比如文本分类任务里有的句子长有的句子短。如果你按最大长度padding短句子的计算就浪费了。更好的做法是用动态形状的推理引擎或者按长度分桶把长度相近的请求放在一个batch里。# 一个简单的动态批处理调度器 import time from collections import deque class BatchScheduler: def __init__(self, max_batch_size32, max_wait_ms10): self.max_batch_size max_batch_size self.max_wait_ms max_wait_ms self.pending deque() def add_request(self, request): self.pending.append((time.time(), request)) def get_batch(self): if not self.pending: return None # 等待窗口内的请求攒成一批 start time.time() while len(self.pending) self.max_batch_size: if (time.time() - start) * 1000 self.max_wait_ms: break time.sleep(0.001) batch [] while self.pending and len(batch) self.max_batch_size: batch.append(self.pending.popleft()[1]) return batch4.3 推理服务的监控与降级上线之后的推理服务最怕的不是性能不够而是你不知道它什么时候会出问题。所以监控是必须的。你需要监控的指标包括请求延迟的P50、P95、P99GPU利用率和显存占用请求队列的长度以及模型的输出分布。输出分布这个指标很多人会忽略但它非常重要。如果模型的输出突然变得很集中比如所有分类结果都变成同一类那大概率是输入数据出了问题或者模型本身出了故障。这时候你需要有一个降级策略要么返回一个默认结果要么切换到备用模型要么直接拒绝请求并返回错误码。我自己的做法是在推理服务前面加一个轻量级的规则引擎对输入做基本的合法性检查。比如文本分类任务里如果输入长度超过模型的最大长度直接截断而不是让模型报错。图像任务里如果输入尺寸不对先resize再送进去。这些检查看起来简单但能避免很多线上事故。5. 性能调优那些只有自己写过才知道的细节5.1 内存墙与计算墙的识别做AI工程调优第一步是搞清楚你的瓶颈到底在哪。是内存带宽不够还是计算单元不够这两个问题的优化方向完全相反。判断方法很简单算一下你的操作的算术强度arithmetic intensity也就是每读取一个字节的数据能做多少次浮点运算。如果算术强度很低比如逐元素操作那瓶颈大概率在内存带宽如果算术强度很高比如大矩阵乘法那瓶颈在计算单元。对于内存密集型的操作优化的方向是减少内存访问次数比如算子融合把多个逐元素操作合并成一个。对于计算密集型的操作优化的方向是提高计算单元的利用率比如用更大的batch、用Tensor Core、用混合精度。我自己的经验是大部分自己从零实现的模型瓶颈都在内存带宽上。因为手写的代码往往没有做算子融合每个操作都要把数据从显存读一遍再写回去。这时候用profiler看一下你会发现大部分时间都花在内存拷贝上而不是计算上。5.2 混合精度训练的实际收益与风险混合精度训练是现在做AI工程几乎必用的技术。原理是用float16做前向和反向计算但用float32维护一份参数副本。这样显存占用能减少将近一半计算速度也能提升不少因为很多硬件对float16有加速。但混合精度不是没有代价的。float16的表示范围比float32小很多梯度很容易下溢变成0或者上溢变成inf。解决办法是损失缩放loss scaling在反向传播之前把损失乘一个很大的数反向之后再除回来。这样梯度就被“抬”到了float16能表示的范围内。还有一个坑是不是所有操作都适合用float16。比如softmax、layer norm、以及涉及累加的操作用float16会损失精度。这些操作需要保留在float32里。在高层框架里这些是自动处理的但自己实现的时候你必须手动指定每个操作的精度。5.3 从零实现时的调试技巧自己写AI工程代码调试是最耗时间的环节。我总结了几条实用的调试技巧。第一用小数据调试。不要一上来就用完整数据集跑先用几个样本把整个流程跑通。确认前向输出正确、梯度计算正确、参数更新正确然后再扩大数据量。第二用数值梯度校验解析梯度。对于每一个自定义的算子都写一个数值梯度的测试用例。虽然慢但能发现绝大多数梯度计算错误。第三可视化中间结果。把每一层的输出分布画出来看看有没有异常值。如果某一层的输出全是0或者全是NaN那问题就出在这一层或者它的前一层。第四固定随机种子。调试的时候一定要固定所有随机源Python的random、NumPy的random、框架的random、以及CUDA的随机。否则你每次跑的结果都不一样根本没法定位问题。第五梯度裁剪。自己实现训练循环的时候梯度爆炸是家常便饭。加一个梯度裁剪把梯度的范数限制在一个合理范围内能避免很多训练崩溃的情况。6. 从Demo到生产还差哪些工程化环节6.1 配置管理与实验追踪从零做AI工程最容易忽略的就是配置管理。刚开始写代码的时候大家都是把超参数硬编码在脚本里。但当你跑了十几个实验之后你根本记不清哪个模型对应哪组参数。正确的做法是把所有配置抽出来用一个配置文件YAML或者JSON管理。每次实验保存一份完整的配置快照和模型checkpoint放在一起。这样你回看任何一个模型都能知道它是用什么参数训出来的。实验追踪工具也很重要。你可以用TensorBoard记录loss曲线和指标用MLflow或者Weights Biases管理实验。但如果你不想引入外部依赖自己写一个简单的日志系统也够用每个实验一个目录里面放配置文件、训练日志、验证指标、模型文件。6.2 模型版本管理与回滚线上模型不是一次性的你需要有能力随时回滚到之前的版本。这就要求你有一套模型版本管理机制。最基本的做法是用语义化版本号主版本号表示模型结构变化次版本号表示训练数据或超参数变化修订号表示微调或者bug修复。每个版本对应一个唯一的模型文件存在一个可靠的存储上。回滚的时候你只需要把推理服务指向旧版本的模型文件然后重启服务。但这里有一个细节如果新旧版本的输入输出格式不一样回滚的时候还需要同步回滚预处理和后处理的代码。所以最好的做法是把预处理、模型、后处理打包成一个整体一起做版本管理。6.3 持续集成与自动化测试AI工程的测试和传统软件测试不太一样。传统软件测试关注的是逻辑正确性AI工程测试还要关注数值稳定性、性能回归、以及数据漂移。我自己的做法是每次代码提交都跑一套自动化测试。这套测试包括单元测试每个算子的数值正确性、集成测试端到端的训练和推理流程、性能测试关键操作的耗时不能超过基线、以及数据测试输入数据的分布不能有显著变化。性能测试特别重要。因为AI代码的性能很容易在重构中退化比如你不小心把一个向量化操作改成了循环性能可能直接掉一个数量级。有了性能基线你就能在合并代码之前发现这些问题。7. 我踩过的那些坑与最后的经验分享回过头看从零做AI工程这件事最大的价值不是让你造出一个比PyTorch更好的框架而是让你在遇到问题时有能力深入到框架内部去定位和解决。我后来在工作中遇到的大部分疑难问题都是因为在某个时刻自己实现过相关逻辑所以能快速判断问题出在哪一层。如果让我给刚开始走这条路的人一个建议那就是不要追求大而全先选一个最小的闭环跑通。比如先做一个简单的图像分类从数据加载到训练到推理全部自己写。跑通之后再逐步替换其中的组件比如把数据加载换成更高效的实现把优化器换成更先进的算法。每替换一个组件你都对系统的理解加深一层。还有一个很实用的技巧多读源码。当你用某个框架遇到问题时不要只查文档直接去看源码。你会发现很多文档里没写的细节比如某个操作的默认行为、某个参数的边界条件。这些细节在你自己实现的时候就是决定成败的关键。最后说一个心态上的问题。从零实现AI工程前期会非常痛苦因为你要处理大量琐碎的细节而且经常遇到各种诡异的Bug。但一旦你跨过了那个门槛你会发现后面学任何新框架、新模型、新技术速度都比别人快很多。因为你理解的是原理而不是API。
返回列表