引言
為什么要學習 LLM
?
當你和 ChatGPT
對話時,它不僅能回答你的問題,還能續寫故事、記住上下文,甚至調整風格。你可能會想:它是怎么做到的?
答案就是:大語言模型(Large Language Model
, LLM
)。近幾年,從 ChatGPT
到 Claude
,從文心一言到通義千問,從 DeepSeek
到 QWen
,幾乎所有新一代 AI
產品都離不開它。
但很多學習者會有疑問:
-
LLM
太大了,我是不是玩不起?其實不用。我們不會上來就研究 1000 億參數的模型,而是用一個幾十萬參數的
mini-GPT
—— 它保留了核心機制(Tokenizer
、Attention
、Context Window
、采樣策略),只需幾百行代碼,就能在Google Colab
跑起來。 -
代碼會不會復雜得看不懂?
不會。我會逐步拆開,一邊講概念,一邊寫代碼。
-
跑通這個
demo
有什么意義?因為它能讓你真正理解:
- 為什么
LLM
能續寫文本? - 為什么它能“記住”上下文?
- 為什么會出現“復讀機”?
- 調整
Temperature
/Top-k
/Top-p
時,為什么風格完全不同?
- 為什么
flowchart TDA[文本輸入] --> B[分詞器]B --> C[Token 序列(數字ID)]C --> D[Embedding 層]D --> E[Transformer 塊 ×N]E --> F[層歸一化 LayerNorm]F --> G[線性輸出層 Head]G --> H[Softmax 概率分布]H --> I[預測下一個 Token]I --> J[拼接到已有序列]J --> K[生成文本輸出]K -->|直到停止條件| I
本文目標
這篇文章會帶你完成以下目標:
- 從零基礎入門:用最直觀的方式解釋
LLM
關鍵概念。 - 代碼逐步實現:一步步構建
tokenizer
、Transformer
、訓練循環、生成函數。 - 跑通一個
demo LLM
:在Google Colab
上實際訓練并生成文本。 - 理解常見問題:為什么
val_loss
很高?為什么結果會復讀? - 學會擴展:如何從
demo
走向更大、更實用的模型。
適合人群
- 零基礎讀者:你只需要一點點
Python
基礎,就能看懂并跑起來。 - 有經驗的開發者:你能深入理解代碼實現的細節,明白
LLM
的內部機制。 - 研究者/愛好者:你能得到一個可擴展的
mini-GPT
框架,作為更大實驗的起點。
一、LLM
基礎概念
在寫代碼之前,我們先把一些“關鍵詞”解釋清楚。你會發現,LLM
的核心思想其實并不復雜。
1.1 Token
與分詞(Tokenization
)
LLM
并不直接理解“漢字”或“英語單詞”,它看到的只是 一串數字。 這些數字的最小單位就叫 Token
,它可能是一個字母、一個漢字、一個子詞,甚至一個完整的單詞。
分詞(Tokenization
)就是把文字切分成 token
,再轉成數字。打個比方:如果一句話是文章,那么 token
就是“樂高積木”,模型就是學會如何把這些積木拼起來。
- 字符級分詞:
H e l l o
→ [6, 15, 22, 22, 25] BPE
分詞:Hello
→ [23](一個子詞搞定)
兩種常見分詞方式:
-
字符級(
char-level
):直接把每個字符當成一個token
。優點:實現簡單,語言無關。
缺點:序列很長,訓練難度大。
-
BPE
(子詞級):從最小的字符開始,逐步合并常見的子串,形成子詞。優點:序列更短,訓練更快,效果更好。
缺點:需要訓練一個分詞模型(我們用
sentencepiece
實現)。
在我們的代碼里,可以選擇 tokenizer_mode = 'char'
或 'bpe'
。BPE
更推薦,能更快收斂,也能生成更自然的句子。
1.2 上下文窗口(Context Window
)
LLM
一次不能看全篇文章,它有一個“短期記憶”,叫 上下文窗口 (block_size
)。比如 block_size=64
,就表示模型最多能記住最近 64 個 token
,再往前的就忘了。
-
窗口越大 → 能處理更長的上下文,但訓練更慢、更占顯存。
-
小實驗時建議 32~64。
-
在代碼里,
block_size
就是這個“記憶力”的大小。
1.3 預測下一個詞(Next Token Prediction
)
LLM
的訓練目標非常簡單:給定前面的 token
,預測下一個 token
的概率分布。
例子:輸入 "The quick brown"
,模型大概率會預測 "fox"
。
這就是 Next Token Prediction
—— 不斷重復這個游戲,模型就學會了語言規律。
1.4 Transformer
架構
mini-GPT
就是一個簡化的 Transformer
解碼器,由三部分組成:
Embedding
層:把token id
變成向量,并加上位置信息。- 自注意力(
Self-Attention
):- 每個
token
可以“關注”前面的token
; - 有 因果遮罩(
Causal Mask
),確保只看過去,不看未來。
- 每個
- 前饋網絡 + 殘差連接 +
LayerNorm
:增加表達能力,保證訓練穩定。
最后輸出層 head
給出下一個 token
的概率分布。
1.5 生成策略(Sampling
)
訓練完模型后,要讓它“說話”。此時它會輸出下一個詞的概率分布,我們可以用不同方式來“抽簽”:
Temperature
(溫度):控制隨機性。- 小于 1 → 更保守、確定性強。
- 大于 1 → 更有創造性。
Top-k
:只在概率最高的k
個詞中挑。Top-p
(核采樣):動態選擇累計概率 ≤p
的候選,更自然。- 頻率懲罰(
frequency penalty
):詞出現越多,下次越難選,防止復讀。 - 出現懲罰(
presence penalty
):只要出現過,就扣分,鼓勵換話題。 - 停止條件 / 最大長度:避免模型“一直說下去”。
在我們的 generate()
函數里,可以通過設置這些參數,直接看到生成風格的變化。
二、代碼框架搭建
理解了 LLM
的基本概念之后,我們要從代碼開始動手。這里我們用 Google Colab
,因為它自帶 Python
和 GPU
,不需要安裝復雜環境,還能免費用 GPU
,非常適合學習和實驗。
2.1 準備環境(安裝依賴)
在 Colab
新建一個 Notebook
,輸入:
!pip install torch sentencepiece
解釋:
torch
:PyTorch
,是我們要用的深度學習框架。sentencepiece
:Google
開源的分詞工具,用來做BPE
分詞。
👉 如果你在本地跑,需要 Python 3.9+
,并建議有一張支持 CUDA
的顯卡,否則速度會比較慢。
2.2 準備語料文件(input.txt
)
LLM
的“教材”就是語料。教材越多、越多樣,模型學得越好。我們先用一份小語料 input.txt
試試:
Once upon a time, there was a small language model.
It tried to read books, tell stories, and learn from text.
Sometimes it was good, sometimes it was silly.
But every day, it became a little bit better.The quick brown fox jumps over the lazy dog.
Hello world! This is a simple test of tokenization, context windows, and generation.詩言志,歌詠言。語言是人類的工具,也是思想的載體。
模型學習文字,就像小孩學說話。
👉 在 Colab
里,可以直接寫入文件:
text_data = """Once upon a time, there was a small language model.
It tried to read books, tell stories, and learn from text.
Sometimes it was good, sometimes it was silly.
But every day, it became a little bit better.The quick brown fox jumps over the lazy dog.
Hello world! This is a simple test of tokenization, context windows, and generation.詩言志,歌詠言。語言是人類的工具,也是思想的載體。
模型學習文字,就像小孩學說話。
"""with open("input.txt", "w", encoding="utf-8") as f:f.write(text_data)
?? 提醒:
- 如果語料太短,模型只會“背書”,輸出幾乎和原文一樣。
- 如果你換成幾萬字小說片段,輸出會更靈活、更有創造性。
2.3 讀取語料并檢查
from pathlib import Pathtext = Path('input.txt').read_text(encoding='utf-8')
print("語料長度(字符數)=", len(text))
print("開頭 200 個字符:\n", text[:200])
運行后會打印語料長度和前 200 個字符,方便確認文件讀取成功。
我在
Colab
中使用的input.txt
的數據是Alice’s Adventures in Wonderland
,所以打印內容和截圖是不一樣的,不要糾結這個點。
2.4 實現分詞器(Tokenizer
)
2.4.1 為什么要分詞?
計算機不能直接理解文字,所以要把文字轉換成數字。分詞器(Tokenizer
)就是把文本拆分成 token
→ 數字 id
,同時還能把數字 id
轉回文本。
2.4.2 字符級分詞(最簡單)
每個字符就是一個 token
:
# 建立字表
chars = sorted(list(set(text)))
stoi = {ch: i for i, ch in enumerate(chars)} # 字符 -> 數字
itos = {i: ch for i, ch in enumerate(chars)} # 數字 -> 字符# 編碼 / 解碼函數
def encode(s: str):return [stoi[c] for c in s]def decode(ids: list):return "".join([itos[i] for i in ids])print("字符表大小 =", len(chars))
print("encode('Hello') =", encode("Hello"))
print("decode =", decode(encode("Hello")))
示例輸出:
字符表大小 = 68
encode('Hello') = [12, 45, 50, 50, 60]
decode = Hello
👉 好處:實現簡單。缺點:序列很長,訓練會更難。
2.4.3 BPE
分詞(更高效)
如果語料較大,用 BPE
(Byte Pair Encoding
)能更好地壓縮序列:
import sentencepiece as spm# 訓練一個 BPE 模型(詞表大小設為 200,適合小語料)
spm.SentencePieceTrainer.train(input="input.txt",model_prefix="spm_bpe",vocab_size=200,model_type="bpe",character_coverage=1.0,bos_id=-1, eos_id=-1, unk_id=0, pad_id=-1,hard_vocab_limit=False
)# 加載模型
sp = spm.SentencePieceProcessor(model_file="spm_bpe.model")# 定義編碼/解碼函數
def encode_bpe(s: str):return sp.encode(s, out_type=int)def decode_bpe(ids: list):return sp.decode(ids)print("BPE 詞表大小 =", sp.get_piece_size())
print("encode_bpe('Hello world') =", encode_bpe("Hello world"))
print("decode_bpe =", decode_bpe(encode_bpe("Hello world")))
示例輸出:
BPE 詞表大小 = 200
encode_bpe('Hello world') = [35, 78, 42]
decode_bpe = Hello world
👉 好處:序列更短,訓練更快,效果更好。
對比:
- 字符級
"Hello world"
→ 11 個token
BPE
"Hello world"
→ 2 個token
注意
BPE
的分詞結果并不是固定的,比如"Hello world"
可能會被切成 2 個,也可能是 5 個token
。這不是bug
,而是因為BPE
只會合并訓練語料里出現過的高頻子串。如果某個詞在語料中出現得不夠頻繁,就會被拆成更小的子詞片段。
顯然,BPE
序列更短,更適合長文本訓練。
?? 注意:
如果語料太短而
vocab_size
太大,會報錯。解決方法:減小詞表大小(比如 200)。
2.5 劃分訓練集與驗證集
機器學習必須要區分 訓練數據 和 驗證數據:
- 訓練數據:模型學習用。
- 驗證數據:檢查模型是否過擬合。
import torchdata = encode(text) # 如果用 BPE,就改成 encode_bpe
data = torch.tensor(data, dtype=torch.long)n = int(0.9 * len(data)) # 90% 訓練,10% 驗證
train_data = data[:n]
val_data = data[n:]print("訓練集大小 =", len(train_data))
print("驗證集大小 =", len(val_data))
示例輸出:
訓練集大小 = 270
驗證集大小 = 30
三、實現 Transformer
解碼器
到目前為止,我們已經準備好了數據和分詞器。接下來要搭建的,就是 LLM
的“大腦”—— Transformer
解碼器。它的任務很明確:根據前面的 token
,預測下一個 token
的概率分布。
我們會逐層拆開看:Embedding
→ 自注意力 → 前饋網絡(MLP
) → 堆疊多層 → 輸出層。
3.1 Embedding
:把數字變成向量
分詞器輸出的只是 token id
(純數字),但神經網絡更擅長處理向量。 所以第一步:把每個 token id
映射到一個向量。
import torch
import torch.nn as nnvocab_size = 200 # 詞表大小(根據分詞器而定)
n_embd = 128 # 向量維度(embedding 維度)
block_size = 64 # 上下文窗口大小class TokenEmbedding(nn.Module):def __init__(self, vocab_size, n_embd, block_size):super().__init__()self.tok_emb = nn.Embedding(vocab_size, n_embd) # token embeddingself.pos_emb = nn.Embedding(block_size, n_embd) # 位置 embeddingdef forward(self, idx):B, T = idx.shapetok = self.tok_emb(idx) # (B, T, n_embd)pos = self.pos_emb(torch.arange(T, device=idx.device)) # (T, n_embd)return tok + pos # token 向量 + 位置信息
token embedding
:每個詞的“語義表示”。position embedding
:告訴模型詞的順序,否則模型只知道“有哪些詞”,卻不知道“順序如何”。
3.2 自注意力機制(Self-Attention
)
這是 Transformer
的核心。它的作用是:每個詞可以決定要多關注前面哪些詞,從中獲取信息。
3.2.1 基本思路
- 每個輸入向量會生成 查詢向量 (
Q
)、鍵向量 (K
)、值向量 (V
)。 - 通過
Q
和K
的點積,得到注意力分數(相關性)。 - 用
Softmax
把分數轉成權重,再加權求和值向量V
。
3.2.2 代碼實現
class CausalSelfAttention(nn.Module):def __init__(self, n_embd, n_head, block_size):super().__init__()self.n_head = n_headself.head_dim = n_embd // n_headself.qkv = nn.Linear(n_embd, 3 * n_embd, bias=False)self.proj = nn.Linear(n_embd, n_embd)# 因果遮罩:保證不能看未來self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size)).view(1, 1, block_size, block_size))def forward(self, x):B, T, C = x.shapeqkv = self.qkv(x).view(B, T, 3, self.n_head, self.head_dim)q, k, v = qkv.unbind(dim=2) # 拆成 Q, K, Vq, k, v = [t.transpose(1, 2) for t in (q, k, v)] # (B, nh, T, hd)# 注意力分數 (B, nh, T, T)att = (q @ k.transpose(-2, -1)) / (self.head_dim ** 0.5)att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf"))att = torch.softmax(att, dim=-1)# 加權求和y = att @ v # (B, nh, T, hd)y = y.transpose(1, 2).contiguous().view(B, T, C) # 拼回 (B, T, C)return self.proj(y)
這里的 因果遮罩 (Causal Mask
) 非常關鍵:它確保每個位置只能看到“自己和前面的詞”,不能偷看未來。這就是“自回歸”的本質。
3.3 前饋網絡(Feed Forward
, MLP
)
注意力層捕捉了依賴關系,但還需要增加“非線性變換能力”。這就是 MLP
(前饋網絡) 的作用。
class FeedForward(nn.Module):def __init__(self, n_embd):super().__init__()self.net = nn.Sequential(nn.Linear(n_embd, 4 * n_embd), # 放大nn.ReLU(),nn.Linear(4 * n_embd, n_embd), # 再縮回去)def forward(self, x):return self.net(x)
3.4 Transformer Block
把 注意力層 和 前饋層 組合起來,并加上 殘差連接 和 層歸一化。一層能學到“短距離依賴”,比如“New → York”;多層堆疊,就能學到更長距離、更復雜的關系。
class TransformerBlock(nn.Module):def __init__(self, n_embd, n_head, block_size):super().__init__()self.ln1 = nn.LayerNorm(n_embd)self.ln2 = nn.LayerNorm(n_embd)self.attn = CausalSelfAttention(n_embd, n_head, block_size)self.ffwd = FeedForward(n_embd)def forward(self, x):x = x + self.attn(self.ln1(x)) # 殘差連接x = x + self.ffwd(self.ln2(x)) # 殘差連接return x
殘差連接:保留原始信息,避免梯度消失。
層歸一化:讓訓練更穩定。
3.5 GPT
模型主體
現在把所有部分拼起來,形成一個完整的 GPT
模型。
class GPT(nn.Module):def __init__(self, vocab_size, n_embd=128, n_head=4, n_layer=4, block_size=64):super().__init__()self.block_size = block_sizeself.embed = TokenEmbedding(vocab_size, n_embd, block_size)self.blocks = nn.Sequential(*[TransformerBlock(n_embd, n_head, block_size) for _ in range(n_layer)])self.ln_f = nn.LayerNorm(n_embd)self.head = nn.Linear(n_embd, vocab_size, bias=False)def forward(self, idx, targets=None):x = self.embed(idx)x = self.blocks(x)x = self.ln_f(x)logits = self.head(x) # (B, T, vocab_size)loss = Noneif targets is not None:# 交叉熵:預測下一個 tokenloss = nn.functional.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))return logits, loss
logits
:每個位置對整個詞表的預測分數(還沒轉成概率)。loss
:用交叉熵衡量“預測和真實答案的差距”。
3.6 小實驗:前向傳播
我們來做一個簡單的測試,看看模型能否正常運行:
model = GPT(vocab_size=vocab_size, n_embd=128, n_head=4, n_layer=2, block_size=64)
x = torch.randint(0, vocab_size, (1, 10)) # 隨機 10 個 token
logits, loss = model(x, x)
print("logits shape =", logits.shape)
print("loss =", loss.item())
示例輸出:
logits shape = torch.Size([1, 10, 200])
loss = 5.3
解釋:
[1, 10, 200]
→ 批大小=1,序列長度=10,每個位置預測 200 個詞的分布。loss=5.3
→ 說明預測和答案差距還比較大,這是正常的,因為模型還沒訓練。
四、訓練循環
前面我們已經實現了模型結構,但它現在就像一個剛出生的孩子:大腦有了,但里面是空的,還不會說話。訓練循環的作用就是不斷地 喂飯 → 考試 → 判分 → 調整,直到它逐漸學會語言規律。
4.1 為什么需要訓練循環?
訓練的本質是:
- 喂飯:給模型輸入文本(前文)。
- 考試:讓它預測下一個詞。
- 判分:計算預測和真實答案的差距(
loss
)。 - 調整:根據差距更新模型的參數。
就像小孩學說話:聽別人說 → 自己模仿 → 被糾正 → 慢慢改進。
4.2 批次采樣(get_batch
)
訓練時不能一次把所有數據丟進去(太慢、顯存會爆),所以我們把數據切成一個個 小批次(batch
)。
import torchdef get_batch(split, block_size, batch_size, device):data = train_data if split == "train" else val_data# 確保不會越界:片段長度 = block_sizemax_start = len(data) - block_size - 1ix = torch.randint(0, max_start, (batch_size,))x = torch.stack([data[i : i + block_size] for i in ix])y = torch.stack([data[i + 1 : i + block_size + 1] for i in ix])return x.to(device), y.to(device)
x
:輸入序列(前文)。y
:目標序列(就是x
右移一位 → 下一個token
)。
例子:
- 輸入:
The quick brown
- 目標:
he quick brown fox
這樣模型學的就是“前文 → 下一個詞”。
4.3 損失函數(Loss
)
我們用 交叉熵(CrossEntropy
) 來衡量預測和真實答案的差距。
- 如果模型預測“fox”的概率高,就獎勵它(
loss
小)。 - 如果它預測“dog”的概率高,就懲罰它(
loss
大)。
可以理解為“預測分布” vs “正確答案(one-hot
向量)”的差距。
在 GPT
類里我們已經寫過:
loss = nn.functional.cross_entropy(logits.view(-1, vocab_size), targets.view(-1))
4.4 優化器(Optimizer
)
優化器的作用就是 更新參數,讓模型一步步變聰明。我們用 AdamW
,這是 Transformer
的常見選擇。
import mathoptimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95), weight_decay=0.01)
參數解釋:
lr=3e-4
:學習率,決定“每次調整的幅度”。betas
:動量參數,幫助收斂更穩定。weight_decay
:權重衰減,防止過擬合。
4.5 學習率調度(Warmup
+ Cosine Decay
)
如果一上來就用大學習率,模型可能“嚇壞了”,訓練會不穩定。所以我們采用:
Warmup
:前期小步慢跑(逐漸加大學習率)。Cosine Decay
:后期慢慢收尾(逐漸減小學習率)。
def cosine_lr(step, max_steps, base_lr, warmup):if step < warmup:return base_lr * (step + 1) / max(1, warmup)t = (step - warmup) / max(1, max_steps - warmup)return 0.5 * (1 + math.cos(math.pi * t)) * base_lr
4.6 梯度裁剪(Gradient Clipping
)
有時候梯度會突然爆炸,導致訓練崩掉。解決辦法是:把梯度裁剪在一定范圍內。
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
4.7 訓練主循環
現在把所有東西拼起來:
device = "cuda" if torch.cuda.is_available() else "cpu"
model = GPT(vocab_size=vocab_size, n_embd=128, n_head=4, n_layer=2, block_size=64).to(device)max_steps = 1000
batch_size = 32
warmup = 100
base_lr = 3e-4optimizer = torch.optim.AdamW(model.parameters(), lr=base_lr, betas=(0.9, 0.95), weight_decay=0.01)for step in range(1, max_steps + 1):# 獲取一批訓練數據x, y = get_batch("train", block_size=64, batch_size=batch_size, device=device)# 前向傳播logits, loss = model(x, y)# 反向傳播optimizer.zero_grad(set_to_none=True)loss.backward()torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)# 動態調整學習率lr = cosine_lr(step, max_steps, base_lr, warmup)for pg in optimizer.param_groups:pg["lr"] = lroptimizer.step()# 每 100 步做一次驗證if step % 100 == 0:with torch.no_grad():vx, vy = get_batch("val", block_size=64, batch_size=batch_size, device=device)_, vloss = model(vx, vy)print(f"step {step}: train_loss={loss.item():.3f} | val_loss={vloss.item():.3f}")
4.8 輸出示例
運行后,你會看到類似這樣的日志:
step 100: train_loss=2.019 | val_loss=6.231
step 200: train_loss=1.444 | val_loss=5.789
step 300: train_loss=0.824 | val_loss=5.159
step 400: train_loss=0.486 | val_loss=4.909
...
解釋:
train_loss
:模型在訓練集上的表現,應該隨訓練下降。val_loss
:模型在驗證集上的表現。如果val_loss
先降后升,說明過擬合。
4.9 如何判斷訓練效果?
- 正常情況:
train_loss
和val_loss
都下降 → 模型在學規律。 - 過擬合:
train_loss
一直下降,但val_loss
上升 → 模型只會背書。 - 欠擬合:
train_loss
長期很高 → 模型太小 / 學習率太低 / 數據太少。
在小語料實驗里,不要糾結
loss
數值,重要的是學會訓練流程。真正要訓練能用的模型,需要更大的語料和更長的訓練時間。
到這里,我們已經完整地實現了一個 mini-GPT
,從最基礎的 Token
/ 分詞器,到 Transformer
架構;從 訓練循環,到能跑通的 第一個語言模型。請繼續閱讀:下篇:《從零實現 LLM(下):推理生成、常見問題與進階優化》
🎁 彩蛋:一鍵運行 Notebook
如果你不想從零復制粘貼代碼,或者想直接體驗完整的 mini-GPT
實現,我已經準備了一份 Google Colab Notebook
:
👉 點擊這里直接運行 mini-GPT(Colab)