1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
"ai-engineering-from-scratch"这个标题,第一次看到的时候我愣了一下。市面上讲AI的文章铺天盖地,但绝大多数都在教你import torch然后跑个预训练模型,真正从工程角度把一套AI系统从零搭起来的内容,少得可怜。我自己在这个方向上摸爬滚打了几年,踩过的坑比跑通的模型多得多,所以想借这个标题,把"从零做AI工程"这件事掰开揉碎聊一聊。
先说清楚这个项目到底在做什么。它不是教你训练一个SOTA模型,也不是教你调参刷榜,而是解决一个更底层的问题:当你手上有一个真实的业务需求,需要把AI能力落地成一个能跑、能维护、能扩展的系统时,从零到一该怎么走。这中间涉及数据管道、特征工程、模型服务、监控告警、版本管理、成本控制等一整套工程问题,任何一个环节掉链子,整个系统就是空中楼阁。
适合谁看?如果你已经会写Python、了解基本的机器学习概念,但一到"把模型部署上线"就发怵,那这篇内容就是给你准备的。如果你是从后端或数据方向转过来做AI工程,同样适用。我不假设你有GPU集群,也不假设你有大厂的基础设施,所有方案都尽量贴近中小团队甚至个人开发者的真实条件。
我见过太多人一上来就pip install transformers,然后发现显存不够、推理太慢、接口不稳定、模型更新后线上直接崩。问题不在于调包本身,而在于跳过了工程化的思考过程。从零搭建的意义,不是让你重复造轮子,而是让你清楚每个轮子为什么这么造,出了问题知道去哪儿找。
2. 整体架构设计与技术选型的底层逻辑
2.1 为什么"从零"不等于"什么都自己写"
很多人对"from scratch"有误解,觉得从零就是不用任何现成框架,自己手写矩阵乘法。这是典型的用力过猛。真正的从零,是指你清楚地知道每一层的职责边界,知道什么时候该用现成组件,什么时候必须自己控制。
我一般把一套AI工程系统拆成五层:数据层、特征层、模型层、服务层、运维层。数据层负责原始数据的采集、清洗、存储;特征层做特征提取和转换;模型层管训练、评估、版本;服务层对外提供推理接口;运维层做监控、日志、告警。每一层都可以用现成工具,但层与层之间的接口必须由你自己定义清楚。
为什么强调接口定义?因为AI系统最大的痛点就是"模型换了,上下游全崩"。如果你在数据层和模型层之间没有约定好数据格式,模型团队换个特征顺序,服务层直接报错。我吃过这个亏,一个线上推荐模型因为特征列顺序变了,导致线上CTR直接掉了一半,排查了整整一天才发现是数据管道的问题。
2.2 技术选型的三个核心原则
选型这件事,我总结了三条原则,按优先级排序。
第一条是可调试性优先于性能。新手最容易犯的错就是追求极致性能,上来就上分布式、上GPU推理,结果出了问题连日志都看不懂。我建议初期用最简单的方案,比如模型服务就用FastAPI包一层,推理就用CPU,等真的遇到性能瓶颈再优化。可调试性意味着你出问题的时候能快速定位,这比省那点推理时间重要得多。
第二条是依赖最小化。每引入一个第三方库,你就多了一个潜在的故障点。我见过一个项目引入了十几个AI相关的库,结果其中一个库升级导致整个环境崩溃,回滚都回滚不了。我的做法是,核心链路只依赖最必要的库,边缘功能能自己写就自己写。
第三条是版本可追溯。数据版本、模型版本、代码版本,三者必须能对应上。我习惯用git commit hash加数据快照ID加模型文件hash组成一个三元组,任何一次线上推理都能追溯到当时用的是哪份数据、哪个模型、哪版代码。这个习惯帮我省了无数次扯皮。
2.3 一个最小可行架构长什么样
说具体点。一个最小可行的AI工程系统,我一般这样搭:
- 数据层:用SQLite或PostgreSQL存结构化数据,原始文件放本地磁盘或对象存储,用DVC做数据版本管理
- 特征层:用pandas或polars做特征处理,特征定义写成配置文件,避免硬编码
- 模型层:用scikit-learn或PyTorch训练,模型文件用joblib或torch.save保存,配一个模型注册表记录版本
- 服务层:FastAPI提供HTTP接口,Pydantic做请求校验,uvicorn做ASGI服务器
- 运维层:用logging模块打结构化日志,Prometheus做指标采集,Grafana做可视化
这套东西跑在一台4核8G的机器上绰绰有余,成本几乎为零。等业务量上来了,再把某一层替换成更重的方案,比如把SQLite换成ClickHouse,把CPU推理换成GPU推理。关键是,替换的时候其他层不用动,因为接口是稳定的。
提示:不要一开始就上Kubernetes。我见过太多团队为了"显得专业"上K8s,结果运维成本比业务开发还高。单机Docker Compose能解决90%的中小规模场景。
3. 核心模块的细节拆解与实操要点
3.1 数据管道:AI工程的地基
数据管道是整套系统里最不起眼但最要命的部分。模型效果不好,十有八九是数据问题,而不是模型问题。我处理数据管道的经验是:先做数据质量检查,再做特征工程。
数据质量检查包括几个维度:缺失率、异常值比例、分布偏移、重复率。我一般写一个data_quality_check.py脚本,每次数据更新后自动跑一遍,输出一份报告。如果某个特征的缺失率突然从5%涨到30%,那大概率是上游数据源出了问题,这时候不该急着训练模型,而该去查数据源。
import pandas as pd import numpy as np def quality_report(df, target_col=None): report = {} report['row_count'] = len(df) report['missing_rate'] = df.isnull().mean().to_dict() report['dtype'] = df.dtypes.astype(str).to_dict() numeric_cols = df.select_dtypes(include=[np.number]).columns report['outlier_rate'] = {} for col in numeric_cols: q1, q3 = df[col].quantile([0.25, 0.75]) iqr = q3 - q1 lower, upper = q1 - 1.5*iqr, q3 + 1.5*iqr outlier = ((df[col] < lower) | (df[col] > upper)).mean() report['outlier_rate'][col] = round(outlier, 4) if target_col: report['target_distribution'] = df[target_col].value_counts(normalize=True).to_dict() return report这个脚本看起来简单,但能帮你提前发现80%的数据问题。我踩过的坑是:有一次上游数据源把日期格式从YYYY-MM-DD改成了YYYY/MM/DD,导致时间特征全部解析失败,模型效果暴跌。如果当时有数据质量检查,这个问题在训练前就能发现。
数据版本管理我用DVC。每次数据更新,dvc add data/raw.csv,然后git commit,这样数据和代码的版本就绑定了。回滚的时候git checkout加dvc checkout,数据和代码一起回到历史版本。这个流程比手动复制文件靠谱一万倍。
3.2 特征工程:别让模型学它学不会的东西
特征工程的核心原则是:让模型学它擅长学的,把不擅长的部分人工处理掉。比如时间特征,模型很难从原始时间戳里学到"周末效应",但你把"是否周末"这个布尔特征显式加进去,模型立刻就能用上。
我一般把特征分成三类:数值特征、类别特征、时间特征。数值特征做标准化或归一化,类别特征做独热编码或目标编码,时间特征拆成年、月、日、星期、小时等。每一类特征的处理方式写成配置文件,避免硬编码。
# feature_config.yaml features: - name: user_age type: numeric transform: standard_scale - name: user_city type: categorical transform: target_encode smoothing: 10 - name: event_time type: datetime extract: [hour, weekday, is_weekend]用配置文件的好处是,特征定义和代码分离,改特征不用改代码,也方便做特征版本管理。我见过一个团队把特征处理逻辑写死在训练脚本里,结果服务层推理时用的特征和训练时不一致,线上效果直接崩了。这种问题用配置文件就能避免。
目标编码有个坑要注意:必须用交叉验证的方式计算,否则会数据泄露。简单说,如果你用全量数据算某个类别特征的均值,那训练集里这个特征就包含了标签信息,模型会过拟合。正确做法是分折计算,每一折用其他折的数据算编码值。
from sklearn.model_selection import KFold def target_encode_cv(train_df, val_df, col, target, smoothing=10): global_mean = train_df[target].mean() kf = KFold(n_splits=5, shuffle=True, random_state=42) train_encoded = np.zeros(len(train_df)) for tr_idx, va_idx in kf.split(train_df): tr, va = train_df.iloc[tr_idx], train_df.iloc[va_idx] agg = tr.groupby(col)[target].agg(['mean', 'count']) smooth = (agg['mean'] * agg['count'] + global_mean * smoothing) / (agg['count'] + smoothing) train_encoded[va_idx] = va[col].map(smooth).fillna(global_mean) agg_full = train_df.groupby(col)[target].agg(['mean', 'count']) smooth_full = (agg_full['mean'] * agg_full['count'] + global_mean * smoothing) / (agg_full['count'] + smoothing) val_encoded = val_df[col].map(smooth_full).fillna(global_mean) return train_encoded, val_encoded这段代码我用了很多次,实测下来很稳。smoothing参数控制平滑程度,类别样本少的时候往全局均值靠,样本多的时候用类别自己的均值。
3.3 模型训练与版本管理:别让"哪个模型最好"变成玄学
模型训练本身不是最难的,难的是管理一堆模型版本,知道哪个版本对应哪次实验。我的做法是每次训练都生成一个实验记录,包含:实验ID、数据版本、特征配置、超参数、评估指标、模型文件路径。这些记录存到一个SQLite表里,方便查询和对比。
import sqlite3 import json import hashlib from datetime import datetime def log_experiment(db_path, data_version, feature_config, params, metrics, model_path): conn = sqlite3.connect(db_path) exp_id = hashlib.md5(f"{datetime.now().isoformat()}{data_version}".encode()).hexdigest()[:12] conn.execute(""" INSERT INTO experiments (exp_id, timestamp, data_version, feature_config, params, metrics, model_path) VALUES (?, ?, ?, ?, ?, ?, ?) """, (exp_id, datetime.now().isoformat(), data_version, json.dumps(feature_config), json.dumps(params), json.dumps(metrics), model_path)) conn.commit() conn.close() return exp_id有了这个记录,任何时候你都能回答"线上这个模型是什么时候训练的、用的什么数据、指标多少"。我踩过的坑是:有一次线上模型效果下降,想回滚到上一个版本,结果发现上一个版本的模型文件被覆盖了,因为训练脚本用的是固定文件名。从那以后我强制要求模型文件名带时间戳和实验ID。
模型评估指标的选择也有讲究。分类问题别只看准确率,要看AUC、F1、召回率;回归问题别只看MSE,要看MAE、分位数误差。而且一定要看业务指标,比如推荐系统看CTR,风控系统看坏账率。我见过一个模型AUC从0.85涨到0.87,但线上CTR反而降了,因为AUC涨的部分都在长尾用户上,而长尾用户贡献的流量很少。
3.4 模型服务:从"能跑"到"跑得稳"
模型服务这块,我的经验是:接口设计比模型本身重要。一个设计糟糕的接口,会让上下游都痛苦。我一般遵循几个原则:
第一,请求和响应都用JSON,字段名用下划线命名,避免大小写混乱。第二,所有输入字段都要做校验,类型不对、范围不对直接返回400,别让脏数据进到模型里。第三,响应里带上模型版本号,方便排查问题。第四,加超时控制,模型推理超过阈值直接返回降级结果,别让请求堆积。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field import joblib import time app = FastAPI() model = joblib.load("models/model_v1.pkl") MODEL_VERSION = "v1.0.0" class PredictRequest(BaseModel): user_age: int = Field(..., ge=0, le=120) user_city: str = Field(..., max_length=50) event_hour: int = Field(..., ge=0, le=23) class PredictResponse(BaseModel): score: float model_version: str latency_ms: float @app.post("/predict", response_model=PredictResponse) def predict(req: PredictRequest): start = time.time() try: features = [[req.user_age, hash(req.user_city) % 1000, req.event_hour]] score = float(model.predict_proba(features)[0][1]) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) latency = (time.time() - start) * 1000 return PredictResponse(score=score, model_version=MODEL_VERSION, latency_ms=latency)这段代码看起来简单,但包含了几个关键点:Pydantic做输入校验、异常捕获、延迟统计、版本号返回。我建议再加一个/health接口,返回模型加载状态和版本,方便运维做健康检查。
服务部署我一般用Docker,Dockerfile写清楚依赖和启动命令。别用latest标签,每次构建打一个带日期的标签,方便回滚。
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]--workers 2这个参数要注意,worker数量不是越多越好。CPU推理的话,worker数一般设成CPU核数加1;GPU推理的话,worker数设成1,因为多个worker会抢GPU。我见过有人设了8个worker,结果GPU显存直接爆了。
4. 实操全流程:从零到上线的一次完整记录
4.1 环境准备与依赖安装
假设你在一台干净的Linux机器上,从零开始。第一步是装Python环境。我强烈建议用conda或venv做环境隔离,别用系统Python,否则依赖冲突会让你怀疑人生。
conda create -n ai-eng python=3.10 -y conda activate ai-eng pip install pandas numpy scikit-learn fastapi uvicorn pydantic joblib dvc prometheus-client这些依赖加起来不到500MB,装起来很快。注意版本,pandas建议2.0以上,scikit-learn建议1.3以上,FastAPI建议0.100以上。版本太老会有API不兼容的问题。
目录结构我一般这样组织:
ai-engineering-from-scratch/ ├── data/ │ ├── raw/ │ └── processed/ ├── features/ │ └── feature_config.yaml ├── models/ │ └── registry.db ├── src/ │ ├── data_pipeline.py │ ├── feature_engineering.py │ ├── train.py │ └── serve.py ├── tests/ ├── Dockerfile └── requirements.txt这个结构清晰,每一层职责明确。data/放数据,features/放特征配置,models/放模型文件和注册表,src/放代码,tests/放测试。
4.2 数据准备与质量检查
假设我们做一个用户流失预测任务。原始数据是一份CSV,包含用户ID、注册时间、最近登录时间、消费金额、是否流失等字段。
import pandas as pd df = pd.read_csv("data/raw/users.csv") print(f"原始数据量: {len(df)}") print(f"缺失率:\n{df.isnull().mean()}") # 数据质量检查 from src.data_pipeline import quality_report report = quality_report(df, target_col="is_churn") print(report)跑完这个脚本,你会看到每个字段的缺失率和异常值比例。如果某个字段缺失率超过50%,考虑直接丢掉;如果异常值比例超过10%,考虑做截断或分箱。
数据清洗我一般做几件事:去掉重复行、填充或删除缺失值、处理异常值、统一时间格式。时间格式统一这件事特别重要,我踩过坑,上游数据源有的用UTC有的用本地时间,导致时间特征全错。
df = df.drop_duplicates(subset=["user_id"]) df["register_time"] = pd.to_datetime(df["register_time"], utc=True) df["last_login_time"] = pd.to_datetime(df["last_login_time"], utc=True) df["days_since_login"] = (pd.Timestamp.now(tz="UTC") - df["last_login_time"]).dt.days df["consume_amount"] = df["consume_amount"].clip(lower=0, upper=df["consume_amount"].quantile(0.99))clip这个操作把异常值截断到99分位数,避免极端值影响模型。这个操作有争议,有人觉得会丢失信息,但实测下来对模型稳定性有帮助。
4.3 特征工程与训练
特征工程按前面说的配置文件方式做。时间特征拆出注册天数、最近登录天数、是否周末注册等。类别特征做目标编码。数值特征做标准化。
from src.feature_engineering import build_features train_df, val_df = train_test_split(df, test_size=0.2, random_state=42) train_features, val_features = build_features(train_df, val_df, "features/feature_config.yaml") from sklearn.ensemble import GradientBoostingClassifier from sklearn.metrics import roc_auc_score, f1_score model = GradientBoostingClassifier(n_estimators=200, max_depth=5, learning_rate=0.05) model.fit(train_features, train_df["is_churn"]) val_pred = model.predict_proba(val_features)[:, 1] print(f"AUC: {roc_auc_score(val_df['is_churn'], val_pred):.4f}") print(f"F1: {f1_score(val_df['is_churn'], (val_pred > 0.5).astype(int)):.4f}")训练完记录实验:
from src.train import log_experiment exp_id = log_experiment( db_path="models/registry.db", data_version="v1.0", feature_config="features/feature_config.yaml", params={"n_estimators": 200, "max_depth": 5, "learning_rate": 0.05}, metrics={"auc": 0.852, "f1": 0.731}, model_path="models/model_20240101.pkl" ) print(f"实验ID: {exp_id}")4.4 服务部署与监控
服务用FastAPI,前面已经写了代码。启动命令:
uvicorn src.serve:app --host 0.0.0.0 --port 8000 --workers 2监控这块,我用Prometheus的Python客户端打点。每次推理记录请求数、延迟、错误数。
from prometheus_client import Counter, Histogram, generate_latest from fastapi import Response REQUEST_COUNT = Counter("predict_requests_total", "Total predict requests", ["status"]) REQUEST_LATENCY = Histogram("predict_latency_seconds", "Predict latency") @app.post("/predict") def predict(req: PredictRequest): with REQUEST_LATENCY.time(): # ... 推理逻辑 REQUEST_COUNT.labels(status="success").inc() return response @app.get("/metrics") def metrics(): return Response(generate_latest(), media_type="text/plain")Prometheus抓取/metrics接口,Grafana做可视化。我一般配几个告警规则:错误率超过1%告警、P99延迟超过500ms告警、模型版本变化告警。这些规则能帮你在用户投诉之前发现问题。
5. 常见问题与排查技巧实录
5.1 模型效果突然下降怎么查
这是最常见也最头疼的问题。我的排查顺序是:先查数据,再查特征,最后查模型。
数据层面,对比线上推理时的输入数据和训练数据的分布。如果某个特征的均值偏移超过20%,那大概率是数据源变了。我一般写一个distribution_check.py脚本,每天跑一次,对比线上和训练数据的分布。
特征层面,检查特征计算逻辑有没有变。我踩过的坑是:有一次特征工程代码里用了pd.Timestamp.now(),导致每天的特征值都不一样,模型效果波动很大。后来改成用固定的基准时间,问题解决。
模型层面,检查模型文件有没有被覆盖、版本有没有搞错。我一般会在服务启动时打印模型版本和训练时间,方便排查。
5.2 推理延迟太高怎么优化
延迟优化有几个方向:模型层面、服务层面、硬件层面。
模型层面,可以换更轻量的模型,比如把GBDT换成逻辑回归,或者做模型蒸馏。也可以做特征裁剪,去掉重要性低的特征,减少计算量。
服务层面,可以加缓存,对相同的请求直接返回缓存结果。也可以做批处理,把多个请求攒一批一起推理,提高吞吐。还可以用异步接口,避免阻塞。
硬件层面,可以上GPU,但要注意GPU推理有启动开销,小模型用GPU可能反而更慢。我实测下来,参数量小于100万的模型,CPU推理比GPU快。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型效果下降 | 数据分布偏移 | 对比线上和训练数据分布 | 重新训练或做数据修正 |
| 推理延迟高 | 模型太大或特征太多 | 打点统计各阶段耗时 | 模型压缩或特征裁剪 |
| 服务崩溃 | 内存泄漏或并发过高 | 看日志和监控指标 | 加内存限制或限流 |
| 版本混乱 | 模型文件覆盖 | 检查模型文件命名 | 强制带时间戳和实验ID |
| 特征不一致 | 训练和推理逻辑不同 | 对比两边特征值 | 用统一配置文件 |
5.4 几个我踩过的坑
第一个坑是用pickle保存模型。pickle有个问题,它依赖Python版本和库版本,换个环境可能加载失败。我后来改用joblib,兼容性好一些,但最好的做法是保存模型参数而不是整个对象,加载时重新构建模型。
第二个坑是日志打太多。初期为了调试,我把每个请求的输入输出都打到日志里,结果日志文件一天涨了10个G,磁盘直接满了。后来改成只打关键信息,错误请求打详细日志,正常请求只打摘要。
第三个坑是没有做限流。有一次线上流量突增,服务直接被压垮,所有请求都超时。后来加了限流,超过阈值的请求直接返回降级结果,保证核心请求可用。
第四个坑是模型更新没有灰度。有一次直接全量更新模型,结果新模型有问题,线上效果暴跌。后来改成灰度发布,先放10%流量,观察一天没问题再全量。
提示:灰度发布这件事,小团队也要做。哪怕只是手动切10%流量,也比全量更新安全得多。
6. 后续扩展方向与个人经验
这套从零搭建的AI工程体系,跑起来之后可以往几个方向扩展。
第一个方向是自动化训练。用Airflow或Prefect做调度,每天自动拉数据、跑特征、训练模型、评估指标,指标达标就自动发布。这个方向能省大量人力,但要注意自动化不等于无人化,关键节点还是要人工审核。
第二个方向是特征平台。把特征定义、计算、存储统一管理,训练和推理共用一套特征逻辑。这个方向能彻底解决特征不一致的问题,但建设成本高,适合特征数量多、团队规模大的场景。
第三个方向是模型监控。除了基础的延迟、错误率,还要监控模型效果指标,比如AUC、CTR。一旦效果下降,自动告警甚至自动回滚。这个方向能让你在用户感知之前发现问题。
第四个方向是成本优化。统计每个模型的推理成本,找出成本高但效果提升有限的模型,做裁剪或替换。我见过一个团队,30%的推理成本花在一个只贡献5%效果提升的模型上,砍掉之后整体效果几乎没变。
我个人在实际操作中的体会是,AI工程这件事,工程能力比算法能力更重要。我见过太多算法很强但工程很弱的团队,模型在notebook里跑得飞起,一上线就各种问题。也见过算法一般但工程扎实的团队,模型效果稳步提升,系统稳定可靠。从零搭建的意义,就是让你把工程能力补起来,知道每个环节该怎么做、为什么这么做。
最后再分享一个小技巧:每次上线新模型,先跑一周的A/B测试。别急着全量,让新模型和老模型并行跑,对比业务指标。我吃过亏,有一次新模型离线指标很好,上线后业务指标反而降了,因为没有做A/B测试,直接全量,损失了一周的流量。从那以后,A/B测试成了我的标配流程。