ARTICLE DETAIL

资讯详情

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

从零搭建AI工程体系:数据、训练、推理与服务全链路实战

从零搭建AI工程体系:数据、训练、推理与服务全链路实战 1. 从零搭建AI工程体系为什么我劝你别急着调库第一次看到ai-engineering-from-scratch这个项目名我脑子里蹦出来的不是“又一个教程仓库”而是过去两年带团队时反复踩过的那个坑太多人把“会调model.fit()”当成“懂AI工程”。这两件事之间的距离大概相当于会拧螺丝和能造一辆车。这个项目标题直译过来就是“从零开始的AI工程”它瞄准的不是教你背API而是把AI系统从数据进、模型出、服务上线这一整条链路拆开让你亲手把每个环节搭一遍。适合谁看我的判断是三类人一是刚转行做算法、只会跑notebook的应届生二是后端出身、被拉来做模型服务的工程师三是带小团队、需要快速判断技术方案可行性的技术负责人。如果你已经能熟练用框架训模型但说不清显存为什么爆、推理延迟卡在哪、数据漂移怎么监控那这个方向对你价值最大。我打算按自己实际复现这类项目的顺序来讲不搞那种“先讲AI历史再讲线性回归”的教科书套路。核心就一句话AI工程的核心不是模型是让模型在真实环境里稳定产出可用结果的那套工程约束。下面从整体设计思路开始拆。2. 整体设计与思路拆解为什么“从零”比“调库”更值得做一遍2.1 先想清楚这个项目到底在解决什么问题大部分AI入门材料的问题在于“跳跃”。它们默认你已经有了干净数据、有了GPU、有了部署环境然后直接教你写模型。但真实工作里80%的时间花在数据管道、环境一致性、性能瓶颈定位上。ai-engineering-from-scratch这类项目的价值就是把这个被跳过的80%补回来。我理解它的核心设计思路是分层解耦把AI系统拆成数据层、训练层、推理层、服务层、监控层每一层都要求你从最朴素的实现开始而不是一上来就用高级封装。为什么这么设计因为只有你自己手写过一遍数据加载器才会真正理解DataLoader的num_workers为什么会影响吞吐只有自己实现过一遍推理批处理才会明白动态batch和静态batch在延迟上的取舍。这种“从零”不是让你重复造轮子用于生产而是让你建立性能直觉和故障定位能力。我见过太多人遇到OOM就只会调小batch size遇到推理慢就只会加机器根本原因是不知道钱花在哪、瓶颈在哪。2.2 方案选型为什么用Python而不是别的有人会问AI工程为什么默认Python我的实测结论是不是Python多优秀而是生态锁定效应太强。从数据处理到模型训练到服务框架Python的库覆盖度是其他语言短期内追不上的。但这个项目如果只讲Python就失去了“工程”的意义。我的建议是核心逻辑用Python性能敏感环节用C或Rust扩展。比如数据预处理里的tokenization纯Python循环处理百万级文本会慢到怀疑人生用C写个扩展或者用现成的Rust绑定速度能差一个数量级。这个项目标题里的“from scratch”我理解也包括这种跨语言边界的处理——知道什么时候该换语言本身就是工程能力。另一个选型点是配置管理。新手喜欢把超参数硬编码在脚本里老手会用YAML或Hydra做配置分层。我踩过的坑是实验跑了几十组之后根本记不清哪个checkpoint对应哪组参数。所以从项目一开始就强制自己用配置文件管理所有可变参数这个习惯能省掉后面无数次的“这个模型到底是哪版数据训的”的追问。2.3 目录结构设计别小看这件事一个能长期维护的AI工程项目目录结构比模型结构更重要。我参考这类项目的常见实践推荐这样的分层project/ configs/ # 所有配置文件按实验分组 data/ # 原始数据、处理后数据、缓存 src/ data/ # 数据加载、清洗、增强 models/ # 模型定义 training/ # 训练循环、损失、优化器 inference/ # 推理逻辑、批处理 serving/ # API、队列、并发 scripts/ # 训练、评估、导出入口 tests/ # 单元测试和集成测试 notebooks/ # 探索性分析不参与生产为什么这么分因为训练和推理的代码必须解耦。我见过太多项目训练脚本里直接调用了推理逻辑结果上线时发现推理环境没有训练依赖整个重写。把inference独立出来强制自己定义清晰的输入输出接口后面部署会顺很多。注意notebooks目录只用于探索任何进入生产的逻辑都必须从notebook里抽出来变成src下的模块。这个纪律不遵守三个月后你的项目就会变成一堆无法复现的ipynb文件。3. 核心细节解析与实操要点数据、训练、推理三层的硬功夫3.1 数据层AI工程里最脏最累但最值钱的部分数据层我把它拆成三件事加载、清洗、版本管理。先说加载。很多人直接用pandas.read_csv读几百MB的CSV内存直接爆掉。正确做法是用分块读取或者换成列式存储格式。我实测下来同样一份1GB的CSV转成Parquet后读取速度快3到5倍内存占用降一半以上。清洗环节的核心是可复现。你这次用某个规则过滤了异常值下次换个人跑同样的脚本结果必须一致。所以所有清洗逻辑必须写成纯函数输入输出明确不能依赖全局状态或随机种子。我习惯把清洗步骤写成管道def clean_pipeline(df): df remove_duplicates(df) df fill_missing(df, strategymedian) df clip_outliers(df, columns[age, income], n_std3) return df每一步单独可测出问题能快速定位是哪步引入的。数据版本管理是新手最容易忽略的。我的经验是数据也要像代码一样打tag。每次训练用的数据快照存一个hash模型checkpoint里记录这个hash。否则你根本不知道线上模型是用哪版数据训的。工具上可以用DVC或者简单的文件hash记录关键是养成习惯。实操心得数据加载的num_workers不是越大越好。我实测在4核机器上num_workers4时吞吐最高调到8反而因为进程切换开销导致下降。这个值需要根据CPU核数和IO速度实测确定没有万能公式。3.2 训练层从手写训练循环开始我强烈建议每个做AI工程的人都手写一遍完整的训练循环不用Trainer那种高级封装。为什么因为只有自己写过才知道梯度累积、混合精度、梯度裁剪这些技术到底在干什么。手写训练循环的核心结构for epoch in range(num_epochs): model.train() for batch in train_loader: optimizer.zero_grad() with autocast(): outputs model(batch) loss criterion(outputs, batch.labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) scaler.step(optimizer) scaler.update()这段代码里每个环节都有讲究。autocast做混合精度省显存提速度但某些操作在fp16下会溢出所以需要GradScaler动态调整缩放因子。梯度裁剪防止梯度爆炸max_norm设多少要看具体任务我一般从1.0开始试。显存估算是训练层的硬技能。一个粗略公式显存 ≈ 参数量 × 4字节fp32× 3模型梯度优化器状态 激活值。激活值和batch size、序列长度成正比。所以当你OOM时优先降batch size其次考虑梯度累积来维持等效batch。学习率调度也是坑。我试过余弦退火、线性预热加衰减、OneCycle结论是没有普适最优但预热几乎总是必要的。前几百步用很小的学习率让模型稳定之后再爬升能显著降低发散概率。3.3 推理层延迟和吞吐的平衡术推理层是AI工程和算法研究分道扬镳的地方。研究只看指标工程要看P99延迟、吞吐量、显存占用这三个指标的平衡。先说批处理。动态batch能提高吞吐但会增加尾延迟。我的做法是设一个最大等待时间比如10ms在这段时间内攒够多少算多少超时立即推理。这样在低峰期延迟可控高峰期吞吐也能上去。模型量化是另一个关键手段。fp16推理能省一半显存速度提升30%到50%精度损失通常可接受。int8量化更激进但需要校准数据集且不是所有层都适合量化。我实测下来对Transformer类模型只量化线性层效果最好注意力部分保持fp16。# 简单的动态批处理逻辑示意 class DynamicBatcher: def __init__(self, max_batch_size, max_wait_ms): self.max_batch_size max_batch_size self.max_wait_ms max_wait_ms self.queue [] def add(self, item): self.queue.append(item) if len(self.queue) self.max_batch_size: return self.flush() return None注意推理服务里千万不要用全局锁保护模型。我见过用threading.Lock导致所有请求串行化的案例吞吐直接掉到十分之一。正确做法是用队列加单worker或者用支持并发的推理框架。4. 实操过程与核心环节实现从空目录到可服务系统4.1 环境搭建可复现是第一原则第一步不是写代码是锁环境。我习惯用conda创建独立环境然后导出environment.yml里面固定所有包的精确版本。为什么不用requirements.txt因为conda能管理非Python依赖比如CUDA版本这对AI项目至关重要。conda create -n ai-eng python3.10 conda activate ai-eng conda install pytorch torchvision cudatoolkit11.8 -c pytorch pip install -r requirements.txt conda env export environment.ymlenvironment.yml里会记录CUDA版本和所有依赖的精确build号换机器重建时能最大程度保证一致。我踩过的坑是开发机用CUDA 11.8服务器用11.6结果某些算子行为不一致排查了一整天。4.2 数据管道实现从原始文件到训练可用的Dataset假设原始数据是一堆JSON文件每行一个样本。第一步是解析并缓存成列式格式import json import pyarrow as pa import pyarrow.parquet as pq def jsonl_to_parquet(input_path, output_path): texts, labels [], [] with open(input_path) as f: for line in f: obj json.loads(line) texts.append(obj[text]) labels.append(obj[label]) table pa.table({text: texts, label: labels}) pq.write_table(table, output_path)转成Parquet后读取速度提升明显而且支持按列读取训练时只加载需要的列。然后是Dataset实现。核心是惰性加载不要在__init__里把所有数据读进内存class TextDataset(Dataset): def __init__(self, parquet_path, tokenizer, max_len): self.table pq.read_table(parquet_path) self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.table) def __getitem__(self, idx): text self.table[text][idx].as_py() label self.table[label][idx].as_py() encoding self.tokenizer(text, truncationTrue, max_lengthself.max_len, paddingmax_length) return { input_ids: torch.tensor(encoding[input_ids]), attention_mask: torch.tensor(encoding[attention_mask]), label: torch.tensor(label) }__getitem__里做tokenization是常见做法但如果tokenizer很慢会成为瓶颈。优化手段是预tokenize并缓存把token ids直接存进Parquet训练时只做tensor转换。4.3 训练脚本配置驱动日志完整训练入口脚本我坚持用argparse加配置文件的方式。命令行只传配置文件路径所有参数从配置读import yaml import argparse def main(): parser argparse.ArgumentParser() parser.add_argument(--config, requiredTrue) args parser.parse_args() with open(args.config) as f: cfg yaml.safe_load(f) # 根据cfg初始化数据、模型、优化器 train(cfg)配置文件长这样data: train_path: data/train.parquet val_path: data/val.parquet max_len: 128 model: name: bert-base-chinese num_labels: 5 training: batch_size: 32 lr: 2e-5 epochs: 3 warmup_steps: 500 max_grad_norm: 1.0日志必须记录损失、学习率、梯度范数、显存占用。梯度范数能提前预警梯度爆炸显存占用帮你判断还能不能加大batch。我习惯每50步打一次日志用TensorBoard或WandB可视化。4.4 推理服务从模型文件到HTTP接口训练完导出模型后推理服务我推荐用FastAPI加Uvicorn。核心是把模型加载成全局单例请求进来走动态批处理from fastapi import FastAPI import torch app FastAPI() model None app.on_event(startup) def load_model(): global model model torch.jit.load(model.pt) model.eval() app.post(/predict) async def predict(text: str): inputs tokenizer(text, return_tensorspt) with torch.no_grad(): outputs model(**inputs) pred outputs.logits.argmax(-1).item() return {label: pred}生产环境还要加健康检查接口、请求超时、限流。健康检查让负载均衡知道实例是否可用超时防止慢请求拖垮整个服务。实操心得模型加载放在startup事件里不要放在模块顶层。模块顶层加载会导致多worker模式下每个worker都加载一遍显存直接翻倍。用startup事件配合单worker多线程或者用共享内存方案。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 训练不收敛先查数据再查模型训练loss不降新手第一反应是调学习率。我的经验是先查数据。具体查三件事标签有没有错位、输入有没有全零或全padding、类别分布是否极度不均衡。我遇到过标签和文本错位一位的情况模型怎么调都不收敛查了两天才发现是数据拼接时少了个换行符。排查顺序我整理成表现象优先排查具体检查项loss完全不降数据标签是否正确、输入是否有效loss降后震荡学习率是否过大、是否需要warmuploss降后突增梯度梯度范数是否爆炸、是否需要裁剪训练loss降验证不降过拟合数据量、正则化、早停5.2 显存OOM算清楚再调OOM时不要盲目调小batch。先算模型参数量 × 4字节 × 3模型梯度优化器是固定开销剩下的是激活值。激活值和batch size、序列长度、模型层数成正比。如果固定开销已经占了大半显存调batch size效果有限得考虑梯度累积或者模型并行。我实测的一个经验值BERT-base1.1亿参数在fp32下固定开销约1.3GBbatch size 32、序列长度128时激活值约2GB总共3.3GB左右。如果显存是8GB还有余量如果是4GB就得用fp16或者减小batch。5.3 推理延迟高定位瓶颈在三处推理慢先定位是预处理慢、模型前向慢、还是后处理慢。加时间戳打日志三段分别计时。我遇到过的案例模型前向只要5ms但tokenization花了50ms整体延迟被预处理主导。解决办法是换更快的tokenizer或者预tokenize。如果是模型前向慢考虑量化、算子融合、换推理引擎。ONNX Runtime和TensorRT通常比原生PyTorch快但转换过程有坑需要仔细验证输出一致性。5.4 常见问题速查表问题可能原因解决方向多worker下显存翻倍模型在模块顶层加载移到startup事件推理结果和训练不一致预处理不一致统一tokenizer和padding策略服务吞吐上不去全局锁串行化改队列单worker或并发框架换机器结果不同环境不一致锁CUDA和包版本数据加载成瓶颈num_workers不合适实测调整配合预取避坑技巧每次改完配置跑实验前先用一个极小数据集比如100条跑通全流程确认没有shape错误、没有路径错误再上全量数据。这个习惯能省掉大量等待时间。6. 工程化扩展从能跑到能维护6.1 测试AI项目也需要单元测试很多人觉得AI项目没法写测试因为结果是随机的。但数据管道、配置解析、预处理逻辑都是确定性的必须写测试。我习惯对每个清洗函数写输入输出测试对Dataset写shape和类型测试。模型前向也可以测固定随机种子后检查输出shape和数值范围。def test_clean_pipeline(): df pd.DataFrame({age: [25, None, 200], income: [5000, 6000, 7000]}) result clean_pipeline(df) assert result[age].isna().sum() 0 assert result[age].max() 1006.2 监控上线只是开始模型上线后要监控三类指标服务指标延迟、QPS、错误率、模型指标预测分布、置信度分布、数据指标输入长度分布、缺失率。预测分布突然偏移往往意味着输入数据变了模型需要重新训练。我建议至少每天跑一次离线评估用当天真实流量采样一批数据算准确率等指标和训练时对比。下降超过阈值就告警。6.3 持续训练数据在变模型不能不变真实场景的数据分布会随时间漂移。我的做法是定期用新数据微调而不是从头重训。微调时用较小的学习率冻结底层只调顶层。这样既能适应新数据又不会遗忘旧知识。微调触发条件可以是时间驱动每周一次也可以是指标驱动线上准确率下降超过5%。我倾向于指标驱动因为不是所有场景都需要频繁更新。这个方向往下还有很多可挖的比如模型压缩、多模型集成、A/B测试框架。但把上面这些基础环节走通一遍你对AI工程的理解就已经超过大多数只会调库的人了。我在实际带人时发现能独立把数据到服务这条链路搭起来的人后面学任何新框架都快因为底层逻辑是通的。
返回列表