1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
"ai-engineering-from-scratch"这个标题,第一次看到的时候我愣了一下。不是因为陌生,恰恰相反,是因为它戳中了我这几年带团队、做项目最痛的一个点:太多人把AI工程等同于"会调API"或者"会跑个demo",但真正落到生产环境里,从数据管道到模型服务、从特征管理到监控告警,中间那一大段"工程化"的活儿,几乎没人系统讲过。
我自己是从传统后端转过来的,最早做推荐系统那会儿,踩过的坑现在想起来还肉疼。模型离线AUC 0.85,上线之后线上效果直接腰斩;特征在训练脚本里算一遍、在服务端又算一遍,两边逻辑对不上,排查了整整三天;模型文件从实验室的pkl换成线上要用的格式,光是序列化兼容问题就折腾了一周。这些事儿,没有一件是"算法"问题,全是工程问题。
所以这篇内容,我想聊的不是某个具体模型怎么训,而是从零开始把一套AI工程体系搭起来这件事本身。它适合谁看?如果你是刚入行的算法工程师,只会写notebook不会写服务,这篇能帮你补上工程这一课;如果你是后端或者数据工程师,想转AI方向但不知道从哪下手,这篇能给你一条清晰的路径;如果你是技术负责人,正发愁团队里"模型能跑但上不了线",这篇里的很多坑你应该都似曾相识。
核心关键词就一个:ai-engineering-from-scratch,从零构建AI工程能力。我会按"整体设计思路 → 核心模块拆解 → 实操落地 → 问题排查"这条线来讲,每一块都尽量给到能直接抄作业的细节,而不是泛泛而谈。
2. 整体架构怎么设计:先想清楚数据流,再谈技术选型
2.1 为什么"从零"不等于"从轮子造起"
很多人对"from scratch"有个误解,以为是要自己手写一个TensorFlow。这就跑偏了。AI工程里的"从零",指的是从零搭建一套完整的工程链路,而不是从零实现底层框架。这个区别特别重要,因为它直接决定了你的技术选型策略。
我的原则是:底层框架和成熟组件直接用,业务链路的胶水层自己写。什么意思?PyTorch、TensorFlow、Spark、Kafka这些,人家几万人维护,你没必要重造;但数据怎么从Kafka流到特征库、特征怎么喂给模型、模型输出怎么回写业务库,这一整条链路的编排逻辑,必须你自己掌控,因为这是你业务的命脉,用现成的"一站式平台"往往会被绑死。
我见过太多团队一上来就买了个"AI中台",结果发现业务需求稍微一变,平台改不动,最后又退回自己搭。所以从零开始,反而是最灵活的路子。
2.2 一条完整的AI工程链路长什么样
抛开具体技术,任何一套AI工程体系,本质上都是这么一条数据流:
原始数据 → 数据清洗与校验 → 特征工程 → 特征存储 → 模型训练 → 模型评估 → 模型注册 → 在线服务 → 监控与回流
这条链路里,离线部分(清洗、特征、训练、评估)和在线部分(服务、监控)是两套完全不同的技术栈,但它们必须共享同一份"特征定义"和"模型契约",否则就会出现我前面说的"线上线下不一致"。
我一般会把整个体系拆成四个层次来设计:
| 层次 | 职责 | 典型组件 | 关键产出 |
|---|---|---|---|
| 数据层 | 采集、清洗、校验 | Kafka、Spark、Great Expectations | 干净的结构化数据 |
| 特征层 | 特征计算、存储、复用 | Spark、Feast、Redis | 特征视图与特征服务 |
| 模型层 | 训练、评估、注册 | PyTorch、MLflow、Airflow | 可追溯的模型版本 |
| 服务层 | 在线推理、监控、回流 | FastAPI、Triton、Prometheus | 稳定的推理接口 |
这四层里,特征层是最容易被忽视、但最要命的一层。很多团队数据层和模型层都做得不错,唯独特征层是"训练脚本里现算",结果就是线上线下两套逻辑,早晚出事。
2.3 技术选型背后的取舍逻辑
选型这事儿,我不喜欢给"标准答案",因为每个团队的规模、人力、业务节奏都不一样。但我可以给你几条我踩过坑之后总结的判断标准。
第一,优先选"能被替换"的组件。比如特征存储,Feast和自研Redis方案,我倾向先用Feast,因为它有标准接口,将来想换底层存储不用改业务代码。反过来,如果你一上来就深度绑定某个商业平台,迁移成本会高到你想哭。
第二,离线在线尽量用同一套计算引擎。如果你的离线特征用Spark算,在线特征用Python算,那两套逻辑必然会有细微差异(浮点精度、空值处理、时间窗口边界)。我的做法是:核心特征用同一份SQL或同一份Python函数定义,离线批量跑、在线单条跑,从源头保证一致性。
第三,别过早引入"重"组件。团队就三五个人,业务量也不大,非要上Kubernetes + Triton + 特征平台全家桶,运维成本能把你拖垮。我一般建议:日请求量百万级以下,FastAPI + Redis + 单机模型服务就够了,等真扛不住了再升级。
3. 核心模块拆解:数据、特征、模型、服务四件套
3.1 数据层:校验比清洗更重要
数据层最容易犯的错,是把精力全花在"清洗"上,却忽略了"校验"。清洗是修数据,校验是发现数据什么时候坏了。生产环境里,数据源突然改字段、上游任务延迟、编码格式变化,这些都会让你的模型悄悄失效,而你还蒙在鼓里。
我的做法是在数据入口加一道数据契约校验。用Great Expectations或者自己写一套简单的规则引擎,对每一批进来的数据做检查:
# 一个极简的数据校验示例,实际项目里我会用GE或pandera import pandas as pd def validate_batch(df: pd.DataFrame) -> list: errors = [] # 1. 关键字段不能为空 for col in ["user_id", "item_id", "event_time"]: if df[col].isnull().any(): errors.append(f"{col} 存在空值") # 2. 数值范围检查 if (df["price"] < 0).any(): errors.append("price 出现负值") # 3. 时间不能是未来 if (pd.to_datetime(df["event_time"]) > pd.Timestamp.now()).any(): errors.append("event_time 出现未来时间") return errors这段代码看着简单,但它救过我很多次。有一次上游把价格单位从"元"改成了"分",数值直接放大100倍,就是靠范围校验第一时间发现的。校验规则要跟着业务走,每加一个字段就加一条规则,别嫌麻烦。
注意:校验失败不要直接丢弃数据,而是告警 + 落盘到隔离区。丢弃会让你丢失现场,事后根本查不出问题出在哪。
3.2 特征层:一致性是命根子
特征层的核心矛盾就一个:离线算的特征和在线算的特征,必须一模一样。这个"一模一样"包括:同样的输入、同样的计算逻辑、同样的空值处理、同样的时间窗口。
我推荐的做法是特征定义即代码。把每个特征写成一个纯函数,离线批量调用、在线单条调用,共用同一份实现:
# features/user_features.py def avg_order_amount_7d(orders: list) -> float: """近7天平均订单金额,离线和在线共用""" if not orders: return 0.0 # 空值统一返回0,别用None,否则线上线下行为不一致 valid = [o["amount"] for o in orders if o["amount"] is not None] if not valid: return 0.0 return sum(valid) / len(valid)离线跑的时候,你把一个用户近7天的订单列表传进去;在线跑的时候,你从Redis里取出这个列表再传进去。逻辑只有一份,就不会有偏差。
特征存储这块,小团队我建议直接用Redis + 一张MySQL元数据表。Redis存特征值(key是feature:user_id:feature_name),MySQL存特征的元信息(谁定义的、什么类型、更新频率)。等特征数量上千、团队多人协作了,再考虑上Feast这类专业平台。
3.3 模型层:可追溯比高精度更重要
模型层我见过最混乱的场景是:线上跑着一个模型,但没人知道它是哪天训的、用的哪份数据、参数是什么。出了问题想回滚,发现旧模型文件找不到了。这就是典型的缺乏模型管理。
我的铁律是:每一个上线的模型,都必须能回答三个问题——用哪份数据训的、用什么代码训的、评估指标是多少。这三个问题答不上来,就不许上线。
实现上,MLflow是性价比最高的选择。它帮你记录每次实验的参数、指标、产物,还能做模型注册。一个典型的训练脚本长这样:
import mlflow import mlflow.pytorch mlflow.set_experiment("recommendation_model") with mlflow.start_run(): # 记录超参数 mlflow.log_params({"lr": 0.001, "batch_size": 256, "epochs": 10}) # 记录数据版本 mlflow.log_param("data_version", "2024-01-15") # 训练... # 记录指标 mlflow.log_metrics({"auc": 0.85, "logloss": 0.32}) # 保存模型 mlflow.pytorch.log_model(model, "model")跑完之后,MLflow的UI里能清楚看到每次实验的对比。模型注册环节,我会给通过评估的模型打上staging标签,人工验证后再转production,绝不自动上线。
3.4 服务层:接口设计决定后期维护成本
服务层是模型和业务之间的桥梁,它的设计好坏,直接决定你后期改需求时是"改一行"还是"改一周"。
我的接口设计原则有三条:
第一,请求和响应都用明确的schema。别用裸dict,用Pydantic定义清楚:
from pydantic import BaseModel from typing import List class PredictRequest(BaseModel): user_id: str item_ids: List[str] context: dict = {} class PredictResponse(BaseModel): scores: List[float] model_version: str第二,响应里必须带模型版本号。这样出问题时你能立刻定位是哪个版本,也方便做A/B测试。
第三,推理逻辑和业务逻辑分离。服务层只负责"取特征 → 调模型 → 返回分数",至于这个分数怎么用、要不要过滤、要不要排序,交给业务层。这样模型迭代时,业务代码不用动。
4. 实操落地:从零跑通一条最小可用链路
4.1 环境准备与目录结构
理论讲再多,不如跑一遍。我给你一条最小可用链路,一台4核8G的机器就能跑起来。先看目录结构,这个结构我用了好几年,清晰且好扩展:
ai-engineering/ ├── data/ # 数据相关 │ ├── ingest.py # 数据接入 │ └── validate.py # 数据校验 ├── features/ # 特征定义(离线在线共用) │ └── user_features.py ├── training/ # 训练 │ └── train.py ├── serving/ # 服务 │ ├── app.py │ └── feature_client.py ├── configs/ # 配置 │ └── config.yaml └── requirements.txt依赖就几个核心的:pandas、scikit-learn、fastapi、uvicorn、redis、mlflow、pyyaml。别一上来就装一堆,够用就行。
4.2 数据接入与校验的实操
假设我们的场景是"用户下单预测",数据从CSV来(真实场景换成Kafka消费即可)。接入脚本的核心是幂等——同一批数据重复跑,结果不能变。
# data/ingest.py import pandas as pd from data.validate import validate_batch def ingest(file_path: str) -> pd.DataFrame: df = pd.read_csv(file_path) # 去重,保证幂等 df = df.drop_duplicates(subset=["order_id"]) # 校验 errors = validate_batch(df) if errors: # 落盘隔离区,别丢 df.to_parquet(f"data/quarantine/{pd.Timestamp.now().date()}.parquet") raise ValueError(f"数据校验失败: {errors}") return df这里有个细节:去重放在校验之前。因为重复数据本身可能触发校验规则(比如同一订单出现两次),先去重能减少误报。这个顺序我调过好几次才定下来。
4.3 特征计算与存储的实操
特征计算我坚持"一份逻辑两处调用"。先定义特征函数,然后写两个入口:一个批量算(离线),一个单条算(在线)。
# features/user_features.py def compute_user_features(orders: list) -> dict: """输入用户订单列表,输出特征字典""" amounts = [o["amount"] for o in orders if o.get("amount") is not None] return { "order_count_7d": len(orders), "avg_amount_7d": sum(amounts) / len(amounts) if amounts else 0.0, "max_amount_7d": max(amounts) if amounts else 0.0, }离线批量算完,写进Redis:
import redis, json r = redis.Redis(host="localhost", port=6379) def save_features(user_id: str, features: dict): r.set(f"feature:{user_id}", json.dumps(features), ex=86400) # 24小时过期在线取的时候,直接从Redis读,读不到就返回默认值(千万别现场去数据库捞,会拖垮服务):
def get_features(user_id: str) -> dict: raw = r.get(f"feature:{user_id}") if raw is None: return {"order_count_7d": 0, "avg_amount_7d": 0.0, "max_amount_7d": 0.0} return json.loads(raw)4.4 训练与模型注册的实操
训练脚本的关键是可复现。固定随机种子、记录数据版本、记录代码commit,这三样缺一不可。
# training/train.py import numpy as np, mlflow from sklearn.ensemble import GradientBoostingClassifier from sklearn.metrics import roc_auc_score np.random.seed(42) # 固定种子 def train(X_train, y_train, X_val, y_val): with mlflow.start_run(): mlflow.log_param("data_version", "2024-01-15") model = GradientBoostingClassifier(n_estimators=100, max_depth=5) model.fit(X_train, y_train) auc = roc_auc_score(y_val, model.predict_proba(X_val)[:, 1]) mlflow.log_metric("auc", auc) mlflow.sklearn.log_model(model, "model") return model, auc跑完之后,去MLflow UI里看结果。AUC达标(比如>0.75)才进入注册环节,注册时打上版本号,人工确认后转生产。
4.5 在线服务的实操
服务层用FastAPI,核心是启动时加载模型,请求时只做推理,别每次请求都重新加载模型。
# serving/app.py from fastapi import FastAPI from pydantic import BaseModel from typing import List import mlflow.pyfunc app = FastAPI() model = None @app.on_event("startup") def load_model(): global model # 从模型注册中心加载生产版本 model = mlflow.pyfunc.load_model("models:/recommendation_model/production") class PredictRequest(BaseModel): user_id: str item_ids: List[str] @app.post("/predict") def predict(req: PredictRequest): features = get_features(req.user_id) # 构造模型输入... scores = model.predict(...) return {"scores": scores.tolist(), "model_version": "v1.2.0"}启动命令就一句:uvicorn serving.app:app --host 0.0.0.0 --port 8000。压测一下,单机QPS跑到几百没问题,小业务完全够用。
5. 常见问题与排查技巧实录
5.1 线上线下效果不一致,怎么查
这是最高频的问题,没有之一。排查思路我总结成一张表:
| 排查方向 | 具体检查点 | 常见原因 |
|---|---|---|
| 特征一致性 | 同一用户离线在线特征值是否相同 | 空值处理不同、时间窗口边界不同 |
| 数据分布 | 线上请求的特征分布 vs 训练集分布 | 训练数据过时、线上新用户多 |
| 模型加载 | 线上加载的是不是最新版本 | 版本号没更新、缓存未刷新 |
| 预处理 | 归一化/编码逻辑是否一致 | 离线用了fit的参数,在线重新fit了 |
我的排查顺序是:先比对特征,再比对分布,最后查模型版本。90%的问题出在特征上。具体做法是:挑10个线上请求,把它们的特征值dump出来,和离线算的对比,一眼就能看出差异。
5.2 模型服务内存泄漏怎么办
服务跑几天内存就涨满,重启才好,这是典型的内存泄漏。常见原因有三个:
- 模型对象被反复加载:检查是不是每次请求都
load_model了,必须放startup里。 - 缓存无上限:本地缓存(比如LRU)没设maxsize,请求多了就爆。
- 特征对象没释放:大对象用完及时
del,别指望GC。
我一般会在服务里加一个/health接口,返回当前内存占用,配合Prometheus做监控,涨到阈值就告警。
5.3 特征更新延迟导致预测偏差
特征不是实时更新的,比如你每小时批量刷一次Redis,那这一小时内的新行为就反映不到特征里。这个延迟对某些业务(比如实时推荐)是致命的。
解决办法有两个:一是缩短批量刷新周期(比如改成5分钟一次),二是对关键特征做实时更新(用户下单后立刻更新该用户的特征)。我一般对"最近一次行为"这类特征做实时更新,对"7天统计"这类做批量更新,两者结合。
提示:实时更新特征时,注意并发写问题。多个请求同时更新同一用户特征,要用Redis的原子操作或者加锁,否则会丢更新。
5.4 模型回滚的正确姿势
模型上线后发现效果不好,要回滚。这时候如果没做版本管理,就只能干瞪眼。我的做法是:每次上线都保留上一个生产版本,回滚就是改一个指针。
在MLflow里,把production标签从新版本移回旧版本,服务端监听标签变化(或者定时拉取),自动加载旧模型。整个过程不超过1分钟。千万别用"重新训练一个旧模型"来回滚,那既慢又不可靠。
5.5 几个我踩过的独家坑
坑一:时间窗口用"自然日"还是"滚动24小时"。离线训练时我用的是自然日(今天0点到昨天0点),在线却用了滚动24小时,结果特征对不上。后来统一成滚动窗口,问题消失。时间窗口的定义必须写进特征文档,全团队统一。
坑二:浮点数精度。离线用float64算,在线用float32存Redis,取出来再算,结果有微小差异。对大多数模型无所谓,但对某些敏感模型(比如排序分数卡阈值)就会出问题。统一用float32,或者干脆存字符串。
坑三:空值语义。离线把"没有订单"处理成0,在线却处理成None,模型输入直接报错。空值的处理方式必须和特征定义绑死,写在一个函数里。
6. 后续可以怎么扩展这套体系
跑通最小链路之后,这套体系还有很多可以往上加的东西。我按优先级给你排个序,你可以根据自己的业务节奏来。
第一优先级:加监控。模型上线不是终点,是起点。至少要监控三个指标:请求量、延迟、预测分布。预测分布尤其重要,如果某天分布突然偏移,说明上游数据或业务变了,模型可能已经失效。用Prometheus + Grafana,半天就能搭起来。
第二优先级:加A/B测试。新模型别直接全量,先切5%流量,对比核心业务指标。A/B测试的框架可以很简单:请求进来时按user_id哈希分流,不同流量走不同模型版本,结果分别打点。
第三优先级:加自动化训练。用Airflow或者简单的cron,定期(比如每天)拉新数据、重训模型、自动评估,达标就推到staging。这一步能把你从"手动炼丹"里解放出来。
第四优先级:特征平台化。当特征数量超过几百个、多个团队共用时,就该上Feast这类专业平台了。但记住,平台是为人服务的,别为了平台而平台,小团队用Redis + 元数据表完全够用很久。
我个人在实际操作中的体会是:AI工程最难的不是某个技术点,而是"一致性"和"可追溯"这两件事。把这两件事做好,你的系统就能稳定运行;做不好,再牛的模型也白搭。从零搭建的过程,其实就是不断和"不一致"作斗争的过程。每次你觉得"这里应该没问题吧",往往就是问题所在。多写校验、多留日志、多做对比,这三件事看起来笨,但最管用。