1. 从零搭建AI工程能力:为什么“手搓一遍”比调包更值钱
这两年AI应用开发的门槛肉眼可见地降低了,一个刚入门的开发者,靠着现成的框架和API,几天就能拼出一个能跑通的对话机器人。但我观察到一个很有意思的现象:很多人做完几个demo之后,一旦遇到线上问题——比如推理延迟突然飙升、显存莫名其妙爆掉、模型输出质量不稳定——就完全不知道从哪里下手。原因很简单,他们跳过了“从零构建”这个环节,直接站在了抽象层的最上面,底下发生了什么,一概不知。
ai-engineering-from-scratch这个项目标题,说的就是这件事:不依赖高层封装,从最底层开始,把AI工程链路中的关键组件一个一个亲手搭出来。它不是一个具体的开源库,而是一种学习路径和工程实践方法论。核心目标很明确——让你理解一个AI系统从数据输入到推理输出,中间到底经过了哪些环节,每个环节的瓶颈在哪里,怎么调、怎么优化。
这篇文章适合三类人看:第一类是有一定编程基础、想转AI工程方向的开发者;第二类是用过一些AI框架但总觉得“心里没底”、想补齐底层认知的工程师;第三类是准备面试AI工程岗位、需要系统梳理知识体系的人。我会按照实际搭建的顺序,把每个模块的设计思路、关键细节、踩坑经验都摊开讲,代码和参数都会给到,你可以直接照着复现。
2. 整体架构设计:从零构建AI工程链路的思路拆解
2.1 为什么要“从零”——抽象泄漏的代价
先说一个我亲身经历的事。之前团队里有个项目,用的是某个高层推理框架,本地测试一切正常,部署到生产环境后,QPS一上去就开始超时。排查了两天,最后发现是框架默认的批处理策略在高并发下导致了请求排队。这个问题如果对底层推理流程有清晰认知,半小时就能定位。
这就是抽象泄漏(Leaky Abstraction)的典型表现。高层框架帮你屏蔽了细节,但细节并没有消失,只是在出问题的时候以更隐蔽的方式暴露出来。从零构建的意义不在于让你以后不用框架,而在于让你在用框架的时候,知道它在背后做了什么,什么时候该信任它,什么时候该绕过它。
从工程角度看,一个完整的AI推理系统至少包含以下环节:
- 数据预处理管道:文本分词、图像缩放、特征归一化等
- 模型加载与初始化:权重加载、设备分配、精度选择
- 推理执行引擎:前向计算、批处理调度、内存管理
- 后处理与输出:解码、阈值过滤、格式化
- 服务层:请求队列、并发控制、超时处理
- 监控与可观测性:延迟统计、资源占用、错误率
从零构建,就是把这六个环节都自己实现一遍,哪怕是最简版本。
2.2 技术选型:为什么用Python + NumPy起步
很多人会问,从零构建是不是要用C++或者Rust?我的建议是:先用Python + NumPy把逻辑跑通,再考虑性能优化。
理由很直接。Python的生态让你可以快速验证想法,NumPy提供了足够的底层数组操作能力,同时保持了代码的可读性。你完全可以用NumPy手写一个矩阵乘法、一个softmax、一个注意力机制,然后跟PyTorch的结果做对比,验证正确性。这个过程对理解模型内部计算非常有帮助。
等你把逻辑跑通了,再逐步替换性能瓶颈部分——比如用Cython加速热点函数,用CUDA写自定义kernel,或者用ONNX Runtime做推理后端。这个渐进式的路径比一上来就啃CUDA要高效得多。
注意:从零构建的目的是学习理解,不是重新造轮子用于生产。生产环境该用成熟框架就用成熟框架,但你要有能力判断框架的行为是否符合预期。
2.3 模块划分与依赖关系
我把整个链路拆成四个核心模块,按依赖顺序排列:
| 模块 | 职责 | 关键依赖 | 难度 |
|---|---|---|---|
| 数据处理 | 分词、编码、批处理 | NumPy, 正则 | 中 |
| 模型推理 | 前向计算、权重管理 | NumPy | 高 |
| 服务调度 | 请求队列、批处理 | asyncio | 中 |
| 监控观测 | 延迟、内存、错误 | logging, time | 低 |
每个模块都可以独立开发和测试,最后再组装成完整系统。这种解耦设计的好处是,你可以在任何一层做替换和优化,不影响其他层。
3. 核心细节解析:每个环节的关键实现与避坑要点
3.1 数据处理管道:分词器不只是“切词”
分词看起来简单,实际上坑很多。以英文BPE(Byte Pair Encoding)为例,你需要处理:
- 词汇表构建:从训练语料中统计字符对频率,迭代合并
- 特殊token处理:
[CLS]、[SEP]、[PAD]、[UNK]的位置和ID分配 - 截断与填充:超过最大长度的序列怎么截,短序列怎么补齐
- 注意力掩码生成:哪些位置是真实token,哪些是padding
我见过最常见的问题就是padding位置搞错。比如在批处理时,如果padding加在了序列前面而不是后面,注意力掩码又没有正确设置,模型就会把padding token当成真实输入来计算注意力,结果输出完全乱掉。
import numpy as np def create_attention_mask(input_ids, pad_token_id=0): """生成注意力掩码:真实token为1,padding为0""" mask = (input_ids != pad_token_id).astype(np.float32) # 扩展为 (batch, 1, 1, seq_len) 用于广播 return mask[:, np.newaxis, np.newaxis, :]这段代码看起来简单,但你要理解为什么mask的形状是(batch, 1, 1, seq_len)——这是为了在计算注意力分数时能够正确广播到(batch, num_heads, seq_len, seq_len)的形状。
另一个容易忽略的点是分词的一致性。训练时用的分词器和推理时用的必须是同一个,词汇表ID的映射必须完全一致。我遇到过有人训练用了一套分词规则,推理时换了另一套,结果模型输出全是乱码,排查了半天才发现是分词器版本不一致。
3.2 模型推理引擎:矩阵乘法的正确打开方式
从零实现推理,最核心的就是矩阵乘法。NumPy的np.dot和@运算符底层调用了BLAS库,性能已经不错。但你要注意几个关键点:
第一,数据类型。默认的float64精度高但速度慢、内存占用大。推理场景下通常用float32甚至float16。转换的时候要注意数值范围,避免溢出。
第二,内存布局。NumPy默认是行优先(C order),但某些BLAS实现列优先(Fortran order)性能更好。对于大矩阵乘法,转置操作可能带来显著的性能差异。
第三,批处理维度。批量推理时,输入形状从(seq_len, hidden_dim)变成(batch, seq_len, hidden_dim),矩阵乘法的维度匹配要格外小心。
def linear_forward(x, weight, bias=None): """ 线性层前向计算 x: (batch, seq_len, in_features) weight: (in_features, out_features) bias: (out_features,) """ # 确保数据类型一致 x = x.astype(np.float32) weight = weight.astype(np.float32) # 矩阵乘法: (batch, seq_len, in_features) @ (in_features, out_features) output = np.matmul(x, weight) if bias is not None: output += bias.astype(np.float32) return output这里有个实操心得:在开发阶段用float64验证正确性,确认无误后再切换到float32做性能测试。因为浮点误差在深层网络中会累积,用高精度先排除逻辑错误,再排查精度问题,效率更高。
3.3 注意力机制的实现细节
注意力机制是Transformer的核心,也是从零实现中最容易出错的部分。我把它拆成几个关键步骤:
步骤一:QKV投影。输入x分别乘以三个权重矩阵W_q、W_k、W_v,得到query、key、value。
步骤二:缩放点积。Q @ K^T / sqrt(d_k),这里的缩放因子sqrt(d_k)非常关键。如果不缩放,当d_k较大时,点积结果会很大,softmax之后梯度会变得极小,训练难以收敛。
步骤三:softmax归一化。注意数值稳定性——要先减去最大值再取指数,防止溢出。
步骤四:加权求和。注意力权重乘以value,得到输出。
def scaled_dot_product_attention(Q, K, V, mask=None): """ Q: (batch, num_heads, seq_len, d_k) K: (batch, num_heads, seq_len, d_k) V: (batch, num_heads, seq_len, d_v) """ d_k = Q.shape[-1] scores = np.matmul(Q, K.transpose(0, 1, 3, 2)) / np.sqrt(d_k) if mask is not None: scores = scores + (1.0 - mask) * (-1e9) # 数值稳定的softmax scores_max = np.max(scores, axis=-1, keepdims=True) exp_scores = np.exp(scores - scores_max) attention_weights = exp_scores / np.sum(exp_scores, axis=-1, keepdims=True) output = np.matmul(attention_weights, V) return output, attention_weights提示:
-1e9这个负数在softmax之后会变成接近0的权重,相当于屏蔽了对应位置。不要用-inf,因为某些实现下-inf参与运算会产生nan。
3.4 服务调度层:批处理与并发控制
单条推理跑通之后,下一步是让它能同时服务多个请求。这里有两个核心问题:批处理策略和并发控制。
批处理的基本思路是:把多个请求攒在一起,合并成一个batch送进模型,推理完再拆开返回。好处是GPU利用率高,吞吐量大。坏处是延迟增加——你得等够一定数量的请求才能组批。
常见的策略有三种:
- 固定窗口:每隔N毫秒组一个batch,不管攒了多少请求
- 动态窗口:请求数达到阈值立即组批,或者超时强制组批
- 连续批处理:不等整个batch完成,完成的请求先返回,新请求补进来
从零实现的话,建议先用动态窗口策略,逻辑简单且效果不错。
import asyncio from collections import deque class BatchScheduler: def __init__(self, max_batch_size=8, max_wait_ms=50): self.max_batch_size = max_batch_size self.max_wait_ms = max_wait_ms self.queue = deque() self.lock = asyncio.Lock() async def add_request(self, request): async with self.lock: self.queue.append(request) if len(self.queue) >= self.max_batch_size: return await self._process_batch() # 等待窗口超时 await asyncio.sleep(self.max_wait_ms / 1000) async with self.lock: if self.queue: return await self._process_batch() async def _process_batch(self): batch = list(self.queue) self.queue.clear() # 实际推理逻辑 results = await self._inference(batch) return results这段代码的核心逻辑是:请求进来先入队,如果队列满了立即处理,否则等一个窗口期再处理。实际生产中还需要考虑超时、错误处理、优先级等,但骨架就是这样。
4. 实操过程:从零搭建一个可运行的推理服务
4.1 环境准备与依赖安装
先把基础环境搭起来。我推荐用虚拟环境隔离依赖:
python -m venv ai-engineer source ai-engineer/bin/activate # Linux/Mac # ai-engineer\Scripts\activate # Windows pip install numpy asyncio不需要装PyTorch或TensorFlow,我们全程用NumPy手写。这样你能看清楚每一步在做什么。
4.2 实现一个最简Transformer推理
下面是一个完整的单层Transformer推理实现,包含多头注意力和前馈网络:
import numpy as np class SimpleTransformerLayer: def __init__(self, hidden_dim=64, num_heads=4, ff_dim=256): self.hidden_dim = hidden_dim self.num_heads = num_heads self.head_dim = hidden_dim // num_heads self.ff_dim = ff_dim # 初始化权重(实际使用时从文件加载) scale = 0.02 self.W_q = np.random.randn(hidden_dim, hidden_dim).astype(np.float32) * scale self.W_k = np.random.randn(hidden_dim, hidden_dim).astype(np.float32) * scale self.W_v = np.random.randn(hidden_dim, hidden_dim).astype(np.float32) * scale self.W_o = np.random.randn(hidden_dim, hidden_dim).astype(np.float32) * scale self.W_ff1 = np.random.randn(hidden_dim, ff_dim).astype(np.float32) * scale self.W_ff2 = np.random.randn(ff_dim, hidden_dim).astype(np.float32) * scale def forward(self, x, mask=None): batch, seq_len, _ = x.shape # QKV投影 Q = np.matmul(x, self.W_q) K = np.matmul(x, self.W_k) V = np.matmul(x, self.W_v) # 拆分为多头 Q = Q.reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3) K = K.reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3) V = V.reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3) # 注意力计算 scores = np.matmul(Q, K.transpose(0, 1, 3, 2)) / np.sqrt(self.head_dim) if mask is not None: scores = scores + (1.0 - mask) * (-1e9) scores_max = np.max(scores, axis=-1, keepdims=True) exp_scores = np.exp(scores - scores_max) attn_weights = exp_scores / np.sum(exp_scores, axis=-1, keepdims=True) attn_output = np.matmul(attn_weights, V) # 合并多头 attn_output = attn_output.transpose(0, 2, 1, 3).reshape(batch, seq_len, self.hidden_dim) attn_output = np.matmul(attn_output, self.W_o) # 残差连接 + LayerNorm(简化版) x = x + attn_output # 前馈网络 ff_output = np.matmul(x, self.W_ff1) ff_output = np.maximum(ff_output, 0) # ReLU ff_output = np.matmul(ff_output, self.W_ff2) x = x + ff_output return x这段代码可以直接运行,输入一个(batch, seq_len, hidden_dim)的数组,就能得到输出。你可以用它来验证自己对注意力机制的理解是否正确。
4.3 性能测试与瓶颈定位
跑通之后,下一步是测性能。我写了一个简单的benchmark脚本:
import time def benchmark(model, batch_size=1, seq_len=128, hidden_dim=64, iterations=100): x = np.random.randn(batch_size, seq_len, hidden_dim).astype(np.float32) mask = np.ones((batch_size, 1, 1, seq_len), dtype=np.float32) # 预热 for _ in range(10): model.forward(x, mask) start = time.perf_counter() for _ in range(iterations): model.forward(x, mask) end = time.perf_counter() avg_ms = (end - start) / iterations * 1000 print(f"batch={batch_size}, seq_len={seq_len}, avg={avg_ms:.2f}ms") return avg_ms实测下来,在普通笔记本上,单层Transformer、batch=1、seq_len=128、hidden_dim=64的配置,单次前向大约在2-5ms。随着seq_len增加,注意力计算是O(n²)的复杂度,延迟会快速上升。这就是为什么长文本推理需要专门的优化技术。
4.4 组装完整服务
把前面的模块串起来,形成一个完整的推理服务:
import asyncio import json class InferenceService: def __init__(self, model, tokenizer, max_batch_size=8): self.model = model self.tokenizer = tokenizer self.scheduler = BatchScheduler(max_batch_size=max_batch_size) async def handle_request(self, text): # 预处理 input_ids = self.tokenizer.encode(text) input_array = np.array([input_ids], dtype=np.int64) mask = create_attention_mask(input_array) # 推理 output = self.model.forward(input_array.astype(np.float32), mask) # 后处理 result = self.tokenizer.decode(output) return result async def serve(self, host='0.0.0.0', port=8000): # 简化的服务循环 while True: request = await self._accept_request() result = await self.scheduler.add_request(request) await self._send_response(result)这个骨架可以直接扩展成HTTP服务或gRPC服务。关键是要理解每一层在做什么,以及层与层之间的数据格式约定。
5. 常见问题与排查技巧实录
5.1 数值不稳定:NaN和Inf从哪里来
这是从零实现推理时最常见的问题。表现是输出突然变成nan或inf。排查思路:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| softmax后出现NaN | 输入包含inf或极大值 | 检查softmax前是否有-inf参与运算 |
| 矩阵乘法后溢出 | float16精度不足 | 切换到float32验证 |
| 梯度爆炸 | 权重初始化过大 | 检查权重scale |
| 除零错误 | 分母为0 | 加epsilon:x / (y + 1e-8) |
我的经验是:先在float64下跑,确认逻辑正确,再逐步降精度。如果float64下正常,float32下出问题,那就是精度问题;如果float64下就出问题,那就是逻辑bug。
5.2 推理结果与框架不一致
你手写的实现和PyTorch的输出对不上,通常有这几个原因:
- 权重加载顺序错误:PyTorch的权重形状可能是
(out_features, in_features),而你的实现用的是(in_features, out_features),需要转置 - LayerNorm的epsilon不同:PyTorch默认
1e-5,你可能用了1e-6 - 注意力mask的广播方式不同:检查mask的形状是否匹配
- 激活函数差异:GELU vs ReLU,精确GELU vs 近似GELU
排查方法很笨但有效:逐层对比。先对比第一层的输出,一致了再对比第二层,以此类推。定位到具体哪一层不一致,问题就缩小到很小的范围了。
5.3 批处理导致的输出错乱
批处理时,如果不同请求的序列长度不同,需要padding到相同长度。这时候如果mask没设置对,短序列的padding位置会参与注意力计算,导致输出异常。
注意:padding token的ID要和训练时保持一致。通常用0,但有些模型用其他ID。查清楚再用。
另一个坑是批处理中的顺序问题。请求A和请求B组批后,输出顺序可能和输入顺序不一致。需要在后处理阶段根据请求ID重新映射。
5.4 内存占用过高
从零实现时,很容易写出内存爆炸的代码。常见原因:
- 中间变量没有及时释放:Python的垃圾回收不是实时的,大数组用完后手动
del并调用gc.collect() - 不必要的拷贝:NumPy的切片操作返回视图而非拷贝,但某些操作(如
reshape)可能触发拷贝 - 注意力矩阵过大:
(batch, num_heads, seq_len, seq_len)的矩阵在seq_len=1024时就有百万级元素
优化手段包括:使用in-place操作、分块计算注意力、及时释放中间结果。
6. 从零构建之后:能力延伸与工程化建议
把上面这套东西跑通之后,你对AI推理链路的理解会有一个质的飞跃。接下来可以往几个方向延伸:
方向一:性能优化。把NumPy替换成CUDA kernel,或者用ONNX Runtime做后端,对比性能差异。你会直观感受到不同实现方式的效率差距。
方向二:模型量化。尝试把float32权重转成int8,观察精度损失和速度提升的权衡。这是生产环境常用的优化手段。
方向三:服务化。把推理服务包装成REST API或gRPC接口,加上健康检查、限流、熔断等生产级特性。
方向四:可观测性。接入Prometheus做指标采集,用Grafana做可视化,实时监控推理延迟、吞吐量、错误率。
我个人在实际操作中的体会是,从零构建最大的价值不是让你成为“不用框架”的人,而是让你成为“能选对框架、用好框架”的人。当你理解了底层的计算流程和瓶颈所在,再去看那些高层框架的文档和配置项,你会发现一切都变得清晰了。你知道每个参数在调什么,每个优化在优化什么,遇到问题也知道从哪里入手排查。这种掌控感,是调包永远给不了的。
最后分享一个小技巧:把你从零实现的版本和框架版本做A/B对比测试,用相同的输入跑,逐层对比输出。这个过程会逼着你把每个细节都搞清楚,是提升最快的方式。