ARTICLE DETAIL

资讯详情

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

从零搭建AI工程能力:环境隔离、数据管道与模型部署的完整实践指南

从零搭建AI工程能力:环境隔离、数据管道与模型部署的完整实践指南 1. 从零搭建AI工程能力为什么大多数人卡在第一步就放弃了聊到从零开始做AI工程这个话题我脑子里第一个蹦出来的画面是两年前带过的一个后端同事。他Python写得比我溜Docker也用得飞起结果在装CUDA驱动那一步折腾了整整三天最后跟我说算了我还是回去写CRUD吧。这件事让我意识到一个问题AI工程的门槛从来不在算法本身而在于从普通开发者到AI工程师之间那条看不见的鸿沟——环境、工具链、思维模式每一样都得重新学。ai-engineering-from-scratch这个标题本身就说明了一切它不是教你调个API就完事而是要求你从最底层开始把整条工程链路自己搭一遍。这跟用LangChain写个Demo完全是两码事。前者是理解每一层在干什么后者只是会按按钮。这篇文章适合三类人看第一类是有一定编程基础、想转AI工程但不知道从哪下手的开发者第二类是已经在做AI相关项目、但总觉得知其然不知其所以然的工程师第三类是想搞清楚AI系统到底怎么跑起来的的技术管理者。我会把从环境搭建到模型部署的完整链路拆开讲重点放在那些文档里不会写、但实际做的时候一定会踩的坑上。先说一个反直觉的结论从零做AI工程最难的既不是数学也不是框架而是环境一致性和数据管道这两件看起来最无聊的事。我见过太多人模型代码写得漂亮结果换台机器就跑不起来也见过特征工程做得精细但训练和推理的数据处理逻辑不一致上线后效果直接腰斩。这些问题的根源都在于没有从工程角度去理解AI系统而是把它当成了一个跑通就行的脚本。所以这篇内容的核心思路是把AI系统当成一个软件工程项目来做而不是当成一个实验。实验可以只关心结果工程必须关心可复现、可维护、可扩展。下面我会按照实际搭建的顺序从环境隔离开始一路讲到模型服务的部署和监控每一步都告诉你为什么要这样做以及不这样做会出什么问题。2. 环境隔离这件事比你想的值得花时间2.1 为什么conda和venv的选择会影响你后面三个月的效率很多人觉得环境管理就是装个虚拟环境而已用什么工具无所谓。但实际做AI项目的时候这个选择会直接影响你后面几个月的开发体验。我先说结论做AI工程优先用conda而不是纯venv但不要用conda装所有包。原因在于AI技术栈里有大量非Python的二进制依赖——CUDA运行时、cuDNN、MKL数学库、各种编译好的wheel。这些依赖之间有严格的版本对应关系比如PyTorch 2.1要求CUDA 11.8或12.1而CUDA 11.8又要求驱动版本不低于某个值。用pipvenv管理的时候这些关系全靠你自己记用conda的话它能在安装时帮你做依赖求解虽然慢一点但能避免很多装完了跑不起来的情况。但conda也不是万能的。conda的包更新速度经常落后于pip特别是一些快速迭代的AI库。所以我的实际做法是# 用conda创建基础环境只装Python和CUDA相关 conda create -n ai-eng python3.11 conda activate ai-eng conda install pytorch torchvision torchaudio pytorch-cuda11.8 -c pytorch -c nvidia # 其余纯Python包用pip装保证版本最新 pip install transformers datasets accelerate evaluate这样分工的好处是底层二进制依赖由conda保证兼容性上层纯Python包由pip保证时效性。我试过全用conda结果transformers版本比pip落后了两个大版本有些新模型的API根本用不了也试过全用pip结果CUDA版本冲突排查了一下午。注意如果你用的是Apple Silicon的MacCUDA那套完全不适用直接用MPS后端就行。但要注意MPS对某些算子的支持还不完整遇到报错先查一下是不是算子不支持别急着怀疑自己的代码。2.2 依赖锁定为什么requirements.txt不够用环境搭好之后下一步是锁定依赖。大部分人习惯用pip freeze requirements.txt但这个做法在AI项目里有个致命问题它只记录了包名和版本没有记录安装来源和平台信息。举个例子同样一行torch2.1.0在Linux上装的是CUDA版本在Mac上装的是CPU版本在Windows上又是另一个版本。如果你把Linux上freeze出来的requirements.txt拿到Mac上用轻则装错版本重则直接报错。更麻烦的是有些包是从特定index安装的比如PyTorch的官方indexfreeze出来的文件里不会体现这一点。我的做法是用pip-compile配合requirements.in来管理# requirements.in 里只写直接依赖 torch transformers datasets # 生成锁定的requirements.txt pip-compile requirements.in --output-file requirements.txt这样生成的文件会包含完整的依赖树和哈希校验换机器的时候能保证装出来一模一样的环境。如果团队里有人用不同操作系统可以按平台生成多份锁定文件比如requirements-linux.txt和requirements-mac.txt。还有一个容易被忽略的点Python版本本身也要锁定。我遇到过好几次因为本地是3.11、服务器是3.9导致的兼容性问题特别是一些用了新语法糖的代码。建议在项目根目录放一个.python-version文件配合pyenv使用这样每个人进项目目录都会自动切换到正确的Python版本。2.3 容器化什么时候该上Docker什么时候不该说到环境一致性很多人第一反应就是上Docker。但我的经验是开发阶段别急着容器化部署阶段必须容器化。开发阶段用conda环境就够了因为你需要频繁改代码、装新包、调试。Docker的层缓存机制在这种场景下反而拖慢效率——每次改一行代码都要重新build或者要搞复杂的volume挂载。而且GPU在Docker里的透传配置nvidia-container-toolkit本身就有一定门槛新手很容易在这里卡住。但到了部署阶段Docker几乎是唯一能保证开发环境能跑、生产环境也能跑的方案。这时候你需要一个精心设计的DockerfileFROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 # 系统依赖 RUN apt-get update apt-get install -y \ python3.11 python3-pip git \ rm -rf /var/lib/apt/lists/* # 先装依赖利用层缓存 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再拷代码这样改代码不会触发依赖重装 COPY . /app WORKDIR /app CMD [python, serve.py]这个Dockerfile的关键在于依赖和代码分层。requirements.txt没变的时候Docker会复用缓存层build速度很快只有代码变了才需要重新拷贝。我见过有人把COPY . .放在pip install前面结果每次改代码都要重装一遍依赖build一次十几分钟效率极低。提示基础镜像选runtime而不是devel体积能小好几个G。除非你需要在容器里编译CUDA扩展否则runtime版本完全够用。3. 数据管道AI工程里最不性感但最要命的部分3.1 训练和推理的数据处理必须共用同一套代码这是我踩过的最大的坑没有之一。当时做一个文本分类项目训练的时候用pandas做了一堆清洗和特征工程效果很好。上线的时候为了性能优化用numpy重写了一遍推理的数据处理逻辑。结果上线后准确率掉了十几个点排查了两天才发现是两边的分词逻辑有细微差异——训练时用了lowercase推理时忘了加。这个教训让我明白一个原则训练和推理的数据处理逻辑必须是同一份代码。不是逻辑相同而是物理上就是同一个函数、同一个文件。具体做法是建一个独立的preprocessing模块# preprocessing.py class TextPreprocessor: def __init__(self, config): self.lowercase config.get(lowercase, True) self.max_length config.get(max_length, 512) self.tokenizer AutoTokenizer.from_pretrained(config[tokenizer_name]) def __call__(self, texts): if self.lowercase: texts [t.lower() for t in texts] return self.tokenizer( texts, truncationTrue, max_lengthself.max_length, paddingmax_length, return_tensorspt )训练脚本和推理服务都import这个类配置从同一个config文件读。这样只要config不变两边的处理就绝对一致。如果哪天需要改逻辑改一处两边同时生效不会出现不一致的情况。3.2 数据集版本管理别再用文件名区分了做实验的时候很多人习惯用data_v1.csv、data_v2_fixed.csv、data_v2_fixed_real.csv这种方式管理数据集。短期看没问题但项目稍微大一点就会乱套——你根本记不住哪个文件对应哪次实验更别说复现了。我的建议是从项目第一天就用DVCData Version Control管理数据。它跟git配合使用git管代码DVC管数据每次实验都能精确对应到代码版本和数据版本。# 初始化 dvc init dvc add data/train.csv # 这会生成一个train.csv.dvc文件记录数据的哈希值 # 把.dvc文件提交到git实际数据存在本地或远程存储 # 切换实验时 git checkout experiment-branch dvc checkout # 数据自动切换到对应版本DVC的学习成本大概半天但它带来的可复现性提升是巨大的。我现在的习惯是每做一个新实验先git checkout -b exp-xxx改代码、改数据、跑实验所有东西都在分支里。回头要复现哪个实验切到对应分支dvc checkout一下环境完全一致。如果觉得DVC太重至少也要做到每次实验的数据集生成过程必须脚本化。不要手动改CSV所有清洗、过滤、划分的操作都写成脚本脚本进git。这样即使不用DVC至少能通过重跑脚本得到相同的数据。3.3 数据加载的性能陷阱为什么你的GPU利用率只有30%训练的时候看到GPU利用率上不去很多人第一反应是模型太小或者batch size不够。但实际排查下来十有八九是数据加载成了瓶颈。PyTorch的DataLoader有几个关键参数直接影响性能dataloader DataLoader( dataset, batch_size32, num_workers4, # 关键参数1 pin_memoryTrue, # 关键参数2 prefetch_factor2, # 关键参数3 persistent_workersTrue # 关键参数4 )num_workers设成CPU核心数的一半到全部太少会导致数据准备跟不上GPU太多会争抢资源。pin_memoryTrue让数据放在锁页内存里从CPU传到GPU会快很多。prefetch_factor控制每个worker预取多少batch默认是2数据加载慢的时候可以调大。persistent_workersTrue避免每个epoch重新创建worker进程对训练时间长的任务很有用。但光调参数还不够数据格式本身也很关键。如果每次__getitem__都要读文件、做复杂计算那worker再多也救不了。我的做法是离线预处理成内存映射格式。比如把处理好的数据存成numpy的.npy文件或者HuggingFace的datasets格式加载的时候直接内存映射速度比读CSV快几十倍。# 离线预处理 from datasets import Dataset dataset Dataset.from_pandas(df) dataset dataset.map(preprocess_fn, batchedTrue, num_proc8) dataset.save_to_disk(data/processed) # 训练时直接加载 dataset load_from_disk(data/processed)num_proc8让预处理并行化几百万条数据几分钟就能处理完。存到磁盘后训练时加载几乎是瞬时的。这个习惯能帮你省下大量等待时间。4. 模型训练之外那些真正决定项目成败的工程细节4.1 实验追踪别再用Excel记结果了我见过太多人用Excel或者记事本记录实验结果——改了哪个参数、跑了多少轮、最终指标是多少。短期项目还行一旦实验数量超过二三十个就彻底乱了。更致命的是你根本记不住每个结果对应的代码版本和数据版本。从第一个实验开始就用实验追踪工具这是我最想强调的一条经验。WBWeights Biases和MLflow是两个主流选择我个人更推荐WB因为它的可视化做得好而且集成特别简单import wandb wandb.init( projectmy-ai-project, config{ learning_rate: 2e-5, batch_size: 32, epochs: 10, model: bert-base-chinese } ) for epoch in range(epochs): train_loss train_one_epoch() val_metrics evaluate() wandb.log({ train_loss: train_loss, val_accuracy: val_metrics[accuracy], val_f1: val_metrics[f1], epoch: epoch })这几行代码带来的好处是每次实验自动记录所有超参数、指标曲线、系统资源占用还能把git commit hash自动关联上。回头要复现最佳结果直接看WB面板点进去就能看到当时用的所有配置。如果因为网络或数据安全原因不能用WBMLflow是很好的替代品可以完全本地部署mlflow ui --backend-store-uri ./mlruns然后代码里用mlflow.log_params()和mlflow.log_metrics()记录效果类似。4.2 检查点管理模型存哪里、存几个、怎么命名模型检查点管理看起来是小事但实际项目里经常出问题。我见过有人训练了三天结果因为磁盘满了没存上检查点只能重跑。也见过检查点文件命名混乱最后分不清哪个是哪个。我的检查点策略是这样的# 目录结构 checkpoints/ ├── best/ # 最佳模型只保留一个 │ ├── model.safetensors │ ├── config.json │ └── tokenizer/ ├── last/ # 最新检查点用于恢复训练 │ └── ... └── epoch_5/ # 定期检查点保留最近3个 └── ...关键点有几个第一最佳模型和最新模型分开存。最佳模型用于最终部署最新模型用于恢复训练。第二定期检查点只保留最近几个避免磁盘被占满。第三用safetensors格式而不是pickle加载更快也更安全。# 保存最佳模型 if val_f1 best_f1: best_f1 val_f1 model.save_pretrained(checkpoints/best, safe_serializationTrue) tokenizer.save_pretrained(checkpoints/best) # 定期保存只保留最近3个 checkpoint_dir fcheckpoints/epoch_{epoch} model.save_pretrained(checkpoint_dir, safe_serializationTrue) cleanup_old_checkpoints(checkpoints, keep3)还有一个容易忽略的点检查点里要包含训练状态。如果只存模型权重恢复训练的时候优化器状态、学习率调度器状态、当前epoch数全丢了恢复后效果会受影响。用torch.save存一个完整的state dicttorch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_f1: best_f1 }, checkpoints/last/training_state.pt)4.3 配置管理别把超参数硬编码在代码里新手最容易犯的错误就是把超参数直接写在训练脚本里改一个参数要翻半天代码。正确的做法是所有配置外置用YAML或者Hydra管理。# config/train.yaml model: name: bert-base-chinese num_labels: 10 dropout: 0.1 training: learning_rate: 2e-5 batch_size: 32 epochs: 10 warmup_ratio: 0.1 weight_decay: 0.01 data: train_path: data/train.csv val_path: data/val.csv max_length: 128然后用Hydra加载它支持命令行覆盖做实验特别方便import hydra from omegaconf import DictConfig hydra.main(config_pathconfig, config_nametrain) def main(cfg: DictConfig): model build_model(cfg.model) train(model, cfg.training, cfg.data) if __name__ __main__: main()跑实验的时候直接命令行改参数python train.py training.learning_rate1e-5 training.batch_size16这样不用改代码就能做超参数搜索而且每次实验的完整配置都会被Hydra自动保存到输出目录复现的时候一目了然。提示Hydra默认会改变工作目录如果代码里有相对路径可能会出问题。可以在hydra.main里加hydra.job.chdirFalse来保持原目录。5. 从训练到上线模型服务化的关键决策5.1 推理框架选型PyTorch原生、ONNX还是TensorRT模型训练完之后下一个问题是用什么框架做推理很多人直接就用PyTorch加载模型跑这在开发阶段没问题但生产环境需要考虑延迟和吞吐。先给一个简单的对比方案延迟吞吐部署复杂度适用场景PyTorch原生基准基准低开发调试、低并发ONNX Runtime0.5-0.8x1.5-2x中通用生产环境TensorRT0.2-0.5x3-5x高高并发、低延迟要求vLLM-10x中大语言模型推理我的建议是先用PyTorch原生跑通整个服务链路然后再考虑优化。不要一上来就搞TensorRT那个转换和调试成本很高而且不是所有模型都支持。如果确实需要优化ONNX Runtime是性价比最高的选择。转换过程也不复杂import torch.onnx # 导出ONNX dummy_input tokenizer(测试文本, return_tensorspt) torch.onnx.export( model, (dummy_input[input_ids], dummy_input[attention_mask]), model.onnx, input_names[input_ids, attention_mask], output_names[logits], dynamic_axes{ input_ids: {0: batch, 1: sequence}, attention_mask: {0: batch, 1: sequence}, logits: {0: batch} }, opset_version14 )关键在dynamic_axes它让模型支持变长输入。如果不设这个导出的模型只能处理固定长度的输入实际用的时候会报错。5.2 服务框架FastAPI够用但要注意这些细节模型服务化最常用的框架是FastAPI轻量、异步、自带文档。但直接写个app.post(/predict)就完事的话生产环境会出各种问题。from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI() # 全局加载模型避免每次请求都加载 model None tokenizer None app.on_event(startup) async def load_model(): global model, tokenizer model AutoModelForSequenceClassification.from_pretrained(checkpoints/best) model.eval() tokenizer AutoTokenizer.from_pretrained(checkpoints/best) if torch.cuda.is_available(): model model.cuda() class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str confidence: float app.post(/predict, response_modelPredictResponse) async def predict(request: PredictRequest): inputs tokenizer(request.text, return_tensorspt, truncationTrue, max_length128) if torch.cuda.is_available(): inputs {k: v.cuda() for k, v in inputs.items()} with torch.no_grad(): outputs model(**inputs) probs torch.softmax(outputs.logits, dim-1) confidence, pred torch.max(probs, dim-1) return PredictResponse( labelid2label[pred.item()], confidenceconfidence.item() )几个关键点第一模型在startup时加载一次不要每次请求都加载。第二推理时用torch.no_grad()不然会构建计算图显存爆炸。第三用Pydantic定义请求和响应格式自动做参数校验和文档生成。还有一个容易忽略的批处理。如果并发量高单条推理效率很低。可以做一个简单的批处理队列攒几条请求一起推理import asyncio from collections import deque batch_queue deque() batch_lock asyncio.Lock() async def batch_predict(texts, max_batch32, timeout0.01): async with batch_lock: batch_queue.extend(texts) await asyncio.sleep(timeout) # 等一小会儿攒更多请求 batch list(batch_queue)[:max_batch] batch_queue.clear() # 批量推理 inputs tokenizer(batch, return_tensorspt, paddingTrue, truncationTrue) with torch.no_grad(): outputs model(**inputs) return outputs.logits这个模式能把吞吐提升好几倍但要注意timeout不能设太大否则单条请求的延迟会很高。0.01秒是个比较平衡的值。5.3 监控上线只是开始不是结束模型上线之后最怕的就是静默失败——服务没报错但预测结果已经不对了。这种情况在数据分布发生变化时特别常见。必须监控的指标分三类第一类是服务指标QPS、延迟P50/P99、错误率、GPU利用率。这些用PrometheusGrafana就能搞定。第二类是模型指标预测分布的均值、方差、各类别占比。如果突然发现某个类别的预测占比从10%涨到50%大概率是出问题了。第三类是数据指标输入文本的长度分布、词汇分布、特殊字符比例。这些能帮你提前发现数据漂移。from prometheus_client import Histogram, Counter prediction_confidence Histogram( prediction_confidence, Model prediction confidence, buckets[0.1, 0.3, 0.5, 0.7, 0.9, 0.95, 0.99] ) prediction_counter Counter( prediction_total, Total predictions, [label] ) app.post(/predict) async def predict(request: PredictRequest): result model_inference(request.text) prediction_confidence.observe(result.confidence) prediction_counter.labels(labelresult.label).inc() return result这些指标暴露在/metrics端点Prometheus定时抓取Grafana做可视化。设置告警规则比如置信度低于0.5的请求占比超过30%就触发告警这样能在用户投诉之前发现问题。注意监控指标本身也有开销不要在每个请求里做太重的计算。像分布统计这种可以采样或者异步做。6. 那些让我半夜爬起来修bug的坑6.1 随机种子你以为设了其实没完全设做实验的时候发现结果不可复现排查了半天代码逻辑最后发现是随机种子的问题。设随机种子不是一行torch.manual_seed(42)就完事的。完整的随机种子设置需要覆盖所有随机源import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 多GPU torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False # 如果用了HuggingFace # transformers.set_seed(seed)cudnn.deterministicTrue会让CUDA的卷积算法变成确定性的但会牺牲一些性能。benchmarkFalse关掉自动调优也是为了保证确定性。这两个设置在做实验阶段建议打开上线后可以关掉换性能。但即使这样有些操作仍然是不确定的比如多GPU训练的梯度聚合顺序、某些CUDA算子的原子操作。如果要求完全可复现可能需要单GPU训练或者接受结果在很小范围内波动这个现实。6.2 显存泄漏为什么训练越跑越慢最后OOM训练的时候显存逐渐增长最后OOM这是非常常见的问题。原因通常有几个第一个是loss没有detach。如果你在训练循环里累积loss用于统计一定要用loss.item()或者loss.detach()不然计算图会一直保留显存越占越多。# 错误做法 total_loss loss # 计算图被保留 # 正确做法 total_loss loss.item() # 只取数值 # 或者 total_loss loss.detach() # 断开计算图第二个是验证阶段没关梯度。验证的时候一定要用torch.no_grad()不然会构建计算图显存占用和训练差不多。model.eval() with torch.no_grad(): for batch in val_loader: outputs model(**batch) # ...第三个是缓存没有清理。如果动态创建了很多tensorPython的垃圾回收可能跟不上。可以手动调torch.cuda.empty_cache()但注意这个操作本身有开销不要频繁调用。排查显存泄漏可以用torch.cuda.memory_summary()看详细的内存分配情况或者用nvidia-smi监控显存变化趋势。如果发现显存呈阶梯状上升基本就是计算图没释放。6.3 多进程DataLoader的坑Windows和Linux行为不一样在Linux上跑得好好的DataLoader到了Windows上直接报错或者卡死。这是因为Windows用spawn方式创建子进程而Linux用fork。spawn方式下子进程会重新import主模块如果主模块里有全局代码比如加载模型就会重复执行。解决办法是把主逻辑放在if __name__ __main__:里面if __name__ __main__: dataset load_dataset() dataloader DataLoader(dataset, num_workers4) train(dataloader)这个在Linux上不是必须的但在Windows上是强制的。如果你在Windows上开发、Linux上部署最好从一开始就养成这个习惯避免后面迁移的时候出问题。还有一个坑是worker里的随机性。每个worker有自己的随机种子如果不设置数据增强的结果每次都不一样。可以在worker_init_fn里设置def worker_init_fn(worker_id): np.random.seed(42 worker_id) random.seed(42 worker_id) dataloader DataLoader( dataset, num_workers4, worker_init_fnworker_init_fn )这样每个worker的随机行为是确定的整个训练过程可复现。7. 从能跑到好用中间差的是工程思维回头看这两年做AI项目的经历最大的感悟是AI工程的难点从来不在AI本身而在工程。模型架构、损失函数、优化器这些网上教程一抓一大把但怎么让训练可复现、怎么让服务稳定、怎么让团队协作顺畅这些才是真正拉开差距的地方。ai-engineering-from-scratch这个标题里的from scratch我理解不只是从零写代码更是从零建立工程规范。环境隔离、依赖锁定、数据版本管理、实验追踪、配置外置、监控告警——这些东西做起来不酷甚至有点枯燥但它们决定了你的项目是跑通一次就完还是能持续迭代。我现在的习惯是每开始一个新项目先花半天把项目骨架搭好——目录结构、配置文件、日志、实验追踪、Dockerfile全部到位之后再写第一行模型代码。这半天看起来是浪费但后面能省下无数个排查环境问题、复现实验、定位线上故障的夜晚。最后分享一个我一直在用的项目模板结构你可以直接拿去改project/ ├── config/ # 配置文件 │ ├── train.yaml │ └── serve.yaml ├── data/ # 数据目录DVC管理 ├── src/ │ ├── data/ # 数据处理 │ │ └── preprocessing.py │ ├── models/ # 模型定义 │ ├── training/ # 训练逻辑 │ ├── serving/ # 推理服务 │ └── utils/ # 工具函数 ├── checkpoints/ # 模型检查点 ├── tests/ # 测试 ├── Dockerfile ├── requirements.in ├── requirements.txt └── README.md这个结构的好处是职责清晰src/data里的代码训练和推理共用config里的配置外置可覆盖checkpoints和data不进git用DVC或gitignore管理。团队里任何人拿到这个项目都能快速理解代码在哪、配置在哪、数据在哪。踩过足够多的坑之后你会发现AI工程的核心竞争力其实就是把这些无聊的事情做到位。模型可以换、框架可以升级但一套好的工程规范能让你在换任何技术栈的时候都游刃有余。
返回列表