1. 从零搭建AI工程体系,为什么我劝你别急着调包
"ai-engineering-from-scratch"这个标题,第一次看到的时候我愣了一下。不是因为陌生,恰恰相反,是因为太熟悉了——过去两年里,我见过太多人问"我想学AI工程,该从哪开始",然后得到的回答清一色是"先跑通一个Transformer""装个PyTorch,找个开源项目clone下来改改"。结果呢?模型能跑,loss能降,但一旦要上线、要压测、要排查线上推理延迟抖动,整个人就懵了。
这就是"from scratch"的价值所在。它不是让你从零手写一个CUDA kernel,也不是让你复现一遍Attention is All You Need的每一行公式,而是让你把AI工程这条链路上每一个环节的"为什么"搞清楚——数据怎么进、模型怎么出、中间那层服务怎么扛住并发、监控看什么指标、出问题从哪查。这套东西,调包调不出来,看论文也看不出来,只能自己动手搭一遍。
我写这篇东西的出发点很简单:过去几年我参与过几个从零到一的AI系统搭建,也接手过不少"别人搭了一半跑不起来"的烂摊子。踩过的坑、绕过的弯路、半夜被报警叫起来查推理超时的经历,攒了不少。这些经验在官方文档里找不到,在教程视频里也不会讲,但它们恰恰是决定一个AI工程能不能真正落地的关键。
这篇文章适合谁看?如果你已经会写Python、大致知道神经网络是什么、跑过几个demo,但一到"要把模型变成服务"就不知道从哪下手,那这篇就是写给你的。如果你已经有一定工程经验,但想系统性地梳理一遍AI工程的完整链路,查漏补缺,也能从中找到一些有用的细节。我不假设你懂Kubernetes,也不假设你懂CUDA,但我会告诉你什么时候需要它们、什么时候不需要。
整篇内容我会按照一条完整的工程链路来展开:从环境搭建和依赖管理开始,到数据处理管线的设计,再到模型训练与实验管理,然后是推理服务的构建与优化,最后是监控、日志和故障排查。每一块我都会讲清楚"为什么这么做"以及"不这么做会怎样",并且给出可以直接抄的配置和代码。中间会穿插大量我在实际项目中踩过的坑和总结出来的技巧,这些东西你花钱买课程都不一定听得到。
2. 环境搭建与依赖管理:别让"在我机器上能跑"成为你的口头禅
2.1 为什么虚拟环境不是可选项而是必选项
我见过太多人在这件事上翻车。一个典型的场景:你本地跑得好好的训练脚本,推到服务器上就报错,一查是numpy版本不一样。或者更隐蔽的——你装了某个包,它悄悄升级了一个底层依赖,导致另一个项目的代码行为变了,loss曲线莫名其妙地抖。
Python的依赖管理在AI领域尤其麻烦,因为AI生态的包依赖关系极其复杂。PyTorch依赖特定版本的CUDA runtime,CUDA runtime又依赖特定版本的显卡驱动,transformers库依赖特定版本的tokenizers,tokenizers又依赖Rust编译的工具链。这些依赖链条中任何一个环节版本对不上,轻则warning满天飞,重则直接segfault。
我的做法是:每个项目一个独立的虚拟环境,而且用conda而不是venv。原因很简单——AI项目经常需要非Python的依赖,比如特定版本的CUDA toolkit、cuDNN、甚至gcc。venv只管Python包,conda能管整个工具链。虽然conda有时候慢得让人想砸键盘,但在依赖冲突排查上省下来的时间,远比等它solve环境的时间多。
# 创建环境时显式指定Python版本,别用默认的 conda create -n ai-eng python=3.10 -y conda activate ai-eng # 安装PyTorch时,去官网复制对应的命令,别自己猜 # 比如CUDA 11.8对应的命令: pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118注意:永远不要用
pip install torch这种不指定版本的命令。PyTorch的默认pip源可能给你装一个CPU-only的版本,或者一个和你CUDA版本不匹配的版本。去PyTorch官网的"Get Started"页面,根据你的CUDA版本选择对应的安装命令,这是最稳妥的做法。
2.2 依赖锁定:把"能跑"变成"永远能跑"
虚拟环境解决了"隔离"的问题,但没有解决"可复现"的问题。你今天pip install装了一堆包,明天换台机器照着requirements.txt装,很可能装出来的版本不一样。因为requirements.txt里如果只写transformers而不写版本号,pip会给你装最新的,而最新的可能已经breaking change了。
我的做法是两层锁定:开发阶段用requirements.in写顶层依赖(不写版本号或者写宽松的范围),然后用pip-compile生成锁定的requirements.txt(所有依赖都写死版本号,包括间接依赖)。这样既方便开发时升级,又保证了部署时的可复现性。
# requirements.in 里只写你直接用的包 torch transformers fastapi uvicorn # 用pip-tools生成锁定文件 pip install pip-tools pip-compile requirements.in -o requirements.txt # 部署时严格按锁定文件安装 pip install -r requirements.txt还有一个容易被忽略的点:CUDA版本也要锁定。你的Dockerfile或者部署脚本里,基础镜像的CUDA版本必须和训练时一致。我遇到过一次,训练用CUDA 11.8,推理服务的基础镜像用了CUDA 12.1,结果模型加载时报了一堆莫名其妙的错误,查了半天才发现是CUDA版本不匹配导致的算子行为差异。
2.3 容器化:从"我的机器"到"任何机器"
到了部署阶段,Docker基本是绕不开的。但AI项目的Dockerfile和普通Web服务不太一样,有几个坑我踩过好几次。
第一个坑是镜像体积。如果你直接用nvidia/cuda:11.8-devel作为基础镜像,光基础层就好几个GB。更合理的做法是用nvidia/cuda:11.8-runtime(不带开发工具),然后在里面装Python和依赖。如果推理不需要CUDA,甚至可以用CPU-only的镜像,体积能小一个数量级。
第二个坑是构建缓存。AI项目的依赖安装特别慢,每次改代码都重新装一遍依赖会疯掉。Dockerfile里要把依赖安装和代码拷贝分开,利用层缓存:
FROM nvidia/cuda:11.8.0-runtime-ubuntu22.04 # 先装系统依赖 RUN apt-get update && apt-get install -y python3.10 python3-pip && rm -rf /var/lib/apt/lists/* # 再装Python依赖(这层会被缓存) COPY requirements.txt . RUN pip install -r requirements.txt # 最后拷贝代码(这层经常变,但上面的缓存不会失效) COPY . /app WORKDIR /app CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]第三个坑是模型文件的处理。模型权重动辄几个GB,直接打进镜像会让镜像巨大且构建极慢。我的做法是把模型文件放在宿主机或者对象存储上,容器启动时挂载或者下载。如果一定要打进镜像,用多阶段构建,把下载模型和运行时分离开。
3. 数据处理管线:AI工程里最容易被低估的环节
3.1 数据质量决定模型上限,这不是一句空话
我刚入行的时候,前辈跟我说过一句话:"模型效果不好,百分之八十的问题出在数据上。"当时我不信,觉得模型结构、超参、训练技巧才是关键。后来做多了才发现,这句话一点不夸张。
一个真实的案例:我们做一个文本分类任务,模型在验证集上F1能到0.92,但上线后用户反馈效果很差。排查了半天,发现是训练数据里的标签有噪声——标注团队把一部分"中性"的样本标成了"正面"。模型学到的其实是标注员的偏见,而不是真实的语义模式。清洗数据重新训练后,验证集F1降到了0.89,但线上效果反而好了很多。
所以数据处理管线的第一步不是写代码,而是建立数据质量的检查机制。我通常会在管线里加这么几个检查点:
- 标签分布检查:每个类别的样本数是否均衡?有没有某个类别只有个位数样本?
- 重复样本检查:训练集里有没有完全重复的样本?重复样本会让模型过拟合到这些样本上。
- 长度分布检查:文本长度、序列长度的分布是否合理?有没有异常长的样本(可能是数据爬取时混入了噪声)?
- 标签一致性检查:同一个样本有没有被标成不同标签?这通常意味着标注规范不清晰。
这些检查用pandas和简单的统计就能做,但能避免很多低级错误。我习惯把检查结果输出成一个HTML报告,每次数据更新都跑一遍,肉眼扫一下有没有异常。
3.2 数据管线的设计原则:可复现、可增量、可回滚
数据处理管线最怕的是什么?是"跑一次要三个小时,中间挂了要从头再来"。我见过一个团队,他们的数据预处理脚本要跑六个小时,而且没有中间状态保存,一旦某个环节报错,整个流程重来。后来我帮他们改成了分阶段、可增量的设计,每个阶段把中间结果落盘,出错时只需要重跑出错的阶段。
具体来说,我会把数据管线拆成这么几个阶段:
- 原始数据拉取:从数据源(数据库、对象存储、API)拉取原始数据,落盘为parquet格式。parquet比CSV快得多,而且自带schema。
- 清洗与过滤:去重、去噪、过滤异常样本。这一步的输出是清洗后的数据集。
- 特征工程/Tokenization:把原始文本转成模型能吃的格式。这一步通常最耗时,所以一定要缓存。
- 数据集划分:train/val/test划分,固定随机种子,保证每次划分结果一致。
- 打包:把处理好的数据打包成模型训练时能直接读的格式(比如HuggingFace的Dataset或者TFRecord)。
每个阶段的输出都带一个版本号(可以用日期+git commit hash),这样任何时候都能追溯到"这个模型是用哪份数据训练的"。
# 一个简单的数据管线示例,用DVC做版本管理 # dvc.yaml stages: fetch: cmd: python scripts/fetch_data.py --output data/raw/ outs: - data/raw/ clean: cmd: python scripts/clean_data.py --input data/raw/ --output data/clean/ deps: - data/raw/ outs: - data/clean/ tokenize: cmd: python scripts/tokenize.py --input data/clean/ --output data/tokenized/ deps: - data/clean/ outs: - data/tokenized/提示:DVC(Data Version Control)是AI项目里做数据版本管理的好工具。它把大文件存在对象存储上,git仓库里只存元数据。这样你既能用git管理代码,又能用DVC管理数据,两者版本对应。
3.3 数据加载的性能优化:别让IO成为瓶颈
到了训练阶段,数据加载经常成为性能瓶颈。GPU利用率上不去,一查是DataLoader在等数据。这个问题在图像和视频任务里尤其严重,因为单样本体积大,从磁盘读一次要好久。
我的优化思路是分层的:首先,把数据转成顺序读性能最好的格式。对于小文件多的场景,打包成WebDataset或者TFRecord;对于大文件,用内存映射(memmap)的方式读。其次,用多进程预取。PyTorch的DataLoader本身支持num_workers参数,但设多少有讲究——设太小了IO跟不上,设太大了CPU上下文切换开销大。我的经验值是num_workers = 4 * GPU数量,然后根据实际IO情况微调。
还有一个容易被忽略的点:数据增强的位置。如果数据增强在CPU上做,而且增强操作很复杂,那CPU很可能成为瓶颈。这时候可以考虑把增强放到GPU上做(比如用NVIDIA的DALI库),或者用更轻量的增强策略。
# PyTorch DataLoader的典型配置 from torch.utils.data import DataLoader loader = DataLoader( dataset, batch_size=32, shuffle=True, num_workers=8, # 根据CPU核心数和IO情况调整 pin_memory=True, # 如果用的是CUDA,开启这个能加速CPU到GPU的传输 prefetch_factor=2, # 每个worker预取多少个batch persistent_workers=True # 避免每个epoch重新创建worker )我实测下来,pin_memory=True和persistent_workers=True这两个选项在大多数情况下都能带来明显的吞吐提升,尤其是小batch的场景。但num_workers不是越大越好,超过一定值之后反而会变慢,因为进程间通信的开销上来了。
4. 模型训练与实验管理:让每一次实验都有迹可循
4.1 实验追踪:别再用Excel记结果了
我见过太多团队用Excel或者记事本记录实验结果——改了哪个超参、对应的指标是多少、用的哪份数据。这种方式在实验少的时候还能凑合,一旦实验数量上来了(几十上百次),就完全乱了。更糟糕的是,过了一个月你回头看,根本不记得当时为什么改那个参数。
实验追踪工具我用过不少,MLflow、Weights & Biases、TensorBoard。我的建议是:如果团队小、预算有限,用MLflow自己搭,数据存在自己的服务器上;如果追求开箱即用和协作体验,用W&B,但要注意数据隐私问题。TensorBoard更适合看训练曲线,但做实验管理偏弱。
不管用哪个工具,核心是养成习惯:每次实验都记录完整的配置(超参、数据版本、代码commit)、指标曲线、以及最终模型文件。这样任何时候都能复现任何一个实验。
# 用MLflow记录实验的示例 import mlflow mlflow.set_experiment("text-classification") with mlflow.start_run(): # 记录超参 mlflow.log_params({ "learning_rate": 2e-5, "batch_size": 32, "epochs": 5, "model_name": "bert-base-chinese" }) # 训练过程中记录指标 for epoch in range(epochs): train_loss = train_one_epoch() val_f1 = evaluate() mlflow.log_metrics({ "train_loss": train_loss, "val_f1": val_f1 }, step=epoch) # 保存模型 mlflow.pytorch.log_model(model, "model")4.2 超参搜索:别靠直觉,靠策略
调超参这件事,新手容易走两个极端:要么完全靠直觉瞎试,要么搞一个巨大的网格搜索跑到天荒地老。我的做法是分阶段:先用少量实验确定哪些超参重要,再在重要的超参上做精细搜索。
具体来说,我会先用随机搜索跑20-30组实验,覆盖一个较宽的范围。然后看哪些超参对指标影响大,哪些基本没影响。对于影响大的超参,再用贝叶斯优化(比如Optuna)做精细搜索。这样比一上来就网格搜索效率高得多。
# 用Optuna做超参搜索的示例 import optuna def objective(trial): lr = trial.suggest_float("lr", 1e-6, 1e-4, log=True) batch_size = trial.suggest_categorical("batch_size", [16, 32, 64]) warmup_ratio = trial.suggest_float("warmup_ratio", 0.0, 0.2) model = train_model(lr=lr, batch_size=batch_size, warmup_ratio=warmup_ratio) val_f1 = evaluate(model) return val_f1 study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50)注意:超参搜索很吃算力。如果GPU资源有限,建议先用小模型或者小数据集做搜索,找到大致范围后再用完整配置跑。另外,随机种子要固定,否则不同实验之间的差异可能来自随机性而不是超参。
4.3 训练过程中的常见坑与排查
训练过程中最让人头疼的不是loss不降,而是loss降了但指标不涨,或者训练集指标很好但验证集很差。这些问题的排查思路不太一样。
Loss不降:先检查学习率是不是太大了(loss震荡)或者太小了(loss几乎不动)。然后检查数据有没有问题——标签是不是对的、输入是不是正常的。如果都正常,可能是模型结构或者初始化有问题。
Loss降但指标不涨:这通常意味着模型在优化一个和最终指标不一致的目标。比如你用的是交叉熵loss,但评估用的是F1,而数据类别极不均衡,模型可能把所有样本都预测成多数类,loss很低但F1很差。这时候要考虑换loss(比如Focal Loss)或者做重采样。
过拟合:训练集指标远好于验证集。常规做法是加正则化(dropout、weight decay)、早停、数据增强。但我要提醒一点:在AI工程里,过拟合有时候不是坏事——如果线上数据分布和训练集一致,过拟合一点反而效果更好。关键是要看线上表现,而不是死盯验证集。
训练不稳定:loss突然变成NaN,或者指标剧烈波动。常见原因是学习率太大、梯度爆炸、数据里有异常样本。排查方法是加梯度裁剪、调小学习率、检查数据。
我习惯在训练脚本里加一个"健康检查"的逻辑:每个epoch结束后,自动检查loss和指标是否在合理范围内,如果异常就发通知。这样不用一直盯着屏幕,出问题了能及时知道。
5. 推理服务构建:从模型文件到线上服务
5.1 推理框架选型:没有最好的,只有最合适的
模型训练完了,下一步是把它变成服务。这一步的选型很多,我列一下我用过的几种方案和适用场景:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| FastAPI + PyTorch | 小规模、快速上线 | 灵活、易调试 | 性能一般、不支持动态batch |
| TorchServe | 中等规模、标准模型 | 开箱即用、支持多模型 | 配置复杂、定制性差 |
| Triton Inference Server | 大规模、多框架 | 性能强、支持动态batch | 学习曲线陡 |
| ONNX Runtime | 跨平台、CPU推理 | 轻量、快 | 算子支持有限 |
| vLLM | 大语言模型 | 吞吐极高 | 只支持特定模型 |
我的建议是:如果只是内部工具或者流量不大,FastAPI足够了,开发效率最高。如果要上生产且流量不小,考虑Triton或者TorchServe。如果是LLM推理,vLLM基本是当前的最优解。
选型的时候还要考虑一个因素:你的团队能不能维护。Triton性能好,但配置和调优需要一定的学习成本。如果团队里没人懂,出了问题排查起来会很痛苦。这种情况下,用简单方案+水平扩展可能更实际。
5.2 动态Batch:推理性能的杀手锏
推理服务和训练最大的区别之一是:训练时batch size是固定的,推理时请求是一个一个来的。如果每个请求都单独跑一次模型,GPU利用率会非常低。动态batch(也叫continuous batching或者in-flight batching)就是把短时间内到达的多个请求攒成一个batch一起推理,能大幅提升吞吐。
实现动态batch有两种方式:一种是在应用层做,自己维护一个请求队列,攒够了或者等超时了就跑一次;另一种是用推理框架自带的动态batch功能(Triton和vLLM都支持)。
# 一个简单的应用层动态batch实现 import asyncio from collections import deque class DynamicBatcher: def __init__(self, model, max_batch_size=32, max_wait_ms=10): self.model = model self.max_batch_size = max_batch_size self.max_wait_ms = max_wait_ms self.queue = deque() self.lock = asyncio.Lock() async def predict(self, input_data): future = asyncio.Future() async with self.lock: self.queue.append((input_data, future)) if len(self.queue) >= self.max_batch_size: await self._process_batch() else: asyncio.create_task(self._wait_and_process()) return await future async def _wait_and_process(self): await asyncio.sleep(self.max_wait_ms / 1000) async with self.lock: if self.queue: await self._process_batch() async def _process_batch(self): batch = list(self.queue) self.queue.clear() inputs = [item[0] for item in batch] results = self.model(inputs) for (_, future), result in zip(batch, results): future.set_result(result)提示:动态batch的
max_wait_ms是个权衡——设太大了延迟高,设太小了batch攒不起来。我的经验值是10-50ms,具体要看你的延迟要求。如果对延迟极其敏感(比如实时对话),可能不适合动态batch,或者要用更激进的策略。
5.3 模型量化与加速:用更少的资源跑更快的推理
模型量化是我在推理优化里用得最多的手段。简单说就是把模型权重从FP32降到FP16或者INT8,减少显存占用和计算量。FP16量化基本无损,而且大多数GPU对FP16有硬件加速,推理速度能提升1.5-2倍。INT8量化更激进,速度更快,但可能会有精度损失,需要做校准。
# PyTorch的FP16推理 model = model.half().cuda() input_tensor = input_tensor.half().cuda() with torch.no_grad(): output = model(input_tensor) # 动态量化(INT8,适合CPU推理) import torch.quantization quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 )除了量化,还有几个常用的加速手段:算子融合(把多个小算子合并成一个大算子,减少kernel launch开销)、KV Cache(针对自回归生成模型,缓存已计算的key和value)、投机解码(用一个小模型快速生成草稿,大模型验证)。这些手段在不同场景下效果不一样,需要根据实际情况选择。
我踩过的一个坑是:量化后的模型在GPU上跑,精度掉了不少。后来发现是某些层的数值范围太大,FP16表示不了。解决办法是对这些层保持FP32,只量化其他层。这种混合精度的做法在大多数情况下能兼顾速度和精度。
6. 监控、日志与故障排查:上线只是开始
6.1 监控指标:别只看QPS和延迟
推理服务上线后,最基础的监控是QPS、延迟、错误率。但这远远不够。AI服务有几个特有的监控维度:
输入分布漂移:线上请求的输入分布和训练数据分布是否一致?如果漂移了,模型效果会下降。监控方法可以是统计输入的长度分布、词汇分布、或者用一个轻量级的分类器判断输入是否"异常"。
预测分布漂移:模型输出的分布是否稳定?比如分类任务里,各类别的预测比例是否和预期一致?如果某个类别的预测比例突然飙升,可能意味着输入分布变了,或者模型出了问题。
置信度分布:模型预测的置信度分布是否正常?如果大量请求的置信度都很低,说明模型遇到了不熟悉的输入,可能需要人工介入或者触发降级策略。
GPU利用率与显存:这两个指标能反映资源是否充分利用,以及有没有内存泄漏。显存缓慢增长通常意味着有地方没释放,时间长了会OOM。
# 用Prometheus客户端暴露自定义指标 from prometheus_client import Counter, Histogram, Gauge prediction_counter = Counter("predictions_total", "Total predictions", ["model_version", "status"]) latency_histogram = Histogram("prediction_latency_seconds", "Prediction latency") confidence_gauge = Gauge("prediction_confidence", "Average confidence") def predict(input_data): with latency_histogram.time(): result = model(input_data) prediction_counter.labels(model_version="v1", status="success").inc() confidence_gauge.set(result.confidence) return result6.2 日志设计:出问题时能快速定位
日志这件事,平时不觉得重要,一出问题就发现日志不够用。我的经验是:日志要包含足够多的上下文,但也不能太多导致磁盘爆掉。
推理服务的日志我通常分三层:访问日志(每个请求的基本信息:时间、请求ID、输入长度、输出、延迟)、错误日志(异常堆栈、输入数据、模型版本)、调试日志(只在排查特定问题时开启,记录中间结果)。
关键是要给每个请求分配一个唯一的request ID,这样从访问日志到错误日志到下游服务,都能串起来。另外,输入数据要不要记日志是个权衡——记了方便排查,但可能涉及隐私。我的做法是记输入的hash和统计特征,不记原始内容,除非是调试模式。
6.3 常见故障与排查速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 延迟突然升高 | 请求量突增、模型退化、资源竞争 | 看QPS曲线、GPU利用率、显存 | 扩容、限流、重启服务 |
| 错误率升高 | 输入异常、模型加载失败、依赖服务挂了 | 看错误日志、输入分布 | 加输入校验、降级策略 |
| 显存OOM | batch太大、内存泄漏、模型太大 | 看显存曲线、batch size | 减小batch、查泄漏、量化 |
| 预测结果异常 | 输入漂移、模型版本错误、预处理bug | 对比线上线下预处理、看输入分布 | 修复预处理、回滚模型 |
| 服务无响应 | 死锁、线程池耗尽、GC停顿 | 看线程栈、GC日志 | 重启、调优线程池、调GC参数 |
我遇到过一次很诡异的问题:服务运行几个小时后延迟逐渐升高,重启就好,但过几小时又出现。查了半天发现是Python的GC没有及时回收,导致内存碎片化,最终影响了推理速度。解决办法是手动触发GC,或者用gc.freeze()把不常变的对象冻结起来。
还有一个常见问题是模型加载慢。如果每次请求都重新加载模型,那肯定慢。正确的做法是服务启动时加载一次,常驻内存。但要注意,如果模型很大,加载时间可能很长,这时候要用健康检查接口,让负载均衡器知道服务还没准备好,别把流量打过来。
7. 一些零散但重要的经验
7.1 版本管理:模型、数据、代码要一起管
AI项目和普通软件项目最大的区别是:除了代码,还有模型和数据。这三者的版本必须对应起来,否则出了问题根本不知道是哪个环节变了。
我的做法是用一个统一的版本号(比如日期+git commit hash)标记一次完整的实验,模型文件、数据版本、代码commit都记录在这个版本号下。部署的时候,服务启动时打印这个版本号,这样线上出问题能快速定位到对应的实验。
7.2 回滚策略:上线前想好怎么退
AI服务上线有个特点:模型效果不是非黑即白的,可能新模型在某些场景下更好,在某些场景下更差。所以回滚策略不能只是"出错了就回滚",而要有更细粒度的控制。
我通常的做法是:新模型先小流量灰度,对比新旧模型的关键指标。如果新模型在核心指标上不差于旧模型,再逐步放大流量。同时保留一键回滚的能力,出问题了能在分钟级切回旧模型。
7.3 成本控制:GPU很贵,别浪费
GPU是AI服务里最贵的资源。我见过不少团队,推理服务跑在A100上,但利用率只有10%。这是巨大的浪费。
控制成本的手段有几个:选择合适的GPU(不是所有任务都需要A100,T4或者A10在很多场景下够用)、动态扩缩容(流量低的时候缩容,流量高的时候扩容)、模型压缩(量化、蒸馏、剪枝,让小GPU也能跑)、请求调度(把多个小请求合并,提高利用率)。
我实测下来,通过量化+动态batch+合适的GPU选型,推理成本能降到原来的三分之一甚至更低。这些优化在项目初期可能不重要,但规模上来之后,省下来的钱非常可观。
7.4 团队协作:别让AI工程师一个人扛
AI工程不是一个人能搞定的事。数据、模型、服务、运维,每个环节都需要专业的人。但现实中很多团队是"一个算法工程师包揽所有",结果就是每个环节都做得不够深入。
我的建议是:至少要有一个人负责数据管线,一个人负责模型训练和实验,一个人负责服务和运维。如果人手不够,也要明确分工,别让一个人同时做所有事。另外,文档和知识共享很重要——AI项目里很多决策是"凭经验"的,如果不记录下来,人一走就全丢了。
8. 最后分享几个我常用的调试技巧
第一个技巧:用小数据快速验证。每次改完代码,先用一个极小的数据集(比如10条样本)跑一遍,确认流程能走通,再上全量数据。这样能快速发现代码层面的bug,而不是等几个小时才发现某个维度对不上。
第二个技巧:固定随机种子。AI项目里随机性无处不在——数据划分、权重初始化、dropout、数据增强。调试的时候一定要固定所有随机种子,否则你根本不知道指标变化是来自你的改动还是随机性。
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False第三个技巧:保存中间结果。训练过程中,把每个epoch的模型、优化器状态、学习率都存下来。这样即使训练中断了,也能从最近的checkpoint恢复,不用从头再来。另外,保存中间结果也方便做模型集成或者分析训练过程。
第四个技巧:写单元测试。AI代码也需要测试,尤其是数据处理和预处理部分。我见过太多bug是因为预处理逻辑不一致导致的——训练时用的分词器和推理时用的不一样,或者归一化参数没对齐。给关键函数写单元测试,能避免很多这类问题。
第五个技巧:性能分析用profiler。PyTorch自带的profiler能告诉你每个算子的耗时,帮你找到性能瓶颈。不要靠猜,用数据说话。
from torch.profiler import profile, ProfilerActivity with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: model(input_tensor) print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))这套东西我从零搭过好几遍,每次都有新的体会。最开始觉得麻烦,觉得不如直接调包快。但踩的坑多了之后发现,那些"麻烦"的步骤——版本锁定、数据检查、实验追踪、监控告警——恰恰是让项目能稳定跑下去的关键。调包能让你快速看到结果,但只有把整条链路都搞清楚,才能在出问题的时候不慌,在需要优化的时候有方向。
如果你正在从零搭建AI工程体系,我的建议是:别追求一步到位,先把最基础的跑通——环境隔离、数据管线、训练脚本、简单推理服务。然后随着项目发展,逐步加上实验管理、监控、优化。每一步都搞清楚为什么这么做,比盲目堆工具重要得多。