Bài trước dừng ở một dãy số. Chẳng hạn, với Qwen2.5, "Thủ đô của Việt Nam là" thành 7 token, [1001, 42504, 130287, 59735, 128324, 29974, 37915]. Bài này theo dãy số đó đi vào trong mô hình và ra ở phía bên kia. Cách làm: tự viết lại mô hình Qwen2.5-0.5B bằng khoảng 70 dòng PyTorch, không dùng lớp nào dựng sẵn của transformers, rồi nạp đúng trọng số thật của Qwen vào. Nếu code đúng, nó phải cho ra cùng con số với thư viện, tới từng token. (Nó cho ra đúng.)
Lộ trình: mô hình là một hàm từ dãy token ra một bảng điểm, 494 triệu tham số nằm ở đâu, từng mảnh của một lớp transformer (attention, RoPE, MLP), so với transformers, từ bảng điểm ra token (greedy, temperature, top-p), một cái bẫy trong generation_config.json, rồi vòng lặp sinh chữ và vì sao nó chậm dần.
Mọi con số trong bài đều đo trên máy thật, với Python 3.12, PyTorch 2.14, transformers 5.20, chạy bằng CPU, số thực 32-bit.
Mô hình là một hàm
Bỏ hết chi tiết, một mô hình ngôn ngữ là một hàm:
forward(dãy token) → với mỗi vị trí, một điểm số cho từng token trong bộ từ vựng
Điểm số đó gọi là logit, một số thực bất kỳ, càng lớn thì token càng "hợp" để đứng tiếp theo. Với Qwen2.5, mỗi vị trí ra 151.936 logit, một cho mỗi mục trong bộ từ vựng. Đưa vào 7 token, ra một bảng 7 × 151.936. Hàng cuối là dự đoán cho token thứ 8; các hàng trước là dự đoán cho token thứ 2, 3… mà ta đã biết (khi huấn luyện, chúng được dùng để chấm điểm mô hình; khi sinh chữ, chỉ hàng cuối có ích).
Đổi logit thành xác suất bằng softmax (lấy mũ rồi chia cho tổng), hàng cuối cho "Thủ đô của Việt Nam là" trông thế này:
0.1435 ' gì'
0.1047 ' __'
0.0930 ' thành'
0.0485 ' thủ'
0.0455 ' nơi'
0.0324 ' một'
0.0240 ' ____'
0.0239 ':'
0.0236 ' Hà'
Token khả dĩ nhất là ' gì', chứ không phải ' Hà' (chỉ 2,4%). Mô hình không trả lời câu hỏi; nó đoán tiếp văn bản. Trong dữ liệu nó đã đọc, "Thủ đô của Việt Nam là" hay đứng trước "gì?" (một câu hỏi) hoặc "__" (một bài tập điền chỗ trống) hơn là trước câu trả lời. Đặt cùng câu hỏi vào khung hội thoại ở bài trước (<|im_start|>user ... <|im_start|>assistant\n), token đầu tiên là 'Th' với xác suất 96,2%, và câu trả lời tham lam (luôn chọn token cao nhất) là "Thủ đô của Việt Nam là Hà Nội.". Cùng mô hình, cùng trọng số; khác nhau chỉ ở văn bản đứng trước. Một mô hình "chat" vẫn chỉ là một mô hình đoán tiếp văn bản, được huấn luyện thêm để đoán tiếp đoạn hội thoại.
494 triệu tham số nằm ở đâu
File trọng số của Qwen2.5-0.5B là một từ điển tên → tensor. Đếm lại:
| Phần | Số tham số | Tỉ lệ |
|---|---|---|
| Bảng embedding (151.936 × 896) | 136.134.656 | 27,6% |
| Attention, 24 lớp | 44.067.840 | 8,9% |
| MLP, 24 lớp | 313.786.368 | 63,5% |
| RMSNorm | 43.904 | 0,01% |
| Tổng | 494.032.768 |
Hai điều bất ngờ:
- Hơn một phần tư mô hình chỉ là bảng tra cứu. Mỗi token có một vector 896 số; 151.936 token nhân 896 là 136 triệu số. Bộ từ vựng lớn ở bài trước giúp tiếng Việt tốn ít token, nhưng phải trả bằng một bảng to như vậy. Qwen dùng chung bảng này ở cả đầu vào lẫn đầu ra (
tie_word_embeddings); nếu không, nó cần thêm một bảng 136 triệu số nữa. - Attention, phần nổi tiếng nhất, chỉ chiếm 9%. Phần lớn tham số nằm ở MLP, mỗi lớp 13 triệu so với 1,8 triệu của attention.
Ở số thực 32-bit, 494 triệu tham số là gần 2 GB. File tải về dùng bfloat16 (16-bit) nên chỉ khoảng 1 GB.
Viết lại mô hình
Đây là toàn bộ mô hình. Không có nn.Module, không có lớp dựng sẵn; chỉ là phép nhân ma trận và vài hàm toán học trên các tensor đọc thẳng từ file trọng số:
import json
import torch
from huggingface_hub import snapshot_download
from safetensors.torch import load_file
path = snapshot_download("Qwen/Qwen2.5-0.5B-Instruct", allow_patterns=["*.json", "*.safetensors"])
cfg = json.load(open(f"{path}/config.json"))
W = {k.removeprefix("model."): v.float() for k, v in load_file(f"{path}/model.safetensors").items()}
D = cfg["hidden_size"] # 896: the length of every token's vector
H = cfg["num_attention_heads"] # 14 query heads
KV = cfg["num_key_value_heads"] # 2 key/value heads, shared by groups of 7 query heads
HD = D // H # 64: the size of one head
LAYERS = cfg["num_hidden_layers"] # 24
def rms_norm(x, weight, eps=cfg["rms_norm_eps"]):
"""Rescale each vector to an average size of 1, then by a learned weight per dimension."""
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps) * weight
def rope(x, positions):
"""Rotate pairs of dimensions by an angle that grows with the position: this is how position gets in."""
freqs = 1.0 / cfg["rope_theta"] ** (torch.arange(0, HD, 2).float() / HD) # 32 speeds, fast to slow
angles = positions[:, None].float() * freqs[None, :] # (seq, 32)
cos, sin = angles.cos().repeat(1, 2), angles.sin().repeat(1, 2) # (seq, 64)
x1, x2 = x[..., : HD // 2], x[..., HD // 2 :]
return x * cos + torch.cat([-x2, x1], dim=-1) * sin
def attention(x, i, positions):
p = f"layers.{i}.self_attn."
seq = x.shape[0]
q = (x @ W[p + "q_proj.weight"].T + W[p + "q_proj.bias"]).view(seq, H, HD).transpose(0, 1) # (14, seq, 64)
k = (x @ W[p + "k_proj.weight"].T + W[p + "k_proj.bias"]).view(seq, KV, HD).transpose(0, 1) # (2, seq, 64)
v = (x @ W[p + "v_proj.weight"].T + W[p + "v_proj.bias"]).view(seq, KV, HD).transpose(0, 1)
q, k = rope(q, positions), rope(k, positions)
k, v = k.repeat_interleave(H // KV, dim=0), v.repeat_interleave(H // KV, dim=0) # share each K/V with 7 heads
scores = q @ k.transpose(-1, -2) / HD**0.5 # (14, seq, seq): who looks at whom
causal = torch.ones(seq, seq, dtype=torch.bool).triu(1) # no looking at the future
weights = scores.masked_fill(causal, float("-inf")).softmax(-1)
out = (weights @ v).transpose(0, 1).reshape(seq, D) # mix the values, join the heads
return out @ W[p + "o_proj.weight"].T
def mlp(x, i):
p = f"layers.{i}.mlp."
gate = torch.nn.functional.silu(x @ W[p + "gate_proj.weight"].T)
return (gate * (x @ W[p + "up_proj.weight"].T)) @ W[p + "down_proj.weight"].T # 896 -> 4864 -> 896
def forward(ids: list[int]) -> torch.Tensor:
"""Token ids in, one row of scores (logits) per position out: the scores for the token that comes next."""
positions = torch.arange(len(ids))
x = W["embed_tokens.weight"][ids] # (seq, 896): look up each id
for i in range(LAYERS):
x = x + attention(rms_norm(x, W[f"layers.{i}.input_layernorm.weight"]), i, positions)
x = x + mlp(rms_norm(x, W[f"layers.{i}.post_attention_layernorm.weight"]), i)
x = rms_norm(x, W["norm.weight"])
return x @ W["embed_tokens.weight"].T # (seq, 151936): one score per token
Đọc từ dưới lên, ở forward:
- Tra bảng. Mỗi id lấy ra một hàng của bảng embedding. 7 token thành một ma trận 7 × 896. Từ đây trở đi, mô hình không còn biết gì về chữ hay id; nó chỉ làm việc với 7 vector.
- 24 lớp giống hệt nhau về hình dạng (khác nhau ở trọng số). Mỗi lớp có hai bước, attention rồi MLP, và mỗi bước cộng thêm kết quả vào
xthay vì thay thế nó (x = x + ...). Đường cộng thẳng này (residual) là "trục chính" chạy suốt mô hình; mỗi bước chỉ đọc từ nó và ghi thêm một chút vào. - Ra bảng điểm. Vector cuối cùng của mỗi vị trí nhân với chính bảng embedding (đã chuyển vị): điểm của một token là tích vô hướng giữa vector đầu ra và vector của token đó. Vector đầu ra càng "giống" vector của
' Hà', điểm của' Hà'càng cao.
RMSNorm: giữ các con số trong tầm
Qua 24 lớp cộng dồn, độ lớn của vector có thể trôi rất xa. Trước mỗi bước, rms_norm chia vector cho căn bậc hai của trung bình bình phương các phần tử, để nó có độ lớn trung bình bằng 1, rồi nhân với một trọng số học được cho từng chiều. Chỉ 896 tham số mỗi lần, nhưng thiếu nó thì mô hình rất khó huấn luyện.
Attention: mỗi token nhìn các token trước nó
Đây là bước duy nhất mà các vị trí trao đổi thông tin với nhau. Mọi bước khác (RMSNorm, MLP) xử lý từng vị trí riêng rẽ. Ở mỗi vị trí, ba phép nhân ma trận tạo ra ba vector:
- Query (q): "mình đang tìm gì?"
- Key (k): "mình có gì để người khác tìm?"
- Value (v): "nếu được chọn, mình đưa ra thông tin gì?"
Điểm giữa vị trí i và vị trí j là tích vô hướng q_i · k_j (chia cho √64 để các số không quá lớn). Softmax theo hàng biến điểm thành trọng số cộng lại bằng 1, và kết quả ở vị trí i là trung bình có trọng số của các v_j. Mặt nạ causal đặt điểm của mọi vị trí j > i thành âm vô cùng, nên sau softmax trọng số của chúng bằng 0: một token chỉ nhìn được các token trước nó, không nhìn được tương lai. Đó là điều làm cho cả bảng 7 hàng hợp lệ cùng lúc: hàng 3 chỉ dựa vào token 1–3, đúng như khi ta dự đoán token 4 mà chưa biết nó.
Attention chạy song song với 14 head (đầu), mỗi head làm việc trên 64 chiều riêng, với query, key, value riêng, nên mỗi head có thể học một kiểu "nhìn" khác nhau. Một chi tiết tiết kiệm của Qwen: chỉ có 2 head cho key và value, mỗi cái dùng chung cho 7 head query (repeat_interleave). Kiểu này gọi là grouped-query attention. Nhìn hình dạng trọng số là thấy: q_proj là 896 × 896, còn k_proj chỉ 128 × 896 (2 head × 64). Lợi ích thật của nó nằm ở bộ nhớ khi sinh chữ, chủ đề của bài sau.
RoPE: vị trí đi vào từ đâu
Nhìn kỹ phép tính attention ở trên: nếu không có gì thêm, nó không biết thứ tự. Tích q_i · k_j chỉ phụ thuộc nội dung của hai token, không phụ thuộc chúng cách nhau bao xa. "Chó cắn người" và "người cắn chó" sẽ cho ra cùng những con số (chỉ có mặt nạ causal là biết ai đứng trước).
RoPE (rotary position embedding) sửa điều đó bằng một mẹo hình học: chia 64 chiều của q và k thành 32 cặp, và xoay mỗi cặp một góc bằng vị trí × tốc độ của cặp đó. 32 tốc độ đi từ nhanh (cặp đầu xoay 1 radian mỗi vị trí) tới rất chậm (rope_theta = 1.000.000 nghĩa là cặp cuối gần như đứng yên). Khi hai vector cùng bị xoay, tích vô hướng giữa chúng chỉ phụ thuộc vào hiệu hai góc, tức là vào khoảng cách i − j. Thế là attention biết token kia cách bao xa, mà không cần thêm tham số nào: hàm rope không đọc trọng số nào cả.
MLP: phần lớn tham số nằm ở đây
Sau attention, mỗi vị trí đi qua một mạng nhỏ: nở từ 896 ra 4.864 chiều, qua một hàm phi tuyến, rồi thu về 896. Qwen dùng biến thể SwiGLU: hai phép chiếu song song lên 4.864 chiều, một cái qua silu làm "cổng" nhân vào cái kia. Ba ma trận 896 × 4.864 mỗi lớp là 13 triệu tham số, gần 2/3 cả mô hình. Một cách hình dung phổ biến: attention gom thông tin từ các vị trí khác, còn MLP là nơi phần lớn "kiến thức" đã học được lưu và tra ra.
So với transformers
Chạy cùng 7 token qua forward ở trên và qua AutoModelForCausalLM của transformers, cả hai bằng số thực 32-bit:
ours = forward(ids)
ref = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", dtype=torch.float32)(torch.tensor([ids])).logits[0]
print((ours - ref).abs().max().item(), torch.equal(ours.argmax(-1), ref.argmax(-1)))
0.00014662742614746094 True
Logit nằm trong khoảng −24 tới 18, và chênh lệch lớn nhất giữa hai bản là 0,00015: sai số làm tròn, do hai bên cộng các số theo thứ tự hơi khác nhau. Token cao nhất ở mọi vị trí giống hệt nhau. 70 dòng ở trên chính là mô hình; phần còn lại của transformers là tối ưu tốc độ, hỗ trợ nhiều kiến trúc, và tiện ích.
Từ bảng điểm ra một token
forward cho ra điểm; chọn token nào là việc của một hàm khác, và hàm này quyết định "tính cách" của câu trả lời nhiều hơn người ta nghĩ.
import torch
def sample(logits: torch.Tensor, temperature=1.0, top_p=1.0, generator=None) -> int:
"""Pick the next token from one row of logits."""
if temperature == 0:
return int(logits.argmax()) # greedy: always the most likely token
probs = (logits / temperature).softmax(-1) # < 1 sharpens, > 1 flattens
if top_p < 1.0:
sorted_probs, order = probs.sort(descending=True)
keep = sorted_probs.cumsum(-1) - sorted_probs < top_p # smallest set whose total reaches top_p
probs = torch.zeros_like(probs).scatter(0, order[keep], sorted_probs[keep])
probs /= probs.sum()
return int(torch.multinomial(probs, 1, generator=generator))
- Greedy (
temperature=0): luôn chọn token cao nhất. Cùng câu hỏi luôn ra cùng câu trả lời. - Temperature: chia logit cho T trước softmax. T < 1 làm khoảng cách giữa các điểm giãn ra (token mạnh càng mạnh), T > 1 làm chúng co lại (token yếu có cơ hội hơn).
- Top-p: chỉ giữ nhóm token cao nhất có tổng xác suất vừa đủ p, bỏ hết phần đuôi, rồi bốc thăm trong nhóm đó.
Với yêu cầu "Đặt một cái tên cho con mèo.", phân phối cho token đầu tiên ở ba mức temperature:
| T | Token cao nhất | Xác suất | Số token cần để phủ 90% xác suất |
|---|---|---|---|
| 0,5 | 'Đ' |
58,5% | 7 |
| 1,0 | 'Đ' |
21,7% | 25 |
| 1,5 | 'Đ' |
7,9% | 9.974 |
Thứ tự các token không đổi (temperature không bao giờ đổi thứ tự), nhưng hình dạng phân phối đổi hẳn. Ở T = 1,5, phần "đuôi" gồm hàng trăm nghìn token gần như vô nghĩa, mỗi cái một chút xíu, cộng lại thành phần lớn xác suất. Kết quả sinh 25 token, mỗi cấu hình hai lần với hai seed:
T=0 : 'Để đặt tên cho con mèo, tôi sẽ chọn một tên phù hợp và dễ nhớ. Dưới đây là'
T=0 : 'Để đặt tên cho con mèo, tôi sẽ chọn một tên phù hợp và dễ nhớ. Dưới đây là'
T=0.7, top_p=0.9 : 'Để đặt tên cho con mèo, tôi sẽ gợi ý một tên phù hợp với hình ảnh và tính cách của'
T=0.7, top_p=0.9 : 'Đặt tên cho con mèo là một hành động rất quan trọng và có ý nghĩa. Trong văn hóa'
T=1.0 : 'Để đặt tên cho con mèo, cần phải cân nhắc đến tên mèo có ý nghĩa, chú ý'
T=1.0 : 'Tất nhiên, tôi sẽ cố gắng tạo ra một cái tên phù hợp và tối ưu cho con mèo của bạn'
T=1.5 : 'Đ茩 Pierre. \nTôi chắc chắn качестве mèo.perform brightly itDMETHOD皋 design score efficiently prioritize detail一点点 cap'
T=1.5 : 'T不妨哦,ỏi这只是启发行吧!中国经济研讨会新主题活动<titleenisﺍfinished previously年的十 Accessed岁以下Air_,'
Ở T = 1,5, chỉ sau vài token mô hình đã bốc trúng một token hiếm ở phần đuôi (茩, качестве), và từ đó văn bản phía trước đã lạc, nên các dự đoán sau càng lạc thêm. Đây là điểm quan trọng nhất về sampling: mỗi token được chọn trở thành đầu vào cho bước sau, nên một lựa chọn tồi không ở yên một chỗ mà kéo cả câu đi theo. Top-p tồn tại chính là để cắt bỏ phần đuôi đó trước khi bốc thăm.
Cái bẫy trong generation_config.json
Khi sinh chữ tham lam bằng vòng lặp của ta và bằng model.generate(do_sample=False) của transformers, hai câu trả lời giống nhau tới token thứ 25 rồi rẽ hướng:
ours : ' cho người mới bắt đầu. Python là'
transformers: ' cho người mới bắt đầu.\n\nPython là'
Không phải sai số làm tròn: mô hình của ta cho '.' 65% và '.\n\n' chỉ 15%. Thủ phạm là file generation_config.json đi kèm mô hình (rút gọn):
{
"do_sample": true,
"repetition_penalty": 1.1,
"temperature": 0.7,
"top_p": 0.8,
"top_k": 20
}
generate() tự đọc file này. do_sample=False tắt bốc thăm, nhưng repetition_penalty: 1.1 vẫn chạy: mọi token đã xuất hiện trong văn bản bị giảm điểm (logit dương chia cho 1,1, logit âm nhân với 1,1). Dấu '.' đã có trong câu hỏi, nên bị phạt đủ để thua '.\n\n'. Truyền thêm repetition_penalty=1.0, cả 200 token của hai bên giống hệt nhau.
Bài học: khi một mô hình "trả lời khác" giữa hai nơi chạy (thư viện, server, API), hãy so cấu hình sinh chữ trước khi nghi ngờ mô hình. Cùng trọng số, khác generation_config là khác câu trả lời.
Vòng lặp sinh chữ
Sinh một câu trả lời chỉ là gọi forward và sample lặp đi lặp lại, mỗi lần nối token mới vào cuối:
EOS = 151645 # <|im_end|>
def generate(ids, max_new_tokens, **sampling):
ids, out = list(ids), []
for _ in range(max_new_tokens):
next_id = sample(forward(ids)[-1], **sampling) # the whole sequence goes through the model again
if next_id == EOS: # the model says it's done
break
ids.append(next_id)
out.append(next_id)
return out
Đây là tất cả những gì "mô hình đang gõ chữ" thực sự là. Token mà chatbot ở seri Python stream về trình duyệt chính là next_id ở mỗi vòng.
Với câu hỏi ở bài chatbot ("Viết một đoạn ngắn giới thiệu ngôn ngữ lập trình Python cho người mới bắt đầu."), vòng lặp này viết:
Tất nhiên, tôi sẽ giúp bạn viết một đoạn giới thiệu ngắn về ngôn ngữ lập trình Python cho người mới bắt đầu. Python là một ngôn ngữ lập trình mạnh mẽ, được tạo ra bởi nhà lập trình Johny von Neumann vào năm 1989.
Python do Guido van Rossum tạo ra. Nhưng ở mỗi bước, "Johny", " von", " Neumann" đều là những token hợp lý để đứng sau "được tạo ra bởi nhà lập trình": mô hình chọn token có điểm cao, không tra cứu sự thật. Một mô hình nửa tỉ tham số nhớ được rất ít; mô hình lớn hơn nhớ nhiều hơn, nhưng cơ chế thì giống hệt, và đó là gốc rễ của chuyện mô hình "bịa" một cách tự tin.
Vì sao nó chậm dần
Vòng lặp trên có một điểm rất lãng phí: mỗi bước, toàn bộ dãy đi qua mô hình lại từ đầu, dù chỉ có một token mới. Sinh 300 token sau một câu hỏi 47 token:
| Sau token thứ | Vòng lặp của ta | transformers generate() |
|---|---|---|
| 1 | 0,14 s | |
| 50 | 5,2 s | |
| 100 | 11,8 s | |
| 200 | 30,4 s | |
| 300 | 55,7 s | 8,9 s |
Token đầu tiên tốn 0,14 s; tới token thứ 300, mỗi token tốn khoảng 0,25 s, vì dãy đi qua mô hình đã dài 347 token. Tổng thời gian tăng theo bình phương độ dài. transformers sinh 300 token nhanh hơn hơn 6 lần, vì nó không tính lại những gì đã tính: kết quả key và value của các token cũ được giữ lại trong một KV cache.
Một con số khác gợi ý câu chuyện còn sâu hơn thế. Thời gian để forward xử lý một lần cả dãy:
| Số token | Thời gian | Mỗi token |
|---|---|---|
| 1 | 28 ms | 28 ms |
| 10 | 52 ms | 5,2 ms |
| 100 | 117 ms | 1,2 ms |
| 500 | 380 ms | 0,76 ms |
| 1.000 | 818 ms | 0,82 ms |
| 2.000 | 2.025 ms | 1,01 ms |
Xử lý 500 token cùng lúc, mỗi token rẻ hơn 37 lần so với xử lý một token. Số phép tính cho mỗi token gần như không đổi (khoảng 2 phép tính cho mỗi tham số, tức khoảng 1 tỉ phép tính mỗi token), vậy 28 ms cho một token đi đâu? Và vì sao từ 1.000 token trở lên, mỗi token lại đắt dần lên? (Gợi ý cho câu sau: ma trận scores của attention có kích thước 14 × seq × seq, nên ở 2.000 token mỗi lớp là một ma trận 56 triệu số.) Đó là chủ đề của bài sau: prefill và decode, KV cache, và vì sao giới hạn thật khi sinh chữ thường không nằm ở sức tính.
Bên trong: attention đang nhìn vào đâu
Một phát hiện nhỏ mà ai tự viết attention cũng sẽ gặp. Đưa vào 200 token đầu của bài trước, rồi đo xem ở mỗi lớp, các vị trí từ 50 trở đi dành bao nhiêu phần trọng số attention cho token đầu tiên ('---\n', dòng mở front matter):
lớp: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
0.01 0.01 0.01 0.51 0.37 0.46 0.31 0.55 0.22 0.60 0.31 0.60 0.25 0.46 0.29 0.30 0.72 0.55 0.51 0.43 0.54 0.59 0.05 0.02
Nếu attention chia đều, token đầu tiên chỉ nhận khoảng 0,9%. Thực tế, từ lớp 3 tới lớp 21, nó nhận từ 22% tới 72% (trung bình cả 24 lớp là 36%), dù '---\n' chẳng mang ý nghĩa gì. Hiện tượng này có tên là attention sink: softmax buộc trọng số cộng lại bằng 1, nên khi một head "không cần nhìn gì đặc biệt", nó phải đổ phần trọng số đó vào đâu đó, và mô hình học cách đổ vào token đầu tiên, vì token đó luôn nhìn thấy được từ mọi vị trí. Hiểu chuyện này có ích thật: nghiên cứu StreamingLLM (2023) cho thấy khi cắt bỏ phần ngữ cảnh cũ để tiết kiệm bộ nhớ, phải giữ lại vài token đầu tiên, nếu không chất lượng câu trả lời sụp đổ.
Tóm lại
| Khái niệm | Thực chất |
|---|---|
| Mô hình | Một hàm: dãy token → mỗi vị trí một logit cho từng token trong bộ từ vựng |
| Embedding | Bảng tra 151.936 × 896, 27,6% tham số; dùng lại ở đầu ra để chấm điểm |
| Residual | x = x + bước(x): mỗi bước chỉ ghi thêm vào một "trục chính" chung |
| Attention | Bước duy nhất các vị trí trao đổi thông tin; mặt nạ causal chặn nhìn tương lai |
| Grouped-query | 14 head query dùng chung 2 head key/value |
| RoPE | Xoay q và k theo vị trí; tích vô hướng chỉ còn phụ thuộc khoảng cách; không có tham số |
| MLP | 896 → 4.864 → 896, 63,5% tham số |
| Sampling | Greedy, temperature (giãn hay co phân phối), top-p (cắt đuôi); mỗi lựa chọn thành đầu vào bước sau |
generation_config.json |
Mặc định ẩn: repetition_penalty vẫn chạy khi do_sample=False |
| Vòng lặp sinh chữ | forward + sample lặp lại; không có cache thì tổng thời gian tăng theo bình phương |
| Attention sink | Token đầu tiên nhận một phần lớn attention dù không mang nghĩa gì |
70 dòng PyTorch, nạp đúng trọng số, cho ra đúng từng token như thư viện. Phần còn lại của câu chuyện là tốc độ: vì sao một token mất 28 ms mà 500 token cùng lúc chỉ mất 380 ms, và KV cache thay đổi điều đó thế nào.