ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从零手搓AI工程链路:数据、训练、评估与服务全流程实战

从零手搓AI工程链路:数据、训练、评估与服务全流程实战 1. 从零搭建AI工程能力为什么“手搓一遍”比调包更值钱这两年AI应用层的工具链成熟得吓人一个周末就能用现成框架拼出一个能跑通的demo。但我在团队里带人、面试、做技术评审的过程中反复撞见同一个尴尬很多人能说出Transformer的公式却讲不清一次推理请求从输入到输出中间到底经过了哪些环节能背出各种微调方法的缩写却在自己动手准备一份训练数据时被格式、分词、对齐这些琐事卡住整整两天。ai-engineering-from-scratch这个方向之所以值得认真做一遍恰恰是因为它逼着你把那些被封装层藏起来的细节全部摊开来看。所谓“从零做AI工程”不是让你去手写CUDA内核也不是要求你从随机初始化开始复现一个大模型。它更准确的定位是在不依赖高层封装的前提下把一条完整的AI链路自己搭一遍——数据怎么进来、怎么清洗、怎么切分、怎么喂给模型、模型怎么加载、推理怎么调度、结果怎么评估、服务怎么暴露出去。这条链路上每一个环节你亲手写过一次之后再面对任何框架都能一眼看穿它在替你做什么、代价是什么、哪里可能出问题。我个人的判断是这套能力对三类人价值最大。第一类是刚入行的算法或后端工程师简历上写满了“熟悉PyTorch”但真让他从零写一个训练循环就露怯第二类是从传统后端转AI方向的开发者工程底子好但缺的是对AI链路特有环节比如数据泄漏、评估口径、显存管理的直觉第三类是做技术管理或架构的人需要判断一个方案到底靠不靠谱而判断力只能来自亲手踩过坑。这篇文章我会把这条链路拆成几个核心模块每个模块讲清楚为什么这么设计、具体怎么做、容易在哪里翻车尽量给到可以直接抄作业的代码和参数。2. 整体链路设计与技术选型思路2.1 为什么坚持“最小依赖”原则从零做AI工程第一个要做的决定就是到底允许自己用多少现成的东西。我的建议是划一条清晰的线——底层数值计算和自动求导可以用框架但数据管道、训练循环、评估逻辑、服务封装必须自己写。这条线的依据很简单框架帮你做的是“数学”而工程能力体现在“流程”上。你调model.fit()永远学不会学习率调度和梯度累积的配合但你自己写一遍训练循环这些问题会主动找上门来。具体到工具选型我通常这样配数值层用PyTorch或NumPy取决于是否要GPU数据处理用原生Python加少量pandas分词如果做中文就自己写一版基于词表的实现服务层用FastAPI或Flask这种轻量框架。刻意避开那些“一行搞定”的高层库比如某些自动训练框架、自动调参平台。原因不是它们不好而是它们把学习曲线最陡、也最有价值的那段给抹平了。提示最小依赖不等于重复造轮子。像矩阵乘法、卷积这种已经被优化到极致的算子没必要自己写。判断标准是这个环节的“决策逻辑”是否值得你理解值得就自己写纯粹是性能优化就用现成的。2.2 一条完整链路的模块划分把整条链路拆开我一般分成五个模块它们之间有明确的输入输出契约可以独立开发和测试模块核心职责关键产出常见翻车点数据层加载、清洗、切分、构造样本训练/验证/测试集数据泄漏、类别失衡特征与分词文本转ID、padding、mask定长张量批次词表越界、padding位置错误模型与训练前向、损失、反向、优化可复现的checkpoint梯度爆炸、学习率不当评估层指标计算、错误分析可信的评估报告口径不一致、过拟合验证集服务层封装推理、批处理、限流可调用的API冷启动、并发下的显存问题这个划分的好处是每个模块都能单独写单元测试。我见过太多人把整条链路揉在一个脚本里结果调参时根本分不清是数据问题还是模型问题。模块化不是为了好看是为了可调试。2.3 从零实现的收益到底在哪有人会问这些框架都帮你做了自己写一遍除了“学习”还有什么实际收益我的回答是当线上出问题时你能不能定位到根因取决于你对链路的掌控深度。举个真实例子我们线上一个分类服务某天准确率突然掉了几个点排查了半天发现是新版本的数据预处理里某个字段的空值填充逻辑变了导致分布偏移。如果整条链路是黑盒你只能看到“输入变了、输出变了”根本无从下手。但因为你亲手写过数据层你知道每个字段经过了哪些变换就能快速二分定位。另一个收益是性能优化的抓手。自己写的推理循环你知道瓶颈在哪一步——是分词慢、还是模型前向慢、还是后处理慢。用高层封装时你只能笼统地感觉“有点慢”优化无从谈起。我实测过一个文本分类服务把分词从逐条处理改成批量处理QPS直接翻了一倍多而这种优化只有在你清楚链路细节时才想得到。3. 数据层从原始文件到可训练样本的完整实操3.1 数据清洗的取舍逻辑数据清洗最容易犯的错是“洗得太狠”或“洗得太松”。洗太狠把有用的信号当噪声扔了洗太松模型学到一堆脏东西。我的经验是先做统计再做决策。拿到一份原始数据第一步不是急着写清洗规则而是先跑一遍统计样本总数、标签分布、文本长度分布、特殊字符占比、重复样本比例。这些数字会告诉你该往哪个方向使劲。比如文本长度分布如果90%的样本在50字以内但有1%的样本超过5000字那这1%大概率是异常数据直接截断或过滤掉比让它们拖慢整个训练划算得多。再比如标签分布如果某个类别只占0.5%你要么做重采样要么在损失函数里加权绝不能视而不见——模型会直接学会忽略这个类别。import pandas as pd from collections import Counter def profile_dataset(df, text_col, label_col): stats {} stats[total] len(df) stats[label_dist] Counter(df[label_col]).most_common() lengths df[text_col].astype(str).str.len() stats[len_mean] lengths.mean() stats[len_p95] lengths.quantile(0.95) stats[len_max] lengths.max() stats[dup_ratio] df[text_col].duplicated().mean() stats[empty_ratio] (lengths 0).mean() return stats这段代码我几乎每个项目都会先跑一遍输出的数字直接决定后面清洗策略的松紧。先看数据再动手是省时间而不是浪费时间。3.2 训练/验证/测试切分的坑切分看似简单但坑特别多。第一个坑是切分前做了全局统计。比如你先对全量数据算了个均值来填充缺失值再切分那验证集的信息就泄漏到训练集了。正确顺序永远是先切分再在训练集上算统计量然后应用到验证集和测试集。第二个坑是随机切分导致分布不一致。如果数据有时间属性随机切分会让未来信息泄漏到过去。这时候必须按时间切分。如果是多来源数据还要考虑按来源分层切分保证每个集合里各来源比例一致。from sklearn.model_selection import train_test_split def split_dataset(df, label_col, test_size0.2, val_size0.1, seed42): train_val, test train_test_split( df, test_sizetest_size, stratifydf[label_col], random_stateseed ) val_ratio val_size / (1 - test_size) train, val train_test_split( train_val, test_sizeval_ratio, stratifytrain_val[label_col], random_stateseed ) return train, val, test注意这里用了stratify参数保证切分后各集合的标签分布一致。这个参数在类别不平衡时尤其重要我见过有人忘了加结果验证集里某个类别一个样本都没有评估结果完全失真。3.3 样本构造与负采样策略到了构造训练样本这一步核心问题是正负样本怎么配比。以分类任务为例如果正样本很少直接训练会让模型偏向多数类。常见做法是负采样但负采样也有讲究随机负采样简单但可能采到“简单负样本”模型学不到东西难负采样效果好但实现复杂且容易引入噪声。我的折中方案是分层负采样把负样本按“与正样本的相似度”分成几档从每一档里按比例采样。这样既保证了难度分布又不会让训练太难收敛。具体实现上可以先用一个轻量模型比如TF-IDF加逻辑回归给负样本打分按分数分桶再从每个桶里采样。注意负采样比例不是越高越好。我试过1:1到1:10的各种比例实测下来1:3到1:5在多数任务上比较稳。比例太高会让训练集过大、训练变慢收益却递减。3.4 数据管道的可复现性设计数据管道最容易被忽视的是可复现性。同一个脚本今天跑和明天跑结果不一样排查起来能让人崩溃。保证可复现的关键是所有随机操作都固定种子所有依赖外部状态的步骤都记录下来。具体做法在管道入口设一个全局种子所有用到随机的地方切分、采样、打乱都从这个种子派生。同时把数据版本、清洗规则版本、切分种子一起写进一个manifest文件跟模型checkpoint一起保存。这样任何时候你都能回答“这个模型是用哪份数据、哪套规则训出来的”。import json, hashlib, random def make_manifest(data_path, seed, rules_version): with open(data_path, rb) as f: data_hash hashlib.md5(f.read()).hexdigest() return { data_path: data_path, data_hash: data_hash, seed: seed, rules_version: rules_version }这个manifest看起来不起眼但在团队协作和线上问题回溯时能救命。4. 模型与训练手写训练循环的核心细节4.1 从零写一个训练循环训练循环是整条链路的心脏。框架的fit()帮你做了太多事自己写一遍才能理解每个环节的作用。一个最小可用的训练循环包含前向传播、损失计算、反向传播、参数更新、日志记录。我把它拆成几个清晰的步骤import torch import torch.nn as nn def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) optimizer.zero_grad() logits model(input_ids, attention_maskattention_mask) loss criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这里有几个细节值得说。optimizer.zero_grad()必须在反向传播前调用否则梯度会累积clip_grad_norm_是防止梯度爆炸的常用手段max_norm设1.0在多数任务上够用loss.item()要转成Python标量否则会一直持有计算图导致显存泄漏。这些都是框架帮你隐藏、但你必须知道的点。4.2 学习率调度与梯度累积学习率是训练里最敏感的超参。固定学习率往往不是最优我一般会用预热加余弦退火的组合前10%的步数线性预热之后余弦退火到接近零。预热的目的是让模型在初期不要被大梯度带偏退火的目的是后期精细收敛。from torch.optim.lr_scheduler import LambdaLR import math def get_cosine_schedule(optimizer, warmup_steps, total_steps): def lr_lambda(step): if step warmup_steps: return step / max(1, warmup_steps) progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return max(0.0, 0.5 * (1 math.cos(math.pi * progress))) return LambdaLR(optimizer, lr_lambda)梯度累积是另一个实用技巧。当显存不够、batch size上不去时可以累积多个小批次的梯度再更新一次等效于大batch。实现上就是在loss.backward()后不立即step()而是累积若干次后再更新并清零。提示梯度累积时损失要除以累积步数否则等效学习率会变大。这个细节很多人会漏导致训练不稳定。4.3 显存管理与混合精度显存是训练时的硬约束。除了减小batch size还有几个实用手段。混合精度训练用float16做前向和反向用float32保存主权重能省将近一半显存速度也有提升。PyTorch里用torch.cuda.amp几行就能开启from torch.cuda.amp import autocast, GradScaler scaler GradScaler() with autocast(): logits model(input_ids, attention_maskattention_mask) loss criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是防止float16下梯度下溢。注意混合精度不是万能的某些模型尤其是涉及大范围数值的用float16会不稳定这时候要退回float32或者用bfloat16。另一个手段是梯度检查点用计算换显存适合超深模型。还有及时释放不用的中间变量、避免在循环里累积张量等都是基本功。4.4 checkpoint与断点续训训练中断是常态checkpoint设计得好不好直接决定你能不能续上。我一般保存三类信息模型参数、优化器状态、训练进度epoch、step、最佳指标。只存模型参数是不够的因为优化器状态比如Adam的动量丢了续训时会有明显的性能抖动。def save_checkpoint(path, model, optimizer, scheduler, epoch, best_metric): torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), epoch: epoch, best_metric: best_metric }, path)保存频率上我通常每个epoch存一次同时保留最近三个和最佳一个。这样既不会占太多空间又能应对各种意外。5. 评估层让指标真正反映模型能力5.1 指标选择的场景适配评估指标选错等于白训。准确率在类别平衡时好用但类别不平衡时会严重误导——一个把所有样本都预测为多数类的模型准确率可能很高但毫无价值。这时候要看F1、AUC、或者每类的召回率。我的习惯是同时看多个指标整体准确率、宏平均F1、加权F1、以及混淆矩阵。宏平均F1对少数类敏感加权F1反映整体表现混淆矩阵帮你看清错在哪。单看一个指标很容易被误导。场景推荐主指标辅助指标类别平衡分类准确率F1类别不平衡分类宏平均F1AUC、每类召回排序任务NDCGMRR、MAP生成任务人工评估BLEU/ROUGE参考5.2 评估口径的一致性评估最容易出的问题是口径不一致。训练时用的评估逻辑和最终报告用的不一致导致数字对不上。解决办法是把评估逻辑抽成一个独立函数训练和最终评估都调它。这个函数接收模型和数据返回完整的指标字典不依赖任何外部状态。另一个细节是评估时的模型状态。一定要先model.eval()并配合torch.no_grad()否则dropout和batch norm会干扰结果而且会白白消耗显存。我见过有人忘了切eval模式评估结果比实际低了好几个点排查了半天。5.3 错误分析与bad case挖掘指标只是数字真正有价值的是错误分析。我习惯在评估后导出所有预测错误的样本按错误类型分类看看模型到底在哪些情况下翻车。常见的错误模式有长文本截断导致信息丢失、特定领域词汇未登录、标签边界模糊等。def analyze_errors(model, dataloader, device, id2label): model.eval() errors [] with torch.no_grad(): for batch in dataloader: logits model(batch[input_ids].to(device), attention_maskbatch[attention_mask].to(device)) preds logits.argmax(dim-1).cpu() labels batch[labels] for i in range(len(labels)): if preds[i] ! labels[i]: errors.append({ text: batch[raw_text][i], pred: id2label[preds[i].item()], true: id2label[labels[i].item()] }) return errors拿到错误样本后人工看几十条往往就能发现系统性问题。比如发现错误集中在某类文本上那可能是数据分布问题发现错误集中在某个长度区间那可能是截断策略问题。错误分析是提升模型最直接的抓手比盲目调参有效得多。5.4 过拟合验证集的识别与规避反复在验证集上调参本质上是在过拟合验证集。识别方法是留一个从不参与调参的测试集只在最终报告时用一次。如果验证集指标很好但测试集差很多说明调参过拟合了。规避手段有几个一是限制调参次数别把验证集当训练集用二是用交叉验证减少单次切分的随机性三是记录每次调参的验证集指标如果发现指标波动很大说明验证集太小需要扩大。注意测试集一旦用过就不能再用来做任何决策否则它就变成了验证集。这个纪律必须守住否则评估结果没有任何可信度。6. 服务层把模型变成可调用的接口6.1 推理服务的封装要点模型训好了最终要变成服务。服务层最核心的要求是稳定、可观测、可扩展。用FastAPI封装一个推理接口基本结构是这样from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI() model None tokenizer None class PredictRequest(BaseModel): text: str app.on_event(startup) def load_model(): global model, tokenizer model torch.load(model.pt, map_locationcpu) model.eval() tokenizer load_tokenizer(vocab.txt) app.post(/predict) def predict(req: PredictRequest): inputs tokenizer.encode(req.text, max_len128) with torch.no_grad(): logits model(inputs) pred logits.argmax(dim-1).item() return {label: pred}这里的关键点是模型在启动时加载一次而不是每次请求都加载。我见过有人在请求处理函数里加载模型QPS低得可怜还以为是模型慢其实是IO拖累。6.2 批处理与并发下的显存控制单条推理效率很低实际服务要支持批处理。做法是维护一个请求队列攒够一批或超时后统一推理。这样能充分利用GPU的并行能力QPS能提升好几倍。但批处理会带来显存问题并发高时如果每个请求都触发一次推理显存会爆。解决办法是限制最大批次大小和最大并发数超出的请求排队等待。同时要监控显存使用设置一个安全阈值接近阈值时主动降级比如减小批次或拒绝部分请求。MAX_BATCH 32 MAX_QUEUE 256 async def batch_worker(): while True: batch await collect_batch(MAX_BATCH, timeout0.05) if not batch: continue texts [item.text for item in batch] with torch.no_grad(): results model.predict_batch(texts) for item, result in zip(batch, results): item.future.set_result(result)6.3 冷启动与预热策略服务刚启动时第一次推理往往特别慢因为要初始化各种缓存、加载权重到显存。解决办法是启动时做一次预热推理用一个假样本跑一遍完整流程把该初始化的都初始化好。这样第一个真实请求就不会遇到冷启动延迟。预热样本的选择也有讲究最好用接近真实分布的样本长度取中等偏上这样能覆盖大部分初始化路径。预热次数一般跑个三五次就够太多浪费时间。6.4 服务可观测性设计服务上线后你得知道它跑得怎么样。最基本的可观测性包括请求量、延迟分布、错误率、显存使用。这些指标要能实时看到出问题时能快速定位。延迟分布尤其重要平均值会掩盖长尾。我一般看P50、P95、P99三个分位。如果P99特别高说明有少量请求特别慢可能是长文本或者异常输入导致的需要针对性优化。提示日志里要记录每个请求的输入长度、推理耗时、输出结果。出问题时这些日志是唯一的线索。但要注意脱敏别把用户敏感信息写进日志。7. 常见问题与排查技巧实录7.1 训练不收敛的排查顺序训练不收敛是最常见的问题排查要有顺序别乱试。我的顺序是先看数据再看损失再看梯度最后看超参。数据层面检查标签是否正确、输入是否正常、有没有全零或全一的批次。损失层面看损失曲线是震荡、上升还是不动。震荡通常是学习率太大上升可能是标签错了或损失函数用错不动可能是梯度消失或学习率太小。梯度层面打印梯度范数如果接近零说明梯度消失如果特别大说明梯度爆炸。超参层面最后再调学习率、batch size这些。7.2 显存溢出的定位方法显存溢出OOM的定位关键是找到显存增长的位置。用torch.cuda.memory_allocated()在关键步骤打印显存看哪一步增长异常。常见原因有在循环里累积了张量没释放、计算图没断开、batch size太大、模型太大。一个实用技巧是用torch.cuda.memory_summary()看显存分布能清楚看到是参数占的多还是激活占的多。参数多就减模型或量化激活多就减batch size或用梯度检查点。7.3 评估指标异常的常见原因评估指标异常先别怀疑模型先怀疑评估代码。常见原因有忘了model.eval()、标签和预测对错了位置、padding位置参与了损失计算、类别映射错了。这些错误都会让指标看起来很奇怪。排查方法是用一个小批量手动算一遍跟代码算的对比。如果对不上就是代码问题。这个手动验证的过程虽然笨但最可靠。7.4 服务延迟高的优化路径服务延迟高优化要按从外到内的顺序。先看网络和框架层有没有不必要的序列化、有没有同步阻塞。再看推理层能不能批处理、能不能用更快的推理后端。最后看模型层能不能量化、能不能蒸馏。我实测下来收益最大的往往是批处理和推理后端优化模型层面的优化反而见效慢。所以别一上来就想着换小模型先把工程层面的优化做足。问题现象可能原因排查手段解决方向损失不下降学习率过小/梯度消失打印梯度范数调大学习率/换激活函数损失震荡学习率过大看损失曲线调小学习率/加预热验证集差过拟合对比训练验证曲线加正则/减模型显存溢出batch过大/泄漏memory_summary减batch/查泄漏服务延迟高无批处理/后端慢分位延迟分析批处理/换后端8. 我在这条路上踩过的几个真实坑第一个坑是数据泄漏。早期做文本分类我在切分前对全量数据做了TF-IDF结果验证集指标虚高上线后掉了一大截。后来才明白任何用到全量数据的变换都必须在切分之后做。这个教训让我养成了“先切分、后变换”的肌肉记忆。第二个坑是评估口径不一致。训练时用了一个评估函数最终报告时图省事另写了一个结果两个数字对不上排查了一整天才发现是padding处理不同。从那以后评估逻辑我只写一份所有地方都调它。第三个坑是服务冷启动。上线后第一个请求延迟特别高被用户投诉。后来加了预热逻辑问题解决。这个坑让我意识到训练和服务是两套完全不同的工程问题不能混为一谈。第四个坑是checkpoint不完整。有次训练中断想续训发现只存了模型参数优化器状态丢了续训后性能抖动很大只能从头再来。从那以后checkpoint一定存全。这些坑的共同点是它们都不会在demo阶段暴露只在真实场景下才出现。这也是为什么我坚持认为AI工程能力必须通过完整走一遍链路来建立光看文档、跑demo是学不到的。你亲手搭过一遍这些坑会主动找上你你解决一次就真正记住了。
返回列表