拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从零手搓AI工程栈:数据管道、模型训练、推理服务与监控运维全链路实践

从零手搓AI工程栈:数据管道、模型训练、推理服务与监控运维全链路实践 1. 为什么我要从零手搓一套AI工程栈第一次看到ai-engineering-from-scratch这个项目名的时候我正被一堆调包侠式的教程搞得有点烦。满屏都是pip install transformers然后三行代码跑个推理看着很爽但真到了线上环境模型加载慢、显存炸、并发上不去、日志一团糟这些问题一个都躲不掉。所以当我看到这个标题第一反应就是终于有人愿意把AI工程里那些脏活累活摊开来讲了。这个项目本质上是一套从零构建AI工程能力的学习路径与代码实践集合它不依赖某个特定框架的黑盒封装而是带着你一步步把数据管道、模型训练、推理服务、监控运维这些环节用最朴素的方式搭起来。它解决的问题很明确让你真正理解一个AI系统从数据到上线的完整链路而不是只会调API。适合谁看我觉得有三类人最该认真读一是刚入行做算法但没碰过工程部署的二是后端转AI方向、想补齐模型侧知识的三是已经在大厂做AI平台、但想回头夯实底层原理的。我自己的背景是做了六年后端两年前开始带一个小团队做推荐和NLP相关的服务。踩过的坑包括但不限于用Flask裸写推理接口结果QPS一高就雪崩、把模型文件直接塞进Docker镜像导致镜像3个G、日志里只打loss不打输入输出导致线上badcase完全没法复现。所以这个项目里提到的很多点我看的时候是很有共鸣的。接下来我会按照我理解的这个项目的核心脉络把每个环节拆开讲补充一些我实际落地时的参数选择和避坑经验。2. 整体架构设计与技术选型思路2.1 为什么选择从零实现而不是直接上框架这个项目最核心的设计哲学就是去黑盒化。市面上主流的AI工程框架比如TorchServe、Triton、BentoML确实能帮你省掉大量样板代码但代价是你对内部机制的理解是缺失的。举个例子Triton的动态批处理很香但如果你不知道它底层是怎么做请求聚合和超时控制的一旦出现延迟毛刺你根本无从下手。从零实现的好处在于你会被迫面对每一个决策点数据怎么分片、模型怎么序列化、请求怎么排队、GPU显存怎么管理。这些决策在框架里都是默认值但默认值不一定适合你的场景。我试过在一个延迟敏感的场景里用默认的动态批处理参数结果P99延迟直接飙到800ms后来把max_batch_size从32降到8、batch_timeout从10ms降到3ms才压下来。如果我一直用框架默认值可能到现在都不知道问题出在哪。当然从零实现不等于重复造轮子。这个项目的合理做法是核心链路自己写周边工具用成熟的。比如Web框架可以用FastAPI序列化可以用ONNX但请求调度、批处理逻辑、监控埋点这些必须自己掌控。2.2 分层架构的拆解逻辑我理解这个项目把整个AI工程栈分成了四层这个分法很实用层级核心职责关键产出常见坑点数据层采集、清洗、版本管理可复现的数据集数据漂移无感知训练层特征工程、模型训练、调参模型权重配置实验不可复现服务层模型加载、推理、批处理稳定低延迟的API显存泄漏、冷启动慢运维层监控、日志、告警、回滚可观测的运行态指标缺失、回滚困难这个分层的关键在于每层之间的契约要清晰。数据层交给训练层的必须是一个带版本号的、schema固定的数据集训练层交给服务层的必须是一个包含模型结构、权重、预处理逻辑的完整包服务层暴露给运维层的必须是一组标准化的指标。我见过太多项目在这三层之间用口头约定来传递结果就是训练时用的分词器和推理时用的不一致离线指标涨了线上反而跌了。2.3 技术选型的取舍原则项目里涉及的技术选型我总结下来遵循三个原则第一优先选可调试的。比如推理服务用FastAPI而不是gRPC虽然gRPC性能更好但FastAPI的请求响应可以直接用curl测、用浏览器看调试成本低太多。等性能真的成为瓶颈了再换也不迟。第二优先选依赖少的。模型序列化用ONNX而不是PyTorch原生pickle因为ONNX的运行时依赖更轻跨语言支持更好。我试过用pickle序列化的模型换了个PyTorch小版本就加载失败这种坑在生产环境是致命的。第三优先选能观测的。日志、指标、追踪这三样必须从第一天就埋进去。我习惯用Prometheus做指标、用结构化JSON做日志每个请求带上request_id这样从网关到模型推理的整条链路都能串起来。提示技术选型没有绝对的对错关键是你要清楚每个选择的代价是什么。选FastAPI的代价是性能上限选ONNX的代价是算子支持不全这些代价你要提前知道并接受。3. 核心模块的细节拆解与实操要点3.1 数据管道的构建与版本控制数据管道是整个AI工程的地基但也是最容易被忽视的环节。这个项目里我特别认同的一点是数据必须像代码一样做版本控制。具体怎么做不是简单地把数据文件扔进Git LFS而是要做到三件事schema版本化、内容哈希化、切分可复现。schema版本化是指你的数据字段定义要有一个明确的版本号。比如用户特征从{age, gender}扩展到{age, gender, city}这就是一个schema变更必须记录在案。我习惯用一个schema.json文件来管理每次变更都递增版本号训练和推理都读取同一个schema文件。内容哈希化是指每次生成的数据集要计算一个哈希值这个哈希值由数据内容、schema版本、生成脚本的commit hash共同决定。这样当线上模型出问题时你可以精确回溯到当时用的是哪份数据。切分可复现是指训练集、验证集、测试集的划分必须用固定随机种子并且划分逻辑要写进代码而不是手动操作。我踩过的坑是有一次手动划分数据集结果测试集里混进了训练集样本离线指标虚高上线后直接翻车。实操上我推荐用DVC或者自己写一套基于文件哈希的版本管理。核心代码逻辑大概是这样import hashlib import json def compute_dataset_hash(data_path, schema_version, script_commit): hasher hashlib.sha256() with open(data_path, rb) as f: while chunk : f.read(8192): hasher.update(chunk) hasher.update(schema_version.encode()) hasher.update(script_commit.encode()) return hasher.hexdigest()这个哈希值要写进模型包的元数据里推理服务启动时校验不匹配就拒绝加载。3.2 模型训练中的可复现性保障训练环节最大的痛点是实验不可复现。同一个脚本跑两次结果不一样这种情况太常见了。原因通常有三个随机种子没固定、数据加载顺序不确定、GPU算子有非确定性。固定随机种子这件事很多人只设了torch.manual_seed但忘了设numpy.random.seed和Python内置的random.seed更忘了设torch.backends.cudnn.deterministic True。我一般会写一个set_seed函数把所有能设的都设一遍import torch import numpy as np import random import os def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ[PYTHONHASHSEED] str(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False注意benchmark False会牺牲一点性能但换来的是确定性。在实验阶段我建议关掉等确定最终配置后再打开做性能优化。数据加载顺序不确定的问题通常出在DataLoader的shuffleTrue配合多worker上。解决办法是给每个epoch设置固定的generator种子。另外如果你用了IterableDataset那顺序控制会更麻烦建议在实验阶段先用map-style数据集。GPU算子的非确定性主要来自一些原子操作和cuDNN的某些算法。除了上面说的deterministic True还要注意torch.use_deterministic_algorithms(True)但这个开关会导致部分算子报错需要逐个排查。注意完全确定性是有性能代价的通常会有5%到15%的吞吐下降。我的做法是实验阶段全开确定性上线前再评估是否关闭。3.3 推理服务的性能优化关键点推理服务是AI工程里最考验工程能力的部分。这个项目里提到的几个优化点我结合实际经验展开讲。模型加载策略。冷启动慢是推理服务的通病。一个BERT-base模型加载要3到5秒大模型更是几十秒。解决办法有两个一是服务启动时预热用一个dummy请求跑一遍完整链路二是用模型缓存把加载好的模型放在共享内存里多个worker进程共享。我试过用torch.multiprocessing的共享内存方案启动时间从8秒降到1.5秒。动态批处理。这是提升GPU利用率的关键。核心逻辑是请求进来后不立即推理而是放进一个队列等攒够一批或者超时了再一起推理。参数选择上max_batch_size要根据模型大小和显存来定batch_timeout要根据延迟要求来定。我的经验值是延迟要求100ms以内的batch_timeout设5ms延迟要求500ms以内的可以设20ms。显存管理。显存泄漏是推理服务的隐形杀手。常见原因包括中间张量没释放、缓存没清理、批处理队列积压。我习惯在每次推理后手动调用torch.cuda.empty_cache()虽然官方说不需要但实测下来能缓解碎片化问题。另外要监控显存使用率超过85%就要告警。请求排队与限流。当QPS超过服务能力时必须有排队和限流机制。我一般用令牌桶算法做限流队列长度设成max_batch_size的3到5倍超过就返回429。这样能保证服务不雪崩而不是所有请求一起超时。3.4 监控指标的设计与埋点监控是AI工程的眼睛。这个项目里我特别欣赏的一点是它把监控指标分成了四类业务指标、模型指标、系统指标、链路指标。业务指标是给产品看的比如请求量、成功率、平均延迟。模型指标是给算法看的比如预测分布、置信度分布、特征缺失率。系统指标是给运维看的比如CPU、内存、GPU利用率。链路指标是给排查问题用的比如各阶段耗时、队列长度、批处理大小。埋点的时候要注意指标不能太多也不能太少。太多会导致监控系统压力大太少会漏掉关键信息。我的经验是每个请求至少打这几个点请求ID、模型版本、输入长度、推理耗时、批处理大小、是否命中缓存。这些点组合起来基本能覆盖80%的问题排查场景。用Prometheus的话指标命名要规范比如ai_inference_latency_seconds、ai_batch_size、ai_queue_length。标签不要太多model_version和endpoint这两个就够了标签基数太大会导致Prometheus内存爆炸。4. 完整实操流程与关键环节实现4.1 环境准备与依赖管理从零搭建AI工程栈环境准备是第一步。我的建议是用Docker做环境隔离用conda做Python依赖管理。为什么不用pipvenv因为AI相关的包依赖关系复杂conda在处理CUDA、cuDNN这些系统级依赖上更省心。Dockerfile的写法有讲究。基础镜像不要用python:3.9这种要用nvidia/cuda:11.8-cudnn8-runtime-ubuntu22.04这样CUDA环境是现成的。然后装miniconda再用conda装PyTorch。层数要尽量少把apt install和conda install合并到一层减少镜像体积。FROM nvidia/cuda:11.8-cudnn8-runtime-ubuntu22.04 RUN apt-get update apt-get install -y \ wget git curl \ wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh \ bash Miniconda3-latest-Linux-x86_64.sh -b -p /opt/conda \ rm Miniconda3-latest-Linux-x86_64.sh ENV PATH/opt/conda/bin:$PATH RUN conda install -y pytorch2.0.1 torchvision0.15.2 pytorch-cuda11.8 -c pytorch -c nvidia \ pip install fastapi uvicorn onnx onnxruntime prometheus-client WORKDIR /app COPY . /app镜像体积优化上记得清理conda缓存和apt缓存能省下好几个G。我见过一个镜像做到8个G的拉取一次要十分钟完全没法做快速扩缩容。4.2 数据预处理与特征工程落地数据预处理这块核心原则是训练和推理用同一套代码。很多项目训练时用pandas做特征推理时用numpy重写一遍结果两边逻辑不一致离线线上效果对不上。我的做法是把预处理逻辑封装成一个类训练和推理都调用它class FeatureProcessor: def __init__(self, config): self.config config self.scaler None def fit(self, data): self.scaler StandardScaler().fit(data) return self def transform(self, data): return self.scaler.transform(data) def save(self, path): with open(path, wb) as f: pickle.dump(self.scaler, f) classmethod def load(cls, path, config): instance cls(config) with open(path, rb) as f: instance.scaler pickle.load(f) return instance这个类要跟模型一起打包推理服务加载模型时同时加载它。这样就能保证预处理逻辑的一致性。特征工程里还有一个容易忽视的点是特征缺失处理。训练时如果有缺失值你可能做了填充但推理时如果来了一个缺失值填充逻辑必须一致。我习惯在预处理类里显式定义每个特征的缺失填充策略而不是依赖pandas的默认行为。4.3 模型训练与超参调优实操训练脚本的编写我建议遵循配置驱动的原则。所有超参、路径、模型结构都写在一个YAML文件里脚本只负责读取配置并执行。这样实验管理会清晰很多。model: name: bert-base num_labels: 10 dropout: 0.1 training: batch_size: 32 learning_rate: 2e-5 epochs: 5 warmup_ratio: 0.1 weight_decay: 0.01 data: train_path: /data/train.jsonl valid_path: /data/valid.jsonl max_length: 128超参调优上我的经验是先粗后细。先用网格搜索确定学习率和batch size的大致范围再用贝叶斯优化在小区间里精调。学习率是最重要的超参一般从1e-5到5e-5之间试BERT类模型2e-5是个不错的起点。batch size受显存限制能大就大但要注意学习率要相应调整通常batch size翻倍学习率也翻倍。训练过程中要监控的指标不只是loss还有梯度范数和学习率变化。梯度范数突然变大通常意味着要梯度裁剪学习率如果一直很小说明warmup没做好。我习惯用TensorBoard记录这些训练完回头看一眼曲线很多问题一目了然。4.4 推理服务部署与压测推理服务用FastAPI写核心接口就一个/predict。但要注意几个细节from fastapi import FastAPI, HTTPException from pydantic import BaseModel import torch import time app FastAPI() model None processor None class PredictRequest(BaseModel): text: str request_id: str None app.on_event(startup) async def load_model(): global model, processor model torch.load(/models/model.pt, map_locationcuda) model.eval() processor FeatureProcessor.load(/models/processor.pkl, config) # 预热 dummy torch.zeros(1, 128).long().cuda() with torch.no_grad(): model(dummy) app.post(/predict) async def predict(req: PredictRequest): start time.time() try: features processor.transform(req.text) with torch.no_grad(): output model(features) latency time.time() - start return {result: output.argmax().item(), latency: latency} except Exception as e: raise HTTPException(status_code500, detailstr(e))压测用locust或者wrk。我习惯用locust因为可以模拟复杂的请求分布。压测时要关注三个指标QPS、P99延迟、错误率。QPS上不去通常是批处理没做好P99延迟高通常是队列积压错误率高通常是显存不够或者超时。压测的梯度要设计好从低并发开始逐步加压找到性能拐点。我一般会跑5个梯度10并发、50并发、100并发、200并发、500并发每个梯度跑5分钟记录稳定后的指标。提示压测环境要和线上环境尽量一致包括GPU型号、CUDA版本、网络带宽。我见过在T4上压测通过、上A100反而出问题的因为A100的算力太强导致批处理逻辑的瓶颈暴露了。5. 常见问题与排查技巧实录5.1 模型加载失败与版本兼容问题模型加载失败是最常见的问题原因通常有三类序列化格式不兼容、算子不支持、路径错误。序列化格式不兼容的典型表现是RuntimeError: version mismatch。解决办法是固定PyTorch版本并且在保存模型时用torch.save(model.state_dict())而不是torch.save(model)前者只保存权重后者保存整个对象后者对版本更敏感。算子不支持的典型表现是RuntimeError: Could not run xxx with arguments from the CUDA backend。这通常发生在用ONNX导出时某些自定义算子ONNX不支持。解决办法是用ONNX的opset_version调高一点或者把自定义算子拆成标准算子组合。路径错误的典型表现是FileNotFoundError。这个最简单但也最容易犯尤其是在Docker里相对路径和绝对路径要搞清楚。我习惯在代码里用os.path.abspath把路径转成绝对路径避免歧义。5.2 推理延迟毛刺的排查思路延迟毛刺是最难排查的问题之一。我的排查思路是先分层再定位。分层是指把请求链路拆成网关、排队、预处理、推理、后处理五段每段打点。毛刺出现在哪一段问题就在哪一段。如果毛刺在排队段说明请求量超过了处理能力需要扩容或者优化批处理。如果毛刺在预处理段说明预处理逻辑有性能问题比如正则表达式回溯、大对象拷贝。如果毛刺在推理段说明GPU有争抢或者显存碎片化。如果毛刺在后处理段说明结果序列化有问题。我遇到过一个典型案例P99延迟每隔几分钟就飙一次。分层打点后发现毛刺在推理段进一步排查发现是Python的GC导致的。解决办法是调整GC阈值或者用gc.freeze()把模型相关的对象冻结。5.3 显存泄漏的定位与解决显存泄漏的表现是服务跑一段时间后显存占用越来越高最终OOM。定位方法是每隔一段时间打印一次显存使用量看趋势。import torch def print_gpu_memory(): allocated torch.cuda.memory_allocated() / 1024**3 reserved torch.cuda.memory_reserved() / 1024**3 print(fAllocated: {allocated:.2f}GB, Reserved: {reserved:.2f}GB)如果allocated持续增长说明有张量没释放。常见原因是把张量存进了全局变量或者缓存里。如果allocated稳定但reserved增长说明是显存碎片化需要定期empty_cache。我踩过的一个坑是在异常处理里忘了释放中间张量导致每次异常都泄漏一点。解决办法是用try...finally确保释放或者用with torch.no_grad()包裹推理逻辑。5.4 常见问题速查表问题现象可能原因排查方法解决方案模型加载失败版本不兼容检查PyTorch版本固定版本用state_dict推理延迟毛刺GC或显存碎片分层打点调GC阈值定期empty_cache显存持续增长张量未释放打印显存趋势try...finally释放QPS上不去批处理未生效检查batch_size调大max_batch_size离线线上不一致预处理逻辑不同对比两边代码封装统一预处理类服务启动慢模型加载耗时计时加载过程预热共享内存5.5 独家避坑经验分享最后分享几个我在实际项目中总结的避坑经验这些在常规文档里基本看不到。第一日志要打输入输出的摘要不要打全量。全量日志会拖慢服务而且可能泄露敏感信息。我习惯打输入的长度、哈希值、以及输出的top3结果。这样既能排查问题又不会影响性能。第二模型版本要跟服务版本解耦。模型更新不应该导致服务重启。我的做法是模型文件放在共享存储上服务定期检查版本号有新版本就热加载。热加载时要保证旧请求用旧模型、新请求用新模型避免请求处理到一半模型被换掉。第三压测要在生产同构环境做。我见过太多在开发机上压测通过、上线就崩的案例。GPU型号、内存大小、网络延迟这些都会影响性能压测环境必须尽量贴近生产。第四回滚方案要提前准备好。模型上线出问题是常态关键是要能快速回滚。我的做法是保留最近三个版本的模型文件回滚时只需要改一个配置项然后重启服务整个过程控制在1分钟内。第五监控告警要分级。不是所有异常都要打电话。我一般分三级P0是服务不可用立即告警P1是性能下降工作时间处理P2是偶发错误每天汇总看一次。这样既能及时响应又不会造成告警疲劳。这套从零搭建的AI工程栈我带着团队落地了大概三个月中间踩的坑比预想的多但收获也大。最大的体会是AI工程的核心不是模型有多先进而是整个链路的稳定性和可观测性。模型可以慢慢调但链路不稳再好的模型也发挥不出价值。后续我打算在这套基础上加上A/B测试和自动扩缩容等有新的经验再分享。
返回列表