尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从零手写AI工程链路:深入理解计算图与推理服务化

发布时间:2026/9/28 17:55:59

资讯中心
01
ARTICLE

从零手写AI工程链路:深入理解计算图与推理服务化

从零手写AI工程链路:深入理解计算图与推理服务化
1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年AI应用开发的门槛肉眼可见地降低了随便拉个框架、调个API就能跑出一个能对话的Demo。但我在带团队和做技术评审的过程中发现一个很普遍的现象很多人能跑通Demo却说不清楚一次推理请求背后到底经过了哪些环节模型加载为什么慢、显存为什么爆、并发一上来就崩这些问题一出现就抓瞎。ai-engineering-from-scratch这个方向说白了就是冲着这个痛点来的——它不满足于让你会“用”AI而是让你理解并亲手实现AI工程链路里的每一个关键环节从张量运算、自动求导、模型加载、推理调度到服务化部署和性能调优一层一层自己搭起来。这篇文章适合谁看如果你是有一定编程基础、想真正搞懂AI系统底层运转逻辑的开发者或者你正在准备AI工程相关的面试、想系统梳理知识体系再或者你带团队做AI项目、需要一套从零构建的参考路径那这篇内容会对你有实打实的帮助。我不会只给你讲概念而是把每个环节的选型理由、参数计算、实操步骤和踩坑经验都摊开来讲让你看完能直接动手复现。我个人的判断是AI工程能力正在从“会调库”向“懂系统”迁移。早期大家拼的是谁先跑通模型现在拼的是谁能把模型稳定、高效、低成本地跑在生产环境里。这个转变意味着光会model.generate()是不够的你得知道KV Cache怎么省显存、批处理怎么提吞吐、量化怎么权衡精度和速度。接下来我就按从底层到上层的顺序把这条从零构建的路径拆开讲。2. 整体设计思路为什么选择从底层手写而不是直接上框架2.1 先搞清楚“从零”到底指什么很多人一听到“从零构建AI工程”第一反应是要不要自己写一个PyTorch出来。我的答案是不需要也不现实。ai-engineering-from-scratch里的“从零”指的是从工程链路的起点开始理解而不是从数学公式的第一行开始推导。具体来说它覆盖的是这样一条链路数据准备与预处理、张量操作与计算图、模型结构搭建与前向传播、损失计算与反向传播、优化器更新、模型保存与加载、推理服务封装、批处理与并发调度、性能监控与调优。这条链路里数学原理你只需要理解到“知道它在干什么”的程度比如你知道矩阵乘法在神经网络里对应的是线性变换知道链式法则决定了梯度怎么传这就够了。真正的重点在于工程实现数据怎么组织成高效的批次、计算怎么利用硬件并行、内存怎么管理、服务怎么扛住并发。这些才是AI工程师日常真正要解决的问题。我选择从底层手写而不是直接上框架核心理由有三个。第一框架帮你屏蔽了太多细节导致你遇到性能瓶颈时无从下手。比如PyTorch的DataLoader有num_workers参数你不理解它背后的多进程机制就调不明白为什么有时候加了worker反而更慢。第二手写一遍能帮你建立准确的性能直觉。你知道一次矩阵乘法的计算量大概是多少FLOPs知道一次全连接层的参数量怎么算这些直觉在优化时极其重要。第三面试和职业发展角度能讲清楚底层原理的人和只会调API的人天花板完全不一样。2.2 技术选型的取舍逻辑既然是“从零”语言和工具的选择就很关键。我的建议是核心计算部分用Python加NumPy起步理解清楚之后再过渡到PyTorch服务化部分用FastAPI性能监控用Prometheus加Grafana这套组合。为什么这么选NumPy起步是因为它的API足够底层ndarray的广播机制、内存布局、视图与拷贝的区别这些概念在NumPy里体现得最清楚。你用NumPy手写一个两层神经网络的前向和反向传播会对“计算图”和“梯度”有非常具体的认识。直接上PyTorch的话autograd帮你把梯度算了你反而失去了理解的机会。过渡到PyTorch是因为生产环境不可能用NumPy手搓你需要框架的GPU加速、自动微分和生态。但有了NumPy的基础你看PyTorch的代码会通透很多知道tensor.backward()背后大概发生了什么。服务化选FastAPI是因为它异步支持好、性能足够、代码简洁而且和Python的AI生态无缝衔接。监控选Prometheus加Grafana是业界最成熟的方案指标暴露简单可视化灵活。注意不要一上来就追求分布式训练、混合精度这些高级特性。先把单机单卡的完整链路跑通把每个环节的耗时和内存占用摸清楚再考虑优化。我见过太多人一上来就搞分布式结果连单卡的瓶颈在哪都不知道。2.3 整体架构的分层设计我把整个系统分成四层来设计这样每层的职责清晰也方便你分阶段实现。第一层是计算层负责张量运算和自动微分。这一层用NumPy实现包含基础的矩阵乘法、激活函数、损失函数以及一个简易的计算图来支持反向传播。第二层是模型层负责定义网络结构、参数初始化和前向传播。这一层可以先用简单的全连接网络再扩展到卷积和注意力机制。第三层是训练层负责数据加载、批次划分、优化器更新和训练循环。第四层是服务层负责模型加载、推理接口、批处理调度和监控指标暴露。这样分层的好处是每一层都可以独立测试和替换。比如你想把计算层从NumPy换成PyTorch只要接口定义清楚上层代码几乎不用改。这种可替换性在真实工程里非常重要因为技术栈是会演进的。3. 核心细节解析手写计算图与自动微分的实操要点3.1 张量类的设计与内存布局手写计算图的第一步是设计一个Tensor类。这个类需要包含几个核心属性data存储实际数值grad存储梯度requires_grad标记是否需要计算梯度_backward存储反向传播函数_prev记录前驱节点。这些设计直接借鉴了微框架的思路但你要理解每个属性的工程意义。data用NumPy数组存储这里有个关键选择用np.float32还是np.float64我的建议是训练用float32因为GPU对float32的支持最好而且float64在深度学习里几乎没有精度收益反而占一倍内存。你可以做个简单计算一个1000乘1000的矩阵float32占4MBfloat64占8MB一层网络几十个这样的矩阵差距就出来了。内存布局方面NumPy默认是C顺序行优先这对大多数操作是友好的。但如果你要做转置操作转置后的数组在内存里不连续后续运算会变慢。这时候用np.ascontiguousarray()强制连续虽然多一次拷贝但后续运算能快不少。这个细节在框架里是自动处理的手写的时候就得自己注意。import numpy as np class Tensor: def __init__(self, data, requires_gradFalse): self.data np.ascontiguousarray(data, dtypenp.float32) self.requires_grad requires_grad self.grad None self._backward lambda: None self._prev set() def __add__(self, other): other other if isinstance(other, Tensor) else Tensor(other) out Tensor(self.data other.data, requires_gradself.requires_grad or other.requires_grad) out._prev {self, other} def _backward(): if self.requires_grad: self.grad (self.grad or 0) out.grad if other.requires_grad: other.grad (other.grad or 0) out.grad out._backward _backward return out上面这段代码展示了加法操作的反向传播。加法的梯度是1所以直接把上游梯度传下来就行。这里有个工程细节self.grad (self.grad or 0) out.grad为什么要用累加而不是直接赋值因为一个张量可能被多个下游节点使用梯度需要累加。这是自动微分里最容易出错的地方之一我当初手写的时候就因为直接赋值导致梯度算错排查了大半天。3.2 反向传播的拓扑排序实现反向传播的核心是沿着计算图反向遍历依次调用每个节点的_backward函数。但这里有个顺序问题必须保证一个节点的所有下游节点都处理完了才能处理它自己。这就是拓扑排序的作用。实现思路是从输出节点开始做一次深度优先搜索记录每个节点的访问状态最后得到一个拓扑序然后反向遍历这个序列调用_backward。这个算法的时间复杂度是O(VE)V是节点数E是边数。对于典型的神经网络节点数在几百到几千量级这个开销完全可以接受。def backward(self): topo [] visited set() def build_topo(v): if v not in visited: visited.add(v) for child in v._prev: build_topo(child) topo.append(v) build_topo(self) self.grad np.ones_like(self.data) for node in reversed(topo): node._backward()这段代码里self.grad np.ones_like(self.data)是设置输出节点的初始梯度。对于损失函数来说这个初始梯度通常是1因为损失对自己的导数就是1。但如果你做的是其他操作这个初始值需要根据具体情况调整。实操心得手写反向传播时建议先用数值梯度检验来验证。具体做法是给某个参数加一个极小值epsilon计算损失变化再除以epsilon得到数值梯度和你算出来的解析梯度对比。如果误差在1e-5以内说明实现正确。这个检验方法虽然慢但能帮你快速定位bug。3.3 激活函数与损失函数的工程实现激活函数里ReLU是最常用的实现也简单np.maximum(0, x)。但反向传播时要注意ReLU在x小于0时梯度为0大于0时梯度为1。实现时用(x 0)生成掩码然后乘以上游梯度。Sigmoid和Tanh现在用得少了但在某些场景还有用。Sigmoid的实现要注意数值稳定性1 / (1 np.exp(-x))在x很大时np.exp(-x)会下溢更好的写法是分情况处理。不过说实话现在隐藏层基本都用ReLU或者GELUSigmoid主要用在二分类的输出层。损失函数方面交叉熵损失是分类任务的标准选择。它的实现有个坑直接算-log(softmax(x))在数值上不稳定因为softmax可能产生0log(0)是负无穷。正确的做法是用log-sum-exp技巧把softmax和log合并计算。这个技巧在框架里是内置的手写的时候必须自己处理。def cross_entropy_loss(logits, targets): # logits: (batch_size, num_classes) # targets: (batch_size,) 整数标签 shifted logits - np.max(logits, axis1, keepdimsTrue) log_probs shifted - np.log(np.sum(np.exp(shifted), axis1, keepdimsTrue)) batch_size logits.shape[0] loss -np.mean(log_probs[np.arange(batch_size), targets]) return loss这里的shifted logits - np.max(logits, axis1, keepdimsTrue)就是数值稳定技巧减去最大值不改变softmax结果但能防止指数溢出。这个细节在面试里经常被问到也是实际工程中必须处理的。4. 实操过程从训练循环到推理服务的完整搭建4.1 数据加载与批次划分的性能考量数据加载看起来简单但它是训练性能的关键瓶颈之一。我做过一个测试在一个图像分类任务里如果数据加载用单进程同步读取GPU利用率只有30%左右大部分时间都在等数据。改成多进程预读取之后GPU利用率能到90%以上。批次划分的核心参数是batch_size。这个参数怎么选我的经验是从32或64起步然后根据显存占用调整。显存够就加大batch_size能提高吞吐显存不够就减小或者用梯度累积来模拟大batch。这里有个计算公式显存占用约等于batch_size × 单样本激活值大小 × 层数。你可以先跑一个batch用nvidia-smi看显存占用然后线性推算最大batch_size。数据预读取的实现思路是维护一个队列后台进程持续从磁盘读数据并做预处理训练进程从队列取数据。这样数据读取和计算重叠减少等待。Python里可以用multiprocessing.Queue实现但要注意进程间通信的开销数据量大的时候用共享内存更高效。import multiprocessing as mp class DataLoader: def __init__(self, dataset, batch_size, num_workers4): self.dataset dataset self.batch_size batch_size self.num_workers num_workers self.queue mp.Queue(maxsize10) def _worker(self): while True: indices np.random.choice(len(self.dataset), self.batch_size) batch [self.dataset[i] for i in indices] self.queue.put(batch) def __iter__(self): for _ in range(self.num_workers): p mp.Process(targetself._worker) p.daemon True p.start() while True: yield self.queue.get()这个简化版的DataLoader展示了核心思路。实际使用中还要处理epoch结束、数据打乱、异常处理等问题。maxsize10是控制队列长度防止内存爆掉。daemonTrue保证主进程退出时子进程也跟着退出。4.2 训练循环的完整实现与监控训练循环的骨架是前向传播算损失、反向传播算梯度、优化器更新参数。但工程实现里有很多细节。比如梯度清零的时机必须在每次反向传播之前清零否则梯度会累加。再比如学习率调度训练初期用大学习率快速下降后期用小学习率精细调整。def train(model, dataloader, epochs, lr0.01): optimizer SGD(model.parameters(), lrlr) losses [] for epoch in range(epochs): epoch_loss 0 for batch_x, batch_y in dataloader: # 前向传播 logits model(batch_x) loss cross_entropy_loss(logits, batch_y) # 反向传播 optimizer.zero_grad() loss.backward() optimizer.step() epoch_loss loss.data avg_loss epoch_loss / len(dataloader) losses.append(avg_loss) print(fEpoch {epoch}, Loss: {avg_loss:.4f}) return losses监控方面我建议至少记录三个指标损失值、学习率、梯度范数。损失值看收敛情况学习率看调度是否合理梯度范数看是否出现梯度爆炸或消失。梯度范数如果持续增大说明学习率可能太大如果接近0说明梯度消失可能需要换激活函数或加残差连接。注意训练过程中如果损失突然变成NaN八成是学习率太大导致梯度爆炸。这时候先降低学习率如果还不行就加梯度裁剪。梯度裁剪的实现很简单算完梯度后如果范数超过阈值就按比例缩放。4.3 模型保存与加载的兼容性处理模型保存不只是存参数还要存结构信息否则加载的时候对不上。我的做法是存一个字典包含state_dict参数、config超参数、version版本号。版本号很重要因为模型结构可能会演进加载旧版本时要能兼容。def save_model(model, path, config): checkpoint { state_dict: {k: v.data for k, v in model.named_parameters()}, config: config, version: 1.0 } np.savez(path, **checkpoint) def load_model(path, model_class): checkpoint np.load(path, allow_pickleTrue) config checkpoint[config].item() model model_class(**config) state_dict checkpoint[state_dict].item() for k, v in model.named_parameters(): v.data state_dict[k] return model这里用np.savez存成压缩包比pickle安全也比纯文本高效。allow_pickleTrue是因为config是字典需要pickle支持。实际生产环境里我建议用更结构化的格式比如ONNX或者SafeTensors兼容性和安全性更好。4.4 推理服务的封装与批处理调度推理服务和训练最大的区别是训练追求吞吐推理追求延迟。但推理也可以做批处理来提高吞吐关键是怎么在延迟和吞吐之间权衡。我的做法是设置一个最大等待时间比如10毫秒在这段时间内攒够一批请求就一起推理攒不够就单个推理。from fastapi import FastAPI import asyncio app FastAPI() model None request_queue [] batch_lock asyncio.Lock() app.post(/predict) async def predict(input_data: dict): async with batch_lock: request_queue.append(input_data) if len(request_queue) 8: batch request_queue[:8] request_queue.clear() else: await asyncio.sleep(0.01) batch request_queue[:] request_queue.clear() # 批处理推理 inputs np.stack([item[data] for item in batch]) outputs model(inputs) return {results: outputs.tolist()}这个实现用异步锁来保护队列攒够8个或者等10毫秒就触发推理。实际生产里还要考虑超时、错误处理、请求优先级等。批处理大小8是个经验值太小了吞吐上不去太大了延迟高。你可以根据实际负载压测来调整。5. 常见问题与排查技巧实录5.1 梯度相关问题的排查思路梯度问题是手写AI系统里最常见的。我整理了一个排查表按现象、可能原因、排查方法、解决方案来组织。现象可能原因排查方法解决方案损失不下降学习率太小或梯度消失打印梯度范数调大学习率或换激活函数损失震荡学习率太大观察损失曲线降低学习率或加动量损失变NaN梯度爆炸或除零检查中间值梯度裁剪或加epsilon梯度为0计算图断开检查requires_grad确保所有参数参与计算梯度累加错误未清零或重复累加对比数值梯度检查zero_grad时机梯度消失的典型表现是靠近输入的层梯度接近0参数几乎不更新。原因是链式法则里多个小于1的导数相乘越乘越小。解决方案是用ReLU替代Sigmoid或者加残差连接让梯度有捷径可走。梯度爆炸则相反梯度值越来越大最终溢出。解决方案是梯度裁剪把梯度范数限制在一个阈值内。阈值一般设1.0或5.0具体看任务。5.2 内存与性能问题的实战排查内存问题在AI工程里太常见了。我遇到过一个案例训练一个中等规模的模型显存总是差一点不够batch_size只能设到8。后来发现是中间激活值没有及时释放导致显存峰值过高。解决方案是用梯度检查点技术牺牲一点计算时间换显存空间。性能问题的排查我一般用分层定位法。先看GPU利用率如果低于50%说明瓶颈在数据加载或CPU预处理。再看CPU利用率如果某个核跑满说明是单线程瓶颈。最后看磁盘IO如果读取速度慢说明数据格式需要优化比如从图片文件改成打包的二进制格式。# 实时监控GPU利用率 nvidia-smi -l 1 # 监控CPU和内存 htop # 监控磁盘IO iostat -x 1这三个命令是我排查性能问题的标配。nvidia-smi -l 1每秒刷新一次GPU状态htop看CPU和内存iostat看磁盘。结合起来就能快速定位瓶颈在哪。实操心得如果GPU利用率忽高忽低通常是数据加载不稳定导致的。可以尝试增大预读取队列长度或者把数据预处理提前做好训练时只做必要的转换。我试过把图片预处理从训练时移到数据准备阶段训练速度提升了将近一倍。5.3 数值稳定性问题的处理技巧数值稳定性问题往往很隐蔽但后果严重。除了前面提到的softmax溢出还有几个常见场景。一是除法操作分母可能为0要加一个极小的epsilon。二是log操作输入可能为0或负数要加epsilon或者用clamp限制范围。三是平方根操作输入可能为负要加epsilon。# 安全的除法 def safe_divide(a, b, eps1e-8): return a / (b eps) # 安全的log def safe_log(x, eps1e-8): return np.log(np.maximum(x, eps)) # 安全的sqrt def safe_sqrt(x, eps1e-8): return np.sqrt(np.maximum(x, eps))这些安全函数看起来简单但在实际工程里能避免很多莫名其妙的NaN。我建议在写任何涉及除法、log、sqrt的操作时都下意识地考虑数值稳定性。5.4 服务化部署的常见坑推理服务部署有几个坑我踩过。第一个是模型加载慢每次请求都加载模型肯定不行要在服务启动时加载一次常驻内存。第二个是并发问题多个请求同时访问模型如果模型不是线程安全的会出问题。解决方案是加锁或者用多进程。第三个是内存泄漏长时间运行后内存持续增长通常是缓存没清理或者张量没释放。# 服务启动时加载模型 app.on_event(startup) async def startup_event(): global model model load_model(model.npz, MyModel) model.eval() # 切换到推理模式 # 推理时禁用梯度计算 app.post(/predict) async def predict(input_data: dict): with np.no_grad(): # 类比PyTorch的no_grad result model(input_data) return resultmodel.eval()在PyTorch里是切换Dropout和BatchNorm的行为手写框架里也要实现类似机制。推理时禁用梯度计算能省不少内存因为不需要存中间激活值。6. 从手写实现到生产框架的迁移路径6.1 什么时候该切换到成熟框架手写实现的价值在于理解原理但生产环境还是要用成熟框架。切换的时机是当你已经能准确判断性能瓶颈在哪并且知道框架的哪些特性可以解决这些问题时。比如你知道数据加载是瓶颈就会去用PyTorch的DataLoader并合理设置num_workers你知道显存不够就会去用混合精度训练或梯度检查点。迁移的时候我建议保持接口不变只替换实现。比如你的Tensor类可以包装PyTorch的Tensorbackward方法调用PyTorch的autograd。这样上层代码不用改迁移成本最低。6.2 手写经验如何反哺框架使用手写一遍之后你看框架文档会有完全不同的感受。比如PyTorch的torch.no_grad()你知道它省的是中间激活值的内存optimizer.zero_grad()你知道它清的是累加的梯度model.train()和model.eval()你知道它切换的是Dropout和BatchNorm的行为。这些理解让你在调参和排查问题时更有方向。我在面试里经常问候选人为什么训练时要zero_grad能答出“因为梯度会累加”的人不少但能进一步解释“为什么设计成累加而不是覆盖”的人就少了。累加的设计是为了支持多任务学习、梯度累积这些场景覆盖的话这些功能就实现不了。这种理解只有手写过才会有。6.3 持续深入的学习方向手写实现只是起点后面还有很长的路。我建议按这个顺序深入先搞定单机单卡的完整链路再学分布式训练数据并行、模型并行然后学推理优化量化、剪枝、蒸馏最后学服务化架构负载均衡、自动扩缩容、A/B测试。每个方向都有对应的论文和开源项目可以参考。我的习惯是看论文理解原理看开源项目学工程实现然后自己动手复现关键部分。这个过程很慢但每一步都扎实。最后分享一个小技巧建立一个自己的“性能基线库”记录不同模型、不同硬件、不同配置下的训练和推理性能。下次遇到新任务时先查基线库估算资源需求能避免很多盲目试错。我积累了两年的基线数据现在评估一个新模型需要多少资源误差基本在20%以内。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。