第 24 章 · 代码实战
mini-LM 实战:把第四、五部分跑起来
最后一站。我们来读那个真能训练、真能一个字一个字往下写的字符级语言模型——
它就是仓库里的 src/demo/transformer_char/main.cpp,配上 src/deeplearning/transformer/
那一堆模块。你会看到第四部分的注意力、Transformer,和第五部分的分词、embedding、生成、采样、困惑度,
全都变成了可以运行、可以改参数的具体代码。读完这一章,你就真正“从一个神经元走到了大模型”的完整代码。
读完这一章,你会明白
- 一个语言模型程序的完整链路:语料 → 分词 → 数据集 → 建模 → 训练 → 评估 → 生成 → 存盘;
MiniTransformerLM::Config里每个字段,对应第 17、18 章的哪个结构;- “预测下一个 token”的训练和“自回归生成”在代码里长什么样;
- greedy 与带温度/top-k/top-p 采样,在命令行怎么一键切换;
- 从这个几千参数的玩具,到上千亿参数的大模型,中间隔着的正是第 20–22 章。
1. 全景:一条从文本到文本的流水线
第 19 章已经讲过这条链路的道理,这一章我们对着代码把它跑通。默认这个 demo 会拿一小段语料
(比如 "abcabcabc...")训练一个极小的模型,然后按你的提示往下续写。
这正是第 19 章那张图,只不过现在每个方框都对应 transformer/ 下的一个真实模块。
2. 语料 → 分词 → 数据集
第一步和第 19 章一致:扫一遍语料建词表,再把字符串编码成 id 序列。然后 CharacterDataset 把这条长序列切成一堆“上文 → 下一个字”的训练样本 ——这就是语言模型的“题目和答案”。
CharacterTokenizer tokenizer;
tokenizer.Init(CharacterTokenizer::BuildVocabularyFromText(corpus)); 1
tokenizer.Encode(corpus, corpus_token_ids); 2
CharacterDataset dataset;
dataset.Init(corpus_token_ids, context_size); 3
dataset.BuildNextTokenSamples(input_samples, target_tokens); 4
- 建词表:统计语料里出现过的所有字符,每个配一个唯一 id(第 19 章)。
- 编码:把整段语料翻译成一串 id。
context_size是“一次最多看几个字”——就是模型的上下文窗口(第 17、20 章都提过)。- 切样本:用滑动窗口生成一批
(上文 tokens → 下一个 token)。input_samples[i]是上文,target_tokens[i]是它该预测的下一个字。
3. 建模:一个 Config 描述整座 Transformer
第 17、18 章拆过的所有结构,在这里被浓缩进一个 Config 结构体。填几个数字, 一座(迷你)Transformer 就定义好了:
MiniTransformerLM::Config cfg;
cfg.vocab_size_ = tokenizer.vocab_size(); // 词表大小(输出层宽度) 1
cfg.model_dim_ = 6; // 每个 token 的向量维度 2
cfg.head_num_ = 1; // 多头注意力的“头”数(第 17 章) 3
cfg.feed_forward_dim_ = 12; // Block 里前馈网络的宽度(第 18 章) 4
cfg.block_num_ = 2; // 堆几层 Transformer Block(第 18 章) 5
cfg.backbone_type_ = BACKBONE_DECODER; // 解码器主干, 带 causal mask 6
cfg.max_context_size_ = context_size; // 上下文窗口:生成时只看最近这么多字 7
MiniTransformerLM model;
model.Init(cfg); // LM Head(投影到词表)在 Init 里自动建好
vocab_size决定最后 LM Head 要在多少个候选字里做选择(第 19 章)。model_dim= 每个 token 的 embedding 维度,也是信息在网络里流动的“管道宽度”。真实大模型是几千,这里只有 6。head_num= 多头注意力的头数:请几位“专家”各看一遍再汇总(第 17 章)。feed_forward_dim= 每个 Block 里前馈网络(FFN)的隐藏宽度(第 18 章)。block_num= 把 Transformer Block 叠几层。叠得越深,能力越强也越难训——大模型就是把这个数字堆到几十上百(第 18、20 章)。backbone_type= decoder:带 causal mask,只能看左边、不能偷看未来(第 17、18 章),正是生成式模型要的。max_context_size:生成时只把最近这么多个 token 喂进模型(滑动窗口),让位置编码始终落在训练见过的范围内(第 17、20 章)。而把 Transformer 输出投影到词表、算“下一个字概率”的 LM Head(第 19 章)在Init里就自动建好了。
把 model_dim 从 6 改到几千、block_num 从 2 改到几十、head_num 从 1 改到几十,
再喂上万亿 token、用上千张 GPU——结构一模一样,就成了第 20、21 章说的那种大模型。
你现在读的这个 demo,和 GPT 的差别只是这几个数字的大小。
4. 训练:反复练习“预测下一个 token”
训练目标朴素得就一句话:让模型对每个样本“上文 → 下一个字”猜得越来越准。 用的还是第 4 章的交叉熵、第 6 章的反向传播,只是任务换成了预测 token。
auto cb = [](int epoch, double loss, bool &early_stop) {
if (epoch % 50 == 0) cout << "epoch " << epoch << " loss " << loss << endl;
if (loss < 0.02) early_stop = true; // 学得够好就提前收手 1
};
// 学习率用 warmup + 余弦退火(概念见第 9 章;现代 Transformer 常用)
WarmupCosineLR sched(learning_rate, warmup_epochs, epoch_num, learning_rate * 0.1);
model.TrainNextToken(input_samples, target_tokens,
cb, epoch_num, learning_rate, &sched); 2
- 回调每 50 轮打印一次损失,并在损失足够低时提前停止(early stop)——省得白训。
- 核心一句:把样本喂进去反复训。每一轮内部,对序列每个位置都做“预测下一个字”的前向、算交叉熵、反向传播,再用 Adam(第 8 章的优化器,和第 23 章 MNIST 同一套)更新参数;学习率还挂了 warmup + 余弦退火调度(第 9 章)。机制和 MNIST 相同,只是输出从“10 个数字”换成了“整个词表”。
5. 评估:损失与困惑度
训练完,用第 19 章的两个指标看它学得好不好:平均交叉熵损失,以及它的指数——困惑度(perplexity)。 困惑度可以理解成“模型平均在几个候选字里纠结”,越接近 1 越笃定。
model.CalcNextTokenLoss(input_samples, target_tokens, average_loss); 1
model.CalcPerplexity(input_samples, target_tokens, perplexity); 2
- 平均交叉熵:对所有样本,取“正确的下一个字”被预测到的概率,算 −log 再平均(第 4、19 章)。
- 困惑度 = e平均损失。训练有效时,你会看到它随损失一起稳步下降(第 19 章)。
6. 生成:greedy 与采样,一键切换
最好玩的一步:让它写字。程序同时演示了两种挑字策略——死板但确定的 greedy, 和带随机性的采样(温度 / top-k / top-p)。它们的道理第 19 章讲透了,这里看它们怎么被调用:
tokenizer.Encode(prompt, token_ids); // 把提示词也编码成 id
// (A) greedy: 每步都取概率最高的字
model.Generate(token_ids, generate_num, greedy_ids); 1
// (B) 采样: 由温度 / top-k / top-p 控制随机性
MiniTransformerLM::SamplingOption opt;
opt.temperature_ = 0.8; opt.top_k_ = 2; opt.top_p_ = 0.9; 2
model.GenerateSample(token_ids, generate_num, sampled_ids, opt); 3
tokenizer.Decode(greedy_ids, greedy_text); // id 序列还原成文字 4
你在聊天框看到答案一个字一个字冒出来,底层跑的就是这个 Generate 循环:
每一步只决定“下一个 token”,然后把它接回上文继续。规模天差地别,机制分毫不差。
7. 存盘、加载与观察注意力
和 MNIST 一样,训练好的模型会存成文件(.param),下次可直接加载复用或用
--eval-only 只评估不训练。更妙的是,这个 demo 还能把真实的注意力权重导出成 JSON:
MiniTransformerLMLoader::ExportModelToFile(model, model_file); 1
// ... 下次运行会自动 ImportModelFromFile 加载续用 ...
// 可选: 把每层每个头的注意力权重导成 JSON, 供讲解页回放
WriteAttentionJson(attention_file, model, tokenizer, ...); 2
- 把整座模型(embedding、各层 Block、输出层)序列化到磁盘。
- 导出的 JSON,正是第 17、19 章那个“真实注意力回放”交互实验读取的数据——你在书里看到的热力图,就是这里跑出来的,不是示意图。
8. 从这个玩具,到真正的大模型
你手里这个几千参数、几秒训完的小模型,和 GPT 的骨架完全相同。差距不在“原理”,而在第 20–22 章讲的那些事:
- 放大(第 20 章):把
model_dim / block_num / head_num调大,语料换成整个互联网,靠 Scaling Law 换来质变与涌现; - 后训练(第 20 章):预训练之后再做 SFT、RLHF/DPO,把“会接话”调成“听话、有用、无害”的助手;
- 工程(第 21 章):上千张 GPU、显存优化、数据/张量/流水线并行、KV cache,才跑得起来;
- 用好(第 22 章):提示词、RAG、工具调用、Agent,把它接到真实世界里干活。
在 src/ 目录下:./build.sh 编译后,试试
./bin/transformer_char --prompt "ab" --generate-num 20
再玩玩这些旋钮,亲眼看它们如何影响输出:
--temperature 1.2(更大胆)、--top-k 3、--block-num 2、
--backbone encoder、--corpus "你的语料"。
小结
- 语言模型程序 = 语料 → 分词 → 数据集(上文→下一字)→ 建模 → 训练 → 评估 → 生成 → 存盘。
Config用几个数字描述整座 Transformer:model_dim / head_num / feed_forward_dim / block_num / backbone,一一对应第 17、18 章的结构。- 训练就是反复练“预测下一个 token”,机制和第 23 章 MNIST 相同,只是输出是整个词表。
- 生成 = 自回归循环;greedy 确定、采样(温度/top-k/top-p)更自然(第 19 章)。
- 放大这个骨架 + 后训练 + 工程 + 会用,就是第 20–22 章讲的“大模型”。
动手与思考
问题 1:Config 里的 block_num 和 head_num 分别控制什么?
问题 2:decoder 主干为什么要带 causal mask?
因为它要做“预测下一个字”,训练和生成时都不能偷看未来的字。causal mask 挡住每个位置右边的 token,保证它只能依据左边的上文来预测(第 17、18 章)。
你已经把第四、五部分的语言模型在代码里对上了号。下一章如法炮制,去读 第 25 章的井字棋 Q-learning 程序——把第 12 章的 Q 表、ε-greedy 与 TD 更新也落到代码上。