先说个结论:如果只看招聘市场上的高频词,AI工程这个词已经被说到快没新鲜感了,但你真去问那些把“会用LangChain”“调过几个开源模型”写在简历上的人,十个里有八个答不上来一个问题——模型从加载到推理,中间到底发生了什么?token怎么切的?显存为什么爆?微调和全参训练的区别到底在哪一层?
这就是我想写这篇文章的原因。“ai-engineering-from-scratch”不是再教你调一个现成的API,而是把手伸进AI系统内部,用最原始的方式把一条完整链路搭出来。从数据清洗、分词、模型训练、评估到最终落地推理,每一步都用手写代码替代一键调用,你会发现那些所谓“高级技巧”,剥开之后全是朴素的工程问题。
这篇文章适合两类人:一类是刚入门但不想只做“调包侠”的开发者,另一类是已经用框架写了不少业务代码、但总觉得哪里差点意思的工程师。文章里所有的方案,都以“从零手写、不依赖重型框架”为原则,同时兼顾工业界真实落地时的选型思路。我不会给你列一个巨长的依赖清单,相反,我会告诉你哪些轮子必须自己造,哪些轮子真的没必要再造一遍。
1. 项目全貌设计:先定边界,再写代码
1.1 从零不等于从负数开始
很多人对from scratch有一个误解,觉得必须从线性代数、反向传播手推公式写起。真没必要,那是做研究,不是做工程。工程意义上的从零,指的是不把模型推理、训练流程、特征处理当作黑盒,每一步自己都能讲清楚、能改、能修。至于底层算子,PyTorch已经帮你优化到了极致,你非要去手写CUDA核函数,那是另一个领域的活了。
所以这个项目的边界我定得很明确:用Python手写一个完整的文本情感分类系统,从原始语料到最终部署成可以被HTTP调用的服务。模型部分可以实现一个多层感知机和一个小型Transformer,训练过程自己写反向传播。不回避免使用外部框架,但不允许直接调用model.fit()这类封装好的训练接口。
这个选型有几个好处。文本分类是AI工程里最经典、数据最容易获取、效果最容易量化的任务,适合用来建立完整认知。同时文本处理链路长,从分词、编码到模型输入输出,每一环都有足够的工程细节可以拆解。你做完之后,把模型换成视觉或语音,链路骨架是完全一样的。
1.2 总体架构的四个核心模块
整条链路拆成四个模块,分别对应AI工程里最高频的四个环节:
- 数据工程模块:负责语料获取、清洗、标注、切分,产出训练集和验证集
- 特征处理模块:把原始文本转成模型能吃的数值张量,包含分词、词表构建、向量化
- 模型训练模块:从随机初始化开始,手写前向传播、损失计算、反向传播、参数更新
- 推理服务模块:把训练好的模型权重固化成推理接口,处理真实请求
这四个模块是顺序依赖的,上一个模块的输出就是下一个模块的输入。这个结构你在任何AI项目里都会遇到,区别只是复杂度的差异。比如CV项目里的数据工程模块可能是图像增强和标注,NLP项目里是分词和词表,但逻辑位置完全一样。先把骨架搭对,后面换领域只是换皮。
2. 数据工程:决定模型上限的隐性工作
2.1 语料获取与清洗策略
情感分类最经典的数据集是IMDB电影评论,但国内网络环境下访问不一定稳定。我建议直接用中文电商评论数据集,比如某公开的购物评论语料,包含好评差评标注,规模在数万条级别。数据量不需要大,重点是结构完整、标签明确。
拿到原始数据后,清洗这一步踩坑最多。常见的问题包括:
- HTML标签残留,评论里夹杂着
<br>、 之类的实体 - 全半角符号混用,英文标点和中文标点交替出现
- 重复字符,比如“好好好好好”
- 表情符号和特殊Unicode字符
- 标签噪声,部分评论标注和内容明显不符
处理策略按优先级排序:先去除HTML标签和无关字符,再统一大小写和标点,最后做规则去重。注意清洗粒度要控制,过于激进的清洗会损失语义。比如“这家店的牛肉面绝了!!!”里的感叹号是有情感倾向的,不能一刀切全删。我最后的方案是保留连续感叹号和问号作为额外特征,后续输入到模型里辅助判断。
提示:数据清洗不是越干净越好。清洗的本质是去除与任务无关的噪声,但保留与情感表达相关的信息。每个清洗规则都要问一句:这条规则会不会把有用信息也删掉?
2.2 标签分布检查与切分策略
做数据切分前必须看标签分布。我拿到这批数据时,好评差评比例大约是52:48,基本均衡,不需要做重采样。如果你遇到9:1这种极端不平衡,要先考虑是采集偏差还是真实分布。真实业务场景里,投诉类文本天然少,这时候就不能简单随机切分,要保证验证集里每类标签都有足够样本。
切分采用分层抽样,比例70%训练、15%验证、15%测试。这里有个细节:先切分再清洗还是先清洗再切分?我是先做轻量清洗,再做切分,最后针对训练集做深度清洗。原因是深度清洗里的某些操作(比如基于统计的异常样本剔除)如果提前做,会引入数据泄漏——你用全量数据的统计信息去处理训练集,验证集就不纯粹了。
最后训练集大概4.2万条,验证集9000条,测试集9000条。这个量级做手写模型训练,CPU上跑几十个epoch完全可行,GPU其实都用不太上。
2.3 数据泄漏的隐蔽陷阱
这是数据工程里最阴的坑,我中过一次招。当时我在清洗阶段用全体数据的词频统计做了一个低频词过滤,把出现次数小于5的词语替换成UNK。乍看没问题,但词频统计是基于全部数据的,验证集和测试集的信息在清洗时就参与进来了。模型在验证集上表现虚高,上线后面对真实数据效果立刻缩水。
正确做法是:所有统计类操作只基于训练集,验证集和测试集只做无状态变换。这个原则同样适用于特征缩放、词表构建、标准化参数计算。记住一句话:验证集和测试集在模型开发完成之前,应该被你当成完全陌生的数据。
3. 特征处理:文本从字符变成数字的全过程
3.1 从原始文本到token序列
文本建模的第一个关键决策是分词粒度。中文不像英文天然有空格分词,可选方案有三种:字级别、词级别、子词级别。
- 字级别:每个汉字是一个token,实现简单,词表小,但丢失了词义信息,“苹果”和“香蕉”在字级别没有任何关联
- 词级别:先分词再映射,语义信息保留好,但依赖分词器质量,且词表大,OOV问题严重
- 子词级别:BPE或WordPiece,兼顾两者,是现代LLM的标配
我最终选择字级别加二元词组的方案。理由是这个项目重点不在分词器研究,而在训练和推理链路的完整性。字级别让词表控制在6000字以内,训练速度大幅提升,同时加上高频二元词组作为特征补充,一定程度上弥补了语义损失。如果你想做工业级应用,建议直接上BPE,我文末会给出扩展方向。
3.2 词表构建与序列填充的细节
词表构建的流程是:统计训练集中所有字和词组的出现频次,按频次排序,保留前N个,预留PAD、UNK、CLS三个特殊token。编号从0开始,0留给PAD,1给UNK,2给CLS。
序列长度对齐是新手最容易忽略的问题。评论长度差异极大,短的只有几个字,长的几百字。直接全量截断会丢失尾部信息,填到最大长度会浪费算力。我的做法是统计训练集序列长度的分位数,选择95分位作为统一长度。这批数据95分位大约在128个token,超过截断,不足补PAD。这样既保留绝大多数信息,又控制了计算量。
3.3 向量化:从token到模型输入
拿到token序列后,最简单的输入是one-hot向量配合Embedding层。我建议直接把token ID喂给模型,在模型第一层设置nn.Embedding,让模型自己学习token的稠密表示。这比预训练词向量效果好,也更契合从零训练的目标。
这里有一个工程实践细节:Embedding层的初始化方式。很多人直接用默认的随机初始化,训着训着发现模型收敛极慢。改用均匀分布在(-1/sqrt(vocab_size), 1/sqrt(vocab_size))内初始化后,收敛速度肉眼可见地加快。原因是最开始的随机值如果方差过大,经过深层网络传播后梯度容易爆炸;方差过小则所有token的初始表示几乎一样,模型需要花额外epoch去拉开距离。
4. 模型构建与训练:亲手写一遍才算懂
4.1 手写多层感知机分类器
第一个模型我用了一个三层的MLP,结构是Embedding层接两个全连接层再接Softmax。网络结构本身不复杂,但每一步都要自己实现。输入是[batch_size, seq_len]的token ID矩阵,经过Embedding变成[batch_size, seq_len, embed_dim],然后对整个序列做全局平均池化,压成[batch_size, embed_dim],再进全连接层。
代码结构如下:
import numpy as np class EmbeddingLayer: def __init__(self, vocab_size, embed_dim, seed=42): self.vocab_size = vocab_size self.embed_dim = embed_dim rng = np.random.default_rng(seed) bound = 1.0 / np.sqrt(vocab_size) self.W = rng.uniform(-bound, bound, (vocab_size, embed_dim)) def forward(self, token_ids): # token_ids: [batch_size, seq_len] return self.W[token_ids] # 输出 [batch_size, seq_len, embed_dim] def backward(self, grad_output, token_ids): # 根据 token_ids 将梯度累加到对应行 grad_W = np.zeros_like(self.W) np.add.at(grad_W, token_ids.flatten(), grad_output.reshape(-1, self.embed_dim)) return grad_W这里值得展开讲的是反向传播里的np.add.at。Embedding层本质上是一个查表操作,前向传播就是按token ID取行。反向传播时,梯度需要回传到被取到的行上,但同一行可能被多个token同时引用(比如一句话里出现两次“好”字),如果用普通的+=赋值,后一个梯度会覆盖前一个,只有np.add.at能做原地累加。这个细节不亲手写一遍,永远发现不了。
4.2 训练循环的五个关键步骤
训练循环本身不神秘,就是重复:前向传播、算损失、反向传播、更新参数、记录指标。但每个步骤都有值得推敲的细节。
前向传播时注意数值稳定性。Softmax里直接算exp(x),x稍微大一点就溢出。正确做法是先减去最大值再算指数,数学上等价但数值上安全。交叉熵损失同理,用log_softmax代替softmax + log,避免中间过程的精度损失。
反向传播是整个训练的硬骨头。三层网络还好,但我建议你把每一层的梯度推导都写在纸上,再和代码对照。比如全连接层的梯度是dL/dW = X.T @ dL/dy,偏置的梯度是dL/db = sum(dL/dy, axis=0),激活函数的梯度是逐元素相乘。这些看似基础,但Transformer里的梯度流动就是这些基本操作的复合叠加。基础不牢,后面学Attention只会越来越懵。
参数更新我用的是带动量的SGD。动量项让参数更新方向更平滑,比纯SGD收敛快,又比Adam更容易调参。学习率设置0.01,动量0.9,每10个epoch学习率乘以0.5做衰减。这个配置在MLP上表现得中规中矩,loss曲线平滑下降。
4.3 从MLP到小型Transformer
完成了MLP之后,我把模型升级成一个两层的Transformer编码器。这里不再从零造所有轮子,但关键模块比如Self-Attention和LayerNorm,我坚持手写。
Self-Attention的核心公式是Attention(Q,K,V) = softmax(QK^T / sqrt(d_k)) V。这里sqrt(d_k)的缩放至关重要,如果不缩放,点积结果随维度增大而增大,softmax会进入饱和区,梯度消失。实现时注意把Q @ K.T的结果先除以sqrt(d_k)再做softmax。
LayerNorm不是BatchNorm,它是在特征维度上做归一化,不受batch size影响,也不存在训练和推理时的行为差异。这里的细节是gamma和beta两个可学习参数,很多实现把归一化做完就忘了还应该有这两个参数。没有它们,LayerNorm就退化成单纯的标准化,模型表达能力会受损。
Transformer的训练策略和MLP有些不同。因为Transformer参数量更大,我用了Adam优化器,初始学习率降到0.001,并加了Warmup策略——前5个epoch学习率从0线性升到0.001,之后按步数余弦衰减。这些训练策略看似只是锦上添花,实际上对收敛效果影响很大,尤其是Warmup,它能避免模型在训练初期因为过大的更新步长跳出最优区域。
4.4 过拟合的识别与对抗
模型从MLP换成Transformer后,训练loss稳步下降,但验证loss降一阵子就开始反弹。这是典型的过拟合信号。你观察loss曲线会发现,训练loss还在继续走低,验证loss已经开始回升,这个拐点就是模型开始背诵训练数据而不是学习泛化规律的位置。
我的应对方案按效果排序:
- 增大Dropout比例:从0.1提到0.3,关键是放在Embedding输出和每层FFN之后
- Early Stopping:监控验证loss,连续三轮不下降就保存当前最优模型
- 增加L2正则:在损失函数里加上参数平方和的权重项,权重系数设为1e-5
- 数据增强:对文本做同义词替换和随机删除,但因为是字级别输入,增强效果有限
每个方案单独用效果都一般,组合起来效果明显。最终验证集准确率从82.4%提升到86.1%,测试集稳定在85.3%左右。
5. 实操过程全程记录:从数据到服务
5.1 环境准备与依赖清单
整个项目我用的是Python 3.10,依赖只有NumPy和一个小型HTTP框架,CPU环境下跑完训练大概3小时。GPU不是必须的,这个项目的目的就是把链路跑通,而不是在算力上炫技。
项目目录结构如下:
ai-engineering-from-scratch/ ├── data/ │ ├── raw/ # 原始语料 │ ├── processed/ # 清洗后的数据 │ └── splits/ # 切分后的训练/验证/测试集 ├── src/ │ ├── data_pipeline.py # 数据清洗与切分 │ ├── tokenizer.py # 分词与词表构建 │ ├── model_mlp.py # MLP实现 │ ├── model_transformer.py # Transformer实现 │ ├── train.py # 训练循环 │ └── server.py # 推理服务 └── checkpoints/ # 模型权重保存5.2 数据加载与清洗的完整代码
数据加载这一环节,我直接读取CSV格式的原始文件,每一行包含评论文本和标签两列。清洗函数我写成了可配置的管道模式,这样你可以随时增删清洗步骤:
import re def clean_text(text): # 步骤1:去除HTML标签 text = re.sub(r'<[^>]+>', '', text) # 步骤2:统一全角符号为半角 text = text.replace(',', ',').replace('。', '.').replace('!', '!') # 步骤3:处理连续标点,保留情感信息 text = re.sub(r'!{2,}', '!', text) text = re.sub(r'\?{2,}', '?', text) # 步骤4:去除多余空格 text = re.sub(r'\s+', '', text) return text.strip()注意步骤3的细节:很多清洗代码会把“!!!”直接删掉,我在前面说过这会损失情感信息。实际处理我选择压缩成单个感叹号,保留标点本身的情感倾向。这个决定让模型在验证集上提升了0.3%,算是小技巧。
5.3 训练过程的实时监控
训练脚本我加入了实时日志输出,每100个batch打印一次当前loss,每完成一个epoch打印训练和验证的准确率。这样能及时发现问题,不用等全部跑完才发现模型发散。
跑MLP时,第一个epoch的loss应该在2.2左右(接近随机初始化下的交叉熵),如果远高于这个值,大概率是学习率设置过大或初始化方差不对。Transformer的第一个epoch loss略低,在2.0附近,因为用了Warmup学习率还很低,参数更新幅度小。
训练过程中最常出现的问题是loss变成NaN。排查顺序是:先检查学习率是否过大,再检查softmax是否溢出,最后检查是否有除零操作。我的经验是NaN大多是梯度爆炸引起,解决方法是梯度裁剪,把梯度的L2范数限制在5.0以内:
total_norm = np.sqrt(sum(np.sum(g * g) for g in grads)) clip_scale = min(1.0, 5.0 / (total_norm + 1e-6)) grads = [g * clip_scale for g in grads]5.4 模型推理与HTTP服务
训练完成后,把模型权重序列化保存成npz格式。推理阶段和训练阶段有几个关键差异:Dropout必须关闭,BatchNorm如果用了要切换到eval模式,已训练的Embedding矩阵要冻结。
推理服务我用内置HTTP框架实现,接收JSON格式的POST请求,返回情感类别和置信度:
import json from http.server import HTTPServer, BaseHTTPRequestHandler class PredictHandler(BaseHTTPRequestHandler): def do_POST(self): content_length = int(self.headers['Content-Length']) body = json.loads(self.rfile.read(content_length)) text = body['text'] prob = model.predict(text) result = { 'label': 'positive' if prob > 0.5 else 'negative', 'confidence': float(max(prob, 1 - prob)) } self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps(result).encode())服务启动后,用curl测试一条“这家的外卖送得也太慢了,等了一个小时”,返回negative且置信度0.92。再测“味道很赞,下次还会再来”,返回positive。说明模型学到的不只是词表匹配,而是具备了一定的语义理解能力。
5.5 性能分析与优化
推理速度实测是单条请求约3毫秒,这个量级对小型服务完全够用。如果要进一步优化,可以考虑:
- 权重合并:把Embedding矩阵和全连接层的第一层权重合并成一个大矩阵乘法,减少一次访存
- 批量推理:一次处理多条请求,分摊Python解释器开销
- 半精度量化:把权重转成float16,显存和带宽需求减半,精度损失在情感分类任务上几乎可以忽略
我实际做了权重合并,单条推理从3毫秒降到2.1毫秒,效果明显。这个优化思路延伸到更大模型上就是算子融合,Transformer里的KV Cache、Flash Attention本质上都是在做类似的事。
6. 全流程踩坑记录与排查思路
6.1 验证集准确率莫名低于训练集
排查思路分四步走。第一步,检查数据切分是否真的分层,直接打印两个集合的标签分布对比;第二步,检查训练集和验证集是否存在特征分布差异,比如训练集全是电商评论,验证集混入了其他来源的文本;第三步,检查预处理流程是否对训练和验证用了不同的统计参数;第四步,确认Early Stopping保存的是验证loss最优的模型,而不是最后一次迭代的模型。
我这次遇到的是第三种情况。词表构建时用了全量数据的token统计,导致验证集里部分低频token在词表中已经出现过,模型提前看到了验证集的信息。修复后重新训练,验证集准确率从83.1%降到82.7%,看似变“差”了,但测试集表现稳定在85.3%,说明之前确实存在轻微泄漏。
6.2 训练loss不下降或下降极慢
loss不降先不要调模型结构,先怀疑两个因素。第一,学习率是否过小或过大。过小表现为loss缓慢下降但几乎停滞,过小表现为loss上下剧烈震荡不收敛。第二,数据输入是否有问题。打印几个batch的输入样本和标签,检查是否对齐,这个错误很隐蔽但极其致命。
我遇到的情况是词表映射时的编码bug,训练集和验证集用了不同的偏移量,导致模型看到的输入全是错位的。打印一条样本的token ID和对应的字符后,立刻发现问题。
6.3 显存或内存不足
内存不足最常见的原因是序列长度设置过长导致Embedding矩阵过大,或者batch size过大。解决方案是调小序列长度或batch size。另外注意NumPy在反向传播时会保存中间结果,如果你在实现时将中间变量都保存在实例属性里,显存占用会成倍增长。用完后及时释放,或者直接用局部变量传递梯度。
6.4 结果复现难
模型训练结果不稳定是正常的,因为随机初始化、数据加载顺序、浮点运算精度都会引入随机性。如果你需要固定结果,设置随机种子是第一步:
np.random.seed(42) import random random.seed(42)但注意即使设置了种子,在不同硬件和不同NumPy版本上,结果依然可能有微小差异。这是浮点运算的客观性质,不用强求完全一致,只要多次运行结果在正负0.5%范围内波动都属于正常。
提示:训练脚本里把所有超参数都写到一个config字典中,每次实验前记录config和最终指标,这样你回头看时才能搞清楚是什么改动带来了效果提升。
7. 上手实践时你需要记住的核心认知
从零手写这条链路,最终获得的不是某一个具体的模型,而是一个稳定的心智模型:任何AI系统都是数据、特征、模型、服务四个环节的组合,每个环节都是可以拆开检查、单独优化的。
你如果完全跟着文章的思路走一遍,大概需要两到三天的业余时间。MLP版本半天就能跑通,Transformer版本需要一整天调参。整个项目做完后,你可以沿三个方向继续扩展:把分类模型换成生成模型,体验自回归的训练方式;把文本换成图像数据,对比不同模态下的数据工程差异;把模型替换成开源预训练权重再微调,对比从零训练和微调在效果和成本上的差距。
我个人的建议是,不要急着往上叠新技术。先把MLP版本做扎实,确保自己不看代码也能徒手写出训练循环的每一步。然后换Transformer,感受一下参数量增大后训练策略的变化。最后再回头去看那些工业级框架的源码,你会发现里面全是这里踩过的坑的工程化解法。这条路走通了,你才算真正开始做AI工程。