1. 这不是调包,是亲手造轮子:从零构建AI工程系统的实战手记
“AI Engineering from Scratch”——看到这个标题,我第一反应不是兴奋,而是下意识摸了摸键盘右上角那块被常年敲击磨得发亮的空格键。过去三年,我带过七支跨职能AI落地团队,亲手推过12个从0到1的生产级AI系统,其中8个最终跑在金融风控、工业质检、医疗影像预筛这类对稳定性、可追溯性、合规性要求极高的场景里。这些项目有个共同点:没人敢直接把Hugging Face Model Hub里下载的checkpoints往生产环境一扔就走人。不是模型不够好,而是整个工程链路——从数据版本控制怎么设计,到推理服务如何做灰度发布,再到模型漂移怎么量化告警——全得自己搭骨架、铸关节、接神经。所谓“from scratch”,不是拒绝工具,而是拒绝黑盒;不是重复造轮子,而是清楚每个轮子的轴承公差、热胀系数和疲劳寿命。它解决的不是“能不能跑出结果”,而是“结果能不能被审计、能不能扛住峰值、出了问题能不能3分钟内定位到是数据脏了还是特征缩放参数漂了”。适合三类人:刚跳出Kaggle排行榜想进真实产线的算法同学,正被“模型上线后效果断崖下跌”折磨的MLOps工程师,以及需要向监管方解释“为什么这个诊断建议必须由当前版本模型生成”的技术负责人。它不教你怎么写Transformer,但会告诉你,当GPU显存突然涨了17%时,该先查Docker镜像里的cuDNN版本,还是先翻CI流水线里那个被跳过的pytest测试用例。
2. 整体架构设计:为什么必须放弃“Jupyter+Flask”的速成型方案
2.1 真实产线的四个硬约束,决定了架构不能妥协
很多团队起步时用Jupyter写训练脚本,用Flask包装成API,本地跑通就以为万事大吉。我在某家三甲医院部署辅助诊断模型时,就吃过这个亏。表面看,模型AUC 0.92,API响应<200ms,一切完美。直到上线第三周,放射科主任拿着打印出来的报告找上门:“为什么昨天下午三点的CT报告,和今天早上八点的同序列报告,给出的结节风险分级不一样?”我们排查了三天,最后发现是Flask服务在自动重启时,加载了不同日期采集的校准数据集——而这个数据集路径,就硬编码在train.py第42行的一个字符串里。这件事让我彻底抛弃了“能跑就行”的思路,转而用四个不可协商的硬约束来反推架构:
- 可重现性(Reproducibility):任何一次训练、推理、评估,必须能通过唯一ID回溯到精确的代码提交哈希、数据版本号、环境依赖树。这意味着Git不能只管代码,还要管数据摘要(用DVC或Delta Lake生成SHA256)、管conda环境yml、管Dockerfile构建上下文。
- 可观测性(Observability):不是只看CPU利用率,而是要实时知道“当前批次推理中,有多少样本触发了特征值域外告警”、“过去24小时,模型输出的熵值标准差是否超过基线3个σ”。这要求从数据输入层就开始埋点,而不是等API网关返回500才报警。
- 可演进性(Evolution):新模型上线不能停服。必须支持A/B测试(比如新旧模型各处理50%流量)、影子模式(新模型不参与决策,只记录输出比对)、金丝雀发布(先放1%流量,监控准确率/延迟/内存增长)。这直接否定了单体Flask服务。
- 可审计性(Auditability):医疗、金融场景下,监管检查时要能拿出证据:这个预测结果,是由哪次训练产生的哪个模型版本,在哪个硬件配置上,用哪批清洗后的数据验证过,验证报告谁签字谁复核。所有环节必须有不可篡改的时间戳和签名。
2.2 分层解耦:用“数据-特征-模型-服务”四层隔离风险
基于上述约束,我最终采用的架构不是微服务,而是更严格的分层契约(Layered Contract)。每一层只和相邻层交互,且接口必须用Protocol Buffer明确定义,禁止跨层调用。这样做的好处是:当某天需要把TensorFlow模型换成PyTorch时,只需重写“模型层”的实现,其他三层完全不动。
数据层(Data Layer):核心是Delta Lake + S3。不用HDFS是因为云上对象存储成本更低、扩展性更好;选Delta Lake而非纯Parquet,是因为它原生支持ACID事务、时间旅行(Time Travel)和Schema演化。比如,当上游ETL作业意外写入了含NULL的必填字段,Delta Lake的
MERGE操作能自动拒绝这批数据,并触发告警,而不是让脏数据一路流到模型训练层。数据湖目录结构严格按raw/{source}/{date}/、curated/{domain}/{table}/、ml-ready/{project}/{version}/划分,每个目录下必须有_SUCCESS文件和schema.json。特征层(Feature Layer):这是最容易被忽视的“隐形瓶颈”。我见过太多团队把特征工程代码散落在各个notebook里,导致同一个“用户30天活跃度”在风控模型和推荐模型里计算逻辑不一致。解决方案是建立统一特征仓库(Feature Store),但不是买商业产品,而是用Feast开源版+自研元数据服务。关键设计点在于:所有特征必须注册为“实体-特征”对(如
user:30d_active_days),计算逻辑封装成Python函数,但函数本身不存代码,只存Docker镜像哈希。每次特征更新,必须触发全量重算并生成新版本号,旧版本保留90天供回溯。模型层(Model Layer):拒绝“一个模型一个repo”。所有模型代码强制遵循统一模板:
model/目录下放训练脚本,inference/目录下放服务化代码,tests/目录下必须包含单元测试(验证前向传播数值)、集成测试(用mock数据验证端到端输出)、压力测试(模拟1000QPS下的内存泄漏)。模型序列化不用pickle(不安全且跨版本不兼容),统一用ONNX + 自定义metadata.json(存入训练超参、评估指标、数据版本)。模型注册中心用MLflow,但禁用其默认的“artifact存储”,所有模型文件存S3,MLflow只存元数据。服务层(Serving Layer):不用Triton或TFServing的开箱即用配置。核心是自研的轻量级推理网关,用Rust编写(内存安全+零拷贝),功能只有三个:请求路由(根据header里的
x-model-version转发)、预处理(调用特征层API获取实时特征)、后处理(把模型原始输出转成业务可读的JSON Schema)。真正的模型服务用KServe(原KFServing),因为它原生支持Kubernetes的滚动更新、资源配额、网络策略,且能无缝对接Prometheus监控。
提示:分层不是为了炫技,而是为了故障隔离。去年某次线上事故,特征层因上游数据源变更导致
user_age字段类型从int变成string,结果只影响了依赖该特征的3个模型,其他模型照常运行。如果当初把特征计算硬编码在模型代码里,整个服务集群就得停机修复。
2.3 工具链选型逻辑:为什么不用Airflow而选Prefect?
调度引擎选型曾让我纠结很久。Airflow太重,dag定义和执行环境强耦合,调试一个失败task得翻三遍日志;Luigi学习曲线陡峭,社区生态弱。最终选择Prefect 2.x,理由很实在:
- 动态DAG生成:我们的数据管道不是静态的。比如,每天凌晨要根据昨日数据质量报告,动态决定是否触发模型重训。Prefect的
@flow装饰器允许在运行时用Python逻辑生成task依赖图,Airflow的DAG必须提前定义死。 - 本地调试友好:Prefect的
flow.run()能在本地IDE里单步调试,看到每个task的输入输出,而Airflow必须起webserver+scheduler+worker三进程才能debug。 - 错误恢复粒度细:Prefect支持task级重试(比如某个API调用超时,只重试这个task,不影响下游),Airflow只能重跑整个DAG或手动标记task为success。
- 云原生适配:Prefect Agent能直接对接Kubernetes Job,无需额外部署Executor。我们用它管理特征计算任务,每个task对应一个K8s Pod,资源请求精准到CPU毫核和内存MiB,避免资源浪费。
当然,Prefect也有坑:它的默认结果存储用SQLite,不适合生产。我们把它替换成S3+DynamoDB组合——S3存task输出的大对象(如parquet文件),DynamoDB存元数据(状态、时间戳、重试次数),用AWS Lambda做清理冷数据。
3. 核心细节解析:数据版本控制、特征一致性与模型可解释性落地
3.1 数据版本控制:不是git-lfs,而是Delta Lake的事务日志
很多人以为数据版本控制就是用git-lfs把CSV塞进Git。这在小数据集上可行,但一旦数据量超1GB,Git clone就变成灾难。我们的真实方案是:Git只管代码和schema,Delta Lake管数据,两者通过“数据版本ID”关联。
具体操作流程:
- 每日ETL作业完成,生成新数据版本,Delta Lake自动写入
_delta_log/目录下的JSON事务日志(如00000000000000000010.json); - 脚本提取该日志的
readVersion(即本次写入的版本号),连同代码commit hash、环境hash,写入一个data_manifest.json文件; data_manifest.json被git commit,作为该次训练的“数据身份证”。
这样做的好处是:回滚数据版本只需一条命令delta_table.restore_to_version(10),Delta Lake自动重放或撤销指定版本间的事务日志,无需复制数据。更重要的是,事务日志里记录了每次写入的operationMetrics(如numOutputRows、numFiles),我们可以监控数据量突增突降——比如某天numOutputRows比均值高300%,大概率是上游漏了去重逻辑。
注意:Delta Lake的
VACUUM命令必须谨慎使用。我们设为每周日凌晨执行,且保留7天历史版本(RETAIN 7 DAYS)。曾有一次误删了保留窗口外的版本,导致无法回溯某次关键模型训练的数据源,只能从备份S3桶里手动恢复,耗时4小时。
3.2 特征一致性:用“特征签名”堵住数据漂移漏洞
特征不一致是模型效果下跌的头号杀手。我们曾遇到一个经典案例:风控模型在离线评估AUC 0.85,上线后跌到0.72。排查发现,离线训练用的特征是“用户近7天登录次数”,而线上服务调用的特征API返回的是“用户近7天成功登录次数”(过滤了密码错误的请求)。两个特征名字一样,语义却天差地别。
解决方案是引入“特征签名(Feature Signature)”机制:
- 每个注册特征在元数据服务里,除了存计算逻辑,还存一个
signature_hash,它是基于特征定义(SQL或Python函数)、输入schema、采样数据(1000行)生成的SHA256; - 模型训练时,特征仓库返回特征数据的同时,附带该批次的
signature_hash,训练脚本将其写入模型metadata; - 线上服务启动时,先调用特征API获取当前特征签名,与模型metadata里的签名比对,不一致则拒绝启动并告警。
签名生成算法很简单:把特征定义字符串、输入字段名列表、字段类型列表、采样数据的统计摘要(min/max/mean/std for numeric, top3 values for categorical)拼接后哈希。这样即使SQL里WHERE date >= '2023-01-01'改成>= '2023-01-02',签名也会变。
3.3 模型可解释性:不是SHAP图,而是业务规则映射表
SHAP/LIME在论文里很美,但在银行信贷审批场景,风控总监不会看一张热力图,他要的是:“为什么给张三拒绝贷款?因为他的‘近3月信用卡最低还款额占比’超过阈值85%,且‘近6个月查询机构数’大于5”。所以我们不生成全局SHAP值,而是构建“模型决策-业务规则”映射表。
实现方式:
- 训练后,用训练集的10%样本做局部解释(LIME),但只保留那些对决策起决定性作用的特征组合;
- 把这些组合抽象成业务规则,存入PostgreSQL的
decision_rules表,字段包括:rule_id,feature_conditions(JSON,如{"credit_utilization": ">0.85", "inquiry_count": ">5"}),business_impact(如“拒绝授信”); - 线上服务返回预测结果时,同步返回匹配的
rule_id,前端直接查表渲染成自然语言。
这套机制让模型从“黑盒”变成“透明盒”,也极大降低了合规审计成本。某次银保监检查,我们30分钟就导出了全部生效规则及其覆盖样本数,而传统SHAP方案需要现场生成并解释数百张图表。
4. 实操过程:从零搭建一个可审计的图像分类流水线
4.1 环境初始化:用Nix替代Docker构建可复现环境
Docker镜像看似可复现,但apt-get update的源地址、pip install的wheel缓存、甚至gcc编译器版本,都可能因构建时间不同而变化。我们改用Nix包管理器,它保证“相同.nix表达式,永远产出相同二进制”。
初始化步骤:
- 在项目根目录创建
shell.nix:
{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell { buildInputs = [ pkgs.python39 (pkgs.python39.withPackages (ps: with ps; [ torch torchvision opencv-python mlflow prefect ])) pkgs.curl pkgs.awscli ]; shellHook = '' export PYTHONPATH=$PWD/src:$PYTHONPATH export MLFLOW_TRACKING_URI="http://localhost:5000" ''; }- 开发者只需
nix-shell,就能进入一个纯净环境,所有依赖版本锁定(如torch固定为1.13.1+cu117); - CI流水线用
nix-build生成docker镜像,镜像里只包含nix profile里的二进制,体积比pip安装小40%,且无网络依赖。
实操心得:Nix的学习曲线确实陡,但回报巨大。我们曾用它解决了“为什么开发环境能跑通,CI环境报CUDA error 700”的问题——根本原因是CI用的NVIDIA driver版本和开发机不同,而Nix表达式里明确声明了
cudaPackages.cudatoolkit_11_7,CI自动匹配driver版本,开发机却没装对应驱动。
4.2 数据准备:用DVC管理非结构化数据的版本
图像数据不能像表格数据那样用Delta Lake。我们用DVC(Data Version Control),但它不是简单git-lfs替代品,而是结合了Git和S3的协同工作流。
关键配置:
.dvc/config里设置远程为S3 bucket;dvc init后,dvc add data/raw/images/生成.dvc文件,它只存S3路径和md5哈希,不存实际文件;- Git commit
.dvc文件,相当于commit了数据指针; - 团队成员
git pull后,dvc pull从S3下载对应版本数据。
但DVC有个坑:它默认用文件md5,而图像经过EXIF清理、格式转换后md5会变。解决方案是改用content-based hash:在.dvc/config里加[core] checksum_jobs = 1,并用dvc remote modify --local myremote jobs 1确保单线程计算,避免并发导致hash不一致。
4.3 训练流水线:Prefect Flow串联数据、训练、评估
完整Prefect Flow代码(简化版):
from prefect import flow, task from prefect.tasks import task_input_hash from datetime import timedelta @task(cache_key_fn=task_input_hash, cache_expiration=timedelta(days=1)) def load_data(version: str) -> pd.DataFrame: # 从Delta Lake读取指定版本数据 return delta_table.to_pandas(version=version) @task def train_model(data: pd.DataFrame, config: dict) -> str: # 训练模型,返回模型URI(如s3://bucket/models/v1.2.3/) model_uri = train_and_log_to_mlflow(data, config) return model_uri @task def evaluate_model(model_uri: str, test_data: pd.DataFrame) -> dict: # 加载模型,计算指标 metrics = evaluate_on_testset(model_uri, test_data) return metrics @flow def image_classification_pipeline(): # 获取最新数据版本 latest_version = get_latest_delta_version("ml-ready/image-classification") # 加载训练/测试数据 train_df = load_data.submit(latest_version, key="train") test_df = load_data.submit(latest_version, key="test") # 训练 model_uri = train_model.submit(train_df.result(), {"lr": 0.001, "epochs": 50}) # 评估 metrics = evaluate_model.submit(model_uri.result(), test_df.result()) # 写入MLflow log_metrics_to_mlflow(metrics.result(), model_uri.result())这里的关键是@task(cache_key_fn=task_input_hash):load_data任务会缓存结果,只要version参数不变,下次运行直接返回缓存,省去Delta Lake读取开销。cache_expiration设为1天,避免缓存过期数据。
4.4 模型服务化:KServe + 自研网关的灰度发布
KServe部署YAML关键片段:
apiVersion: kserve.io/v1beta1 kind: InferenceService metadata: name: image-classifier spec: predictor: serviceAccountName: kserve-sa containers: - name: kserve-container image: <our-registry>/image-classifier:v1.2.3 ports: - containerPort: 8080 resources: limits: memory: "4Gi" nvidia.com/gpu: 1 requests: memory: "2Gi" nvidia.com/gpu: 1 componentSpecs: - spec: containers: - name: transformer image: <our-registry>/preprocessor:v1.0.0自研网关的灰度逻辑:
- 请求Header带
x-deployment-strategy: canary-10%,网关将10%流量路由到新版本KServe服务(image-classifier-new),90%到旧版(image-classifier-stable); - 网关收集两路响应,比对输出label和置信度,若新版本置信度标准差超过旧版2倍,则自动降权至1%;
- 所有请求日志打上
deployment_version标签,接入ELK,可随时查“v1.2.3版本在灰度期间的准确率趋势”。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 数据漂移检测失效:不是算法问题,是采样偏差
现象:用KServe部署的模型,线上监控显示“数据漂移指数”连续3天超标,但人工抽检样本,发现图像质量、分布都正常。
排查过程:
- 先确认漂移检测算法(用PCA+KS检验)本身没问题,用合成数据验证过;
- 查KServe日志,发现漂移检测模块每小时采样1000张图,但采样逻辑是“从Kafka topic随机取”,而topic里凌晨2-4点的流量极少,导致采样集中在白天,忽略了夜间低光照场景;
- 修正:改用分层采样,按小时段权重采样(白天70%,夜间30%),并加入光照强度直方图作为辅助特征。
独家技巧:数据漂移监控必须和业务时段强绑定。我们给每个数据源配置
business_hours字段(如{"start": "09:00", "end": "18:00", "timezone": "Asia/Shanghai"}),漂移检测模块会自动按此窗口聚合统计量,而不是简单按自然小时切片。
5.2 模型服务OOM:不是显存不足,是Python GC没触发
现象:KServe pod在处理高分辨率图像时,GPU显存缓慢上涨,几小时后OOM kill。
排查:
nvidia-smi显示显存占用持续上升,但torch.cuda.memory_allocated()返回值稳定;ps aux --sort=-%mem发现Python进程RSS内存也在涨;- 最终定位:模型
__call__方法里,用cv2.imread()读图后,没显式del img,而OpenCV的Mat对象在Python GC前,底层C++内存不释放。
解决方案:
- 在推理函数末尾加
torch.cuda.empty_cache(); - 更治本:用
PIL.Image.open()替代cv2.imread(),PIL的内存管理更符合Python习惯; - 加一道防护:在KServe容器里启动一个sidecar进程,定期
ps aux | grep python | awk '{print $6}',若RSS超阈值,发SIGUSR1触发Python GC。
5.3 特征服务延迟飙升:不是网络问题,是Redis连接池耗尽
现象:特征API P99延迟从100ms飙到2s,但Redis服务器CPU、网络带宽都正常。
根因分析:
- 特征服务用Redis Py客户端,默认
connection_pool大小是10; - 高峰期并发请求超100,大量请求阻塞在连接获取上;
- 而Redis本身连接数上限设为1000,远未达到。
修复:
- 将连接池
max_connections设为200; - 关键:启用
health_check_interval=30,让连接池定期探活,避免僵尸连接占位; - 监控指标增加
redis_connection_pool_available,低于阈值时自动扩容。
实操心得:所有外部依赖(DB、Redis、HTTP API)的客户端,必须配置熔断器(如tenacity库)。我们给特征服务加了
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10)),单点故障时自动降级返回缓存特征,而不是拖垮整个链路。
5.4 模型版本混乱:不是Git冲突,是MLflow的Artifact URI陷阱
现象:在MLflow UI里看到模型版本v1.2.3,但下载下来发现是v1.1.0的权重。
真相:
- MLflow的
log_model()默认用相对路径存artifact,如./models/; - 当CI流水线在不同机器构建时,相对路径解析不同;
- 正确做法:
log_model()时显式传artifact_path="models",且在mlflow.set_tracking_uri()前,确保MLFLOW_ARTIFACT_URI环境变量指向绝对S3路径(如s3://my-bucket/mlflow/)。
验证方法:下载模型后,检查MLmodel文件里的artifact_path字段,必须是绝对路径,且和S3实际路径一致。
6. 工程化思维的真正起点:从“能跑通”到“敢交付”
写完这篇,我重新翻了下三年前的第一个“from scratch”项目文档,里面写着“目标:两周内上线第一个可用版本”。现在回头看,那根本不是起点,只是幻觉的开始。真正的起点,是第一次因为没做数据版本回滚,导致客户投诉后,我花了18小时手动从S3备份里拼凑出正确数据集;是第一次因为特征签名没校验,让一个bug在线上跑了72小时,损失了37万授信额度;是第一次在深夜接到电话,说模型服务OOM,而我连kubectl exec都打错命令。
AI Engineering from Scratch,本质是一场对抗不确定性的修行。它不追求最前沿的架构,而追求最扎实的契约;不崇拜最炫酷的工具,而敬畏最朴素的日志;不迷信最完美的代码,而信赖最冗余的备份。当你能把一个模型的生命周期,拆解成可审计的137个原子操作,并为每个操作写下失败预案时,你才算真正踏入了这个领域。至于那些热搜词——AI Engineering, from-scratch——它们只是路标,不是目的地。目的地是,当监管人员指着屏幕问“这个决策依据是什么”,你能不假思索地打开系统,输入ID,3秒后弹出完整的证据链:从原始数据快照,到特征计算日志,到模型训练报告,再到本次推理的逐层激活值。那一刻,你交付的不是代码,而是信任。