1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一听到“AI工程”这四个字,第一反应就是打开某个云平台,拖几个组件,调几个API,然后跑通一个Demo,就觉得自己已经入门了。我刚开始接触这个方向的时候也是这么想的,直到有一次线上环境出了个诡异的问题——模型推理延迟突然从80毫秒飙到2秒,日志里什么异常都没有,监控面板上CPU和内存也都很正常。那一次排查花了整整两天,最后发现是特征预处理阶段的一个归一化参数在某个边界条件下发生了数值溢出,导致整个批次的向量全部变成了NaN,模型内部走了兜底逻辑,白白浪费了大量算力。
这件事让我彻底明白了一个道理:如果你不理解AI系统底层每个环节在干什么,你连问题出在哪都找不到,更别提修了。这也是我后来开始系统性地“从零构建AI工程”的初衷——不是为了炫技,而是为了在出问题的时候,脑子里能有一张完整的链路图,知道数据从哪来、经过了什么变换、在哪一步可能出岔子。
“ai-engineering-from-scratch”这个方向,核心就是不依赖高级封装框架,用最基础的工具和代码,把AI系统的每一个组件亲手搭一遍。它适合那些已经会用现成框架跑模型,但总觉得心里没底、想搞清楚“黑盒里面到底发生了什么”的开发者。你不需要是数学博士,但得愿意动手写代码、愿意看报错信息、愿意为了搞懂一个矩阵乘法的维度问题折腾半小时。
这篇文章我会按照一个完整的AI工程流水线来展开:从数据管道的搭建,到特征工程的手工实现,再到模型训练循环的裸写、推理服务的封装,最后聊一聊监控和迭代。每一部分我都会给出可运行的代码骨架和我在实际项目中踩过的坑。你不需要全部照搬,但至少看完之后,再遇到类似问题时,能有一个清晰的排查方向。
2. 数据管道:别让脏数据毁了你后面所有的努力
2.1 为什么数据加载器值得你花一整天去写
大部分教程里,数据加载就是一行DataLoader(dataset, batch_size=32)的事。但在真实项目里,数据管道的健壮性直接决定了你模型能不能收敛、服务能不能稳定。我见过太多团队在模型结构上反复调优,结果最后发现是数据里混了一批标注错误的样本,导致验证集指标怎么都上不去。
从零构建数据管道,你需要自己处理几个核心问题:样本的读取与解码、批次的组装与填充、多进程的并行加载、以及异常样本的跳过与记录。这四个环节每一个都有坑。
先说读取与解码。假设你的数据是JSON Lines格式,每行一个样本,包含文本和标签。最朴素的写法是:
import json def read_samples(path): samples = [] with open(path, 'r', encoding='utf-8') as f: for line in f: try: obj = json.loads(line.strip()) samples.append(obj) except json.JSONDecodeError: continue return samples这段代码能跑,但有两个致命问题。第一,它把所有样本一次性加载到内存里,数据量大了直接OOM。第二,它静默跳过了所有解析失败的样本,你根本不知道跳过了多少、为什么跳过。正确的做法是流式读取+异常计数+采样记录:
import json from collections import Counter def stream_samples(path, error_log_path): error_counter = Counter() with open(path, 'r', encoding='utf-8') as f, \ open(error_log_path, 'w', encoding='utf-8') as err_f: for line_no, line in enumerate(f, 1): line = line.strip() if not line: error_counter['empty_line'] += 1 continue try: obj = json.loads(line) yield obj except json.JSONDecodeError as e: error_counter['json_error'] += 1 err_f.write(f"line {line_no}: {e}\n") print(f"Error summary: {dict(error_counter)}")这样你既能流式处理大数据,又能在最后看到错误分布。如果json_error占比超过1%,你就得回去检查数据源了,而不是硬着头皮往下跑。
2.2 批次组装中的填充陷阱与动态策略
当你的样本长度不一致时,填充是绕不开的。最常见的做法是取一个最大长度,短的全部补零。但这里有个容易被忽略的细节:填充的位置会影响模型对位置信息的感知。如果你用的是Transformer类模型,填充在左边还是右边,对注意力掩码的计算有直接影响。
我一般会实现一个动态填充策略:每个批次内,只填充到当前批次的最大长度,而不是全局最大长度。这样能显著减少无效计算。代码大概长这样:
def collate_batch(batch, pad_token_id=0): max_len = max(len(item['input_ids']) for item in batch) input_ids = [] attention_mask = [] labels = [] for item in batch: seq = item['input_ids'] pad_len = max_len - len(seq) input_ids.append(seq + [pad_token_id] * pad_len) attention_mask.append([1] * len(seq) + [0] * pad_len) labels.append(item['label']) return { 'input_ids': input_ids, 'attention_mask': attention_mask, 'labels': labels }注意attention_mask的构造:真实token位置为1,填充位置为0。这个掩码后面会传给模型,告诉它哪些位置是有效的。如果你忘了传或者传错了,模型会把填充的零也当成真实输入,注意力权重会被稀释,效果直接打折。
还有一个坑是多进程加载时的随机种子问题。如果你用multiprocessing做并行读取,每个worker的随机种子可能相同,导致数据顺序在每个epoch都一样。解决办法是在worker初始化时,用worker_id和当前epoch数混合生成种子:
def worker_init_fn(worker_id): seed = base_seed + worker_id random.seed(seed) np.random.seed(seed)这些细节在高级框架里都是自动处理的,但一旦你自己写,就必须一个一个考虑到。我的经验是,数据管道写完以后,先跑一个epoch,把每个批次的形状、填充比例、标签分布都打印出来检查一遍。这一步花十分钟,能省掉后面十小时的调试。
3. 特征工程:手工实现那些你以为框架帮你做了的事
3.1 数值特征的归一化与分桶:为什么标准化不是万能的
数值特征处理是特征工程里最基础的部分。很多人拿到数值列,二话不说先做一个StandardScaler,均值归零方差归一。但标准化有个前提假设:你的数据分布近似高斯分布,且没有极端离群点。如果数据是长尾分布,标准化之后大部分值会挤在零附近,少数极端值会拉到很远,模型反而更难学。
我一般会先画一下直方图,看看分布形态。如果是长尾分布,我会用分位数分桶代替标准化。具体做法是:计算特征的1%、5%、10%...99%分位数,把连续值映射到离散的桶ID。这样既保留了排序信息,又对离群点鲁棒。
import numpy as np def quantile_bucketize(values, n_buckets=10): percentiles = np.linspace(0, 100, n_buckets + 1) boundaries = np.percentile(values, percentiles) boundaries[0] = -np.inf boundaries[-1] = np.inf bucket_ids = np.digitize(values, boundaries) - 1 return bucket_ids, boundaries这里有个细节:np.digitize返回的索引是从1开始的,所以要减1。另外边界值要处理成负无穷和正无穷,确保所有值都能落到某个桶里。分桶之后,你可以把桶ID当作类别特征处理,做嵌入或者独热编码。
如果一定要用标准化,我建议用RobustScaler的思路:用中位数代替均值,用四分位距代替标准差。这样即使有5%的离群点,也不会把整体分布带偏。
3.2 类别特征的编码:目标编码的泄漏问题与交叉验证方案
类别特征编码里,独热编码适合基数小的类别,目标编码适合基数大的类别。但目标编码有一个非常隐蔽的坑:目标泄漏。如果你直接用全量数据计算每个类别的目标均值,然后把这个均值作为特征喂给模型,模型在训练集上会看到“未来信息”,导致验证集指标虚高,上线后效果暴跌。
正确的做法是在交叉验证的框架内做目标编码。具体来说,把训练数据分成K折,对于每一折,用其他K-1折的数据计算类别目标均值,然后应用到当前折。这样每个样本的目标编码值都不包含自身标签的信息。
from sklearn.model_selection import KFold def target_encode_cv(categories, targets, n_splits=5, smoothing=10): encoded = np.zeros(len(categories)) global_mean = np.mean(targets) kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) for train_idx, val_idx in kf.split(categories): cat_train = categories[train_idx] target_train = targets[train_idx] # 计算每个类别的均值 cat_means = {} for cat in np.unique(cat_train): mask = cat_train == cat cat_means[cat] = np.mean(target_train[mask]) # 应用平滑 for idx in val_idx: cat = categories[idx] if cat in cat_means: count = np.sum(cat_train == cat) encoded[idx] = (cat_means[cat] * count + global_mean * smoothing) / (count + smoothing) else: encoded[idx] = global_mean return encoded平滑项smoothing的作用是:当某个类别的样本数很少时,它的均值不可信,应该向全局均值靠拢。这个值一般取10到100之间,具体看数据量。我试过在某个点击率预估项目里,不做平滑直接编码,结果稀有类别的编码值波动极大,模型完全学不动。
还有一个进阶技巧:用贝叶斯目标编码,把每个类别的目标均值建模成一个Beta分布,用后验均值作为编码值。这样能更优雅地处理小样本类别,但实现复杂度也更高,适合对效果有极致要求的场景。
4. 模型训练循环:裸写反向传播教会我的那些事
4.1 手写训练循环的骨架与梯度累积
用高级框架的时候,model.fit()一行就完事了。但当你自己写训练循环,你需要显式地处理:前向传播、损失计算、反向传播、参数更新、梯度清零。这五个步骤的顺序和细节,每一步都有讲究。
先看一个最朴素的训练循环骨架:
import torch import torch.nn as nn def train_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) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)这个骨架能跑,但有几个问题。第一,loss.item()会触发GPU到CPU的同步,频繁调用会拖慢训练速度。更好的做法是累积一个tensor,最后再取一次。第二,没有梯度裁剪,遇到梯度爆炸直接NaN。第三,没有学习率调度,后期收敛会震荡。
梯度累积是另一个实用技巧。当你的显存不够大,但想要更大的有效批次时,可以每N个批次才更新一次参数:
accumulation_steps = 4 optimizer.zero_grad() for i, batch in enumerate(dataloader): loss = compute_loss(model, batch) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() optimizer.zero_grad()注意loss要除以累积步数,这样梯度才是平均的。梯度裁剪放在step()之前,防止爆炸。这些细节在高级框架里都是封装好的,但自己写一遍之后,你就知道为什么有时候调大batch size效果反而变差了——因为学习率没有相应调整。
4.2 验证集评估与早停:别被训练损失骗了
训练损失下降不代表模型变好。我见过太多次训练损失一路降到0.01,验证损失却在第三个epoch之后就开始上升。这就是典型的过拟合。所以每个epoch结束后必须在验证集上评估,并且根据验证指标做早停。
def evaluate(model, dataloader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for batch in dataloader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) logits = model(input_ids, attention_mask) loss = criterion(logits, labels) total_loss += loss.item() preds = torch.argmax(logits, dim=-1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / len(dataloader), correct / total早停的逻辑是:维护一个best_val_loss,如果连续N个epoch验证损失没有下降,就停止训练,并回滚到最佳epoch的模型参数。N一般取3到5。这里有个细节:保存模型参数时要用深拷贝,否则后续训练会覆盖掉最佳参数。
import copy best_val_loss = float('inf') best_model_state = None patience = 3 wait = 0 for epoch in range(max_epochs): train_loss = train_epoch(...) val_loss, val_acc = evaluate(...) if val_loss < best_val_loss: best_val_loss = val_loss best_model_state = copy.deepcopy(model.state_dict()) wait = 0 else: wait += 1 if wait >= patience: print(f"Early stopping at epoch {epoch}") break model.load_state_dict(best_model_state)这个模式我在几乎所有项目里都会用。它不仅能防止过拟合,还能节省大量训练时间。有一次我跑一个文本分类任务,本来计划训练50个epoch,结果第8个epoch就触发了早停,省了80%的算力。
5. 推理服务封装:从模型文件到可用接口的距离
5.1 模型加载与预热:别让第一个请求等十秒
训练完的模型保存成文件之后,下一步是把它封装成一个推理服务。最朴素的做法是写一个Flask接口,收到请求后加载模型、推理、返回结果。但这里有个性能陷阱:如果你在每个请求里都重新加载模型,延迟会高得离谱。模型加载通常需要几秒到几十秒,而推理本身可能只需要几十毫秒。
正确的做法是服务启动时加载一次模型,常驻内存。同时,在正式接收请求之前,用几条假数据做一次预热推理,让CUDA核函数完成初始化、内存池分配完毕。这样第一个真实请求的延迟就不会异常高。
import torch from flask import Flask, request, jsonify app = Flask(__name__) model = None device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') def load_model(): global model model = MyModel() model.load_state_dict(torch.load('model.pt', map_location=device)) model.to(device) model.eval() # 预热 dummy_input = torch.zeros(1, 128, dtype=torch.long).to(device) dummy_mask = torch.ones(1, 128, dtype=torch.long).to(device) with torch.no_grad(): for _ in range(3): model(dummy_input, dummy_mask) print("Model loaded and warmed up.") @app.route('/predict', methods=['POST']) def predict(): data = request.get_json() input_ids = torch.tensor(data['input_ids']).to(device) attention_mask = torch.tensor(data['attention_mask']).to(device) with torch.no_grad(): logits = model(input_ids, attention_mask) probs = torch.softmax(logits, dim=-1) return jsonify({'probs': probs.cpu().tolist()}) if __name__ == '__main__': load_model() app.run(host='0.0.0.0', port=5000)预热这一步很多人会忽略,但在生产环境里非常关键。我实测过一个BERT模型,不预热的话第一个请求要等8秒,预热之后稳定在50毫秒以内。
5.2 批处理与超时控制:平衡吞吐与延迟
单个请求推理效率很低,因为GPU的并行能力没有被充分利用。理想情况下,服务应该支持动态批处理:把短时间内到达的多个请求合并成一个批次,一起推理,然后拆分结果返回。这样吞吐量能提升几倍到几十倍。
但批处理会引入延迟:如果请求A到达后要等请求B、C、D一起凑够一个批次,那A的响应时间就变长了。所以需要设置一个最大等待时间,比如10毫秒。超过这个时间,即使批次没满,也立即推理。
import time import threading from queue import Queue class BatchProcessor: 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 = Queue() self.lock = threading.Lock() def add_request(self, input_ids, attention_mask): event = threading.Event() result = {} self.queue.put((input_ids, attention_mask, event, result)) event.wait() return result['output'] def process_loop(self): while True: batch = [] start_time = time.time() while len(batch) < self.max_batch_size: timeout = self.max_wait_ms / 1000 - (time.time() - start_time) if timeout <= 0: break try: item = self.queue.get(timeout=timeout) batch.append(item) except: break if batch: self._process_batch(batch)这个实现是简化版,真实场景还要考虑线程安全、异常处理、超时熔断等。但核心思想就是:用微小的延迟换取大幅的吞吐提升。具体参数要根据你的业务SLA来调,如果延迟要求是100毫秒以内,那max_wait_ms设10毫秒是安全的。
6. 监控与迭代:上线只是开始,不是结束
6.1 推理延迟的P99比均值更重要
服务上线之后,你肯定要看监控。很多人只看平均延迟,觉得平均50毫秒就万事大吉了。但真实用户体验是由长尾请求决定的。如果1%的请求要等2秒,那这1%的用户就会觉得你的服务很慢。
所以监控要看P50、P95、P99分位数。P99延迟高,通常意味着有资源竞争、GC停顿、或者某些特殊输入触发了慢路径。我一般会在推理代码里埋点,记录每个请求的耗时,然后定期聚合上报。
import time from collections import deque class LatencyTracker: def __init__(self, window_size=1000): self.latencies = deque(maxlen=window_size) def record(self, latency_ms): self.latencies.append(latency_ms) def get_percentiles(self): if not self.latencies: return {} sorted_lat = sorted(self.latencies) n = len(sorted_lat) return { 'p50': sorted_lat[int(n * 0.5)], 'p95': sorted_lat[int(n * 0.95)], 'p99': sorted_lat[int(n * 0.99)], 'max': sorted_lat[-1] }如果P99突然飙升,第一件事是看输入长度分布有没有变化。很多时候是上游传了一批超长文本进来,导致推理时间线性增长。解决办法是在入口做长度截断,或者对超长输入走单独的慢路径。
6.2 数据漂移检测:模型为什么会悄悄变差
模型上线之后,效果不是一成不变的。上游数据分布会变,用户行为会变,模型会慢慢“过时”。这就是数据漂移。如果不做检测,你可能要等到业务指标明显下滑才发现,那时候已经损失了很多。
一个简单的漂移检测方法是:定期计算线上输入特征的分布,和训练集分布做对比。常用的指标是PSI(Population Stability Index)。PSI小于0.1表示分布稳定,0.1到0.25表示有轻微漂移,大于0.25表示显著漂移,需要重新训练模型。
import numpy as np def calculate_psi(expected, actual, buckets=10): def scale_range(input_arr, min_val, max_val): input_arr = np.clip(input_arr, min_val, max_val) return (input_arr - min_val) / (max_val - min_val) breakpoints = np.arange(0, buckets + 1) / buckets * 100 breakpoints = np.percentile(expected, breakpoints) expected_percents = np.histogram(expected, breakpoints)[0] / len(expected) actual_percents = np.histogram(actual, breakpoints)[0] / len(actual) expected_percents = np.clip(expected_percents, 0.0001, None) actual_percents = np.clip(actual_percents, 0.0001, None) psi_value = np.sum((expected_percents - actual_percents) * np.log(expected_percents / actual_percents)) return psi_value这个函数对每个数值特征算一个PSI,如果超过阈值的特征占比超过20%,就触发告警。对于类别特征,可以把类别ID当作离散值,计算频率分布的PSI。
我自己的做法是每周跑一次漂移检测,把结果写进报表。如果连续两周PSI上升,就开始准备重新训练。这样就不会出现“模型悄悄变差,业务方来投诉才发现”的被动局面。
7. 一些让我少走弯路的实操心得
7.1 版本管理不只是代码,还有数据和模型
做AI工程,最容易乱的就是版本。代码用Git管,这没问题。但数据和模型呢?我见过太多团队把模型文件命名为model_final_v2_真的最终版.pt,过两周谁也不知道哪个是哪个。
我的做法是:每次训练产出的模型,都附带一个元数据文件,记录训练数据的时间范围、特征版本、超参数、验证集指标。模型文件名用UUID,元数据里写清楚人类可读的描述。这样回溯的时候,直接看元数据就知道这个模型是怎么来的。
{ "model_id": "a3f8c2d1-...", "train_data_range": "2024-01-01 to 2024-03-31", "feature_version": "v2.3", "hyperparameters": {"lr": 1e-4, "batch_size": 64}, "val_metrics": {"loss": 0.234, "acc": 0.912}, "created_at": "2024-04-01T10:30:00Z" }这个习惯看起来麻烦,但当你需要回滚或者对比两个模型的时候,能省下大量翻聊天记录的时间。
7.2 日志要打够,但别打太多
日志是排查问题的生命线。但日志太多会拖慢服务,还会淹没关键信息。我的原则是:入口和出口必打,中间环节按需打。入口记录请求ID、输入摘要、时间戳;出口记录请求ID、输出摘要、耗时、状态码。中间的特征变换、模型推理等环节,只在DEBUG级别打详细日志,生产环境默认INFO级别。
另外,请求ID要贯穿整个链路。从接收到请求开始生成一个UUID,传给下游所有环节,这样出问题的时候能快速串联起完整的调用链。
7.3 别过早优化,但要知道优化点在哪
从零构建AI工程,很容易陷入“过度工程”的陷阱。一开始就想着做分布式训练、做模型量化、做多级缓存,结果核心功能还没跑通,时间全花在基础设施上了。
我的建议是:先用最朴素的方案跑通端到端流程,然后再根据瓶颈逐个优化。瓶颈在哪?看监控。如果推理延迟高,先看是模型计算慢还是数据预处理慢;如果是训练慢,先看是IO瓶颈还是GPU利用率低。有针对性地优化,比盲目堆技术栈有效得多。
但“不优化”不等于“不知道优化点”。你在写每一段代码的时候,心里要清楚:这里如果数据量涨十倍,会不会成为瓶颈?如果会,大概怎么改?这样等到真需要优化的时候,你已经有方案了,而不是从头调研。
从零构建AI工程这件事,最大的价值不在于你造出了多牛的轮子,而在于你对整个系统的理解深度。当你亲手处理过数据里的脏样本、亲手调过梯度爆炸、亲手封装过推理接口、亲手排查过线上延迟抖动,你对AI系统的认知就不再是“调包侠”的水平了。这种认知,才是你在遇到新问题、新场景时,能快速定位和解决的底气。