Xây dựng LLM đầu tiên
Mục tiêu
Phần tiêu đề “Mục tiêu”Sau bài này, bạn sẽ:
- Hiểu một mô hình ngôn ngữ (language model) chỉ làm một việc: cho xác suất của token kế tiếp (next-token prediction). Sinh văn bản là lặp lại việc đó.
- Tự dẫn được hàm mất mát cross-entropy từ xác suất của cả đoạn văn, và đọc được perplexity như “số lựa chọn mà mô hình đang phân vân”.
- Cài đặt được mô hình bigram mức ký tự bằng NumPy, và đọc hiểu một GPT nhỏ chỉ có decoder (decoder-only), có mặt nạ nhân quả (causal mask), viết bằng PyTorch.
- Nhận ra overfitting khi huấn luyện mô hình ngôn ngữ trên ít dữ liệu, và giải thích được vì sao LLM cần rất nhiều văn bản.
Cần biết trước
Phần tiêu đề “Cần biết trước”- Sử dụng API văn bản, thuộc phần Mô hình ngôn ngữ lớn (Đã học)
- Transformers, thuộc phần Học sâu (Đã học)
- Tách từ (Tokenizer), thuộc phần Xử lý ngôn ngữ tự nhiên (Đã học)
Trực giác
Phần tiêu đề “Trực giác”Trò đoán chữ
Phần tiêu đề “Trò đoán chữ”Che chữ cuối của câu thơ này và đoán thử:
Trăm năm trong cõi người …
Nếu thuộc Truyện Kiều, bạn điền ngay “ta”. Có hai điều đáng để ý. Thứ nhất, bạn dùng ngữ cảnh: chỉ nhìn chữ “người” thì khó đoán, nhìn cả câu thì dễ. Thứ hai, câu trả lời của bạn không chắc chắn hoàn toàn: “ta” có lẽ 95%, “đời” một chút, các chữ khác ít hơn nữa.
Một mô hình ngôn ngữ làm đúng trò này. Cho đoạn văn đã có, nó trả về một phân phối xác suất trên mọi token có thể đứng tiếp theo. Muốn viết văn bản, bạn chọn một token theo phân phối đó, nối vào, rồi hỏi lại. ChatGPT trả lời bạn cũng theo cách ấy, từng token một.
Trong bài này, mỗi ký tự là một token. Như vậy không cần bộ tách từ, và từ vựng rất nhỏ: 24 câu đầu Truyện Kiều, viết thường, chỉ có 73 ký tự khác nhau, kể cả khoảng trắng, dấu câu và xuống dòng. Ở mức ký tự, trò đoán chữ vẫn có quy luật: trong 20 câu đầu, sau “q” luôn là “u”, sau dấu phẩy luôn là khoảng trắng hoặc xuống dòng. Mô hình chỉ nhìn một ký tự đứng trước gọi là bigram. GPT nhìn cả một đoạn dài phía trước.
Vì sao tự xây một LLM đồ chơi
Phần tiêu đề “Vì sao tự xây một LLM đồ chơi”Bài này chia việc dùng LLM thành bốn cấp. Đây là cách chia của AI Atlas để sắp xếp các bài, không phải một chuẩn chung:
| Cấp | Bạn làm gì | Cần gì | Bài trong lộ trình |
|---|---|---|---|
| 1 | Gọi LLM có sẵn qua API | Key, vài dòng code | Sử dụng API văn bản |
| 2 | Xây prompt, RAG, agent trên LLM có sẵn | Dữ liệu của bạn, thiết kế hệ thống | Prompt Engineering, RAG và chatbot hiện đại, Agents |
| 3 | Fine-tune hoặc huấn luyện tiếp một LLM | Fine-tune: vài nghìn tới vài trăm nghìn mẫu. Huấn luyện tiếp trên văn bản một lĩnh vực: thường từ hàng tỉ token. Cả hai cần GPU | Họ GPT và fine-tune đa nhiệm, Đánh giá mô hình và PEFT |
| 4 | Pre-train từ đầu | Từ hàng trăm tỉ tới hàng chục nghìn tỉ token, cụm GPU lớn | Chuẩn bị dữ liệu pre-train, Họ LLaMA |
Phần lớn công việc thật nằm ở cấp 1 và 2. Bài này làm một bản đồ chơi của cấp 4 để bạn thấy bên trong một LLM, và hiểu vì sao cùng câu hỏi mỗi lần một câu trả lời, vì sao mô hình có thể “bịa” một cách tự tin, và vì sao phải gom nhiều văn bản tới vậy.
Bài toán của bài này là: từ 20 câu thơ, xây một mô hình cho xác suất của ký tự kế tiếp, chấm nó bằng một con số trên 4 câu mà nó chưa thấy, rồi dùng nó để viết tiếp.
Lý thuyết
Phần tiêu đề “Lý thuyết”Mô hình ngôn ngữ: xác suất của token kế tiếp
Phần tiêu đề “Mô hình ngôn ngữ: xác suất của token kế tiếp”Gọi một đoạn văn là dãy token , với là độ dài chuỗi, tức số token của đoạn đó. Viết gọn cho các token đứng trước vị trí , tức . Theo quy tắc nhân xác suất (chain rule of probability), xác suất của cả đoạn bằng tích các xác suất có điều kiện:
Ví dụ, . Công thức này luôn đúng, không cần giả định gì. Vậy chỉ cần đoán được token kế tiếp, bạn có xác suất của mọi đoạn văn. Mọi mô hình ngôn ngữ, từ bigram tới GPT, chỉ khác nhau ở cách tính .
Mô hình cho ra một vectơ logit , mỗi token trong từ vựng một số, rồi softmax đổi logit thành xác suất:
Mô hình có các tham số (bảng đếm của bigram, các ma trận trọng số của GPT). Ta viết khi cần nói rõ xác suất phụ thuộc vào .
Cross-entropy: chấm điểm một mô hình ngôn ngữ
Phần tiêu đề “Cross-entropy: chấm điểm một mô hình ngôn ngữ”Mô hình tốt là mô hình gán xác suất cao cho văn bản thật. Ta đi từ ý đó tới một hàm mất mát, qua ba bước. Gọi văn bản huấn luyện là , trong đó là số vị trí được dự đoán; mỗi vị trí là một mẫu.
Bước 1: xác suất của cả văn bản. Theo quy tắc nhân, mô hình gán cho văn bản huấn luyện xác suất , gọi là likelihood. Ta muốn chọn làm số này lớn nhất.
Bước 2: lấy logarit. là hàm đồng biến, nên làm likelihood lớn nhất cũng làm của nó lớn nhất. Logarit của tích bằng tổng các logarit:
Bước này còn tránh lỗi số: tích của 659 số cỡ 0.1 là , nhỏ hơn mọi số dương mà kiểu float64 biểu diễn được (nhỏ nhất khoảng ), nên máy làm tròn thành 0. Tổng các logarit thì không sao.
Bước 3: đổi dấu và chia trung bình. Đổi dấu để biến “lớn nhất” thành “nhỏ nhất”, như mọi hàm mất mát ở phần ML. Chia cho để con số không phụ thuộc độ dài văn bản:
Đây là hàm mất mát mà GPT-1 (Radford và cộng sự, 2018) và gần như mọi LLM dùng khi pre-train. Mỗi vị trí đóng góp , với là xác suất mô hình gán cho token thật:
| gán cho token thật | 1 | 0.5 | 0.1 | 1/73 | 0.01 |
|---|---|---|---|---|---|
| (nat) | 0 | 0.693 | 2.303 | 4.290 | 4.605 |
Đoán chắc và đúng thì không mất gì; gán xác suất càng nhỏ cho token thật thì phạt càng nặng, tới vô cùng khi . Có thể đọc là mức mô hình ngạc nhiên khi thấy token thật. Bài dùng như NumPy và PyTorch, nên đơn vị là nat (dùng thì là bit).
Vì sao gọi là cross-entropy? Viết token thật thành vectơ one-hot : ở vị trí của token thật, 0 ở mọi chỗ khác. Entropy chéo (cross-entropy) giữa và phân phối của mô hình là:
Đẳng thức thứ hai có được vì mọi số hạng có đều biến mất, chỉ còn số hạng của token thật. Vậy là cross-entropy trung bình trên các vị trí. Hàm nn.functional.cross_entropy của PyTorch nhận logit và chỉ số token thật, tự làm softmax rồi tính đúng số này.
Perplexity: đọc loss thành số lựa chọn
Phần tiêu đề “Perplexity: đọc loss thành số lựa chọn”Con số 2.7 nat khó hình dung. Perplexity đổi nó sang một đơn vị dễ đọc hơn:
Để hiểu nó, viết lại theo các xác suất :
Dòng thứ hai đưa dấu trừ vào trong logarit: . Dòng thứ ba dùng , vì của tổng là tích, và chia trong số mũ là lấy căn bậc . Vậy perplexity là trung bình nhân của .
Giờ giả sử ở mọi vị trí, mô hình phân vân đều giữa token và token thật nằm trong số đó: , nên . Vì vậy:
- Perplexity là số lựa chọn đều nhau tương đương mà mô hình đang phân vân ở mỗi bước. Nhỏ hơn là tốt hơn, nhỏ nhất là 1 (luôn chắc và đúng).
- Mô hình chưa học gì đoán đều trên token có và . Với 73 ký tự, đó là và perplexity 73: mốc để so.
- Chỉ so được khi cùng cách tách token và cùng tập kiểm tra (hộp thứ hai ở mục Lỗi hay gặp).
Mô hình bigram: đếm rồi chia
Phần tiêu đề “Mô hình bigram: đếm rồi chia”Mô hình đơn giản nhất chỉ nhìn một token đứng trước: . Với ký tự, toàn bộ mô hình là một bảng : hàng là phân phối của ký tự kế tiếp khi ký tự trước là .
“Huấn luyện” chỉ là đếm. Gọi là số lần ký tự đứng ngay sau ký tự trong văn bản huấn luyện. Lấy tỉ lệ đếm của từng hàng:
Khi , đây đúng là tỉ lệ đếm, và có thể chứng minh nó là lựa chọn làm cross-entropy trên tập huấn luyện nhỏ nhất. Nhưng có một lỗ hổng: cặp chưa từng gặp có xác suất đúng bằng 0, và chỉ một cặp như thế trong tập kiểm tra cũng làm , tức perplexity vô hạn. Chuyện này không hiếm: ở ô bigram bên dưới, 26 trong 130 cặp của tập kiểm tra chưa từng gặp trong tập huấn luyện. Làm trơn cộng (add- smoothing) cộng thêm lần đếm “giả” cho mọi ô. Mỗi hàng có ô nên mẫu số cộng thêm , để hàng vẫn cộng lại bằng 1.
Cái giá của làm trơn: hàng nào ít dữ liệu thì bị kéo mạnh về phân phối đều. Trong 20 câu đầu, “q” chỉ xuất hiện 3 lần, lần nào cũng theo sau là “u”. Với và , mẫu số là , nên , thấp hơn nhiều so với 1. Không có nào đúng cho mọi trường hợp. Bài chọn .
GPT: Transformer chỉ có decoder
Phần tiêu đề “GPT: Transformer chỉ có decoder”Bigram chỉ có ngữ cảnh một ký tự, nên không biết đang ở chữ thứ mấy của câu thơ. GPT tính từ cả đoạn phía trước, tối đa token, bằng một chồng khối Transformer. Từ đây, là cửa sổ ngữ cảnh (context window): độ dài đoạn dài nhất mô hình nhìn trong một lần.
- Embedding. Mỗi token được thay bằng một hàng của ma trận , cộng với một vectơ vị trí (một hàng của ma trận ), để mô hình biết token đứng ở đâu. Kết quả là ma trận ẩn , mỗi hàng một vị trí.
- Các khối Transformer. Mỗi khối làm , rồi , với là chuẩn hóa lớp. Dấu cộng là kết nối tắt: mỗi khối chỉ thêm một phần hiệu chỉnh, nên chồng nhiều khối vẫn huấn luyện được.
- Đầu ra. Thêm một lần , rồi một lớp tuyến tính cho logit ở mọi vị trí.
Cách đặt LN trước attention và MLP, cộng một LN cuối, là của GPT-2 (Radford và cộng sự, 2019, mục 2.3). GPT-1 và Transformer gốc đặt LN sau phép cộng.
Transformer gốc (Vaswani và cộng sự, 2017) có hai nửa: encoder đọc câu nguồn, decoder sinh câu đích và nhìn sang encoder qua cross-attention. GPT bỏ encoder và cross-attention, chỉ giữ chồng khối self-attention có mặt nạ, nên gọi là decoder-only. GPT-1 (2018) có 12 khối, , ngữ cảnh 512 token. GPT nhỏ của bài có 2 khối, , ngữ cảnh ký tự, khoảng 112 nghìn tham số.
Self-attention có mặt nạ nhân quả. Từ (đã chuẩn hóa), mỗi đầu attention nhân với ba ma trận học được để có truy vấn (query) , khóa (key) và giá trị (value) , mỗi ma trận . Ở đây in đậm là ma trận giá trị, đừng nhầm với cỡ từ vựng . Rồi:
Đọc từng phần:
- là ma trận . Ô là tích vô hướng của truy vấn ở vị trí với khóa ở vị trí : vị trí “quan tâm” tới vị trí bao nhiêu.
- Chia . Nếu các thành phần của truy vấn và khóa độc lập, trung bình 0, phương sai 1, thì tích vô hướng của chúng có phương sai . Không chia, càng lớn thì điểm càng lớn, softmax càng nhọn về một ô, và gradient gần như bằng 0. Chia đưa phương sai về 1.
- Cộng . Ô có (vị trí ở tương lai) bị cộng . Softmax làm theo từng hàng, và , nên các ô đó có trọng số đúng 0. Các ô còn lại, , vẫn cộng lại bằng 1. Hàng của kết quả là trung bình có trọng số của các hàng giá trị ở vị trí .
- Nhiều đầu (multi-head). đầu chạy song song, mỗi đầu chiều, rồi ghép lại. Chữ không đậm là số đầu, khác với ma trận ẩn in đậm. GPT nhỏ của bài có đầu, mỗi đầu .
Với 4 ký tự đầu bài thơ, mặt nạ cho phép nhìn như sau:
| Vị trí | nhìn “t” | nhìn “r” | nhìn “ă” | nhìn “m” |
|---|---|---|---|---|
| 1: “t” | có | che | che | che |
| 2: “r” | có | có | che | che |
| 3: “ă” | có | có | có | che |
| 4: “m” | có | có | có | có |
Mặt nạ cho phép huấn luyện song song. Lấy một đoạn ký tự. Đầu vào là ký tự tới , đáp án là ký tự tới . Đầu ra ở vị trí dự đoán ký tự , và nhờ mặt nạ nó chỉ dựa vào các ký tự . Như vậy một lần chạy qua đoạn cho dự đoán, mỗi dự đoán như thể tờ giấy che đúng phần phía sau nó, và là trung bình của cả số hạng. Không có mặt nạ, vị trí nhìn thấy luôn đáp án .
Sinh văn bản
Phần tiêu đề “Sinh văn bản”Khi đã có mô hình, sinh văn bản là một vòng lặp:
- Đưa ngữ cảnh hiện tại (tối đa token cuối) vào mô hình, lấy logit ở vị trí cuối.
- Chia logit cho nhiệt độ , lấy softmax, rồi rút một token theo phân phối đó.
- Nối token vừa rút vào ngữ cảnh, quay lại bước 1.
Bước 2 chính là phần lấy mẫu của bài Sử dụng API văn bản, kể cả top-k và top-p nếu muốn. Với bigram, phân phối đã là xác suất , nên lấy làm logit. Khi đó , tức softmax có nhiệt độ chỉ là nâng mỗi xác suất lên lũy thừa rồi chuẩn hóa lại. là bình phương các xác suất: số lớn càng lớn so với số nhỏ.
Giới hạn
Phần tiêu đề “Giới hạn”- Mô hình học cho khớp văn bản, không học cho đúng sự thật. Nó chỉ được thưởng khi đoán đúng token kế tiếp của văn bản huấn luyện. Câu nghe hợp lý nhưng sai vẫn có thể có xác suất cao. Các bài RLHF và RAG và chatbot hiện đại bàn cách giảm chuyện này.
- Token ký tự làm chuỗi rất dài. LLM thật dùng token là mảnh từ, để mỗi bước mang nhiều thông tin hơn (bài Tách từ trong phần NLP).
- Một LLM thật cần nhiều hơn pre-train. Chuyển từ “viết tiếp văn bản” sang “làm theo yêu cầu” là việc của bài Họ GPT và fine-tune đa nhiệm.
Minh họa
Phần tiêu đề “Minh họa”Bước “rút một token” là chỗ duy nhất có ngẫu nhiên trong vòng lặp sinh văn bản. Hình dưới dùng chung với bài Sử dụng API văn bản: sau ngữ cảnh “Hôm nay trời rất”, mô hình cho 10 token với 10 logit cố định. Thanh đậm là xác suất cuối cùng, thanh mờ là xác suất trước khi cắt đuôi. Ba thanh trượt đổi , top-k và top-p. Nút “Lấy 1 mẫu” rút một token, “Lấy 100 mẫu” rút 100 lần và đếm. Hãy thử:
- Lúc đầu, , top-k và top-p đều tắt: token đầu, “đẹp”, có xác suất 0.38, và cả 10 token đều có thể được rút.
- Kéo xuống 0.5: token đầu lên 0.60, ba token đầu chiếm gần hết xác suất. Đây là việc ô bigram làm ở .
- Kéo lên 1.5: token đầu còn 0.29, đuôi dày lên. Rút 100 mẫu, bạn sẽ thấy cả những token cuối bảng.
- Đặt top-p 0.9: ở còn 5 token, ở còn 3, ở còn 7. Kéo về 0: chỉ còn một token, xác suất 1.
Mô hình không đổi khi bạn kéo thanh trượt: logit vẫn vậy, chỉ cách chọn từ phân phối là khác.
Hôm nay trời rất
| Token | Xác suất | Số lần | |
|---|---|---|---|
| đẹp | 0.38 đã loại | 0 | |
| nóng | 0.26 đã loại | 0 | |
| lạnh | 0.14 đã loại | 0 | |
| mát | 0.09 đã loại | 0 | |
| nắng | 0.06 đã loại | 0 | |
| oi | 0.03 đã loại | 0 | |
| trong | 0.02 đã loại | 0 | |
| xanh | 0.01 đã loại | 0 | |
| ẩm | 0.01 đã loại | 0 | |
| tối | 0.00 đã loại | 0 | |
Còn 10 trên 10 token có thể được chọn.
Mười token có thể đứng sau "Hôm nay trời rất", xếp từ khả năng cao nhất xuống thấp nhất. Logit là số tự đặt để minh họa, không do mô hình thật tính ra.
Ví dụ code
Phần tiêu đề “Ví dụ code”Code trong bài chạy thử lần cuối tháng 10/2026: ô bigram với Python 3.14 và NumPy 2.4 (bản có trong Pyodide), ô GPT với Python 3.14 và PyTorch 2.14 trên CPU. Ô bigram chạy ngay trên trang. Ô GPT cần PyTorch nên chạy trên máy bạn.
Hai ô dùng cùng dữ liệu: 24 câu đầu Truyện Kiều, lấy từ trang Truyện Kiều trên Wikisource tiếng Việt (bản sửa ngày 6/5/2026, lấy ngày 24/10/2026). Tác phẩm thuộc phạm vi công cộng; văn bản quốc ngữ do cộng đồng Wikisource hiệu đính, trang phát hành theo CC BY-SA 4.0. Mỗi dòng code chứa hai câu thơ, cách nhau bởi " / ". Code chuẩn hóa Unicode về dạng NFC, đổi sang chữ thường, rồi tách mỗi câu một dòng: 790 ký tự. Câu 1 tới 20 (660 ký tự) để huấn luyện, câu 21 tới 24 để kiểm tra. Từ vựng lấy từ cả 24 câu, để ký tự nào ở tập kiểm tra cũng có số thứ tự.
Bigram bằng NumPy
Phần tiêu đề “Bigram bằng NumPy”Ô dưới đây đếm cặp ký tự trên tập huấn luyện, làm trơn với , in loss và perplexity trên hai tập, in , rồi sinh hai dòng ở và hai dòng ở .
Bigram ký tự trên 24 câu Truyện Kiều
# Bigram mức ký tự, 24 câu đầu Truyện Kiều (Wikisource, CC BY-SA 4.0)import unicodedata
import numpy as np
POEM = """Trăm năm trong cõi người ta, / Chữ tài chữ mệnh khéo là ghét nhau.Trải qua một cuộc bể dâu, / Những điều trông thấy mà đau đớn lòng.Lạ gì bỉ sắc tư phong, / Trời xanh quen thói má hồng đánh ghen.Cảo thơm lần giở trước đèn, / Phong tình cổ lục còn truyền sử xanh.Rằng năm Gia Tĩnh triều Minh, / Bốn phương phẳng lặng, hai kinh vững vàng.Có nhà viên ngoại họ Vương, / Gia tư nghĩ cũng thường thường bực trung.Một trai con thứ rốt lòng, / Vương Quan là chữ, nối dòng nho gia.Đầu lòng hai ả tố nga, / Thúy Kiều là chị, em là Thúy Vân.Mai cốt cách, tuyết tinh thần, / Một người một vẻ, mười phân vẹn mười.Vân xem trang trọng khác vời, / Khuôn trăng đầy đặn, nét ngài nở nang.Hoa cười ngọc thốt đoan trang, / Mây thua nước tóc, tuyết nhường màu da.Kiều càng sắc sảo, mặn mà, / So bề tài, sắc, lại là phần hơn."""# Chuẩn hóa NFC, viết thường, mỗi câu thơ một dòngtext = unicodedata.normalize("NFC", POEM).lower().replace(" / ", "\n")chars = sorted(set(text)) # từ vựng: các ký tự khác nhauV = len(chars)ids = np.array([chars.index(c) for c in text])cut = text.index("hoa cười") # câu 1-20 để huấn luyện, 21-24 để kiểm tratrain, test = ids[:cut], ids[cut - 1:]
# C[i, j]: số lần ký tự j đứng ngay sau ký tự i. Làm trơn: cộng kk = 0.1C = np.zeros((V, V))np.add.at(C, (train[:-1], train[1:]), 1)P = (C + k) / (C + k).sum(axis=1, keepdims=True) # mỗi hàng cộng lại = 1print(f"V = {V}, uniform: loss = {np.log(V):.3f}, ppl = {V}")for name, s in [("train", train), ("test", test)]: loss = -np.mean(np.log(P[s[:-1], s[1:]])) # cross-entropy, đơn vị nat print(f"{name}: loss = {loss:.3f}, ppl = {np.exp(loss):.2f}")print(f"P(u | q) = {P[chars.index('q'), chars.index('u')]:.3f}")
rng = np.random.default_rng(0)for T in [1.0, 0.5]: # nhiệt độ: softmax(log P / T) = P^(1/T) chuẩn hóa lại Q = P ** (1 / T) Q /= Q.sum(axis=1, keepdims=True) i, out = chars.index("\n"), "" while out.count("\n") < 2: # sinh hai dòng i = rng.choice(V, p=Q[i]) out += chars[i] print(f"--- T = {T}\n{out}", end="")Lần chạy đầu cần tải Python, khoảng 10 MB.
Đọc kết quả:
- Mốc đoán đều: loss 4.290, perplexity 73.
- Tập huấn luyện: loss 2.076, perplexity 7.98. Tập kiểm tra: loss 2.720, perplexity 15.18. Chỉ nhờ đếm cặp, mô hình đã bớt phân vân từ 73 xuống khoảng 15 lựa chọn trên văn bản nó chưa thấy. Số trên tập huấn luyện đẹp hơn vì bảng đếm được lập từ chính tập đó, nên muốn biết mô hình tốt tới đâu, hãy nhìn tập kiểm tra.
- , đúng như tính tay ở mục Lý thuyết.
Hai dòng ở có nhiều ký tự lạ, như “ồĩầyộẳ”. Hàng của các ký tự hiếm (ví dụ “ĩ” chỉ có 2 lần đếm) gần như toàn là phần làm trơn, nên ký tự kế tiếp gần như được chọn đều trong 73 ký tự. Ở , các cặp hay gặp chiếm ưu thế và văn trông “giống tiếng Việt” hơn: có “trời”, “trông”, “lặn”. Nhưng vẫn không có nghĩa, và một dòng dài hơn 100 ký tự: bigram không biết câu lục bát có 6 hay 8 chữ, vì nó chỉ nhớ đúng một ký tự trước.
GPT nhỏ bằng PyTorch
Phần tiêu đề “GPT nhỏ bằng PyTorch”File dưới đây dựng GPT của mục Lý thuyết: 2 khối, 4 đầu, , . Mỗi khối là nn.TransformerEncoderLayer với norm_first=True, đúng thứ tự chuẩn hóa lớp, attention, kết nối tắt, rồi chuẩn hóa lớp, MLP, kết nối tắt. PyTorch gọi lớp này là “Encoder”, nhưng khi có mặt nạ nhân quả và không có cross-attention, nó có đúng cấu trúc khối decoder của GPT; chỉ khác hàm kích hoạt (PyTorch mặc định ReLU, GPT dùng GELU). Mặt nạ là torch.triu(..., 1) kiểu bool, True nghĩa là cấm nhìn. Vòng lặp chạy các bước 0 tới 300 bằng AdamW, tức 301 lần cập nhật, mỗi lần một lô 32 đoạn dài 33 ký tự. Cứ 50 bước, file in loss của lô vừa học, tính trước khi cập nhật (train, nên hơi lên xuống), và loss trên mọi đoạn của tập kiểm tra (test). Cuối cùng, nó viết tiếp 60 ký tự sau “trăm năm” ở .
Trên Windows và macOS, pip install torch cài bản không kèm CUDA. Trên Linux, lệnh đó mặc định tải bản có CUDA, nặng hơn nhiều; muốn bản chỉ chạy CPU thì dùng chỉ mục riêng của PyTorch:
pip install torch --index-url https://download.pytorch.org/whl/cpuChạy trên máy bạn
GPT nhỏ trên 24 câu Truyện Kiều
- Cần cài
pip install torch- Phần cứng
- CPU là đủ, không cần GPU. Khoảng 20 giây trên máy phát triển, có thể vài phút trên máy chậm hoặc đang bận
# GPT nhỏ (decoder-only), 24 câu Kiều như ô bigram (Wikisource, CC BY-SA 4.0)import unicodedataimport torchfrom torch import nn
POEM = """Trăm năm trong cõi người ta, / Chữ tài chữ mệnh khéo là ghét nhau.Trải qua một cuộc bể dâu, / Những điều trông thấy mà đau đớn lòng.Lạ gì bỉ sắc tư phong, / Trời xanh quen thói má hồng đánh ghen.Cảo thơm lần giở trước đèn, / Phong tình cổ lục còn truyền sử xanh.Rằng năm Gia Tĩnh triều Minh, / Bốn phương phẳng lặng, hai kinh vững vàng.Có nhà viên ngoại họ Vương, / Gia tư nghĩ cũng thường thường bực trung.Một trai con thứ rốt lòng, / Vương Quan là chữ, nối dòng nho gia.Đầu lòng hai ả tố nga, / Thúy Kiều là chị, em là Thúy Vân.Mai cốt cách, tuyết tinh thần, / Một người một vẻ, mười phân vẹn mười.Vân xem trang trọng khác vời, / Khuôn trăng đầy đặn, nét ngài nở nang.Hoa cười ngọc thốt đoan trang, / Mây thua nước tóc, tuyết nhường màu da.Kiều càng sắc sảo, mặn mà, / So bề tài, sắc, lại là phần hơn."""text = unicodedata.normalize("NFC", POEM).lower().replace(" / ", "\n")chars = sorted(set(text))V, L, D, H = len(chars), 32, 64, 4 # từ vựng, ngữ cảnh, số chiều, số đầuids = torch.tensor([chars.index(c) for c in text])cut = text.index("hoa cười") # câu 1-20 để huấn luyện, 21-24 để kiểm tratrain, test = ids[:cut].unfold(0, L + 1, 1), ids[cut - 1:].unfold(0, L + 1, 1)MASK = torch.triu(torch.ones(L, L, dtype=torch.bool), 1) # True: cấm nhìn
class GPT(nn.Module): def __init__(self): super().__init__() self.tok, self.pos = nn.Embedding(V, D), nn.Embedding(L, D) blk = nn.TransformerEncoderLayer(D, H, 4 * D, dropout=0.0, # khối GPT batch_first=True, norm_first=True) self.blocks = nn.TransformerEncoder(blk, 2, enable_nested_tensor=False) self.ln, self.head = nn.LayerNorm(D), nn.Linear(D, V)
def forward(self, x): # x: (B, n) chỉ số ký tự -> (B, n, V) logit h = self.tok(x) + self.pos(torch.arange(n := x.shape[1])) return self.head(self.ln(self.blocks(h, mask=MASK[:n, :n])))
def loss(self, w): # w: (B, L + 1). Dự đoán ký tự 2..L+1 từ 1..L z = self(w[:, :-1]).mT # (B, V, L): cross_entropy cần lớp ở chiều 1 return nn.functional.cross_entropy(z, w[:, 1:])
torch.manual_seed(0)model = GPT()opt = torch.optim.AdamW(model.parameters(), lr=3e-3)for step in range(301): loss = model.loss(train[torch.randint(len(train), (32,))]) # lô 32 đoạn opt.zero_grad() loss.backward() opt.step() if step % 50 == 0: print(f"step {step}: train {loss:.3f}, test {model.loss(test):.3f}")
x = torch.tensor([[chars.index(c) for c in "trăm năm"]])for _ in range(60): # sinh văn bản: dự đoán, lấy mẫu với T = 0.8, nối vào p = torch.softmax(model(x[:, -L:])[0, -1].detach() / 0.8, -1) x = torch.cat([x, torch.multinomial(p, 1)[None]], dim=1)print("".join(chars[i] for i in x[0]))Kết quả mẫu
Lấy từ một lần chạy thật.
step 0: train 4.467, test 4.173step 50: train 1.770, test 2.613step 100: train 1.123, test 3.065step 150: train 0.548, test 3.744step 200: train 0.219, test 4.459step 250: train 0.211, test 5.031step 300: train 0.183, test 5.341trăm năm trong cõi người ta,chữ tài chữ mệnh khéo là ghéi ghét nha.Đọc kết quả:
- Bước 0: loss khoảng 4.2 tới 4.5, gần . Mô hình mới khởi tạo gần như đoán đều.
- Bước 50: loss kiểm tra 2.613 (perplexity khoảng 13.6), thấp hơn bigram (2.720) một chút. Đây là lúc mô hình tốt nhất trên văn bản chưa thấy, trong các lần in ra. Phép so này chỉ tương đối: GPT tính loss trên các đoạn chồng lấn nhau (một ký tự được tính nhiều lần), còn bigram tính mỗi cặp một lần.
- Từ bước 100 trở đi: loss huấn luyện giảm tiếp, về 0.183, còn loss kiểm tra tăng đều, lên 5.341. Từ bước 200, nó đã cao hơn cả mốc đoán đều 4.290; ở bước 300, perplexity kiểm tra khoảng 209, tệ hơn đoán bừa giữa 73 ký tự. Mô hình quá tự tin vào những gì nó thuộc lòng, nên khi văn bản mới đi khác, nó gán xác suất rất nhỏ cho ký tự thật và bị phạt nặng.
- Văn sinh ra chép lại gần nguyên văn hai câu đầu bài thơ. Chỗ “ghéi ghét nha” là do lấy mẫu ở thỉnh thoảng rút trúng một ký tự kém hơn. Mô hình đang nhớ, không phải đang viết.
Trên máy khác hoặc bản PyTorch khác, các con số có thể lệch đôi chút, nhưng hình dạng thì như nhau.
Vì sao LLM cần rất nhiều dữ liệu
Phần tiêu đề “Vì sao LLM cần rất nhiều dữ liệu”Đây chính là overfitting của bài Overfitting và Underfitting. Ở đó, đa thức bậc 15 đi qua đúng 16 điểm train, MSE train bằng 0 còn MSE test khoảng 133. Ở đây, khoảng 112 nghìn tham số học 660 ký tự: thừa sức nhớ từng ký tự. Loss huấn luyện giảm mãi, loss kiểm tra giảm rồi tăng.
Các cách chữa cũng như ở phần ML:
- Thêm dữ liệu. Đây là con đường của LLM. GPT-2 (Radford và cộng sự, 2019) có bản lớn nhất khoảng 1.5 tỉ tham số, học trên khoảng 40 GB văn bản. Khi văn bản nhiều hơn khả năng nhớ của mô hình, cách duy nhất để loss giảm là học quy luật chung của ngôn ngữ: chính tả, ngữ pháp, rồi tới nghĩa.
- Dừng sớm (early stopping). Giữ lại mô hình ở bước có loss nhỏ nhất trên một tập kiểm định riêng, không phải tập kiểm tra. Chọn bước 50 vì nhìn cột
testở trên là lỗi “chọn mô hình bằng tập test” mà bài Overfitting và Underfitting đã cảnh báo. - Mô hình nhỏ hơn, hoặc regularization. Dropout, hay weight decay (cùng ý với phạt L2 ở bài Ridge, Lasso, Elastic Net: kéo trọng số về 0; AdamW áp nó tách khỏi gradient nên không trùng hẳn với cộng phạt L2 vào loss). Ô GPT ở trên đã dùng weight decay 0.01 mặc định của AdamW mà vẫn học thuộc: chúng làm chậm việc học thuộc, nhưng không thay được dữ liệu.
Bạn có thể tự thấy tác dụng của dữ liệu: chép các câu thơ giữa hai thẻ <poem> trong bản thô của trang Wikisource (thêm &action=raw vào địa chỉ, rồi bỏ dấu tab đầu dòng và các mã đánh số dạng {{số|5}}) vào POEM, đổi dòng cut thành cut = int(len(text) * 0.9) và range(301) thành range(1001). Ở lần chạy thử của bài, khoảng cách giữa hai loss gần như biến mất (bước 1 000: huấn luyện 1.676, kiểm tra 1.732; tập kiểm tra đã khác nên đừng so với các số ở trên). Văn sinh ra không còn chép nguyên câu, dù phần lớn vẫn vô nghĩa.
Lỗi hay gặp
Phần tiêu đề “Lỗi hay gặp”Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Tóm tắt và đọc thêm
Phần tiêu đề “Tóm tắt và đọc thêm”Ý chính:
- Mô hình ngôn ngữ cho xác suất của token kế tiếp, . Theo quy tắc nhân, thế là đủ để có xác suất của mọi đoạn văn. Sinh văn bản là lặp: dự đoán, lấy mẫu với nhiệt độ , nối vào.
- Huấn luyện là làm nhỏ cross-entropy , tức làm lớn likelihood của văn bản. là số lựa chọn đều nhau tương đương, bằng khi đoán đều. Chỉ so perplexity khi cùng cách tách token và cùng tập kiểm tra.
- Bigram chỉ đếm cặp ký tự và cần làm trơn. GPT là Transformer chỉ có decoder: embedding, chồng khối self-attention có mặt nạ nhân quả và MLP, rồi logit. Mặt nạ giúp một lần chạy cho dự đoán mà không nhìn trước đáp án.
- Trên 24 câu thơ, bigram có loss kiểm tra 2.720; GPT tốt nhất ở 2.613 rồi overfit tới 5.341. LLM cần rất nhiều văn bản để buộc mô hình học quy luật thay vì học thuộc.
Đọc thêm:
- Radford, A., Narasimhan, K., Salimans, T., Sutskever, I. (2018). Improving Language Understanding by Generative Pre-Training (PDF), báo cáo kỹ thuật của OpenAI, mục 3.1 và phần thiết lập mô hình. GPT-1: hàm mất mát của pre-train và Transformer chỉ có decoder.
- Radford, A., Wu, J., Child, R., Luan, D., Amodei, D., Sutskever, I. (2019). Language Models are Unsupervised Multitask Learners (PDF), báo cáo kỹ thuật của OpenAI, mục 2. GPT-2: dữ liệu WebText, BPE mức byte, các cỡ mô hình. Bài viết xác suất của chuỗi đúng như quy tắc nhân ở mục Lý thuyết.
- Vaswani, A. và cộng sự (2017). Attention Is All You Need, arXiv:1706.03762, mục 3.2. Gốc của self-attention có chia và attention nhiều đầu. Bài báo dùng cho số chiều của khóa, chỗ bài này viết .
- Bengio, Y., Ducharme, R., Vincent, P., Jauvin, C. (2003). A Neural Probabilistic Language Model. Journal of Machine Learning Research 3, 1137–1155. Một trong những bài đầu tiên cho thấy mạng nơ-ron với vectơ biểu diễn của từ đoán từ kế tiếp tốt hơn mô hình đếm n-gram.
- Loshchilov, I., Hutter, F. (2019). Decoupled Weight Decay Regularization. ICLR 2019, arXiv:1711.05101. Bài báo của AdamW: vì sao weight decay tách khỏi gradient không trùng với cộng phạt L2 vào loss khi dùng Adam.
- Karpathy, A. nanoGPT (GitHub, giấy phép MIT). Một GPT khoảng 300 dòng, có ví dụ huấn luyện GPT mức ký tự trên Shakespeare, giống tinh thần của bài này ở quy mô lớn hơn. Tác giả ghi repo đã cũ và ngừng cập nhật (11/2025), và giới thiệu dự án mới
nanochat.
Trình duyệt đang chặn lưu dữ liệu. Tiến độ sẽ mất khi bạn tải lại trang.
Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu