“AI工程从零开始”这句话,我见过太多人理解成“从零开始学Python、学PyTorch、学深度学习”。真做过项目的人会告诉你,不是这么回事。AI工程的核心不是训练出一个模型,是走通一条从“想法”到“数据”,再到“模型”,最后到“能被人稳定调用”的完整链路。我见过太多卡在半路的案例:Notebook里准确率95%,一部署就崩;本地跑得飞快,换个环境全部报错;训练Loss降得很好,线上效果一塌糊涂。这些问题,都是因为只盯着“模型”这一个点,没有把“工程”这条线走通。
这篇文章,我把自己做AI工程项目的完整路线、实操细节、踩坑记录,原原本本摊开来讲。适合三类人:正在做第一个正式AI项目、不想只停留在跑通Demo的初学者;写过不少训练脚本、但没系统梳理过工程化流程的开发者;需要在团队里牵头做AI落地、要把想法变成可交付产物的人。
1. AI工程到底在解决什么问题
1.1 你缺的不是模型,是“从模型到服务”的这段路
很多技术帖子教你调参、教你刷榜,但没人告诉你,真实项目里一个模型从“能跑”到“能用”,中间隔着一片海。这片海名字叫工程化。
我见过一个很典型的场景:算法工程师花了两周微调一个模型,离线指标涨了三个点,老板很高兴,说赶紧上线。结果部署时发现,模型推理一次要200毫秒,线上接口要求100毫秒;输入文本里有大量乱码,训练数据里根本没见过;还有一批用户传的是图片不是文字,整个pipeline接不住。这哪是模型问题,这是从头到尾的工程问题。
研究者的终点是“准确率”,工程者的终点是“有人用、用得稳”。这意味着你得同时关心数据质量、评测口径、失败预案、部署环境、监控反馈。模型只是这个系统里的一环,不是全部。
1.2 为什么“从零开始”的路线最有价值
直接拿HuggingFace现成模型微调,或者套一个成熟的训练框架,当然能很快跑出结果。但我强烈建议你至少完整走一遍“从零开始”的流程——不是从写神经网络开始,而是从“自己动手处理数据、定义任务、搭训练循环、写评估脚本、做模型导出、写推理接口”开始。
原因很简单:框架帮你把路铺好了,你看不到路下面的地基。你以为数据加载是天经地义的,直到你自己写才发现不同格式的编码、字段缺失、标签分布偏移,都是要处理的细节;你以为评估就是print一个accuracy,直到你遇到一个数据类别严重不平衡,才发现准确率毫无意义。
我自己带项目时有个判断标准:一个工程师能不能在没有框架的情况下,用一个最小流程跑通一个真实任务。能,说明他理解每个环节在干什么;不能,说明他只是把框架用熟了。这两者的差距,在项目遇到环境依赖冲突、线上数据分布漂移、推理性能瓶颈时,会瞬间暴露。
1.3 AI工程的能力模型:三条线都要硬
做AI工程不是只懂算法就行,它需要三条线同时在线:
| 能力线 | 具体内容 | 对应的常见问题 |
|---|---|---|
| 算法线 | 模型选型、训练方法、参数调整、效果优化 | Loss不降、过拟合、效果平台期 |
| 数据线 | 数据采集、清洗、标注、增强、评测集设计 | 脏数据、标注不一致、线上线下分布不一致 |
| 工程线 | 环境管理、代码结构、训练部署流程、推理性能 | 环境冲突、OOM、推理延迟高、模型文件管理混乱 |
新手通常只盯着第一条线,踩了坑才发现后面两条才是大头。实际项目里,数据线花的时间最多,工程线决定模型能不能落地,算法线反而是最容易被“现有工具”替代的部分。想清楚这个,你的精力分配才不会跑偏。
2. 从想法到上线:一张可复用的路线图
2.1 前置准备:不是从“0”开始,是从“能跑”开始
“从零开始”最容易让人误入的歧途,是从装显卡驱动、配CUDA开始。除非你要自己从物理层面部署机房,否则千万不要这么干。你的起点应该是一套能跑通的最简环境。
我建议的前置条件很朴素:一台至少有8G显存的GPU机器(没有就用云GPU按小时租),Python 3.10+,conda或者venv做环境隔离。框架层面,PyTorch是主力,HuggingFace Transformers作为模型和Tokenizer的标准接口。这些就是全部。别在环境上追求“最新最全”,你会发现项目后半段比的不是谁环境炫,而是谁能稳定复现。
有一个我吃过亏的经验:新建项目时,第一时间把依赖版本固定写进requirements.txt,最好再生成一个lock文件。别指望“等以后再整理”,等到你踩完所有坑回头整理版本时,你已经分不清当时是哪个版本跑出来的结果了。我现在每个项目第一件事就是初始化一个干净的虚拟环境,然后每装一个关键包就记录版本,后面能少掉一半头发。
2.2 把需求翻译成一条可执行的pipeline
绝大多数项目失败,不是因为技术难度,是因为需求没有翻译成工程任务。你拿到的需求往往是:“帮我们做一个评论审核系统”“给客服做一个自动分类工具”“做一个知识库问答机器人”。这种描述不能直接开工,你要把它拆成pipeline。
我习惯拆成七个环节:数据采集、数据清洗与标注、评测集设计、基线模型、训练迭代、评估分析与归档、部署与监控。每个环节都要有明确的交付物:数据环节交付的是“清洗脚本+数据集文件”,评测环节交付的是“评测脚本+指标定义”,部署环节交付的是“推理接口+性能报告”。
关键原则:先定义“什么算做对了”。很多项目在训练开始后才争论评估标准,结果发现数据集跟指标不匹配,整个流程要返工。正确做法是在动手处理数据之前,就先把评测脚本写好,拿一个最简单的基线跑通,后面的所有优化都以这个评测为准。没有评测规则的优化,都是自我感动。
2.3 模型选型:别一上来就追大模型
现在聊AI项目,很多人开口就是“要不要上大模型”。我理解这个冲动,大模型确实能在很多任务上直接给出不错的效果。但在一个真实工程里,模型选型要考虑推理成本、响应速度、硬件资源、可维护性,这些往往比“更高一个点的准确率”重要得多。
我的选型逻辑分两条路。验证路:先用一个中小规模模型(比如BERT-base级别的几亿参数模型)快速验证数据和pipeline是否靠谱,这阶段目标是跑通全流程,不是刷指标。上线路:如果任务对效果要求极高且资源充裕,再考虑更大规模模型;如果任务相对垂直且数据量不大,中小模型微调已经够用,上线成本低得多。实际案例里,很多文本分类任务用几百M的模型微调就能达到95%以上准确率,完全没必要请一个几百B的大模型来做,响应又慢又贵。
记住一个观点:模型大小跟项目成功概率没有任何线性关系。跟项目成功强相关的是:数据是否干净、评测是否合理、pipeline是否稳定、失败预案是否充分。
3. 实操实录:一个文本分类项目从零到可部署的完整过程
这一章我直接用一个“工单分类”项目走一遍全流程。需求很简单:客服系统里每天有大量用户提交的工单文本,需要自动判断工单属于哪个类别(比如“退货退款”“技术支持”“账号问题”“物流查询”等),然后转给对应的处理小组。技术选型用BERT中文小模型微调。下面就按真实做事顺序来,所有代码都能直接用。
3.1 数据先行:5条脏数据比50条干净数据值钱
很多教程一上来就让你去下载某某公开数据集,真实项目里根本没这好事。我建议从零开始搭数据集的第一件事,是先人工收集一小批真实数据,哪怕只有三五十条。
为什么先要少的?因为少样本能让你快速看清任务的真实形态。你会发现自己以为的“分类规则”在真实文本面前有多天真:用户会写错别字,会用口语化表达,会一句话里掺杂两个意图,会发一个只有半截的句子。这些东西,你在臆想的数据集里永远见不到。
我自己做文本项目的标准流程,是先手工标注50条真实数据,不管准确率,先把数据的“脾气”摸清楚。下表是这类数据常见的原始形态:
| 原始文本 | 人工判断类别 |
|---|---|
| 我要退了这个东西,质量太差了 | 退货退款 |
| 登录一直提示密码错误怎么办 | 账号问题 |
| 我的快递卡在运输中三天没动了 | 物流查询 |
| 这个功能怎么用啊,找不到入口 | 技术支持 |
| 麻烦帮我看看订单号8934到哪了 | 物流查询 |
| 有优惠券吗,我想买那个套装 | 售前咨询 |
注意看最后一条:“有优惠券吗”严格说不是被动的售后请求,而是售前咨询。你不拿真实数据过一遍,根本不会意识到这六个类别之外还藏着第七类。这种认知,只有从真实样本里长出来。
3.2 数据清洗与划分:先定评测规则,再动模型
拿到原始数据后的第一件事不是训练,是清洗和划分。我见过太多人直接拿原始数据开训,最后模型被乱码带偏。清洗的核心是“去脏但不破坏语义”。不同项目策略不同,这里给出通用思路:
- 去掉HTML标签、URL、多余空白符、不可见字符。
- 统一全角半角(中文场景特别重要)。
- 修正编码乱码(比如UTF-8读成GBK的情况)。
- 去掉过短和无意义文本(但不迷信长度,有时候短文本恰好是有效意图)。
清洗代码很简单,难在你要理解每一条规则为什么存在。下面这段函数,我几乎每个文本项目都会复用:
import re def clean_text(text: str) -> str: # 去除 HTML 标签 text = re.sub(r"<[^>]+>", "", text) # 去除 URL text = re.sub(r"http\S+|www\.\S+", "", text) # 全角转半角(中文中标点转英文标点,统一格式) text = text.replace(",", ",").replace("。", ".").replace("!", "!").replace("?", "?") # 压缩连续空白 text = re.sub(r"\s+", " ", text).strip() return text清洗完做数据划分。文本分类任务常规做法是训练集70%、验证集15%、测试集15%。来自同一个用户的多条工单,要全部划分到同一个集合里,防止数据泄漏导致评估结果失真。更重要的是,划分必须发生在任何统计观察之前。你要是先看了全量数据再做划分,很容易在写代码时不自觉地“记住”测试集特征,等于作弊。
数据划分为三份,各有用途:训练集喂给模型学习;验证集在训练过程中做早停、调参;测试集只在最终评估时使用一次,并且必须保证模型从未见过。这个纪律性,比任何训练技巧都重要。
3.3 基线模型与评估脚本:没基线之前,一切调参都是瞎调
直接上BERT微调之前,强烈建议先训练一个简单的基线模型。基线不需要效果好,目的是给你一个“参照系”。没有基线,你无法判断后面深度模型提升的准确率里,有多少是模型结构带来的,有多少是数据质量带来的。
文本分类最简单有效的基线是TF-IDF + 逻辑回归,用scikit-learn几行代码就能完成:
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline import joblib pipeline = Pipeline([ ("tfidf", TfidfVectorizer(max_features=5000, ngram_range=(1, 2))), ("clf", LogisticRegression(max_iter=1000)) ]) pipeline.fit(train_texts, train_labels) joblib.dump(pipeline, "baseline_tfidf_lr.joblib") val_acc = pipeline.score(val_texts, val_labels) print(f"Baseline Validation Accuracy: {val_acc:.4f}")同时,你要把真正的评估脚本写出来。光看Accuracy在类别不平衡时会被骗,必须看每个类别的精确率、召回率、F1,还有混淆矩阵:
from sklearn.metrics import classification_report, confusion_matrix y_pred = pipeline.predict(val_texts) print(classification_report(val_labels, y_pred, target_names=label_names)) print(confusion_matrix(val_labels, y_pred))这一步的产出物不是“一个还不错的模型”,而是一套评估代码。后面的所有实验都跑同一套脚本,结果才能公平对比。我见过有人每次实验都现场写几行验证代码,今天看accuracy,明天看f1,完全是给自己制造混乱。评估脚本固定后,你才能真正开始做科学实验。
3.4 深度学习训练:从数据加载到模型微调的关键细节
基线确认后,开始切到深度学习模型。这里我用HuggingFace生态,记住一个原则:能用标准接口解决的事,不要自己造轮子。Tokenizer、Dataset、Trainer这些接口,比自己手搓稳定太多了。
模型结构选择,这个任务用bert-base-chinese就够。它的中文处理能力经过大规模预训练验证,模型大小在单卡上训练毫无压力。如果你的任务是在英文场景,对应换成bert-base-uncased即可。
Tokenizer和Dataset加载代码:
from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=len(label_names) ) label2id = {label: i for i, label in enumerate(label_names)} id2label = {i: label for label, i in label2id.items()} def encode(examples): return tokenizer(examples["text"], truncation=True, padding="max_length", max_length=128) from datasets import Dataset train_dataset = Dataset.from_dict({"text": train_texts, "label": [label2id[t] for t in train_labels]}) train_dataset = train_dataset.map(encode, batched=True)训练参数这里容易踩坑。我用一套自己的默认值起步,实测稳定:
| 参数 | 值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 | BERT微调的标准学习率,再大容易破坏预训练权重 |
| batch_size | 16 | 8G显存能跑得动,再大要看显存余量 |
| num_epochs | 5 | 配合早停,防止过拟合 |
| max_length | 128 | 工单文本一般不长,够用且省显存 |
| weight_decay | 0.01 | 轻微正则,稳定训练 |
训练过程代码,我用transformers的Trainer封装训练循环。很多人喜欢手写train loop,我理解那种想要掌控感的心态,但实际项目中Trainer真的更省心:
from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir="./checkpoints", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=5, weight_decay=0.01, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="f1", logging_dir="./logs", ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, tokenizer=tokenizer, ) trainer.train()有个细节值得展开说:为什么要用load_best_model_at_end=True?因为深度学习训练过程中,验证集效果不是单调上升的。模型可能在第三轮达到最佳,在第五轮过拟合了。如果不好好选最佳checkpoint,你最后保存的可能是“已经退化”的模型。True的作用是训练结束后自动加载验证指标最好的那一次权重。这个参数救了无数个实验,我见过太多人因为没开这个,拿着过拟合模型上线后骂效果差。
3.5 模型导出与推理接口:让模型离开Notebook
训练完模型,接下来是最容易被忽视但最能区分“Demo”和“产品”的环节:让模型能脱离训练环境被调用。
第一种方式是保存为Transformers标准格式,适合PyTorch生态内部使用:
model.save_pretrained("./final_model") tokenizer.save_pretrained("./final_model")第二种方式是为了服务化部署,导出为ONNX格式。ONNX的好处是推理框架多、跨语言支持好,C++、Java、Python都能加载,并且在CPU上也能有不错的推理速度。用optimum库导出:
optimum-cli export onnx --model ./final_model ./final_model_onnx导出ONNX后写一个推理脚本,加载ONNX模型做预测。ONNX Runtime在CPU上的推理速度,实测通常比PyTorch原版快1.5到3倍,对于没有GPU推理条件的场景很关键:
import numpy as np import onnxruntime as ort from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("./final_model_onnx") session = ort.InferenceSession("./final_model_onnx/model.onnx") def predict(text: str): inputs = tokenizer(text, return_tensors="np", truncation=True, padding="max_length", max_length=128) outputs = session.run( None, { "input_ids": inputs["input_ids"], "attention_mask": inputs["attention_mask"], "token_type_ids": inputs["token_type_ids"], }, )[0] pred_id = int(np.argmax(outputs[0])) return id2label[pred_id] print(predict("我想退货,东西坏了"))部署层面,如果只是内部工具,用Flask或FastAPI包一层HTTP接口就够了:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Item(BaseModel): text: str @app.post("/predict") def get_prediction(item: Item): return {"label": predict(item.text), "confidence": ...}这段就够内部系统用了。如果线上并发高、稳定性要求高,再考虑用专门的推理服务框架(比如Triton或TorchServe),但一开始别追求架构复杂度,先把接口跑起来。
3.6 一个小小的进阶操作:置信度阈值与“拒判”
工程里一个很实用的技巧,是给模型加一个“我不确定”的出口。现实中,模型不是每个输入都能精确分类的。有些工单写得太模糊,硬分类等于瞎猜,不如直接转人工。
做法是看softmax输出概率:最高概率大于某个阈值(比如0.8)才输出分类结果,否则输出“不确定,需要人工介入”。这个策略对线上效果的提升巨大,因为它把“模型做错”转化成了“模型知道自己不知道”,从产品角度讲,诚实比硬撑重要得多。实现时只需要在Softmax输出后加一个阈值判断,几分钟就能做出来,但体验完全不一样。
这个技巧还可以跟业务指标挂钩:比如目标是“减少20%的人工处理量”,同时又要求“不能让错误率超过1%”。简单分类做不到,加上拒判阈值后,模型只处理高置信度样本,剩下的给人,两个指标都能满足。这是工程思维比单纯堆模型效果更好用的典型案例。
4. 现场实录:从“能跑”到“能扛”的踩坑记录
这一章全是真金白银换来的经验。我不讲漂亮理论,只讲我在真实项目里见过、踩过的问题,以及排查思路。
4.1 五个高频翻车点和速查表
| 现象 | 可能原因 | 快速解法 |
|---|---|---|
| 训练Loss不降 | 学习率过大/过小、标签有噪声、数据没归一化 | 先跑几十步看趋势;用2e-5起步试一遍;检查标签是否错标 |
| 训练Loss降、验证Loss反弹 | 过拟合 | 加正则、加dropout、早停、减少训练轮数 |
| 验证集指标好、线上实际效果差 | 评测集与真实分布不一致、数据泄漏 | 检查数据划分是否混入用户在多个集合;重新采集线上真实样本做测试 |
| 推理速度慢 | 模型太大、没有用ONNX/量化、batch太小 | 换小模型、导出ONNX、开动态batch |
| 换环境跑不通 | 依赖版本没固定 | 用requirements.txt+lock file锁版本,用Docker固化环境 |
4.2 排查方法论:先看数据,再看代码,最后怀疑模型
遇到问题,我的排查顺序永远固定:先看数据,再看代码,最后怀疑模型。为什么?因为模型很少突然变笨,但数据经常会莫名其妙变脏。
举一个实际案例:有个项目训练过程中指标突然暴跌,团队第一反应是“模型参数出问题了”“学习率调坏了”。我让他们先看训练样本,结果发现数据pipeline里有一段新增的代码,把所有文本里的数字都替换成了固定占位符,导致包含“订单号”“手机号”的样本全部失去区分度。数据错了,再好的模型也白搭。
具体排查顺序是这四步:第一,从训练集里随机抽20条样本,打印出来肉眼看清洗结果是否合理。第二,检查数据划分的种子random seed是否固定,不固定会导致每次跑实验数据不一样。第三,打印模型输入输出的shape,确认维度对齐。第四,最后才动模型结构和超参数。这四步走完,80%的问题都能定位。
4.3 那些pipeline里的小陷阱
这里有三个容易忽略的细节,值得单独拎出来记:
第一个是Token对齐。BERT类模型的输入是Token ID,如果你的数据里有特殊标记、自定义词汇,必须保证训练和测试时用同一套Tokenizer,并且padding和truncation策略完全一致。很多人训练时max_length=128,推理时忘了传,默认长度不同,结果输入被截断,效果凭空掉几个点。
第二个是随机种子。深度学习训练里,数据加载顺序、权重初始化、dropout都是有随机性的。不固定种子,你每次跑的同一套代码结果都不同,甚至没法判断是参数生效了还是运气生效了。项目开跑前,在代码最顶部固定所有随机源:Python、NumPy、PyTorch三处都要设置。
第三个是ONNX推理时设备的设备对齐问题。ONNX模型虽然跨框架,但如果你用CPU训练,导出、推理时也要明确设备。我见过有人在GPU上训练、在CPU上推理,没问题的,但如果你用了GPU特有的算子,CPU上可能不支持,这时要留意算子兼容性。规律是:能不用自定义算子就不用了,省很多麻烦。
5. 工具体系与长期沉淀
最后一个层面,聊的是把一个从零开始的项目,变成一份能长期复用、可交底、可复现的资产。这一步是区分“做完一个项目”和“沉淀一套能力”的分野。
5.1 从脚本到模块再到服务:项目结构怎么组织
临时脚本总会随着项目推进变得不可维护。我推荐一套简单的演进路线,不要一上来就建复杂的微服务架构,先让代码分家,再谈扩展。
起步阶段:train.py、evaluate.py、predict.py,三个脚本东西不多,但职责要分清。进阶段:抽出data_processing.py(数据清洗函数)、model_utils.py(模型加载保存)、config.py(参数集中管理)。成熟阶段:变成一个标准的Python包,目录长这样:
project/ ├── data/ │ ├── raw/ │ └── processed/ ├── src/ │ ├── __init__.py │ ├── data.py │ ├── model.py │ └── train.py ├── models/ ├── eval/ │ └── evaluate.py ├── config.yaml └── requirements.txt配置管理用YAML,好处是参数和代码分离,不用改代码就能调参。每条实验跑完,把配置、代码版本、评估结果三个绑定在一起存档。这三个信息缺一个,这条实验将来就无法追溯。
5.2 评估看板与实验记录的仪式感
做AI工程项目,一定要有记录实验的习惯,不是写日记,是记关键信息:日期、数据版本、代码commit号、模型结构、关键超参数、训练集验证集指标。就这几列,再多不写。
为什么要记录?因为深度学习实验变量太多,不记录的话,三天后你看到“验证集指标突然涨了2个点”,你根本想不起来是哪个改动带来的。我见过最悲惨的场景是:实验跑出了最好效果,但没人记得当时用的哪个数据版本、哪个参数组合,导致线上复现不了,只能从头再调。
指标记录不追求花哨,一张表格就够了。每个实验结果加一行,长期积累后,你对自己项目的数据和模型会形成一种直觉——“这个数据分布下,这个模型大概能到多少分”。这种直觉,全是记录喂出来的。
5.3 项目“收尾”的正确姿势
项目做完不是把代码往仓库一推就完事。我给自己定了一个“收尾清单”,每次项目做完后对一下:
- 是否有一键运行的训练脚本和评估脚本?
- 是否有一个README说明项目背景、数据格式、模型结构、复现步骤?
- 是否导出过可直接部署的模型文件(不只是训练权重)?
- 是否锁定了依赖版本?
- 是否记录了“已知问题”和“未解决事项”?
这份清单看着不起眼,但决定了一个项目三个月后还有没有人能接手。真实团队里最常见的悲剧是:核心工程师在的时候一切正常,人一走项目就黄了。不是技术不行,是知识没有沉淀下来。README文档里写清楚“为什么这么做”,比写“做了什么”更重要——因为“做了什么”代码里看得到,“为什么”只有人记得住。
最后说点实在的
AI工程这件事,我做了这些年,最大的体会是:它不神秘,但确实每一个环节都有它的脾气。数据不是拿来就能用的,模型不是跑通就完事的,部署不是写个接口就结束的。每一个看起来“不应该有问题”的步骤,都藏着足够让你折腾一晚上的细节。
但恰恰是这个“从零到一”的过程最值得认真走一遍。你只有亲手处理过脏数据,才知道数据清洗规则的每一行代码为什么存在;只有亲自排查过训练崩溃,才知道随机种子和版本锁定有多重要;只有自己导出模型、写推理接口,才知道一个模型从Notebook到生产环境之间隔了多少层包装。
我的建议是:不要追求一步到位,不要贪多,找一个真实的小任务,几十条数据,一个中小型模型,把整条链路完整走一遍。中途遇到问题不要跳过去,每一个坑都是你未来判断力的来源。等你能独立把一个小项目从想法带到上线,再回看你会发现,那些曾经让你头疼的“工程细节”,已经变成了你信手拈来的基本操作。