最近我把一个自己跟了很久的项目收了个尾,名字就叫ai-engineering-from-scratch。这名字听起来有点中二,说白了就是一件事:不依赖现成的 AI 平台或封装好的 SDK,纯粹靠开源模型、公开数据集和基础工具,把一个能用的 AI 应用从零搭起来。这个过程中踩过的坑、总结出的方法,以及我对“AI 工程”这四个字的理解,值得单独拎出来写一篇,给正在走这条路的人一个参考。
这个项目解决的痛点很实际。现在市面上讲 AI 的教程铺天盖地,但大多数是“调包侠”路线——装个库、跑通 demo、调用 OpenAI 接口就完事了。一旦要落地到真实业务,面对数据清洗、模型调优、算力限制、推理延迟这些问题,很多人直接懵掉。ai-engineering-from-scratch这个项目想做的,就是把这些被跳过的“中间地带”补齐。适合的人群也很明确:有一定 Python 基础、想深入理解 AI 应用背后原理的开发者,以及正在做技术选型、需要评估“自建 vs 调用现成服务”的工程师。
我把这个项目的完整经验整理成下面四个部分:先讲整体设计和思路,再拆解核心细节,然后给出一条可落地的实操路径,最后聊聊我真实遇到的坑和排查方法。
1. 整体设计思路:为什么选择“从零构建”这条路子
1.1 “调包”与“自建”之间的真实差距
很多人会问,2025 年了,开源生态这么成熟,何必还要从零开始?直接from transformers import pipeline三行代码跑个模型不香吗?这个观点没错,但它掩盖了一个关键事实:从零构建的价值不在“造轮子”,而在“拆轮子”。
我用一个真实的对比数据来说明。同样是做一个中文文本分类任务,调用 Hugging Face 上的现成 fine-tune 模型,十分钟就能跑通。但当我尝试把这个模型部署到生产环境时,问题接连出现:模型体积 400MB 导致首包延迟高达 3 秒,CPU 推理单次耗时 2.5 秒,遇到特殊标点和网络用语时分类结果几乎随机。这些问题的根源在于,我只用了模型,却不了解模型内部的工作机制,更不知道如何针对自己的业务数据做适配。
而ai-engineering-from-scratch项目的核心设计思路,就是把整套 AI 工程链路完整走一遍。这条路分成五层:数据层(采集、清洗、增强)、训练层(模型选型、训练策略、调优)、评估层(指标设计、badcase 分析)、部署层(模型转换、推理优化、服务化)、迭代层(数据回流、模型再训练)。五层缺一环都会导致项目烂尾。
1.2 技术路线的选型逻辑
在技术选型上,我纠结过几个方案,最终确定的组合是:PyTorch 作为深度学习框架(动态图机制方便调试,生态最成熟)、HuggingFace Transformers 做模型和 tokenizer 管理(但不直接用 Trainer,自己写训练循环以便控制细节)、FastAPI 做推理服务、Docker 做容器化部署。
这个组合的选型理由很具体。PyTorch 的动态计算图在调试的时候几乎是降维打击,你能在任何一行代码处打印中间张量的形状和值;Transformers 提供了统一的模型接口和 tokenizer,省去了处理词汇表映射的麻烦;我自己写训练循环,是为了能精细控制梯度累积、学习率调度和混合精度,这些细节在标准 Trainer 里被封装成参数,出了问题很难排查。
另外我还刻意避开了重量级平台工具(比如云厂商的 AI 平台),理由有二:一是这些平台的抽象层级太高,用户的代码变成了配置文件,出了问题只能提工单;二是从学习成长的角度,自己搭一套能跑通全链路的系统,对理解 AI 工程的标准结构有不可替代的作用。这个决定让我后期排查问题的效率高了很多,因为每个环节都是我亲手搭的,哪里可能出现问题心里有数。
2. 核心细节拆解:数据、模型与训练的关键实操
2.1 数据工程的“脏活累活”决定了项目的天花板
任何 AI 项目,数据工程所占的时间和精力大概在 60% 以上。ai-engineering-from-scratch项目里我用的是公开的中文评论数据集,最初拿到的原始数据有多离谱?编码混乱、HTML 标签残留、大量重复样本,甚至夹杂着无意义的字符刷屏。如果不去管这些直接训练,模型精度大概率停留在 70% 上下,而且无法收敛。
数据清洗这个环节,我的具体做法是建立了三级处理流水线。第一级是规则清洗:用正则把 HTML 标签、URL、多余空白去掉;第二级是语义去重:基于 Jaccard 相似度先粗筛一遍,再用 MinHash 处理大规模近似去重,这步把训练集从 12 万条降到 8.9 万条,去重率高得惊人——重复数据对模型训练的影响比噪声数据更严重,会让模型对高频句子过拟合;第三级是标签校验:随机抽检 1000 条样本,人工核对标签准确率,低于 95% 就返工重新标注。
这里有个很多人容易忽略的细节:类别平衡性处理必须放在划分训练/验证集之前。我用了分层抽样保证类别比例在三个集合里保持一致。如果先切分再平衡,会导致验证集分布与训练集不一致,评估出来的指标完全没有参考意义。这套流水线跑完之后,训练数据干净了很多,后续模型收敛速度和最终效果都明显改善。
2.2 模型选型与“小而精”的调优目标
模型选型方面,我并没有一开始就上 7B、13B 的大模型。原因很简单,这项目要验证的是“从零构建”的工程链路,而不是堆算力。一个中文场景的分类任务,BERT 系列足以胜任。我选了相对轻量的模型,参数量 1 亿出头,单卡能够轻松训练。
但这不意味着任务简单。我给自己设定的调优目标是:模型体积压缩到原始大小的 25%,推理耗时降低 70%,同时精度下降不超过 2 个百分点。为此我做了三步优化:
- 知识蒸馏:用参数量更大的模型当 teacher,把知识蒸馏到小模型上,精度损失比直接用小模型从头训练低 50% 左右。
- 动态量化:把权重从 FP32 压缩到 INT8,模型体积直接减到四分之一,CPU 推理速度提升约 3 倍。量化后精度轻微下降,但结合蒸馏补偿后效果可以接受。
- 批处理优化:在推理服务层实现动态 batching,把单条请求的 GPU 推理转变为多请求合并推理,吞吐量提升了四倍多。
我发现很多人做 AI 项目容易走进一个误区:模型越大越好。真实场景里,推理延迟、部署成本和功耗往往比精度更重要。这个项目里确定的“小而精”路线,本质上是把工程约束纳入模型选型的考虑,这是 AI 工程和 AI 研究最明显的区别之一。
2.3 训练过程中的“玄学”与科学
训练过程中的细节决定了模型能不能收敛。我用了几组对项目影响极大的配置,值得记下来:
- 学习率调度:选用带 warmup 的线性衰减,warmup 步数设为总步数的 6%。这能避免训练初期 loss 剧烈震荡。如果不设 warmup,前几百步 loss 会出现明显尖峰,收敛速度变慢。
- 混合精度:AMP(自动混合精度)在单卡训练中提速明显,显存占用下降了约 30%。关键点在于 loss scaling 的配置,初始 scale 设大容易溢出,设小又可能欠拟合。我按官方推荐 1024 起步,遇到 overflow 后会自动调整。
- 梯度累积:这个小 batch 模拟大 batch 的技巧,实际使用时要注意同步 BN 统计量,否则模型效果会有微妙的影响。
训练中期出现过一个有意思的现象:loss 在稳步下降,但验证集上的 F1 值却停滞不动了。排查之后发现问题出在优化器状态和模型状态不同步——由于某个钩子写错了,模型在验证时没有切换到 eval 模式,Dropout 层还在工作,导致验证指标失真。这类“玄学”问题,多数是工程细节没做到位,而不是模型本身有问题。
3. 实操过程:从数据准备到推理服务的完整落地
3.1 环境搭建与关键依赖版本锁定
环境搭建这个环节容易出问题的地方在依赖兼容性。PyTorch、CUDA、Transformers 三个大件版本不匹配会导致各种奇怪的报错,比如CUDA error: no kernel image is available for execution on the device,这通常就是 CUDA 版本和 PyTorch 编译时的 CUDA 版本不一致。
我最终锁定的核心版本组合是:Python 3.10、PyTorch 2.1.0(CUDA 11.8 版)、Transformers 4.36.0。这几个版本经过实测兼容性较好。用requirements.txt锁死版本,比用pip install latest稳妥得多。
训练代码的结构,我用最直白的方式组织:
# 核心训练循环(简化版) for epoch in range(epochs): for batch in train_dataloader: # 把 batch 搬运到 GPU batch = {k: v.to(device) for k, v in batch.items()} # 前向传播 outputs = model(**batch) loss = outputs.loss # 反向传播 + 梯度累积 loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()这里有个关键操作:optimizer.zero_grad()一定要放在梯度累积的完整周期之后执行,而不是每个 batch 都清零。如果每个 batch 都清零,梯度累积就失效了,效果等同于小 batch 训练,模型性能会下降。
3.2 模型训练与评估闭环
训练时我使用了一个全量的评估策略:每训练 500 步就做一次验证集评估,记录 F1、精确率、召回率三个核心指标。这一步的意图很明确——监控指标曲线的变化趋势,比看单点数值更有价值。曲线能告诉你模型是否过拟合、学习率是否设置合理、训练是否陷入停滞。
模型训练过程中的 loss 曲线如果出现“断崖式下跌”,不用高兴,很可能是数据问题(比如某类样本特别多导致模型快速偏向该类)。如果出现“loss 平台期”,先尝试调整学习率,再考虑加大数据增强力度。我实际遇到的情况更隐蔽:loss 正常下降,但验证集 F1 在第十七万个 step 附近突然涨了十个百分点。后来查日志发现,是数据预处理时 Tokenizer 的截断策略在中途被不小心改掉了,导致训练数据前后分布不一致。这不是模型“突然变聪明”,而是数据“突然变简单”。这提醒我:任何环节的改动都要留下记录,否则排查起来感觉在读天书。
3.3 模型转换、压缩与服务化部署
训练完成后进入部署环节。这个环节的流程是:模型导出 → 量化压缩 → 编写推理服务 → 容器化打包。
模型导出这里有个大坑:直接torch.save(model.state_dict())保存的是权重,但部署时需要完整的模型结构。正确做法是导出为 ONNX 格式,或者保持 Transformers 的save_pretrained格式。我选择 ONNX 导出,因为 ONNX Runtime 在 CPU 上的推理优化做得更好,而且部署环境不用装 PyTorch,依赖大幅减少。
量化这步我用了动态量化,代码大概长这样:
import torch quantized_model = torch.quantization.quantize_dynamic( model, # 原始模型 {torch.nn.Linear}, # 只量化 Linear 层 dtype=torch.qint8 )注意,量化时如果模型里有 LayerNorm 这类对数值敏感的层,要格外小心,动态量化后精度波动最明显的往往不是 Linear 层本身,而是它后面的激活分布变化。我加了几个代表性样本做校准,帮助模型适应量化后的数值分布。
推理服务我用 FastAPI 写了一个极简接口,接收文本、返回分类结果和置信度:
@app.post("/predict") async def predict(text: str): inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128) with torch.no_grad(): outputs = model(**inputs) probs = torch.softmax(outputs.logits, dim=-1) confidence, label = probs.max(dim=-1) return {"label": id2label[label.item()], "confidence": float(confidence.item())}部署成 Docker 容器时,镜像体积的控制是个技巧活。基础镜像选择python:3.10-slim,比全量版小了近 1GB,安装依赖时顺手清理 pip 缓存。最终镜像体积控制在 900MB 左右,其中模型文件就占了 300MB。如果想进一步压缩,可以考虑挂在外部存储卷上,而不是打进镜像。
4. 常见问题与排查技巧实录
4.1 训练不收敛的排查思路
训练 loss 一点都不降,是每个 AI 工程师都遇到过的问题。我的排查顺序是:先看数据、再看模型、最后看训练配置。
- 数据问题:检查标签是否错乱、文本是否被错误截断、类别分布是否过于失衡。有一次我的 loss 一直降不下去,查到最后发现是标签映射文件编码错了,导致所有样本的标签都是同一个类别。
- 模型问题:换一个极小的数据集(100 条)跑过拟合测试,如果小数据都过拟合不了,那大概率是模型结构有 bug。
- 训练配置问题:检查学习率是否过大或过小。学习率过大容易导致 loss 爆炸,过小则龟速收敛。
4.2 推理服务的内存泄漏
服务上线后跑了大概两天,内存占用从 200MB 逐步涨到 1.2GB,最后触发 OOM 重启。这个问题的根源在于 PyTorch 的推理模式没有正确使用。我最初在预测接口里写的是model(inputs),这会构建完整的计算图,导致内存不断累积。
修复方式很简单——加@torch.no_grad()装饰器或者进入torch.inference_mode()上下文。但最容易被忽视的是 tokenizer 端的缓存问题。如果每次请求都重新加载词表,内存自然扛不住。正确做法是在服务启动时一次性加载到内存,后续请求复用。
4.3 量化后效果崩塌的抢救方案
动态量化后,我遇到过一个精度崩塌的问题:F1 从 0.87 掉到 0.65。排查发现,问题出在模型里有一层自定义的注意力掩码实现,这层实现没有注册为量化可忽略的模块,导致量化器尝试量化它时产生了灾难性的数值误差。
解决办法是对特定模块设置白名单,禁止量化该模块:
torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, # 只量化指定层类型 dtype=torch.qint8 )然后手动把不稳定层保持为 FP32。经过逐步排查,最终量化和未量化的模型精度差距控制在了 1.5 个百分点以内,符合预期的“不超过 2%”的目标。
4.4 服务启动慢和并发瓶颈
服务刚上线时,冷启动需要 5 秒以上,其中大部分时间花在模型权重加载上。解决办法有两个:一是把模型序列化格式从pytorch_model.bin转换为更轻量的 safetensors 格式,加载速度提升了 30%;二是在 Docker 启动脚本里增加预热请求——服务起来后自动发送一条预测请求,把模型真正加载到显存/内存里,避免第一个用户请求撞上冷启动。
并发处理上,FastAPI 默认线程池在 GPU 推理场景下容易把显存打满。最终采用了异步队列方案:请求进来先入队,推理线程从队列里批量取数据,合并成一个 batch 做推理,结果再按顺序返回。这个方式和第 2.2 节提到的动态 batching 是同一个思路,在真实业务里效果非常明显。
5. 沿着这条路还能走多远
项目主体已经收尾,但我自己的探索没有停。目前的版本解决了文本分类这个经典场景的全链路构建,而接下来的扩展方向也清晰——把同样的方法平移到其他任务(如文本相似度匹配、命名实体识别)上,在不同任务中验证这套工程流程的通用性。另一个让我感兴趣的方向是把向量检索纳入系统,做成一个小规模的 RAG 应用,这是目前从零构建 AI 应用最实用的路径之一。
关于ai-engineering-from-scratch这个项目,我个人的体会是:AI 工程的能力不是靠看文档和调参学会的,必须完整地经历一遍“从数据到产品”的闭环。这个过程里的每一个 bug 都在教你理解系统的运行逻辑,而不仅仅是模型的行为。这条路不轻松,但它带给你的是任何现成工具都给不了的东西——对系统的整体掌控感。
最后分享一个实操中总结的小技巧:每完成一个里程碑,把当时的依赖版本、关键配置、踩过的坑整理成一个NOTES.md放在项目根目录。三个月后再回来看,这份记录的价值比任何文档都高。因为 AI 工程迭代太快,记忆不可靠,而笔记是最忠实的技术合伙人。