ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零手搓AI工程:数据管道、特征工程与推理服务实战

从零手搓AI工程:数据管道、特征工程与推理服务实战 1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始接触这个方向的时候也是这么想的直到有一次线上环境出了个诡异的问题——模型推理延迟突然从80毫秒飙到2秒日志里什么异常都没有监控面板上CPU和内存也都很正常。那一次排查花了整整两天最后发现是特征预处理阶段的一个归一化参数在某个边界条件下发生了数值溢出导致整个批次的向量全部变成了NaN模型内部走了兜底逻辑白白浪费了大量算力。这件事让我彻底明白了一个道理如果你不理解AI系统底层每个环节在干什么你连问题出在哪都找不到更别提修了。这也是我后来开始系统性地“从零构建AI工程”的初衷——不是为了炫技而是为了在出问题的时候脑子里能有一张完整的链路图知道数据从哪来、经过了什么变换、在哪一步可能出岔子。“ai-engineering-from-scratch”这个方向核心就是不依赖高级封装框架用最基础的工具和代码把AI系统的每一个组件亲手搭一遍。它适合那些已经会用现成框架跑模型但总觉得心里没底、想搞清楚“黑盒里面到底发生了什么”的开发者。你不需要是数学博士但得愿意动手写代码、愿意看报错信息、愿意为了搞懂一个矩阵乘法的维度问题折腾半小时。这篇文章我会按照一个完整的AI工程流水线来展开从数据管道的搭建到特征工程的手工实现再到模型训练循环的裸写、推理服务的封装最后聊一聊监控和迭代。每一部分我都会给出可运行的代码骨架和我在实际项目中踩过的坑。你不需要全部照搬但至少看完之后再遇到类似问题时能有一个清晰的排查方向。2. 数据管道别让脏数据毁了你后面所有的努力2.1 为什么数据加载器值得你花一整天去写大部分教程里数据加载就是一行DataLoader(dataset, batch_size32)的事。但在真实项目里数据管道的健壮性直接决定了你模型能不能收敛、服务能不能稳定。我见过太多团队在模型结构上反复调优结果最后发现是数据里混了一批标注错误的样本导致验证集指标怎么都上不去。从零构建数据管道你需要自己处理几个核心问题样本的读取与解码、批次的组装与填充、多进程的并行加载、以及异常样本的跳过与记录。这四个环节每一个都有坑。先说读取与解码。假设你的数据是JSON Lines格式每行一个样本包含文本和标签。最朴素的写法是import json def read_samples(path): samples [] with open(path, r, encodingutf-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, encodingutf-8) as f, \ open(error_log_path, w, encodingutf-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(fline {line_no}: {e}\n) print(fError summary: {dict(error_counter)})这样你既能流式处理大数据又能在最后看到错误分布。如果json_error占比超过1%你就得回去检查数据源了而不是硬着头皮往下跑。2.2 批次组装中的填充陷阱与动态策略当你的样本长度不一致时填充是绕不开的。最常见的做法是取一个最大长度短的全部补零。但这里有个容易被忽略的细节填充的位置会影响模型对位置信息的感知。如果你用的是Transformer类模型填充在左边还是右边对注意力掩码的计算有直接影响。我一般会实现一个动态填充策略每个批次内只填充到当前批次的最大长度而不是全局最大长度。这样能显著减少无效计算。代码大概长这样def collate_batch(batch, pad_token_id0): 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_buckets10): 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_splits5, smoothing10): encoded np.zeros(len(categories)) global_mean np.mean(targets) kf KFold(n_splitsn_splits, shuffleTrue, random_state42) 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_norm1.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(fEarly 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_locationdevice)) model.to(device) model.eval() # 预热 dummy_input torch.zeros(1, 128, dtypetorch.long).to(device) dummy_mask torch.ones(1, 128, dtypetorch.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(host0.0.0.0, port5000)预热这一步很多人会忽略但在生产环境里非常关键。我实测过一个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_size32, max_wait_ms10): 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(timeouttimeout) 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_size1000): self.latencies deque(maxlenwindow_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 数据漂移检测模型为什么会悄悄变差模型上线之后效果不是一成不变的。上游数据分布会变用户行为会变模型会慢慢“过时”。这就是数据漂移。如果不做检测你可能要等到业务指标明显下滑才发现那时候已经损失了很多。一个简单的漂移检测方法是定期计算线上输入特征的分布和训练集分布做对比。常用的指标是PSIPopulation Stability Index。PSI小于0.1表示分布稳定0.1到0.25表示有轻微漂移大于0.25表示显著漂移需要重新训练模型。import numpy as np def calculate_psi(expected, actual, buckets10): 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系统的认知就不再是“调包侠”的水平了。这种认知才是你在遇到新问题、新场景时能快速定位和解决的底气。
返回列表