第 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...")训练一个极小的模型,然后按你的提示往下续写。

语料一段文本
Tokenizer字符↔id
Dataset切成(上文→下一字)
MiniTransformerLMEmbedding+Transformer
训练/生成预测下一个 token

这正是第 19 章那张图,只不过现在每个方框都对应 transformer/ 下的一个真实模块。

2. 语料 → 分词 → 数据集

第一步和第 19 章一致:扫一遍语料建词表,再把字符串编码成 id 序列。然后 CharacterDataset 把这条长序列切成一堆“上文 → 下一个字”的训练样本 ——这就是语言模型的“题目和答案”。

src/demo/transformer_char/main.cpp · 分词与数据集(精简)
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
  1. 建词表:统计语料里出现过的所有字符,每个配一个唯一 id(第 19 章)。
  2. 编码:把整段语料翻译成一串 id。
  3. context_size 是“一次最多看几个字”——就是模型的上下文窗口(第 17、20 章都提过)。
  4. 切样本:用滑动窗口生成一批 (上文 tokens → 下一个 token)input_samples[i] 是上文,target_tokens[i] 是它该预测的下一个字。

3. 建模:一个 Config 描述整座 Transformer

第 17、18 章拆过的所有结构,在这里被浓缩进一个 Config 结构体。填几个数字, 一座(迷你)Transformer 就定义好了:

src/demo/transformer_char/main.cpp · 配置并初始化模型(精简)
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 里自动建好
  1. vocab_size 决定最后 LM Head 要在多少个候选字里做选择(第 19 章)。
  2. model_dim = 每个 token 的 embedding 维度,也是信息在网络里流动的“管道宽度”。真实大模型是几千,这里只有 6。
  3. head_num = 多头注意力的头数:请几位“专家”各看一遍再汇总(第 17 章)。
  4. feed_forward_dim = 每个 Block 里前馈网络(FFN)的隐藏宽度(第 18 章)。
  5. block_num = 把 Transformer Block 叠几层。叠得越深,能力越强也越难训——大模型就是把这个数字堆到几十上百(第 18、20 章)。
  6. backbone_type = decoder:带 causal mask,只能看左边、不能偷看未来(第 17、18 章),正是生成式模型要的。
  7. max_context_size:生成时只把最近这么多个 token 喂进模型(滑动窗口),让位置编码始终落在训练见过的范围内(第 17、20 章)。而把 Transformer 输出投影到词表、算“下一个字概率”的 LM Head(第 19 章)在 Init 里就自动建好了。
同一个 Config,放大就是 GPT

model_dim 从 6 改到几千、block_num 从 2 改到几十、head_num 从 1 改到几十, 再喂上万亿 token、用上千张 GPU——结构一模一样,就成了第 20、21 章说的那种大模型。 你现在读的这个 demo,和 GPT 的差别只是这几个数字的大小。

4. 训练:反复练习“预测下一个 token”

训练目标朴素得就一句话:让模型对每个样本“上文 → 下一个字”猜得越来越准。 用的还是第 4 章的交叉熵第 6 章的反向传播,只是任务换成了预测 token。

src/demo/transformer_char/main.cpp · 训练(精简)
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
  1. 回调每 50 轮打印一次损失,并在损失足够低时提前停止(early stop)——省得白训。
  2. 核心一句:把样本喂进去反复训。每一轮内部,对序列每个位置都做“预测下一个字”的前向、算交叉熵、反向传播,再用 Adam(第 8 章的优化器,和第 23 章 MNIST 同一套)更新参数;学习率还挂了 warmup + 余弦退火调度(第 9 章)。机制和 MNIST 相同,只是输出从“10 个数字”换成了“整个词表”。

5. 评估:损失与困惑度

训练完,用第 19 章的两个指标看它学得好不好:平均交叉熵损失,以及它的指数——困惑度(perplexity)。 困惑度可以理解成“模型平均在几个候选字里纠结”,越接近 1 越笃定。

src/demo/transformer_char/main.cpp · 评估(精简)
model.CalcNextTokenLoss(input_samples, target_tokens, average_loss);  1
model.CalcPerplexity(input_samples, target_tokens, perplexity);      2
  1. 平均交叉熵:对所有样本,取“正确的下一个字”被预测到的概率,算 −log 再平均(第 4、19 章)。
  2. 困惑度 = e平均损失。训练有效时,你会看到它随损失一起稳步下降(第 19 章)。

6. 生成:greedy 与采样,一键切换

最好玩的一步:让它写字。程序同时演示了两种挑字策略——死板但确定的 greedy, 和带随机性的采样(温度 / top-k / top-p)。它们的道理第 19 章讲透了,这里看它们怎么被调用:

src/demo/transformer_char/main.cpp · 两种生成(精简)
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
  1. Generate 就是第 19 章自回归:预测一个字 → 接回输入 → 再预测下一个,循环 generate_num 次。
  2. 三个采样旋钮:温度控制“大胆程度”,top-k / top-p 把候选裁到“靠谱的一小撮”(第 19 章)。
  3. GenerateSample 在每步的候选里按概率掷骰子,于是同样的提示每次可能写出不同内容。
  4. 最后 Decode 把生成的 id 序列翻译回人能读的字符串。
这就是 ChatGPT “逐字往外蹦”的原理

你在聊天框看到答案一个字一个字冒出来,底层跑的就是这个 Generate 循环: 每一步只决定“下一个 token”,然后把它接回上文继续。规模天差地别,机制分毫不差。

7. 存盘、加载与观察注意力

和 MNIST 一样,训练好的模型会存成文件(.param),下次可直接加载复用或用 --eval-only 只评估不训练。更妙的是,这个 demo 还能把真实的注意力权重导出成 JSON:

src/demo/transformer_char/main.cpp · 存模型 / 导出注意力(精简)
MiniTransformerLMLoader::ExportModelToFile(model, model_file);        1
// ... 下次运行会自动 ImportModelFromFile 加载续用 ...

// 可选: 把每层每个头的注意力权重导成 JSON, 供讲解页回放
WriteAttentionJson(attention_file, model, tokenizer, ...);           2
  1. 把整座模型(embedding、各层 Block、输出层)序列化到磁盘。
  2. 导出的 JSON,正是第 17、19 章那个“真实注意力回放”交互实验读取的数据——你在书里看到的热力图,就是这里跑出来的,不是示意图。

8. 从这个玩具,到真正的大模型

你手里这个几千参数、几秒训完的小模型,和 GPT 的骨架完全相同。差距不在“原理”,而在第 20–22 章讲的那些事:

自己跑一遍

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_numhead_num 分别控制什么?

block_num 是堆叠多少层 Transformer Block(越深越强也越难训,第 18 章);head_num 是多头注意力的头数,让模型从多个“角度”同时看序列再汇总(第 17 章)。放大它们是走向大模型的关键一步。

问题 2:decoder 主干为什么要带 causal mask?

因为它要做“预测下一个字”,训练和生成时都不能偷看未来的字。causal mask 挡住每个位置右边的 token,保证它只能依据左边的上文来预测(第 17、18 章)。

问题 3:这个 demo 和真正的大模型,本质差别在哪?

骨架(Embedding + Transformer + 预测下一个 token + 自回归生成)完全一样。差别在规模(参数/数据/算力,第 20 章)、后训练(SFT/RLHF,第 20 章)、工程基础设施(第 21 章)和使用方式(第 22 章),而不在原理。

你已经把第四、五部分的语言模型在代码里对上了号。下一章如法炮制,去读 第 25 章的井字棋 Q-learning 程序——把第 12 章的 Q 表、ε-greedy 与 TD 更新也落到代码上。