1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,拖几个组件,调一下API,然后跑通一个Demo,就觉得自己已经掌握了。我刚开始也是这么想的,直到有一次线上推理服务在高峰期直接雪崩,日志里全是显存溢出和请求超时,我才意识到——只会调包的人,根本不知道模型在底层到底经历了什么。ai-engineering-from-scratch这个项目标题,核心不是教你如何快速拼凑一个能跑的东西,而是逼着你从最原始的矩阵乘法开始,把整个AI工程链路亲手搭一遍。它适合那些已经会用框架、但总觉得心里没底的中级开发者,也适合刚入行、想真正理解推理服务内部构造的新人。这篇文章我会把从零构建AI工程的关键环节拆开,包括张量内存布局、算子手写、推理引擎调度、服务化封装和性能压测,全部基于我在实际项目中踩过的坑和验证过的方案。你不需要有编译器背景,但需要愿意动手写代码,而不是只复制粘贴。
2. 为什么“从零”这件事在AI工程里越来越稀缺
2.1 框架封装带来的认知断层
现在的深度学习框架确实把门槛降到了地板以下。model.fit()一调,训练就跑起来了;model.predict()一调,推理就出结果了。但问题在于,当服务出现延迟抖动、吞吐量上不去、显存莫名其妙涨了又降不下来的时候,你打开框架源码,发现里面是几千行的C++和CUDA交织的调度逻辑,根本无从下手。我见过太多团队,模型效果很好,但一上线就崩,最后只能靠加机器硬扛。这就是认知断层的代价——你知道怎么用工具,但不知道工具在背后做了什么。ai-engineering-from-scratch的价值就在于,它强迫你回到没有框架的时代,用最基础的数据结构和循环,把前向传播、反向传播、参数更新全部写一遍。这个过程痛苦,但一旦走通,你再回头看那些框架API,就能一眼看出它在哪个环节做了优化、哪个环节埋了坑。
2.2 从零实现能暴露哪些真实问题
我举一个最典型的例子:内存对齐。当你手写一个矩阵乘法算子时,如果输入张量的内存布局不是连续的,或者步长设置不对,性能可能直接差十倍。这个问题在框架里被自动处理了,你根本感知不到。但一旦你要自己写一个自定义算子,或者做模型量化,内存布局就是绕不过去的坎。再比如,推理时的批处理调度。框架通常给你一个batch_size参数,但实际服务中,请求是流式到达的,如何动态组批、如何设置超时等待、如何在延迟和吞吐之间做权衡,这些策略框架不会告诉你,只能自己从零设计。我在一个实时推荐场景里,就因为组批策略没设计好,导致P99延迟从50毫秒飙到800毫秒。后来把组批逻辑重写了一遍,才把延迟压回去。这些经验,只有从零构建过的人才会真正重视。
2.3 从零不等于重复造轮子
这里要澄清一个误区:从零构建AI工程,不是让你抛弃所有框架,自己写一个PyTorch出来。那是另一个层面的工作,对绝大多数人没有意义。ai-engineering-from-scratch的“从零”是指:你要理解每一层的输入输出、内存变化、计算代价,并且能够用最简化的代码复现核心逻辑。比如,你可以用NumPy实现一个完整的Transformer前向传播,不需要考虑GPU加速,只需要把注意力机制、层归一化、残差连接的计算过程写清楚。这样做的目的是建立直觉,而不是替代生产工具。等你有了这个直觉,再去用TensorRT或者ONNX Runtime做部署,就能准确判断哪些优化是有效的,哪些参数调整是徒劳的。
3. 手写推理引擎:从张量定义到算子调度
3.1 张量类的设计:不只是存数据
从零开始的第一步,是定义一个自己的张量类。很多人觉得张量就是一个多维数组,用NumPy的ndarray就够了。但在AI工程里,张量还需要携带额外信息:数据类型、设备位置、是否需要梯度、内存是否连续、步长是多少。我最初的设计只存了数据和形状,结果在做转置和切片的时候,性能直接崩了。后来参考了主流框架的设计,把步长(stride)加进去,才解决了问题。具体来说,一个形状为(2, 3, 4)的张量,如果内存是连续存储的,它的步长就是(12, 4, 1)。当你做转置操作时,不需要真正移动数据,只需要交换步长即可。这个设计在后续实现矩阵乘法时非常关键,因为BLAS库对内存连续性有要求,步长不对就得先做一次拷贝,代价很高。
class Tensor: def __init__(self, data, shape=None, stride=None, dtype=np.float32): self.data = np.asarray(data, dtype=dtype) self.shape = shape if shape else self.data.shape if stride is None: self.stride = self._compute_stride(self.shape) else: self.stride = stride self.offset = 0 def _compute_stride(self, shape): stride = [1] * len(shape) for i in range(len(shape) - 2, -1, -1): stride[i] = stride[i + 1] * shape[i + 1] return tuple(stride)这个类看起来简单,但它决定了后续所有算子的实现方式。比如切片操作,只需要调整offset和shape,不需要拷贝数据。转置操作,只需要反转shape和stride。这些设计在框架里都是基础,但自己写一遍,感受完全不同。
3.2 矩阵乘法的三种实现与性能对比
矩阵乘法是AI工程里最核心的算子,没有之一。全连接层、注意力机制、卷积展开,底层都是矩阵乘法。我从零实现了三个版本:朴素三重循环、NumPy的dot、以及分块乘法。朴素三重循环在(512, 512)的矩阵上跑了将近3秒,NumPy的dot只要0.8毫秒,分块乘法在块大小设为64时,能跑到1.2毫秒左右。虽然NumPy底层用了BLAS,但分块乘法让我理解了为什么缓存友好性这么重要。具体来说,朴素循环每次取一个元素,内存访问模式是跳跃的,缓存命中率极低。分块乘法把大矩阵切成小块,每个小块能完整放进L1缓存,计算完再换下一块,缓存命中率大幅提升。这个实验让我在后来的推理优化中,特别关注算子的内存访问模式,而不是只看浮点运算次数。
| 实现方式 | 矩阵大小 | 耗时 | 相对加速比 |
|---|---|---|---|
| 朴素三重循环 | 512x512 | 2.8s | 1x |
| NumPy dot | 512x512 | 0.8ms | 3500x |
| 分块乘法(块=64) | 512x512 | 1.2ms | 2333x |
注意:分块乘法虽然比NumPy慢,但它的意义在于让你理解缓存的作用。实际生产中直接用BLAS库即可,不需要自己写。
3.3 算子调度的依赖分析与执行顺序
当你手写多个算子后,下一个问题就是:如何组织它们的执行顺序?在框架里,计算图会自动处理依赖关系。但从零构建时,你需要自己设计一个简单的调度器。我的做法是:每个算子记录它的输入张量和输出张量,调度器根据张量的引用关系,构建一个有向无环图,然后做拓扑排序。这个过程中,我遇到了一个典型问题:原地操作(in-place operation)会破坏依赖关系。比如x = x + 1,如果直接修改x的数据,那么依赖x旧值的算子就会出错。解决方案是引入版本号机制,每次修改张量时递增版本号,调度器检查版本号是否匹配。这个机制在PyTorch里也有,叫version_counter。自己实现一遍,就能理解为什么框架要禁止某些原地操作,以及为什么在推理时开启inference_mode能提升性能。
4. 模型服务化:把推理引擎包装成可用接口
4.1 请求队列与动态组批的设计取舍
推理引擎写好后,下一步是把它变成一个服务。最朴素的做法是:来一个请求,跑一次推理。但这样吞吐量极低,因为GPU利用率上不去。动态组批是解决这个问题的标准方案,但组批策略有很多细节。我试过三种策略:固定超时组批、固定批量组批、以及自适应组批。固定超时组批是设置一个最大等待时间,比如10毫秒,超时或者凑够最大批量就触发推理。这个策略实现简单,但在低流量时延迟高,高流量时批量又容易超限。自适应组批是根据当前队列长度动态调整等待时间,队列长就少等,队列短就多等。我在一个图像分类服务里用了自适应组批,P99延迟降低了40%,吞吐量提升了2.3倍。具体参数是:最小批量4,最大批量32,基础等待时间5毫秒,队列长度超过16时等待时间降为1毫秒。
class BatchScheduler: def __init__(self, min_batch=4, max_batch=32, base_wait=0.005): self.min_batch = min_batch self.max_batch = max_batch self.base_wait = base_wait self.queue = [] def add_request(self, request): self.queue.append(request) if len(self.queue) >= self.max_batch: return self._flush() return None def _flush(self): batch = self.queue[:self.max_batch] self.queue = self.queue[self.max_batch:] return batch def get_wait_time(self): if len(self.queue) > 16: return 0.001 return self.base_wait4.2 序列化与传输格式的选择
服务化绕不开序列化。我对比过JSON、MessagePack、Protobuf和Arrow四种格式。JSON可读性最好,但序列化一个(1, 3, 224, 224)的浮点张量,大小约600KB,序列化耗时约15毫秒。MessagePack大小降到450KB,耗时8毫秒。Protobuf需要预先定义schema,大小约400KB,耗时5毫秒。Arrow是列式存储,大小约380KB,耗时3毫秒,而且支持零拷贝读取。最终我选了Arrow作为内部传输格式,因为它在批量传输时优势明显。但对外接口还是保留了JSON,方便调试和兼容。这里有个坑:浮点数的精度问题。JSON默认用双精度,但模型输入通常是单精度,序列化和反序列化过程中会出现微小的数值差异,导致推理结果不一致。解决方案是在序列化时显式指定单精度,或者用二进制格式直接传字节流。
4.3 健康检查与优雅退出的实现细节
服务上线后,健康检查和优雅退出是必须的。健康检查不能只返回一个200 OK,还要检查推理引擎是否正常、显存是否充足、队列是否积压。我的做法是:暴露一个/health接口,返回当前队列长度、平均推理耗时、GPU显存使用率。如果队列长度超过阈值或者显存使用率超过90%,就返回503,让负载均衡器把流量切走。优雅退出更关键:收到终止信号后,不能直接杀进程,要先把队列里的请求处理完,再关闭推理引擎,最后释放显存。我见过一个服务因为直接kill -9,导致GPU显存没释放,后续服务启动时直接报显存不足。后来加了信号处理,等待所有进行中的推理完成,再退出,问题才解决。
5. 性能压测与瓶颈定位:从数据出发
5.1 压测工具的选择与脚本编写
压测不是随便跑个ab或者wrk就完事了。AI推理服务的压测需要模拟真实请求:输入张量的形状要多样,请求到达要符合泊松分布,还要能统计P50、P90、P99延迟。我用Locust写了一个压测脚本,自定义了客户端,直接发送Arrow格式的张量数据。脚本里设置了三个场景:低负载(10 QPS)、中负载(100 QPS)、高负载(500 QPS),每个场景跑5分钟。结果发现,低负载时P99延迟只有12毫秒,中负载时涨到45毫秒,高负载时直接飙到800毫秒,而且错误率超过5%。这个数据说明服务在中高负载之间存在一个性能悬崖,必须找到瓶颈点。
5.2 瓶颈定位:CPU、GPU还是IO
定位瓶颈的第一步是看监控。我用nvidia-smi看GPU利用率,发现高负载时GPU利用率只有40%,说明GPU不是瓶颈。然后用py-spy抓了CPU火焰图,发现大量时间花在数据预处理上——具体来说是图像解码和归一化。原来我的服务是在Python层面做预处理,GIL锁导致多线程无法并行。解决方案是把预处理移到C++扩展里,或者用torchvision的decode_jpeg直接输出张量。改完之后,GPU利用率升到75%,P99延迟降到120毫秒。第二步是看IO,发现Arrow的反序列化在批量大时耗时明显,后来改成零拷贝读取,又省了10毫秒。第三步是看推理引擎本身,发现矩阵乘法的分块大小没调优,默认块大小64在(256, 768)的矩阵上不是最优,改成128后,单次推理耗时降低了15%。
| 瓶颈环节 | 定位工具 | 优化前 | 优化后 |
|---|---|---|---|
| 数据预处理 | py-spy | 320ms | 45ms |
| 反序列化 | 手动计时 | 25ms | 8ms |
| 矩阵乘法 | 基准测试 | 18ms | 15ms |
| 组批调度 | 日志分析 | 200ms | 60ms |
5.3 压测中发现的三个反直觉现象
第一个现象:批量越大,单样本延迟越低,但P99延迟反而越高。原因是大批量会导致排队时间增加,虽然后续计算快了,但前面的请求等得太久。第二个现象:GPU利用率高不代表性能好。有时候GPU利用率100%,但吞吐量上不去,因为计算单元在等内存。第三个现象:增加工作线程数不一定提升吞吐量。在Python里,由于GIL的存在,多线程对CPU密集型任务无效,必须用多进程。我把工作进程从1个加到4个,吞吐量提升了3.2倍,但再加到8个,吞吐量反而下降了,因为进程间通信和显存拷贝的开销上来了。最终稳定在4个进程,每个进程绑定一个GPU流。
6. 从零构建后的认知升级与后续扩展
走完这一整套从零构建的流程,我最大的感受是:以前看框架文档,觉得那些参数都是魔法数字,现在看,每一个都有明确的物理意义。比如num_workers对应数据加载的并行度,pin_memory对应是否使用锁页内存加速拷贝,batch_size对应组批策略的上限。这些理解不是看书能看来的,必须自己踩一遍坑。后续如果想继续深入,有三个方向:一是把推理引擎用CUDA重写,理解线程束和共享内存;二是引入量化,把FP32模型转成INT8,观察精度和速度的权衡;三是做分布式推理,把模型切到多张卡上,处理跨卡通信。每个方向都能写一篇独立的文章。我在实际项目里,就是从单卡推理开始,逐步做到多卡并行,中间因为张量并行和流水线并行的选择纠结了很久,最后根据模型层数和通信开销,选了流水线并行。这些决策没有标准答案,只有根据具体场景做权衡。如果你也在做类似的事情,建议先把单机单卡跑通,把延迟和吞吐的基线测出来,再考虑扩展。否则,分布式只会让问题更复杂。