第 24 章 · 代码实战
mini-LM 实战:把第四、五部分跑起来
最后一站。这一章我们拿一个真能训练、真能一个字一个字往外写的完整项目开刀:
用仓库里的 src/demo/mini_lm/ 配上 src/deeplearning/transformer/ 那一堆模块,
把公版全本《三國志演義》喂进去,从零训出一个会写三国腔的字符级语言模型。
第四部分的注意力、Transformer和第五部分的分词、embedding、生成、采样、困惑度,
全都会变成跑过、调过参数、留下训练日志的具体代码。本章所有命令、日志、数字,没有一个是编的。
读完这一章,你会明白
- 一个语言模型项目的完整链路:语料 → 清洗 → 分词 → 数据集 → 建模 → 训练 → 评估 → 生成 → 存盘;
MiniTransformerLM::Config里每个字段,对应第 17、18 章的哪个结构、对训练成本有什么影响;- 大语料下“逐字滑窗”为什么会被换掉,非重叠切块省在哪里;
- 贪心与温度/top-k/top-p 采样在代码和命令行两个层面各长什么样;
- loss 从 8.29 降到 3.62 的一夜发生了什么,以及这个迷你距离上千亿参数的大模型,隔着的正是第 20–22 章。
1. 任务与全景:先玩,再看代码
任务是语言模型最经典的那个:给一个开头,接着往下写(第 19 章)。
只不过这次喂给模型的不是 "abcabc..." 玩具语料,而是《三國志演義》全本 57 万字。
老规矩,先玩再看代码——下面这个实验台直接接上训练成果:页面一打开就在后台下载模型权重(约 7MB),
下完之前先用在线接口顶着,下完就完全在你浏览器本地推理,不再依赖任何服务器:
试试“话说天下大势”“孔明曰”“却说曹操”这类开头;温度调高更野、调低更稳。它只有 ~182 万参数,会断片、会循环,但人物名、对白腔、战争叙事已经像模像样。
这正是第 19 章那张图,每个方框都对应 transformer/ 下的一个真实模块。
左边是本书讲过的道理,右边是它在 mini_lm/main.cpp 与训练日志里的真身。
你不需要背代码,只要能把“这几行在干第几章那件事”对上号,就说明前面真的懂了。
2. 第一步 · 数据:把一本繁体古籍变成 57 万字语料
真实项目的第一个难点从来不是模型,而是数据。第 20 章讲的数据清洗(那一节里就有可动手的“洗语料”实验台), 在这个迷你项目里要一个不少地经历一遍。本次语料链路和每一步的理由:
# ① 下载公版原文(繁体 UTF-8,公有领域,可自由使用)
curl -O https://www.gutenberg.org/cache/epub/23950/pg23950.txt 1
# ② 用仓库自带 tools/clean_text.py --keep-non-ascii 去 Gutenberg 页眉页脚
# (许可证、制作人员信息混在正文里,不洗掉模型会学这些废话) 2
# ③ OpenCC 繁转简(t2s):「三國志演義」→「三国志演义」 3
# ④ 字符集收敛:只保留 汉字 + ,。 + 换行 4
# 结果:571,567 字,3,981 个不同字符(汉字 ≈ 3978 + 3 个符号)
- 公版书没有版权问题,这是选它的第一理由;顺带它文体统一、量够大。
- 页眉页脚里全是英文许可证,留着只会污染词表和学习信号。
- 繁简混排等于把"国"和"国"学成两个字,平白浪费本就可怜的参数。
- 为什么只留三种符号?字符级模型的词表大小 = embedding 行数 = 输出层宽度:每多一个稀有符号(花引号、【】、装饰符……),词表大一圈、小模型更难学,还会被极少数病态字符拖累 softmax。换行符单独保留——它就当段落/句子的边界,模型能学会"换行之后另起一段"。
语料备好,接着是 分词(tokenize)。mini_lm init 会扫一遍语料建词表、定结构、随机初始化并存盘(本节所有命令均为真实执行过的):
./bin/mini_lm init --model sanguo.param --corpus-file sanguo_clean.txt \
--tokenizer utf8-char \ 1
--model-dim 128 --head-num 4 --feed-forward-dim 512 \
--block-num 4 --context-size 48 --rand-seed 42 2
$ ./bin/mini_lm info --model sanguo.param
Tokenizer: utf8-char
Vocab size: 3981
Vocabulary: [第] [一] [回] [宴] [桃] [园] [豪] [杰] [... 3
utf8-char:一个汉字一个 token。若用默认字节级,一个汉字会被拆成 3 个 UTF-8 字节,模型要先学会"拼字节"才能谈语言,白白浪费容量。另外还有词级--tokenizer word,那是给英文语料准备的。- 这些就是下一节要逐一拆解的 Config 字段;
rand-seed让初始化可复现。 - 注意词表编号顺序:框架的分词器是“首次出现即编号”——语料开头是《第一回宴桃园豪杰三结义》,所以「第」拿 0 号、「一」拿 1 号……这带来一个副作用:两个模型的词表不能互换,模型文件只能绑定自己那份词表。
// ① 扫语料建词表:见过的跳过,新字符分配下一个 id(首次出现顺序)
for (char ch : text) {
if (char_to_id.count(ch) != 0) continue;
char_to_id[ch] = vocabulary.size();
vocabulary.push_back(ch);
} 1
// ② 建 embedding 表并按 Xavier-uniform 随机初始化
const double limit = std::sqrt(6.0 / (vocab_size + model_dim));
std::uniform_real_distribution<double> dist(-limit, limit); 2
// ③ 存盘:结构+权重写 .param(二进制),字符表写 .param.vocab(纯文本) 3
- 所以词表顺序就是"语料开头长什么样"的顺序(《三国演义》开头是「第一回宴桃园…」,故「第」拿 0 号);两个模型的词表不能互换,
LoadModel时会校验词表大小一致。 - Xavier 均匀分布(第 9 章讲的初始化)按
rand_seed可复现——同一个种子,两次 init 出来的模型一模一样。 - 一个模型是两个文件,拷贝/备份必须成对;词表文件里还能看到 tokenizer 类型(
utf8-char)。完整格式约定见docs/mini-lm-demo.md。
词表大小同时是 embedding 的行数和输出层的维度,属于模型结构,存进 sanguo.param.vocab sidecar 文件。
之后训练/生成时遇到词表外的字符,只会被跳过并告警(skipped N characters outside the model vocabulary),
想支持新字只能连词表一起重新 init。所以 init 用的语料,字符覆盖一定要够。
3. 第二步 · 建模:一个 Config 描述整座 Transformer
第 17、18 章拆过的所有结构——embedding、位置编码、多头注意力、LayerNorm、FFN、残差—— 浓缩进一个 Config 结构体。这次填的是真刀真枪的数字:
vocab_size_ = 3981 // 汉字+,。+\n(扫语料得出,冻结) 1
model_dim_ = 128 // 每个 token 的向量维度 2
head_num_ = 4 // 多头注意力(第 17 章) 3
feed_forward_dim_ = 512 // Block 内 FFN 宽度(第 18 章) 4
block_num_ = 4 // 叠 4 层 Block 5
backbone_type_ = BACKBONE_DECODER // causal mask,不能偷看未来 6
max_context_size_ = 48 // 上下文窗口:一次最多看 48 个字 7
- 词表大小同时决定 embedding 行数和 LM 输出头宽度(第 19 章)。
model_dim是信息流动的“管道宽度”,训练成本按平方增长——从 32 提到 128,每样本耗时大约贵 16 倍;真实大模型是几千。- 头数必须整除 model_dim(每个头均分维度,128/4 = 每头 32 维)。
- FFN 隐藏宽度,惯例取 model_dim 的 4 倍左右。
- 每多一层 Block,能力和耗时同涨;Block 数量直接决定“深度”。
- decoder 带 causal mask(第 17、18 章),每个位置只能看左边,正是生成式模型要的。
- 生成时只喂最近 48 个字(实现里叫
CalcNextTokenLogits:超长就裁尾部窗口),让位置编码始终落在训练见过的范围内。
| 部件 | 计算式 | 参数量 | 占比 |
|---|---|---|---|
| Token Embedding | 3981 × 128 | 509,568 | 28% |
| 4 × Transformer Block | 4 ×(注意力 4×128² + FFN 2×128×512 + 2×LayerNorm) | 793,088 | 43% |
| LM 输出头 | 3981 × 128 + 3981 | 513,549 | 28% |
| 合计 | 1,814,157 |
可以和模型文件互证:sanguo.param 14.56MB ÷ 每参数 8 字节(double)≈ 182 万,分毫不差。小词表字符级模型的特点是——一半以上参数在 embedding 和输出头里,“脑子”(4 层 Block)只占四成多。
把 model_dim 从 128 调到几千、block_num 从 4 调到几十、语料换成整个互联网——
结构一模一样,就成了第 20、21 章说的那种大模型。差别只是这几个数字的大小。
4. 第三步 · 训练:先修数据集,再开跑
训练目标还是那句朴素的话:对每个样本“上文 → 下一个字”猜得越来越准, 用第 4 章的交叉熵、第 6 章的反向传播、 第 8 章的 Adam。但一到全本 57 万字,第一个工程问题立刻冒头:
CharacterDataset 默认逐字滑窗(stride=1):57 万字 = 57 万个样本/epoch, 每个样本还要对 48 个位置 × 3981 词表各算一次全量 softmax——在朴素纯 CPU 实现里,实测一个 epoch 要 7.6 天。
CharacterDataset::RC CharacterDataset::BuildNextTokenSamples(
std::vector<std::vector<int>> &input_samples, std::vector<int> &target_tokens) {
// ... 校验略 ...
for (int i = 0; i + context_size_ < (int)token_ids_.size();
i += sample_stride_) { 1
input_samples.push_back(std::vector<int>(token_ids_.begin() + i,
token_ids_.begin() + i + context_size_));
target_tokens.push_back(token_ids_[i + context_size_]); 2
}
return SUCCESS;
}
- 本次新增
set_sample_stride()+--sample-stride:步长 1 是逐字滑窗(旧行为,默认),设成 context_size 就是非重叠切块——GPT 预训练的标准做法。每个字每轮照样被预测一次,但样本数从 571,567 直接除以 48 变成 11,907,一个 epoch 从 7.6 天缩到 3.8 小时。 - 每个样本 = 48 个字的上文 + 1 个"正确的下一个字"——这就是语言模型的"题目和答案"。
数据集修好,训练循环改动不大,命令先行,再看实现里发生了什么:
# 第一轮:3 epochs,batch 240(事后看是个错误决定,见第 5 节)
nohup ./bin/mini_lm train --model sanguo.param \
--corpus-file sanguo_clean.txt --epochs 3 \
--batch-size 240 --thread-num 24 --learning-rate 0.001 \
--sample-stride 48 --progress-every-sec 300 > train.log 2>&1 &
# 第二轮:从 checkpoint 续训到总计 12 epochs,batch 降到 48
nohup ./bin/mini_lm train --model sanguo.param \
--checkpoint sanguo.param.ckpt --resume-checkpoint \ 1
--corpus-file sanguo_clean.txt --epochs 12 \
--batch-size 48 --thread-num 24 --learning-rate 0.001 \
--sample-stride 48 --skip-final-eval > train2.log 2>&1 & 2
--resume-checkpoint语义:--epochs传的是目标总轮数,不是"再训几轮"——已完成 3 轮 + 目标 12 = 再训 9 轮。注意 checkpoint 只存权重、词表和训练轮数,不保存 Adam 的动量状态,恢复后优化器冷启动(短期 loss 可能小波动,教学项目可接受)。--skip-final-eval:训练末尾默认会对全部样本再做一次全量评估(stride=1 的 57 万份,在这个里就是再白等几个小时),我们是"每轮看日志"党,直接跳过。
// ① 每个 batch 先给每个 worker 拷一份完整模型(含 Adam 状态)
for (int i = 0; i < worker_num; i++) workers.push_back(*this); 1
// ② worker 各领 batch 里的不同样本,并行做前向+反向,梯度各自累积
threads.emplace_back([order_pos = begin + worker_idx; ...
workers[worker_idx].BackwardSample(sample, target, ..., true, ...);
// ③ 主线程合并所有 worker 的梯度,做一次 Adam 更新
AddAccumulatedGradientsFrom(workers[i]); 2
ApplyAccumulatedGradients(epoch_lr, block_lr, 1.0 / batch_n); 3
- 这是教学代码直来直去的代价:182 万参数 × 24 份拷贝,每个 batch 一次。真实框架会用共享只读权重 + 每线程梯度缓冲,这一节你可以想想怎么改。
- 合并梯度 = 对位求和;等价于把 batch 里每个样本的梯度加起来。
- 一轮 Adam 更新 = 第 8 章那一套(动量 + 二阶矩 + bias correction);学习率由 第 9 章的 WarmupCosineLR 逐轮给出:0.001 起步、1 轮 warmup、余弦退火到约 1.2e-4 收工。
// ① 前向:embedding 查表 ×√128 + 位置编码 → 4 层 decoder Block
EncodeSequence(sample, encoded); 1
// ② 对窗口里每个位置:3981 维 logits → softmax → 交叉熵
for (pos = 0..47) {
logits[tok] = Σ output_weight[tok][d] · encoded[pos][d] + bias; 2
probs = Softmax(logits); loss += -log(probs[target[pos]]); 3
grad_logits = probs; grad_logits[target] -= 1; 4
grad_logits *= 1.0 / seq_len; // 让 batch 内长短样本同权
}
// ③ 反向:梯度从输出头回传,穿过 4 层 Block 回到每个位置
decoder.BackwardAccumulate(grad_encoded, grad_hidden); 5
for (i, tok : sample) grad_embedding[tok][d] += grad_hidden[i][d]; 6
- 查表 + 缩放 + 位置编码:embedding 行向量乘 √128,再按位置加正弦/余弦编码(第 17 章);然后依次过 4 层 Block(注意力→残差→LayerNorm→FFN→残差→LayerNorm,第 18 章)。
- 一个 48 字样本要算 48 次"全词表 softmax"——这就是为什么词表收窄到 3981 仍然是大头开销。
- 对每个位置取"正确的下一个字"的预测概率,−log 累加,就是第 4 章的交叉熵。
- softmax + 交叉熵的梯度有个优雅的简化形式:预测概率减去 one-hot 答案(probs −1 于目标位),第 4 章推导过。
- 梯度按"输出头 → Block 堆 → 位置"一路传回(第 6 章反向传播);
Accumulate版本只攒梯度不更新,留给 batch 合并。 - embedding 的梯度按 token id 散射回对应行:同一个字在窗口里出现几次就累加几次,因此高频字的 embedding 被更新得最多。
ApplyAccumulatedGradients(epoch_lr, block_lr, 1.0 / batch_sample_num);
// 三个优化器实例分管输出头/embedding/各层 Block,
// Adam 内部:动量 m、二阶矩 v、bias correction(第 8 章) 1
- 注意
1.0 / batch_sample_num:合并的是平均梯度,batch 大小不会改变期望更新方向,只改变方差和步数——这又把第 4 节的"步数预算"串起来了。
每轮梯度更新次数 = 样本数 ÷ batch。第一轮 batch 240 时,11,907 样本只换来 ~50 次 Adam 更新,3 轮才 148 步—— loss 从 8.29 磨蹭到 6.24 就降不动了;换 batch 48 后每轮 248 步,斜率立刻变了。batch 不是越大越好,它和学习率、步数预算是要一起算的账。
Resumed checkpoint: completed_epoch=3 last_loss=6.24456 last_perplexity=515.201
epoch 4/12 sample 11136/11907 loss=5.99894 recent_loss=5.48098 samples/s=2.75 epoch_eta=4m40s 1
epoch 4/12 loss=5.96081 perplexity=387.922 recent_loss=5.42029 lr=0.000928564 elapsed=1h10m49s
Saved checkpoint: sanguo.param.ckpt completed_epoch=4 2
...
epoch 12/12 loss=3.6166 perplexity=37.211 recent_loss=3.6063 lr=0.000118228 elapsed=7h32m0s
Saved updated weights: sanguo.param 3
- 进度行:
loss是当前轮累计平均,recent_loss是最近一段样本的滑动平均——后者更快反映"现在学得怎么样"。 - 每个 epoch 完整跑完都会落盘一份 checkpoint,断电/Ctrl+C 都不至于从头再来。
- 完训时权重写回模型文件(sanguo.param + .vocab 成对儿存在,缺一不可)。
5. 第四步 · 评估:loss 与困惑度,一夜的完整下降曲线
指标还是第 19 章那两个:平均交叉熵损失和困惑度
(“模型平均在几个候选字里纠结”,越接近 1 越笃定)。先说一个没有训练也会有的事实:
词表 3981、随机初始化时模型对每个字近乎均匀下注,loss ≈ ln(3981) ≈ 8.29——这就是曲线的起点。
下面是 12 轮的全程实录(model/train.log、train2.log 原文):
| epoch | loss | perplexity | 批大小 | 生成观察(当轮 checkpoint 实测) |
|---|---|---|---|---|
| 1 | 6.66 | 780 | 240 | 只吐高频字和标点,逗号句号连发 |
| 2 | 6.25 | 518 | 240 | 偶尔蹦出「之」「曰」单字短语 |
| 3 | 6.24 | 515 | 240 | 降不动——换 batch 48 续训 |
| 4 | 5.96 | 388 | 48 | 出现「以有一路,必以为计,可」 |
| 5 | 5.03 | 153 | 48 | 短句渐通,人名站位准了 |
| 6 | 4.60 | 99 | 48 | 「孔明曰吾不想汝与」「曹丕大败,即差人往」 |
| 7 | 4.33 | 76 | 48 | 对白、叙事交替自然 |
| 8 | 4.12 | 62 | 48 | 段落感出现,会自己换行 |
| 9 | 3.95 | 52 | 48 | 「主公等之事,吾今欲来...玄德曰此人乃天子」 |
| 10 | 3.81 | 45 | 48 | 长句连贯,战争词汇丰富 |
| 11 | 3.70 | 40 | 48 | - |
| 12 | 3.62 | 37.2 | 48 | 「孔明曰:却说魏延引五万兵出城」 |
同一台共享 64 核 CPU 服务器,有效并行 ~21 线程,每 epoch 约 40-50 分钟。loss 曲线仍处下降通道——想继续,--resume-checkpoint --epochs 20 接着跑就是。
perplexity 从 ~4000 → 37,意思是"平均纠结的候选字数"缩小了两个数量级。但注意单位:这是字符级困惑度, 不能和论文里词级/BPE 级的数字直接比。对它自己的衡量标准只有一个——掉得稳不稳、文字像不像话。
6. 第五步 · 生成:贪心与采样,逐字流式往外蹦
生成的机制第 19 章讲过:预测下一个字 → 接回上文 → 再预测。先看两种策略的效果对比(同一个模型、同一句开头):
| prompt | 策略 | 真实输出(截取) |
|---|---|---|
| 话说天下大势 | 贪心 | 「,不可轻动。却说曹操在寨中,」确定,但常循环 |
| 孔明曰 | T=0.7, top-k 10 | 「却说魏延引五万兵出城,前后」 |
| 玄德曰 | T=0.8, top-p 0.9 | 「哥哥哥在许都,你做来相问。玄德曰我自有」 |
| 却说曹操 | T=0.7, top-k 10 | 「操大惊,急急奔到来,见曹仁军大叫一」 |
| 只见 | T=0.7, top-k 10 | 「吾有一计,只是」 |
贪心稳定但死板,采样更像活物。人物名(魏延、曹仁)、对白标记(曰)、军议叙事(引兵/大惊/大叫)都是模型自己学出来的。
采样不是一句"随机抽"就完事,它在 MiniTransformerLM::SampleNextToken 里有一条清晰的流水线:
auto probs = SoftmaxWithTemperature(logits, option.temperature_); 1
std::sort(order.begin(), order.end(),
[&](int a, int b){ return probs[a] > probs[b]; }); 2
if (option.top_k_ > 0) keep = min(keep, option.top_k_); 3
for (i...) { cumulative += probs[order[i]];
if (cumulative >= option.top_p_) break; } 4
// 截断 → 重归一 → 在幸存候选里按归一后概率掷骰子 5
- 温度先缩放 logits:T<1 拉大差距(更保守),T>1 拉平(更敢写)(第 19 章)。
- 按概率从高到低排序整个词表(3981 个候选)。
- top-k:只保留前 k 名。
- top-p(nucleus):从头部累计概率,够到 p 就停——候选集大小随分布胖瘦自适应。
- 幸存候选重新归一化后按概率抽取;三个参数可以任意组合,命令行上给一个就算采样模式。
# 贪心(不给任何采样参数)
./bin/mini_lm generate --model sanguo.param --prompt "话说天下大势" --generate-num 60
# 采样
./bin/mini_lm generate --model sanguo.param --prompt "孔明曰" \
--generate-num 60 --temperature 0.7 --top-k 10
# 不传 --prompt:进入交互模式,输一句它接一句,空行/:q 退出 1
- 三种模式都是逐字流式输出(每算出一个字就打印并 flush)——你在 ChatGPT 聊天框里看字一个个蹦出来,底层就是这种自回归循环,规模天差地别,机制分毫不差。
7. 从这个迷你模型,到真正的大模型
你手里这个 182 万参数、一夜训完的小模型,和 GPT 的骨架完全相同。差距不在“原理”,而在第 20–22 章讲的那些事:
- 放大(第 20 章):把
model_dim / block_num / head_num调大,语料换成整个互联网,靠 Scaling Law 换来质变与涌现; - 后训练(第 20 章):预训练之后再做 SFT、RLHF/DPO,把“会接话”调成“听话、有用、无害”的助手;
- 工程(第 21 章):上千张 GPU、显存优化、数据/张量/流水线并行、KV cache,才跑得起来;
- 用好(第 22 章):Prompt、Context、Harness、Agent,把它接到真实世界里干活。
在 src/ 目录下:./build.sh false Release 构建后,一条命令就能复现本章玩具版:
./bin/transformer_char --prompt "ab" --generate-num 20
想完整复现三国版:语料、两轮训练命令与生成验证,全在 deeplearning-model 仓库的模型手册里,纯 CPU 一夜跑完。
小结
- 语言模型项目 = 语料(清洗!)→ 分词 → 数据集 → 建模 → 训练 → 评估 → 生成 → 存盘,每一步都可能成为真正的活儿。
- 字符级模型里,词表就是结构:init 扫语料冻结、.vocab sidecar 与权重成对、OOV 只能跳过。
Config几个数字描述整座 Transformer,每个都和训练成本直接挂钩(model_dim 平方级最敏感)。- 大语料记得非重叠切块(
--sample-stride);batch 决定每轮梯度步数,别和小语料一个改法。 - checkpoint + resume 让一夜的长训练可中断;loss 8.29 → 3.62 走的是 warmup+余弦退火。
- 生成 = 自回归循环(贪心确定 / 温度+top-k+top-p 采样);流式输出就是"算一字打一字"。
- 放大这个骨架 + 后训练 + 工程 + 会用,就是第 20–22 章讲的“大模型”。
动手与思考
问题 1:Config 里的 block_num 和 head_num 分别控制什么?哪个对训练时间影响更大?
问题 2:为什么“非重叠切块”能省将近 48 倍训练时间,而每个字并没有少学?
逐字滑窗(stride=1)时,每个字会出现在 48 个窗口里、被当 48 次样本重复计算。切块(stride=context_size)后每个字每轮照样被预测一次,只是不再重复占用 48 份前向/反向算力——预训练语料足够大时,这是标准做法。
问题 3:第一轮 batch 240 时 loss 为什么降不动?换小 batch 为什么就动了?
每个 epoch 的梯度更新次数 = 样本数 ÷ batch。11,907 样本、batch 240 只剩约 50 次更新;换 batch 48 后每轮 248 步,参数才有机会沿梯度走足够远。batch 是要和学习率、步数预算一起算的账(第 8 章)。
问题 4:词表为什么只能在 init 时确定、不能训练中扩?
因为词表大小同时是 embedding 的行数和输出层的宽度,属于模型结构的一部分;训练中途加词,等于改矩阵形状,已有权重都对不上号。所以新字想支持,只能重新 init + 重新预训练(这也解释了为什么"续训"只能换语料、不能换字符集)。
问题 5:贪心为什么会陷入“the the the…”这种循环?采样怎么缓解?
贪心每步都选概率最高者,一旦走进循环态,同样的上文永远推出同一个字,再也出不来。温度/top-k/top-p 把决策改成"在一小撮靠谱候选里掷骰子",给了它跳出局部的机会(第 19 章),代价是完全确定时的可复现性。
你已经把第四、五部分的语言模型从语料一路跟到了生成。下一章如法炮制,去读 第 25 章的 AlphaZero 五子棋——把第 12 章的策略、价值、搜索与自对弈真正跑起来。