1. 从零搭建AI工程体系,为什么我劝你别一上来就搞模型
"ai-engineering-from-scratch"这个标题,第一次看到的时候我以为是又一个教你调包的教程。点进去翻了翻,发现它讲的是从最底层开始,把AI工程化这件事从头捋一遍——不是从pip install transformers开始,而是从数据怎么进来、特征怎么存、模型怎么上线、推理怎么加速、监控怎么做,这一整条链路。
这个方向其实特别对。我见过太多团队,模型训得漂漂亮亮,F1刷到0.95,结果一上线就崩:QPS扛不住、延迟飙到几秒、特征对不上、模型版本回滚要半天。问题从来不在模型本身,而在工程。AI工程化这件事,说白了就是把实验室里的"能跑"变成生产环境里的"稳跑",中间隔着的不是算法,是工程。
这篇内容适合谁看?如果你是刚入行的算法工程师,只会写notebook不会写服务,那这篇能帮你补齐工程短板;如果你是后端工程师,被拉来做AI平台但不懂模型那套东西,这篇能帮你理解AI系统的特殊性;如果你是技术负责人,正在规划团队的AI基础设施,这篇能帮你理清哪些环节必须自建、哪些可以买。我不打算讲太玄的东西,就按一个真实项目从零到一的顺序,把每个环节的关键决策和踩坑点摊开说。
2. 整体架构设计:先想清楚数据怎么流,再想模型怎么放
2.1 为什么"从零"反而比"用现成"更难
很多人觉得从零搭建就是不用框架、不用云服务,什么都自己写。这是个误解。真正的"from scratch"是指你理解每一层的职责边界,知道什么时候该用现成的、什么时候必须自己控制。比如特征存储,你可以用Redis凑合,也可以用Feast,还可以自己写一套。选哪个不取决于哪个"高级",取决于你的特征更新频率、一致性要求和团队维护能力。
我自己的经验是,从零搭建AI工程体系,最难的不是写代码,是做减法。市面上工具太多了,每个都号称能解决问题,但你把十个工具拼在一起,维护成本是指数级上升的。所以第一步不是选工具,是画数据流图。从数据源到最终预测结果,中间经过哪些环节,每个环节的输入输出是什么,延迟要求是多少,一致性要求是什么。这张图画清楚了,工具选型自然就出来了。
2.2 分层架构的取舍逻辑
一个典型的AI工程体系,我会分成五层:数据层、特征层、训练层、服务层、监控层。这个分法不是教科书上的标准答案,是我在实际项目里觉得职责最清晰的切法。
数据层负责原始数据的采集、清洗、存储。这里的关键决策是批处理和流处理怎么分工。我的建议是,能用批处理解决的绝不上流处理。流处理虽然听起来酷,但运维复杂度高一个量级。只有那些对时效性要求极高的场景,比如实时风控、实时推荐,才值得上流处理。
特征层是AI系统区别于普通后端系统的地方。普通后端系统的"状态"就是数据库里的记录,AI系统的"状态"是特征向量。特征层要解决的核心问题是训练和推理的一致性——训练时用的特征和线上推理时用的特征,必须是同一套计算逻辑,否则就会出现训练服务偏差。这个问题我在三个项目里都遇到过,后面会详细讲怎么解。
训练层相对成熟,但要注意的是训练环境的可复现性。你今天用这个版本的PyTorch训了一个模型,三个月后想复现,发现依赖升级了、数据变了、随机种子没固定,那就抓瞎了。所以训练层的第一要务不是跑得快,是跑得可复现。
服务层是模型上线的地方。这里的关键指标是延迟、吞吐和资源利用率。一个模型能不能上线,不取决于它的准确率,取决于它在P99延迟约束下能不能扛住峰值QPS。
监控层是最容易被忽视的。很多人觉得模型上线就完事了,其实上线才是开始。数据漂移、概念漂移、特征异常、预测分布偏移,这些问题不监控就发现不了,等业务方找上门来就晚了。
2.3 一个最小可行架构的参考
如果你现在要从零开始搭,我建议先搭一个最小可行版本,跑通全链路,再逐步优化。最小版本可以长这样:
| 层级 | 最小方案 | 后续演进方向 |
|---|---|---|
| 数据层 | 定时脚本拉数据到对象存储 | 引入调度系统、数据质量检查 |
| 特征层 | Python函数统一计算 | 抽成特征平台、加缓存 |
| 训练层 | 单机脚本+配置文件 | 分布式训练、实验管理 |
| 服务层 | FastAPI包一层 | 模型服务器、动态批处理 |
| 监控层 | 日志+简单统计 | 漂移检测、自动告警 |
这个表的意思是,不要一上来就搞大而全。先把链路跑通,哪怕每个环节都很粗糙。跑通之后你才知道瓶颈在哪,才知道该优化什么。我见过太多团队,花三个月搭了一套"完美"的平台,结果业务需求变了,平台白搭。
3. 特征工程与数据管道:训练推理一致性是命门
3.1 特征计算的双轨制问题
特征工程里最经典的坑,就是训练和推理用了两套代码。训练的时候用Pandas做特征,推理的时候用Java重写一遍,两边逻辑稍微对不齐,线上效果就掉。这个问题有个专门的名字叫训练服务偏差(Training-Serving Skew)。
我踩过最惨的一次,是训练时对缺失值填了0,推理时忘了填,直接传了null进去,模型输出全是NaN。排查了一整天,最后发现是两行代码不一致。从那以后我就定了个规矩:特征计算逻辑只能有一份代码,训练和推理都调这一份。
具体怎么做?如果你的技术栈是Python,可以把特征计算逻辑抽成一个独立的包,训练脚本import它,推理服务也import它。如果推理服务是Java或Go,那就把特征计算做成一个独立的服务,训练和推理都通过RPC调它。后者的好处是语言无关,坏处是多了网络开销。对于延迟敏感的场景,可以在推理服务里嵌入一个轻量级的特征计算库,但逻辑必须从同一份定义生成。
3.2 特征存储的选型与实操
特征存储(Feature Store)这个概念被炒得很热,但不是所有团队都需要。我的判断标准是:如果你有超过三个模型共用同一批特征,或者特征更新频率高于每天一次,那就值得上特征存储。否则用Redis或数据库凑合就行。
如果决定上特征存储,Feast是个不错的起点。它的核心概念是feature view,把特征定义、数据源、实体绑定在一起。下面是一个简化的例子:
from feast import FeatureView, Field, FileSource from feast.types import Float32, Int64 driver_source = FileSource(path="data/driver_stats.parquet") driver_stats_fv = FeatureView( name="driver_hourly_stats", entities=["driver_id"], ttl=timedelta(hours=24), schema=[ Field(name="conv_rate", dtype=Float32), Field(name="avg_daily_trips", dtype=Int64), ], source=driver_source, )定义好之后,训练时用get_historical_features拿历史特征,推理时用get_online_features拿实时特征,两边逻辑自动一致。这个设计的好处是把一致性保证下沉到了框架层,不依赖工程师的自觉。
但Feast也不是银弹。它的在线存储默认用SQLite,生产环境要换成Redis或DynamoDB。离线存储用Parquet或BigQuery。这些配置项不少,第一次搭要花点时间。我的建议是先在一个小项目上试,跑通了再推广。
3.3 数据质量检查的实操要点
数据管道里另一个容易被忽视的环节是数据质量检查。原始数据出问题,后面全白搭。我一般会在数据入口做三层检查:
第一层是schema检查,字段类型、字段数量、必填字段是否齐全。这层用Great Expectations或Pydantic都能做。第二层是分布检查,关键字段的均值、方差、分位数是否在预期范围内。第三层是业务规则检查,比如年龄不能为负、订单金额不能超过某个上限。
注意:数据质量检查的阈值不要设得太死。我见过团队把阈值设成"均值波动不超过1%",结果每次大促都被告警淹没。阈值应该基于历史数据的分布来定,留出合理的波动空间。
检查发现问题之后怎么办?我的做法是分级处理。schema错误直接阻断管道,分布异常发告警但继续跑,业务规则违反记录到单独的表里人工审核。这样既不会因为小问题阻断整个流程,也不会让大问题溜过去。
4. 模型训练与版本管理:可复现比跑得快重要
4.1 训练环境的可复现性设计
训练环境可复现这件事,说起来简单做起来烦。你需要固定四样东西:代码版本、依赖版本、数据版本、随机种子。代码版本用Git commit hash,依赖版本用lock文件,数据版本用快照或哈希,随机种子在每个涉及随机性的地方都设上。
我一般会在训练脚本开头加这么一段:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意最后两行,deterministic=True会让cuDNN用确定性算法,benchmark=False会关掉自动调优。这两个设置会牺牲一点性能,但换来的是可复现性。如果你的训练任务对时间不敏感,建议打开。如果对时间敏感,至少要在实验阶段打开,确认结果稳定后再关掉跑最终版本。
数据版本这块,小数据可以直接存快照,大数据用DVC或LakeFS做版本管理。DVC的好处是和Git集成得好,dvc add之后数据文件的哈希会记录在Git里,切换commit就能切换数据版本。LakeFS更重一些,适合数据量特别大的场景。
4.2 实验管理与模型注册
实验管理工具我用过MLflow、Weights & Biases和TensorBoard。MLflow胜在开源、可自托管,适合对数据安全要求高的团队。W&B体验好、功能全,但数据要传到云端。TensorBoard最轻量,但只管可视化,不管实验追踪。
如果从零搭建,我建议先用MLflow。它的核心概念是experiment和run,每个run记录一组参数、指标和artifact。下面是一个典型用法:
import mlflow mlflow.set_experiment("my_model") with mlflow.start_run(): mlflow.log_param("learning_rate", 0.001) mlflow.log_param("batch_size", 64) for epoch in range(epochs): train_loss = train_one_epoch() mlflow.log_metric("train_loss", train_loss, step=epoch) mlflow.pytorch.log_model(model, "model")跑完之后在MLflow UI里就能看到所有实验的对比。这个工具最大的价值不是可视化,是让每个模型都有迹可循。三个月后你回头看,能知道当时用了什么参数、数据是什么版本、指标是多少。
模型注册是实验管理的下一步。当一个模型被验证有效、准备上线时,把它注册到模型注册表里,打上版本号,标记为production或staging。这样服务层加载模型时,只需要指定模型名和stage,不用关心具体的文件路径。MLflow Model Registry和SageMaker Model Registry都提供这个能力。
4.3 训练管道的编排
训练管道编排工具,Airflow、Kubeflow Pipelines、Metaflow各有拥趸。我的选择逻辑是:如果团队已经有Airflow在跑数据管道,那就用Airflow跑训练,别引入新工具。如果是从零开始,Kubeflow Pipelines对K8s原生支持好,Metaflow对Python工程师友好。
不管用哪个,训练管道要解决的核心问题是依赖管理和失败重试。一个典型的训练管道包括:数据拉取、特征计算、数据校验、模型训练、模型评估、模型注册。每一步都可能失败,失败后要能重试,重试要能从上一步的产物继续,而不是从头再来。
Airflow里用TaskFlow API可以比较优雅地表达这种依赖:
from airflow.decorators import dag, task @dag(schedule="@daily") def training_pipeline(): @task def fetch_data(): return "data_path" @task def compute_features(data_path): return "feature_path" @task def train_model(feature_path): return "model_path" data = fetch_data() features = compute_features(data) model = train_model(features) training_pipeline()这个写法比传统的Operator方式简洁很多,而且任务之间的数据传递是自动的。但要注意,Airflow的任务之间传递大数据不合适,应该传路径而不是传数据本身。
5. 模型服务与推理优化:延迟和吞吐的平衡术
5.1 服务框架的选型对比
模型服务框架,常见的有TorchServe、Triton Inference Server、TF Serving、BentoML,还有直接用FastAPI自己包的。选哪个取决于你的模型类型和性能要求。
| 框架 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| FastAPI自包 | 灵活、可控 | 性能优化要自己做 | 小规模、快速上线 |
| TorchServe | PyTorch原生 | 只支持PyTorch | 纯PyTorch团队 |
| Triton | 多框架、性能强 | 配置复杂 | 多框架、高性能要求 |
| BentoML | 开发体验好 | 生态相对小 | 快速迭代的团队 |
我自己的经验是,如果QPS在100以下,FastAPI自包完全够用,而且最灵活。如果QPS上千,或者要跑多个模型,Triton是更好的选择。Triton的动态批处理(Dynamic Batching)功能特别实用,能把多个小请求合并成一个大batch,GPU利用率能提升好几倍。
5.2 动态批处理的参数计算
动态批处理的核心参数是max_batch_size和max_queue_delay_microseconds。前者控制最大batch大小,后者控制等待时间。这两个参数怎么定?要看你的延迟预算和QPS。
假设你的P99延迟要求是100ms,模型单次推理耗时20ms(batch_size=1),那么留给排队的时间最多80ms。如果QPS是500,平均每2ms来一个请求,那么等待10ms能攒到5个请求。所以max_queue_delay_microseconds可以设成10000,max_batch_size设成8或16。
但这是理想情况。实际中请求不是均匀到达的,有峰值有低谷。所以参数要留余量,而且要在压测中验证。我一般会做一组压测,把不同参数组合下的P99延迟和吞吐画成曲线,选那个在延迟约束下吞吐最高的点。
提示:动态批处理对GPU模型效果明显,对CPU模型效果有限。因为CPU模型的瓶颈往往不在计算,而在内存带宽或IO。如果你的模型跑在CPU上,优先优化的是模型本身,比如量化、剪枝,而不是批处理。
5.3 模型量化的实操与坑
模型量化是推理优化的另一个大招。FP32转FP16能省一半显存,速度提升30%到50%,精度损失通常很小。INT8量化更激进,能省四分之三显存,速度提升2到4倍,但精度损失要看模型和校准数据。
PyTorch的量化分动态量化和静态量化。动态量化最简单,一行代码:
model_quantized = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 )但动态量化只对Linear层有效,而且推理时还是要动态计算量化参数,加速有限。静态量化需要校准数据,精度更好,加速更明显,但流程复杂:
model.eval() model.qconfig = torch.quantization.get_default_qconfig('fbgemm') model_prepared = torch.quantization.prepare(model) # 用校准数据跑一遍 for data in calibration_loader: model_prepared(data) model_quantized = torch.quantization.convert(model_prepared)校准数据的选择很关键。要用真实分布的数据,不能随便拿几条凑数。我一般会从验证集里随机抽100到500条,覆盖各种边界情况。校准完之后一定要在测试集上验证精度,如果掉点超过1%,就要考虑混合量化或者放弃量化。
5.4 服务层的弹性与降级
模型服务上线之后,要考虑弹性伸缩和降级策略。弹性伸缩用K8s的HPA就行,但要注意指标的选择。CPU利用率对模型服务来说往往不是好指标,因为GPU模型的CPU利用率可能一直很低。更好的指标是QPS或队列长度。
降级策略是很多人忽略的。当流量突增、模型服务扛不住时,要有兜底方案。最简单的降级是返回缓存结果或默认结果。复杂一点的可以准备一个轻量级模型,主模型扛不住时切到轻量模型。再复杂一点可以做请求采样,只对部分请求做推理,其余走规则。
我一般会在服务层加一个开关,通过配置中心控制。出问题时运维不用改代码,改个配置就能降级。这个开关平时是关的,但要有,而且要定期演练,确保真出问题时能生效。
6. 监控、漂移检测与持续迭代:上线只是开始
6.1 监控指标的四个层次
AI系统的监控比普通后端系统复杂,因为除了系统指标,还要监控数据指标和模型指标。我一般分四层:
第一层是基础设施指标,CPU、内存、GPU利用率、网络IO。这层用Prometheus加Node Exporter就能覆盖。第二层是服务指标,QPS、延迟分布、错误率、超时率。这层用Prometheus加应用埋点。第三层是数据指标,输入特征的分布、缺失率、异常值比例。第四层是模型指标,预测分布、置信度分布、业务指标(如点击率、转化率)。
这四层里,前两层是标配,后两层是AI系统特有的。很多团队只做前两层,结果模型效果掉了都不知道为什么。我的建议是至少要把第三层做起来,因为数据问题是最常见的。
6.2 数据漂移与概念漂移的检测
数据漂移(Data Drift)是指输入特征的分布发生了变化。概念漂移(Concept Drift)是指输入和输出之间的关系发生了变化。两者的检测方法不同。
数据漂移检测常用PSI(Population Stability Index)或KS检验。PSI的计算方式是:
def calculate_psi(expected, actual, buckets=10): def scale_range(input, min_val, max_val): input = np.clip(input, min_val, max_val) return (input - 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) def sub_psi(e_perc, a_perc): if a_perc == 0: a_perc = 0.0001 if e_perc == 0: e_perc = 0.0001 return (e_perc - a_perc) * np.log(e_perc / a_perc) psi_value = sum(sub_psi(expected_percents[i], actual_percents[i]) for i in range(len(expected_percents))) return psi_valuePSI小于0.1说明分布稳定,0.1到0.25说明有轻微漂移,大于0.25说明有显著漂移。这个阈值不是绝对的,要根据业务容忍度调整。
概念漂移检测更复杂,因为它需要标签。如果标签能及时拿到,可以监控模型指标的变化。如果标签延迟很大,可以用代理指标,比如预测分布的熵、置信度的均值。这些指标变化不一定意味着概念漂移,但值得警惕。
6.3 模型迭代的触发机制
模型什么时候该重新训练?常见的有三种触发方式:定时触发、指标触发、事件触发。
定时触发最简单,每周或每月重训一次。适合数据分布变化慢的场景。指标触发是当漂移指标超过阈值时触发重训。事件触发是当业务发生重大变化时手动触发,比如大促、政策调整。
我一般会组合使用。定时触发作为兜底,保证模型不会太旧。指标触发作为主要方式,及时响应分布变化。事件触发作为补充,应对突发情况。
重训之后不能直接上线,要走评估流程。评估包括离线指标和在线指标。离线指标看准确率、召回率、AUC这些。在线指标看A/B测试的结果。我一般会先跑一周的A/B测试,新模型流量占10%,观察业务指标有没有提升。确认没问题再逐步放量。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 线上效果远差于离线 | 训练服务偏差 | 对比训练和推理的特征值 | 统一特征计算逻辑 |
| 延迟突然升高 | 模型变大或流量突增 | 查看模型大小和QPS曲线 | 量化模型或扩容 |
| 预测分布偏移 | 数据漂移 | 计算PSI和KS | 重新训练模型 |
| 部分请求超时 | 长尾请求或资源竞争 | 查看延迟分布 | 加超时限制或隔离资源 |
| 模型加载失败 | 版本不兼容或文件损坏 | 检查模型文件和依赖版本 | 回滚到上一版本 |
| GPU利用率低 | batch太小或IO瓶颈 | 查看batch size和IO等待 | 调大batch或优化数据加载 |
这张表是我自己排查问题时总结的,不一定全面,但覆盖了大部分常见情况。排查的核心思路是先定位问题在哪一层,再深入具体原因。不要一上来就怀疑模型,大部分问题出在数据和工程上。
7. 一些踩坑之后的个人体会
从零搭建AI工程体系这件事,我做过三次,每次都有新的教训。第一次是低估了特征一致性的重要性,上线后效果掉了一半,排查了两周才发现是特征计算逻辑不一致。第二次是低估了监控的价值,模型跑了三个月效果慢慢变差,但没人发现,直到业务方投诉。第三次是低估了可复现性的成本,想复现半年前的一个实验,发现依赖升级了、数据变了,折腾了一周也没完全复现。
如果让我给正在从零搭建的团队一个建议,我会说:先把链路跑通,再优化每个环节。不要一上来就追求完美架构,不要一上来就引入一堆工具。跑通之后你会发现问题在哪,那时候再针对性优化,效率高得多。
还有一个体会是,AI工程化这件事,工程能力比算法能力更重要。我见过算法很强但工程很弱的团队,模型训得很好但上不了线。也见过算法一般但工程很强的团队,模型效果不是最好但业务价值很大。从零搭建AI工程体系,核心不是把模型做到极致,是把整个系统做到可靠、可维护、可迭代。
最后分享一个小技巧:在服务层加一个"影子模式",新模型上线前先让它跑在影子流量上,不返回结果但记录预测。对比影子预测和主模型预测的差异,如果差异在可接受范围内,再切流量。这个做法能大幅降低上线风险,我现在的项目都会加这个。