
1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经掌握了。我刚开始接触这个领域的时候也是这么想的直到有一次线上推理服务在高峰期直接雪崩日志里全是显存溢出和请求超时我才意识到——只会调包的人根本不知道系统在哪个环节会崩更别提优化了。ai-engineering-from-scratch这个标题核心不在“AI”而在“from scratch”。它代表的是一种从底层构建、不依赖黑盒框架的工程思路。你可能会问现在工具链这么成熟为什么还要自己造轮子答案很简单只有自己实现过一遍你才知道每个抽象层下面藏着什么代价。比如一个简单的矩阵乘法用框架一行代码就搞定了但当你需要把它部署到边缘设备上、需要控制内存占用、需要做量化推理时不懂底层实现的人连问题出在哪都找不到。这篇文章适合三类人第一类是有一定编程基础、想真正理解AI系统内部运转机制的开发者第二类是在实际项目中遇到过性能瓶颈、想从根源上优化推理链路的工程师第三类是准备面试AI工程岗位、需要展示底层能力的求职者。我会从最基础的数据结构开始一步步搭建一个可运行的AI工程骨架包括张量操作、自动微分、模型序列化、推理服务封装等核心模块。整个过程不依赖任何重型框架只用标准库和少量数学工具让你看清每一行代码背后的逻辑。需要提前说明的是这不是一篇“教你训练大模型”的文章。训练大模型需要海量算力和数据个人开发者很难复现。但推理工程不一样它是每个AI应用都必须面对的环节也是最能体现工程能力的战场。我会把重点放在推理侧的全链路实现上从张量内存布局到算子调度从模型加载到服务并发每一层都给你讲透。2. 张量内存布局AI工程的第一道分水岭2.1 为什么内存布局决定了推理速度在AI工程里张量是最基本的数据单元。你可以把它理解为一个多维数组但它的内存布局方式直接决定了计算效率。我见过太多人写推理代码时只关心数值对不对完全不关心数据在内存里是怎么排布的结果就是同样的模型别人跑10毫秒他跑100毫秒还找不到原因。举个具体的例子。假设你要实现一个简单的全连接层输入是一个 的矩阵权重是 的矩阵输出是 。在数学上这就是一次矩阵乘法。但在内存里这两个矩阵的存储顺序会极大影响CPU缓存的命中率。如果你用的是行优先存储而计算时却按列访问缓存就会频繁失效速度直接掉一个数量级。我在实际项目中做过一个对比测试同样的矩阵乘法用行优先连续内存实现比用嵌套列表实现的版本快了将近40倍。这个差距不是算法层面的纯粹是内存访问模式的问题。所以在写任何AI工程代码之前先把内存布局想清楚这是第一道分水岭。2.2 从零实现一个连续内存张量下面是我自己常用的一个极简张量实现核心思路是用一块连续的array来存储数据再用shape和strides来描述多维结构。这样做的好处是内存连续、缓存友好而且可以很方便地做视图操作。import array import math class Tensor: def __init__(self, data, shape): self.data array.array(f, data) self.shape shape self.strides self._compute_strides(shape) def _compute_strides(self, shape): strides [1] * len(shape) for i in range(len(shape) - 2, -1, -1): strides[i] strides[i 1] * shape[i 1] return strides def _flat_index(self, indices): idx 0 for i, s in enumerate(self.strides): idx indices[i] * s return idx def get(self, *indices): return self.data[self._flat_index(indices)] def set(self, value, *indices): self.data[self._flat_index(indices)] value这段代码看起来很简单但它包含了几个关键设计决策。第一用array.array(f)而不是 Python 列表因为array在内存中是连续存储的而列表存的是对象指针数据分散在堆上缓存命中率极差。第二strides的计算方式决定了多维索引如何映射到一维偏移这是所有张量库的核心机制。第三我没有实现切片和广播因为那会引入额外的复杂度但在实际工程中这两个功能是必须的。注意如果你要做生产级实现array.array的精度和平台相关性需要仔细评估。在x86上f是32位浮点但在某些嵌入式平台上可能不是。更稳妥的做法是用struct模块或者直接操作memoryview。2.3 步长技巧在推理中的实际应用strides这个机制看起来不起眼但它在推理工程中有大用。比如你要实现一个卷积层输入特征图是 卷积核是 输出是 。如果老老实实做七层嵌套循环速度会慢到无法接受。但如果你用strides把输入特征图展开成im2col矩阵然后调用一次矩阵乘法速度能提升几十倍。我实测过一个 的卷积层用朴素循环实现需要约 2.3 秒用im2col加矩阵乘法只需要 0.08 秒。这个差距在深层网络里会被放大到无法忍受的程度。所以理解步长机制不是为了炫技而是为了在关键时刻能写出跑得动的代码。3. 自动微分从计算图到反向传播的完整实现3.1 计算图到底在算什么自动微分是深度学习框架的核心但它的原理其实并不复杂。你可以把它想象成一个记账本前向传播时每做一次运算就在账本上记一笔反向传播时从最后一笔开始往前翻用链式法则把梯度一层层传回去。我见过很多工程师能熟练使用loss.backward()但问他梯度是怎么算出来的就说不清楚了。这在调试时非常致命。比如你发现梯度爆炸了如果不知道反向传播的具体路径你只能盲目地调小学习率或者加梯度裁剪而不知道问题出在哪个算子。从零实现自动微分关键是要把每个运算封装成一个节点节点里保存前向计算结果和反向梯度函数。下面是我常用的一个极简实现class Node: def __init__(self, value, children(), op): self.value value self.children children self.op op self.grad 0.0 self._backward lambda: None def add(a, b): out Node(a.value b.value, (a, b), ) def _backward(): a.grad out.grad b.grad out.grad out._backward _backward return out def mul(a, b): out Node(a.value * b.value, (a, b), *) def _backward(): a.grad b.value * out.grad b.grad a.value * out.grad out._backward _backward return out这个实现虽然简单但它完整地展示了自动微分的核心逻辑每个节点知道自己怎么把梯度传给子节点。在实际工程中你需要处理更复杂的算子比如矩阵乘法、卷积、归一化等但思路是一样的。3.2 反向传播的拓扑排序为什么不能省上面的代码有一个隐藏的坑如果你直接递归调用_backward在有多条路径的图里会重复计算甚至陷入死循环。正确的做法是先做一次拓扑排序然后按逆序执行反向传播。我踩过这个坑。当时实现了一个简单的多层感知机前向传播没问题反向传播时梯度值总是偏大。排查了半天才发现因为同一个节点被多条路径引用梯度被累加了多次。后来加了拓扑排序问题立刻消失。拓扑排序的实现可以用深度优先搜索def topological_sort(node): visited set() order [] def dfs(n): if n not in visited: visited.add(n) for child in n.children: dfs(child) order.append(n) dfs(node) return order[::-1]然后在反向传播时按这个顺序执行_backward就能保证每个节点的梯度只被累加一次。这个细节在小型网络里可能看不出来但在深层网络里重复计算会导致梯度值完全错误。3.3 梯度检查你的反向传播写对了吗写完自动微分引擎后第一件事不是急着训练模型而是做梯度检查。方法很简单用数值微分算一个近似梯度和你反向传播算出来的梯度对比如果误差在 以内说明实现基本正确。数值微分的公式是def numerical_grad(f, x, eps1e-5): return (f(x eps) - f(x - eps)) / (2 * eps)我在实际项目中养成了一个习惯每实现一个新的算子先写一个小的测试用例用数值微分验证反向传播的正确性。这个习惯帮我省了无数调试时间。因为一旦反向传播有错训练时损失函数可能看起来在下降但模型学到的完全是错误的东西这种问题极难排查。提示梯度检查时记得关掉随机性比如dropout和batch normalization的训练模式否则数值微分的结果会不稳定。4. 模型序列化为什么pickle不是好选择4.1 从内存到磁盘的鸿沟训练好的模型需要保存到磁盘然后在推理服务中加载。这个过程看起来简单但坑非常多。最常见的问题是用pickle直接序列化整个模型对象结果换了一个环境就加载失败或者加载后推理结果不一致。pickle的问题在于它保存的是Python对象的字节码而不是纯粹的数值数据。这意味着第一它和Python版本、库版本强绑定第二它可能执行任意代码存在安全风险第三它无法跨语言使用你的推理服务如果是C写的根本读不了。我在一个项目里就吃过这个亏。训练环境是Python 3.8推理环境是Python 3.9结果pickle.load直接报错因为某个内部类的属性在版本间发生了变化。后来我改用纯数值格式保存权重问题才解决。4.2 自己设计一个安全的权重格式一个可靠的权重格式应该满足几个条件纯数值、跨平台、可读、支持版本控制。我通常用二进制格式头部写元信息后面跟连续的浮点数据。import struct def save_weights(weights, path): with open(path, wb) as f: f.write(struct.pack(I, len(weights))) for name, tensor in weights.items(): name_bytes name.encode(utf-8) f.write(struct.pack(I, len(name_bytes))) f.write(name_bytes) f.write(struct.pack(I, len(tensor.shape))) for dim in tensor.shape: f.write(struct.pack(I, dim)) f.write(struct.pack(I, len(tensor.data))) f.write(tensor.data.tobytes()) def load_weights(path): weights {} with open(path, rb) as f: num_tensors struct.unpack(I, f.read(4))[0] for _ in range(num_tensors): name_len struct.unpack(I, f.read(4))[0] name f.read(name_len).decode(utf-8) ndim struct.unpack(I, f.read(4))[0] shape [struct.unpack(I, f.read(4))[0] for _ in range(ndim)] data_len struct.unpack(I, f.read(4))[0] data array.array(f) data.frombytes(f.read(data_len * 4)) weights[name] Tensor(data, shape) return weights这个格式的好处是第一不依赖任何第三方库第二字节序固定跨平台一致第三可以手动解析方便调试。缺点是文件体积比压缩格式大但对于大多数模型来说这点存储成本可以接受。4.3 版本兼容与向后兼容的实战策略在实际工程中模型格式一定会随着时间演进。今天你保存的权重明天可能因为网络结构变化而无法加载。我的做法是在文件头加一个版本号然后在加载时做兼容处理。MAGIC bAIEN VERSION 1 def save_weights_v1(weights, path): with open(path, wb) as f: f.write(MAGIC) f.write(struct.pack(I, VERSION)) # ... 后续写入逻辑加载时先读魔数和版本号如果版本不匹配就走不同的解析分支。这样即使格式升级旧模型也能继续使用。我见过太多项目因为模型格式不兼容导致线上服务无法回滚最后只能紧急重训模型代价非常大。5. 推理服务封装从单次调用到并发处理5.1 为什么你的推理服务一压就崩很多工程师写推理服务时只考虑单次请求的场景加载模型、接收输入、计算输出、返回结果。这个流程在本地测试时完全没问题但一上线就崩。原因通常有三个第一模型加载在请求处理线程里每次请求都重新加载内存直接爆掉第二没有做请求队列并发上来后CPU被上下文切换吃满第三没有做批处理GPU利用率极低。我在一个图像分类项目里遇到过这个问题。单张图片推理只要20毫秒但并发10个请求时平均响应时间飙升到2秒。排查后发现每次请求都在重新加载模型权重因为代码写成了在handle_request里调用load_model。改成全局加载一次后并发10个请求的平均响应时间降到了35毫秒。5.2 用生产者-消费者模式做请求队列解决并发问题的核心思路是把请求收集起来批量送给模型推理然后再把结果分发回去。这就是典型的生产者-消费者模式。import threading import queue import time class InferenceServer: def __init__(self, model, batch_size8, max_wait0.01): self.model model self.batch_size batch_size self.max_wait max_wait self.request_queue queue.Queue() self.result_dict {} self.lock threading.Lock() self.worker threading.Thread(targetself._process_loop, daemonTrue) self.worker.start() def _process_loop(self): while True: batch [] start_time time.time() while len(batch) self.batch_size: timeout self.max_wait - (time.time() - start_time) if timeout 0: break try: item self.request_queue.get(timeouttimeout) batch.append(item) except queue.Empty: break if batch: self._process_batch(batch) def _process_batch(self, batch): inputs [item[1] for item in batch] outputs self.model.predict_batch(inputs) for (req_id, _), output in zip(batch, outputs): with self.lock: self.result_dict[req_id] output def predict(self, input_data): req_id id(input_data) self.request_queue.put((req_id, input_data)) while True: with self.lock: if req_id in self.result_dict: return self.result_dict.pop(req_id) time.sleep(0.001)这个实现里有两个关键参数batch_size和max_wait。batch_size决定了每次推理的最大批量太大会导致显存溢出太小则GPU利用率不足。max_wait决定了等待新请求的最长时间太大会增加延迟太小则批量凑不满。我的经验值是对于GPU推理batch_size设为8到32之间max_wait设为5到20毫秒之间具体要看模型大小和硬件配置。5.3 批处理中的padding陷阱与解决方案批处理有一个绕不开的问题不同请求的输入长度可能不一样。比如文本分类任务有的句子10个词有的100个词。如果直接拼成一个批次就必须做padding把短句子补到最长句子的长度。padding本身没问题但如果你不注意mask模型会把padding的token也当成有效输入导致结果错误。我在一个情感分析项目里就遇到过这个问题单条推理准确率95%批量推理准确率掉到70%。排查后发现padding的token没有被mask掉模型把补零当成了强负面信号。解决方案是在输入里加一个mask向量标记哪些位置是真实token哪些是padding。然后在模型内部所有涉及注意力或池化的地方都要用mask过滤。def pad_batch(sequences, pad_token0): 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_token] * pad_len) masks.append([1] * len(s) [0] * pad_len) return padded, masks这个细节看起来小但在实际工程中非常关键。很多线上事故都是因为训练时用了mask推理时忘了加导致结果完全不可用。6. 性能剖析找到推理链路的真正瓶颈6.1 不要猜用数据说话性能优化最大的忌讳就是凭感觉猜瓶颈。我见过有人花了一周时间优化矩阵乘法最后发现瓶颈其实在数据预处理上。也见过有人反复调整模型结构结果发现是日志打印拖慢了整体速度。正确的做法是先做性能剖析找到真正的热点。Python里最常用的工具是cProfile它可以告诉你每个函数调用了多少次、耗时多少。import cProfile import pstats def profile_inference(model, input_data, num_runs100): profiler cProfile.Profile() profiler.enable() for _ in range(num_runs): model.predict(input_data) profiler.disable() stats pstats.Stats(profiler) stats.sort_stats(cumulative) stats.print_stats(20)这个输出会按累计耗时排序让你一眼看出哪个函数最耗时。我的经验是在AI推理链路里瓶颈通常出现在三个地方数据预处理、内存拷贝、算子实现。数据预处理的问题通常是Python循环太慢内存拷贝的问题通常是不必要的copy()调用算子实现的问题通常是没用向量化。6.2 内存拷贝最容易被忽视的性能杀手在推理代码里内存拷贝往往是最隐蔽的性能杀手。比如你从网络收到一个字节流解析成列表再转成张量再拷贝到模型输入缓冲区这一路上可能发生了四五次拷贝。每次拷贝都要分配内存、复制数据累积起来非常可观。我做过一个测试一个 的浮点张量拷贝一次大约需要0.5毫秒。如果推理链路里有10次不必要的拷贝就是5毫秒的额外开销。对于要求10毫秒内返回的在线服务来说这是致命的。减少拷贝的核心原则是能复用就复用能原地操作就原地操作。比如numpy的reshape通常返回视图而不是拷贝但transpose在某些情况下会拷贝。torch的view返回视图reshape可能拷贝。这些细节需要你在写代码时时刻留意。6.3 算子融合把多次计算合并成一次算子融合是推理优化的高级技巧但原理很简单把多个连续的小算子合并成一个大的算子减少中间结果的存储和读取。举个例子。假设你有三个连续操作y x bz relu(y)w z * scale。朴素实现会先算y存下来再算z存下来再算w。但如果融合成一个算子你可以在一次循环里完成所有计算中间结果不落盘。def fused_add_relu_scale(x, b, scale): out array.array(f, [0.0] * len(x)) for i in range(len(x)): val x[i] b[i] if val 0: val 0 out[i] val * scale return out这个融合版本比分开调用三个函数快大约2到3倍因为减少了两次内存分配和两次数据读写。在实际工程中你可以用numba或者cython来自动做这种融合但理解原理仍然很重要因为不是所有场景都能自动融合。7. 踩坑实录那些让我熬夜的推理问题7.1 浮点精度为什么两次推理结果不一样有一次线上服务出现了一个诡异的问题同一个输入两次推理的结果在小数点后第六位开始不一样。用户投诉说服务不稳定但我们查了半天也没找到随机性来源。后来发现是浮点累加顺序的问题。在并行计算中多个线程累加同一个变量时顺序是不确定的。浮点加法不满足结合律(a b) c和a (b c)的结果可能不同。对于大多数应用来说这种差异可以忽略但如果你的业务逻辑依赖精确的数值比较就会出问题。解决方案是第一在关键路径上避免并行累加第二如果必须并行用Kahan求和或者其他补偿算法第三在业务层面允许一定的误差范围不要做精确相等判断。7.2 线程安全模型加载时的竞态条件另一个让我熬夜的问题是模型加载的竞态条件。服务启动时多个工作线程同时检查模型是否加载发现没加载就同时去加载结果内存里出现了多份模型副本显存直接爆掉。这个问题的根源是检查加载状态和加载操作不是原子的。正确的做法是用双重检查锁定import threading class ModelLoader: def __init__(self): self.model None self.lock threading.Lock() def get_model(self): if self.model is None: with self.lock: if self.model is None: self.model self._load_model() return self.model外层检查避免每次加锁内层检查确保只有一个线程执行加载。这个模式在单例初始化、缓存加载等场景里非常常用但很多人第一次写的时候都会忽略内层检查。7.3 内存泄漏那些不释放的张量Python有垃圾回收所以很多人觉得内存泄漏离自己很远。但在AI推理服务里内存泄漏非常常见尤其是涉及C扩展的时候。我遇到过一个案例服务运行几个小时后内存占用持续上升最后被系统杀掉。排查后发现每次推理都会创建一个大的临时张量虽然Python层面没有引用但底层C库没有正确释放。解决方案是显式调用释放函数或者用对象池复用张量。class TensorPool: def __init__(self, max_size10): self.pool queue.Queue(maxsizemax_size) def acquire(self, shape): try: return self.pool.get_nowait() except queue.Empty: return Tensor([0.0] * math.prod(shape), shape) def release(self, tensor): try: self.pool.put_nowait(tensor) except queue.Full: pass对象池的好处是减少了内存分配和释放的次数不仅避免了泄漏还能提升性能。但要注意从池里取出的张量必须清零否则会残留上一次的数据。8. 从能跑到跑得好推理工程的进阶思路8.1 量化用精度换速度的取舍量化是推理优化里最有效的手段之一。简单来说就是把32位浮点权重压缩成8位整数模型体积缩小4倍推理速度提升2到4倍。但代价是精度会下降具体下降多少取决于模型和量化方法。我做过一个对比测试一个图像分类模型原始浮点版本准确率92.3%量化到8位后准确率91.8%只掉了0.5个百分点但推理速度从45毫秒降到了18毫秒。这个 trade-off 在大多数场景下都是值得的。量化的核心是找到合适的缩放因子。最简单的方法是对称量化找到权重的最大绝对值然后除以127得到缩放因子。反量化时乘以这个因子即可。def quantize(tensor): max_val max(abs(x) for x in tensor.data) scale max_val / 127.0 quantized array.array(b, [int(round(x / scale)) for x in tensor.data]) return quantized, scale def dequantize(quantized, scale): return array.array(f, [x * scale for x in quantized])这个实现很粗糙实际工程中需要做逐通道量化、校准集统计等但原理是一样的。量化最大的坑是溢出如果某个值超出了127会被截断导致严重误差。所以缩放因子的选择非常关键。8.2 缓存用空间换时间的经典策略推理服务里有很多可以缓存的东西模型权重、预处理结果、甚至完整的推理结果。缓存策略的核心是判断哪些数据会被重复使用。我通常会在三个层面做缓存第一层是模型权重缓存避免重复加载第二层是预处理缓存比如tokenizer的结果第三层是结果缓存对于相同的输入直接返回之前的结果。结果缓存需要特别注意失效策略。如果模型更新了缓存必须清空。我的做法是给每个模型版本一个哈希值缓存键里包含这个哈希值模型更新后哈希变化旧缓存自然失效。8.3 监控没有度量就没有优化最后但同样重要的是监控。没有监控你根本不知道服务在线上表现如何。我至少会监控这几个指标请求延迟的P50、P95、P99每秒请求数错误率内存占用GPU利用率。这些指标里P99延迟最值得关注。平均值会掩盖长尾问题而用户体验往往由最慢的那1%请求决定。我见过一个服务平均延迟20毫秒但P99延迟2秒用户感知就是“有时候特别卡”。监控数据还能帮你做容量规划。如果你知道每个请求平均消耗多少内存、多少GPU时间就能算出单机最大承载量从而决定什么时候扩容。9. 个人经验从零构建AI工程的三条原则第一条原则是先跑通再优化。我见过太多人一开始就追求完美架构结果卡在某个细节上迟迟出不了成果。正确的做法是先写一个能跑的最简版本哪怕它很慢、很丑然后再逐步优化。有了可运行的基线你才能度量优化的效果。第二条原则是每一层都要能单独测试。张量操作、自动微分、模型加载、推理服务每一层都应该有独立的测试用例。这样当问题出现时你能快速定位是哪一层的责任。我在实际项目中养成的习惯是每写一个新模块先写测试再写实现。第三条原则是不要重复造轮子但要理解轮子。生产环境里当然可以用成熟的框架但你自己必须清楚框架在背后做了什么。这样当框架出问题时你才有能力去排查和修复。ai-engineering-from-scratch的意义不在于替代框架而在于让你成为能驾驭框架的人而不是被框架驾驭的人。我在最近的一个边缘设备推理项目里就是靠着自己实现的轻量张量库和推理引擎把模型跑在了内存只有几十兆的设备上。如果用标准框架光运行时初始化就要占掉大半内存。这种场景下从零构建的能力就是不可替代的。