做AI工程最容易被忽略的一个问题,恰恰是"从零开始"这四个字。过去一年我见过太多团队拿着现成的框架、模板和开源项目直接开跑,模型训得飞快,效果也好看,但一到线上就各种翻车——数据分布一变就崩、推理延迟压不下来、模型更新一次要烧掉整个周末去排查链路。原因不复杂:大家太习惯用别人搭好的积木,反而不知道自己的工程底座是怎么来的。
所以这篇文章想把"ai-engineering-from-scratch"这件事讲透。不是让你所有代码都从汇编开始写,而是把AI系统从数据、训练、评测到部署的每一条链路,都亲手捋一遍。我会按照我自己从零搭建一套AI工程的完整过程来展开,包括怎么选型、怎么搭底座、怎么设计数据管道、怎么跑训练闭环、怎么做评测迭代,以及最后部署上线时真正会踩的坑。这套路径适合两类人:一类是手里有业务问题、不想被现成工具绑架的工程师,另一类是刚入行想建立整体工程思维的学习者。
1. 为什么值得从零搭一套AI工程:重建逻辑链的价值
1.1 现在抄作业太容易,反而丢了基本功
我见过不少团队的做法是:拿到一个业务需求,先去HuggingFace找一个效果最好的模型,然后拉一个微调框架,配上数据集,跑几个epoch就上线。这套流程的确快,快到你一周就能做demo。但问题也藏在这种"快"里面——你完全不知道模型为什么效果好,也不知道换一个场景它为什么就失灵了。
AI工程和传统软件工程最大的区别在于:传统软件的行为是确定的,你写了什么逻辑就执行什么逻辑;而AI系统的行为是概率性的,你给它喂什么样的数据、用什么策略训练、在什么分布下评测,都会直接影响最终结果。如果你连自己的数据管道和评测逻辑都是别人框架里默认的,你就丧失了判断模型好坏的能力。
从零搭一套AI工程,不是要拒绝所有工具,而是要把每一层都拆开看一遍,重新选择、重新组装,建立属于自己的逻辑链。这条逻辑链包含了:数据从哪来、怎么清洗、怎么切分、用什么格式进入训练、评测指标到底在测什么、上线后怎么观测模型行为。
1.2 从零搭与"调包侠"的路线差异
同样的目标——做一个中文文本分类模型,两条路线的做法完全不同:
| 环节 | 直接调包路线 | 从零构建路线 |
|---|---|---|
| 数据获取 | 找现成公开数据集 | 采集业务数据并设计标注规范 |
| 数据清洗 | 用框架默认处理逻辑 | 针对业务场景自定义清洗链路 |
| 模型选型 | 直接用效果最好的模型 | 根据算力、延迟、数据量综合选择 |
| 训练框架 | 用一条命令跑默认配置 | 自己搭建训练循环,理解每一行代码 |
| 评测 | 看框架输出的准确率 | 设计业务指标和切片评测集 |
| 部署 | 用现成服务化组件 | 自己设计推理接口和弹性策略 |
调包路线不是不能说,但对于工程来说,它有一个致命的问题:当系统出问题时,你根本不知道问题出在哪一层。是数据污染了?是训练代码的bug?是评测集分布不合理?还是部署环境不一致?直接调包的时候,你面对的是一个巨大的黑盒。
而从零构建一遍,哪怕最终生产环境用的还是那些成熟框架,你对整个系统会有一种"底气"——出了问题你能定位到具体环节,能复现、能回滚、能优化。
1.3 什么项目适合从零起步
也不是所有项目都值得从零搭。我自己的判断标准是看三个条件:
第一,业务场景没有完全对标的现成方案。比如做通用的情感分类,公开数据集够用,直接调包完全合理;但如果做的是小众领域——比如某个专业领域的文本结构化、特定设备信号的异常检测——现成方案覆盖不了,这时候从数据管道开始搭建反而省时间。
第二,团队有长期维护和迭代的计划。AI系统上线只是开始,后续要不断加数据、调策略、换模型。如果这套系统只跑一次demo,没必要投入从零搭建的精力;如果打算运行两年以上,前期把基础打扎实就是最大的返工预防。
第三,对成本和延迟有硬性要求。现成方案往往为了通用性牺牲了效率和成本,从零搭建时你可以针对自己的数据规模和业务特点做专门优化,比如用更小的模型、更精简的数据管道、更高效的推理方案。
2. 先搭底座:环境、依赖与代码仓库的工程化
从零搭建第一步不是写模型代码,而是建立工程底座。这块做得不好,后面每走一步都在还债。
2.1 最小依赖原则:别为了"省事"引一堆包
我见过很多刚起步的项目,一个文本分类就引入了十几个依赖包,其中有几个其实根本没用到。依赖越多,环境越不可复现,版本冲突的概率也越高。
我的原则是:核心代码只依赖最必要的库,其余能力自己实现或延迟引入。一个从零搭建的中小型AI工程,依赖甚至可以精简到下面这些:
- Python 3.10+(基础运行时)
- NumPy(数组运算,几乎绕不开)
- 模型训练框架(PyTorch或TensorFlow二选一,推荐PyTorch,调试直观)
- 数据处理小工具(Pandas或纯Python够用,看数据规模)
- Transformers(用于加载预训练模型和分词器,前提是你决定用预训练模型)
其他像可视化训练曲线的TensorBoard、做数据增广的各种库,完全可以等到确定需要时再加。不要迷信"全家桶",每次加依赖都要问自己:这个功能我自己写要多久?引入这个包会带来什么维护成本?
2.2 用Python虚拟环境起手,保证可复现
在做任何训练之前,第一件事是锁定Python环境。这一步经常被忽略,但线上复现不了实验结果、队友机器跑不出同样指标,往往都是环境不一致造成的。
我使用的方案是结合venv和requirements.txt锁定环境:
# 创建虚拟环境 python -m venv ai-engineering-env source ai-engineering-env/bin/activate # 安装核心依赖 pip install numpy pandas torch transformers # 导出当前环境依赖快照 pip freeze > requirements.txtpip freeze导出的requirements.txt包含了当前环境中所有包的具体版本号,别人拿到它就能复现一模一样的环境。这一步看似简单,却是后面所有实验能够对比的基石。
更进一步,建议用pip-tools管理依赖——它会把顶层依赖和传递依赖分开管理,升级时不会把整个环境搞得乱七八糟。当然,如果你团队已经全面容器化,直接用Dockerfile固化环境更彻底,虚拟环境适合单人开发和快速迭代期。
2.3 数据、代码、模型分别放在哪里
从零搭建工程的时候,最容易出现的就是"所有东西堆在一个仓库"的情况。后面你会发现数据、代码、模型文件的更新频率完全不同,放在一起会互相污染。
我的习惯是把工程分成三个区域:
ai-engineering-from-scratch/ ├── src/ # 核心代码 │ ├── data/ # 数据加载与清洗逻辑 │ ├── train/ # 训练循环与模型定义 │ ├── eval/ # 评测逻辑与指标计算 │ └── serve/ # 推理服务与接口 ├── experiments/ # 实验记录 │ ├── run-001/ │ └── run-002/ └── artifacts/ # 训练产物(模型权重、词表、配置) ├── models/ └── data/代码部分用Git管理,模型权重和数据文件原则上不进入Git——它们体积大且更新频繁,放进Git会拖慢clone和commit速度。我自己会用一个内部存储区(或者云对象存储)专门放模型和数据集,用文件路径和版本号关联。
数据也要分三层:原始数据(不可修改)、中间数据(清洗后)、实验数据(切分后的训练/验证/测试集)。每层都记得带上时间戳或版本号,否则迭代到第五版的时候,你根本不知道上一次跑出那个好指标用的是哪份数据。
3. 最小可用的数据管道:样本、清洗与格式编排
3.1 样本格式设计:别用"一行一条"糊弄过去
很多入门教程喜欢用最简单的方式组织数据——每行一条样本,tab分列。小规模试验无所谓,但一旦涉及真实业务,你一定会需要更完整的样本结构。
我推荐在从零搭建阶段就使用JSON Lines格式(每行一个JSON对象)。它比纯文本带更多结构化信息,又不像完整JSON文件那样一次性加载进内存,适合流式读取。
{"id": "sample-0001", "text": "这家店的菜品很新鲜,服务态度也特别好。", "label": "positive", "source": "review-2024-05-01"} {"id": "sample-0002", "text": "等了一个小时还没上菜,体验极差。", "label": "negative", "source": "review-2024-05-01"}给每条样本放一个id和source字段,这个习惯在后期排查问题时非常关键。当你发现训练集和测试集之间存在数据泄露,或某类样本被模型系统性搞错时,能快速追溯到这些样本的来源。
3.2 清洗脚本的策略:宁可保守,不要激进
数据清洗是从零搭建里最不起眼但影响最大的环节。很多团队的痛点不是没有清洗逻辑,而是清洗逻辑太激进——把原本有用的信息也顺带删了。
我自己总结了一套保守的清洗优先级:
- 去掉完全无效的样本(空文本、纯HTML标签残留、乱码)
- 处理明显异常的编码(统一为UTF-8,处理中文全半角符号)
- 规则性修正(去掉URL、@用户等与业务无关的内容,但先确认是否真的无关)
- 去重(不只是完全重复,还有近似重复——比如只差一个标点的两个句子)
- 长度过滤(过短的样本可能信息量不足,过长的可能包含太多噪声,设上下限)
每一条清洗规则都要留下日志,记录清洗前后各删了多少条样本、为什么删。那些被删掉的样本不要直接扔了,归档到一个单独的目录。如果后面模型在某种输入上表现异常,你还能回头查是不是清洗规则误伤了这个类型的样本。
3.3 数据版本化:做一个简单的Dataset类
从零搭建阶段不需要上很重的数据管理平台,但一个简单的Dataset封装必不可少。它的职责是:提供统一的读取接口、记录数据版本、支持按配置切分训练/验证/测试集。
class DatasetRegistry: def __init__(self, raw_data_dir, version): self.version = version self.raw_path = f"{raw_data_dir}/raw-{version}.jsonl" self.cleaned_path = f"{raw_data_dir}/cleaned-{version}.jsonl" def load_cleaned(self): with open(self.cleaned_path, "r", encoding="utf-8") as f: return [json.loads(line) for line in f] def split(self, train_ratio=0.8, val_ratio=0.1, seed=42): samples = self.load_cleaned() random.seed(seed) random.shuffle(samples) n_train = int(len(samples) * train_ratio) n_val = int(len(samples) * val_ratio) return (samples[:n_train], samples[n_train:n_train + n_val], samples[n_train + n_val:])这个封装看起来简单,但它让整个实验过程都建立在"确定的数据版本+确定的划分逻辑"之上。同一个实验可以复现,不同实验可以对比,不会出现"你用的训练集跟我用的不一样"这种争论。
4. 训练闭环:从预训练到微调的技术选型
4.1 基座模型怎么选:不是越大越好
从零搭建AI工程,第一个纠结的问题就是选什么模型。我的判断标准很简单:从业务约束倒推。
- 数据量少(几千条级别):选择参数量小的模型,比如经典的中文B/S架构模型(参数量1亿级别),或者训练更充分的密集模型(参数量4亿级别),不容易过拟合,训练成本也可控。
- 数据量中等(几万条级别):可以选择中等规模的密集预训练模型(参数量6亿左右),微调效果和推理速度的平衡最好。
- 数据量大且算力充足:再考虑解码器大模型(参数量70亿或以上)用参数高效微调方式适配,比如LoRA(低秩适配)或QLoRA(量化低秩适配)。
有一个容易被忽略的问题:模型不是越大越好,而是越匹配越好。典型的例子是,在某垂直领域做短文本分类任务,实测下来,参数量2亿的模型在F1指标上可能比参数量70亿的模型还高,而且推理速度差了一个数量级。原因很简单——大模型需要更多数据才能发挥能力,数据不足时它只是在"背"训练集,而不是在"学"模式。
4.2 微调方式对比:全量微调、LoRA、还是只用预训练?
如果决定用预训练模型,从零搭建时面临的另一个选择是"怎么微调"。我把三种常见方式放在一起对比:
| 微调方式 | 适用场景 | 训练成本 | 显存占用 | 效果预期 |
|---|---|---|---|---|
| 全量微调 | 数据量充足,任务与预训练分布差异大 | 高 | 高 | 上限最高,但容易过拟合 |
| LoRA | 数据量适中,任务接近预训练分布 | 低 | 低 | 接近全量微调,稳定实用 |
| 冻结特征 | 数据量极少,或只做特征提取 | 最低 | 最低 | 稳定但效果上限有限 |
我的建议是:第一版从LoRA开始。它的训练速度快、显存占用低,跑一轮实验的成本小,便于快速验证数据管道和评测逻辑是否正确。等整个工程链路都跑通了、确认方案有效,再考虑要不要上全量微调提升上限。
4.3 训练循环中的三个高频坑
训练脚本本身网上有大把模板,但真正从零搭建时会踩到三个高频坑:
第一个坑是学习率设置不当。很多刚起步的人喜欢直接用默认学习率,结果模型要么不收敛,要么过拟合。经验法则是:先跑一个很小的实验(几百条样本、1~2个epoch),用不同学习率试,看loss曲线是否平滑下降。常见的中文预训练模型微调,学习率在2e-5到5e-5之间比较合适,但具体要观察训练集loss和验证集loss的差距来调。
第二个坑是批次大小与显存的平衡。批次大小太小,梯度噪声大,训练不稳定;批次太大,显存不够,且容易收敛到尖锐极小值,泛化变差。我通常的做法是:在显存允许范围内先用16或32,配合梯度累积(gradient accumulation)模拟更大的批次。
accumulation_steps = 4 total_batch_size = batch_size * accumulation_steps optimizer.zero_grad() for i, (inputs, labels) in enumerate(train_loader): outputs = model(inputs) loss = criterion(outputs, labels) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()第三个坑是评估频率太低。很多入门模板每个epoch才跑一次验证集,这对小数据集还行,数据集稍大就容易跑偏——你根本不知道模型在训练过程中哪一步开始过拟合。我的做法是:设定一个步数间隔(比如每500个batch)跑一次验证,记录loss和关键指标,后面可以很清晰地画出训练曲线,找到最佳checkpoint。
5. 评测与迭代:没有指标的工程等于玄学
训练完成不代表项目完成,评测才是从零搭建AI工程真正体现功力的环节。
5.1 评测集怎么建:先定业务指标,再定数据集
很多团队犯的错误是反着来——先指定一个公开数据集,然后跑一个公开的指标。这样评测结果好看,但和业务目标没什么关系。
从零搭建评测体系的正确顺序是:先想清楚业务上什么叫"好用",再把它翻译成可计算的指标,最后才构建评测数据集。
比如一个文本分类项目,业务目标可能是"减少用户投诉被误分到无关类别的比例"。那么核心指标就要看混淆矩阵,重点关注投诉类是否被分错,而不是只盯着整体准确率。
评测集至少要覆盖三类情况:
- 正常情况:代表真实业务中占大多数的一般样本
- 边界情况:容易混淆的样本,比如"贵"在价格语境和性价比语境下的不同情感
- 异常输入:空文本、超长文本、夹杂大量符号的噪声文本
这三类样本在评测集里比例多少,取决于你的业务对错误有多敏感。
5.2 评测脚本要可复现:固定随机种子与确定性推理
评测环节最常见的坑是"指标忽高忽低"。原因往往出在推理阶段没有固定随机种子,或者模型处于training模式而不是eval模式,导致BatchNorm和Dropout还在起作用。
模型测试时,务必显式调用.eval()方法,并且固定推理时的随机种子:
import torch import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) model.eval() set_seed(42) with torch.no_grad(): predictions = model(test_inputs)这一步做扎实之后,同一份测试集跑出来的指标才是稳定可比的。否则你会发现跑一次结果变一次,根本没有办法判断改动是好是坏。
5.3 迭代节奏:小步快跑还是大版本重训?
AI工程的迭代和传统软件的迭代不太一样。传统软件改一行代码可以立刻发版,AI系统改动数据或训练策略后,需要重新训练、评测、上线,周期明显更长。
我自己的节奏是"小步快跑+阶段Version":
- 每个迭代周期(比如一周)内,只改动一个变量——要么换数据、要么换超参、要么换模型结构,不要同时改多个。
- 每次改动都记录到实验日志里,包括数据版本、模型配置、训练参数、评测结果。
- 每个阶段(比如每个月)产出一个稳定版本,放到独立的artifacts目录中。
这样到了上线阶段,你完全知道当前模型的来龙去脉:用的哪版数据、在哪份训练配置下跑的、评测集上表现的边界是什么。
6. 部署与成本:从实验到可用的最后一公里
6.1 推理服务怎么设计:先按吞吐量定方案
很多从零搭建的AI工程在前半段都跑得很顺,一到部署就卡壳。原因是训练和推理对资源的需求曲线完全不同:训练阶段是"长时高吞吐",推理阶段是"低延迟高并发"。
如果业务的高峰QPS在50以下,最简单的方案是用FastAPI把模型包成一个HTTP服务,用Gunicorn配合少量worker就够了。
from fastapi import FastAPI from pydantic import BaseModel class InferenceRequest(BaseModel): text: str app = FastAPI() @app.post("/predict") def predict(request: InferenceRequest): result = model_pipeline.predict(request.text) return {"label": result.label, "confidence": result.confidence}如果峰值QPS到几百甚至上千,就要考虑批处理推理(把多个请求攒一批送入模型)、模型量化(INT8或FP16)、以及把模型放到GPU服务而不是CPU服务上。部署方案不是越复杂越好,而是匹配你的真实流量特征。
6.2 成本估算:训练一次到底烧多少钱
成本核算是从零搭建AI工程里最容易低估的部分。很多人只看见GPU的租赁价格,忽略了数据和评测的成本。
我以一个中等规模的微调项目为例大致算一笔账:
| 成本项 | 估算方式 | 一个月的典型开销 |
|---|---|---|
| GPU租用(训练) | 单卡A100每小时约20元,训练72小时 | 约1440元 |
| GPU租用(推理) | 单卡T4每小时约5元,常驻 | 约3600元 |
| 数据标注 | 按条计,几千条样本 | 数千元到上万元 |
| 存储与带宽 | 模型文件+数据集 | 每月几百元 |
| 人工成本 | 工程开发+评测迭代 | 视团队情况而定 |
这个账算完之后,你会发现推理服务的成本往往比训练还高——因为训练是一次性的,推理是持续性的。这也是我从零搭建时倾向于用参数量较小模型的原因:训练成本可能只差一倍,但推理成本在长期运行下可能差一个数量级。
6.3 观测与告警:模型不是发版就结束了
AI系统上线之后最怕的不是效果差,而是"效果有没有变差"你不知道。数据分布会漂移,线上输入的文本格式会变化,用户的行为模式也会演化。如果只靠发版那一瞬间的评测,系统随时可能悄悄劣化。
从零搭建时就可以内嵌一个简单的观测机制:
- 记录每条线上请求的输入长度、耗时、模型输出的置信度分布
- 定期(每天或每周)抽样线上样本,人工标注后与模型预测对比
- 设定告警规则:比如连续几小时平均置信度下降超过20%,就触发复审
这些观测数据存下来,就是下一轮迭代最可靠的训练和评测依据。很多团队等到模型效果崩了才开始找数据,手忙脚乱。而工程化做得好的团队,早就在日常观测里发现了蛛丝马迹。
7. 最后想说的几点经验
做完这一整套从零搭建之后,我最大的体会有三件小事,直接决定项目成败,但教程里很少提到。
第一件是关于失败记录的。每跑完一组实验,不管效果好坏,把结论和推测写下来。效果好的实验,你可能说不清为什么好;效果差的实验,你更说不清为什么差。如果复盘时只有结果没有过程猜测和验证,那下一轮迭代就是在碰运气。我自己每轮实验会顺手写几行notes,哪怕歪歪扭扭,后面回头看都有用。
第二件是关于复盘的节奏。从零搭建的工程,前两周一定是最痛苦的——处处受挫,连个能看的指标都跑不出来。但不要急着怀疑方案,先检查基础链路:数据清洗有没有bug、训练代码有没有用错API、评测逻辑是不是测错了对象。我见过太多团队在这种时候慌慌张张换模型换方案,结果发现是源头问题。
第三件是"够用就好"价值观。从零搭建AI工程的最终目标不是做一个复杂炫酷的系统,而是做一个能稳定解决业务问题、持续可迭代的系统。我之前帮一个团队优化过对账文本的分类处理,用最朴素的数据清洗加一个小模型的组合,就比他们之前用大模型配合大量调参的效果更稳定、成本更低。工程不是给模型炫技,而是给业务兜底。