ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零构建AI工程能力:手写数据、模型、推理与服务全链路

从零构建AI工程能力:手写数据、模型、推理与服务全链路 1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年AI应用开发的门槛肉眼可见地降低了随便拉个框架、调个API就能跑出一个能对话的Demo。但我带过不少新人也面试过不少号称“做过AI项目”的候选人发现一个很普遍的问题大家会用工具但不知道工具背后发生了什么。模型输出不稳定不知道从哪查推理速度慢不知道怎么优化显存爆了只会加钱换卡。这种状态下做出来的东西能跑但不可靠更谈不上工程化。ai-engineering-from-scratch这个方向说白了就是反过来走一遍——不急着调包先把AI工程里那些绕不开的核心环节用最朴素的方式亲手实现一遍。它解决的不是“怎么快速出效果”而是“怎么真正理解并掌控整个链路”。适合谁看如果你已经会用Python跑过几个模型Demo但总觉得心里没底想搞清楚数据怎么流、梯度怎么算、推理怎么加速、服务怎么部署那这篇内容就是写给你的。我会按照一个从业者实际踩过的路径把从零构建AI工程能力的思路、关键细节、实操步骤和避坑经验完整拆开讲。2. 整体设计思路为什么“从零”比“调包”更值得投入2.1 先搞清楚“从零”到底指什么很多人一听到“从零实现”第一反应是手写一个Transformer。这个理解太窄了。AI工程能力不等于模型结构本身它覆盖的是从数据进入系统到结果返回用户的完整链路。我把它拆成四层数据层、模型层、推理层、服务层。数据层负责清洗、分词、批处理模型层负责前向传播、损失计算、反向传播推理层负责权重加载、算子优化、显存管理服务层负责接口设计、并发处理、监控告警。ai-engineering-from-scratch的核心思路是这四层里每一层都至少亲手实现一个最小可用版本。不是让你抛弃现成框架而是让你在用过框架之后能看懂框架替你做了什么。我自己的经验是手写一遍反向传播之后再看PyTorch的autograd理解深度完全不一样手写一遍KV Cache之后再调推理框架的参数就知道每个参数在省什么、换什么。2.2 方案选型为什么用NumPy起步而不是直接上框架这里有个关键取舍。直接用PyTorch或TensorFlow当然快但框架把太多细节封装掉了初学者很容易陷入“能跑就行”的状态。用NumPy从零实现好处是每一个矩阵乘、每一次梯度更新都暴露在你眼前维度不对、梯度消失、数值溢出这些问题会逼着你去理解原理。但也不能一直停在NumPy。我的建议是分阶段第一阶段用NumPy实现一个极简的神经网络包括前向、损失、反向和参数更新第二阶段切换到PyTorch但手动实现训练循环不用高级封装第三阶段引入推理优化手动管理KV Cache和批处理第四阶段用FastAPI搭服务把整个链路串起来。这样既保证了理解深度又不会脱离工程实际。注意不要一上来就追求性能。从零实现的目标是理解不是跑分。用NumPy跑一个几百参数的小网络速度慢没关系关键是每一步你都能解释清楚。2.3 为什么这个路径对工程能力提升最明显我带人做项目时发现调包出身的人遇到问题习惯性搜“XX报错怎么解决”而从零走过一遍的人会先定位是数据问题、梯度问题还是显存问题。这个差别在真实项目里非常致命。举个例子模型输出重复调包的人可能去调temperature但从零实现过采样逻辑的人会先检查是不是padding token没mask掉。再比如推理延迟高调包的人可能直接换更小的模型但从零管理过KV Cache的人知道很多时候是batch策略没设计好。这个路径还有一个隐性收益你会对“什么该自己写、什么该用现成的”有更清晰的判断。不是所有东西都值得从零造但你必须知道边界在哪。3. 核心细节解析数据、模型、推理、服务四层的关键实现3.1 数据层分词和批处理里藏着最多的坑数据层最容易被低估。很多人觉得数据就是读进来、喂进去实际上大部分训练不稳定和推理异常都源于数据层。从零实现时我建议先手写一个简单的字符级或词级分词器理解token到id的映射、特殊token的插入、padding和truncation的逻辑。关键细节在于padding的处理。假设一个batch里有三条样本长度分别是5、3、8你需要pad到8。pad的位置填什么填0。但0在词表里可能对应某个真实token所以必须配合attention mask告诉模型哪些位置是padding。这个mask在后续的注意力计算里要参与否则模型会把padding当成真实内容。import numpy as np def pad_batch(sequences, pad_id0): max_len max(len(s) for s in sequences) padded [] masks [] for s in sequences: pad_len max_len - len(s) padded.append(s [pad_id] * pad_len) masks.append([1] * len(s) [0] * pad_len) return np.array(padded), np.array(masks)批处理还有一个容易忽略的点动态batch。推理时如果按固定长度pad短样本会浪费大量计算。实际工程里常用的是按长度分桶把相近长度的样本放在一个batch里减少padding比例。这个逻辑从零实现一遍后面用任何推理框架都能理解它的batch策略参数。实操心得分词器的词表大小直接影响模型参数量和显存占用。从零实现时先用小词表跑通再逐步扩大。我见过有人一上来用5万词表结果embedding层就占了大半显存调试起来非常痛苦。3.2 模型层手写反向传播是理解训练的捷径模型层从零实现的核心不是堆结构而是把前向传播、损失计算、反向传播这条链路走通。我建议从一个单层全连接网络开始输入维度、隐藏维度、输出维度都设小一点比如4、8、2。前向传播就是矩阵乘加偏置再激活损失用交叉熵反向传播用链式法则手动推导。这里的关键是理解梯度的形状和流动方向。假设输入X是(batch, 4)权重W1是(4, 8)那么隐藏层H X W1形状是(batch, 8)。损失对W1的梯度等于X的转置乘以损失对H的梯度。这个转置操作很多人一开始会搞混手写一遍就清楚了。def forward(X, W1, b1, W2, b2): H np.maximum(0, X W1 b1) # ReLU logits H W2 b2 return H, logits def backward(X, H, logits, y, W2): batch X.shape[0] probs np.exp(logits) / np.exp(logits).sum(axis1, keepdimsTrue) dlogits probs.copy() dlogits[np.arange(batch), y] - 1 dlogits / batch dW2 H.T dlogits db2 dlogits.sum(axis0) dH dlogits W2.T dH[H 0] 0 # ReLU反向 dW1 X.T dH db1 dH.sum(axis0) return dW1, db1, dW2, db2手写一遍之后你会自然理解为什么学习率不能太大、为什么需要batch normalization、为什么梯度会消失。这些直觉在调包时是很难建立的。3.3 推理层KV Cache和批处理是性能的关键推理层是从零实现里最能体现工程价值的部分。训练和推理最大的区别在于推理是自回归的每次只生成一个token然后把这个token拼回输入继续生成。如果每次都重新计算整个序列的注意力计算量会随序列长度平方增长。KV Cache的思路是把之前计算过的Key和Value缓存下来每次只计算新token的Key和Value然后和缓存拼接。这样计算量从平方降到线性。从零实现时你需要维护一个缓存结构记录每一层的K和V每次生成时追加。class KVCache: def __init__(self, num_layers, max_len, dim): self.cache [{K: np.zeros((max_len, dim)), V: np.zeros((max_len, dim))} for _ in range(num_layers)] self.pos 0 def update(self, layer, new_K, new_V): self.cache[layer][K][self.pos] new_K self.cache[layer][V][self.pos] new_V return (self.cache[layer][K][:self.pos1], self.cache[layer][V][:self.pos1])批处理是另一个关键。推理时如果一次只处理一个请求GPU利用率极低。把多个请求拼成一个batch能显著提升吞吐。但不同请求的生成长度不同需要动态管理短请求生成完后要移出batch新请求要能加入。这个逻辑从零实现一遍后面用vLLM这类推理引擎时就能理解它的continuous batching在做什么。注意KV Cache会占用大量显存。缓存大小等于层数乘以最大长度乘以维度乘以2K和V乘以精度字节数。以12层、2048长度、768维度、FP16为例单个请求的缓存约72MB。并发100个请求就是7.2GB这个账一定要提前算。3.4 服务层接口设计和并发处理决定可用性服务层是把前面三层串起来对外提供能力。从零实现时用FastAPI搭一个简单的接口接收请求、调用推理、返回结果。关键细节在于并发处理推理是计算密集型任务如果直接在请求处理函数里同步调用并发能力会非常差。常见的做法是用队列加worker的模式。请求进来后放入队列后台worker从队列取任务执行推理结果通过future返回。这样能控制并发度避免显存溢出。同时要加超时和限流防止单个慢请求拖垮整个服务。from fastapi import FastAPI from concurrent.futures import ThreadPoolExecutor import asyncio app FastAPI() executor ThreadPoolExecutor(max_workers4) app.post(/generate) async def generate(prompt: str): loop asyncio.get_event_loop() result await loop.run_in_executor(executor, inference, prompt) return {text: result}监控也是服务层不可少的部分。至少要记录请求量、延迟分布、显存占用、错误率。这些指标能帮你在出问题时快速定位是数据问题、模型问题还是资源问题。4. 实操过程从零到一搭建完整链路4.1 环境准备和依赖管理第一步是把环境搭干净。我建议用conda建一个独立环境Python版本选3.10或3.11这两个版本在AI生态里兼容性最好。核心依赖就几个NumPy用于手写实现PyTorch用于后续切换FastAPI和Uvicorn用于服务transformers用于加载预训练权重做对比。conda create -n ai-from-scratch python3.11 conda activate ai-from-scratch pip install numpy torch fastapi uvicorn transformers依赖管理有个坑不要一次性装太多包。每装一个包先确认它能正常导入再继续。我见过有人装了几十个包最后某个版本冲突导致整个环境不可用排查起来非常耗时。用requirements.txt锁定版本是个好习惯。4.2 手写一个极简训练循环环境好了之后先用手写的方式跑通一个训练循环。数据集用最简单的比如手动构造的异或问题或者小规模的分类数据。目标是让损失下降验证前向和反向的正确性。X np.array([[0,0],[0,1],[1,0],[1,1]]) y np.array([0,1,1,0]) W1 np.random.randn(2, 8) * 0.1 b1 np.zeros(8) W2 np.random.randn(8, 2) * 0.1 b2 np.zeros(2) lr 0.1 for epoch in range(1000): H, logits forward(X, W1, b1, W2, b2) dW1, db1, dW2, db2 backward(X, H, logits, y, W2) W1 - lr * dW1 b1 - lr * db1 W2 - lr * dW2 b2 - lr * db2跑通之后把学习率调大调小各试一次观察损失曲线的变化。学习率太大损失震荡甚至发散太小下降极慢。这个直观感受比看任何教程都管用。4.3 切换到PyTorch并手动实现训练循环手写版本跑通后切换到PyTorch但不要用Trainer这类高级封装。手动写训练循环手动清零梯度、手动反向传播、手动更新参数。这样你能对比出框架帮你做了什么。import torch import torch.nn as nn model nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2)) optimizer torch.optim.SGD(model.parameters(), lr0.1) criterion nn.CrossEntropyLoss() X_t torch.tensor(X, dtypetorch.float32) y_t torch.tensor(y, dtypetorch.long) for epoch in range(1000): optimizer.zero_grad() logits model(X_t) loss criterion(logits, y_t) loss.backward() optimizer.step()对比两段代码你会发现PyTorch的autograd自动处理了反向传播optimizer封装了参数更新。理解了这个对应关系后面用任何框架都不会慌。4.4 实现KV Cache并对比性能接下来实现KV Cache。先用一个简单的自注意力层做实验对比有无缓存时的推理耗时。构造一个长度为512的序列分别用两种方式生成100个token记录时间。import time # 无缓存 start time.time() for _ in range(100): full_forward(sequence) no_cache_time time.time() - start # 有缓存 start time.time() cache KVCache(num_layers1, max_len1024, dim64) for _ in range(100): cached_forward(new_token, cache) cache_time time.time() - start print(f无缓存: {no_cache_time:.3f}s, 有缓存: {cache_time:.3f}s)实测下来序列越长缓存带来的加速越明显。512长度时可能差两三倍2048长度时能差十倍以上。这个数据能帮你理解为什么推理框架都把KV Cache作为核心优化。4.5 搭建FastAPI服务并压测最后把推理封装成服务。用FastAPI定义接口用ThreadPoolExecutor控制并发然后用ab或wrk做压测。压测时重点关注两个指标吞吐量每秒处理请求数和P99延迟。ab -n 1000 -c 10 -p payload.json -T application/json http://localhost:8000/generate压测结果会告诉你并发度设多少合适。并发太高显存溢出太低吞吐上不去。这个平衡点需要根据实际硬件和模型大小来调。我一般从4开始试逐步加到8、16观察显存和延迟的变化。5. 常见问题与排查技巧实录5.1 训练不收敛先查数据和梯度训练不收敛是最常见的问题。排查顺序我总结为先看数据再看梯度最后看超参。数据方面检查标签是否正确、输入是否归一化、有没有nan或inf。梯度方面打印每一层的梯度范数如果某层梯度全是0可能是激活函数饱和如果梯度爆炸需要加梯度裁剪。现象可能原因排查方法损失不下降学习率太小调大10倍观察损失震荡学习率太大调小10倍观察损失变nan梯度爆炸加梯度裁剪检查输入某层梯度为0激活饱和换激活函数或调初始化实操心得初始化权重时用小的随机数比如乘以0.01。我见过有人用默认的randn初始梯度就爆炸训练根本起不来。5.2 推理结果异常检查mask和位置编码推理时如果输出重复、乱码或者提前结束优先检查attention mask和位置编码。padding mask没加模型会把padding当成真实内容位置编码超出训练长度模型会输出不可预测的结果。从零实现过这两块的人排查起来会快很多。5.3 显存溢出算清楚每一笔账显存溢出不要急着换卡先算账。模型参数、梯度、优化器状态、激活值、KV Cache每一项都要算。以FP16为例参数量乘以2字节是权重占用训练时还要乘以4左右梯度加优化器状态。激活值跟batch size和序列长度成正比。KV Cache前面算过。把这些加起来就知道瓶颈在哪。5.4 服务延迟高区分计算延迟和排队延迟服务延迟高先区分是计算慢还是排队久。在请求处理函数里打时间戳记录进入时间、开始推理时间、推理结束时间。如果开始推理时间和进入时间差很多说明排队严重需要加worker或限流。如果推理本身慢就要看是模型太大还是batch策略不合理。6. 我在这条路上踩过的几个坑第一个坑是过早优化。刚开始从零实现时我总想着把性能做到极致结果花大量时间在算子优化上反而忽略了整体链路的理解。后来调整策略先跑通再优化效率高很多。第二个坑是忽视数值稳定性。手写softmax时没减最大值导致指数溢出损失直接变nan。这个坑让我记住了所有涉及指数的计算都要做数值稳定处理。第三个坑是服务层设计太随意。早期直接把推理写在请求处理函数里并发一上来就崩。后来改成队列加worker稳定性好了很多。这个经验告诉我AI工程不只是模型服务架构同样重要。第四个坑是KV Cache的显存管理。一开始没限制最大长度长序列直接把显存打满。后来加了长度检查和缓存淘汰策略才稳定下来。7. 后续可以继续深入的方向这条链路跑通之后有几个方向可以继续深入。一是量化把FP16换成INT8或INT4理解精度和速度的取舍。二是分布式推理把模型切到多张卡上理解张量并行和流水线并行。三是更复杂的服务架构引入负载均衡和自动扩缩容。每一个方向都值得单独花时间但前提是前面这条基础链路你已经亲手走过一遍。我个人在实际操作中的体会是从零实现的价值不在于造出多好的轮子而在于你从此看任何轮子都能看出它的设计取舍。这种判断力才是AI工程能力里最值钱的部分。
返回列表