☰
从零搭建AI工程能力:手写张量、自动求导与推理服务实战
2026/9/30 8:25:01 网站建设 项目流程

1. 从零搭建AI工程能力:为什么我劝你别一上来就调包

这两年AI应用开发的门槛肉眼可见地降低了,一个刚入门的开发者,花一个下午就能用现成的框架跑通一个对话机器人。但我在团队里带过不少新人,也面试过很多号称“做过AI项目”的候选人,发现一个很普遍的现象:大家能把模型跑起来,却说不清楚一次推理背后到底发生了什么,显存为什么突然爆了,延迟为什么忽高忽低,换个模型为什么整个流程就崩了。这就是我特别想聊“ai-engineering-from-scratch”这个方向的原因——不是让你拒绝框架,而是让你在依赖框架之前,先亲手把关键环节走一遍,知道每一层在替你做什么。

所谓AI工程能力,说白了就是把一个模型从“能跑”变成“稳定、可控、可维护地跑”的能力。它涵盖的东西比训练一个模型要杂得多:数据怎么进、张量怎么算、显存怎么管、推理怎么加速、服务怎么部署、监控怎么做。标题里的“from-scratch”我理解成两层意思,一层是从零开始理解原理,另一层是从零开始搭一套最小可用的工程链路。这篇文章适合那些已经会用框架、但总觉得自己在“黑盒”上跳舞的开发者,也适合想系统补齐AI工程基础的在校同学。我会把整个从零搭建的思路、关键细节、实操步骤和我自己踩过的坑都摊开讲,尽量让你看完能直接动手复现。

2. 整体设计思路:先拆黑盒,再谈工程

2.1 为什么“从零”反而更快

很多人有个误区,觉得从零实现就是重复造轮子,浪费时间。我一开始也这么想,直到有一次线上推理服务出现偶发的数值异常,排查了整整两天,最后发现是某个框架版本里一个默认的算子融合行为导致的。如果我对底层的张量计算和算子行为有清晰认知,这个问题可能半小时就能定位。从零实现的价值不在于替代框架,而在于建立一套“心智模型”:你知道矩阵乘法在内存里是怎么走的,知道一次前向传播会分配多少中间张量,知道softmax在数值上为什么要先减最大值。这些认知在调优和排障时就是你的底气。

从工程角度看,从零搭建还能帮你建立正确的抽象分层。框架帮你封装了太多东西,导致很多人分不清“模型逻辑”和“工程逻辑”的边界。比如一个文本分类服务,模型部分只负责把输入张量映射到输出logits,而分词、批处理、超时控制、降级策略这些都属于工程层。把这两层混在一起写,后期维护就是灾难。从零搭一遍,你会自然地把这些层分开。

2.2 分层设计:把大象切成块

我习惯把整个AI工程链路分成五层,从下往上依次是:数值计算层、模型结构层、训练/推理循环层、服务封装层、可观测层。每一层只依赖它下面的一层,层与层之间通过明确的接口通信。这个分层不是教科书上的标准答案,而是我在实际项目中反复调整后觉得最顺手的一种切法。

数值计算层负责张量、算子、自动求导这些最基础的东西。模型结构层负责把算子组合成网络,定义参数和初始化。训练/推理循环层负责数据迭代、损失计算、梯度更新或前向推理。服务封装层负责把模型包装成可调用的接口,处理请求解析、批处理、并发。可观测层负责日志、指标、追踪。为什么要这么分?因为每一层的变更频率和测试方式完全不同。数值层几乎不变,模型层偶尔变,循环层经常调,服务层天天改。分层之后,改服务层不会影响模型层的测试,改模型层也不会把服务层搞崩。

2.3 技术选型的取舍逻辑

从零搭建不代表什么都要自己写。我的原则是:核心认知相关的部分自己写,纯工程脚手架用现成的。比如张量运算和自动求导,我会用NumPy手写一遍,因为这是理解深度学习的核心。但HTTP服务框架我会直接用FastAPI,因为这部分没有认知价值,纯粹是体力活。再比如分词器,我会先用简单的空格分词跑通流程,等流程稳定了再换成成熟的子词分词方案。

具体到工具,数值层我用NumPy起步,因为它足够透明,每个操作你都能看到内存变化。等理解了之后再对比PyTorch的Tensor,感受框架帮你省了什么。模型层我会手写一个两层全连接网络和一个简单的注意力模块,不追求性能,只追求逻辑清晰。循环层用纯Python写训练循环,手动实现mini-batch、梯度清零、参数更新。服务层用FastAPI加Uvicorn,配合Pydantic做请求校验。可观测层先用Python的logging和time模块,后面再引入Prometheus这类专业工具。这个选型思路的核心是:每一层都先用最朴素的方案跑通,再逐步替换成工业级方案,这样你永远知道每个组件存在的理由。

3. 核心细节解析:那些框架不会告诉你的东西

3.1 张量运算:内存布局决定性能

手写张量运算时,第一个让我震惊的事实是:同样的矩阵乘法,不同的内存布局性能能差好几倍。NumPy默认是行优先(C order),而很多底层库偏好列优先(Fortran order)。当你做A @ B时,如果A是行优先、B是列优先,底层BLAS库能更高效地利用缓存。这个细节在框架里被自动处理了,但你自己写的时候如果不在意,性能就会莫名其妙地差。

另一个关键点是广播机制。广播让不同形状的张量能一起运算,但它是有代价的。比如一个形状(1000, 1)的张量和一个形状(1, 1000)的张量相加,结果会是(1000, 1000),瞬间产生一百万个元素。我在早期项目里就犯过这个错,一个本意是逐元素相加的操作,因为形状没对齐,变成了外积式的广播,显存直接爆掉。所以手写的时候一定要养成习惯:每次运算前打印形状,确认广播行为符合预期。

还有数值稳定性。手写softmax时,如果直接算exp(x) / sum(exp(x)),当x里有较大的值时,exp会溢出成inf。正确做法是先减去最大值:exp(x - max(x)) / sum(exp(x - max(x)))。这个技巧框架里是默认做的,但你自己写的时候如果不知道,就会遇到莫名其妙的nan。类似的还有log-sum-exp、sigmoid的稳定实现等。这些数值技巧是AI工程的基本功,值得单独花时间整理成工具函数。

3.2 自动求导:计算图的构建与释放

自动求导的核心是计算图。每次前向传播时,框架会记录所有操作,形成一个有向无环图,反向传播时沿着图反向计算梯度。手写一个简易版自动求导,能帮你理解几个关键问题:为什么需要保留中间激活值?为什么推理时可以用torch.no_grad()省显存?为什么有些操作不支持高阶求导?

我手写自动求导时用的是“值+梯度”的节点设计,每个节点记录自己的值和产生它的操作,反向传播时递归调用每个操作的backward函数。这个实现很慢,但逻辑清晰。写完之后我就明白了:训练时显存占用大,很大一部分是中间激活值;推理时不需要这些,所以可以关掉梯度追踪。这个认知直接指导了我在服务层的设计——推理路径和训练路径必须分开,推理时坚决不构建计算图。

还有一个容易忽略的点是计算图的释放。在训练循环里,如果每个batch结束后不把计算图释放掉,显存会持续增长。PyTorch里是通过loss.backward()之后自动释放,但如果你手动实现了自动求导,就要记得在参数更新后清空图。这个细节在长训练任务里特别重要,我见过有人因为忘了释放,跑了几百个step就OOM了。

3.3 批处理与填充:效率与正确性的平衡

批处理是提升推理吞吐最直接的手段,但它带来一个经典问题:变长序列怎么处理?文本、语音、时间序列都是变长的,而张量要求形状固定。常见的做法是填充到批次内最大长度,同时用attention mask标记哪些位置是填充的。这里有个坑:填充位置如果不屏蔽,注意力机制会把它们也算进去,导致结果偏差。

我手写注意力时,一开始忘了加mask,模型在短序列上表现正常,在长序列上就崩了。排查后发现是填充位置的注意力权重没有被置零。正确的做法是在计算attention score之后、softmax之前,把填充位置对应的score设成一个很大的负数,这样softmax之后它们的权重就接近零。这个技巧叫masked softmax,是序列建模的必备操作。

另一个批处理的细节是动态批处理。固定批大小在请求量波动时效率很低:请求少时GPU闲置,请求多时排队。动态批处理会在一个很短的时间窗口内收集请求,凑够一定数量或超时后一起推理。这个策略能显著提升吞吐,但实现时要小心超时控制和批大小上限,否则延迟会失控。我在服务层用了一个简单的队列加定时器来实现,效果比固定批大小好很多。

3.4 显存管理:看不见的成本

显存是AI工程里最稀缺的资源之一。除了模型参数和激活值,还有很多隐形的显存开销:CUDA上下文、内存碎片、缓存分配器。手写推理时,如果不注意,很容易出现“明明模型不大,却跑不起来”的情况。

一个实用的技巧是预分配显存池。框架通常有自己的缓存分配器,但你自己写的时候,频繁的malloc和free会导致碎片。我的做法是在初始化时一次性申请一大块显存,然后自己管理分配和回收。这个实现有点复杂,但对于需要长时间稳定运行的服务来说很值得。另一个技巧是及时释放不再需要的中间张量,Python的引用计数机制在这里帮不上忙,因为张量可能还被计算图引用着,需要显式地断开引用。

还有一个容易被忽略的点是数据类型。float32和float16的显存占用差一倍,推理时用float16通常精度损失可接受,但要注意数值范围。我遇到过用float16时梯度下溢的问题,后来改用了混合精度,关键部分保持float32,其余用float16。这个策略在训练和推理里都适用。

4. 实操过程:从零搭一个最小可用的推理服务

4.1 环境准备与依赖安装

我假设你用的是Linux或macOS,Python 3.9以上。先建一个干净的虚拟环境,这一步别偷懒,我见过太多因为环境混乱导致的诡异问题。依赖方面,起步阶段只需要NumPy和FastAPI,后面再按需添加。

python -m venv venv source venv/bin/activate pip install numpy fastapi uvicorn pydantic

NumPy用来做数值计算,FastAPI和Uvicorn用来做HTTP服务,Pydantic用来做请求校验。这个组合足够轻量,能让你把注意力放在核心逻辑上。如果你有GPU,可以额外装CUDA版的NumPy替代品,但起步阶段CPU就够了,先把逻辑跑通。

4.2 手写一个简易张量库

先定义一个Tensor类,包含数据和梯度两个属性,以及基本的运算方法。这里我只实现加法和矩阵乘法,够用就行。

import numpy as np class Tensor: def __init__(self, data, requires_grad=False): self.data = np.asarray(data, dtype=np.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, self.requires_grad or other.requires_grad) 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 out._prev = {self, other} return out def __matmul__(self, other): out = Tensor(self.data @ other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = (self.grad or 0) + out.grad @ other.data.T if other.requires_grad: other.grad = (other.grad or 0) + self.data.T @ out.grad out._backward = _backward out._prev = {self, other} return out def backward(self): topo = [] visited = set() def build(v): if v not in visited: visited.add(v) for child in v._prev: build(child) topo.append(v) build(self) self.grad = np.ones_like(self.data) for v in reversed(topo): v._backward()

这段代码很粗糙,但包含了自动求导的核心逻辑:前向时记录操作和输入,反向时按拓扑序调用每个操作的backward。你可以用它跑一个简单的线性回归,感受一下梯度是怎么流动的。写完之后再去看PyTorch的autograd文档,会有种豁然开朗的感觉。

4.3 构建一个简单的模型

用上面的Tensor搭一个两层全连接网络,做一个小型分类任务。模型结构是输入层到隐藏层用ReLU激活,隐藏层到输出层用softmax。

class SimpleNet: def __init__(self, in_dim, hidden_dim, out_dim): self.W1 = Tensor(np.random.randn(in_dim, hidden_dim) * 0.01, requires_grad=True) self.b1 = Tensor(np.zeros(hidden_dim), requires_grad=True) self.W2 = Tensor(np.random.randn(hidden_dim, out_dim) * 0.01, requires_grad=True) self.b2 = Tensor(np.zeros(out_dim), requires_grad=True) def forward(self, x): h = (x @ self.W1 + self.b1).relu() logits = h @ self.W2 + self.b2 return logits.softmax() def parameters(self): return [self.W1, self.b1, self.W2, self.b2]

这里需要给Tensor补上relu和softmax方法,以及对应的backward。relu的backward是梯度在输入大于零时原样传回,否则为零。softmax的backward稍微复杂一点,但网上有标准推导,照着实现就行。这个模型很小,但包含了神经网络的所有核心要素:线性变换、非线性激活、概率输出、参数管理。

4.4 训练循环与参数更新

训练循环是AI工程里最考验细节的地方。我把它拆成几个明确的步骤:取数据、前向传播、计算损失、反向传播、更新参数、清零梯度。

def train_step(model, x, y, lr=0.01): logits = model.forward(x) loss = cross_entropy(logits, y) loss.backward() for p in model.parameters(): p.data -= lr * p.grad p.grad = None return loss.data

cross_entropy的实现要注意数值稳定性,先对logits做log_softmax再取负对数似然。参数更新用的是最朴素的SGD,没有动量、没有自适应学习率,但足够让你理解优化的本质。清零梯度这一步千万别忘,我见过有人因为忘了清零,梯度不断累加,训练直接发散。

跑几十个epoch,观察loss下降曲线。如果loss不降,先检查学习率是不是太大,再检查数据有没有归一化,最后检查梯度有没有正确传播。这个排查顺序是我踩了很多坑之后总结出来的,能覆盖大部分常见问题。

4.5 封装成HTTP服务

模型训练好之后,用FastAPI把它包装成一个推理接口。请求体包含输入特征,响应体返回预测类别和概率。

from fastapi import FastAPI from pydantic import BaseModel import numpy as np app = FastAPI() model = SimpleNet(10, 32, 3) # 这里省略加载训练好的参数 class PredictRequest(BaseModel): features: list[float] class PredictResponse(BaseModel): label: int probs: list[float] @app.post("/predict", response_model=PredictResponse) def predict(req: PredictRequest): x = Tensor(np.array([req.features])) probs = model.forward(x).data[0] label = int(np.argmax(probs)) return PredictResponse(label=label, probs=probs.tolist())

启动命令是uvicorn main:app --host 0.0.0.0 --port 8000。这个服务很简陋,没有批处理、没有并发控制、没有超时,但它是一个完整的端到端链路。你可以用curl或Postman发请求测试,感受一下从HTTP请求到模型推理的完整流程。跑通之后,再逐步加上批处理、缓存、限流这些工程特性。

5. 常见问题与排查技巧实录

5.1 数值异常排查速查表

数值问题是AI工程里最烦人的一类,因为它们往往不报错,只是结果悄悄变差。我整理了一个速查表,按现象分类。

现象可能原因排查方法
loss变成nan学习率过大、除零、log(0)降低学习率,检查分母和log输入
loss不下降梯度消失、数据未归一化、标签错误打印梯度范数,检查数据分布和标签
推理结果随机参数未加载、权重初始化错误对比训练和推理的输入输出
显存持续增长计算图未释放、缓存未清理检查backward后是否清空图
性能突然下降内存碎片、批大小变化监控显存和延迟指标

这个表是我从多次线上事故中总结出来的,覆盖了八成以上的常见问题。遇到数值异常时,先按表排查,能省很多时间。

5.2 性能调优的实操心得

性能调优不是盲目试参数,而是先定位瓶颈。我的习惯是先测基线,再逐层优化。基线测试包括:单条请求的延迟、批量请求的吞吐、不同批大小下的资源占用。有了基线数据,才能判断优化是否有效。

第一个优化点通常是批处理。把单条推理改成批量推理,吞吐能提升几倍到几十倍。但批大小不是越大越好,要找到延迟和吞吐的平衡点。我的经验是,从批大小1开始,逐步翻倍,观察延迟变化。当延迟增长开始超过吞吐增长时,就接近最优点了。

第二个优化点是数据类型。float16通常能带来接近两倍的吞吐提升,但要注意精度损失。我的做法是先用float32跑通,再切换到float16对比结果,如果精度损失在可接受范围内就保留。对于分类任务,float16通常没问题;对于回归任务,要谨慎一些。

第三个优化点是算子融合。把多个小算子合并成一个大算子,能减少内存访问和kernel启动开销。这个在框架里通常是自动的,但你自己写的时候要注意。比如把矩阵乘法和偏置加法融合成一个操作,能省一次内存读写。

5.3 服务稳定性的避坑指南

服务上线后,稳定性比性能更重要。我踩过的坑包括:请求超时没处理导致线程堆积、内存泄漏导致服务逐渐变慢、异常输入导致服务崩溃。这些问题的共同点是:在测试环境很难复现,上线后才暴露。

我的应对策略是:第一,所有外部输入都要校验,用Pydantic定义严格的请求模型,拒绝不符合预期的输入。第二,所有可能阻塞的操作都要设超时,包括模型推理、数据库查询、外部调用。第三,加一个全局异常处理器,把未捕获的异常记录日志并返回友好的错误信息,而不是让服务崩溃。第四,定期做压力测试,模拟高并发和异常输入,提前发现问题。

还有一个容易被忽略的点是优雅关闭。服务收到停止信号时,应该先停止接收新请求,等正在处理的请求完成后再退出。这个在Kubernetes环境里特别重要,否则滚动更新时会丢请求。FastAPI配合Uvicorn可以通过信号处理实现优雅关闭,具体做法是注册shutdown事件,在里面等待正在进行的任务完成。

5.4 从零实现到生产可用的差距

手写一个能跑的版本和做一个生产可用的服务,中间隔着很多工程细节。我列几个最关键的差距:第一,错误处理。手写版本通常假设输入都是合法的,生产环境必须处理各种异常输入。第二,并发控制。手写版本通常是单线程的,生产环境要处理并发请求,需要线程池或异步。第三,监控告警。手写版本靠打印日志,生产环境需要指标采集和告警。第四,版本管理。模型和代码都要有版本,方便回滚和对比。

这些差距不是一篇文章能全部覆盖的,但我想强调的是:从零实现的价值在于让你理解每个工程细节的必要性。当你亲手经历过因为没有超时控制导致服务雪崩,你就会明白为什么生产环境里每个外部调用都要设超时。这种认知是看文档看不来的,必须自己踩一遍。

6. 后续扩展方向与个人体会

这套从零搭建的链路跑通之后,你可以往几个方向扩展。一个是性能方向,引入更高效的数值库、尝试量化、做算子融合。另一个是功能方向,加上模型版本管理、A/B测试、在线学习。还有一个是规模方向,把单机服务扩展成分布式推理,处理更大的流量。

我个人在实际操作中的体会是,从零搭建最大的收获不是写出了多好的代码,而是建立了一套判断力。当框架出新版本时,你能判断哪些变化会影响你的服务;当性能出问题时,你能快速定位到是哪一层的问题;当需要做技术选型时,你能清楚地知道每个选项的代价。这种判断力是AI工程师最核心的竞争力,也是我从这个练习里得到的最有价值的东西。

最后分享一个小技巧:每次遇到问题,不管最后怎么解决的,都记下来。我有个文档叫“踩坑记录”,里面记了几百条问题和解决方案。这个文档现在是我团队里最受欢迎的参考资料,比任何官方文档都实用。从零搭建的过程中,你会产生大量这样的记录,它们才是你真正的工程能力沉淀。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询