☰
从零手搓AI工程链路:数据、训练、评估与服务全流程实战
2026/10/1 13:31:04 网站建设 项目流程

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_size=0.2, val_size=0.1, seed=42): train_val, test = train_test_split( df, test_size=test_size, stratify=df[label_col], random_state=seed ) val_ratio = val_size / (1 - test_size) train, val = train_test_split( train_val, test_size=val_ratio, stratify=train_val[label_col], random_state=seed ) 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_mask=attention_mask) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.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_mask=attention_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_mask=batch['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_location='cpu') model.eval() tokenizer = load_tokenizer('vocab.txt') @app.post("/predict") def predict(req: PredictRequest): inputs = tokenizer.encode(req.text, max_len=128) 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, timeout=0.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是学不到的。你亲手搭过一遍,这些坑会主动找上你,你解决一次,就真正记住了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询