
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或macOSPython 3.9以上。先建一个干净的虚拟环境这一步别偷懒我见过太多因为环境混乱导致的诡异问题。依赖方面起步阶段只需要NumPy和FastAPI后面再按需添加。python -m venv venv source venv/bin/activate pip install numpy fastapi uvicorn pydanticNumPy用来做数值计算FastAPI和Uvicorn用来做HTTP服务Pydantic用来做请求校验。这个组合足够轻量能让你把注意力放在核心逻辑上。如果你有GPU可以额外装CUDA版的NumPy替代品但起步阶段CPU就够了先把逻辑跑通。4.2 手写一个简易张量库先定义一个Tensor类包含数据和梯度两个属性以及基本的运算方法。这里我只实现加法和矩阵乘法够用就行。import numpy as np class Tensor: def __init__(self, data, requires_gradFalse): self.data np.asarray(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, 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_gradTrue) self.b1 Tensor(np.zeros(hidden_dim), requires_gradTrue) self.W2 Tensor(np.random.randn(hidden_dim, out_dim) * 0.01, requires_gradTrue) self.b2 Tensor(np.zeros(out_dim), requires_gradTrue) 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, lr0.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.datacross_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_modelPredictResponse) def predict(req: PredictRequest): x Tensor(np.array([req.features])) probs model.forward(x).data[0] label int(np.argmax(probs)) return PredictResponse(labellabel, probsprobs.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工程师最核心的竞争力也是我从这个练习里得到的最有价值的东西。最后分享一个小技巧每次遇到问题不管最后怎么解决的都记下来。我有个文档叫“踩坑记录”里面记了几百条问题和解决方案。这个文档现在是我团队里最受欢迎的参考资料比任何官方文档都实用。从零搭建的过程中你会产生大量这样的记录它们才是你真正的工程能力沉淀。