ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

从零手搓AI工程:不调包搭建可运行系统的完整指南

从零手搓AI工程:不调包搭建可运行系统的完整指南 1. 从零手搓AI工程为什么我不建议你直接调包第一次看到ai-engineering-from-scratch这个项目名的时候我脑子里蹦出来的画面是一个人坐在黑漆漆的终端前拒绝所有现成的框架从矩阵乘法开始一行一行地把一个能跑起来的AI系统给搭出来。这个直觉基本是对的但它比“手写一个神经网络”要宽得多。AI工程不等于模型训练它是一整条链路——数据怎么进来、特征怎么处理、模型怎么选、推理怎么部署、服务怎么监控、成本怎么压下来。这个项目标题里的 “from scratch”我理解成两层意思一层是不依赖重型框架去理解底层原理另一层是从零搭建一套可运行的工程骨架而不是停留在 notebook 里跑个 demo。我做了十多年一线见过太多团队在“调包”这件事上翻车。不是调包不好而是当你不知道包里面发生了什么出了问题你连日志都看不懂。模型输出突然变差你怀疑是数据漂移结果发现是预处理里某个归一化参数写反了线上延迟飙高你以为是模型太大结果发现是 batch 拼装逻辑在某个边界条件下退化成了一条一条推理。这些坑只有你亲手从零搭过一遍才会有肌肉记忆。这篇东西适合谁看如果你是刚入行、只会model.fit()的算法同学它能帮你把工程这条腿补上如果你是后端转AI、被各种框架绕晕的工程师它能给你一条清晰的、不依赖魔法的主线如果你是带团队的技术负责人它可以当作一份“最小可用AI系统”的搭建清单用来对齐团队认知。我不打算写成教科书就按我自己搭这类系统的真实顺序来讲中间会穿插大量我踩过的坑和实测参数。2. 整体架构设计先想清楚边界再动手写代码2.1 从零不等于从原始社会开始很多人对 “from scratch” 有个误解觉得必须连 NumPy 都不用纯 Python 列表推导式算矩阵。我明确说没必要也不推荐。从零的核心是“你清楚每一层的职责和数据形态”而不是“拒绝一切工具”。NumPy 是数值计算的基础设施用它不丢人真正要警惕的是那种一行pipeline.fit()把数据清洗、特征工程、模型训练全包了、你完全不知道中间发生了什么的黑盒。我的选型原则很简单底层数值用 NumPy数据处理用原生 Python Pandas 做轻量清洗模型部分手写核心算子服务层用 FastAPI。为什么不用 PyTorch 或 TensorFlow因为一旦用了你就很难忍住不去调nn.Linear而手写一遍前向和反向传播你对梯度、维度、初始化的理解会上一个台阶。等你手写完再回去用框架你会发现你读源码的速度完全不一样了。这里有个关键取舍手写模型只适合中小规模、结构清晰的场景。如果你要做的是十亿参数的大模型那必须用分布式框架手写不现实。所以这个项目的定位是“教学 小规模生产验证”不是“替代工业级训练框架”。想清楚这一点后面的技术选型就不会拧巴。2.2 分层架构与数据流设计我习惯把整个系统切成四层每层只跟相邻层打交道这样出问题的时候能快速定位是哪一层的锅。层级职责典型技术出问题时的表现数据层采集、清洗、切分、版本管理Pandas、Parquet、DVC训练指标抖动、特征分布异常特征层归一化、编码、特征交叉NumPy、sklearn预处理线上线下不一致、推理结果偏移模型层前向、损失、反向、优化手写NumPy损失不下降、梯度爆炸/消失服务层接口、批处理、监控、日志FastAPI、Prometheus延迟高、超时、内存泄漏数据流是这样的原始数据先落盘成 Parquet比 CSV 快得多列式存储对特征读取友好然后经过清洗脚本产出训练集和验证集特征层把原始字段转成模型能吃的数值矩阵模型层训练并保存权重服务层加载权重对外提供推理。每一层的输出都要落盘或打日志没有落盘的中间产物等于没有这是我在生产环境用血换来的教训。2.3 为什么坚持“可复现”优先于“高性能”新手搭系统最容易犯的错是一上来就追求 QPS 和低延迟结果代码写得极其复杂换个数据集就跑不起来。我的建议是第一阶段只追求可复现。什么叫可复现同样的数据、同样的随机种子、同样的代码跑出来的结果必须一模一样。为此你要做三件事固定所有随机源NumPy、Python random、甚至哈希种子把数据切分逻辑写成确定性的把超参数全部外置成配置文件。我实测下来一个可复现的朴素实现哪怕推理延迟是优化后的三倍它的价值也远高于一个跑得快但结果飘忽的版本。因为只有可复现你才能做 A/B 对比才能定位是哪个改动带来了提升。等你把基线跑稳了再去做向量化、批处理、缓存这些优化顺序不能反。3. 核心模块拆解手写模型到底要写哪些东西3.1 数据预处理最脏最累但最不能省数据预处理这块我见过太多人草草了事。真实数据里一定有缺失值、异常值、类型不一致、时间格式混乱。我的处理顺序是先做数据探查统计每个字段的缺失率、唯一值数量、数值分布然后做清洗缺失率超过 60% 的字段直接丢弃数值字段用中位数填充比均值抗异常值类别字段用众数或单独标记为 “unknown”最后做切分按时间切分而不是随机切分如果你的场景有时序性。这里有个大坑归一化参数必须从训练集计算然后应用到验证集和测试集。我见过有人对整个数据集做归一化再切分这会导致数据泄漏验证集指标虚高上线后直接崩。正确做法是训练集算均值和标准差存下来推理时用同一套参数。这个参数文件要跟模型权重一起版本管理缺一不可。# 训练集计算归一化参数 mean X_train.mean(axis0) std X_train.std(axis0) 1e-8 # 防止除零 X_train_norm (X_train - mean) / std # 验证集和测试集用同一套参数 X_val_norm (X_val - mean) / std # 保存参数 np.savez(norm_params.npz, meanmean, stdstd)那个1e-8是防止某个特征方差为零导致除零这种细节不写出来线上就会给你报一堆 nan。3.2 手写前向传播维度对齐是永恒的主题手写前向传播核心就三件事矩阵乘法、激活函数、维度对齐。我建议从最简单的全连接网络开始两层隐藏层足够验证整条链路。权重初始化用 He 初始化针对 ReLU公式是std sqrt(2 / fan_in)其中fan_in是输入维度。为什么不用全零初始化因为全零会让所有神经元对称反向传播时梯度一样等于白搭。为什么不用过大的随机值因为会导致激活值饱和梯度消失。def init_weights(in_dim, out_dim): std np.sqrt(2.0 / in_dim) return np.random.randn(in_dim, out_dim) * std def forward(X, W1, b1, W2, b2, W3, b3): z1 X W1 b1 a1 np.maximum(0, z1) # ReLU z2 a1 W2 b2 a2 np.maximum(0, z2) z3 a2 W3 b3 return z3, (z1, a1, z2, a2)维度对齐这块我的经验是每写一行就打印一次 shape。别嫌麻烦等你遇到(32, 10) (64, 10)这种报错的时候回头查维度能查到你怀疑人生。我习惯在开发阶段加一个assert检查比如assert X.shape[1] W1.shape[0]上线前再把这些断言去掉或改成日志。3.3 反向传播链式法则的工程化落地反向传播是手写模型里最容易写错的部分。我的方法是先推导再编码推导过程写在注释里。以交叉熵损失 Softmax 为例输出层的梯度有一个非常优雅的简化形式dz y_pred - y_true。这个结论能省掉一大堆求导但前提是你用的是 Softmax 交叉熵的组合。如果你换成别的损失函数这个简化就不成立了必须老老实实按链式法则推。def backward(X, y_true, cache, weights): z1, a1, z2, a2 cache W1, W2, W3 weights m X.shape[0] # 输出层梯度Softmax 交叉熵的简化形式 dz3 (softmax(z3) - y_true) / m dW3 a2.T dz3 db3 dz3.sum(axis0) # 隐藏层2 da2 dz3 W3.T dz2 da2 * (z2 0) # ReLU导数 dW2 a1.T dz2 db2 dz2.sum(axis0) # 隐藏层1 da1 dz2 W2.T dz1 da1 * (z1 0) dW1 X.T dz1 db1 dz1.sum(axis0) return dW1, db1, dW2, db2, dW3, db3注意那个/ m是对 batch 求平均这样学习率不会随 batch size 变化而需要重新调。ReLU 的导数(z 0)在 z0 处不可导工程上直接取 0 或 1 都行实测影响可以忽略。3.4 优化器与训练循环学习率是最重要的超参数优化器我建议从最朴素的 SGD 开始然后加 Momentum最后再上 Adam。为什么因为 SGD 能让你直观感受到学习率的影响而 Adam 的自适应学习率会掩盖很多问题。学习率的选择有个经验公式先试1e-1、1e-2、1e-3、1e-4四个量级看损失下降曲线选那个下降最快又不震荡的。我实测下来对于中小型全连接网络1e-3配合 Adam 通常是个不错的起点。训练循环里必须有的东西训练损失、验证损失、验证指标、早停机制。早停的 patience 我一般设 5 到 10 个 epoch具体看数据量。数据量大就设小一点因为每个 epoch 成本高数据量小就设大一点给它更多机会。还有一个细节每个 epoch 结束后打乱训练数据但验证集不要打乱这样验证指标才可比。4. 工程化落地从能跑到能用还差十万八千里4.1 配置管理别把超参数写死在代码里我见过太多项目学习率、batch size、层数全写在代码里想改一个参数得改代码、重新提交、重新部署。正确做法是用配置文件YAML 或 JSON代码只读配置。配置里至少包含数据路径、模型结构参数、训练超参数、服务端口、日志级别。这样你换数据集、调参、部署到不同环境都只改配置不改代码。# config.yaml data: train_path: data/train.parquet val_path: data/val.parquet batch_size: 64 model: hidden_dims: [128, 64] dropout: 0.2 training: lr: 0.001 epochs: 100 patience: 7 serving: host: 0.0.0.0 port: 8000配置管理还有个好处实验可追溯。每次训练把配置和对应的指标存到一张表里回头分析“哪个配置效果好”的时候直接查表就行不用翻聊天记录。4.2 模型持久化权重、参数、版本一个都不能少模型保存不是只存权重就完事了。我要求保存的东西包括模型权重、归一化参数、特征字段列表、模型版本号、训练时的配置、训练日期。为什么因为推理的时候你需要知道输入字段的顺序、需要做同样的归一化、需要知道这个模型是什么时候训练的。少任何一样线上都可能出问题。我习惯用目录来组织models/v1/weights.npz、models/v1/norm_params.npz、models/v1/feature_list.json、models/v1/config.yaml。服务启动时加载整个目录版本号从目录名读。这样回滚的时候直接切目录就行干净利落。4.3 服务层设计批处理与单条推理要兼顾服务层用 FastAPI 是个务实的选择轻量、异步、自带文档。接口设计上我建议同时提供两个端点/predict处理单条请求/predict_batch处理批量请求。为什么因为线上流量往往是混合的实时请求走单条离线补数据走批量。如果只做单条批量场景下网络开销会拖垮性能如果只做批量实时请求又没法满足。from fastapi import FastAPI import numpy as np app FastAPI() model load_model(models/v1) app.post(/predict) def predict(item: dict): x preprocess(item) # 用保存的归一化参数 logits model.forward(x) prob softmax(logits) return {label: int(np.argmax(prob)), confidence: float(np.max(prob))} app.post(/predict_batch) def predict_batch(items: list): X np.stack([preprocess(item) for item in items]) logits model.forward(X) probs softmax(logits) return {labels: np.argmax(probs, axis1).tolist()}批处理的时候注意内存别一次塞太多。我一般限制 batch 上限为 256超过就分片处理。还有推理的时候记得关掉梯度计算虽然手写 NumPy 没有自动求导但如果你用了框架torch.no_grad()是必须的。4.4 监控与日志上线才是真正的开始服务上线只是开始监控才是保证它活着的东西。我至少要监控四个指标请求量、延迟分布P50/P95/P99、错误率、输入特征分布。前三个是常规的第四个是AI系统特有的。为什么监控输入分布因为模型对训练时没见过的数据分布表现很差如果线上输入分布突然偏移模型输出会悄悄变差但你从延迟和错误率上看不出来。日志方面我要求每次推理都记录请求ID、输入特征摘要脱敏后、输出结果、耗时。这样出问题的时候能快速定位是哪个请求、什么输入导致的。日志量大的话采样记录但错误请求必须全量记录。5. 常见问题与排查技巧实录5.1 损失不下降的排查顺序损失不下降是新手最常遇到的问题。我的排查顺序是先看数据再看初始化再看学习率最后看梯度。数据方面检查标签有没有对齐、有没有全零或全一的特征、类别是否极度不平衡。初始化方面检查权重标准差是不是太大或太小。学习率方面从1e-4到1e-1扫一遍。梯度方面打印每一层的梯度范数如果某层梯度全是零说明那层死了ReLU 的经典问题可以换 LeakyReLU 或调小学习率。现象可能原因排查方法解决损失不变学习率过小扫学习率调大损失震荡学习率过大看损失曲线调小或加动量损失变 nan梯度爆炸打印梯度范数梯度裁剪验证损失上升过拟合对比训练/验证曲线加正则、早停某层梯度全零神经元死亡打印激活值换激活函数5.2 线上线下不一致的经典原因线上线下不一致我总结下来就三个原因特征处理不一致、归一化参数不一致、字段顺序不一致。特征处理不一致比如训练时对缺失值填中位数线上填了零归一化参数不一致比如训练用训练集均值线上用了全量均值字段顺序不一致比如训练时字段是 [A, B, C]线上传进来是 [C, A, B]。这三个问题只要你在服务层严格复用训练时的预处理代码和参数文件就能避免。我的做法是把预处理逻辑封装成一个类训练和推理共用同一个类。训练时fit计算参数并保存推理时load参数并transform。这样代码只有一份不存在两边逻辑漂移的可能。5.3 性能优化的几个实用手段性能优化别一上来就搞复杂的。我实测有效的顺序是先向量化再批处理再缓存最后才考虑模型压缩。向量化就是把 Python 循环换成 NumPy 操作这一步通常能带来十倍以上的提升。批处理是把多条请求合并成一次矩阵运算提升吞吐。缓存是针对重复输入如果线上有大量重复查询加一层 LRU 缓存能显著降低延迟。模型压缩量化、剪枝是最后的手段因为它会损失精度需要仔细评估。提示优化之前一定要有基线数据不然你无法判断优化是否有效。我习惯用time.perf_counter()在关键路径打点记录每个阶段的耗时这样瓶颈在哪一目了然。5.4 我踩过的三个真实坑第一个坑随机种子没固定全。我只固定了 NumPy 的种子忘了 Python 内置 random 的种子结果数据打乱顺序每次不一样实验无法复现。后来我写了个set_seed函数把所有随机源都固定一遍。第二个坑归一化参数保存成了 Python list加载后变成了字符串。因为用了 JSON 保存浮点数精度丢失加上类型转换导致推理结果和训练差了一点点。后来改用np.savez保存二进制问题消失。第三个坑服务层没有限制请求体大小有人传了一个超大 batch直接把内存打满服务挂了。后来加了请求体大小限制和 batch 上限并且在入口做了参数校验。6. 从零搭建的扩展方向与个人体会这套从零搭起来的骨架跑通之后你会发现它像个乐高底座往上加东西很自然。想加文本特征就在特征层加一个分词和 embedding 模块想加时序特征就在数据层加滑窗逻辑想加多模型融合就在服务层加一个路由层。因为每一层职责清晰扩展的时候不会牵一发动全身。我个人在实际操作中的体会是从零搭一遍最大的收获不是那个模型本身而是你对“数据怎么流动”的直觉。以前调包的时候数据在框架里怎么走我是不关心的手写一遍之后我知道每一步的输入输出是什么形状、什么范围、什么含义。这种直觉在你排查线上问题、做架构设计、跟别人讨论方案的时候价值巨大。最后分享一个小技巧如果你觉得从零写整个系统太重可以先从只手写模型层开始数据层和服务层用现成工具。等你对模型层有感觉了再逐步往下替换。这样学习曲线平缓也不容易半途而废。这个项目后续还可以往分布式训练、模型版本灰度发布、特征存储这些方向扩展但那是另一个阶段的事了先把单机版跑稳再说。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表