第 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 万参数,会断片、会循环,但人物名、对白腔、战争叙事已经像模像样。

语料57 万字简体《三国演义》
Tokenizer汉字↔id,词表 3981
Dataset切成(上文→下一字)
MiniTransformerLM182 万参数
训练/生成预测下一个字

这正是第 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 个符号)
  1. 公版书没有版权问题,这是选它的第一理由;顺带它文体统一、量够大。
  2. 页眉页脚里全是英文许可证,留着只会污染词表和学习信号。
  3. 繁简混排等于把"国"和"国"学成两个字,平白浪费本就可怜的参数。
  4. 为什么只留三种符号?字符级模型的词表大小 = embedding 行数 = 输出层宽度:每多一个稀有符号(花引号、【】、装饰符……),词表大一圈、小模型更难学,还会被极少数病态字符拖累 softmax。换行符单独保留——它就当段落/句子的边界,模型能学会"换行之后另起一段"。

语料备好,接着是 分词(tokenize)mini_lm init 会扫一遍语料建词表、定结构、随机初始化并存盘(本节所有命令均为真实执行过的):

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
  1. utf8-char:一个汉字一个 token。若用默认字节级,一个汉字会被拆成 3 个 UTF-8 字节,模型要先学会"拼字节"才能谈语言,白白浪费容量。另外还有词级 --tokenizer word,那是给英文语料准备的。
  2. 这些就是下一节要逐一拆解的 Config 字段;rand-seed 让初始化可复现。
  3. 注意词表编号顺序:框架的分词器是“首次出现即编号”——语料开头是《第一回宴桃园豪杰三结义》,所以「第」拿 0 号、「一」拿 1 号……这带来一个副作用:两个模型的词表不能互换,模型文件只能绑定自己那份词表。
character_tokenizer.cpp / token_embedding.cpp · init 这一行里面做了什么
// ① 扫语料建词表:见过的跳过,新字符分配下一个 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
  1. 所以词表顺序就是"语料开头长什么样"的顺序(《三国演义》开头是「第一回宴桃园…」,故「第」拿 0 号);两个模型的词表不能互换,LoadModel 时会校验词表大小一致。
  2. Xavier 均匀分布(第 9 章讲的初始化)按 rand_seed 可复现——同一个种子,两次 init 出来的模型一模一样。
  3. 一个模型是两个文件,拷贝/备份必须成对;词表文件里还能看到 tokenizer 类型(utf8-char)。完整格式约定见 docs/mini-lm-demo.md
词表在 init 时就冻结了

词表大小同时是 embedding 的行数和输出层的维度,属于模型结构,存进 sanguo.param.vocab sidecar 文件。 之后训练/生成时遇到词表外的字符,只会被跳过并告警(skipped N characters outside the model vocabulary), 想支持新字只能连词表一起重新 init。所以 init 用的语料,字符覆盖一定要够。

3. 第二步 · 建模:一个 Config 描述整座 Transformer

第 17、18 章拆过的所有结构——embedding、位置编码、多头注意力、LayerNorm、FFN、残差—— 浓缩进一个 Config 结构体。这次填的是真刀真枪的数字:

MiniTransformerLM::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
  1. 词表大小同时决定 embedding 行数和 LM 输出头宽度(第 19 章)。
  2. model_dim 是信息流动的“管道宽度”,训练成本按平方增长——从 32 提到 128,每样本耗时大约贵 16 倍;真实大模型是几千。
  3. 头数必须整除 model_dim(每个头均分维度,128/4 = 每头 32 维)。
  4. FFN 隐藏宽度,惯例取 model_dim 的 4 倍左右。
  5. 每多一层 Block,能力和耗时同涨;Block 数量直接决定“深度”。
  6. decoder 带 causal mask(第 17、18 章),每个位置只能看左边,正是生成式模型要的。
  7. 生成时只喂最近 48 个字(实现里叫 CalcNextTokenLogits:超长就裁尾部窗口),让位置编码始终落在训练见过的范围内。
部件计算式参数量占比
Token Embedding3981 × 128509,56828%
4 × Transformer Block4 ×(注意力 4×128² + FFN 2×128×512 + 2×LayerNorm)793,08843%
LM 输出头3981 × 128 + 3981513,54928%
合计1,814,157

可以和模型文件互证:sanguo.param 14.56MB ÷ 每参数 8 字节(double)≈ 182 万,分毫不差。小词表字符级模型的特点是——一半以上参数在 embedding 和输出头里,“脑子”(4 层 Block)只占四成多。

同一个 Config,放大就是 GPT

model_dim 从 128 调到几千、block_num 从 4 调到几十、语料换成整个互联网—— 结构一模一样,就成了第 20、21 章说的那种大模型。差别只是这几个数字的大小。

4. 第三步 · 训练:先修数据集,再开跑

训练目标还是那句朴素的话:对每个样本“上文 → 下一个字”猜得越来越准, 用第 4 章的交叉熵第 6 章的反向传播第 8 章的 Adam。但一到全本 57 万字,第一个工程问题立刻冒头:

滑窗 48 次,一个字学 48 遍?

CharacterDataset 默认逐字滑窗(stride=1):57 万字 = 57 万个样本/epoch, 每个样本还要对 48 个位置 × 3981 词表各算一次全量 softmax——在朴素纯 CPU 实现里,实测一个 epoch 要 7.6 天

character_dataset.cpp · 本次参评的改动(完整函数,含新增 stride)
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;
}
  1. 本次新增 set_sample_stride()+--sample-stride:步长 1 是逐字滑窗(旧行为,默认),设成 context_size 就是非重叠切块——GPT 预训练的标准做法。每个字每轮照样被预测一次,但样本数从 571,567 直接除以 48 变成 11,907,一个 epoch 从 7.6 天缩到 3.8 小时
  2. 每个样本 = 48 个字的上文 + 1 个"正确的下一个字"——这就是语言模型的"题目和答案"。

数据集修好,训练循环改动不大,命令先行,再看实现里发生了什么:

本次训练的两条真实命令(共 12 epochs ≈ 9.5 小时纯 CPU)
# 第一轮: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
  1. --resume-checkpoint 语义:--epochs 传的是目标总轮数,不是"再训几轮"——已完成 3 轮 + 目标 12 = 再训 9 轮。注意 checkpoint 只存权重、词表和训练轮数,不保存 Adam 的动量状态,恢复后优化器冷启动(短期 loss 可能小波动,教学项目可接受)。
  2. --skip-final-eval:训练末尾默认会对全部样本再做一次全量评估(stride=1 的 57 万份,在这个里就是再白等几个小时),我们是"每轮看日志"党,直接跳过。
mini_transformer_lm.cpp · 并行训练一个 batch 时发生了什么(精简)
// ① 每个 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
  1. 这是教学代码直来直去的代价:182 万参数 × 24 份拷贝,每个 batch 一次。真实框架会用共享只读权重 + 每线程梯度缓冲,这一节你可以想想怎么改。
  2. 合并梯度 = 对位求和;等价于把 batch 里每个样本的梯度加起来。
  3. 一轮 Adam 更新 = 第 8 章那一套(动量 + 二阶矩 + bias correction);学习率由 第 9 章的 WarmupCosineLR 逐轮给出:0.001 起步、1 轮 warmup、余弦退火到约 1.2e-4 收工。
mini_transformer_lm.cpp · BackwardSample:一个样本内部的完整旅程(精简)
// ① 前向: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
  1. 查表 + 缩放 + 位置编码:embedding 行向量乘 √128,再按位置加正弦/余弦编码(第 17 章);然后依次过 4 层 Block(注意力→残差→LayerNorm→FFN→残差→LayerNorm,第 18 章)。
  2. 一个 48 字样本要算 48 次"全词表 softmax"——这就是为什么词表收窄到 3981 仍然是大头开销。
  3. 对每个位置取"正确的下一个字"的预测概率,−log 累加,就是第 4 章的交叉熵
  4. softmax + 交叉熵的梯度有个优雅的简化形式:预测概率减去 one-hot 答案(probs −1 于目标位),第 4 章推导过。
  5. 梯度按"输出头 → Block 堆 → 位置"一路传回(第 6 章反向传播);Accumulate 版本只攒梯度不更新,留给 batch 合并。
  6. embedding 的梯度按 token id 散射回对应行:同一个字在窗口里出现几次就累加几次,因此高频字的 embedding 被更新得最多。
mini_transformer_lm.cpp · batch 合并后只做一次 Adam
ApplyAccumulatedGradients(epoch_lr, block_lr, 1.0 / batch_sample_num);
//  三个优化器实例分管输出头/embedding/各层 Block,
//  Adam 内部:动量 m、二阶矩 v、bias correction(第 8 章)           1
  1. 注意 1.0 / batch_sample_num:合并的是平均梯度,batch 大小不会改变期望更新方向,只改变方差和步数——这又把第 4 节的"步数预算"串起来了。
batch 大小 = 每个 epoch 的“步数预算”

每轮梯度更新次数 = 样本数 ÷ batch。第一轮 batch 240 时,11,907 样本只换来 ~50 次 Adam 更新,3 轮才 148 步—— loss 从 8.29 磨蹭到 6.24 就降不动了;换 batch 48 后每轮 248 步,斜率立刻变了。batch 不是越大越好,它和学习率、步数预算是要一起算的账。

train2.log · 训练日志长什么样(每 5 分钟一条进度 + 每轮一条汇总)
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
  1. 进度行:loss 是当前轮累计平均,recent_loss 是最近一段样本的滑动平均——后者更快反映"现在学得怎么样"。
  2. 每个 epoch 完整跑完都会落盘一份 checkpoint,断电/Ctrl+C 都不至于从头再来。
  3. 完训时权重写回模型文件(sanguo.param + .vocab 成对儿存在,缺一不可)。

5. 第四步 · 评估:loss 与困惑度,一夜的完整下降曲线

指标还是第 19 章那两个:平均交叉熵损失困惑度 (“模型平均在几个候选字里纠结”,越接近 1 越笃定)。先说一个没有训练也会有的事实: 词表 3981、随机初始化时模型对每个字近乎均匀下注,loss ≈ ln(3981) ≈ 8.29——这就是曲线的起点。 下面是 12 轮的全程实录(model/train.logtrain2.log 原文):

epochlossperplexity批大小生成观察(当轮 checkpoint 实测)
16.66780240只吐高频字和标点,逗号句号连发
26.25518240偶尔蹦出「之」「曰」单字短语
36.24515240降不动——换 batch 48 续训
45.9638848出现「以有一路,必以为计,可」
55.0315348短句渐通,人名站位准了
64.609948「孔明曰吾不想汝与」「曹丕大败,即差人往」
74.337648对白、叙事交替自然
84.126248段落感出现,会自己换行
93.955248「主公等之事,吾今欲来...玄德曰此人乃天子」
103.814548长句连贯,战争词汇丰富
113.704048
123.6237.248「孔明曰:却说魏延引五万兵出城」

同一台共享 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 里有一条清晰的流水线:

mini_transformer_lm.cpp · 采样流水线(精简)
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
  1. 温度先缩放 logits:T<1 拉大差距(更保守),T>1 拉平(更敢写)(第 19 章)。
  2. 按概率从高到低排序整个词表(3981 个候选)。
  3. top-k:只保留前 k 名。
  4. top-p(nucleus):从头部累计概率,够到 p 就停——候选集大小随分布胖瘦自适应。
  5. 幸存候选重新归一化后按概率抽取;三个参数可以任意组合,命令行上给一个就算采样模式。
命令行用法(真实):贪心 / 采样 / 交互模式
# 贪心(不给任何采样参数)
./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
  1. 三种模式都是逐字流式输出(每算出一个字就打印并 flush)——你在 ChatGPT 聊天框里看字一个个蹦出来,底层就是这种自回归循环,规模天差地别,机制分毫不差。

7. 从这个迷你模型,到真正的大模型

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

自己跑一遍(强烈建议)

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_numhead_num 分别控制什么?哪个对训练时间影响更大?

block_num 是堆叠多少层 Transformer Block(越深越强也越难训,第 18 章);head_num 是多头注意力的头数,让模型从多个“角度”同时看序列再汇总(第 17 章)。耗时上 block_num 更敏感(每层一整套注意力+FFN),而真正的"平方杀手"是 model_dim。

问题 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 章),代价是完全确定时的可复现性。

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

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

你已经把第四、五部分的语言模型从语料一路跟到了生成。下一章如法炮制,去读 第 25 章的 AlphaZero 五子棋——把第 12 章的策略、价值、搜索与自对弈真正跑起来。