1. 从零搭建AI工程体系为什么我劝你别一上来就搞模型ai-engineering-from-scratch这个标题乍一看像是又一篇教你从零训练大模型的教程。但我做了十多年一线开发带过不少从算法岗转工程岗的同事发现一个很普遍的现象很多人把AI工程等同于调模型结果项目做到一半发现模型效果还行但整个系统根本跑不起来——推理延迟高得离谱、显存动不动就爆、上线之后监控一片空白、数据管道三天两头断流。AI工程从来不是单点技术它是一整套围绕模型构建的工程化体系。从数据采集、特征处理、训练调度、模型压缩、推理服务、到线上监控和迭代闭环每一环都有它自己的坑。这个项目标题里的from scratch我理解成两层意思一是从零开始搭建一套完整的AI工程链路二是不依赖那些开箱即用的重型平台用最朴素的工具把每个环节吃透。这篇文章适合谁看如果你是刚转AI方向的后端工程师、想把自己算法原型产品化的算法同学、或者带小团队要做AI落地的技术负责人那接下来的内容应该能帮你少走至少半年的弯路。我不会堆砌名词而是把每个环节为什么这么设计参数怎么算坑在哪里讲清楚你可以直接照着搭一套属于自己的AI工程骨架。2. 整体架构设计先想清楚数据怎么流再想模型怎么跑2.1 为什么我坚持薄平台、厚管道的思路市面上有很多一站式AI平台功能很全但我实际用下来小团队用它们往往适得其反。原因很简单平台把细节封装了出问题的时候你根本不知道是哪一层挂了。所以我更推荐薄平台、厚管道的思路——平台层只做最基础的资源调度和任务编排把数据管道、特征逻辑、推理服务这些核心环节用代码显式地写出来。具体来说整个体系我通常拆成五层数据层、特征层、训练层、服务层、监控层。数据层负责原始数据的采集和清洗特征层做特征工程和样本拼接训练层管实验追踪和模型版本服务层负责推理和扩缩容监控层盯数据漂移和模型衰减。这五层之间通过明确的接口通信任何一层出问题都能快速定位。这个设计的好处是你不需要一开始就上Kubernetes那一套。我早期项目就是一台带GPU的服务器加几个Docker容器跑起来的等业务量上来了再逐步替换组件。架构的可演进性比一开始就先进重要得多。2.2 一个容易被忽略的选型原则让数据可回溯我在做架构设计时有个硬性要求任何一次模型推理的结果都必须能回溯到它用的模型版本、特征版本和原始数据。听起来很基础但很多团队做不到。等线上出问题要排查的时候发现模型文件被覆盖了、特征逻辑改过了、原始数据被清理了那就只能干瞪眼。实现这个原则我一般会在每次训练产出的模型里嵌入一个元数据文件记录训练时间、数据快照ID、特征代码的git commit、超参数配置。推理服务加载模型时把这些元数据一起加载每次请求的日志里带上模型版本号。这样出问题的时候一条SQL就能查出是哪个版本在什么数据上出的错。提示元数据不要只存在数据库里一定要和模型文件放在一起。我踩过的坑是数据库迁移的时候元数据丢了模型文件还在结果完全不知道这个模型是用什么数据训的。2.3 从零搭建的目录结构参考我习惯用这样的目录结构来组织一个AI工程项目你可以直接参考ai-project/ ├── data/ # 原始数据与快照 │ ├── raw/ │ └── snapshots/ ├── features/ # 特征工程代码 │ ├── definitions/ │ └── pipelines/ ├── training/ # 训练相关 │ ├── configs/ │ ├── experiments/ │ └── artifacts/ # 模型产物 ├── serving/ # 推理服务 │ ├── api/ │ └── workers/ ├── monitoring/ # 监控与告警 └── scripts/ # 运维脚本这个结构的关键在于每一层都有独立的版本控制。特征代码改了要提交训练配置改了要提交模型产物用对象存储管理。不要把所有东西塞在一个大仓库里也不要全靠手动管理。3. 数据管道与特征工程AI工程里最脏最累但最值钱的活3.1 数据清洗的三遍原则和它的代价数据清洗这件事我的经验是至少要过三遍。第一遍做格式统一把各种来源的数据转成统一的schema第二遍做异常剔除把明显不合理的值处理掉第三遍做分布检查看清洗后的数据分布是否符合预期。但这里有个代价问题每多洗一遍数据量就少一截而且清洗逻辑本身可能引入偏差。我见过一个团队为了追求干净把大量长尾样本都过滤掉了结果模型在长尾场景上表现极差。所以我的建议是清洗规则要可配置、可回滚并且保留清洗前后的对比样本。每次调整清洗规则都要重新评估对下游模型的影响。具体操作上我会写一个清洗配置的YAML文件把每条规则和它的阈值都参数化。比如cleaning_rules: - name: remove_null_target enabled: true action: drop - name: clip_numeric_outliers enabled: true method: iqr multiplier: 3.0 - name: normalize_text enabled: true steps: [lowercase, strip_whitespace]这样调整规则不用改代码改配置就行而且每次清洗产出的数据快照都带着这份配置方便回溯。3.2 特征工程的版本管理比模型版本管理还重要很多人只重视模型版本管理忽略了特征版本。但实际线上出问题十次有七次是特征的问题。特征逻辑改了没同步、特征计算用了未来数据、训练和推理的特征处理不一致这些都是经典事故。我的做法是把特征定义和特征计算彻底分开。特征定义用声明式的方式写描述这个特征是什么、依赖哪些原始字段、用什么聚合方式特征计算则根据定义生成具体的计算代码。这样训练和推理用的是同一份定义从根上避免了不一致。举个具体的例子假设你要做一个用户近7天点击次数的特征# 特征定义 feature_def { name: user_click_count_7d, entity: user_id, dependencies: [click_events], aggregation: { type: count, window: 7d, filter: event_type click } }训练的时候这个定义会生成基于历史数据的批量计算逻辑推理的时候同一个定义会生成基于实时流的增量计算逻辑。两边逻辑一致只是执行引擎不同。注意时间窗口特征最容易出问题的是边界对齐。训练时用的是自然日窗口推理时用的是滑动窗口结果特征分布对不上。我的经验是训练和推理统一用滑动窗口并且窗口的起止时间要显式记录在特征值里。3.3 样本拼接别让标签泄露毁了你的模型样本拼接是把特征和标签对齐的过程这里最大的坑是标签泄露。所谓标签泄露就是你在特征里不小心用到了预测时点之后才能知道的信息。我举个真实的例子。之前做一个用户流失预测特征里有个最近一次登录距今天数。训练的时候这个特征是用当前时间算的但标签是未来30天是否流失。结果模型学到的其实是已经流失的用户最近登录天数大因为流失用户的这个特征天然就大。上线之后效果一塌糊涂。正确的做法是所有特征的计算时点必须严格早于标签的观察时点。实现上我会在样本拼接的时候强制加一个时间戳检查特征的计算时间必须小于标签的观察时间否则这条样本直接丢弃。这个检查会损失一些样本但能保证模型学到的是真实的预测信号。3.4 数据管道的容错设计数据管道跑在线上最怕的是某个环节挂了导致整个链路断掉。我的经验是每个环节都要有幂等性和断点续传能力。具体来说每个数据处理任务都要记录自己的处理进度比如处理到了哪个文件、哪一批数据。任务重启的时候从上次的进度继续而不是从头再来。同时每个任务的输出要写到临时位置全部成功后再原子性地移动到最终位置避免半成品数据被下游消费。这套机制听起来复杂但用简单的状态文件加文件系统操作就能实现。我早期项目就是用一个JSON文件记录进度配合文件重命名来实现原子性跑了大半年没出过数据一致性问题。4. 训练与实验管理让每一次实验都可复现4.1 实验追踪的最小可行方案实验追踪工具很多但我建议从最小的方案开始一个实验目录加一个配置文件。每次训练启动自动创建一个以时间戳和实验名命名的目录把配置文件、代码commit、训练日志、模型checkpoint全部放进去。这个方案的好处是零依赖、易理解、易迁移。等实验多了再考虑上MLflow或者Weights Biases这类工具。我见过太多团队一上来就搭重型实验平台结果平台本身成了维护负担实验反而没做几个。实验目录的结构我一般这样组织experiments/ └── 20240515_143022_user_churn_v3/ ├── config.yaml ├── git_commit.txt ├── train.log ├── metrics.json ├── checkpoints/ │ ├── epoch_1.pt │ └── best.pt └── artifacts/ └── feature_importance.json关键是metrics.json这个文件里面记录每个epoch的损失、准确率等指标。后续做实验对比的时候直接读这个文件就行不用去解析日志。4.2 超参数搜索网格搜索不是万能的超参数搜索我踩过的坑是盲目用网格搜索算力浪费严重。实际上大部分超参数对结果的影响很小真正重要的就那么几个。我的做法是先做敏感性分析再针对性搜索。具体步骤是先固定其他参数单独调整某一个参数看指标变化。如果指标对这个参数不敏感那就不用花力气搜了。对敏感的参数再用贝叶斯优化或者随机搜索来精细调。这样能把搜索空间缩小一个数量级。举个例子学习率和batch size通常是最敏感的两个参数而dropout rate和weight decay相对不敏感。所以我会先粗调学习率和batch size确定大致范围后再细调其他参数。4.3 模型版本管理别用文件名区分版本我见过太多团队用model_v1.pt、model_v2.pt这种方式管理模型版本结果版本一多就乱套了。正确的做法是用内容哈希作为版本标识。每次训练产出的模型计算它的文件哈希作为唯一版本号。同时维护一个版本索引文件记录每个版本对应的训练配置、数据快照、评估指标。这样不管模型文件怎么命名都能通过哈希找到对应的元数据。更进一步我会给每个模型打上标签比如production、staging、archived。推理服务只加载production标签的模型切换版本就是改标签不用改代码。4.4 训练中断与恢复的实操细节训练大模型动辄几天中断是常态。我的经验是checkpoint不要只存模型权重还要存优化器状态、学习率调度器状态、随机数生成器状态。只存权重的话恢复训练后学习率会重置随机性也会变导致训练曲线不连续。具体实现上PyTorch的state_dict要保存这几样checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), rng_state: torch.get_rng_state(), cuda_rng_state: torch.cuda.get_rng_state_all(), best_metric: best_metric, } torch.save(checkpoint, path)恢复的时候按相反顺序加载。这样即使训练中断恢复后也能无缝衔接。提示checkpoint的保存频率要权衡。太频繁影响训练速度太稀疏中断后损失大。我的经验是每N个step存一次N取一个epoch的step数的十分之一左右比较合适。5. 推理服务与性能优化让模型真正跑起来5.1 推理服务的三种形态和选择依据推理服务我一般分三种形态同步API、异步任务、批量离线。选择哪种取决于业务对延迟的要求和请求的形态。同步API适合实时性要求高的场景比如推荐、搜索延迟要求在几十毫秒以内。异步任务适合耗时较长的场景比如图像生成、文档处理用户能接受几秒到几分钟的等待。批量离线适合对时效性要求不高的场景比如每天跑一次的用户画像更新。我见过有人把批量离线任务硬做成同步API结果请求超时、资源占满。所以先想清楚业务场景再选服务形态这一步不能省。5.2 模型压缩量化、剪枝、蒸馏怎么选模型压缩是推理优化的核心手段但三种方法各有适用场景。量化是把浮点参数转成低精度表示比如FP32转INT8。优点是实现简单、加速明显缺点是可能损失精度。我一般先试量化因为它的投入产出比最高。PyTorch的torch.quantization就能做动态量化几乎不用改代码。剪枝是去掉模型中不重要的连接或通道。优点是能显著减小模型体积缺点是需要重新训练微调流程复杂。我一般只在模型体积是硬约束的时候才用。蒸馏是用大模型教小模型。优点是能保持较好的精度缺点是需要训练两个模型成本高。适合有充足算力、对精度要求高的场景。我的选择顺序是先量化不够再蒸馏最后才考虑剪枝。因为量化的工程成本最低剪枝的维护成本最高。5.3 批处理与动态批处理的参数计算推理服务的吞吐量很大程度上取决于批处理策略。静态批处理简单但延迟高动态批处理延迟低但实现复杂。动态批处理的核心参数是最大批大小和最大等待时间。这两个参数需要根据业务延迟要求和硬件能力来算。假设你的延迟要求是100毫秒模型单次推理耗时20毫秒那么最大等待时间可以设为80毫秒留20毫秒给推理。最大批大小则取决于显存用这个公式估算max_batch_size (可用显存 - 模型显存) / 单样本激活显存实际中还要留20%的余量避免OOM。我一般会先设一个保守值然后压测逐步调大找到吞吐量和延迟的平衡点。5.4 推理服务的监控指标推理服务上线后必须监控这几个指标QPS、P50/P95/P99延迟、错误率、GPU利用率、显存占用。其中P99延迟最能反映用户体验因为平均值会被大量快请求拉低。除了这些系统指标还要监控模型层面的指标比如输入特征的分布、输出结果的分布。如果输入分布突然偏移说明上游数据可能出了问题如果输出分布偏移说明模型可能不适应新数据了。我一般会把这些指标做成一个看板设置告警阈值。比如P99延迟超过200毫秒告警输入特征均值偏移超过3个标准差告警。这样能在用户投诉之前发现问题。6. 线上监控与迭代闭环模型上线只是开始6.1 数据漂移检测的实操方法数据漂移是模型衰减的主要原因。检测方法我常用两种统计检验和分布距离。统计检验用KS检验或者卡方检验判断当前数据和训练数据的分布是否有显著差异。分布距离用PSIPopulation Stability Index或者KL散度量化差异的大小。PSI的经验阈值是小于0.1说明分布稳定0.1到0.25说明有轻微漂移大于0.25说明漂移严重。实现上我会对每个重要特征定期计算PSI超过阈值就告警。同时保留漂移前后的样本对比方便分析原因。6.2 模型衰减的归因分析发现模型效果下降后要快速定位原因。我的归因框架分三层数据层、特征层、模型层。数据层看原始数据的分布和量级有没有变化特征层看特征的分布和缺失率有没有异常模型层看预测结果的分布和置信度有没有偏移。逐层排查一般能定位到问题所在。我遇到过一个案例模型AUC突然掉了5个点。排查发现是上游数据源改了字段格式导致某个关键特征全部变成了默认值。这种问题如果只看模型指标根本找不到原因必须结合数据监控。6.3 模型迭代的触发机制模型迭代不能靠拍脑袋要有明确的触发条件。我一般设三个触发条件定时触发、指标触发、事件触发。定时触发就是固定周期重训比如每周一次。指标触发是当监控指标超过阈值时自动触发重训。事件触发是当业务发生重大变化时手动触发比如大促、新功能上线。这三个条件配合使用既能保证模型定期更新又能在异常时快速响应。重训流程我一般做成自动化的从数据拉取到模型评估全自动人工只做最终审核。6.4 灰度发布与回滚机制新模型上线必须灰度。我的做法是按流量比例逐步放量先1%流量观察一天没问题放到10%再观察一天然后50%最后全量。每一步都对比新旧模型的核心指标如果新模型明显差于旧模型立即回滚。回滚机制要提前准备好不能等出问题再临时搭。我一般会保留最近三个版本的模型回滚就是改一下配置里的版本号重启服务即可。整个过程控制在5分钟以内。注意灰度发布的时候要确保同一用户的请求始终路由到同一个模型版本否则用户体验会不一致。实现上可以用用户ID做哈希按哈希值分流。7. 常见问题与排查技巧实录7.1 训练和推理结果不一致的排查清单这是最经典的问题我整理了一个排查清单按顺序检查排查项检查方法常见原因特征处理逻辑对比训练和推理的特征代码代码分支不同、版本不一致数据预处理对比输入数据的统计量归一化参数不同、缺失值处理不同模型加载检查加载的模型版本加载了错误的checkpoint计算精度对比FP32和FP16的结果推理用了低精度导致差异随机性检查是否有随机操作dropout未关闭、随机种子不同按这个清单走一遍90%的不一致问题都能定位。7.2 显存溢出的常见原因和解决显存溢出我遇到的原因主要有几个batch size太大、模型太大、中间激活值没释放、内存碎片。解决办法按优先级先减小batch size这是最快的然后检查有没有不必要的张量保留比如在循环里累积loss再考虑用梯度检查点或者混合精度训练最后才考虑换更大的卡或者模型并行。我一般会在训练脚本里加一个显存监控每个step打印当前显存占用这样能快速定位是哪个环节吃显存。7.3 推理延迟高的优化路径推理延迟高优化路径我一般按这个顺序先看是不是批处理没开再看模型能不能量化然后看有没有冗余计算最后才考虑换硬件。批处理没开是最常见的很多服务默认单样本推理开了批处理延迟能降一半。量化是第二有效的INT8量化通常能提速2到4倍。冗余计算比如重复的特征处理可以通过缓存来消除。换硬件是最后手段成本最高。7.4 数据管道断流的应急处理数据管道断流第一要务是保证推理服务不崩。我的做法是推理服务对特征缺失有降级策略如果实时特征拿不到就用最近一次的历史特征如果历史特征也没有就用默认值同时记录降级日志。降级策略要提前设计好不能等断流了再想。同时断流告警要足够灵敏我一般设5分钟无数据就告警这样能在影响扩大前介入。8. 我踩过的几个印象深刻的坑第一个坑是特征时间窗口的时区问题。训练数据用的是UTC时间推理服务用的是本地时间结果窗口错位了8小时模型效果直接崩了。后来统一用UTC并且在特征定义里显式标注时区才解决。第二个坑是模型文件的热更新。早期为了不停机更新模型直接覆盖模型文件结果推理服务加载到一半的文件直接崩溃。后来改成先写临时文件再原子性重命名才解决。第三个坑是监控指标的采样偏差。监控只采样了成功请求忽略了失败请求结果看到的延迟指标比实际好很多。后来改成全量采集才发现真实延迟高得多。这些坑的共同点是问题都不在模型本身而在工程细节。这也是为什么我一直强调AI工程的核心是工程不是AI。模型可以调但工程细节没做好再好的模型也跑不起来。最后分享一个小技巧每次上线新功能我都会先写一个故障演练脚本模拟各种异常情况看系统能不能正确处理。这个习惯帮我提前发现了很多问题比等线上出事再排查高效得多。
阅读完成 · 觉得有帮助?