ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

nanoGPT中文语料训练全流程复盘:从数据准备到过拟合判断

nanoGPT中文语料训练全流程复盘:从数据准备到过拟合判断

2026年4月7日,周日,我照例打开那台租来的服务器,准备跑一轮nanoGPT训练。没想到这一跑,把之前攒的一堆疑问全带出来了:数据集准备、参数设定、loss曲线的解读、过拟合的边界判断,还有那些藏在训练日志里不容易被注意到的小坑。这篇不是教程,就是一次完整训练过程的复盘记录,把我当天实际怎么操作、怎么调整、怎么判断的细节都写下来,给同样在折腾生成模型的朋友做个参考。

我用的数据集不是莎士比亚全集,也不是维基百科,而是自己准备的一份中文语料,大概25MB,不到20万条短文本,覆盖了一些产品描述、技术FAQ和短小评论。之所以选这个规模,是因为nanoGPT本身定位就是“小而能跑”,如果一上来就用大语料,反而不容易看清模型训练的各个阶段变化。小语料更容易出现可观察的过拟合现象,更适合用来理解训练机制。

1. 为什么选nanoGPT跑这轮实验

先说动机。我之前用现成的API调过几次文本生成,效果不错,但心里一直没底:这些模型内部到底怎么一步步学习的?为什么同样的超参数在大模型上有效、在小模型上就不一定?为什么有人建议先训到loss拐点再调学习率?这些靠调API是搞不明白的,必须自己把一条完整的训练链路跑一遍,才能建立直觉。

nanoGPT正好是这个场景最合适的实验载体。它把GPT的训练流程压缩到几千行代码以内,核心模块包括数据切分、tokenization、attention计算、训练循环和采样生成,每一段都能单独读、单独改。相比自己从零写一个Transformer,nanoGPT省掉了大量工程化的部分,保留的是真正值得理解的东西。

项目作者在说明里强调了它的定位:用于教育目的和快速实验,而不是生产部署。这一点很重要,意味着训练速度、并行优化、分布式支持都被特意简化了,跑起来不需要复杂的集群配置,一张普通GPU就能完成。对我来说,这种“接近底层但可运行”的特性最合适,既能看到每个参数如何流动,又有即时反馈。

还有一个实际考量是复现成本。很多大模型的配置动辄几十亿参数,即便能下载权重,重新训练一轮也是天文数字。nanoGPT最小的示例配置只有不到1000万参数,几分钟就能跑完一轮实验,这意味着我可以大胆做超参数对比,不用担心时间成本。本次训练定的目标是验证三件事:

  • 在规模较小的中文语料上,nanoGPT默认配置是否仍然能学到有意义的语言模式;
  • 训练过程中loss的下降曲线呈现什么形态,什么时候开始出现明显的收益递减;
  • 生成的文本质量和过拟合程度如何平衡,训练多少步能得到相对自然的结果。

带着这三个问题,我开始了当天的准备工作。

2. 数据准备与预处理环节的实际操作

2.1 数据来源与清洗

训练记录里最容易被略过的数据准备阶段,在当天占了将近一个小时。原始语料是从某个内部知识库导出的纯文本,格式混乱,有大量超链接、表格残留、重复段落,直接拿去训练会浪费大量token在学习噪声上。

我用Python脚本做了三件事:去掉无关符号和URL、合并断行文本、按一定长度过滤短句。最后留下的数量大约17.2万条,总字符数约2600万。清洗脚本需要特别注意编码问题,有些文本源会用GBK而不是UTF-8,在读取阶段就需要统一转换,否则会在切分时报出奇怪的索引错误。

with open("raw_corpus.txt", "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines()

第一版脚本跑完之后,我抽查了300条清洗后的文本,发现还有不少日期格式不对齐的情况,比如“2026/4/7”和“2026年4月7日”混用。为了减少分词后的碎片化影响,我又加了一个格式统一的正则替换规则,把常见日期格式转成同一写法。这种细节看似不影响训练能否跑起来,但会影响模型对数字和日期表达的连贯性,尤其是后续要做文本生成时,日期混乱会非常明显。

2.2 tokenizer的选择与踩坑

nanoGPT默认使用字符级tokenizer,也就是把每个汉字当作一个token。对于一个2600万字符的语料来说,字符级tokenizer的词汇表很小(只有几千个不同的字符),训练速度会很快,但生成结果的语义连贯性通常不如子词级或词级tokenizer。

我考虑过直接引入BPE tokenizer,但权衡后还是先用字符级跑一轮baseline。这样后续做对比实验时,可以先确认模型结构和训练参数本身没问题,再换tokenizer观察效果。字符级模型学到的更像“字形组合规律”,而BPE模型能学到“词义组合规律”,两个结果放在一起看,可以更清楚地区分模型能力的瓶颈到底在哪。

实践中有个重要的坑:如果用openai的tiktoken做BPE编码,数据切分后的训练样本格式必须和nanoGPT默认的字符编码保持一致,否则会出现输入输出维度对不上的问题。第2.1节里我们清洗过的数据,在字符级模式下直接写入train.bin和val.bin,每两三个小时训练一次也不需要重新处理数据。但换BPE时就要重新生成bin文件,并且val集的切分比例建议沿用默认的0.9/0.1,太小的验证集会放大loss波动的视觉幅度,影响我们对收敛状态的判断。

2.3 数据切分与二进制化

nanoGPT数据链路的核心是把纯文本变成二进制token序列。之所以用二进制格式,是为了在训练循环中减少CPU和GPU之间的数据搬运量,直接用内存映射读取。字符级tokenizer下,token就是字符在词汇表中的索引,存储时用uint16就够了,文件也不会太大。

我用官方提供的prepare.py改了一个版本,输出两个文件:train.bin(约2300万token)和val.bin(约260万token)。在训练脚本中,nanoGPT通过内存映射加载数据,每次迭代随机截取一段block_size长度的序列作为一条样本,这个机制使得模型在单个epoch内能看到大量随机窗口,相当于隐式做了数据增强。

数据切分这里还有个容易出错的地方:如果语料总长度小于train.bin所需的取样范围,代码会在边界截断时报错,表现为“IndexError: index out of range for tensor shape”。我当时第一次跑就遇到了,排查后发现是语料清洗后的行尾符残留导致总字符数偏少。加上尾部补全逻辑后解决:将超过边界的部分用wrap-around的方式补零。

3. 训练参数设计与loss曲线观察

3.1 模型配置与训练环境

当天用的服务器配置不算高,一张大约16GB显存的消费级GPU,CPU为8核,内存32GB。因为nanoGPT对显存的占用比较温和,16GB完全够跑1200万参数级别的模型,而且还能把batch size稍微放大一些。

我选的模型配置如下表所示:

参数名称数值说明
n_layer6Transformer层数
n_head6注意力头数
n_embd384嵌入维度
block_size256单次输入的最大token长度
batch_size32单次迭代的样本数
learning_rate3e-4初始学习率
max_iters10000最大训练迭代步数
grad_accum_steps4每更新一次权重累计的batch数

这个配置大致相当于一个极小的GPT-2,参数量在600万到1000万之间,浮点计算量在单卡上完全可接受。block_size选择256,而不是默认的1024,是因为语料里很多文本本身就不长,过长的上下文窗口会浪费训练数据,且增加显存开销。对于中文短文本场景,256通常已经够用。

学习率3e-4是nanoGPT在小型配置下的常用值。这里有一个关键认知:学习率并不是越大越好。过大的学习率会在训练早期把梯度推入难以收敛的区域,导致loss值在高位震荡;过小的学习率则让漫长训练的大部分时间都花在“低效率的缓慢下降”上。我在之前跑过一次lr=1e-3的配置,loss很明显在1500步之后开始反复横跳,最后又调回3e-4才稳定下来。

3.2 训练过程中的实时观察

训练启动后,我打开了两个终端:一个跑训练脚本,另一个用nvidia-smi -l监控显存和GPU利用率。nanoGPT默认每100步打印一次训练loss和验证loss,这个间隔对小语料来说稍微稀疏了一点,我把它改成每25步打印一次,以便更早发现异常。

前200步的loss下降非常快,从最初的约4.2下降到了1.8左右。这个阶段对应的是模型在快速学习最简单的词频规律和常见搭配,基本上就是“记住”了哪些字符经常一起出现。到400步时,loss降到1.3附近,下降速度开始放缓,可以明显看到曲线坡度变平。这是常见的“收益递减”特征:模型已经掌握了大部分显式的统计规律,接下来要学的则是更微妙的语义组合约束。

我把关键节点整理成下表:

迭代步数train lossval loss观察状态
254.164.12模型刚开始学习
2001.851.91快速下降期
5001.321.47速度开始放缓
10001.051.22出现明显val高于train
20000.881.10开始出现过拟合迹象
50000.570.98过拟合加深
100000.341.02附近验证loss几乎不再改善

从第1000步开始,val loss稳定高于train loss,且差值不断拉大,这是模型在训练集上过度拟合的经典信号。到了第5000步,train loss已经低于0.6,而val loss还停在1.0附近,虽然生成文本看起来更通顺,但泛化能力并没有同步提升。

关于loss曲线的解读,一个容易误解的点是:训练loss低并不代表生成质量好。对于语言模型来说,loss是交叉熵损失,它反映的是模型对下一个token预测的不确定性。loss越低,说明模型对语料的统计规律掌握得越紧密,但同时也可能意味着它记住了太多训练集中的偶然细节。所以判断训练是否成功,一定要同时看val loss和实际生成的文本,两者相互印证。

3.3 学习率调度策略的调整记录

nanoGPT默认提供warmup和余弦退火两种学习率调整方式。warmup阶段让学习率从小到大逐步上升,目的是避免一开始就用大学习率把权重冲乱;余弦退火则让学习率在训练中后期周期性下降,帮助模型在损失表面的低洼区域精细收敛。

我这次训练中设置warmup_iters=1000,lr_decay_iters=10000,min_lr=1e-5。在1000步之后,实际学习率会从3e-4沿余弦曲线下降到最低值1e-5。这个策略在小模型上非常有效,训练后期val loss的波动明显减小。

如果读者用的是自己的数据,我建议将lr_decay_iters设置为和max_iters相同,这样不会出现训练还没跑完学习率就已经降到最低的情况。另一个会被忽略的参数是eval_interval,默认值500,而eval_iters是200,表示每500步用200个batch去估算验证loss。对于小语料,可以适当降低eval_interval到100,方便更快观察验证集表现。

4. 训练过程中的异常排查与修复

4.1 显存不足与batch_size调整

第一次启动时,我按习惯把batch_size设成了64,结果运行到第2个iteration就报了CUDA out of memory错误。nanoGPT在分配attention中间变量时,显存占用量大致和batch_size × block_size × n_embd成正比,block_size越大,占用量越吓人。

把batch_size降到32、grad_accum_steps设为4之后,显存占用从接近15GB降到约9GB,问题解决。这里要说明的是,grad_accum_steps是“梯度累积”,效果等价于更大的batch size,但实际显存占用不增加,只是单步的训练时间略长。我后来试过batch_size=16 + grad_accum_steps=8的组合,发现效果类似,但训练速度更慢,不值得。

4.2 中文语料下的loss不降问题

在中文语料的第一次训练尝试里,我遇到了loss卡在3.5附近不动的情况,大约300步都没有明显变化。检查之后发现是语料中的标点符号和换行符太多,模型找不到稳定的预测目标。清洗之后,loss在200步内就顺利降到了2.0以下。

这提醒我一个规律:nanoGPT的训练效果高度依赖数据的“可预测性”。如果语料中包含了大量随机符号、短文本碎片、无意义的重复,模型会花大量容量去拟合噪声,造成val loss长期偏高。中文文本最好保留完整标点,因为标点在字符级模型中也是token,会影响上下文建模。

4.3 生成阶段的采样温度与top-k设置

训练完成后,我用了三种采样参数对比生成结果:

温度top_k生成效果描述
0.540通顺但略显保守,重复词多
0.8200流畅度和多样性平衡较好
1.2无内容变化大,但出现语法断裂

最合适的组合是温度0.8、top_k=200。温度太低会让模型反复输出训练集中出现频率最高的句子,温度太高则会让模型失去连贯性,把不相关的词强行拼在一起。top_k参数的作用是限制每一步采样只从概率最高的k个token中选,防止模型挑选那些概率很低但会造成语义断裂的词汇。

我在当天生成了一些测试句子,比如输入“这款产品的主要特点是”,模型能输出“续航能力强、体积小巧、适合户外使用……”这样通顺的后续。虽然句子偶尔会出现重复或逻辑跳跃,但作为一个小模型的表现已经超出预期。

5. 从这轮训练中得到的几个明确结论

跑完这轮nanoGPT,最直接的收获不是生成的文本有多惊艳,而是建立了一套“训练—观察—调整—验证”的闭环判断方法。有几个结论值得单独拿出来说。

第一,val loss的走势是判断模型泛化能力的核心指标,而不是train loss。训练loss会一路下降,哪怕模型已经开始严重过拟合,所以只看训练loss等于把实验做成了自欺欺人。我在5000步之后的train loss已经在0.57附近,val loss还停在1.02左右,如果当时只盯着train loss,说不定会误以为模型还在继续变好。

第二,小语料训练不宜跑太多步。我在max_iters=10000的配置下,val loss在5000步之后几乎不再改善,但train loss仍在下降。这说明模型把更多容量花在了记忆训练集的偶然特征上。对于2500万token规模的数据,5000到8000步之间已经接近实际效果的甜点区。

第三,字符级tokenizer是理解训练机制的入门方案,但不是生成质量的最优解。后续如果想提升效果,我计划换成BPE或句子片段tokenizer,对比两者的训练速度和生成质量差异。对于中文,BPE需要选择合适的词表大小,比如8000或16000,词表太大会增加嵌入层参数,太小则会把常用词拆得太碎。

第四,训练日志本身就是最好的实验笔记。我把当天所有命令行输出保存到了logs目录下,包括每次变更参数前的git diff记录,以及生成文本的样例,这样后续再复现或调整时不用靠记忆。这个习惯强烈建议保持,很多看起来“玄学”的问题,回查日志后往往能找到具体的诱因。

另外有一个小技巧:训练进行中如果想中断,再继续训练,可以直接修改脚本中的init_from参数为“resume”,指定输出目录中的ckpt.pt权重文件。这个过程不影响已训练的进度,是一个很实用的功能。

当晚最后我又用温度0.7、top_k=150跑了一批生成样本,效果比之前稳定不少。输入“请注意,使用本产品前需要”,模型输出了一句完整的注意事项:“请确保设备已充满电,并保持接口干燥,否则可能导致接触不良。”句子结构基本正确,内容也符合训练语料中产品描述的常见表达。作为一个训练了不到两个小时的1000万参数小模型,这个效果我已经比较满意。

下一步我准备做两组对照实验:一组换BPE tokenizer,一组把block_size从256增加到512,重点看val loss和生成连贯性的变化。到时候再写一份完整的对比记录,作为这个系列的后续内容。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进