1. 从零开始前,先把成本账算清楚
先说结论:所谓 "ai-engineering-from-scratch",在大多数人的预期里不应该是"复现ChatGPT",而是亲手把一条管线的每一个环节都踩一遍——数据怎么洗、token怎么分、模型怎么搭、梯度怎么流动、loss怎么掉、推理怎么部署。我见过太多人开局就立志训练一个千亿参数模型,结果卡在环境配置三个月,最后连有效的数据样本都没凑够。这条路走不长。
我给自己定的边界很明确:不做研究型创新,不做超大模型,只做一条"小但完整"的工程链路,以手写一个GPT风格的decoder-only语言模型为核心,参数量控制在1亿以内,在单张消费级显卡上完成训练、评估、部署闭环。目标不是刷榜,而是让每个模块都能讲清楚"为什么这样做"。
1.1 为什么我不建议一上来就"复现ChatGPT"
很多人忽略了一个事实:大型语言模型的效果,背后是超出个人承受范围的工程系统,而不只是模型架构。数据管道每天吞吐几十TB,训练集群的稳定性、断点续训、日志告警、评估回灌、RLHF偏好数据管理,每一项都是单独的工程方向。个人项目如果一上来就奔着"大模型"去,大概率会陷入资源黑洞。
更合理的方式是:用一个小模型,把全链路真实地走通。模型缩小的代价只是某些表面上不那么惊艳,但该用的技术一样都不少——tokenizer要自己训、注意力要自己写、loss要自己盯着掉、推理要自己优化。我在做完第一版9千万参数模型后,再回头去看相关论文里关于分布式训练、混合精度、长上下文的部分,理解速度完全不一样了。
1.2 我用一台消费级GPU就能跑通的资源方案
我这里给出一份非常务实的硬件账单,供参考:
| 资源项 | 我的配置 | 备注 |
|---|---|---|
| GPU | 单张RTX 4060 Ti 16GB | 显存16GB,训练和推理都能兼顾 |
| CPU | 8核 | 数据tokenize阶段比较吃CPU |
| 内存 | 32GB | 数据预处理阶段会短暂吃紧 |
| 存储 | 1TB NVMe SSD | 数据集原始文件约200GB,清洗后约80GB |
| 预算 | 约1.5万元人民币(含整机) | 个人项目足够 |
如果只有8GB显存的显卡,也并非完全不行,后面会专门讲梯度累积和offload策略。前期最容易被低估的是数据预处理对CPU和内存的要求,我第一版连个像样的分词模型都没训完,内存就崩了一次。建议先把数据规模切成小份,一步步放大。
2. 数据管线:文本清洗、分词与上下文窗口的取舍
2.1 从原始语料到高质量训练样本的清洗规则
我的数据来源是公开爬取的网页和多份开源文本,总原始量约200GB。很多人拿到语料的第一反应是直接扔进tokenizer,这是最大的坑。原始网页里藏着大量重复导航栏、cookie弹窗、页面模板噪音,训练出来的模型会在生成时突然冒出"点击此处了解更多"之类的片段。
我的清洗规则按照优先级排序如下:
- 删除HTML标签、脚本块、样式块、不可见字符。
- 按行去重,再按段落指纹去重(simhash的简化实现)。
- 过滤所有长度小于20个字符的段落,过滤纯数字、纯标点。
- 删除包含成人内容、暴力、赌博类关键词的文档。
- 语言识别过滤,只保留中文和英文语料。
- 对剩下的文本做段落级别的shuffle,避免同一个源站内容过度集中。
清洗之后,体积会缩水到40%左右。这一步不要用正则硬扛,正则只处理HTML和特定模式,其余用规则加权的方式做。我写了一个十层规则管道,每一层都记录过滤掉的样本数量和原因,方便后续复查。
2.2 BPE分词:把"词"拆成我们真正能控制的单位
后续所有参数都会落在token数量上,因此分词是第一个真正影响模型能力的技术决策。我选用Byte-Pair Encoding(BPE),直接用了HuggingFace的tokenizers库来训练一个中文+英文混合的tokenizer。词汇表定在32k,因为对于一个小模型,超过这个规模只会让embedding矩阵吃掉太多参数量。
训练BPE时有一点值得注意:要在UTF-8字节级别做基础切分,而不是直接按空格或字符。这样既能覆盖中文,又不浪费词表容量。我的配比如下:
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders tokenizer = Tokenizer(models.BPE()) tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True) trainer = trainers.BpeTrainer(vocab_size=32000, min_frequency=2) files = ["data/clean_zh.txt", "data/clean_en.txt"] tokenizer.train(files, trainer) tokenizer.save("tokenizer.json")实际训练过程中,我建议开一个"分词后语料长度"的统计脚本,看看每条样本平均token规模。这个数字直接决定你训练时的最大序列长度,也决定一个batch的显存占用。
2.3 上下文窗口与批次组成的联调
我最初的上下文窗口设为512,后来发现数据里很多长文段被硬生生截断,导致模型学不到跨段的依赖。之后调整为768,位置编码的表示能力有所增强,但训练时间也上涨了约20%。上下文窗口不是越大越好,它是一个工程权衡。
在组batch时,我会按序列长度做bucket分组,避免一条长样本拖慢整个batch。具体做法是:先把语料按token数粗略分桶,每个batch尽量由长度相近的样本组成,padding比例因此大幅下降。实测下来,吞吐量提升了30%以上。padding浪费的不仅是算力,更浪费了注意力矩阵里的无效计算。
3. 从Attention到参数计数:手写一个decoder-only骨干
3.1 GPT风格模型的整体数据流
我选用decoder-only架构,也就是常说的GPT风格。它的核心思路是把语言建模当成"给定前文,预测下一个token"的自回归任务。输入一个token序列,模型通过多层Transformer计算每步的隐状态,最后一层输出一个在词表上的分布,训练时用交叉熵损失对比真实下一个token。
实例骨干结构:
| 超参数 | 数值 |
|---|---|
| 词表大小 | 32,000 |
| 最大序列长度 | 768 |
| 隐藏层维度 d_model | 512 |
| 层数 | 8 |
| 注意力头数 | 8 |
| Feedforward隐藏层 | 2048 |
| Dropout | 0.1 |
| 总参数 | 约86M |
这套配置下,参数主要分布在三块:token embedding + output head(32000×512×2,但通常共享权重)、8层Transformer的注意力和FFN层、LayerNorm与位置编码。算出来之后我对参数分布有了直觉:embedding头在超小词表下依然占比很大,所以后来我参考常见做法,让输入embedding和输出映射共享同一套权重,参数立即省了约一半。
3.2 LayerNorm、因果注意力与位置编码的实现要点
因果注意力是decoder-only区别于BERT的关键。实现上最优雅的方式是先构造一个上三角掩码矩阵,把未来位置置为负无穷,再在softmax之前加到注意力分数上。我第一次手写时先在attention分数里用masked_fill生成了上三角的极大负数,走了不少弯路。核心工整的写法是:
import torch import torch.nn.functional as F def causal_attention(q, k, v, mask): scores = torch.matmul(q, k.transpose(-2, -1)) / (q.shape[-1] ** 0.5) scores = scores.masked_fill(mask == 0, float("-inf")) return torch.matmul(F.softmax(scores, dim=-1), v)位置编码我第一版用的是可学习的绝对位置编码,简单直接,小模型下完全够用。如果你打算做长上下文扩展,后续可以考虑旋转位置编码RoPE,它在外推性上明显更好,但当时的复杂度对第一版并不友好。
LayerNorm的位置也要注意:Transformer里放在Attention和FFN之前(pre-norm),比放在之后训练更稳定。我在实验日志里对比过,post-norm在同样的学习率下更快发散,原因在于残差结构和梯度范数的相互作用。这个细节很多入门资料不提,但实战影响极大。
3.3 参数清单:每个数字都该花在哪儿
理解参数去向是工程调优的基础。我把自己第一版模型的参数分布记录如下:
| 模块 | 参数量 | 占比 |
|---|---|---|
| Token Embedding(共享head) | 32000×512 = 16.4M | 约19% |
| 位置编码 | 768×512 = 0.4M | 约0.5% |
| 8层Attention层 | 约40M | 约46% |
| 8层FFN层 | 约25M | 约29% |
| LayerNorm及其他 | 约4M | 约5% |
从表中能看出,Transformer层占了大头,而其中Attention的参数量在d_model远小于词表时并不是绝对主导。如果以后要压缩模型,优先减层数比减隐藏维度更见效。这些认知只有在亲手计算后才会形成。
4. 训练策略:学习率调度、梯度累积与显存极限实测
4.1 学习率:为什么小模型也需要warmup
训练刚开始时模型权重接近随机,如果直接用比较大的学习率,梯度中的噪声会被放大,导致loss陡增甚至发散。warmup阶段的本质是让模型先在小步长下适应数据的梯度分布,再逐步过渡到目标学习率。我观察到warmup从零线性升到目标值,步数占总训练步数的5%到10%,之后采用余弦退火到最高学习率的十分之一。实测下来,不给warmup的对照组在初期loss曲线上会出现一个明显尖峰。
我的具体配置如下:
from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR def lr_lambda(step): warmup_steps = 2000 total_steps = 50000 if step < warmup_steps: return step / warmup_steps progress = (step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * (1 + math.cos(math.pi * progress)) optimizer = AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1) scheduler = LambdaLR(optimizer, lr_lambda)对于一个9千万参数的小模型,3e-4是我试下来较稳的最高学习率。如果batch size改大一倍,学习率通常也可以适当上浮,但幅度不要超过1.5倍,否则容易触发损失震荡。
4.2 梯度累积与显存极限:拿8GB显存训练9千万参数
我身边有朋友只有8GB显存,也想跑类似规模的模型。梯度的做法是梯度累积——把多个mini-batch的梯度累加后再更新一次,等效于扩大batch size。它的原理并不复杂:PyTorch默认每次backward都会把梯度累加到Parameter.grad中,我们只需要控制每隔多少个step执行一次optimizer.step()和zero_grad()。
我使用的等效总batch是768:实际每个batch为12条序列,梯度累积64步,12×64=768。这个数字让显存占用稳定在13GB左右,单纯堆batch size是办不到的。还配合了混合精度训练,用torch.autocast把大部分计算降为FP16,显存进一步缩减。注意loss scaling自动处理,我基本没有手动介入。
scaler = torch.cuda.amp.GradScaler() accum_steps = 64 for step, batch in enumerate(dataloader): with torch.autocast(device_type="cuda", dtype=torch.float16): loss = model(batch["input_ids"], batch["labels"]) / accum_steps scaler.scale(loss).backward() if (step + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad() scheduler.step()第一次训练时我漏掉了除accum_steps这个操作,导致loss计算被放大了64倍,学习率的有效步长也变得极大,模型在几百步之内loss就飙升到十几。后来加上这个除法,训练曲线才恢复正常。
4.3 Loss曲线诊断:过拟合、欠拟合与数据重复
训练过程中我发现一个有意思的现象:验证集loss在3.2附近开始缓慢上升,训练集loss还在继续下降。很多人第一反应是过拟合,但我的训练集规模足够大,模型容量很小,过拟合不太可能这么早出现。逐一排查后,问题出在验证集和训练集存在相似文档。原始语料里同一篇文章经常出现在多个镜像站,清洗阶段我只按精确指纹去重,没有做近似指纹,导致训练集里被学过的文章又出现在验证集,造成了一个假象。
把验证集严格按simhash去重后,验证曲线恢复平稳。这个教训给我一个提醒:loss曲线的拐点本身只是现象,归因时要回到数据分布上,模型只负责忠实地反映数据。
5. 不只看Loss:评估指标、早期停止与失败样本分析
5.1 从perplexity到下游任务:评估体系的搭建
Loss在下降不代表模型具备实用能力,我需要更贴近任务的指标。常用的perplexity等于交叉熵损失的指数形式,它反映模型对下一个token的置信度,但过于宏观。我为这个项目建了三层评估:
- token级别:验证集perplexity、重复率、生成多样性。
- 句子级别:完形填空准确率、句子接龙的自然度打分。
- 任务级别:简单的中文摘要测试集、英文冒犯性语言检测、基础常识问答。
任务级别测试我并没有额外训练,只是以zero-shot条件概率来度量。例如给模型一个不完整句子"太阳从___升起",看它选"东"和"西"的概率谁高。这类测试可以直接用模型的logprob获取,实现成本很低,但能明显反映预训练数据的质量和模型的基础世界知识。
5.2 评估集上的"诡异"行为与我对失败样本的归因
典型槽点:模型就是把你的提问"接龙"下去,而不是在回应。例如我输入"中国的首都是",模型更可能续写"中国"而并非"北京"。查明原因后发现,我的训练语料大量来自百科和新闻的完整段落,单纯的前缀继续概率统计,模型会把高频共现当成目标,更高频的关键词反而获得关注。这不是严重的错误,但它意味着模型没有在"问答"意图下被微调,纯粹是基座模型的正常状态。
随后我做了第二次小规模数据进行监督微调:准备了一批"问题-答案"对,用普通回答的前缀作为输入,目标输出放在下一段。几百个样例就能让模型在简单问答测试上的准确率从21%提升到47%。这让我意识到:基座模型的"常识"已经有一个底部,但有效的工程微调可以释放它。
6. 推理部署:量化精度、缓存策略与API封装细节
6.1 把训练权重变成可对外服务的推理接口
模型训练完后,下一步就是让它能以一个合理的延迟对外服务。我用TorchServe写了一个封装服务,对外暴露的API只有两个:/generate和/score。generate用于自回归生成,score用于计算某段文本的logprob。
其中最容易被忽视的是自回归生成的KV Cache。第一次实现时我每次生成一个token都重新算一遍完整输入的注意力,在768长度下生成100个token要跑几十秒。加上KV Cache后,只缓存已有token的Key和Value矩阵,每个新token只计算新位置的结果,生成端到端速度提升大约4倍。
KV Cache的核心代码思路大致如下:
def generate(model, tokenizer, prompt, max_new_tokens=128): input_ids = tokenizer.encode(prompt) past_key_values = None model.eval() with torch.no_grad(): for _ in range(max_new_tokens): outputs = model(input_ids=torch.tensor([input_ids]), past_key_values=past_key_values) logits, past_key_values = outputs next_token = logits[0, -1].argmax().item() input_ids = [next_token] if tokenizer.decode([next_token]).strip() == "[EOS]": break return tokenizer.decode(origin_ids + generated)这里要强调:如果没有给模型实现padded past_key_values,则无法把每步序列长度压缩为1。工程上做推理优化,第一步永远是缓存,这一步收益最大。
6.2 量化方案的取舍:INT8足够吗
我最初用FP16部署,显存占用约1.7GB,延迟也还行,但多路并发时GPU显存会迅速吃紧。而后尝试了INT8量化,参数量从86M降到实际存储约86MB,内存占用大约减少30%。注意这里的"减少30%"并不是80%,因为embedding表仍然按纯FP16保存了一部分权重,以及量化带来的额外结构。
我用的是动态量化:
quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 )实际操作中,LayerNorm和Embedding没有被量化,但Linear层大幅瘦身。跑了几百条生成结果对比,发现量化后在短句生成质量上基本持平,但长文本的重复率略有上升。如果你的应用是对话场景,这点差异可以忽略;如果用于高质量长文写作,建议保留FP16。
6.3 推理缓存与批处理:吞吐量翻倍的简单技巧
服务上线后,单实例的并发能力很快就成了瓶颈。我做了两件事:请求缓存和连续批处理。
请求缓存适合重复性的简单问题。对于完全相同的输入,直接把生成的输出缓存到内存里,命中后在毫秒级返回。考虑到这个模型本身就是低资源、低并发的个人项目,缓存能减少实际压力。
连续批处理相对更技术一些:把多个请求拼成一个batch,但各请求的长度不同。最简单的方法是padding到batch最大长度,缺点是越长越浪费。后来我在batch内按长度由大到小排列,然后用动态padding让最长样本单独捕获最长时间,其他样本可以提前结束,GPU利用率明显上升。小模型在低并发下,一次处理16个请求,吞吐量相比逐条处理大约提升了2倍。
更进阶的Continuous Batching我没有完全实现,核心思路是在一个大batch里动态插入新请求,同时允许完成的序列随时退出。以后如果我要部署更大一点的模型,这会是一个重要优化方向。
7. 从语言模型到reasoning model:下一阶段的延伸思路
7.1 为什么"会说话"不等于"会推理"
整个预训练阶段只学习了一个能力:根据前文续写下一个最可能的token。模型对我来说像一把基础武器,它知道"人没吃饭会饿",但它不会主动把多个这样的事实链式组合起来。如果你问它一个简单数学题,它照样会生成一大段貌似推理但事实完全混乱的内容。这是没有"推理监督信号"导致的,仍然只学会了下一词的分布。
近两年大家都开始讨论reasoning model,本质上是把链式推理过程显式加入训练目标。在模型更弱、数据更少的规模下,我能做的最直接一件事是收集一批带详细推理解释的数据,进行短链路的监督微调,然后再用RL风格的方法优化。不追求大型系统,但不能望而却步。
7.2 我在下一代项目里准备做的四件事
第一,把上面的9千万参数模型扩展成一个3亿参数的版本,采用RoPE位置编码,序列长度提升到2048。这个体量在单卡16GB显存下仍可训练,只是需要更多的耐心和更长的运行时间。
第二,构建一套"推理轨迹"数据标注管线:针对数学、逻辑、常识问答三类任务,收集包含中间步骤的问题-解析-答案三元组。在现有模型的输出基础上进行人工修正,效率比完全从零写要快很多。
第三,引入一个简单的过程监督信号:不光奖励最终答案正确,还要求中间步骤的每一步都能在语言逻辑上自洽。我会用一个小分类器去判断每一步是否与上一步和最终答案一致。这种方法需要的工作量大,但对reasoning能力的提升是直接有效的。
第四,在推理端搭建一套真正的连续批处理服务,加入FLOPs监控、延迟统计、错误回退机制。我希望下一版服务不止是"能跑",而是"能稳定的跑"。
从零开始做一个AI工程项目的价值,恰恰在于它逼你去面对所有技术选择。你不再有现成的完整系统可用,每个细节都必须自己决策和负责。我在这条路上踩过数据清洗的坑,被梯度累积的除法和注意力掩码卡过很久,也曾经在一个没有问题的验证集上浪费过一周时间。正是这些看起来琐碎的东西,构成了AI工程中不可回避的实体。下一轮扩展中,我依然会保留"从零开始"的习惯:先跑通一条最小但完整的链路,然后逐段放大。