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

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

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

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

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

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

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


一、模型在做什么

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

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


二、整体流程

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

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

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

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


如何运行和使用本项目

1. 环境准备

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

bash 复制代码
# 确认 Python 版本(3.8+ 即可)
python3 --version

2. 准备训练数据

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

erlang 复制代码
李雷
韩梅梅
李娜
王芳
张伟
...

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

3. 运行

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

4. 交互起名(怎么用)

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

bash 复制代码
👤 你: 我姓李
🤖 为你起的名字: 李雷明

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

👤 你: exit
再见 👋

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

5. 重新训练

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

bash 复制代码
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...](https://link.juejin.cn?target=https%3A%2F%2Farxiv.org%2Fabs%2F1706.03762 "https://arxiv.org/abs/1706.03762") (重塑AI 的祖师爷),

BLOCK_SIZE = 16

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

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

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

vbnet 复制代码
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 循环读取用户输入,提取姓氏后生成多个候选名字去重输出。 本质就是:用训练存下的线(参数),多维度的线,整合多条线,根据实际情况推出下个字(向前推导)

流程图

ini 复制代码
┌─────────────────────────────────────────────────────────────┐
│                    ① 初始化(随机脑子)                        │
│  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.py GPT-4 级别
参数初始化 正态分布随机填表(std=0.08 正态分布随机填表(更精细的缩放)
核心结构 Transformer + Attention + MLP 同左
自动求导 手写标量级 Value 引擎 框架内置张量级自动求导
参数量 ~1.3 万 万亿级
维度 n_embd=16 12288+

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


八、关键心智模型

scss 复制代码
模型   = 有很多可调数字的函数        → state_dict
训练   = 用数据不断调整这些数字      → train()
loss   = 模型当前错得多严重           → 交叉熵
梯度   = 每个参数对 loss 的责任      → backward()
优化器 = 根据梯度执行参数调整        → Adam
推理   = 用固定参数处理新输入        → generate_one()
相关推荐
火云牌神1 小时前
前后端分离:约束 AI 分工,避免接口耦合与职责错乱
人工智能·系统架构·ai编程·前后端分离·vibecoding
刘立军2 小时前
领域驱动设计:给 AI 划定上下文边界,告别“大泥球”代码
架构·ai编程·领域驱动设计
岛雨QA2 小时前
Claude Code国内无障碍接入 DeepSeek使用指南
ai编程·claude·deepseek
ServBay3 小时前
DeepSeek V4 Pro 发布,1.6T 参数、1M 上下文,又有人坐不住了
aigc·ai编程·deepseek
坚强小橙5 小时前
30 分钟的重复工作,我写了 Claude Code Skill 变成了 8 秒
ai编程
神奇霸王龙5 小时前
DeepSeek 接 Anthropic:迁移屠夫
ai·ai作画·agent·ai编程·claude·claudecode
plainGeekDev5 小时前
如何设计一个 Loop:以登录模块为例
ai编程·claude
全栈弄潮儿6 小时前
为什么 AI 生成的代码不能直接使用?如何对 AI 代码进行检查和验证?
chatgpt·openai·ai编程
文心快码BaiduComate7 小时前
文心快码能力扩展、记忆、代码可视化上线
人工智能·ai编程·vibecoding