从零实现 LLM(下):推理生成、常见问题与进阶优化

2025-12-17 13:13:44
文章摘要
这篇文章从零实现一个可运行的 mini-GPT,用通俗解释和代码示例带你理解分词、训练和生成,让零基础也能上手训练属于自己的小型语言模型。

上一篇文章 中,我们从零开始实现了一个 mini-GPT,完成了以下内容:从 Tokenization(分词) 到 Transformer 解码器,逐步拆解了 LLM 的大脑;完成了 mini-GPT 的训练,并成功让它生成了第一段文字;甚至还拿到了一个 Colab Notebook 彩蛋,可以随时实验和截图。在这篇下篇文章里,我们将针对这些问题,继续深入:




五、推理与生成

前面我们已经完成了模型的训练。接下来,就是让它“开口说话”了。 这一步就叫 推理(Inference 或 生成(Generation

5.1 训练 vs 推理:有什么不同?

  1. 训练:模型知道“前文 + 正确答案”,目标是调整参数,让预测越来越准。
  2. 推理(生成):只有“前文”,没有答案。模型需要自己接龙:预测下一个 token,再接着预测下一个……

换句话说:训练是在学,推理是在用

5.2 推理的基本逻辑

推理的过程就像接龙游戏:

  1. 给定一个起始文本(prompt)。
  2. 模型预测下一个 token 的概率分布。
  3. 根据采样策略选一个 token
  4. 把它接到文本末尾。
  5. 重复步骤 2–4,直到达到最大长度或遇到停止条件。

5.3 generate 函数实现

下面是一版通用的 generate 函数,支持温度、top-ktop-p、惩罚项和停止条件。为了让逻辑更清晰,我们在代码中分步写了注释。

import torch
@torch.no_grad()
def generate(model, idx, max_new_tokens, temperature=1.0, top_k=None, top_p=None,
             freq_penalty=0.0, pres_penalty=0.0, stop_strings=None):
    """
   idx: 初始输入 (prompt),形状 [1, T]
   max_new_tokens: 最大生成长度
   """
    device = next(model.parameters()).device
    generated = idx.clone().to(device)
    token_counts = {}  # 用来统计 token 出现次数
    for _ in range(max_new_tokens):
        # 1. 取最近 block_size 个 token 作为上下文
        idx_cond = generated[:, -model.block_size:]
        # 2. 前向传播,取最后一个位置的 logits
        logits, _ = model(idx_cond)
        logits = logits[:, -1, :]  # 只取最后一个 token 的预测分布
        # 3. 温度缩放
        if temperature != 1.0:
            logits = logits / temperature
        # 4. 频率/出现惩罚,避免复读机
        for t, count in token_counts.items():
            logits[0, t] -= freq_penalty * count + pres_penalty
        # 5. 转为概率分布
        probs = torch.softmax(logits, dim=-1)
        # 6. top-k 策略:只保留前 k 个高概率 token
        if top_k is not None:
            v, ix = torch.topk(probs, top_k)
            probs = torch.zeros_like(probs).scatter_(1, ix, v)
            probs = probs / probs.sum()
        # 7. top-p 策略:只保留累积概率 <= p 的 token
        if top_p is not None:
            sorted_probs, sorted_idx = torch.sort(probs, descending=True)
            cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
            mask = cumulative_probs <= top_p
            # 保证至少保留一个 token
            mask[..., 0] = True
            probs = torch.zeros_like(probs).scatter_(1, sorted_idx, sorted_probs * mask)
            probs = probs / probs.sum()
        # 8. 抽样得到下一个 token
        next_token = torch.multinomial(probs, num_samples=1)
        # 9. 更新生成序列
        generated = torch.cat((generated, next_token), dim=1)
        # 10. 更新 token 计数(用于惩罚项)
        t = next_token.item()
        token_counts[t] = token_counts.get(t, 0) + 1
        # 11. 停止条件
        if stop_strings is not None:
            decoded = decode(generated[0].tolist())  # 或 decode_bpe
            for s in stop_strings:
                if decoded.endswith(s):
                    return generated
    return generated

5.4 参数详解(+ 推荐组合)

  1. temperature(温度):控制随机性。
  2. 小(0.7):更稳定 → 像背课文。
  3. 大(1.2):更发散 → 更有创意。
  4. top-k:只在概率最高的 k 个 token 中挑,常用:k=50
  5. top-p(核采样):动态控制候选集合,只保留累计概率 ≤p 的 token,常用:p=0.9
  6. freq_penalty / pres_penalty:控制“复读机”。
  7. freq_penalty=0.5:出现越多,惩罚越大。
  8. pres_penalty=0.5:只要出现过,就扣分。
  9. stop_strings:设置终止条件(如 "\n\n")。
  10. max_new_tokens:限制最大生成长度,避免无限输出。

👉 常用配置推荐:

  1. 稳定输出temperature=0.7, top_k=50
  2. 创意输出temperature=1.2, top_p=0.9, freq_penalty=0.5

5.5 小实验:文本生成

假设我们已经训练过模型,现在让它生成一些句子。

# 假设我们用字符级分词
prompt = "Once upon a time"
idx = torch.tensor([encode(prompt)], dtype=torch.long).to(device)
# Sample 1:偏确定
out = generate(model, idx, max_new_tokens=100, temperature=0.7, top_k=50)
print("=== Sample 1 ===")
print(decode(out[0].tolist()))
# Sample 2:更有创意
out = generate(model, idx, max_new_tokens=100, temperature=1.2, top_p=0.9, freq_penalty=0.5)
print("=== Sample 2 ===")
print(decode(out[0].tolist()))

输出示例:

=== Sample 1 ===
Once upon a time, there was a small language model. It tried to read books, tell stories, and learn from text.
=== Sample 2 ===
Once upon a time, there was a curious model. It played with symbols, invented new phrases, and even wrote in Chinese: 模型学习文字。

5.6 如何观察差异?

  1. 低温度 + top-k → 输出更稳定,像复述语料。
  2. 高温度 + top-p → 输出更自由,可能带来惊喜。
  3. 加惩罚项 → 避免复读机,让文本更有变化。


六、常见问题与报错修复

当你在 Google Colab 或本地训练 mini-GPT 时,可能会遇到各种报错或奇怪现象。这里可以帮你快速定位问题、理解原因、找到解决办法。

6.1 随机数范围错误(RuntimeError

常见报错

RuntimeError: random_ expects 'from' to be less than 'to', but got from=0 >= to=-91

原因:在 get_batch 里,我们会随机选一个起点 ix。如果 语料太短,而 block_size 设置得太大,就会出现 “可选范围是负数” 的情况。

解决方法

  1. 扩充语料:保证文本长度远大于 block_size
  2. 减小 block_size:比如从 128 改为 32。

预防建议:一般保证 len(text) ≥ 10 × block_size,训练才比较稳定。

6.2 AMP 警告(FutureWarning

常见警告

FutureWarning: `torch.cuda.amp.GradScaler` is deprecated.
Please use `torch.amp.GradScaler('cuda', args...)` instead.

原因PyTorch 新版本更换了 AMP(自动混合精度)的 API

解决方法

把原来写的:

scaler = torch.cuda.amp.GradScaler(enabled=(device=="cuda"))

改成:

scaler = torch.amp.GradScaler("cuda", enabled=(device=="cuda"))

同样,把:

with torch.cuda.amp.autocast(enabled=(device=="cuda")):

改成:

with torch.amp.autocast("cuda", enabled=(device=="cuda")):

预防建议:每次升级 PyTorch 版本,注意官方 release notes

6.3 CUDA 显存不足(Out of Memory, OOM

常见报错

RuntimeError: CUDA out of memory

原因:模型太大,或者 batch_size 太大,超出了 GPU 显存。

解决方法

  1. 减小 batch_size,比如从 64 改成 16。
  2. 减小 模型参数,比如 n_layer=2, n_head=2, n_embd=128
  3. 使用 混合精度训练(AMP,减少显存占用。

预防建议:先用小模型、小 batch 跑通流程,再逐渐加大规模。

6.4 验证集损失(val_loss)过高

训练日志可能是:

step 100: train_loss=0.8 | val_loss=9.0

原因

  1. 模型过拟合,只会背训练集,不会泛化。
  2. 验证集太小,loss 波动大。
  3. 小模型本身泛化能力有限。

解决方法

  1. 扩充训练语料(最好几 MB 起步)。
  2. 使用 BPE 分词,减少 token 数量,提高效率。
  3. 调整数据划分,比如 80% 训练 / 20% 验证。

预防建议:不要在意小模型的绝对 val_loss 数值,更关注“是否在下降”。

6.5 模型输出“复读机”

模型生成时不断重复同一句:

Hello world! Hello world! Hello world! ...

原因

  1. 小模型 + 小语料,只学到最常见的模式。
  2. 温度太低,模型总是选最稳妥的答案。
  3. 没有加惩罚项,导致重复越来越多。

解决方法

  1. 调高 温度(1.0 ~ 1.2)。
  2. 使用 top_p=0.9 或 top_k=50
  3. 设置 freq_penalty=0.5,减少重复。
  4. 提供更丰富的语料。

预防建议:一开始就别指望小模型输出“长篇大论”,要么会复读,要么会“胡言乱语”,这是正常现象。

6.6 BPE 训练失败(词表太大)

常见报错

RuntimeError: Vocabulary size too high (8000).
Please set it to a value <= 620.

原因:语料太小,支持不了这么大的词表。

解决方法

  1. vocab_size_bpe 改小(200 ~ 1000)。
  2. 设置 hard_vocab_limit=False,让实际词表小于目标值也能训练成功。

预防建议:语料量 ≤ 1 MB 时,建议 vocab_size 不要超过 1000。

6.7 生成结果和预期不符

输出结果和训练文本几乎一模一样,像在“背书”。

原因

  1. 模型规模太小。
  2. 数据太少。
  3. 训练步数不足。

解决方法

  1. 增加训练步数(比如从 1000 → 5000)。
  2. 增大模型(更多层数/头数/embedding)。
  3. 用更丰富的语料。

预防建议:小语料实验本来就是“背书” → “复读” → “慢慢有点新意”的过程,不必焦虑。

遇到问题时,先对照这里找原因,再去调整参数或数据。记住:大多数问题并不是“你写错代码”,而是 小模型 + 小语料的正常局限


七、实验结果与分析

跑完 mini-GPT 之后,很多同学都会问:

“我跑出来的结果怎么像是在背书,这算成功吗?”

答案是:是的,这已经是成功了!因为在小语料 + 小模型的实验里,能输出通顺的句子,就说明模型结构正确、训练流程跑通。

记住:第一次跑 mini-GPT 的目标,不是生成“惊艳的小说”,而是确认 LLM 的基本机制你理解了



7.1 什么算是“成功”?

  1. 输出是 通顺的文本(即使和训练语料很像)。
  2. 结果 不是乱码(说明分词器没问题)。
  3. loss 在训练中 有下降趋势

真正的“失败”信号:

  1. loss 一直不下降。
  2. 输出全是乱码。

7.2 常见的生成现象

① 复读机

输出内容反复,比如:

Once upon a time, Once upon a time, Once upon a time...

原因

  1. 小模型参数少,只能捕捉最简单的模式。
  2. 推理时温度过低,总是选“最安全”的答案。

改善方法

  1. 提高 温度(1.0 ~ 1.2)。
  2. 使用 top_p=0.9 或 top_k=50
  3. 加入 freq_penalty=0.5,减少重复。
  4. 增加语料,让模型学会更多样的表达。
② 背课文

模型直接输出训练语料里的句子:

The quick brown fox jumps over the lazy dog.

原因

  1. 模型参数少,只能死记硬背。
  2. 数据太少,没法学到更复杂的规律。

改善方法

  1. 增加训练语料(从几百 KB → 几 MB)。
  2. 增大模型容量(更多层数、更多 embedding 维度)。
  3. 使用更高级的采样策略(top-p + penalty)。
③ 夹杂语言

输出中英文混合:

Hello world! 模型学习文字。

原因

  1. 分词器里同时包含中英文。
  2. 语料双语混合,模型在概率分布上“左右摇摆”。

改善方法

  1. 如果目标是单语模型,就只用一种语言的语料。
  2. 如果想做双语模型,就需要更多中英文混合的数据,让模型学会“自然切换”。
④ 偶尔胡言乱语

有时输出会像这样:

Hello world! Soks, ck字frn...

原因

  1. 模型参数不足,概率分布不够平滑。
  2. 数据太少,模型没学会长程依赖。

改善方法

  1. 增加训练步数(比如从 1000 → 5000)。
  2. 增加语料。
  3. 增大模型规模。

7.3 如何评估模型效果?

训练阶段
  1. train_loss 持续下降 → 模型在学习。
  2. val_loss 先降后升 → 过拟合,需要更多数据或正则化。
生成阶段
  1. 文本通顺 → 学到语言模式。
  2. 不是乱码 → 分词器/模型结构正常。
  3. 有多样性 → 采样策略发挥作用。
  4. 能生成语料外的新组合 → 模型开始“创作”。
👉 小模型的评估不看“多强大”,而是“是不是在正确地学习”。

7.4 为什么小模型容易复读?

  1. 参数太少 → 只能捕捉最简单的统计规律。
  2. 语料太小 → 没有足够的“语言多样性”。
  3. 分布太尖锐 → 训练中模型对某些 token 过度自信。
  4. 采样策略过保守 → 温度太低,总是选同一个答案。

7.5 示例结果解读

在前面对话里,你的输出是:

=== Sample 1 ===
Once upon a time, there was a small language model...
语言是人类的工具, 也是思想的载体。
Once upon a time...
=== Sample 2 ===
Once upon a time, there was a small language model...
诗言志,歌咏言。语言是人类的工具...
  1. 英文部分能续写 → 学到了英文语料。
  2. 中文部分能生成 → 分词器支持多语言。
  3. 出现复读 → 符合小模型的特点。

结论:模型结构正确,训练流程跑通,结果符合预期。

7.6 如何从“背书”进阶到“写作文”?

要让模型从“背语料”进化到“创作新内容”,需要三方面提升:

短期优化(实验友好)
  1. 增加训练步数(1000 → 5000+)。
  2. 使用更好的采样策略(temperature + top-p + penalty)。
  3. 扩充语料(比如几 MB 的小说或文章)。
长期优化(研究/实用方向)
  1. 加大模型
  2. n_layer=2 → 8
  3. n_head=4 → 8
  4. n_embd=128 → 512
  5. 加大语料规模:从几 MB → 几 GB
  6. 使用更强硬件:单卡 → 多卡训练。
小结
  1. 想理解原理 → mini-GPT 就够了。
  2. 想做实验玩具 → 扩充语料 + 参数调优。
  3. 想接近真实 LLM → 需要大规模训练 + 硬件支持。


八、进阶优化

在前面,我们已经跑通了一个 mini-GPT,它能背书、能复读、能生成简单的句子。但如果你希望它更聪明、更像“ChatGPT”,就需要进阶优化。下面我们从数据、模型、训练、推理到应用,一步步升级。

8.1 扩充语料:从几 KB 到几 MB

问题:我们之前只喂了几十行文本。就好比让小孩只看了十几页书,他只能重复里面的内容,当然会“复读机”。

改进方法

  1. 更大的语料库,至少几 MB 起步。
  2. 语料来源:
  3. 公共版权的小说(如《西游记》、《哈利波特》英文版)。
  4. 新闻文章。
  5. 维基百科 dump
  6. 开源数据集(TinyStoriesOpenWebText)。

👉 经验法则:

  1. 语料越大 → 模型学到的模式越丰富。
  2. 语料越多样 → 生成的内容越自然。

建议

  1. 初学者:找一本英文小说或几万字中文故事即可。
  2. 进阶用户:用几十 MB 的开源数据集。

8.2 增大模型容量

问题mini-GPT 只有几十万参数,太“小脑袋”,只能学点皮毛。

改进方法:逐步增加参数。

参数

当前设置

推荐进阶

n_layer

2

4~12

n_head

4

8

n_embd

128

256~512

block_size

64

128~512

⚠️ 注意:

  1. 模型一旦变大,需要更多显存。
  2. Colab 免费版大约能撑到 n_layer=6, n_head=8, n_embd=256

建议

  1. 初学者:保持小模型,先理解机制。
  2. 进阶用户:逐步加大,看看效果如何变化。

8.3 长上下文:从 64 → 512+

问题:我们的 block_size=64,模型的“记忆力”只有一句话长。

改进方法

  1. 增大 block_size,比如 256 或 512。
  2. 使用 RoPE(旋转位置编码) 代替传统位置 embedding,支持更长上下文。
  3. 确保训练片段长度 ≥ block_size

👉 好处:模型能记住更长的对话或文章,而不是“一句话记忆”。

建议

  1. 初学者:64 就够用,训练快。
  2. 进阶用户:尝试 256+,体验更长的上下文建模。

8.4 提升训练稳定性

问题:小实验里训练可能会崩溃或不稳定。

我们已经用过

  1. 学习率调度(warmup + cosine)。
  2. 梯度裁剪。

进阶方法

  1. 混合精度训练(AMP:显存占用减半,速度更快。
  2. 梯度累积:把多个小 batch 合成大 batch
  3. 检查点保存:定期保存模型,避免断电或 Colab 崩掉时损失进度。

8.5 采样策略再优化

训练好后,生成文字的“风格”主要靠采样策略控制。

常见方法

  1. 贪心搜索:每次都选最可能的词 → 最安全,但容易复读。
  2. Temperature(温度):调节随机性,温度越高越大胆。
  3. Top-k:只在概率最高的 k 个里选。
  4. Top-p(核采样):保留累计概率 ≤ p 的词,更灵活。
  5. 惩罚项freq_penalty=0.5 避免复读,pres_penalty=0.3 鼓励换话题。

建议

  1. 故事生成 → 高温度 + top-p
  2. 问答 → 低温度 + 贪心搜索。

8.6 微调方法(Finetuning

如果你不想从零训练,而是基于大模型做“定制”,可以用:

  1. 全量微调:更新所有参数 → 成本高。
  2. LoRA(低秩适配):只加“外挂模块”,只更新外挂,不改大脑 → 轻量。
  3. QLoRA:先把大脑压缩(量化),再加外挂 → 更省显存。

建议

  1. 初学者:不用管微调,先跑通小模型。
  2. 进阶用户:尝试 LoRA,很多开源工具已经封装好。

8.7 让模型“更懂人类”

训练好的语言模型会说话,但不一定符合人类喜好。要让它“听话”,需要:

  1. SFT(监督微调):在人工标注的问答对上训练。
  2. RLHF(人类反馈强化学习):收集人类打分,用奖励模型指导生成。
  3. DPO(直接偏好优化):替代 RLHF,更高效,近期很火。

这正是 GPT-3 变成 ChatGPT 的关键步骤。

建议

  1. 初学者:知道就好。
  2. 进阶用户:可以尝试小规模 SFT

8.8 部署与应用

训练好模型之后,如何用起来?

最小可行方案: 在 Colab 写个 while True: input() 的循环,就能做命令行聊天。

进阶方法

  1. 推理优化KV Cache(避免每次都重算上下文)。
  2. 模型压缩:量化成 int8/int4,节省显存。
  3. 服务化:用 FastAPI/Gradio 包装成网页或接口。

小结

  1. 初学者目标:扩充语料,尝试采样参数调节,能体验模型的改进效果。
  2. 进阶用户目标:扩大模型,尝试 LoRA 微调,做更长上下文训练,甚至部署成小应用。

记住:mini-GPT 的价值在于让你“看懂和跑通”。真正的大模型,需要更多数据、显卡和工程手段。



九、总结与学习路径

9.1 我们做了什么?

在这篇长文里,我们从零开始,走完了一个 mini-GPT 的完整实现流程。到这里,你已经能自信地说:我理解了 LLM 的核心原理;我能从头写出一个简化版 GPT;我能让它在 Colab 上跑起来,并生成文字。换句话说,你已经从“只是用 ChatGPT 的人”,升级为“知道 ChatGPT 内部怎么运作的人”。

9.2 mini-GPT 给了我们什么?

很多人觉得大语言模型是个“黑箱”。但通过这次实践,你应该收获了三个关键词:

  1. 透明性:每一步代码都能看懂,模型不再神秘。
  2. 可控性:调温度、调 top-p,就能立刻看到输出风格变化。
  3. 成长性:知道如何从“小实验”一步步扩展到“大模型”。

这就像学乐器:一开始你只能弹《小星星》,但乐理你已经掌握了,接下来就是练习、扩展、进阶。

9.3 零基础读者的学习路径(三步法)

  1. :先反复跑通本教程的代码,确保理解每个环节。
  2. :修改参数(block_sizen_layern_head、 温度、top-p),观察生成差别。
  3. 扩展:换语料(诗歌、小说、新闻),感受模型如何学习不同风格。

9.4 进阶读者的学习路径(五步法)

  1. 加数据:从 KB 文本扩展到 MB 级别小说或开源数据集。
  2. 加模型:把层数、头数、embedding 提高,体验模型“更聪明”的差别。
  3. 用框架:学习 Hugging Face,加载预训练权重,再在本地语料上微调。
  4. 轻量微调:尝试 LoRA/QLoRA,这是工业界常用的技巧。
  5. 对齐优化:理解并尝试 SFTRLHFDPO,知道为什么 ChatGPT 更贴近人类需求。

结语

大语言模型看似庞大复杂,但本质就是:预测下一个 token,然后通过数据和计算,把这种预测能力放大到“像人一样对话”。你今天跑的 mini-GPT,也许笨拙、爱复读,但它的原理和 GPT-4 完全一致。

所以,从今天起: 你不再只是“用 LLM 的人”,而是“理解 LLM 的人”。 你已经从观众席走上舞台,手里有了属于自己的“小型 ChatGPT”。这,就是你进入 AI 世界的重要一步。



🎁 彩蛋:一键运行 Notebook

如果你不想从零复制粘贴代码,或者想直接体验完整的 mini-GPT 实现,我已经准备了一份 Google Colab Notebook

👉 点击这里直接运行 mini-GPT(Colab)


声明:该内容由作者自行发布,观点内容仅供参考,不代表平台立场;如有侵权,请联系平台删除。
标签:
大模型
生成式大模型
模型训练
模型优化
自然语言处理
语言模型应用