
1. 从零搭建AI工程体系为什么我劝你别一上来就搞模型“ai-engineering-from-scratch”这个标题我第一次看到的时候心里咯噔了一下。因为过去两年多我面试过不下五十个想转AI工程方向的候选人也带过几个从算法岗转过来的同事发现一个特别普遍的现象大部分人一提到“从零做AI工程”脑子里第一反应就是“我要训练一个模型”。然后就开始折腾数据集、调参、买显卡、跑训练脚本最后卡在部署上模型在notebook里跑得挺好一上线就各种问题。这个项目标题的核心其实不是“AI”而是“engineering”和“from scratch”。它真正要解决的问题是当你手里还没有成熟的基础设施、没有现成的MLOps平台、没有大厂那套工具链的时候怎么用最小的成本把一个AI能力从想法变成能稳定跑在线上、能被人用起来的东西。适合谁看适合那些有基本编程能力、懂一点Python、但对AI工程全貌没有系统认知的开发者也适合那些已经在做AI相关开发、但总觉得自己的项目“差点意思”的工程师。我自己走过这条路。最早做AI项目的时候我也是一个notebook打天下模型训练完导出pkl文件写个Flask接口就上线了。结果第一次遇到线上流量波动服务直接挂了因为模型加载占满了内存。那次之后我才意识到AI工程和普通后端工程最大的区别在于它的不确定性更高资源消耗更大而且模型本身是会“退化”的。所以“from scratch”搭建AI工程体系核心不是把模型做得多牛而是把整个链路做得足够健壮、可观测、可迭代。这篇文章我会按照我自己实际踩坑的顺序把从零搭建AI工程体系的完整思路拆开讲。包括整体架构怎么设计、核心模块怎么实现、实操过程中会遇到哪些坑、以及怎么排查问题。每一部分我都会给出具体的方案和参数你直接抄作业就行。但更重要的是我会告诉你为什么这么选这样你遇到类似场景的时候能自己判断。2. 整体架构设计与技术选型思路2.1 为什么我选择“轻量级分层架构”而不是“全家桶”很多人一上来就想上Kubeflow、MLflow、Feast这一整套觉得这样才叫“工程化”。我试过在一个只有三个人的小团队里光维护这套基础设施就占了60%的精力真正花在业务上的时间反而少了。所以我的建议是从零开始的时候架构要分层但每一层都用最轻量的方案能跑起来再逐步替换。我采用的是一种四层结构数据层、模型层、服务层、监控层。每一层之间通过明确的接口通信这样你后面想换掉任何一层都不会影响其他层。比如数据层我一开始就是用本地文件系统加Pandas后来数据量大了换成MinIO加PyArrow接口没变上层代码一行不用改。具体来说数据层负责数据的采集、清洗、版本管理。模型层负责训练、评估、版本管理。服务层负责把模型包装成API处理请求和响应。监控层负责收集指标、日志、追踪。这四层听起来简单但真正落地的时候每一层都有很多细节要注意。提示不要一开始就追求“全自动”。我见过太多项目死在“自动化”上因为自动化本身也是需要维护的。先手动跑通全流程再把重复的部分自动化。2.2 技术选型的核心原则可替换性大于性能选型的时候我给自己定了一个规矩任何一个组件如果替换成本超过两天就不选它。这个原则帮我避开了很多坑。比如模型服务框架我一开始用的是FastAPI后来换成BentoML再后来换成Triton每次替换都没超过一天因为接口都是标准的HTTP。具体选型上数据层我用的是DVC加本地存储后来换成MinIO加DVC。DVC的好处是它和Git集成得很好数据版本和代码版本能对应上。模型层我用的是PyTorch Lightning加OptunaLightning帮你处理训练循环Optuna帮你调参这两个加起来能省掉很多样板代码。服务层我用的是FastAPI加Uvicorn轻量、异步、性能足够。监控层我用的是Prometheus加Grafana加Loki这套组合是事实标准社区支持好遇到问题容易找到答案。这里重点说一下为什么不用Kubernetes。K8s确实强大但对于从零开始的项目来说它的学习曲线太陡了。我建议先用Docker Compose等你的服务超过五个、或者需要动态扩缩容的时候再考虑上K8s。Docker Compose的配置文件写起来直观调试也方便本地开发和生产环境能保持一致。2.3 目录结构设计让代码自己说话一个清晰的目录结构能省掉很多沟通成本。我用的结构是这样的ai-engineering-from-scratch/ ├── data/ │ ├── raw/ # 原始数据只读 │ ├── processed/ # 清洗后的数据 │ └── features/ # 特征工程后的数据 ├── models/ │ ├── trained/ # 训练好的模型文件 │ └── configs/ # 模型配置文件 ├── src/ │ ├── data/ # 数据处理代码 │ ├── features/ # 特征工程代码 │ ├── models/ # 模型定义和训练代码 │ ├── serving/ # 服务层代码 │ └── monitoring/ # 监控代码 ├── tests/ # 测试代码 ├── notebooks/ # 探索性分析 ├── docker/ # Docker相关文件 ├── configs/ # 全局配置 └── scripts/ # 运维脚本这个结构的关键在于数据和代码分离训练和服务分离。data目录下的文件不进Git用DVC管理。models目录下的模型文件也不进Git用版本号管理。src目录下的代码进Git每个模块有明确的职责。这样新人进来看一眼目录就知道东西在哪。3. 核心模块实现与关键细节3.1 数据层版本管理比清洗更重要数据层最容易犯的错误是只关注清洗不关注版本。我早期做项目的时候数据清洗完就直接覆盖原文件结果后来想复现一个实验结果发现数据已经变了根本复现不出来。从那以后我强制自己用DVC管理数据版本。DVC的基本用法很简单# 初始化DVC dvc init # 添加数据文件到DVC管理 dvc add data/raw/dataset.csv # 提交DVC文件到Git git add data/raw/dataset.csv.dvc data/raw/.gitignore git commit -m add raw dataset这样每次数据变更都会生成一个新的.dvc文件和Git commit对应。你想回到某个版本的数据只需要checkout对应的commit然后dvc checkout就行。数据清洗的代码我放在src/data/目录下每个清洗步骤写成一个函数用函数式编程的风格避免副作用。比如def remove_duplicates(df: pd.DataFrame) - pd.DataFrame: 去除重复行 return df.drop_duplicates() def fill_missing_values(df: pd.DataFrame, strategy: str mean) - pd.DataFrame: 填充缺失值 if strategy mean: return df.fillna(df.mean()) elif strategy median: return df.fillna(df.median()) else: raise ValueError(fUnknown strategy: {strategy})这样做的好处是每个步骤都可以单独测试而且可以灵活组合。我一般会写一个pipeline.py把清洗步骤串起来def clean_data(raw_df: pd.DataFrame) - pd.DataFrame: df remove_duplicates(raw_df) df fill_missing_values(df, strategymedian) df normalize_columns(df) return df注意数据清洗的顺序很重要。我一般先处理缺失值再处理异常值最后做归一化。因为异常值会影响均值和方差的计算如果先归一化再处理异常值归一化的参数就不准了。3.2 模型层训练脚本要能“一键复现”模型层的核心要求是可复现。我见过太多项目训练脚本跑一次就再也跑不出同样的结果了。原因通常是随机种子没固定、依赖版本没锁定、数据版本没记录。我的做法是每个训练实验都生成一个唯一的实验ID把所有相关信息都记录在这个ID下面。具体实现上我用的是Hydra加MLflow。Hydra管理配置MLflow记录实验。配置文件放在configs/目录下用YAML格式# configs/train_config.yaml model: name: resnet18 num_classes: 10 learning_rate: 0.001 batch_size: 32 data: path: data/processed/ train_split: 0.8 val_split: 0.2 training: epochs: 50 early_stopping_patience: 5 seed: 42训练脚本用Hydra装饰器import hydra from omegaconf import DictConfig import mlflow hydra.main(config_path../configs, config_nametrain_config) def train(cfg: DictConfig): # 固定随机种子 set_seed(cfg.training.seed) # 加载数据 train_loader, val_loader load_data(cfg.data) # 初始化模型 model create_model(cfg.model) # 训练 with mlflow.start_run(): mlflow.log_params(cfg) trainer Trainer(max_epochscfg.training.epochs) trainer.fit(model, train_loader, val_loader) mlflow.log_metrics({val_loss: trainer.callback_metrics[val_loss]}) mlflow.pytorch.log_model(model, model)这样每次训练都会在MLflow里生成一条记录包含所有参数、指标和模型文件。你想复现哪个实验直接点进去看参数就行。参数计算方面学习率和batch size的关系需要特别注意。我一般遵循线性缩放规则如果batch size翻倍学习率也翻倍。比如batch size是32的时候学习率是0.001那么batch size是64的时候学习率应该调到0.002。但这个规则不是绝对的还要看优化器和数据分布。我一般会先用小batch size试几个学习率找到合适的范围再放大batch size。3.3 服务层API设计要“防御性”一点服务层是把模型变成产品的关键一步。我见过很多模型服务接口设计得很“理想化”假设输入永远是干净的、请求量永远是稳定的。结果一上线就各种问题。我的经验是API设计要防御性一点对输入做严格校验对输出做降级处理。我用FastAPI写服务基本结构是这样的from fastapi import FastAPI, HTTPException from pydantic import BaseModel, validator import numpy as np app FastAPI() class PredictionRequest(BaseModel): features: list[float] validator(features) def check_features(cls, v): if len(v) ! 10: raise ValueError(Expected 10 features) if any(np.isnan(x) for x in v): raise ValueError(Features contain NaN) return v class PredictionResponse(BaseModel): prediction: float confidence: float model_version: str app.post(/predict, response_modelPredictionResponse) async def predict(request: PredictionRequest): try: features np.array(request.features).reshape(1, -1) prediction model.predict(features)[0] confidence model.predict_proba(features).max() return PredictionResponse( predictionfloat(prediction), confidencefloat(confidence), model_versionmodel_version ) except Exception as e: raise HTTPException(status_code500, detailstr(e))这里有几个关键点第一用Pydantic做输入校验确保输入格式正确。第二对异常做捕获不要让服务直接崩溃。第三返回模型版本号方便排查问题。第四用异步接口提高并发能力。模型加载方面我一般会在服务启动的时候加载模型而不是每次请求都加载。这样可以避免请求延迟过高。但如果模型很大启动时间会很长这时候可以用懒加载第一次请求的时候加载模型后续请求复用。model None app.on_event(startup) async def load_model(): global model model load_model_from_path(models/trained/model.pkl)提示模型文件不要放在代码仓库里用对象存储或者共享文件系统。我一般用MinIO启动的时候从MinIO拉取模型文件。3.4 监控层没有监控的AI服务等于裸奔监控层是最容易被忽略的但也是最重要的。我见过太多AI服务上线之后没人管直到用户投诉才发现模型效果下降了。监控要覆盖三个维度系统指标、业务指标、模型指标。系统指标包括CPU、内存、磁盘、网络这些用Prometheus的node_exporter就能采集。业务指标包括请求量、响应时间、错误率这些需要在代码里埋点。模型指标包括预测分布、置信度分布、特征分布这些需要定期计算。我用Prometheus加Grafana做系统监控用Loki做日志聚合。代码里的埋点用prometheus_clientfrom prometheus_client import Counter, Histogram, Gauge REQUEST_COUNT Counter(predict_requests_total, Total predict requests) REQUEST_LATENCY Histogram(predict_latency_seconds, Predict latency) MODEL_CONFIDENCE Gauge(model_confidence, Model confidence) app.post(/predict) async def predict(request: PredictionRequest): REQUEST_COUNT.inc() with REQUEST_LATENCY.time(): prediction model.predict(request.features) MODEL_CONFIDENCE.set(confidence) return prediction模型指标的计算我一般用滑动窗口比如最近1000次请求的预测均值、方差、置信度分布。如果发现均值偏移超过阈值就触发告警。阈值怎么定我一般用训练集上的统计量作为基准比如训练集预测均值是0.5标准差是0.1那么线上均值超过0.5±0.3就告警。数据漂移检测也是监控的一部分。我一般用PSIPopulation Stability Index来衡量线上特征分布和训练特征分布的差异。PSI的计算公式是$$PSI \sum_{i1}^{n} (Actual_i - Expected_i) \times \ln(\frac{Actual_i}{Expected_i})$$其中Actual是线上分布Expected是训练分布。PSI小于0.1说明分布稳定0.1到0.25说明有轻微漂移大于0.25说明漂移严重需要重新训练模型。4. 实操过程与核心环节实现4.1 环境搭建从零到一跑通全流程环境搭建我一般用Docker Compose把所有服务编排在一起。配置文件大概长这样version: 3.8 services: api: build: . ports: - 8000:8000 environment: - MODEL_PATH/models/model.pkl - MLFLOW_TRACKING_URIhttp://mlflow:5000 volumes: - ./models:/models depends_on: - mlflow - minio mlflow: image: ghcr.io/mlflow/mlflow:v2.0.0 ports: - 5000:5000 command: mlflow server --host 0.0.0.0 --backend-store-uri sqlite:///mlflow.db minio: image: minio/minio:latest ports: - 9000:9000 - 9001:9001 environment: - MINIO_ROOT_USERminioadmin - MINIO_ROOT_PASSWORDminioadmin command: server /data --console-address :9001 prometheus: image: prom/prometheus:latest ports: - 9090:9090 volumes: - ./configs/prometheus.yml:/etc/prometheus/prometheus.yml grafana: image: grafana/grafana:latest ports: - 3000:3000 environment: - GF_SECURITY_ADMIN_PASSWORDadmin这个配置包含了API服务、MLflow、MinIO、Prometheus、Grafana。启动命令是docker-compose up -d等所有服务起来之后访问localhost:8000就能看到API文档访问localhost:5000能看到MLflow界面访问localhost:3000能看到Grafana。实操现场记录我第一次搭这套环境的时候遇到一个坑API服务启动的时候MLflow还没完全启动导致连接失败。解决办法是在API的启动脚本里加一个重试逻辑import time import requests def wait_for_service(url, timeout60): start time.time() while time.time() - start timeout: try: requests.get(url) return True except requests.ConnectionError: time.sleep(1) return False wait_for_service(http://mlflow:5000)这个重试逻辑看起来简单但能省掉很多“为什么服务起不来”的排查时间。4.2 数据准备从原始数据到特征工程数据准备我一般分三步采集、清洗、特征工程。采集阶段我一般用Python脚本从数据库或者API拉数据存成Parquet格式。Parquet比CSV快很多而且支持列式存储读取特定列的时候效率很高。import pandas as pd from sqlalchemy import create_engine def fetch_data(query: str, db_url: str) - pd.DataFrame: engine create_engine(db_url) df pd.read_sql(query, engine) df.to_parquet(data/raw/dataset.parquet, indexFalse) return df清洗阶段我一般用Pandas加PyArrow。PyArrow能加速Pandas的很多操作特别是字符串处理和类型转换。清洗完的数据存到data/processed/目录下。特征工程阶段我一般用Feature-engine或者sklearn的Pipeline。Feature-engine的好处是它专门为特征工程设计支持缺失值填充、异常值处理、离散化、编码等操作而且能和sklearn的Pipeline无缝集成。from feature_engine.imputation import MeanMedianImputer from feature_engine.outliers import Winsorizer from sklearn.pipeline import Pipeline feature_pipeline Pipeline([ (imputer, MeanMedianImputer(imputation_methodmedian)), (outlier, Winsorizer(capping_methodiqr, tailboth, fold1.5)), ])这个Pipeline先填充缺失值再处理异常值。Winsorizer的fold参数是1.5意思是超过Q1-1.5IQR和Q31.5IQR的值会被截断。这个参数可以根据数据分布调整如果数据偏态严重可以调到3。参数计算过程假设某个特征的Q1是10Q3是20那么IQR是10。fold1.5的时候下界是10-15-5上界是201535。超过这个范围的值会被截断到边界。如果数据是正态分布这个范围能覆盖99.3%的数据。如果数据偏态可以适当放宽。4.3 模型训练从实验到生产模型训练我一般分两个阶段实验阶段和生产阶段。实验阶段在notebook里快速试各种模型和参数生产阶段用脚本跑最终选定的方案。实验阶段我一般用Optuna做超参数搜索。Optuna的好处是它支持剪枝能提前终止表现不好的实验节省时间。import optuna def objective(trial): learning_rate trial.suggest_float(learning_rate, 1e-5, 1e-2, logTrue) batch_size trial.suggest_categorical(batch_size, [16, 32, 64]) num_layers trial.suggest_int(num_layers, 1, 5) model create_model(learning_rate, batch_size, num_layers) val_loss train_and_evaluate(model) return val_loss study optuna.create_study(directionminimize) study.optimize(objective, n_trials100)这里learning_rate用log分布因为学习率的变化通常是指数级的。batch_size用categorical因为通常只有几个固定选项。num_layers用int范围是1到5。生产阶段我一般用PyTorch Lightning因为它把训练循环、验证、 checkpoint、早停都封装好了代码量少而且不容易出错。from pytorch_lightning import Trainer from pytorch_lightning.callbacks import EarlyStopping, ModelCheckpoint trainer Trainer( max_epochs100, callbacks[ EarlyStopping(monitorval_loss, patience5), ModelCheckpoint(monitorval_loss, save_top_k1), ], acceleratorgpu, devices1, ) trainer.fit(model, train_loader, val_loader)EarlyStopping的patience5意思是验证损失连续5个epoch没有下降就停止训练。这个参数可以根据数据集大小调整数据集小的时候可以设小一点比如3数据集大的时候可以设大一点比如10。4.4 服务部署从本地到线上服务部署我一般用Docker加Docker Compose本地和线上用同一套配置。Dockerfile大概长这样FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ ./src/ COPY models/ ./models/ EXPOSE 8000 CMD [uvicorn, src.serving.app:app, --host, 0.0.0.0, --port, 8000]这个Dockerfile的关键是分层构建先复制requirements.txt安装依赖再复制代码。这样代码变更的时候依赖层可以复用构建速度会快很多。线上部署我一般用云服务商的容器服务比如AWS ECS或者阿里云ACK。配置的时候要注意健康检查和自动扩缩容。健康检查的路径是/health返回200表示服务正常。自动扩缩容的指标一般是CPU使用率超过70%就扩容低于30%就缩容。app.get(/health) async def health(): return {status: healthy}注意健康检查不要检查模型是否加载成功因为模型加载可能很慢会导致健康检查失败。健康检查只检查服务进程是否正常就行。5. 常见问题与排查技巧实录5.1 模型服务启动慢怎么办模型服务启动慢是最常见的问题之一。原因通常是模型文件太大加载时间长。我遇到过最夸张的一次一个BERT模型加载花了3分钟导致服务启动超时。解决办法有三个第一用更小的模型。如果业务允许用蒸馏后的小模型加载时间能减少80%。第二用懒加载。服务启动的时候不加载模型第一次请求的时候再加载。第三用模型缓存。把模型加载到共享内存里多个进程复用。我一般用懒加载加缓存的方式from functools import lru_cache lru_cache(maxsize1) def get_model(): return load_model(models/trained/model.pkl) app.post(/predict) async def predict(request: PredictionRequest): model get_model() return model.predict(request.features)lru_cache的maxsize1意思是只缓存一个模型实例。这样第一次请求加载模型后续请求直接复用。5.2 预测结果不稳定怎么排查预测结果不稳定通常有三个原因输入数据有问题、模型有问题、服务有问题。排查的时候我一般按这个顺序来。先检查输入数据。我一般会在服务里加一个日志记录每次请求的输入特征。如果发现输入特征的分布和训练集差异很大那就是数据漂移了。数据漂移的排查方法是用PSI或者KS检验比较线上和训练集的分布。再检查模型。我一般会定期用固定的测试集跑一遍模型看预测结果是否一致。如果不一致说明模型文件可能损坏了或者加载的时候出了问题。最后检查服务。我一般会看服务的CPU和内存使用情况如果CPU跑满了可能是并发太高需要扩容。如果内存持续增长可能是内存泄漏需要检查代码里有没有全局变量一直累积。常见问题速查表问题现象可能原因排查方法解决方案服务启动超时模型文件太大看启动日志用懒加载或小模型预测延迟高并发太高看CPU使用率扩容或加缓存预测结果偏移数据漂移算PSI重新训练模型内存持续增长内存泄漏看内存曲线检查全局变量请求失败率高输入格式错误看错误日志加输入校验5.3 模型效果下降怎么处理模型效果下降是AI服务最常见的问题也是最难处理的。我一般分三步检测、定位、修复。检测方面我一般用滑动窗口计算线上预测的统计量比如均值、方差、置信度分布。如果发现统计量和训练集差异超过阈值就触发告警。阈值我一般设3个标准差这样误报率比较低。定位方面我一般先看数据漂移再看模型退化。数据漂移用PSI检测模型退化用固定测试集检测。如果PSI高说明数据分布变了需要重新训练。如果PSI正常但模型效果下降说明模型本身退化了可能是概念漂移。修复方面我一般用增量学习或者重新训练。增量学习适合数据量大的场景用新数据微调模型。重新训练适合数据量小的场景用全部数据重新训练。我一般优先用重新训练因为增量学习容易导致灾难性遗忘。提示重新训练之前一定要先备份当前模型。我踩过一次坑重新训练之后效果更差了想回滚发现旧模型被覆盖了。5.4 实操避坑技巧汇总第一永远不要相信“这个模型不会出问题”。我见过太多模型在测试集上表现很好一上线就崩。所以一定要有降级方案比如模型预测失败的时候返回默认值。第二日志要打全。我一般会在请求入口、模型预测、响应返回三个地方打日志记录请求ID、输入特征、预测结果、耗时。这样出问题的时候能快速定位。第三监控要设告警。我一般会设三个告警错误率超过1%、延迟超过1秒、模型置信度低于0.6。告警通过邮件或者即时通讯工具发送。第四定期做压力测试。我一般每个月做一次压力测试用Locust模拟高并发请求看服务的瓶颈在哪里。压力测试的结果用来指导扩容。第五模型版本要管理好。我一般用语义化版本号比如v1.0.0、v1.1.0。每次模型更新都打tag记录变更内容。这样回滚的时候能快速找到旧版本。6. 后续扩展与个人经验分享这套从零搭建的AI工程体系我用了大概一年半支撑了三个业务场景日均请求量从几百涨到几十万。中间经历过几次大的重构但核心架构一直没变。我觉得它最大的价值不是技术多先进而是足够简单、足够透明、足够可控。你知道每个环节在做什么出了问题能快速定位想换组件的时候替换成本很低。后续如果要扩展我建议从三个方向入手。第一自动化训练流水线。用Airflow或者Prefect把数据准备、模型训练、模型评估、模型部署串起来实现定时训练。第二特征存储。用Feast或者Tecton管理特征解决训练和推理特征不一致的问题。第三A/B测试。用多臂老虎机或者固定分流对比不同模型的效果用数据驱动模型迭代。我个人在实际操作中的体会是AI工程最难的不是模型而是工程。模型可以调参、可以换架构但工程问题往往是系统性的需要从架构设计、代码规范、监控告警、运维流程多个方面一起解决。我见过太多团队在模型上花了很多精力但在工程上偷懒最后项目失败。所以如果你正在从零搭建AI工程体系我的建议是先把工程做好再考虑模型优化。一个简单的模型加上健壮的工程比一个复杂的模型加上脆弱的工程价值大得多。最后再分享一个小技巧每次上线新模型之前先用旧模型跑一遍全量测试集记录基线指标。新模型上线之后用同样的测试集跑一遍对比指标变化。这个习惯帮我发现了好几次模型退化的问题也让我对每次上线的效果心里有数。