1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,拖几个组件,调几个API,然后跑通一个Demo,就觉得自己已经入门了。我刚开始接触这个方向的时候也是这么想的,直到有一次线上推理服务在高峰期直接雪崩,日志里全是显存溢出和请求超时,我才意识到——只会调包的人,根本不知道系统在哪个环节出了问题,更别提定位和修复了。
ai-engineering-from-scratch这个标题,核心不在“AI”,而在“from scratch”。它强调的是一种从底层构建、不依赖黑盒的工程能力。换句话说,你要能自己写出推理循环、自己管理显存、自己设计批处理策略、自己搭建特征管道,而不是把所有东西都塞给一个框架然后祈祷它别崩。这篇文章适合那些已经会写Python、用过PyTorch或类似工具,但总觉得“模型跑起来是一回事,服务稳不稳定是另一回事”的开发者。我会把从零搭建一个可用的AI工程链路拆成几个关键模块,每个模块都讲清楚为什么这么设计、实测中会遇到什么坑、以及怎么用最朴素的方式解决问题。
先明确一个前提:这里的“从零”不是让你用C语言手写矩阵乘法,而是指你不依赖高度封装的端到端平台,自己控制数据流、模型加载、推理调度和资源回收。这种能力在真实生产环境里极其重要,因为封装越厚,出问题时你的排查手段就越少。我见过太多团队在模型精度上花了几周时间调参,结果上线第一天就被内存泄漏拖垮,原因仅仅是推理循环里有一个张量没有及时释放。
2. 推理循环的手写实现与显存管理
2.1 为什么一个简单的for循环也会出问题
大部分人写推理代码是这样的:加载模型,把数据塞进去,拿到输出,完事。但在工程场景里,输入是流式的、批大小是动态的、显存是有限的。如果你用一个朴素的for循环逐条推理,GPU利用率会低得可怜;如果你一次性把所有数据塞进去,显存直接爆炸。所以第一步要解决的是动态批处理和显存回收。
我实测下来,最稳妥的方式是维护一个请求队列,设定两个阈值:最大批大小和最大等待时间。当队列长度达到最大批大小,或者最早进入队列的请求等待时间超过阈值,就触发一次推理。这个逻辑用Python的queue.Queue加一个后台线程就能实现,不需要任何高级框架。
import queue import threading import time import torch class InferenceEngine: def __init__(self, model, max_batch=8, max_wait=0.05): self.model = model self.max_batch = max_batch self.max_wait = max_wait self.req_queue = queue.Queue() self.result_dict = {} self.lock = threading.Lock() self.running = True def submit(self, req_id, input_tensor): self.req_queue.put((req_id, input_tensor, time.time())) def _batch_loop(self): while self.running: batch = [] start = time.time() while len(batch) < self.max_batch: try: item = self.req_queue.get(timeout=self.max_wait) batch.append(item) except queue.Empty: break if time.time() - start > self.max_wait: break if not batch: continue ids = [b[0] for b in batch] tensors = torch.stack([b[1] for b in batch]) with torch.no_grad(): outputs = self.model(tensors) for i, rid in enumerate(ids): with self.lock: self.result_dict[rid] = outputs[i].cpu() del tensors, outputs torch.cuda.empty_cache()这段代码里有两个关键点:torch.no_grad()关闭梯度计算,减少显存占用;torch.cuda.empty_cache()在每批结束后释放缓存。很多人以为Python的垃圾回收会自动处理,但PyTorch的CUDA缓存是独立管理的,不手动清理的话,显存会随着请求数缓慢增长,直到OOM。
2.2 显存碎片化比显存不足更隐蔽
显存碎片化是一个很少被提及但极其致命的问题。假设你的GPU有24GB显存,模型占了10GB,理论上还剩14GB可用。但如果这14GB被切成了无数小块,你连一个2GB的连续张量都分配不出来。这种情况在长时间运行的服务里非常常见,尤其是批大小变化频繁的时候。
我的应对策略是预分配固定大小的显存池。具体做法是在服务启动时,一次性申请一块足够大的连续显存,然后自己实现一个简单的内存分配器,按需切分和回收。这听起来很底层,但用PyTorch的torch.cuda.memory.CUDAPluggableAllocator可以做到,或者更简单一点,直接限制最大批大小,让每次分配的尺寸尽量一致。
注意:如果你用的是多卡环境,每张卡都要独立管理显存,不要指望数据并行能自动解决碎片问题。我踩过这个坑,两张卡跑了一周后,其中一张卡的碎片率超过40%,推理延迟直接翻倍。
2.3 推理延迟的组成与优化优先级
很多人优化推理延迟时只盯着模型前向传播的时间,但实际上延迟由四部分组成:请求排队时间、数据预处理时间、模型前向时间、后处理时间。我做过统计,在一个典型的图像分类服务里,前向传播只占总延迟的35%左右,预处理(解码、缩放、归一化)占了40%,剩下的是排队和后处理。
所以优化顺序应该是:先优化预处理,用GPU加速或者多进程并行;再优化排队策略,减少等待;最后才考虑模型量化或剪枝。如果你一上来就搞模型压缩,可能花了很大力气只减少了10%的总延迟,因为瓶颈根本不在那里。
3. 数据管道的构建:从原始输入到模型可用的张量
3.1 预处理为什么不能全放在GPU上
把预处理放在GPU上看起来很美,但实际上会跟模型推理抢计算资源。尤其是当预处理包含大量小算子时,GPU的利用率会非常低,因为每个算子都要启动一个kernel,调度开销远大于计算本身。我的经验是:解码和缩放放在CPU上,用多进程并行;归一化和张量转换放在GPU上,用批处理一次性完成。
具体来说,对于图像输入,我会用concurrent.futures.ProcessPoolExecutor开4到8个进程做JPEG解码和resize,然后把结果攒成一个批,一次性传到GPU上做归一化和to_tensor。这样CPU和GPU各司其职,整体吞吐量能提升2到3倍。
from concurrent.futures import ProcessPoolExecutor import numpy as np from PIL import Image def decode_and_resize(raw_bytes, target_size=(224, 224)): img = Image.open(io.BytesIO(raw_bytes)).convert("RGB") img = img.resize(target_size, Image.BILINEAR) return np.array(img, dtype=np.uint8) def preprocess_batch(raw_list, executor, target_size=(224, 224)): futures = [executor.submit(decode_and_resize, rb, target_size) for rb in raw_list] arrays = [f.result() for f in futures] batch = np.stack(arrays) tensor = torch.from_numpy(batch).permute(0, 3, 1, 2).float().cuda() tensor = tensor / 255.0 mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1).cuda() std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1).cuda() tensor = (tensor - mean) / std return tensor3.2 数据格式的选择直接影响吞吐量
原始输入是JPEG还是PNG,是Base64还是二进制流,对吞吐量的影响非常大。我实测过,同样一张1080p的图片,JPEG解码比PNG快大约3倍,因为JPEG的压缩算法更适合并行解码。如果客户端能控制,尽量传JPEG。另外,Base64编码会让数据体积膨胀33%,而且解码需要额外的CPU时间,能避免就避免。
还有一个容易被忽略的点:内存拷贝。从网络缓冲区到Python bytes对象是一次拷贝,从bytes到numpy数组是第二次,从numpy到torch tensor是第三次。每次拷贝都在消耗带宽和CPU周期。如果追求极致性能,可以用memoryview和torch.frombuffer减少中间环节,但代码可读性会下降。我的建议是先用清晰的方式实现,等压测发现瓶颈后再针对性优化。
3.3 批处理中的填充与掩码处理
当输入长度不一致时(比如文本或变长序列),批处理需要填充。填充本身不复杂,但填充后的掩码处理很容易出错。我见过一个线上事故,原因是填充位置的注意力掩码写反了,导致模型把填充符当成了有效输入,输出完全乱套。
正确的做法是:在预处理阶段记录每个样本的真实长度,生成一个布尔掩码,True表示有效位置,False表示填充位置。在模型内部,注意力计算时把掩码为False的位置的注意力分数设为负无穷,这样softmax之后这些位置的权重就趋近于零。
def collate_fn(batch): max_len = max(len(x) for x in batch) padded = torch.zeros(len(batch), max_len, dtype=torch.long) mask = torch.zeros(len(batch), max_len, dtype=torch.bool) for i, x in enumerate(batch): padded[i, :len(x)] = torch.tensor(x) mask[i, :len(x)] = True return padded, mask提示:掩码的数据类型建议用
bool而不是uint8,因为PyTorch的masked_fill对bool的支持更好,而且布尔运算在GPU上通常更快。
4. 模型加载与版本管理的工程细节
4.1 权重加载的三种方式与各自的坑
从零构建AI工程时,模型加载看似简单,但坑非常多。常见的方式有三种:直接加载完整的state_dict、加载torch.save保存的整个模型对象、以及从HuggingFace等仓库拉取。第一种最安全,因为state_dict只包含参数,不包含代码,不会因为反序列化执行恶意代码。第二种最方便,但依赖模型类的定义,如果代码结构变了,加载就会失败。第三种最省事,但网络依赖和版本兼容性问题会让你在离线环境里寸步难行。
我的建议是:生产环境永远用state_dict加载,并且把模型类的定义和权重文件放在同一个版本控制仓库里。加载时先用torch.load把权重读到CPU,再逐层load_state_dict,最后整体搬到GPU。这样即使权重文件损坏,也能在加载阶段就发现,而不是等到推理时才报错。
def load_model_safely(model_class, weight_path, device="cuda"): model = model_class() state_dict = torch.load(weight_path, map_location="cpu") missing, unexpected = model.load_state_dict(state_dict, strict=False) if missing: print(f"Missing keys: {missing}") if unexpected: print(f"Unexpected keys: {unexpected}") model.to(device) model.eval() return modelstrict=False允许部分加载,这在迁移学习或模型微调后很常见。但你要清楚哪些层是随机初始化的,否则推理结果会完全不可预测。
4.2 版本回滚与灰度发布
模型更新不是简单的替换文件。新模型上线后,如果效果变差,你需要能在几分钟内回滚到旧版本。我的做法是:每个模型版本打一个独立的目录,目录名包含时间戳和git commit hash,服务启动时通过环境变量指定加载哪个版本。同时,在推理服务前面加一层路由,可以按请求比例把流量分给不同版本,实现灰度发布。
/models/ ├── 20240115_a3f2c1/ │ ├── model.py │ ├── weights.pt │ └── config.json ├── 20240120_b7e9d4/ │ ├── model.py │ ├── weights.pt │ └── config.json └── current -> 20240120_b7e9d4/用软链接指向当前版本,回滚时只需要改软链接指向,然后重启服务。这个方案简单但极其有效,比任何复杂的发布系统都可靠。
4.3 模型热更新的实现思路
有些场景不允许重启服务,比如在线广告推荐。这时候需要热更新。热更新的核心是双缓冲:内存里同时保留新旧两个模型,新模型加载完成后,用一个原子操作切换推理入口的指针。Python里可以用threading.Lock保护一个全局变量,推理线程每次取模型时先拿锁再读指针。
class ModelManager: def __init__(self): self.model = None self.lock = threading.Lock() def update(self, new_model): with self.lock: self.model = new_model def get(self): with self.lock: return self.model注意,旧模型不能立即释放,因为可能还有正在进行的推理请求持有它的引用。等所有请求结束后再释放,或者干脆不释放,等下次更新时自然覆盖。显存足够的话,保留两个模型是最稳妥的。
5. 服务化与并发处理:从单机脚本到可用服务
5.1 为什么Flask不适合做推理服务
很多人用Flask写推理接口,因为简单。但Flask默认是同步阻塞的,一个请求处理不完,后面的请求全部排队。虽然可以用gunicorn开多worker,但每个worker都会加载一份模型,显存直接翻倍。如果你的模型占10GB显存,开4个worker就是40GB,一张A100都不够用。
正确的做法是用异步框架或者专用推理服务器。异步框架比如FastAPI加uvicorn,可以用一个进程处理多个并发请求,模型只加载一份。但要注意,Python的GIL会让CPU密集型的预处理成为瓶颈,所以预处理部分还是要用多进程或者放到单独的线程池里。
from fastapi import FastAPI import asyncio from concurrent.futures import ThreadPoolExecutor app = FastAPI() executor = ThreadPoolExecutor(max_workers=4) @app.post("/predict") async def predict(request: Request): data = await request.json() loop = asyncio.get_event_loop() result = await loop.run_in_executor(executor, inference_engine.submit_and_wait, data) return {"result": result}5.2 并发控制与背压机制
推理服务的并发数不能无限增长,否则显存和队列都会爆。必须有一个背压机制:当队列长度超过阈值时,直接拒绝新请求,返回503。这比让请求堆积到超时要好得多,因为超时会让客户端重试,进一步加剧拥堵。
我通常设置三个水位线:低水位(正常接收)、中水位(开始限流,只接收高优先级请求)、高水位(直接拒绝)。优先级可以根据请求来源或者业务类型来定。这个逻辑用信号量或者令牌桶都能实现,关键是阈值要压测后确定,不能拍脑袋。
5.3 日志与监控的最小必要集
从零构建的服务,监控不需要大而全,但有几个指标必须记录:请求延迟的P50/P95/P99、队列长度、GPU利用率和显存占用、批大小的分布。这些指标能帮你快速定位问题是出在排队、预处理、推理还是后处理。
我用的是最朴素的方式:每个请求结束时,把延迟和批大小写到一个环形缓冲区,后台线程每秒聚合一次,输出到标准日志。GPU指标用pynvml读取。不需要Prometheus和Grafana,一个日志文件加一个简单的解析脚本就够了。
import pynvml pynvml.nvmlInit() handle = pynvml.nvmlDeviceGetHandleByIndex(0) info = pynvml.nvmlDeviceGetMemoryInfo(handle) print(f"GPU memory used: {info.used / 1024**3:.2f} GB")注意:
pynvml的初始化要在服务启动时做一次,不要每次请求都调用,否则会有性能开销。
6. 实测中遇到的五个典型问题与排查过程
6.1 推理结果偶尔不一致:随机性从哪里来
有一次压测发现,同样的输入,两次推理的结果有微小差异。模型是eval()模式,torch.no_grad()也开了,理论上应该完全确定。排查后发现,问题出在预处理阶段:图像resize用了Image.BILINEAR,而PIL的某些版本在多线程环境下会有竞态条件,导致像素值有微小抖动。换成Image.NEAREST或者用OpenCV的cv2.resize就稳定了。
这个问题的教训是:任何涉及浮点运算的预处理步骤,都要确认它在并发环境下是确定性的。不确定的话,要么加锁,要么换成确定性算法。
6.2 批大小增大后延迟反而上升
理论上批大小越大,吞吐量越高,延迟也应该在可接受范围内。但我实测发现,当批大小从8增加到32时,P99延迟从50ms飙升到200ms。原因是GPU的显存带宽成了瓶颈,大批量数据的传输时间超过了计算时间的节省。用nsight分析后发现,cudaMemcpy占了总时间的60%。
解决方案是限制最大批大小,同时用pin_memory和non_blocking加速数据传输。另外,把输入数据提前放到GPU上,减少每次推理的拷贝量。
tensor = tensor.pin_memory() tensor_gpu = tensor.cuda(non_blocking=True)6.3 内存泄漏的定位:从现象到根因
服务跑了一天之后,CPU内存从2GB涨到了8GB。用tracemalloc抓了快照,发现是日志模块里有一个全局列表不断追加请求记录,从来没有清理。改成环形缓冲区后,内存稳定在2.5GB。
另一个常见的泄漏点是PyTorch的autograd图。虽然用了no_grad,但如果某个地方不小心保留了中间张量的引用,图就不会释放。用torch.cuda.memory_summary()可以看到详细的分配情况。
6.4 多进程预处理时的序列化开销
用ProcessPoolExecutor做图像解码时,发现CPU利用率只有30%,大部分时间花在了进程间通信上。原因是把numpy数组从子进程传回主进程时,pickle序列化和反序列化消耗了大量时间。解决方案是用multiprocessing.shared_memory共享内存,子进程直接把结果写到共享内存块,主进程读取,避免了拷贝。
6.5 模型加载时的设备不匹配
从CPU加载权重后,直接调用model.cuda(),结果报错说某些层还在CPU上。原因是模型里有一些自定义层,它们的_apply方法没有正确处理设备转移。解决方法是手动遍历所有参数和缓冲区,逐个.to(device),或者确保自定义层正确实现了_apply。
def to_device(model, device): for param in model.parameters(): param.data = param.data.to(device) for buf in model.buffers(): buf.data = buf.data.to(device) return model7. 从能跑到好用:性能调优的优先级清单
7.1 先压测再优化,不要凭感觉
性能调优最大的忌讳是凭感觉猜瓶颈。我见过有人花了一周时间优化模型推理速度,结果发现瓶颈在日志写入上。正确的流程是:先用wrk或locust做压测,拿到基线数据;然后用py-spy或cProfile做火焰图,找到最耗时的函数;最后针对性地优化。
压测时要注意预热。GPU在冷启动时的频率和温度都跟稳定状态不同,前几百个请求的延迟不能作为参考。我通常预热1000个请求,然后再开始统计。
7.2 优化顺序:从外到内,从粗到细
优化优先级从高到低:减少请求量(缓存、去重)> 减少数据量(压缩、降采样)> 并行化(多进程、多线程)> 算法优化(模型量化、剪枝)> 底层优化(CUDA kernel、内存对齐)。
缓存是最容易被忽略的优化手段。如果同一个输入反复出现,缓存结果能直接省掉整个推理过程。我用一个简单的LRU缓存,命中率在20%左右,P95延迟直接降了30%。
7.3 量化与剪枝的适用边界
量化(比如FP16或INT8)能减少显存占用和加速推理,但不是所有模型都适合。对于小模型(参数量小于1M),量化的收益很小,反而可能因为量化误差导致精度下降。对于大模型(参数量大于100M),INT8量化通常能带来2到3倍的加速,精度损失在1%以内。
剪枝的边界更窄。结构化剪枝(去掉整个通道)能真正加速,但需要重新训练。非结构化剪枝(去掉单个权重)在通用GPU上不会加速,因为GPU的稀疏计算支持有限。我的建议是:先量化,再考虑剪枝,剪枝一定要配合微调。
7.4 一个真实的调优案例:从200ms到45ms
最后分享一个我实际做过的调优案例。一个文本分类服务,初始P99延迟200ms。排查后发现:预处理用了Python的json.loads解析大JSON,耗时80ms;模型推理耗时60ms;后处理用了正则表达式,耗时40ms;剩下20ms是排队和网络。
优化措施:把JSON解析换成orjson,耗时降到15ms;后处理的正则预编译,耗时降到5ms;模型用ONNX Runtime推理,耗时降到25ms。最终P99延迟45ms,吞吐量提升了4倍。
这个案例说明,大部分延迟不在模型本身,而在周边的数据处理上。从零构建AI工程的价值就在于,你能清楚地看到每一毫秒花在哪里,并且有能力去改变它。