尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零搭建AI工程体系:避开调包陷阱,掌握核心工程能力

从零搭建AI工程体系:避开调包陷阱,掌握核心工程能力 1. 从零搭建AI工程体系为什么我劝你别急着调包ai-engineering-from-scratch这个标题第一次看到的时候我愣了一下。市面上讲AI的教程铺天盖地但绝大多数都是教你import torch然后跑个预训练模型或者调个API接口就完事。真正从零开始、把AI工程当作一门系统工程来拆解的少之又少。我自己在这个坑里摸爬滚打了几年从最早用sklearn跑逻辑回归到后来搭Transformer训练流水线再到给团队做MLOps基础设施踩过的坑能写一本书。所以看到这个标题的时候我特别有共鸣——因为from scratch这四个字恰恰是大多数人跳过、但最终又不得不回头补课的部分。这篇博文我想聊的是如果你真的想从零构建一套AI工程能力而不是停留在调包侠的层面你需要理解哪些核心环节、避开哪些陷阱、按什么顺序推进。适合刚入行想打牢基础的工程师也适合做了几年CRUD想转型AI工程的后端开发。我不会给你画大饼只讲我实际做过、验证过的东西。2. 整体设计思路AI工程到底在工程什么2.1 先搞清楚AI工程和算法研究的边界很多人把AI工程和算法研究混为一谈这是第一个认知陷阱。算法研究关心的是这个模型能不能在benchmark上刷到SOTA而AI工程关心的是这个模型能不能在线上稳定跑三个月、延迟控制在50ms以内、成本可控、出问题能快速回滚。我见过太多团队算法同学在notebook里跑出一个漂亮的结果交给工程同学部署结果发现推理速度慢十倍、显存爆炸、batch size一改结果就飘。这不是谁的能力问题而是两边对工程的理解根本不在一个维度上。从零搭建AI工程体系我的建议是把整个链路拆成五层数据层、训练层、评估层、部署层、监控层。每一层都有独立的工程挑战但层与层之间的接口设计才是真正决定成败的地方。比如数据层输出的格式直接决定了训练层能不能做流式加载评估层的指标定义决定了监控层能不能及时发现模型退化。2.2 为什么我选择自底向上而不是自顶向下市面上主流的AI工程教程大多走的是自顶向下的路线先给你一个完整的框架比如LangChain、LlamaIndex然后告诉你每个模块怎么用。这种路线上手快但有个致命问题——你永远不知道框架在背后帮你做了什么出了问题只能靠猜。我选择自底向上的路线理由很实际AI工程领域变化太快了。今天流行的框架明年可能就没人维护了。但底层的那些东西——张量运算、梯度传播、数据管道、服务化——这些是十年不变的。你把底层搞明白了上层框架换多少个你都能快速适应。具体来说我建议的推进顺序是先用NumPy手写一个最简单的线性回归包括前向和反向传播然后用PyTorch复现同样的东西接着引入Dataset和DataLoader做数据管道再往上加训练循环、验证、checkpoint最后做服务化和监控。每一步都只引入一个新的复杂度确保你完全理解当前这一层在干什么。2.3 工具选型的核心原则可替换性在选工具的时候我有一条铁律任何一个组件都必须能在不重写其他代码的前提下被替换掉。这条原则听起来简单做起来很难。比如你如果用PyTorch Lightning你的训练逻辑就和Lightning的Trainer强绑定了想换成原生PyTorch或者JAX基本等于重写。但如果你把训练循环、优化器、数据加载都抽象成独立的接口换后端就只是换一个实现类的事。我自己的做法是定义几个核心抽象DataPipeline、ModelWrapper、Trainer、Evaluator、Predictor。每个抽象只暴露必要的方法内部实现随便换。这样即使明年出了个新框架我也只需要写一个新的适配器而不是推倒重来。提示抽象不是越多越好。我一开始定义了十几个接口结果发现大部分都是过度设计。后来砍到五个反而更清晰。判断标准很简单如果这个抽象只有一个实现而且短期内看不到第二个实现的可能性那就不要抽象。3. 核心细节解析从零构建的四个关键环节3.1 数据管道被低估的工程重灾区如果让我选一个AI工程中最容易被低估的环节数据管道绝对排第一。大多数人觉得数据管道就是读文件、转格式、喂给模型实际上这里面藏着无数坑。第一个坑是数据格式的选择。CSV适合小规模、人类可读的场景但一旦数据量上到GB级别CSV的解析开销就会成为瓶颈。Parquet和Arrow格式在列式存储和零拷贝读取上有天然优势我实测下来同样的数据量Parquet的加载速度比CSV快5到10倍。但Parquet也有代价——你不能像CSV那样直接用文本编辑器打开看调试的时候需要额外的工具。第二个坑是数据加载的并行策略。PyTorch的DataLoader有个num_workers参数很多人随手设成4或者8就完事了。但实际上这个值需要根据你的CPU核心数、IO瓶颈、以及每个batch的处理耗时来调。我的经验是如果数据预处理是CPU密集型的num_workers设成CPU核心数的70%左右比较合适如果是IO密集型的可以适当调高但要注意内存占用会线性增长。第三个坑是数据版本管理。模型训练出问题的时候你首先需要确认的是数据有没有变。如果没有版本管理你根本不知道上周的训练数据和这周的是不是同一份。我的做法是用DVC或者简单的hash校验每次训练前记录数据的指纹这样出问题的时候可以快速定位。# 一个我常用的数据管道骨架 class DataPipeline: def __init__(self, source, transformsNone, batch_size32): self.source source self.transforms transforms or [] self.batch_size batch_size def __iter__(self): for batch in self._load_batches(): for transform in self.transforms: batch transform(batch) yield batch def _load_batches(self): # 具体实现根据数据源类型决定 raise NotImplementedError这个骨架的好处是_load_batches的具体实现可以随时替换而上层的transform逻辑和batch逻辑保持不变。我后来从本地文件切换到对象存储、再切换到流式数据源上层代码一行没改。3.2 训练循环手写一遍你就懂了现在很多人直接用model.fit()或者Trainer.fit()从来没手写过训练循环。我强烈建议你至少手写一次完整的训练循环包括前向传播、损失计算、反向传播、梯度裁剪、参数更新、学习率调度。写完之后你会对训练这件事有完全不同的理解。手写训练循环的核心难点在于梯度管理。PyTorch默认会累积梯度如果你忘记在每一步调用optimizer.zero_grad()梯度就会越积越大最后梯度爆炸。这个bug特别隐蔽因为前几十步可能看起来正常到后面突然loss变成NaN。另一个容易出问题的地方是验证集的评估。很多人把验证逻辑写在训练循环里面用model.eval()和torch.no_grad()包起来。这本身没问题但如果你在验证的时候忘了切换回model.train()后面的训练就会出问题——因为dropout和batchnorm在eval模式下的行为和train模式完全不同。# 手写训练循环的核心结构 for epoch in range(num_epochs): model.train() for batch in train_loader: optimizer.zero_grad() outputs model(batch[input]) loss criterion(outputs, batch[label]) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() model.eval() with torch.no_grad(): for batch in val_loader: outputs model(batch[input]) # 累积验证指标这段代码看起来简单但每一行背后都有讲究。clip_grad_norm_的max_norm设多少我一般从1.0开始试如果训练不稳定就降到0.5如果收敛太慢就升到2.0。scheduler.step()的位置也很关键——是按step调还是按epoch调效果可能差很多。3.3 评估体系别让离线指标骗了你离线评估指标好看上线效果拉胯这是AI工程中最常见的翻车场景。根本原因在于离线评估和线上真实分布之间存在gap。我踩过最惨的一次坑是做一个文本分类任务离线F1到了0.92上线之后用户反馈经常分类错误。排查了半天才发现离线评估用的是清洗过的数据而线上数据里混了大量的噪声样本——用户输入里有特殊符号、有错别字、有中英文混杂。模型在干净数据上表现很好但面对真实数据就露馅了。从那以后我的评估体系里一定会包含三个部分标准测试集、对抗测试集、线上采样集。标准测试集用来跟踪模型的基本能力对抗测试集用来测试模型的鲁棒性线上采样集用来监控真实分布下的表现。三个指标一起看才能对模型有全面的判断。评估的另一个关键是切片分析。整体指标好看不代表每个子群体都表现好。比如一个推荐模型整体AUC可能是0.85但对新用户的AUC可能只有0.6。如果不做切片分析你根本发现不了这个问题。我的做法是按用户活跃度、地域、设备类型等维度做切片每个切片单独算指标低于阈值的切片重点排查。3.4 服务化从notebook到生产环境的鸿沟把模型从notebook搬到生产环境这个过程的复杂度往往被严重低估。notebook里你只需要关心输入输出的正确性生产环境里你要关心延迟、吞吐、并发、容错、版本管理、灰度发布。延迟方面我实测下来一个BERT-base模型在CPU上单次推理大概要200ms在GPU上大概20ms。如果你的业务要求P99延迟在100ms以内那CPU方案直接出局。但GPU方案的成本是CPU的几十倍所以你需要根据业务量做权衡。我的经验是QPS低于10的场景用CPU加批处理就够了QPS高于100的场景必须上GPU。吞吐方面批处理是最有效的优化手段。同样的GPUbatch size从1提到32吞吐量能提升10倍以上而延迟只增加一点点。但批处理有个前提——你的请求要能攒起来。如果是实时性要求极高的场景比如自动驾驶批处理就不适用。版本管理方面我强烈建议用模型注册中心来管理模型版本。每次训练产出的模型都注册进去带上训练数据版本、超参数、评估指标等元信息。上线的时候从注册中心拉取指定版本回滚的时候也只需要切换版本号。这样出问题的时候你能快速定位是哪个版本、用了什么数据、改了什么参数。4. 实操过程从零搭建一个可用的AI工程骨架4.1 环境准备与依赖管理环境准备这一步我踩过的坑比后面所有环节加起来都多。核心问题是依赖冲突——PyTorch、CUDA、cuDNN、Python版本之间的兼容性矩阵极其复杂稍不注意就装出一个跑不起来的组合。我的做法是用conda管理Python环境用pip管理Python包CUDA和cuDNN通过conda安装。这样能最大程度避免版本冲突。具体的版本组合我会记录在一个environment.yml文件里每次重建环境都用这个文件确保可复现。# environment.yml 示例 name: ai-engineering channels: - pytorch - nvidia - conda-forge dependencies: - python3.10 - pytorch2.1 - pytorch-cuda12.1 - numpy - pandas - pyarrow - scikit-learn - pip - pip: - transformers - datasets - mlflow这个文件的好处是任何人拿到你的项目一条命令就能重建出一模一样的环境。我团队里新同学入职配环境从原来的一天缩短到十分钟。注意不要用pip install torch这种不带版本号的方式。今天装的是2.1明天可能就变成2.2了而2.2可能和你的CUDA版本不兼容。所有依赖都要锁版本。4.2 项目结构设计项目结构这件事看起来是小事实际上影响巨大。我见过太多项目所有代码堆在一个main.py里超过两千行改一个地方要翻半天。好的项目结构应该让人一眼就能看出每个模块的职责。我常用的结构是这样的project/ ├── configs/ # 配置文件 ├── data/ # 数据相关 │ ├── raw/ # 原始数据 │ ├── processed/ # 处理后数据 │ └── pipeline.py # 数据管道 ├── models/ # 模型定义 ├── training/ # 训练逻辑 │ ├── trainer.py │ └── callbacks.py ├── evaluation/ # 评估逻辑 ├── serving/ # 服务化 │ ├── app.py │ └── predictor.py ├── monitoring/ # 监控 ├── tests/ # 测试 └── scripts/ # 工具脚本这个结构的核心思想是按职责分层按流程分模块。数据相关的都在data/下模型相关的都在models/下训练相关的都在training/下。新人拿到项目看目录结构就能大致理解整个流程。配置管理我推荐用Hydra或者简单的YAML加dataclass。Hydra的好处是支持配置组合和命令行覆盖比如你可以定义一个base.yaml然后experiment_1.yaml继承它并覆盖部分参数。这样管理多组实验特别方便。4.3 训练流程的完整实现训练流程的实现我分成四个阶段数据准备、模型初始化、训练循环、checkpoint管理。数据准备阶段核心是把原始数据转换成模型能吃的格式。这一步我一般会做缓存——第一次处理完之后把结果存成Parquet或者Arrow格式后续直接加载缓存省去重复处理的时间。缓存的有效性用数据指纹来判断数据变了就重新处理。模型初始化阶段核心是权重初始化。很多人直接用默认初始化但默认初始化不一定适合你的任务。比如如果你的激活函数是ReLU用He初始化通常比Xavier初始化效果好。如果你的网络很深残差连接和LayerNorm的位置也会影响初始化策略。训练循环阶段前面已经讲过核心结构。这里补充一个经验训练过程中一定要记录足够多的信息。除了loss和accuracy还要记录学习率、梯度范数、参数范数、每层的激活值分布。这些信息在排查训练问题时极其有用。我一般用TensorBoard或者Weights Biases来记录实时能看到曲线。checkpoint管理阶段核心是存什么和什么时候存。我一般会存三样东西模型参数、优化器状态、训练进度epoch和step。只存模型参数的话断点续训的时候优化器状态丢失会导致训练不稳定。存的频率我一般按epoch存同时保留最近三个和最好的一个避免磁盘爆掉。# checkpoint保存和加载 def save_checkpoint(model, optimizer, epoch, step, path): torch.save({ model_state: model.state_dict(), optimizer_state: optimizer.state_dict(), epoch: epoch, step: step, }, path) def load_checkpoint(model, optimizer, path): checkpoint torch.load(path) model.load_state_dict(checkpoint[model_state]) optimizer.load_state_dict(checkpoint[optimizer_state]) return checkpoint[epoch], checkpoint[step]4.4 服务化部署的实操细节服务化部署我推荐从FastAPI开始。轻量、异步、自带文档对于大多数AI服务场景够用了。核心是把模型加载和请求处理分开——模型在服务启动时加载一次请求处理时直接复用避免每次请求都重新加载模型。from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI() model None class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str confidence: float app.on_event(startup) def load_model(): global model model torch.load(model.pt) model.eval() app.post(/predict, response_modelPredictResponse) async def predict(request: PredictRequest): with torch.no_grad(): output model(request.text) return PredictResponse( labeloutput.argmax().item(), confidenceoutput.softmax().max().item() )这段代码看起来简单但有几个细节需要注意。第一model.eval()必须在加载后调用否则dropout和batchnorm的行为不对。第二torch.no_grad()必须加否则会累积计算图内存爆炸。第三模型加载放在startup事件里确保服务启动完成前模型已经就绪。批处理是服务化性能优化的关键。如果QPS较高可以在服务层做一个请求队列攒够一定数量或者等待一定时间后一起推理。这样能大幅提升GPU利用率。但批处理会增加延迟需要根据业务场景权衡。5. 常见问题与排查技巧实录5.1 训练不收敛的排查思路训练不收敛是最高频的问题没有之一。我的排查顺序是这样的第一步检查数据。把数据打印出来看几眼确认标签和输入是对应的没有错位、没有乱码、没有全零。我遇到过好几次排查了半天模型问题最后发现是数据加载的时候shuffle和batch对不上。第二步检查损失函数。分类任务用交叉熵回归任务用MSE这个基本不会错。但要注意标签的格式——交叉熵要求标签是类别索引不是one-hot。如果标签格式不对损失函数不会报错但梯度会完全错误。第三步检查学习率。学习率太大loss会震荡甚至爆炸学习率太小loss下降极慢。我的经验是从1e-3开始试如果loss震荡就降到1e-4如果下降太慢就升到1e-2。学习率调度器也很重要cosine退火和step衰减是我最常用的两种。第四步检查梯度。打印每一层的梯度范数如果某一层的梯度特别大或者特别小说明那一层有问题。梯度特别大通常是初始化问题或者学习率太大梯度特别小通常是梯度消失需要加残差连接或者换激活函数。5.2 显存不足的优化手段显存不足是第二高频的问题。优化手段按性价比排序优化手段显存节省对精度影响实现难度减小batch size线性可能影响收敛低梯度累积线性基本无影响低混合精度训练约50%基本无影响中梯度检查点约60%基本无影响中模型并行取决于切分无影响高梯度累积是我最推荐的手段。原理很简单用小的batch size做前向和反向但不清零梯度累积几次之后再更新参数。这样等效于大batch训练但显存占用是小batch的。实现上只需要把optimizer.zero_grad()和optimizer.step()的频率改一下。混合精度训练是另一个高性价比的手段。用FP16做前向和反向用FP32做参数更新显存占用减半速度还能提升。PyTorch的torch.cuda.amp模块让这件事变得很简单几行代码就能搞定。但要注意混合精度训练有时候会导致数值不稳定需要用梯度缩放来缓解。5.3 线上效果和离线不一致的排查线上效果和离线不一致这个问题最让人头疼因为排查起来涉及的面很广。我的排查清单是这样的首先确认特征一致性。离线用的特征和线上用的特征是不是同一套计算逻辑我遇到过好几次离线特征是用历史数据算的线上特征是用实时数据算的两者的分布有细微差异导致模型表现不一致。其次确认预处理一致性。离线预处理和线上预处理是不是完全一样比如文本分词离线用jieba线上用另外的分词器结果肯定不一样。我的做法是把预处理逻辑封装成一个独立的模块离线和线上共用同一份代码。然后确认数据分布。线上数据的分布和离线训练数据的分布是不是一致如果不一致说明有数据漂移需要重新训练或者做在线学习。监控数据分布是发现这个问题的关键我一般会监控几个关键特征的均值和方差偏离阈值就告警。最后确认评估口径。离线和线上的评估指标定义是不是一样比如离线算的是micro-F1线上算的是macro-F1数字当然对不上。评估口径必须在项目开始的时候就统一写进文档里。5.4 模型版本管理和回滚模型版本管理我的做法是用MLflow或者类似的工具。每次训练产出一个模型就注册到MLflow里带上完整的元信息训练数据版本、超参数、评估指标、代码commit hash。上线的时候从MLflow拉取指定版本回滚的时候也只需要切换版本号。回滚的触发条件我一般设三个线上指标下降超过阈值、错误率上升超过阈值、延迟上升超过阈值。任何一个触发自动回滚到上一个稳定版本。自动回滚比人工回滚快得多能把故障时间从几十分钟缩短到几分钟。提示回滚不是万能的。如果新版本的问题出在数据上回滚模型也没用。所以回滚的同时要保留现场把出问题的请求样本保存下来事后分析根因。6. 我踩过的那些坑和总结的经验6.1 关于从零的理解from scratch不等于不用任何库。我见过一些人走极端非要用纯NumPy实现所有东西结果代码又长又慢完全没有实用价值。我的理解是核心逻辑要自己写一遍理解清楚但工程实现可以用成熟的库。比如训练循环你可以手写一遍理解原理但实际项目里用PyTorch Lightning或者自己封装好的Trainer都没问题。关键是要知道每个库在帮你做什么。如果你用了一个库但不知道它在背后干了什么出了问题你就没法排查。我要求团队里的同学用任何一个新库之前先花半天时间读它的核心源码搞清楚它的输入输出和内部逻辑。6.2 关于工程和算法的平衡AI工程师最容易犯的错误是重算法轻工程。花大量时间调模型结构、调超参数但数据管道写得一塌糊涂服务化部署随便搞搞。结果就是离线指标很好看上线一塌糊涂。我的经验是在AI工程项目里工程的时间分配应该是数据40%、训练20%、评估20%、部署和监控20%。算法调优的时间应该控制在总时间的20%以内。因为大多数场景下数据质量的提升带来的收益远大于模型结构的微调。6.3 关于持续学习AI工程这个领域变化速度用日新月异来形容一点不夸张。今天流行的技术半年后可能就过时了。但底层的东西变化很慢——数据结构、算法复杂度、系统设计原则这些是十年不变的。我的学习策略是底层的东西花80%的时间打牢上层的新技术花20%的时间跟进。具体来说我会定期读一些经典的教材和论文同时关注几个高质量的技术博客和开源项目。新技术出来的时候先看它的核心思想不要急着上手用。等它稳定了、社区验证过了再投入时间学习。6.4 一个实用的检查清单最后分享一个我每次启动新AI项目都会过一遍的检查清单数据版本是否已记录能否复现训练环境是否已锁定能否一键重建评估指标是否已定义离线和线上是否一致模型版本是否已管理能否快速回滚监控是否已覆盖关键指标是否有告警文档是否已更新新人能否快速上手这个清单看起来简单但每一条背后都是血泪教训。我见过太多项目因为数据版本没记录出了问题查了两周因为环境没锁定换台机器就跑不起来因为监控没覆盖线上挂了半天才发现。这个清单我用了三年每次都能帮我提前发现一些问题。如果你刚开始做AI工程建议你也建一个自己的清单把踩过的坑都记下来下次就不会再踩了。
返回列表