
1. 項目概述為什么26M參數的GPT值得你花2小時看到“26M參數”和“GPT”這兩個詞放在一起很多人的第一反應可能是這能干什么現在動輒百億、千億參數的大模型滿天飛一個區區兩千六百萬參數的“小玩意兒”有什么訓練的必要這正是這個教學項目的精妙之處——它剝離了所有關于算力的神話和資源的焦慮直指大語言模型LLM最核心的運作原理。這個項目的目標不是讓你復現一個能寫詩、編程、聊天的ChatGPT而是讓你在短短兩小時內親手“捏”出一個能理解字符序列、并基于此生成新文本的微型GPT。這26M參數就像一個精密的鐘表機芯雖然體積小但齒輪注意力機制、發條前饋網絡、擒縱機構層歸一化一應俱全。通過訓練它你將透徹理解Token是如何被嵌入成向量的自注意力機制到底在“注意”什么模型是如何通過概率預測下一個詞的這些問題的答案遠比盲目調用API來得深刻。它適合所有對AI底層原理抱有好奇心但被海量數學公式和龐大工程嚇退的開發者、學生甚至產品經理。你不需要八卡A100一臺有GPU的消費級電腦甚至用CPU也能跑只是慢點就足夠了。這個項目的價值在于“教學”在于“體驗”在于讓你獲得對Transformer架構最直觀的、肌肉記憶般的理解。當你看著自己從零搭建的模型從輸出亂碼到逐漸能拼湊出有意義的單詞和短句時那種成就感是無可替代的。接下來我們就拆開這個“鐘表”看看每一個零件是怎么工作的。2. 核心架構拆解微型GPT的“五臟六腑”一個完整的GPT模型無論參數大小其核心架構都是Transformer的解碼器Decoder堆疊。我們的26M參數版本可以看作是一個高度精簡但功能完備的“教學模型”。我們來逐一拆解它的核心組件并解釋為什么在這個規模下我們如此設計。2.1 詞表與嵌入層從字符到數字世界的橋梁首先模型不認識單詞它只認識數字。我們需要一個“詞典”把輸入的文本比如“hello world”轉換成一串數字ID這個過程叫Tokenization分詞。對于教學項目為了極致簡單我們通常采用字符級Character-level分詞。也就是說我們的詞表Vocabulary就是所有可能出現的字符集合例如英文小寫字母a-z、數字0-9、空格、標點等。假設我們有100個字符那么詞表大小vocab_size就是100。為什么用字符級而不是更先進的子詞Subword分詞如BPEByte Pair Encoding原因很簡單簡化。字符級分詞無需復雜的合并算法詞表極小實現直觀。雖然它會讓模型學習更長距離的依賴關系變得更難因為“hello”需要5個token而不是1個但對于理解原理和在小數據集上快速驗證它是完美的選擇。在26M參數規模下模型有能力學習字符間的組合規律。嵌入層Embedding Layer就是一個簡單的查找表。每個字符ID一個整數通過這個查找表被映射為一個固定長度的稠密向量比如128維。這個向量就是該字符的“分布式表示”它會在訓練過程中被不斷調整使得語義相近的字符如‘a’和‘A’在向量空間中的位置也接近。2.2 核心引擎Transformer解碼器塊這是模型的心臟。一個解碼器塊主要由以下部分組成我們的微型GPT可能會堆疊4到6個這樣的塊自注意力機制Causal Self-Attention這是Transformer的靈魂。它允許序列中的每個“位置”去查看序列中所有之前的位置因果掩碼確保它不能“偷看”未來并計算一個加權和。簡單來說模型在預測下一個字符時會問自己“根據我已經看到的這些字符哪一個或哪幾個對預測下一個字符最重要” 權重就是通過查詢Query、鍵Key、值Value三組向量計算得出的。在微型GPT中我們通常使用多頭注意力比如4個頭每個頭學習不同方面的依賴關系例如一個頭關注語法結構一個頭關注詞性搭配。前饋神經網絡Feed-Forward Network注意力層的輸出會經過一個簡單的全連接網絡通常包含一個放大和縮小的過程例如從128維放大到512維再縮回128維。它的作用是為每個位置的特征提供一次非線性變換和特征混合增加模型的表達能力。層歸一化LayerNorm與殘差連接Residual Connection這是訓練深層網絡穩定的關鍵。每個子層注意力、前饋之前或之后都會應用層歸一化將數據分布拉回穩定狀態。殘差連接則是將子層的輸入直接加到其輸出上輸出 子層(輸入) 輸入。這有效地解決了深度網絡中的梯度消失問題讓信息可以暢通無阻地穿越很多層。2.3 輸出層從特征到概率經過多個解碼器塊處理后我們得到了每個位置的一個高級特征向量。最后我們需要將這個向量映射回詞表空間。我們使用一個線性層Linear Layer將特征向量的維度如128投影到詞表大小100。這個操作會為詞表中的每個字符生成一個“分數”logits。然后我們使用Softmax函數將這些分數轉換為概率分布。模型預測的下一個字符就是從這個概率分布中采樣或取概率最大的那個得到的。參數估算26M參數從哪里來我們來粗略算一下。假設詞表大小100嵌入維度128那么嵌入層參數約100 * 128 12.8K。一個解碼器塊的主要參數在注意力層和前饋層注意力層的QKV投影矩陣和前饋層的兩個線性層。如果堆疊6個塊每個塊參數約4M總共就在24M左右加上最后的輸出層總數就接近26M。這是一個非常緊湊但足以演示Transformer核心機制的設計。3. 實戰兩小時訓練流水線全解析理論清晰后我們進入實戰環節。這兩小時需要高效利用每一步都有其目的和技巧。3.1 環境準備與數據加載10分鐘環境推薦使用Python和PyTorch。安裝命令極其簡單pip install torch。如果你有NVIDIA GPU確保安裝了對應版本的CUDA和cuDNNPyTorch安裝時會自動匹配。數據選擇一個小而經典的數據集。莎士比亞全集、維基百科的某個小條目、甚至是幾篇新聞文章都可以。數據量在1MB到10MB之間為宜。太大的數據兩小時處理不完太小則模型學不到模式。這里我們以“莎士比亞作品”文本為例。import torch import torch.nn as nn import torch.nn.functional as F import requests # 下載數據 url https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt text requests.get(url).text print(f數據長度: {len(text)} 字符) print(text[:500]) # 預覽前500個字符數據預處理構建字符級詞表。# 創建字符到索引和索引到字符的映射 chars sorted(list(set(text))) vocab_size len(chars) print(f詞表大小: {vocab_size}) print(.join(chars)) stoi {ch:i for i,ch in enumerate(chars)} # 字符 - 索引 itos {i:ch for i,ch in enumerate(chars)} # 索引 - 字符 encode lambda s: [stoi[c] for c in s] # 編碼函數 decode lambda l: .join([itos[i] for i in l]) # 解碼函數 # 將整個文本編碼為張量 data torch.tensor(encode(text), dtypetorch.long) print(data.shape, data.dtype)3.2 模型定義與初始化20分鐘現在我們根據第二部分的設計用PyTorch定義模型。這里給出一個高度精簡但結構清晰的實現框架。import torch.nn as nn import math class CausalSelfAttention(nn.Module): 帶因果掩碼的多頭自注意力 def __init__(self, embed_dim, num_heads): super().__init__() assert embed_dim % num_heads 0 self.num_heads num_heads self.head_dim embed_dim // num_heads # 通常將Q,K,V投影合并到一個線性層中提升效率 self.c_attn nn.Linear(embed_dim, 3 * embed_dim) # 輸出Q, K, V self.c_proj nn.Linear(embed_dim, embed_dim) # 輸出投影 # 因果掩碼確保位置i只能看到i的位置 self.register_buffer(bias, torch.tril(torch.ones(block_size, block_size)) .view(1, 1, block_size, block_size)) def forward(self, x): B, T, C x.size() # 批大小序列長度特征維度 # 計算Q, K, V qkv self.c_attn(x) q, k, v qkv.split(self.embed_dim, dim2) # 重塑為多頭 k k.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) q q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) v v.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) # 注意力計算 (縮放點積注意力) att (q k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1))) att att.masked_fill(self.bias[:,:,:T,:T] 0, float(-inf)) att F.softmax(att, dim-1) y att v # 合并多頭輸出 y y.transpose(1, 2).contiguous().view(B, T, C) y self.c_proj(y) return y class Block(nn.Module): 一個Transformer解碼器塊 def __init__(self, embed_dim, num_heads): super().__init__() self.ln1 nn.LayerNorm(embed_dim) self.attn CausalSelfAttention(embed_dim, num_heads) self.ln2 nn.LayerNorm(embed_dim) self.mlp nn.Sequential( nn.Linear(embed_dim, 4 * embed_dim), # 放大 nn.GELU(), # 激活函數 nn.Linear(4 * embed_dim, embed_dim), # 縮小 ) def forward(self, x): # 殘差連接 層歸一化Pre-Norm結構更穩定 x x self.attn(self.ln1(x)) x x self.mlp(self.ln2(x)) return x class MiniGPT(nn.Module): 我們的26M參數微型GPT def __init__(self, vocab_size, embed_dim256, block_size256, num_layers6, num_heads8): super().__init__() self.block_size block_size self.token_embedding nn.Embedding(vocab_size, embed_dim) self.position_embedding nn.Embedding(block_size, embed_dim) # 位置編碼 self.blocks nn.Sequential(*[Block(embed_dim, num_heads) for _ in range(num_layers)]) self.ln_f nn.LayerNorm(embed_dim) self.lm_head nn.Linear(embed_dim, vocab_size) # 語言模型頭 # 參數初始化很重要 self.apply(self._init_weights) def _init_weights(self, module): if isinstance(module, nn.Linear): torch.nn.init.normal_(module.weight, mean0.0, std0.02) if module.bias is not None: torch.nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): torch.nn.init.normal_(module.weight, std0.02) def forward(self, idx, targetsNone): B, T idx.shape # 詞嵌入 位置嵌入 tok_emb self.token_embedding(idx) # (B,T,embed_dim) pos torch.arange(0, T, deviceidx.device) # (T) pos_emb self.position_embedding(pos) # (T, embed_dim) x tok_emb pos_emb # (B,T,embed_dim) x self.blocks(x) x self.ln_f(x) logits self.lm_head(x) # (B, T, vocab_size) loss None if targets is not None: B, T, C logits.shape logits logits.view(B*T, C) targets targets.view(B*T) loss F.cross_entropy(logits, targets) return logits, loss def generate(self, idx, max_new_tokens): 自回歸生成文本 for _ in range(max_new_tokens): # 裁剪上下文到block_size idx_cond idx[:, -self.block_size:] # 前向傳播 logits, _ self(idx_cond) # 聚焦最后一個時間步 logits logits[:, -1, :] # (B, C) # 用溫度采樣增加隨機性 probs F.softmax(logits, dim-1) idx_next torch.multinomial(probs, num_samples1) # (B, 1) # 拼接生成結果 idx torch.cat((idx, idx_next), dim1) return idx初始化技巧注意代碼中的_init_weights方法。用較小的正態分布std0.02初始化權重是訓練Transformer模型的標準做法這有助于在訓練初期保持激活值的穩定性。將偏置bias初始化為0也是常見操作。3.3 訓練循環與超參數設置80分鐘這是最耗時的部分但代碼結構很清晰。我們將數據分割成訓練集和驗證集90%/10%并創建數據加載器。# 分割數據 n int(0.9 * len(data)) train_data data[:n] val_data data[n:] def get_batch(split): 隨機獲取一個小批量的數據 data train_data if split train else val_data ix torch.randint(len(data) - block_size, (batch_size,)) x torch.stack([data[i:iblock_size] for i in ix]) y torch.stack([data[i1:iblock_size1] for i in ix]) return x, y # 超參數設置這是關鍵 batch_size 32 # 每次訓練輸入的樣本數 block_size 256 # 模型能處理的最大上下文長度 learning_rate 3e-4 # 學習率Adam優化器的黃金標準 max_iters 5000 # 最大迭代步數控制訓練時間 eval_interval 500 # 每多少步評估一次 eval_iters 200 # 評估時使用的迭代次數用于估算平均損失 # 初始化模型、優化器 model MiniGPT(vocab_sizevocab_size, embed_dim256, block_sizeblock_size, num_layers6, num_heads8) print(f模型參數量: {sum(p.numel() for p in model.parameters())/1e6:.2f}M) model model.to(device) # 如果有GPU移到GPU上 optimizer torch.optim.AdamW(model.parameters(), lrlearning_rate) # AdamW是Adam的改進版帶權重衰減 torch.no_grad() def estimate_loss(): 估算訓練集和驗證集的損失 out {} model.eval() for split in [train, val]: losses torch.zeros(eval_iters) for k in range(eval_iters): X, Y get_batch(split) X, Y X.to(device), Y.to(device) _, loss model(X, Y) losses[k] loss.item() out[split] losses.mean() model.train() return out # 訓練循環 for iter in range(max_iters): # 每隔一段時間評估一次 if iter % eval_interval 0 or iter max_iters - 1: losses estimate_loss() print(f第{iter}步: 訓練損失 {losses[train]:.4f}, 驗證損失 {losses[val]:.4f}) # 獲取一個批量數據 xb, yb get_batch(train) xb, yb xb.to(device), yb.to(device) # 前向傳播計算損失 _, loss model(xb, yb) # 反向傳播更新參數 optimizer.zero_grad(set_to_noneTrue) # 清零梯度set_to_noneTrue可以節省內存 loss.backward() optimizer.step() print(訓練完成)超參數設置心得學習率3e-4對于Adam優化器這是一個經過大量實踐驗證的、近乎“萬能”的起始學習率。對于我們的微型模型這個值非常安全。批量大小32在GPU內存允許的范圍內批量大小越大梯度估計越準訓練越穩定。但太大也可能導致泛化能力下降。32是一個兼顧速度和穩定性的常見值。上下文長度256這限制了模型能“看到”多遠的過去。對于字符級模型256個字符大約是一段話的長度足以讓模型學習到基本的單詞拼寫和短句結構。最大迭代步數5000在兩小時的限制下我們需要估算每一步的時間。在消費級GPU上5000步大約需要60-80分鐘留出評估和生成的時間。3.4 文本生成與效果評估10分鐘訓練結束后最激動人心的時刻到了讓模型“開口說話”。我們提供一個起始字符串context讓模型自回歸地生成后續文本。# 將模型設置為評估模式 model.eval() # 生成文本 context torch.tensor([encode(KING: )], dtypetorch.long, devicedevice) # 以“KING: ”開頭 generated_ids model.generate(context, max_new_tokens500)[0].tolist() generated_text decode(generated_ids) print(generated_text)生成策略解析代碼中使用了torch.multinomial進行采樣。這意味著模型不是永遠選擇概率最高的那個字符貪婪搜索而是根據概率分布隨機采樣。這能帶來更多樣化、更有趣的生成結果但有時也會產生不合邏輯的內容。你可以嘗試“溫度”Temperature采樣。在Softmax之前將logits除以一個溫度系數T。T 1如1.2會使分布更平滑生成更隨機、更有創造性的文本T 1如0.8會使分布更尖銳生成更確定、更保守的文本。代碼中可以這樣修改temperature 0.8 logits logits / temperature probs F.softmax(logits, dim-1)另一種高級策略是Top-k 或 Top-p核采樣即只從概率最高的k個候選詞中采樣或從累積概率達到p的最小候選詞集合中采樣。這能有效避免采樣到概率極低的奇怪字符。評估生成質量沒有絕對標準但你可以觀察字符級連貫性生成的單詞看起來像英文單詞嗎如“helllo”是錯的“hello”是對的。語法結構有沒有出現大寫字母開頭、句號結尾的短句上下文一致性如果輸入是“KING: ”生成的內容是否像戲劇臺詞模型是否學到了訓練數據莎士比亞的風格4. 避坑指南與性能優化實錄在實際操作中你幾乎一定會遇到下面這些問題。這里記錄了我的踩坑經驗和解決方案。4.1 訓練不收斂或損失為NaN這是新手最常見的問題。檢查初始化確保你按照示例代碼進行了正確的權重初始化std0.02。錯誤的初始化如std過大會導致激活值爆炸梯度變成NaN。檢查學習率3e-4對AdamW通常是安全的。如果你手動調整了模型架構如大幅增加embed_dim可能需要微調學習率。一個簡單的策略是使用學習率預熱Warmup在訓練的前幾百步將學習率從0線性增加到設定值這有助于訓練初期穩定。檢查梯度裁剪Gradient Clipping在loss.backward()之后optimizer.step()之前加入一行代碼torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。這可以防止梯度爆炸將梯度向量的范數norm限制在1.0以內是訓練RNN和Transformer的常用穩定技巧。檢查輸入數據確保你的輸入張量idx和targets的 dtype 是torch.long整數類型而不是浮點數。交叉熵損失函數要求索引是整數。4.2 模型過擬合與欠擬合過擬合表現訓練損失持續下降但驗證損失在某個點后開始上升。模型“死記硬背”了訓練數據而無法泛化到新數據。解決方案增加數據量是最根本的。此外可以嘗試Dropout在注意力層和前饋層之后添加Dropout。例如在Block的forward函數中x x F.dropout(self.attn(self.ln1(x)), p0.1)。權重衰減Weight Decay我們使用的AdamW優化器已經內置了權重衰減通過weight_decay參數設置通常為0.01或0.1這相當于L2正則化能有效防止過擬合。早停Early Stopping監控驗證損失當其在連續多個評估周期內不再下降時停止訓練。欠擬合表現訓練損失和驗證損失都很高且下降緩慢。模型能力不足無法捕捉數據中的模式。解決方案增加模型容量更多層、更大的embed_dim、延長訓練時間增加max_iters或者檢查模型架構是否有錯誤例如注意力掩碼是否正確殘差連接是否生效。4.3 生成文本質量差輸出重復或陷入循環這是采樣策略的問題。貪婪搜索總是選最高概率極易導致循環。務必使用采樣sampling而非貪婪搜索。同時可以嘗試降低溫度如0.7或使用Top-p采樣如p0.9來平衡生成的質量和多樣性。生成亂碼或非字符檢查你的詞表itos和解碼函數decode。確保模型輸出的索引在詞表范圍內。有時在生成時模型可能輸出超出范圍的索引這通常是因為Softmax前的logits有問題或者采樣函數出錯。4.4 訓練速度慢兩小時是目標但如果你的機器只有CPU可能會超時。使用GPU這是最大的加速手段。確保你的PyTorch安裝了CUDA版本并使用.to(device)將模型和數據移到GPU上。降低精度使用混合精度訓練Mixed Precision Training。這可以顯著減少GPU顯存占用并加快計算。PyTorch中可以使用torch.cuda.amp自動混合精度模塊。調整批量大小在GPU顯存允許的前提下盡可能增大batch_size。更大的批次意味著更少的迭代步數就能看完一遍數據并且梯度估計更準確。減少評估頻率將eval_interval設得大一些如1000步減少驗證集上的前向傳播次數這些是不更新梯度的純耗時間。5. 從教學模型到實用化的思考完成這個26M參數GPT的訓練后你已經掌握了Transformer語言模型最核心的構建、訓練和生成流程。但這只是一個起點。如果你想走向更實用、更強大的模型以下方向值得深入1. 分詞器的升級將字符級分詞換成子詞分詞如BPE。這能極大提升模型處理常見單詞和未知詞的效率。你可以使用Hugging Face的tokenizers庫在更大的語料上訓練一個BPE分詞器然后替換掉項目中的簡單字符詞表。2. 數據與規模的擴展嘗試用更大的數據集如幾十MB的文本訓練一個參數稍多如100M的模型。你會發現模型開始能生成更長的、語法更正確的段落甚至表現出初步的“主題”一致性。這就是“規模定律”Scaling Law的直觀體現更多的數據和參數會涌現出更復雜的能力。3. 引入更先進的架構細節 -旋轉位置編碼RoPE替換掉簡單的可學習位置嵌入RoPE能更好地處理長序列也是LLaMA、GPT-4等主流模型的選擇。 -SwiGLU/RMSNorm嘗試使用SwiGLU激活函數和RMSNorm層歸一化這些是近年來被證明更有效的變體。 -Flash Attention如果你的GPU支持使用Flash Attention實現可以大幅加速注意力計算并降低內存占用讓你能處理更長的序列。4. 指令微調Instruction Tuning與對齊我們的模型現在只是一個“續寫模型”。要讓它能回答問題、遵循指令你需要進行指令微調。這需要收集或構造大量的(指令, 輸入, 輸出)三元組數據在預訓練好的模型基礎上進行有監督微調SFT。這之后還可以通過人類反饋強化學習RLHF進一步對齊模型的輸出與人類偏好。這個2小時的項目就像給你一張地圖和一把鑰匙。地圖是Transformer的架構圖鑰匙是親手運行代碼的體驗。現在你已經站在了大語言模型世界的大門口門后的廣闊天地等待你去探索。真正的挑戰和樂趣始于你開始根據自己的想法修改架構處理新數據解決新問題的那一刻。