不少人第一次接触AI工程时以为把模型在Notebook里跑通就完事了。真正把项目推到线上才发现从零搭建一套AI工程体系跟你想象中“调参、炼丹、出指标”的路径完全不是一回事。本篇就围绕“ai-engineering-from-scratch”这个话题把我从零开始构建AI工程能力的一段完整经历摊开讲用了哪些工具、踩了哪些坑、哪些选型后来证明是对的、哪些一开始就该换掉。内容不搞“速成宝典”那套适合准备把模型做成真实服务的开发者也适合已经写了几个月Python、却始终没想清楚“工程化到底在工程什么”的朋友。1. 先分清“AI研究”和“AI工程”——路线不同目标也不同1.1 从“能跑通模型”到“能交付系统”的鸿沟我在刚入门时犯过一个典型错误花三个月学会训练深度模型把公开数据集上的准确率从91%调到93%觉得自己已经懂AI了。直到第一次把一个分类模型部署到客户环境才明白前面那三个月其实只完成了“实验”连工程的边都没摸到。可以打一个比方AI研究像是“研发菜谱”AI工程像是“开一家餐厅”。菜谱研发要考虑口味、营养、成本核算但开餐厅要考虑供应链、高峰出餐速度、餐具损耗、员工培训、投诉处理。模型在Notebook里的高精度就像一盘精心炮制的样品菜端给评委吃和一天出三百份完全不是一个问题。落到工程场景模型准确率只是起点真正缠住你的往往是数据能不能复现换台机器跑结果会不会变训练完成后模型怎么被打包、上线、回滚服务在高并发下会不会超时GPU跑还是CPU跑延迟能不能接受线上数据分布变了模型什么时候会悄悄变差整套链路需要多少人维护单位请求成本是多少如果你只想发论文这些问题可以不管。但如果你要交付一个“系统”上面每一行都躲不掉。1.2 AI工程的能力地图不止“模型训练”大多数人理解AI工程脑子里浮现的是“PyTorch 训练代码”。实际拆开看一个有落地能力的AI工程岗位大概横跨下面六块能力领域具体工作典型工具/切入点数据工程管道调度、质量校验、版本管理、特征存储dbt、DVC、Pandas、pydantic、Airflow实验管理超参记录、指标追踪、结果对比、可复现性MLflow、WandB、自建JSON/CSV日志模型训练与服务化训练脚本、模型封装、服务端点、推理优化PyTorch、FastAPI、ONNX、TensorRT服务工程API设计、并发控制、限流、降级、负载均衡FastAPI、Docker、Nginx、K8s监控评估线上指标、数据漂移、延迟/吞吐、badcase分析Prometheus、Grafana、自定义统计脚本基础设施环境复现、CI/CD、资源调度、成本管理Docker、GitHub Actions、Compose、K8s注意这个表格的目的不是让你一次性全学完而是给你一张“地图”。很多朋友学着学着迷茫是因为只在地图的某一个角落打转不知道自己的位置。我当时先集中精力打通“模型训练→服务化”这条主线再把监控和自动化的能力慢慢补上。1.3 为什么“从零开始”反而适合练功有人会问现在开源框架那么多为什么不直接啃某个大厂的AI平台我的体会是上手一个大平台你会被它帮你想好的抽象遮蔽掉大量细节。平台替你做了日志记录、资源调度、存储管理你点几下按钮就建立了pipeline这确实爽但出了问题你不知道它底层做了什么。从零开始并不是说让你不依赖任何库而是强调“每一个环节你都知道自己为什么这样搭”。比如你会知道Docker镜像层为什么会膨胀、请求为什么会有网络超时、模型推理为什么CPU和GPU差距巨大。这些颗粒度很细的经验只有在亲手把每一个零部件拧过一遍后才会沉淀下来。等你有了一套自己的最小闭环再去接触生产级平台吸收速度会比原来快很多。2. 从零搭建工程环境选型就是给未来埋下的地基2.1 Python版本、虚拟环境与CUDA组合最基础也最折磨人环境问题是“从零开始”的首选拦路虎而且它出现的频率远超你的想象。我最初直接用系统Python加pip install结果把全局环境装得乱七八糟一个项目要tensorflow1.x另一个要torch2.x还有一个包的编译依赖被意外升级导致另外两个项目直接跑不起来。现在我的环境固定搭配是# 安装指定版本的Python避免依赖系统的旧版本 pyenv install 3.11.9 pyenv local 3.11.9 # 用uv创建虚拟环境比venv快很多 uv venv .venv source .venv/bin/activate # 安装依赖时锁版本把requirements.txt或pyproject.toml纳入Git uv pip install -r requirements.txt选型理由很简单pyenv解决“一台机器多版本Python”的问题不用为了某个项目去动系统级Python。uv或者venv保证同一个项目依赖隔离别人克隆你的仓库时能还原一致的环境。依赖版本必须锁定。我吃过“requirements里面写了个符号半年后同事拉代码装到了大版本升级包”的亏接口还能跑但行为已经变了。GPU环境要额外注意CUDA、cuDNN和深度学习框架的三角组合。常见的坑是服务器驱动版本旧但你pip安装的PyTorch需要新的CUDA runtime。我建议先跑nvidia-smi看驱动支持的CUDA版本再装对应编译好的PyTorch。多用Docker。官方镜像已经把CUDA和cuDNN的版本配对好了你不需要在自己环境里死磕。尽量固定一个基础镜像版本不要用 latest否则某天拉到的镜像跟之前不一样你的复现环境直接失效。2.2 Docker不是可选项是底线思维很多初学者觉得Docker是运维的事。现实是写AI代码的人如果不自己掌握Docker交付模型时会面临“在我机器上明明是好的”这种经典难题。Docker存在的意义是把运行环境、依赖、代码和配置一起封装起来别人拿到之后能复现同样的行为。一个我常用的最小Dockerfile# 固定Python版本不要用python:latest FROM python:3.11-slim WORKDIR /app # 先复制依赖文件利用Docker层缓存 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再复制源码这样只有源码变化时才需要重建最后一层 COPY src/ ./src/ EXPOSE 8000 CMD [uvicorn, src.api:app, --host, 0.0.0.0, --port, 8000]这里有三个细节值得强调--no-cache-dir用来控制镜像体积。不要小看这一点pip缓存有时能把镜像撑大几个GB。先拷贝requirements.txt再拷贝源码利用Docker的层缓存。只要依赖没变后续重建只需重建源码层速度会快很多。.dockerignore一定要写。把.git/、.venv/、__pycache__/、.pytest_cache/这些全过滤掉否则它们会被送进镜像上下文既拖慢构建也把无意义文件带进去。如果团队里有人跑不起来先问一句“你用的什么基础镜像、什么版本”这能省掉一多半问题。2.3 实验跟踪的最小可用方案从第一天开始而不是第三个月实验跟踪是AI工程里“做的时候觉得无所谓、后来补才最痛”的模块。刚开始我自己跑实验也是手动起文件夹run_20240101_1123/里面丢一个metrics.json和一个config.yaml训练完看一眼就忘。等过了两星期连哪个模型对应哪个结果都分不清。我推荐的最小方案起步用MLflow Tracking就够了import mlflow with mlflow.start_run(run_namebert-ticket-v3): mlflow.log_params(config) # 记录超参 mlflow.log_metrics({acc: 0.93, f1: 0.88}) # 记录指标 mlflow.log_artifact(model.bin) # 保存产物命令行启动一个追踪服务所有实验记录集中存放在后端mlflow server --host 0.0.0.0 --port 5000比起自建日志MLflow的界面可以直接做“参数-指标”对比找最优超参省力很多。关键原则是一旦开始跑实验就把参数和指标记录下来哪怕用最简单的JSON也行。不要等到已经跑了五十个实验再补到那时候认知已经混沌了。3. 端到端案例从原始数据到一个可调用的服务这一节用一个具体场景串起整个链路做一个“工单自动分类”模型输入是标题和正文输出是服务类别比如“网络故障”、“支付问题”、“账号问题”等。它简单但五脏俱全。3.1 数据层先做Schema校验再谈清理初学者上手数据时最喜欢的动作是直接pd.read_csv()然后开始处理缺失值和编码。等到训练阶段才发现某些行格式不对回头再清理整个流程被打断。更糟的是模型服务上线后API收到一条格式在预料之外的请求可能直接让整个pipeline崩溃。我的做法是先定义数据Schema用pydantic校验脏数据在入口就被拦住from datetime import datetime from pydantic import BaseModel, Field, ValidationError class TicketRecord(BaseModel): title: str Field(..., min_length1, max_length200) content: str Field(..., min_length10) ticket_type: str Field(..., pattern^[网络|支付|账号]) created_at: datetime def load_tickets(path): records [] for row in read_csv(path): try: records.append(TicketRecord(**row)) except ValidationError as e: # 记录坏样本但不要中断整体读取 logger.warning(bad ticket: %s, error: %s, row.get(id), e) return records这样做的核心原因是明确的数据边界是工程系统的第一道安全网。你宁可让一条脏数据在入站时被打回来也不要在训练或推理时因为某个字段是None才被Python告知异常。提前发现永远比事后补救便宜。注意这里“不要中断整体读取”是我自己踩坑换来的。最初我一遇到坏数据就抛异常结果一批10万条样本里因为有3条格式问题整批数据处理直接失败。正确的做法是把坏样本单独收集批量分析共性。比如后来发现80%的坏样本都是title字段为空这就说明是上游数据采集端的问题属于源头修复的范畴。3.2 训练层超参数从代码里拆出来让实验可复现训练脚本里最容易烂的地方是超参数到处硬编码。什么learning_rate2e-5出现在函数中间batch_size32藏在main下面换一组参数就要改代码。一旦出现这种写法实验跟踪就是一句空话。我的做法是把所有可调参数放进一个YAML文件# configs/experiment_v1.yaml model: name: distilbert-base-uncased num_labels: 3 train: learning_rate: 2e-5 per_device_train_batch_size: 32 num_epochs: 3 output_dir: ./models/ticket_classifier_v1训练脚本里只需一个加载函数import yaml with open(configs/experiment_v1.yaml) as f: config yaml.safe_load(f) # 后续读取全部通过config不再出现魔法数字 model AutoModelForSequenceClassification.from_pretrained( config[model][name], num_labelsconfig[model][num_labels] )我当时做完这个改造后实验对比效率指数级提升不再靠记忆“前几天那次跑的是lr3e-5还是2e-5”而是直接在MLflow里筛选配置。更重要的是任何一次跑出来的结果都可以回放到源码版本和配置版本上也就是“可复现性”。3.3 服务层用FastAPI封装而不是直接暴露Python预测函数训练完后大多数人第一反应是把模型保存成bin文件然后就没了。但要做成一个服务你需要考虑的事会慢慢浮出来谁来调用它用什么协议请求格式是什么出错了返回什么我用FastAPI封装因为它的顺手程度几乎为零摩擦from fastapi import FastAPI from pydantic import BaseModel app FastAPI() model load_model(./models/ticket_classifier_v1) class PredictRequest(BaseModel): title: str content: str class PredictResponse(BaseModel): category: str confidence: float app.post(/predict, response_modelPredictResponse) def predict(req: PredictRequest): text f{req.title} {req.content} label, conf model.predict(text) return PredictResponse(categorylabel, confidenceconf)这里我建议响应也要定义一个Pydantic模型而不是直接返回dict。好处是给客户端一个稳定契约response_model会强制字段类型和结构多出来的字段不会漏出去外面调用方也不会因为响应结构突变而挂掉。服务化之后的几个关键问题模型加载要在app.post之外、进程启动时一次性加载。如果每个请求都重新读模型文件延迟会高得可怕。API要设置超时和重试逻辑不能无限等GPU推理。对于用户侧超时后应该快速返回一个降级结果或者明确错误码。上线初期最好把原始输入和预测结果记录成日志后面做badcase分析会非常有用。3.4 这套最小闭环里其实已经包含工程的三个核心动作回看上面这个小案例先做数据校验再做配置化训练最后用API封装模型。它并不复杂但三个步骤对应了AI工程的三个基本动作——控制输入边界、保证实验可复现、屏蔽模型内部细节。我强烈建议想入门AI工程的朋友先独立做一遍这种从“数据到服务”的最小闭环哪怕它只是一个最简单的分类模型。因为在做这一遍的过程中你会真实遇到环境冲突、数据异常、序列化问题、服务超时等等每一个都是将来要长期打交道的敌人。先把这支小部队打一遍后面再面对大型项目时你的恐惧感会少很多。4. 把模型推向线上三个绕不开的性能现实4.1 延迟与吞吐想象一下同时来200个请求在单机Notebook里跑推理慢一点没关系反正只有你一个人等。但服务上线后请求是并发的。你要理解两个核心指标QPS/RPS每秒请求数衡量系统吞吐。P95/P99延迟95%或99%的请求在多少毫秒内返回。举个例子假设你的模型单条推理耗时150msGPU一次只能推理一条不做并发优化。高峰期来了100个请求如果串行处理最后一个请求要等15秒。实际上任何用户等上5秒基本就流失了。解决办法是单卡上用动态batching把多个请求拼成一个batchGPU从“一个batch算一次”变成“四个请求算一次”吞吐能翻几倍。多卡或者多副本横向扩展前面用负载均衡分发请求。对实时性要求不高的场景比如离线批处理推荐大可不必实时服务改用job定时跑。这里有个容易忽略的点延迟和吞吐是跷跷板。batching虽然提高了吞吐却会让单个请求等待batch凑满延迟反而上升。必须根据业务要求找到平衡点。我以前上线一个命名实体识别服务时为了吃满GPU把所有请求都塞进一个大batch最终P99延迟从80ms涨到900ms用户那边直接报警。后来限制最大batch为16P99降到300ms吞吐量虽然降了一些但业务完全能接受。4.2 模型体积与量化怎么从2GB减到200MB用BERT类模型做服务模型文件动辄几百MB甚至上GB。模型体积直接影响服务启动时间、内存占用和推理延迟。最常用的优化手段是量化把FP32权重转成FP16或者INT8。FP16精度损失极小模型体积减半推理速度通常更快。INT8体积再减半延迟更低但精度可能下降1%-3%需要实际跑评估集验证。剪枝、蒸馏适合想大幅缩小模型结构的场景但工程复杂度更高。工具链上我实际跑通过的是OptimumONNX Runtime的组合。训练完的Transformers模型可以导出一个ONNX文件然后用ONNX Runtime做INT8量化from optimum.onnxruntime import ORTModelForSequenceClassification from optimum.onnxruntime import ORTQuantizer model ORTModelForSequenceClassification.from_pretrained(./ticket_classifier) quantizer ORTQuantizer.from_pretrained(model) quantizer.quantize_save(./ticket_classifier_int8)我经历的一个真实项目里模型从FP32的420MB量化到INT8的110MBP99延迟从180ms降到120ms精度从93.5%只掉了0.7个百分点。上线后内存占用也显著下降一个GPU卡可以同时放更多模型副本。不过要提醒量化不是一个无脑操作。进行量化前一定要先在留出的验证集上对比量化前后的指标。我见过量化后F1暴跌5个百分点的例子原因是模型本身校准数据分布和线上差异太大。量化遇到精度跳水先查校准集选得对不对再考虑换回FP16。4.3 成本AI工程的预算从来不只是“算力”很多团队估算AI项目成本时只盯着GPU价格实际上只算算力会严重低估总开销。从零到可持续运营至少要考虑这四类成本类型特点控制策略训练算力一次性投入按GPU时长计费用小模型基线用早停避免无效训练推理算力持续性投入按天/月计费量化、batch、动态缩减副本闲时缩容数据与存储持续增长容易被忽略只存干净数据模型文件定期清理旧版本人力维护最大的隐性成本自动化运维、完善文档降低认知负担推理成本是真正的长期大头。训练一个模型可能花几千块GPU推理服务则是每个月都在烧钱。几个亲测有效的省钱手段结果缓存对重复请求同样的输入命中缓存直接返回不经过模型。冷热降级高峰期开两个副本低峰期缩到一个副本甚至关停部分服务。在CPU上跑小模型如果模型已经量化和蒸馏到足够小CPU推理也能满足延迟就可以省下GPU费用。清理旧模型和旧镜像别小看占用的存储空间和镜像仓库费用积少成多。我之前遇到过一个案例客户要求上线后提供7x24小时的实时推荐服务我最初方案是两台GPU服务器常驻。后来分析流量发现深夜请求量是白天的1/10改成夜间接入CPU副本GPU在工作日白天才启动月度成本直接降了40%。这类看似不酷的琐碎优化恰恰是AI工程最值钱的部分。5. 模型要持续变好版本、监控、自动化的闭环5.1 版本管理做了吗不然某天你找不到“上次那个好模型”模型不像普通代码改完代码重新部署就行。每次训练数据、超参数、预训练权重的变化都可能让性能波动所以模型必须拥有“可回滚的版本”。我养成的最小习惯是每个模型产物带一个metadata文件{ model_name: ticket_classifier, version: v1.2.3, train_data_version: data_20240501, base_model: distilbert-base-uncased, metrics: {acc: 0.93, f1: 0.88}, created_at: 2024-05-02T10:00:00Z }训练流水线结束后把模型文件和metadata一起注册到MLflow Model Registry。这样线上模型出问题时你能快速查到这个版本对应的训练数据、配置和评测指标然后决定是修复还是回滚。如果没有这套记录出问题时你连“上一个正常版本长什么样”都说不出来只能重新训练损失惨重。5.2 数据漂移监控用简单统计拦截线上的“悄悄变差”模型刚上线时指标很漂亮过了一个月用户反馈变差。这种情况最常见的原因是线上数据分布发生变化——来了一批训练时没见过的新句式、新场景甚至业务规则改了导致标签定义都变了。这叫做数据漂移。我不会一上来就上复杂监控系统而是先做两个简单的统计对文本类任务统计请求长度分布、关键词出现频率分布。对分类模型统计线上预测类别占比和训练集的类别占比做对比。“预测类别占比”是最直接也最容易出问题的指标。如果训练集里“网络故障”类占40%线上预测也是40%左右说明分布稳定。如果某天突然占比变成60%那大概率是上下文变了模型可能正在失效。可以用KL散度做一个简单的漂移分数import numpy as np def kl_divergence(train_dist: np.ndarray, online_dist: np.ndarray) - float: return float(np.sum(train_dist * np.log(train_dist / (online_dist 1e-9)))) # 设定阈值超过0.2就触发告警 score kl_divergence(train_label_dist, online_label_dist) if score 0.2: alert(label distribution drift detected)这个方案从工程角度足够启动预警。后面要加更先进的漂移检测也可以但对大多数项目来说简单版本带来的收益已经非常大。5.3 自动化流水线让“重现”变得不依赖某个人初期跑训练靠手动执行命令完全没问题。但模型项目逐渐积累后“重新训练”会变成一个高频动作手动跑就会出错有人忘了激活虚拟环境有人把参数漏改有人训练中断没有续跑。我搭自动化流水线时先从不重的方案开始。最轻量的一套是Git Shell脚本 cron调度。代码里写好完整训练脚本用一个shell脚本串起来服务端定时调度。#!/bin/bash set -e # 拉取最新代码和数据版本 git pull origin main dvc pull # 激活环境并训练 source .venv/bin/activate python train.py --config configs/current.yaml # 保存模型并注册 python register_model.py如果你只有一台训练机器这套方案足够用。等训练任务变复杂、需要多机器协作和任务依赖时再上Airflow或Prefect。很多人一开始就上Kubernetes Airflow结果光维护调度就得额外投入一个工程师。工程里有一条底线工具复杂度必须匹配团队规模和项目阶段。6. 从零开始这一年我沉淀的五条经验清单6.1 先做最小可用的端到端再补花哨模块这条我放到第一位因为它是方向问题。如果你一开始就盯着“高并发、可扩展、平台化”大概率会在Hadoop、Spark、K8s这些重武器里迷路连一个能用的API都交付不出来。正确的走法是先用最简单的技术栈把“数据到服务”这条路跑通然后观察瓶颈在哪里再针对性地引入新工具。一个朴素的FastAPI服务就是起点它不丢人。6.2 日志从第一天就开始结构化出问题时日志是你的第一现场。我建议每一条请求都输出JSON格式的结构化日志至少包含{ timestamp: 2024-05-02T10:00:00Z, request_id: abc123, model_version: v1.2.3, latency_ms: 120, predicted_category: 网络故障, confidence: 0.92 }别用那种一眼扫过去的纯文本日志。结构化日志能方便地用jq、Grafana、甚至简单的Python脚本做查询和分析。排查问题时对比“某段时间所有失败的请求共同点”这种任务纯文本日志会让你想砸电脑。6.3 别一上来就上KubernetesDocker Compose真的够用很多教程把K8s讲成AI工程的标配但实际项目里一台GPU机器加Docker Compose已经能撑起很大的业务量。Compose的配置短小清晰services: api: build: . ports: - 8000:8000 volumes: - ./models:/app/models environment: - MODEL_VERSIONv1.2.3让你引入K8s再考虑的情况是多台机器组集群、需要自动扩缩容、有专职运维角色。否则维护K8s的成本会蚕食你做模型和业务的精力。我在一个日均几十万请求的服务里用Compose部署三个服务稳定跑了半年一点问题都没有。6.4 遇到问题先看版本再看文档而不是盲目复制代码我自己踩过最深的坑就是版本偏见。网上搜到的相似问题回答者用的PyTorch版本是1.x而我用的是2.xAPI变了照抄代码不但解决不了问题还引入新bug。从零起步时环境里每引入一个库都要养成习惯先确认版本再看对应版本的文档和changelog。升级大版本前一定要通读release notes别嫌麻烦。6.5 检验工程能力的唯一标准你的仓库能不能被一个陌生人跑起来到这一步时你可以做一个自我测试拉一个没有任何背景的同事把你仓库clone下来只允许看README和代码。他能不能在半小时内把服务跑起来不能说明你的依赖、启动步骤或文档有问题。能说明你的工程化已经过关了。最后分享一点个人心得AI工程能力不是靠大量阅读build出来的它是靠项目“喂”出来的。你会不断遇到“怎么这样”“为什么会这样”然后解决问题、记录问题、沉淀经验。这一套从零打怪的路我走完最大的收获不是学会了几个工具而是建立了对系统整体的掌控感。从数据到服务每一个细节都经过自己的验证即使后面出了问题我也知道往哪个方向去查找而不是对着黑盒干瞪眼。如果你正在这条路上摸索按着上面的路线走先解决眼前最直接的那个问题就是最好的下一步。