1. 这不是调包,是亲手搭起AI工程的骨架
“AI Engineering from Scratch”——看到这个标题,很多人第一反应是:又要从零写Transformer?又要手推反向传播?其实完全不是。我做AI工程落地快十年,带过二十多个工业级项目,真正从零开始的“Scratch”,从来不是重复造轮子,而是在没有现成流水线、没有统一数据规范、没有模型服务框架、甚至没有明确SLO指标的情况下,把一个想法变成每天稳定跑在生产环境里的AI能力。它不考你能不能复现论文,而考你能不能让模型在凌晨三点的订单洪峰里不掉链子,在标注员标错5%样本时仍保持92%以上的F1值,在客户临时要求加个“支持方言语音转写”的需求时,两周内完成数据采集、清洗、训练、部署、监控全链路闭环。关键词“AI Engineering”和“from scratch”合起来,本质是在说:当所有基础设施都不存在时,你靠什么让AI真正干活?这类项目适合三类人:刚从算法岗转岗想补工程短板的工程师、技术负责人要搭建首个AI中台、或是创业团队连GPU服务器都得自己选型采购的CTO。它不教你怎么调参,但会告诉你为什么PyTorch Lightning比裸写DistributedDataParallel更适合快速迭代;不讲BERT原理,但会拆解怎么设计一个能自动识别“标错标签”的数据质量探针;不堆砌Kubernetes术语,但会实测对比三种模型热更新方案在真实API延迟上的毫秒级差异。下面这些内容,全部来自我去年帮一家区域物流平台从零构建运单智能分拣系统的真实过程——没有预装的MLflow,没有现成的Feature Store,连Prometheus告警规则都是手写的。
1.1 为什么“从零开始”反而更接近真实战场
很多人误以为“from scratch”等于拒绝所有开源工具,这是最大误区。真正的从零,是拒绝“默认配置陷阱”。举个典型例子:某团队用Hugging Face Transformers训完模型,直接用pipeline.save_pretrained()存档,上线后发现推理延迟飙升300%。查了半天才发现,save_pretrained默认保存的是完整模型权重+tokenizer+config,而他们用的部署框架只加载了model.bin,tokenizer的vocab.json却漏掉了——结果每次请求都触发fallback逻辑,重新下载词表。这不是代码bug,是“默认路径依赖”导致的认知盲区。再比如,用Docker打包时习惯性COPY . /app,结果把.git目录、本地notebook、甚至测试用的10GB dummy数据全塞进镜像,最终镜像体积超2GB,CI/CD流水线拉取耗时4分钟,远超SLA要求的30秒。这些坑,只有当你亲手写Dockerfile、定义volume挂载点、配置healthcheck探针时才会暴露。我统计过近3年接手的17个故障案例,68%的根因不是算法缺陷,而是工程链路中某个“被默认掩盖的环节”失控——数据版本未锁定、模型输入校验缺失、GPU显存泄漏未监控、甚至日志格式不兼容ELK解析。所以“from scratch”的核心价值,不是证明你能重写CUDA kernel,而是强制你对每一层抽象都建立可验证的契约:数据层承诺输入shape和dtype,模型层承诺输出schema和latency分布,服务层承诺错误码语义和重试策略。这种契约思维,才是AI工程区别于纯研究的关键分水岭。
1.2 从“能跑通”到“可运维”的三道生死线
很多团队卡在“from scratch”的第一关:模型训练完,本地Jupyter能predict,但一上生产就崩。根本原因在于混淆了三个维度:
- 功能性正确(Functional Correctness):输入x,输出y,数值对就行;
- 工程鲁棒性(Engineering Robustness):输入x+噪声、x为空、x超长、x含非法字符,系统不panic,有明确fallback;
- 运维可观测性(Operational Observability):当y偏离预期时,能5分钟内定位是数据漂移、模型退化,还是网络抖动。
这三道线,每道都对应具体工程动作。比如“工程鲁棒性”,不能只写if len(text) == 0: return [],而要定义输入契约:text必须为UTF-8字符串,长度1-500字符,不含控制字符。然后用pydantic v2的StrictStr + constr(min_length=1, max_length=500)做schema校验,失败时返回HTTP 400 + machine-readable error code(如INPUT_INVALID_LENGTH),而非模糊的"Bad Request"。再比如“运维可观测性”,不是简单print("model loaded"),而是:
- 启动时上报模型hash、训练数据时间窗口、特征版本号到Metrics DB;
- 每次predict记录input_id、latency_ms、output_confidence、是否触发fallback;
- 每小时聚合统计:p95延迟、fallback率、confidence分布偏移(KS检验)。
这些动作,没有现成SDK能一键搞定。你得自己设计metrics collector,选型时考虑:Prometheus适合pull模式但需暴露/metrics端点,Datadog agent适合push但增加部署复杂度,最终我们选了OpenTelemetry + Jaeger,因为能同时捕获trace(定位慢请求)、metrics(看趋势)、logs(查细节)——这决策背后是权衡了团队现有监控栈、运维人力、以及未来要对接的APM系统。所谓“from scratch”,就是逼你把每个选择背后的trade-off摊开来看:选A省事但锁死生态,选B费劲但留出扩展空间,这才是工程决策的真实模样。
2. 核心模块拆解:从数据管道到模型服务的七层楼
AI工程从零搭建,我习惯把它比作盖一栋七层楼的建筑。地基不牢,上面再炫的装修都会塌。这七层不是理论分层,而是我在物流分拣项目里实际踩坑后梳理出的最小可行单元:
2.1 第一层:数据契约与版本控制(比模型更重要)
多数人把数据当“原料”,但工程视角下,数据是有生命周期的合约。我们定义了三类契约:
- Schema契约:用Apache Avro定义运单结构,字段名、类型、是否nullable、默认值全声明。例如:
{ "type": "record", "name": "Shipment", "fields": [ {"name": "tracking_id", "type": "string"}, {"name": "origin_city", "type": ["null", "string"], "default": null}, {"name": "weight_kg", "type": "double", "default": 0.0} ] }关键点在于default字段——它强制规定当上游缺失该字段时,下游如何填充,避免None引发的连锁崩溃。
- 质量契约:每批数据入库前必跑质量检查。我们用Great Expectations写规则:
expect_column_values_to_not_be_null("tracking_id")expect_column_max_to_be_between("weight_kg", min_value=0.1, max_value=50.0)expect_column_proportion_of_unique_values_to_be_between("origin_city", min_value=0.8)
这些规则不是摆设,而是CI/CD流水线的gate:检查失败,整批数据拒收,触发告警并通知标注团队。
- 版本契约:数据集不是“最新版”,而是
v20240515-001这样的语义化版本。我们用DVC管理,但做了关键改造:DVC默认只跟踪文件哈希,我们额外注入metadata.json,记录该版本的采样策略(如“剔除2023年前数据”)、标注质检通过率(98.2%)、与上一版的diff摘要(新增3个城市,删除2个异常仓库)。这样,当模型效果下降时,能立刻比对v20240515-001和v20240508-001的metadata,确认是否因数据策略变更导致。
提示:别用CSV做生产数据源。我们吃过亏——某次Excel导出CSV时,数字列自动转科学计数法(123456789 → 1.23E+08),下游解析成float后精度丢失。改用Parquet+Avro后,类型强约束+列式存储,问题根除。
2.2 第二层:特征工程流水线(拒绝“一次性脚本”)
特征工程常被当成“数据预处理”,但工程化要求它是可复现、可回滚、可监控的在线服务。我们没用Feature Store,而是用Airflow+Python构建轻量流水线:
- 离线特征:每日凌晨2点触发,读取DVC版本化的原始数据,经Pandas UDF计算特征(如“过去7天同始发地订单均值”),写入ClickHouse。关键设计:
- 所有UDF函数签名固定:
def calc_feature(df: pd.DataFrame) -> pd.DataFrame,输入输出都是DataFrame,便于单元测试; - 特征计算逻辑与模型训练代码分离,放在独立repo,通过pip install -e .安装,确保训练时用的特征代码与线上一致;
- 每次计算生成
feature_manifest.json,记录该批次特征的计算时间、输入数据版本、UDF hash,用于溯源。
- 所有UDF函数签名固定:
- 实时特征:用Flink SQL处理Kafka流,计算“当前小时始发地订单量”。难点在于状态一致性——Flink的RocksDB状态后端在重启时可能丢失部分计数。解决方案:每5分钟将状态checkpoint到S3,并在job启动时从最近checkpoint恢复,同时设置
state.checkpoints.dir指向S3路径。
注意:特征名称必须全局唯一且带业务域前缀。我们约定
logistics__shipment__7d_avg_weight_kg,避免不同团队命名冲突。曾有同事命名avg_weight,结果风控模型和分拣模型用了同一特征但含义不同,导致线上事故。
2.3 第三层:模型训练框架(不追求最先进,追求最可控)
从零搭训练框架,核心原则是隔离性:数据、代码、环境、参数四者必须严格分离。我们用以下组合:
- 代码:Git repo,分支策略为
main(prod-ready)、dev(开发中)、feature/*(特性分支),禁止直接push到main; - 数据:DVC remote指向S3,训练脚本通过
dvc pull -r v20240515-001拉取指定版本; - 环境:Conda env,但不用
environment.yml,而是用requirements.in+pip-compile生成requirements.txt,确保所有依赖精确到patch version(如torch==2.1.0+cu118); - 参数:Hydra管理,配置文件分层:
这样,换模型只需改# conf/base.yaml defaults: - override /model: bert_base - override /trainer: ddp model: _target_: models.BertForSequenceClassification num_labels: 5 trainer: _target_: pytorch_lightning.Trainer accelerator: gpu devices: 4conf/base.yaml里一行,无需动代码。
关键经验:永远用--dry-run先验证配置。Hydra的--dry-run会打印最终合并后的config,我们发现过两次严重问题:一次是defaults顺序导致trainer.devices被覆盖为1(实际要4),另一次是_target_路径拼写错误,运行时才报ImportError。提前发现,省去GPU集群上2小时debug。
2.4 第四层:模型序列化与版本管理(警惕pickle陷阱)
模型保存不是torch.save()完事。我们采用双格式策略:
- 训练态保存:
.pt格式,含完整state_dict、optimizer、scheduler、random state,用于断点续训; - 服务态保存:TorchScript或ONNX,仅含推理所需。
为什么不用pickle?因为pickle不跨Python版本。曾有模型用Python 3.9训练,3.10部署时torch.load()失败。TorchScript则无此问题,且能做图优化:
# 训练后导出 model.eval() traced_model = torch.jit.trace(model, example_input) traced_model.save("model.pt") # 服务态版本管理上,我们不用MLflow,而是自建model_registry表:
| model_id | name | version | framework | input_schema | output_schema | created_at |
|---|---|---|---|---|---|---|
| m-001 | logistics_shipment_classifier | 1.2.0 | torchscript | {"text": "string"} | {"label": "int", "confidence": "float"} | 2024-05-15 03:22:11 |
关键字段input_schema和output_schema用JSON Schema定义,服务层启动时校验请求是否符合schema,不符合则拒收。这比文档描述可靠一万倍。 |
2.5 第五层:模型服务化(API不是终点,是起点)
模型服务不是起个FastAPI就完事。我们定义了服务契约四要素:
- 协议:REST over HTTP/1.1,禁用gRPC(团队无C++维护能力);
- 接口:
POST /v1/predict,body为JSON,含request_id(用于trace)、data(输入)、meta(可选元数据如来源渠道); - 响应:强制包含
status_code(非HTTP status)、result(业务结果)、error(结构化错误)、latency_ms(服务端实测); - SLA:p95延迟≤200ms,可用性≥99.95%,通过Prometheus抓取
http_request_duration_seconds_bucket验证。
实现上,用Triton Inference Server而非自研,因为:
- Triton原生支持TensorRT优化,我们实测BERT-base推理延迟从180ms降到65ms;
- 内置模型热更新,无需重启服务;
- 多框架支持(PyTorch/TensorFlow/ONNX),未来接入新模型零改造。
但Triton配置极简主义:config.pbtxt只保留必要字段:
name: "shipment_classifier" platform: "pytorch" max_batch_size: 32 input [ { name: "input_ids" ... } ] output [ { name: "logits" ... } ]删掉所有注释和冗余字段,避免配置漂移。
2.6 第六层:可观测性体系(没有监控,等于没上线)
监控不是“加几个Grafana面板”,而是建立故障响应的黄金路径。我们监控四层:
- 基础设施层:GPU显存使用率(>90%告警)、CPU负载(>80%持续5分钟告警);
- 服务层:HTTP 5xx错误率(>0.1%告警)、p95延迟(>200ms告警)、QPS突降(环比-50%告警);
- 模型层:预测置信度分布(KS检验p-value<0.05告警,提示数据漂移)、fallback率(>5%告警);
- 业务层:分拣准确率(人工抽检,<95%告警)、异常单拦截率(<80%告警)。
所有告警通过Alertmanager路由,关键告警(如5xx>1%)直呼oncall工程师手机,次要告警(如置信度漂移)发企业微信机器人。特别设计了一个model_health_score指标:
score = 0.4 * (1 - p95_latency/200) + 0.3 * (accuracy/0.95) + 0.2 * (1 - fallback_rate/0.05) + 0.1 * (uptime/0.9995)score<0.8自动触发模型回滚流程。这比看单个指标更反映整体健康度。
2.7 第七层:CI/CD流水线(自动化不是目标,是底线)
我们的CI/CD不是Jenkins或GitLab CI,而是用GitHub Actions自建的极简流水线,共5个stage:
lint:black + isort + mypy,失败即阻断;test:pytest + pytest-cov,覆盖率<80%失败;build:docker build + docker push,镜像tag为sha256:${{ steps.build.outputs.sha }};validate:部署到staging环境,运行端到端测试(模拟真实请求,验证响应schema、延迟、准确性);deploy:手动approve后,kubectl apply -f manifests/,滚动更新。
关键设计:validate阶段必须包含模型效果回归测试。我们用历史1000条样本跑预测,对比新旧模型输出,要求:
- label一致率≥99.5%;
- confidence绝对差值≤0.05;
- 无新增fallback。
这步防止“性能提升但业务效果下降”的陷阱——曾有模型p95延迟降了10ms,但因优化了小样本路径,导致长尾case准确率跌5%,靠此测试捕获。
3. 实操避坑指南:那些文档里不会写的血泪教训
从零搭建AI工程,最大的成本不是时间,而是重复踩同样坑。我把物流项目里最痛的5个教训,按发生频率排序:
3.1 数据版本与模型版本的“幽灵耦合”
现象:模型A在v1数据上训练,上线后效果好;两周后数据升级到v2,模型效果骤降,但没人知道v2改了什么。
根因:数据版本和模型版本在代码里硬编码,如train.py里写死data_version = "v1",模型保存时没记录关联关系。
解决方案:
- 所有训练脚本必须接受
--data-version参数,且该参数值写入模型metadata; - 构建
data_model_link表,记录每次训练的(model_id, data_version, feature_version)三元组; - 在服务层,请求头加
X-Data-Version: v2,服务校验当前模型是否支持该版本,不支持则返回HTTP 422。
我们因此发现:v2数据里新增了“海外仓”字段,但模型输入层没适配,导致tensor shape mismatch。早发现,早修复。
3.2 GPU显存泄漏的“渐进式死亡”
现象:服务运行24小时后,p95延迟从120ms升到350ms,重启即恢复。
排查过程:
- top看GPU memory usage持续上涨;
- nvidia-smi发现
python进程显存占用从1.2GB涨到7.8GB; - 用
torch.cuda.memory_summary()发现cached memory暴涨,但allocated没变; - 最终定位:PyTorch DataLoader的
pin_memory=True+num_workers>0,在worker进程退出时未释放pinned memory。
修复: - 改用
num_workers=0(牺牲吞吐保稳定性); - 或升级PyTorch到2.0+,该bug已修复;
- 加监控:
nvidia_smi_dmon -s m -d 1 -o DT每秒采集显存,告警阈值设为used > total * 0.85。
实操心得:永远在staging环境压测72小时,用
stress-ng --vm 2 --vm-bytes 1G模拟内存压力,比单纯看文档靠谱。
3.3 特征计算的“时区地狱”
现象:实时特征“当前小时订单量”在凌晨0点突降为0,导致分拣策略失效。
根因:Flink作业用UTC时区计算,但业务方期望北京时间(UTC+8)。
解决方案:
- Flink SQL显式指定时区:
SELECT COUNT(*) FROM orders WHERE proctime >= CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai' - INTERVAL '1' HOUR; - 所有时间字段在源头就带timezone信息,用
pd.to_datetime(df['ts'], utc=True)标准化; - 在特征manifest里记录
timezone: Asia/Shanghai。
教训:时间永远是最难的分布式系统问题。宁可多花2小时确认时区,别赌“应该没问题”。
3.4 模型热更新的“原子性幻觉”
现象:Triton热更新模型后,部分请求返回旧结果,部分返回新结果,持续30秒。
根因:Triton的model_repository更新是文件系统操作,而模型加载是异步的,存在短暂窗口期。
解决方案:
- 禁用Triton的auto-reload,改用
tritonserver --model-control-mode=explicit; - 更新流程:
mv new_model/ /models/shipment_classifier_v2/;curl -X POST http://localhost:8000/v2/repository/models/shipment_classifier_v2/load;curl -X POST http://localhost:8000/v2/repository/models/shipment_classifier/unload;
- 加健康检查:
curl http://localhost:8000/v2/models/shipment_classifier_v2/ready,返回200才切流量。
这多出的3步,换来100%原子更新。
3.5 日志的“可检索性破产”
现象:线上报错,查日志发现全是ERROR: failed to predict,无request_id、无input snippet、无stack trace。
根因:日志只打level和message,没结构化字段。
重构后日志格式:
{ "timestamp": "2024-05-15T03:22:11.123Z", "level": "ERROR", "service": "shipment-classifier", "request_id": "req-abc123", "input_truncated": "SF123456789CN...", "error_type": "ModelInferenceError", "error_message": "CUDA out of memory", "stack_trace": "..." }关键点:
request_id由Nginx注入,贯穿全链路;input_truncated只截取前20字符,防日志爆炸;error_type是枚举值,用于ELK聚合分析。
现在查问题,Kibana里搜error_type: "ModelInferenceError",5秒定位。
4. 工具链选型实战:为什么我们弃用热门方案
选型不是比参数,而是比“谁让你少操心”。以下是物流项目中淘汰的热门方案及真实原因:
4.1 为什么不用MLflow?
MLflow的Tracking Server看着很美,但实际落地有三座大山:
- 权限模型太重:需要LDAP集成、RBAC配置,而我们只有3个工程师,没专职运维;
- Artifact存储绑定S3:但我们用MinIO,MLflow官方client对MinIO兼容性差,经常报
NoSuchKey; - 模型注册中心不可编程:想加个“自动归档旧版本”逻辑,得改MLflow源码。
替代方案:用DVC做实验追踪,dvc exp show看指标对比,dvc exp push存实验,简单粗暴。模型注册用自建PostgreSQL表,SQL写起来比MLflow API顺手十倍。
4.2 为什么不用Feast Feature Store?
Feast的架构图很炫,但对我们是过度设计:
- 需要独立部署Redis + PostgreSQL + Feast Core,运维成本高;
- 实时特征用Kafka+Flink已满足,Feast的streaming source抽象反而增加延迟;
- 离线特征用ClickHouse查询快,Feast的offline store(Spark)慢3倍。
我们用ClickHouse物化视图做特征缓存:
CREATE MATERIALIZED VIEW shipment_features_mv TO shipment_features AS SELECT tracking_id, avg(weight_kg) OVER (PARTITION BY origin_city ORDER BY created_at ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) as avg_weight_7d FROM shipments;查询毫秒级,比Feast快,还省了两台服务器。
4.3 为什么不用Kubeflow Pipelines?
Kubeflow的UI确实漂亮,但致命伤是调试成本:
- pipeline里一个组件失败,得进Argo UI看pod log,再ssh到node查容器,最后发现是
pip install超时; - 参数传递用YAML,类型不安全,
"123"和123混用导致下游报错; - 本地调试困难,没法像Airflow那样
airflow tasks test单步执行。
我们用Airflow,虽然UI简陋,但: - DAG代码即配置,
python dags/feature_pipeline.py直接本地跑; - 参数用
@task装饰器传,类型安全; - 错误堆栈直出,5分钟定位到
pandas.read_parquet()的S3 endpoint写错。
工程师的时间,不该浪费在UI上。
4.4 为什么不用Prometheus Operator?
Operator听着高大上,但实际是“运维负债”:
- CRD升级要手动apply,一次失败整个监控瘫痪;
- Alertmanager配置用Helm模板,改个告警阈值要
helm upgrade; - 我们只有1个SRE,他更愿花时间写Python脚本自动扩容GPU节点,而不是学Kustomize。
最终方案: - Prometheus用static config,
prometheus.yml直接git管理; - Alertmanager用
alert.rules文件,CI/CD自动reload; - Grafana dashboard用JSON导出,版本控制。
简单,可控,出了问题自己5分钟修好。
5. 从零开始的终极心法:把不确定性变成确定性
做完物流项目,我悟出AI工程从零搭建的终极心法:不是消除所有不确定性,而是把不确定性转化为可管理的变量。比如:
- 数据质量不确定?→ 定义质量契约,设阈值,超限自动拒收;
- 模型效果不确定?→ 建立效果回归测试,每次更新必跑;
- 部署风险不确定?→ staging环境72小时压测,达标才上prod;
- 故障定位不确定?→ 强制结构化日志+request_id+trace_id,5秒内定位。
这背后是一种思维转换:算法工程师问“这个模型准确率多少?”,AI工程师问“当准确率低于92%时,系统如何自动降级并通知?”前者关注静态指标,后者关注动态响应。
最后分享个小技巧:每周五下午,留1小时做“契约审计”。打开所有契约文件(schema.avsc、feature_manifest.json、model_registry.sql、api_openapi.yaml),逐行问:
- 这个字段还被用吗?
- 这个阈值是基于历史数据定的,现在业务变了,还合理吗?
- 这个错误码,前端真的处理了吗?
- 这个监控指标,上次告警是什么时候?为什么?
坚持半年,你会发现:系统越来越“懂自己”,故障越来越少,而你的焦虑,也从“会不会崩”变成了“怎么让它更快更好”。这才是AI Engineering from Scratch的真正回报——不是你写了多少代码,而是你让不确定性,变得可预测、可干预、可掌控。