推理模型从零构建 第2章:使用Qwen3生成文本
1. 全书定位与本章目标
这本书的核心主题是推理模型 (Reasoning Model) — 不是从零训练一个 LLM(那是 Raschka 上一本书 LLMs from Scratch 的内容),而是在一个已有的预训练模型之上,从零实现各种推理增强技术。
本章是 setup 章节,核心任务:
- 加载 Qwen3 0.6B 基座模型和对应的 tokenizer
- 理解 LLM 逐 token 自回归生成的过程
- 编写
generate_text_basic_stream生成函数 - 用 KV Cache 和 torch.compile 优化推理性能
为什么选 Qwen3?作者给出三个理由:(1) 截至写书时 Qwen3 是性能最强的开源模型;(2) 0.6B 参数量比 Llama 3 1B 更节省显存;(3) 同时有 base 和 reasoning 两个版本,方便做对比实验。
2. Tokenizer:文本与数字的桥梁
LLM 不认识文字,只认识数字。Tokenizer 负责将人类可读的文本转换为 token ID 序列(encode),以及将模型输出的 ID 序列还原为文本(decode)。
书中使用作者自己用 tokenizers 库重新实现的 Qwen3Tokenizer,词汇表大小约 151,936 个 token:
from pathlib import Path
from reasoning_from_scratch.qwen3 import Qwen3Tokenizer, download_qwen3_small
# 下载 tokenizer 文件
download_qwen3_small(kind="base", tokenizer_only=True, out_dir="qwen3")
# 加载 tokenizer
tokenizer_path = Path("qwen3") / "tokenizer-base.json"
tokenizer = Qwen3Tokenizer(tokenizer_file_path=tokenizer_path)
# encode: 文本 → token ID 列表
prompt = "Explain large language models."
input_token_ids = tokenizer.encode(prompt)
for i in input_token_ids:
print(f"{i} --> {tokenizer.decode([i])}")
# 840 --> Ex
# 20772 --> plain
# 3460 --> large
# 4128 --> language
# 4119 --> models
# 13 --> .
# decode: token ID 列表 → 文本(无损还原)
print(tokenizer.decode(input_token_ids))
# "Explain large language models."
ℹ️ 关键观察:注意 “Explain” 被拆成了 “Ex” + “plain” 两个子词 — 这就是 BPE (Byte Pair Encoding) 分词的特点。低频词会被拆成高频子词,确保任何输入都能被编码,不会出现 “unknown token” 问题。Qwen3 的词汇表比 GPT-2 (50,257) 大了约 3 倍,对中文和多语言的支持更好。
3. 加载预训练 Qwen3 模型
模型加载分三步:检测可用硬件 → 下载权重文件 → 实例化模型并加载权重。
3.1 设备检测
书中实现了一个 get_device 函数,按优先级检测 CUDA → MPS (Apple Silicon) → XPU (Intel) → CPU:
import torch
def get_device(enable_tensor_cores=True):
if torch.cuda.is_available():
device = torch.device("cuda")
# 启用 TF32 加速矩阵乘法(Ampere 及以上架构)
if enable_tensor_cores:
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
elif torch.backends.mps.is_available():
device = torch.device("mps")
elif torch.xpu.is_available():
device = torch.device("xpu")
else:
device = torch.device("cpu")
return device
device = get_device() # 自动选择最佳设备
3.2 模型实例化
模型权重文件约 1.5 GB。作者用纯 PyTorch 重新实现了 Qwen3 架构(详见附录 C),与官方权重完全兼容:
from reasoning_from_scratch.qwen3 import (
Qwen3Model, QWEN_CONFIG_06_B, download_qwen3_small
)
# 下载预训练权重(~1.5 GB)
download_qwen3_small(kind="base", tokenizer_only=False, out_dir="qwen3")
# 实例化模型并加载权重
model_path = Path("qwen3") / "qwen3-0.6B-base.pth"
model = Qwen3Model(QWEN_CONFIG_06_B)
model.load_state_dict(torch.load(model_path))
model.to(device)
Qwen3 0.6B 的关键参数:1024 维嵌入,28 层 Transformer,16 个 query head / 8 个 KV head(GQA),词汇表 151,936。总参数量约 6 亿,bfloat16 精度下占用约 1.5 GB 显存。
4. 逐 Token 文本生成
LLM 的文本生成是一个自回归 (autoregressive) 过程:每次把当前的所有 token 喂给模型,取最后一个位置的 logits,用 argmax 选出下一个 token,拼接到输入末尾,循环往复。
理解一下模型的前向传播:输入 6 个 token,模型输出 shape 为 [1, 6, 151936] — 即每个位置都产生了一个 151,936 维的 logit 向量。我们只关心最后一个位置的 logit,对其取 argmax 得到下一个 token ID。
4.1 generate_text_basic_stream
书中用 Python generator(yield)实现了一个流式生成函数,边生成边输出:
@torch.inference_mode()
def generate_text_basic_stream(model, token_ids, max_new_tokens, eos_token_id=None):
model.eval()
for _ in range(max_new_tokens):
# 前向传播,取最后一个位置的 logits
out = model(token_ids)[:, -1]
# 贪心解码:选概率最高的 token
next_token = torch.argmax(out, dim=-1, keepdim=True)
# 遇到终止符则停止
if eos_token_id is not None and torch.all(next_token == eos_token_id):
break
yield next_token # 流式输出每个 token
# 将新 token 拼接到输入序列末尾
token_ids = torch.cat([token_ids, next_token], dim=1)
# 使用示例
prompt = "Explain large language models in a single sentence."
input_ids = torch.tensor(tokenizer.encode(prompt), device=device).unsqueeze(0)
for token in generate_text_basic_stream(model, input_ids, max_new_tokens=100,
eos_token_id=tokenizer.eos_token_id):
print(tokenizer.decode(token.squeeze(0).tolist()), end="", flush=True)
# → Large language models are artificial intelligence systems that can
# understand, generate, and process human language, enabling them to
# perform a wide range of tasks, from answering questions to writing
# articles, and even creating creative content.
⚠️ 为什么需要 eos_token_id? Base 模型在训练时,用 <|endoftext|>(ID: 151643)分隔不同文档。生成时如果不设 eos 终止条件,模型会在生成完有意义的回答后继续 “编” 下一篇文档的内容。设置 eos_token_id 让生成函数在遇到该 token 时及时停止。
5. KV Cache 加速推理
上面的基础版本有一个严重的效率问题:每生成一个新 token,都要重新计算所有之前 token 的 key 和 value。这些已有 token 的 attention 中间结果其实不会变化 — 缓存它们可以避免大量重复计算。
优化后的版本先对完整的 prompt 做一次前向传播(prefill),之后每步只传入一个新 token,配合缓存的 K/V 完成 attention 计算:
from reasoning_from_scratch.qwen3 import KVCache
@torch.inference_mode()
def generate_text_basic_stream_cache(model, token_ids, max_new_tokens,
eos_token_id=None):
model.eval()
cache = KVCache(n_layers=model.cfg["n_layers"]) # 初始化缓存
model.reset_kv_cache()
# Prefill:一次性处理整个 prompt,填充 cache
out = model(token_ids, cache=cache)[:, -1]
for _ in range(max_new_tokens):
next_token = torch.argmax(out, dim=-1, keepdim=True)
if eos_token_id is not None and torch.all(next_token == eos_token_id):
break
yield next_token
# 只传入新生成的 1 个 token,cache 中已有历史 K/V
out = model(next_token, cache=cache)[:, -1]
效果立竿见影:在 Mac Mini M4 CPU 上,从 5 tokens/sec 提升到 29 tokens/sec,接近 6 倍加速。原理很简单 — 避免了 O(n²) 的重复计算,每步的计算量从与序列长度成正比降为常数。
6. torch.compile 进一步提速
torch.compile 是 PyTorch 2.0+ 引入的 JIT 编译功能。它分析模型的计算图,自动进行算子融合、内存优化等,无需修改模型代码:
# 一行代码开启编译优化
model_compiled = torch.compile(model)
# 首次运行会触发编译(较慢),后续运行大幅加速
# 使用方式和原模型完全一样
for token in generate_text_basic_stream_cache(
model=model_compiled,
token_ids=input_ids,
max_new_tokens=100,
eos_token_id=tokenizer.eos_token_id
):
print(tokenizer.decode(token.squeeze(0).tolist()), end="", flush=True)
书中给出了不同硬件和优化组合下的性能对比:
| 模式 | Mac M4 CPU | Mac M4 GPU | NVIDIA H100 |
|---|---|---|---|
| 基础版 | 5 tok/s | 27 tok/s | 51 tok/s |
| + torch.compile | 5 tok/s | 43 tok/s | 164 tok/s |
| + KV Cache | 29 tok/s | 41 tok/s | 48 tok/s |
| + KV Cache + compile | 68 tok/s | 71 tok/s | 141 tok/s |
ℹ️ 性能解读:KV Cache 和 torch.compile 的收益可以叠加。在 CPU 上 KV Cache 效果最显著(6x 加速);在 GPU 上 torch.compile 效果更突出(因为它能优化 GPU kernel 调度)。两者结合是生产环境的标准做法。注意 H100 上 “KV Cache + compile” 比 “compile only” 略慢 — 这是因为 H100 的算力过剩,KV Cache 带来的内存操作反而成了瓶颈。
7. 关键收获
这一章虽然是 setup,但建立了几个贯穿全书的核心概念:
- Tokenizer 是 LLM 的入口:所有文本必须先 encode 为 token ID 才能送入模型。Qwen3 的 BPE tokenizer 有 151,936 个 token,支持多语言。后续章节的 prompt 构造、奖励计算都依赖 tokenizer。
- 自回归生成是基础范式:
generate_text_basic_stream是后续所有采样策略(temperature、top-p、best-of-N)的基础框架。第4章会在这个函数上扩展采样参数。 - KV Cache 是必备优化:不仅是推理加速的工具,第6-7章的 GRPO 训练中也需要生成多个 rollout 采样,KV Cache 直接影响训练效率。
- Greedy decoding 的局限:本章用的
argmax是确定性的贪心解码 — 同样的输入永远得到同样的输出。第4章会引入 temperature scaling 和 nucleus sampling 来增加多样性,这对推理模型至关重要。 - Base model vs Reasoning model:Qwen3 0.6B base 已经能生成连贯文本,但它不会 “思考”。后续章节的目标就是通过各种技术(self-refinement、RLVR、蒸馏)让它学会推理。
ℹ️ 下一章预告:有了能生成文本的模型,下一步是如何评估它的推理能力。第3章将介绍 MATH-500 benchmark,实现答案提取和自动评分流程,建立贯穿全书的评估基准。
本文是 Build a Reasoning Model (From Scratch) (Sebastian Raschka) 的学习笔记。所有配图版权归原作者所有。代码基于原书示例,有简化和中文注释。