
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_colNone): 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(normalizeTrue).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, smoothing10): global_mean train_df[target].mean() kf KFold(n_splits5, shuffleTrue, random_state42) 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(..., ge0, le120) user_city: str Field(..., max_length50) event_hour: int Field(..., ge0, le23) class PredictResponse(BaseModel): score: float model_version: str latency_ms: float app.post(/predict, response_modelPredictResponse) 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_code500, detailstr(e)) latency (time.time() - start) * 1000 return PredictResponse(scorescore, model_versionMODEL_VERSION, latency_mslatency)这段代码看起来简单但包含了几个关键点Pydantic做输入校验、异常捕获、延迟统计、版本号返回。我建议再加一个/health接口返回模型加载状态和版本方便运维做健康检查。服务部署我一般用DockerDockerfile写清楚依赖和启动命令。别用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核数加1GPU推理的话worker数设成1因为多个worker会抢GPU。我见过有人设了8个worker结果GPU显存直接爆了。4. 实操全流程从零到上线的一次完整记录4.1 环境准备与依赖安装假设你在一台干净的Linux机器上从零开始。第一步是装Python环境。我强烈建议用conda或venv做环境隔离别用系统Python否则依赖冲突会让你怀疑人生。conda create -n ai-eng python3.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_colis_churn) print(report)跑完这个脚本你会看到每个字段的缺失率和异常值比例。如果某个字段缺失率超过50%考虑直接丢掉如果异常值比例超过10%考虑做截断或分箱。数据清洗我一般做几件事去掉重复行、填充或删除缺失值、处理异常值、统一时间格式。时间格式统一这件事特别重要我踩过坑上游数据源有的用UTC有的用本地时间导致时间特征全错。df df.drop_duplicates(subset[user_id]) df[register_time] pd.to_datetime(df[register_time], utcTrue) df[last_login_time] pd.to_datetime(df[last_login_time], utcTrue) df[days_since_login] (pd.Timestamp.now(tzUTC) - df[last_login_time]).dt.days df[consume_amount] df[consume_amount].clip(lower0, upperdf[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_size0.2, random_state42) 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_estimators200, max_depth5, learning_rate0.05) model.fit(train_features, train_df[is_churn]) val_pred model.predict_proba(val_features)[:, 1] print(fAUC: {roc_auc_score(val_df[is_churn], val_pred):.4f}) print(fF1: {f1_score(val_df[is_churn], (val_pred 0.5).astype(int)):.4f})训练完记录实验from src.train import log_experiment exp_id log_experiment( db_pathmodels/registry.db, data_versionv1.0, feature_configfeatures/feature_config.yaml, params{n_estimators: 200, max_depth: 5, learning_rate: 0.05}, metrics{auc: 0.852, f1: 0.731}, model_pathmodels/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(statussuccess).inc() return response app.get(/metrics) def metrics(): return Response(generate_latest(), media_typetext/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测试成了我的标配流程。