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

资讯详情

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

从零搭建AI工程能力:数据、训练、推理与监控全链路实战

从零搭建AI工程能力:数据、训练、推理与监控全链路实战 1. 从零搭建AI工程能力这个项目到底在解决什么问题第一次看到ai-engineering-from-scratch这个标题我脑子里蹦出来的第一个念头是终于有人把这件事说明白了。市面上讲AI的教程铺天盖地但绝大多数要么停留在“调包侠”层面——教你import torch然后跑个预训练模型要么直接跳到高深的论文推导中间那一大段“工程落地”的空白地带几乎没人系统性地讲。而这个项目标题里的from-scratch三个字恰恰戳中了这个痛点。所谓 AI 工程不是让你从零推导反向传播公式也不是让你手写一个 Transformer 的每一行注意力计算。它真正指的是把一个 AI 能力从想法变成稳定、可维护、能扛住真实流量的线上服务。这中间涉及数据管道怎么搭、模型怎么选型和微调、推理服务怎么部署、延迟和成本怎么平衡、线上效果怎么监控和迭代——这些才是日常工作中真正吃时间的东西。ai-engineering-from-scratch这个项目本质上就是一套面向“从零开始构建 AI 工程能力”的实践路线它适合那些已经会写 Python、懂一点机器学习基础但一到工程落地就抓瞎的开发者也适合想从传统后端转型到 AI 方向的工程师。我之所以对这个方向特别有感触是因为我自己就踩过这个坑。早年做推荐系统的时候模型在 notebook 里 AUC 跑到 0.85一上线 QPS 直接崩掉排查半天发现是特征拼接那里用了 Python 的for循环逐条处理单次推理耗时 200ms。这种问题任何一本机器学习教材都不会教你但它是 AI 工程的核心。所以当我看到ai-engineering-from-scratch这个定位时第一反应是这才是真正该被系统整理的东西。接下来我会把这个项目涉及的核心思路、技术选型、实操步骤和我自己踩过的坑完整地拆一遍。2. 整体设计思路为什么“从零”不等于“重复造轮子”2.1 核心思路拆解分层构建而非一步到位ai-engineering-from-scratch这个项目最核心的设计哲学我理解是分层解耦。很多新手一上来就想搞一个“端到端”的大系统结果数据、训练、推理、服务全揉在一个脚本里改一处崩三处。正确的做法是把它拆成几个相对独立的层每层有明确的输入输出契约。第一层是数据层。这一层的职责是把原始数据可能是 CSV、日志、图片目录、数据库表清洗、转换、切分成模型能吃的格式。关键点在于这一层的输出必须是可复现的。我见过太多项目数据预处理脚本改了一版又一版最后没人知道线上模型到底是用哪版数据训出来的。所以从零搭建时第一件事就是给数据打版本哪怕只是用日期戳加配置文件的哈希值。第二层是训练与实验层。这一层负责模型选型、微调、超参搜索。from-scratch在这里的含义不是让你手写优化器而是让你理解每个超参背后的权衡。比如学习率设 1e-5 还是 3e-5不是拍脑袋而是跟你的 batch size、模型规模、数据量都有关系。这一层的产出是一个可加载的模型权重文件外加一份实验记录。第三层是推理服务层。这是最容易被低估的一层。训练时你关心的是 loss 降不降推理时你关心的是 P99 延迟、吞吐量、显存占用。这两个目标经常是冲突的。比如训练时用 fp32 精度推理时可能就得量化到 int8训练时 batch size 拉满推理时可能得动态 batching。这一层的核心是把模型包装成一个高可用的服务。第四层是监控与迭代层。线上跑起来不是终点而是起点。你需要知道模型在生产环境下的真实表现输入分布有没有漂移、预测置信度分布有没有异常、有没有出现新的 bad case。这一层往往被忽略但它决定了你的 AI 系统能不能持续产生价值。2.2 技术选型背后的考量为什么是这套组合在ai-engineering-from-scratch的语境下技术选型有几个原则成熟优先、社区活跃、文档齐全、可替换。我见过有人为了追求“最新”选了某个刚发布三个月的框架结果遇到 bug 连 issue 都没人回项目直接卡死。数据层我倾向于用Pandas PyArrow处理中小规模数据用Spark或Ray处理大规模数据。Pandas 的优势是上手快、生态全但它的内存模型是单机的数据量超过内存就歇菜。PyArrow 作为列式内存格式在类型安全和跨语言交互上比原生 Pandas 好很多。如果数据量真的到了 TB 级那就得上 Spark 或 Ray这两个在分布式数据处理上更成熟。训练层主流就是PyTorch。TensorFlow 虽然工业部署生态曾经很强但这几年 PyTorch 在研究和工业界的势头明显更猛尤其是 Hugging Face 生态几乎全部围绕 PyTorch 构建。对于from-scratch的实践者来说PyTorch 的动态图机制调试起来更直观遇到问题也更容易定位。推理服务层有两个主流选择FastAPI Uvicorn适合快速搭建原型Triton Inference Server或TorchServe适合生产级部署。FastAPI 的优势是轻量、灵活你可以完全控制请求处理逻辑Triton 的优势是内置了动态 batching、多模型管理、GPU 利用率优化但学习曲线更陡。我的建议是先用 FastAPI 把链路跑通等 QPS 上来了再考虑迁移到 Triton。监控层用Prometheus Grafana是性价比最高的方案。Prometheus 负责采集指标Grafana 负责可视化。对于模型层面的监控可以额外记录输入特征的统计量均值、方差、分位数和输出置信度的分布这些指标能帮你提前发现数据漂移。2.3 避免的坑不要过早优化也不要从不优化from-scratch最容易走偏的两个极端一是过早优化二是从不优化。过早优化的典型表现是模型还没跑通就开始纠结用不用 ONNX、要不要上 TensorRT、分布式训练怎么配。结果两周过去了连个 baseline 都没出来。从不优化的典型表现是模型上线了延迟 2 秒用户投诉然后才开始慌。我的经验是先跑通再跑快最后跑稳。跑通阶段用最朴素的方案单机、单卡、fp32、同步推理能出结果就行。跑快阶段开始做 profiling找到瓶颈——是数据预处理慢还是模型前向慢还是后处理慢——然后针对性优化。跑稳阶段加监控、加告警、加降级策略确保系统在异常情况下不会雪崩。3. 核心细节解析数据、训练、推理三层的实操要点3.1 数据层从原始数据到模型输入的完整链路数据层的核心任务是构建一条可复现、可扩展、可监控的数据管道。我把它拆成四个步骤采集、清洗、转换、切分。采集阶段的关键是确定数据源和数据量级。如果是文本数据可能来自数据库、日志文件、API如果是图像数据可能来自对象存储或本地目录。这一步要明确数据总量多大、每天增量多少、有没有标注、标注质量如何。我见过一个项目训练集里 30% 的标签是错的模型训出来效果还不如随机猜排查了两周才发现是标注环节的问题。清洗阶段要处理缺失值、异常值、重复值。缺失值不能简单填充 0要看业务含义——比如用户年龄缺失填 0 会引入严重偏差填中位数更合理。异常值要区分是真实异常还是录入错误前者可能是有价值的长尾样本后者应该剔除。重复值如果不处理会导致训练集和验证集泄漏模型评估结果虚高。转换阶段是特征工程的核心。对于结构化数据要做归一化、独热编码、分箱对于文本数据要做分词、截断、padding对于图像数据要做 resize、归一化、数据增强。这里有个细节训练时的预处理和推理时的预处理必须完全一致。我踩过的坑是训练时用了torchvision的Normalize推理时忘了加结果模型输出完全乱套。解决办法是把预处理逻辑封装成一个独立的模块训练和推理共用同一份代码。切分阶段要保证训练集、验证集、测试集的分布一致。随机切分在数据量大时没问题但如果数据有时间属性比如用户行为日志必须按时间切分否则会用未来数据预测过去造成数据泄漏。另外如果数据类别不平衡切分时要分层采样保证每个集合的类别比例接近。# 数据管道示例可复现的预处理 import pandas as pd from sklearn.model_selection import train_test_split import hashlib import json def build_data_pipeline(config_path): with open(config_path) as f: config json.load(f) # 记录配置哈希保证可复现 config_hash hashlib.md5(json.dumps(config, sort_keysTrue).encode()).hexdigest()[:8] df pd.read_parquet(config[input_path]) # 清洗剔除标签缺失和重复 df df.dropna(subset[label]).drop_duplicates(subset[id]) # 切分分层采样 train_df, temp_df train_test_split( df, test_size0.3, stratifydf[label], random_state42 ) val_df, test_df train_test_split( temp_df, test_size0.5, stratifytemp_df[label], random_state42 ) # 输出带版本号 for name, split_df in [(train, train_df), (val, val_df), (test, test_df)]: split_df.to_parquet(f{config[output_dir]}/{name}_{config_hash}.parquet) return config_hash提示数据版本号建议同时记录配置哈希和数据行数方便后续追溯。如果数据源本身有更新哈希会变化能立刻发现。3.2 训练层模型选型、微调与实验管理训练层的第一个决策是模型选型。from-scratch不意味着你必须从随机初始化开始训恰恰相反能用预训练模型就用预训练模型。预训练模型在大规模数据上已经学到了通用表示你只需要在自己的任务数据上做微调效果通常比从零训练好得多而且收敛快、对数据量要求低。选型的考量维度包括任务类型分类、生成、检索、数据量级、延迟要求、显存限制。比如文本分类任务数据量几千条用 BERT-base 微调就够了如果数据量几十万条可以考虑 RoBERTa-large如果延迟要求极高比如 10ms 以内可能得用蒸馏后的小模型或者 FastText 这类轻量方案。微调阶段的关键超参有四个学习率、batch size、训练轮数、warmup 比例。学习率通常设 1e-5 到 5e-5 之间太大容易震荡太小收敛慢。batch size 受显存限制在显存允许范围内尽量大因为大 batch 的梯度估计更稳定。训练轮数要看验证集 loss 曲线通常 3 到 5 轮就够再多容易过拟合。warmup 比例设 0.1 左右让模型在前 10% 的步数里慢慢把学习率升上去避免一开始就大步更新破坏预训练权重。实验管理是很多人忽略的环节。我的做法是每次实验必须记录配置、指标、产出物路径。配置包括超参和数据版本指标包括训练 loss、验证 loss、验证集上的任务指标产出物路径指向保存的模型权重。这样当你想复现某个好结果时能精确找到当时的配置。工具上轻量级可以用 TensorBoard 手动记录重量级可以用 MLflow 或 Weights Biases。# 训练循环核心片段带 warmup 和梯度裁剪 from transformers import AdamW, get_linear_schedule_with_warmup import torch def train_epoch(model, dataloader, optimizer, scheduler, device, max_grad_norm1.0): model.train() total_loss 0 for batch in dataloader: batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() return total_loss / len(dataloader)注意梯度裁剪的阈值不要设太小否则会抑制正常学习。1.0 是个比较安全的默认值如果发现梯度范数经常超过这个值说明学习率可能偏大。3.3 推理层从模型文件到高可用服务推理层的核心目标是把模型文件变成一个低延迟、高吞吐、可容错的服务。这里有几个关键设计点。模型加载策略服务启动时加载模型而不是每次请求都加载。模型加载通常需要几秒到几十秒如果每次请求都加载延迟无法接受。对于大模型可以考虑懒加载——第一次请求时加载后续请求复用。但懒加载的问题是第一个请求会超时所以最好在服务启动后主动触发一次预热。批处理策略单条推理的 GPU 利用率很低因为 GPU 的并行计算能力没被充分利用。动态批处理dynamic batching是把短时间内到达的多个请求合并成一个 batch 一起推理能显著提升吞吐。但批处理会增加延迟因为要等凑够一个 batch。折中方案是设一个最大等待时间比如 10ms超时或者凑够最大 batch size 就触发推理。精度优化fp32 精度最高但最慢fp16 精度略降但速度翻倍int8 量化速度更快但精度损失更明显。选择哪种精度要看任务对精度的敏感度。分类任务通常 fp16 就够生成任务可能对精度更敏感。量化可以用 PyTorch 的torch.quantization或者 ONNX Runtime 的量化工具。容错设计服务必须能处理异常输入。比如输入文本超长、输入图片格式错误、请求体过大。这些情况要返回明确的错误码而不是让服务崩溃。另外如果模型推理超时要有超时机制避免请求堆积拖垮整个服务。# FastAPI 推理服务示例带动态批处理和超时 from fastapi import FastAPI, HTTPException from pydantic import BaseModel import torch import asyncio from typing import List app FastAPI() model None MAX_BATCH_SIZE 32 MAX_WAIT_MS 10 class PredictRequest(BaseModel): text: str class PredictResponse(BaseModel): label: str confidence: float app.on_event(startup) async def load_model(): global model model torch.load(model.pt, map_locationcuda) model.eval() # 预热 with torch.no_grad(): dummy torch.zeros(1, 128, dtypetorch.long).cuda() model(dummy) app.post(/predict, response_modelPredictResponse) async def predict(req: PredictRequest): if len(req.text) 512: raise HTTPException(status_code400, detail输入过长) with torch.no_grad(): inputs tokenizer(req.text, return_tensorspt, truncationTrue, max_length128) inputs {k: v.cuda() for k, v in inputs.items()} outputs model(**inputs) probs torch.softmax(outputs.logits, dim-1) conf, pred torch.max(probs, dim-1) return PredictResponse(labelstr(pred.item()), confidenceconf.item())提示生产环境的推理服务建议加上请求队列和限流防止突发流量打垮服务。可以用slowapi或 Nginx 做限流。4. 完整实操流程从零到一跑通一个文本分类服务4.1 环境准备与依赖安装先把环境搭起来。我习惯用 conda 创建独立环境避免和系统 Python 冲突。Python 版本选 3.10太新可能有兼容性问题太旧很多库不支持。conda create -n ai-eng python3.10 -y conda activate ai-eng # 核心依赖 pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.35.0 datasets2.14.0 pip install fastapi0.104.0 uvicorn0.24.0 pip install pandas2.1.0 pyarrow14.0.0 scikit-learn1.3.0 pip install prometheus-client0.18.0CUDA 版本要和你的显卡驱动匹配。cu118对应 CUDA 11.8如果你的驱动比较新可以用cu121。装完之后跑一下torch.cuda.is_available()确认 GPU 能用。4.2 数据准备与预处理假设我们做一个情感分类任务数据是 CSV 格式两列text和label0 表示负面1 表示正面。先做数据探查看看类别分布和文本长度分布。import pandas as pd df pd.read_csv(sentiment.csv) print(df[label].value_counts()) print(df[text].str.len().describe()) # 如果类别严重不平衡考虑过采样或调整损失函数权重文本长度分布决定了max_length设多少。如果 95% 的文本在 128 个 token 以内那max_length128就够设太大浪费计算。类别不平衡的话可以在损失函数里加class_weight或者对少数类过采样。4.3 模型微调与评估用 Hugging Face 的Trainer可以省很多事但我建议至少手写一次训练循环理解每一步在干什么。模型选bert-base-chinese中文或distilbert-base-uncased英文前者效果好后者速度快。from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments from datasets import Dataset tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModelForSequenceClassification.from_pretrained(bert-base-chinese, num_labels2) def tokenize(examples): return tokenizer(examples[text], truncationTrue, max_length128, paddingmax_length) train_ds Dataset.from_pandas(train_df).map(tokenize, batchedTrue) val_ds Dataset.from_pandas(val_df).map(tokenize, batchedTrue) training_args TrainingArguments( output_dir./checkpoints, num_train_epochs3, per_device_train_batch_size32, per_device_eval_batch_size64, learning_rate2e-5, warmup_ratio0.1, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy, fp16True, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_ds, eval_datasetval_ds, compute_metricslambda p: {accuracy: (p.predictions.argmax(-1) p.label_ids).mean()}, ) trainer.train() trainer.save_model(./final_model)训练完成后在测试集上跑一遍确认没有过拟合。如果验证集准确率远低于训练集说明过拟合了可以减小模型规模、增加 dropout、或者加更多数据。4.4 服务部署与压测模型训好后用 FastAPI 包成服务。启动命令uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1注意workers不要设太大因为每个 worker 都会加载一份模型显存会爆。GPU 服务通常workers1靠动态批处理提升吞吐。压测用locust或wrk。我习惯用locust因为它能模拟复杂的请求模式。# locustfile.py from locust import HttpUser, task, between class PredictUser(HttpUser): wait_time between(0.01, 0.05) task def predict(self): self.client.post(/predict, json{text: 这个产品非常好用})跑起来后观察 QPS、P50/P99 延迟、GPU 利用率。如果 P99 延迟超过 100ms就要考虑优化了。优化方向减小max_length、用量化模型、加动态批处理、升级 GPU。5. 常见问题与排查技巧实录5.1 训练不收敛或效果差这是最常见的问题。排查顺序先看数据再看模型最后看超参。数据问题包括标签错误、类别不平衡、训练集和验证集分布不一致。检查方法是人工抽查一批样本看标签是否合理统计类别分布看是否严重偏斜对比训练集和验证集的特征分布看是否有明显差异。模型问题包括模型规模不匹配任务复杂度、预训练权重没加载成功。如果任务很简单但用了超大模型容易过拟合如果任务复杂但用了小模型欠拟合。检查预训练权重是否加载可以打印模型参数看是否和预训练一致。超参问题包括学习率太大导致震荡、太小导致收敛慢batch size 太小导致梯度噪声大。学习率可以先用 2e-5 试如果 loss 震荡就降到 1e-5如果 loss 下降太慢就升到 5e-5。5.2 推理延迟高延迟高的原因通常有三个模型太大、预处理太慢、没有批处理。模型太大就换小模型或量化。预处理太慢通常是 tokenization 或图像 resize 拖后腿可以把这部分放到 CPU 上异步做或者用更快的 tokenizer比如tokenizers库的 Rust 实现。没有批处理就加动态批处理把多个请求合并推理。还有一个容易被忽略的点GPU 和 CPU 之间的数据传输。如果每次推理都把数据从 CPU 拷到 GPU再拷回来这个开销可能比推理本身还大。解决办法是尽量让数据在 GPU 上流转减少拷贝次数。5.3 线上效果和离线评估不一致这个问题很隐蔽但很常见。原因可能是训练和推理的预处理不一致、线上数据分布和训练数据不同、评估指标和业务指标不匹配。预处理不一致是最常见的。比如训练时用了paddingmax_length推理时用了paddingTrue导致输入长度不同模型行为变化。解决办法是把预处理逻辑封装成独立模块训练和推理共用。数据分布不同叫数据漂移。线上用户行为会随时间变化训练数据是历史的线上数据是当前的分布自然不同。解决办法是定期用线上数据重新训练或者用在线学习持续更新模型。评估指标不匹配是指离线用准确率但业务关心的是召回率或 F1。解决办法是在离线评估时就选和业务一致的指标别等上线了才发现。问题现象可能原因排查方法解决方案训练 loss 不降学习率太小、数据有问题打印梯度范数、检查数据调大学习率、清洗数据验证 loss 上升过拟合对比训练和验证曲线加 dropout、减模型规模推理延迟高模型大、无批处理profiling 各阶段耗时量化、动态批处理线上效果差预处理不一致、数据漂移对比线上线下输入统一预处理、定期重训服务崩溃异常输入、显存不足看日志、监控显存加输入校验、限流提示线上服务一定要加日志记录每次请求的输入摘要、输出、耗时。出问题时这些日志是唯一的线索。5.4 显存不足显存不足在训练和推理时都可能遇到。训练时显存主要被模型参数、梯度、优化器状态、激活值占用。减小显存的方法减小 batch size、用梯度累积模拟大 batch、用混合精度训练、用 gradient checkpointing。推理时显存主要被模型参数和中间激活值占用。减小显存的方法用量化模型、减小 batch size、及时释放中间变量deltorch.cuda.empty_cache()。我踩过的一个坑是推理服务里用了torch.no_grad()但忘了在模型加载后调model.eval()导致 dropout 和 batch norm 还在训练模式不仅结果不对显存占用也偏高。这两个一定要成对出现。6. 我个人的实操体会与后续扩展方向这个项目做下来我最大的体会是AI 工程的难点从来不在模型本身而在模型之外的那一整套工程体系。模型选型、微调这些有现成的库和文档照着做基本不会出大错。但数据版本管理、预处理一致性、推理性能优化、线上监控这些每个项目的情况都不一样没有银弹只能靠经验积累。如果要把这个项目继续扩展我会往三个方向走。第一是自动化把数据校验、模型训练、评估、部署串成一条流水线每次数据更新自动触发重训和评估减少人工干预。第二是可观测性除了基础的延迟和 QPS还要监控输入特征的分布、输出置信度的分布、bad case 的比例这些指标能提前预警模型退化。第三是成本优化GPU 很贵怎么在保证效果的前提下降低推理成本是个长期课题——量化、蒸馏、模型剪枝、请求调度每个方向都有空间。最后分享一个小技巧每次上线新模型前先做影子部署。就是把新模型的推理结果和旧模型并行跑但不返回给用户只记录日志。对比两者的输出差异确认新模型没有严重退化再切流量。这个做法能避免很多线上事故我靠它躲过好几次模型更新导致的翻车。
返回列表