从零手搓一个 GPT:只用 Python 标准库,写一个会起名的字符级模型

33 阅读9分钟

用来理解大模型的基础原理的小项目(字符级别)

gpt.py — 纯 Python 手写的字符级 GPT

不依赖任何第三方库,只用 Python 标准库,从标量级自动求导开始,手写一个字符级 GPT。

核心思想(与 GPT-4 一致):

根据已有上下文,预测下一个字符(token)。

区别只是规模:本项目约 1.3 万参数、一份 names.txt、CPU 上训练;GPT-4 是万亿参数、TB 级数据、数千 GPU。


一、模型在做什么

模型是一个"接龙机器":给它前面若干个字符,它预测下一个字符最可能是什么。

训练数据是一堆中文名字。模型从名字里学习"看到『李』之后,下一个字更可能是哪些字"这样的规律,训练完成后可以按姓氏生成新名字。


二、整体流程

首次运行:
  names.txt → 建词表 → 随机初始化参数 → 训练 1000 步 → 保存 gpt_weights.json → 交互起名

之后运行:
  gpt_weights.json → 加载参数 → 交互起名(不再训练)

重新训练:
  删除 gpt_weights.json 再运行

训练与推理分离:训练把参数从随机数调成"有知识";推理用固定参数处理新输入。


如何运行和使用本项目

1. 环境准备

本项目只依赖 Python 标准库,无需安装任何第三方包。

# 确认 Python 版本(3.8+ 即可)
python3 --version

2. 准备训练数据

在项目根目录创建 names.txt每行一个名字(用于学习"字与字"的接龙规律):

李雷
韩梅梅
李娜
王芳
张伟
...

数据越多、越多样,生成名字越自然。空行会被自动跳过。

3. 运行

python3 gpt.py
  • 首次运行:检测到没有 gpt_weights.json → 读取 names.txt → 建词表 → 随机初始化参数 → 训练 1000 步(终端实时显示 loss 下降)→ 保存 gpt_weights.json → 进入交互起名。
  • 之后运行:检测到已有 gpt_weights.json → 直接加载参数 → 跳过训练 → 进入交互起名。

4. 交互起名(怎么用)

进入交互模式后,按提示输入姓氏即可:

👤 你: 我姓李
🤖 为你起的名字: 李雷明

👤 你: 王
🤖 为你起的名字: 王梓涵

👤 你: exit
再见 👋

支持的输入写法:我姓李 / 姓李 / 李姓 / (单字即视为姓氏)。 输入 exit / quit / 退出 / q 结束程序。

5. 重新训练

想用新的 names.txt 重训,删掉权重文件再运行即可:

rm gpt_weights.json
python3 gpt.py

6. 想改模型大小 / 训练步数?

  • 训练步数:改 gpt.pymain() 中的 num_steps = 1000
  • 模型规模:改 gpt.py 顶部的 N_EMBD / BLOCK_SIZE / N_LAYER / N_HEAD(改后需重新训练,旧权重不兼容)。
  • 生成随机性:交互模式默认 temperature=0.7;想更保守改小、更发散改大(位于 interactive_mode 调用 generate_one 处)。

三、参数(state_dict)

init_state_dict(vocab_size) 初始化模型的全部参数(即模型所有可学习参数的集合)。每个参数是一个矩阵,用正态分布随机数填充(std=0.08)。

参数形状作用
wte[vocab_size × n_embd]Token Embedding:每个字符一行向量,作为"字的含义"
wpe[block_size × n_embd]Position Embedding:每个位置一行向量,作为"第几位"
lm_head[vocab_size × n_embd]输出投影:把最终向量映射回词表,得到每个字作为"下一个字"的分数
layer{i}.attn_wq/wk/wv/wo[n_embd × n_embd]注意力的 Q/K/V 投影与输出投影
layer{i}.mlp_fc1/fc2[4·n_embd × n_embd] / [n_embd × 4·n_embd]前馈网络两层全连接

超参数(gpt.py 顶部):

  • N_EMBD = 16:每个 token 用 16 维向量表示
  • BLOCK_SIZE = 16:模型最多能"看" 16 个字符
  • N_LAYER = 1:Transformer 堆叠层数
  • N_HEAD = 4:注意力头数

本质上可以理解为:被查的表+被用来后面用来计算的矩阵(所有查表(wte/wpe)+ 所有被用来计算的矩阵(attn_/mlp_/lm_head)) 此参数的设置依据是魔法gpt 设置的,其科学来源是参数的结构是按 Transformer 标准架构设计的(学术界定的骨架):《Attention Is All You Need》[arxiv.org/abs/1706.03…] (重塑AI 的祖师爷),

BLOCK_SIZE = 16

模型一次最多能"看"多少个字(上下文窗口上限),这里只是为了演示设置16的窗口,正式的大模型窗口更大。大模型长会话会出现幻觉或者上下文丢失,主要就是这个原因。对话超过上下文窗口能看的范围,最早的消息就会被丢弃,这是最常被说的"丢失上下文"。

四、单次前向传播(gpt 函数)

输入当前字符 token_id 和位置 pos_id,输出下一个字符的 logits(每个字一个分数)。

Step 1  Token Embedding:查 wte 得到字符向量
Step 2  Position Embedding:查 wpe 得到位置向量,加到字符向量上
Step 3  RMSNorm 归一化
Step 4  Transformer Block × N_LAYER:
          ├─ 注意力块(见下)
          └─ MLP 块(见下)
Step 5  LM Head:用 lm_head 把向量投影回词表,得到每个字的 logits

注意力块

把同一输入投影成三个视角:

  • Q(Query):"我要找什么"
  • K(Key):"我是什么特征"
  • V(Value):"我贡献什么信息"

流程:

  1. attn_wq/wk/wv 把输入投影成 Q/K/V
  2. 把当前字符的 K、V 存入缓存(因为注意力:只能看过去的字符)
  3. 多头注意力:每个头计算 Q·K / √d 得到匹配分数 → softmax 变权重 → 按权重对 V 加权求和("该关注前面哪些字"在此决定)
  4. attn_wo 把多头输出投影回去,加残差连接

MLP 块

对注意力收集到的信息做深度加工:

fc1(16 → 64 维)→ ReLU(负数归零,引入非线性)→ fc2(64 → 16 维)→ 加残差

残差连接

注意力块和 MLP 块都先保存输入,处理完后再加回原输入(x = F(x) + x),保证原始信息不丢失、梯度可直达底层。


五、训练循环(train 函数)

目标:把随机参数调成"懂起名"的参数。每步重复三件事:

1. 前向传播(Forward)

  • 取一个名字,包成 [BOS, 字1, 字2, ..., 字n, BOS] 的 token 序列
  • 逐位置让模型根据当前字符预测下一个字符
  • 计算交叉熵 loss:-log(P(正确答案))。正确答案概率越高,loss 越小

2. 反向传播(Backward)

  • loss.backward() 从总 loss 沿计算图反推,算出每个参数对错误的"责任"(梯度 grad)
  • 梯度为正表示该调小,为负表示该调大,接近 0 表示无关

3. 参数更新(Adam)

  • 用 Adam 优化器按梯度调整参数:θ = θ - lr · m̂ / (√v̂ + ε)
  • m 记梯度方向,v 记梯度大小,学习率随步数衰减(先大步走、后小步精修)
  • 更新后清零梯度,进入下一步

训练 num_steps = 1000 步后,loss 从高变低,参数存入 gpt_weights.json


最后的目的,一个字推到下个字的,链接线的向量,这条线是多条的多维度的,粗细不同,然后把这不同的线存下来,一个字在模型里是多维向量,它通过多层多条线(embedding、attention、mlp、lm_head)加权。(主要用到了:熵(Entropy) H(P) 交叉熵 H(P,Q) KL 散度 ,去计算链接线的粗细或者说“肌肉记忆”)。

六、推理 / 生成(generate_one)

训练完成后只做前向传播,不再更新参数:

  1. 把姓氏作为前缀喂入模型(强制作为开头)
  2. 自回归逐字生成:每一步取 logits,按 softmax 概率采样下一个字符(概率生成下个字,而非始终一个字,是永远选最大的,而是按概率随机选)
  3. 遇到 BOS 或达到最大长度则停止
  4. temperature 控制随机性:越小越保守,越大越多样

交互模式 interactive_mode 循环读取用户输入,提取姓氏后生成多个候选名字去重输出。 本质就是:用训练存下的线(参数),多维度的线,整合多条线,根据实际情况推出下个字(向前推导)

流程图

┌─────────────────────────────────────────────────────────────┐
│                    ① 初始化(随机脑子)                        │
│  init_state_dict → create_matrix                            │
│  13824 根线,每根是 随机小数(标准差0.08)                       │
│  state_dict = {wte, wpe, attn_wq/wk/wv/wo, mlp_fc1/fc2,     │
│                lm_head}                                      │
└───────────────────────────────┬─────────────────────────────┘
                                 │
                                 ▼
┌─────────────────────────────────────────────────────────────┐
│                    ② 训练(调线 1000 次)                      │
│                                                               │
│  for step in range(1000):                                     │
│    doc = docs[step % len]        # 拿一个名字,如"李雷"        │tokens = [BOS, 李, 雷, BOS]                                │
│                                                               │
│    ┌── 前向:顺着线算分(不存分,只算)──────────┐            │
│    │ for pos:                                     │            │
│    │   logits = gpt(当前字, pos, 缓存, sd)        │            │
│    │   probs  = softmax(logits)  → 每个下个字可能性│            │
│    │   loss  += -log(probs[正确答案])             │            │
│    └─────────────────────────────────────────────┘            │
│                                                               │
│    反向:loss.backward()    → 每根线算"责任"grad              │
│    更新:p.data -= lr * ...  → 加粗/削细相关线                │
│         ("李→雷"线加粗,"李→梅"线削细)                      │
│                                                               │
│  ★ 重复1000次 → 线定型 = 肌肉记忆                             │
└───────────────────────────────┬─────────────────────────────┘
                                 │
                                 ▼
┌─────────────────────────────────────────────────────────────┐
│                    ③ 存线(固化记忆)                          │
│  save_weights(state_dict) → gpt_weights.json                 │
│  只存 13824 个参数数字(线粗细),不存分数/计算图             │
└───────────────────────────────┬─────────────────────────────┘
                                 │
                                 ▼
┌─────────────────────────────────────────────────────────────┐
│                    ④ 推理/生成(用线接龙)                     │
│  generate_one(姓氏="李")                                      │
│                                                               │
│  阶段1:喂姓氏                                                 │
│    把"李"走一遍gpt → 存进KV缓存(模型"看到"李,不输出)       │
│                                                               │
│  阶段2:自回归接龙                                             │
│    token = BOS                                                │
│    loop:                                                      │
│      logits = gpt(token, pos, 缓存, sd)  # 顺着定型线整合算   │probs  = softmax(logits / temperature)                  │
│      token  = sample_weighted(probs)  # 按可能性抽一个字      │
│      if token == BOS: break    # 名字结束                    │
│      seq.append(抽到的字)                                   │
│                                                               │
│  例:李 →(雷0.4,娜0.3)→ 抽到"雷" → 李雷 → 抽到"明" → 李雷明   │
│      → 抽到BOS → 停止 → 输出"李雷明"                         │
└─────────────────────────────────────────────────────────────┘

七、本项目与真实大模型的关系

gpt.pyGPT-4 级别
参数初始化正态分布随机填表(std=0.08正态分布随机填表(更精细的缩放)
核心结构Transformer + Attention + MLP同左
自动求导手写标量级 Value 引擎框架内置张量级自动求导
参数量~1.3 万万亿级
维度n_embd=1612288+

原理完差不多,规模天差地别。理解本项目的 gpt() 函数,就理解了大模型前向传播的主体逻辑。


八、关键心智模型

模型   = 有很多可调数字的函数        → state_dict
训练   = 用数据不断调整这些数字      → train()
loss   = 模型当前错得多严重           → 交叉熵
梯度   = 每个参数对 loss 的责任      → backward()
优化器 = 根据梯度执行参数调整        → Adam
推理   = 用固定参数处理新输入        → generate_one()