Đến thời điểm này, chúng ta đã tối ưu hóa các ứng dụng LLM bằng cách thiết lập prompt, lập trình đường ống RAG để bổ sung tri thức ngoài, và dựng các AI Agent dùng công cụ. Nhưng chuyện gì sẽ xảy ra nếu bạn cần mô hình tuân thủ tuyệt đối một cấu trúc đầu ra đặc thù, nói chuyện theo đúng giọng điệu thương hiệu của doanh nghiệp, hoặc dạy cho một mô hình nhỏ (7B) học kỹ năng suy luận chuyên ngành sâu của mô hình lớn?
Đó là lúc chúng ta phải can thiệp trực tiếp vào trọng số của mạng neural thông qua kỹ thuật Fine-tuning (Tinh chỉnh mô hình). Bài học này sẽ mổ xẻ sự khác biệt thực chiến giữa RAG và Fine-tuning, giải mã bản chất toán học đằng sau kỹ thuật tối ưu hóa tham số cực kỳ phổ biến LoRA (Low-Rank Adaptation), định dạng dữ liệu Instruction Tuning chuẩn, và tự lập trình một chu trình huấn luyện LoRA từ số 0 bằng NumPy.
pip install numpy. Không cần GPU, không cần tải mô hình — dự án
cài đặt LoRA trên một lớp Linear 8×8 để bạn nhìn thấy từng phép nhân ma trận. Kiến thức cần có: Bài 6 cho Loss và Backpropagation — vòng huấn luyện ở đây là đúng vòng đó, chỉ khác ở chỗ chỉ có hai ma trận được cập nhật. Bài 3 cho phép nhân ma trận và ý nghĩa của các chiều.
19.1 Khi nào nên Fine-tune? Phân biệt với RAG
Có một câu nói kinh điển trong ngành kỹ nghệ AI: "RAG giống như cho mô hình đọc sách mở trước kỳ thi (Open-book Exam), còn Fine-tuning giống như cho mô hình đi học đại học ngành chuyên sâu trong nhiều năm để thay đổi tư duy."
| Đặc trưng | RAG (Retrieval-Augmented Generation) | Fine-tuning (Tinh chỉnh trọng số) |
|---|---|---|
| Mục đích chính | Cung cấp kiến thức ngoài, dữ liệu động cập nhật liên tục. | Dạy kỹ năng mới, định hình văn phong, cấu trúc đầu ra. |
| Cơ chế hoạt động | Truy xuất đoạn văn bản liên quan nạp vào context prompt. | Huấn luyện lan truyền ngược để cập nhật trọng số mô hình. |
| Giảm ảo tưởng (Hallucination) | Cực tốt (nhờ có tài liệu dẫn chứng trực tiếp trong prompt). | Trung bình (chỉ nhớ theo dạng phân phối xác suất trọng số). |
| Chi phí token prompt | Cao (vì phải đính kèm lượng lớn ngữ cảnh thô). | Thấp (mô hình đã học kỹ năng trực tiếp, prompt rất ngắn). |
| Độ động của dữ liệu | Cực tốt (chỉ cần cập nhật Vector Database). | Kém (mỗi lần cập nhật tri thức mới lại phải training lại). |
19.2 Kỹ thuật PEFT & LoRA (Low-Rank Adaptation)
Việc tinh chỉnh toàn bộ các tham số của một mô hình ngôn ngữ lớn (Full Fine-Tuning) đòi hỏi tài nguyên tính toán vô cùng khổng lồ. Ví dụ, để huấn luyện một mô hình Llama-3 8B, chúng ta cần lưu trữ trọng số, gradients, trạng thái của bộ tối ưu hóa (Optimizer States), yêu cầu hệ thống GPU doanh nghiệp cực lớn (hàng trăm GB VRAM).
Để giải quyết bài toán này, kỹ thuật LoRA (Low-Rank Adaptation) đề xuất giải pháp đóng băng (freeze) hoàn toàn ma trận trọng số gốc $W_0 \in \mathbb{R}^{d \times k}$ của mô hình, không cho phép cập nhật. Thay vào đó, ta đưa vào một nhánh phụ song song chứa ma trận hiệu chỉnh trọng số $\Delta W$, được phân rã thành tích của hai ma trận hạng thấp (low-rank matrices) $A \in \mathbb{R}^{r \times k}$ và $B \in \mathbb{R}^{d \times r}$ với hạng $r \ll \min(d, k)$:
\[\Delta W = B \cdot A\]Ví dụ, nếu chiều ẩn của mô hình $d = 4096$, $k = 4096$, ma trận gốc chứa $4096 \times 4096 \approx 16.7$ triệu tham số. Nếu ta cấu hình hạng $r = 8$, ma trận $A$ chỉ có $8 \times 4096 \approx 32,768$ tham số, ma trận $B$ cũng có $32,768$ tham số. Tổng số lượng tham số cần huấn luyện của LoRA chỉ là $65,536$ — giảm hơn 250 lần so với ban đầu!
Trong quá trình Forward Pass, với vector đầu vào $x$, đầu ra $h$ của lớp Linear được tính bằng:
\[h = x \cdot W_0 + \frac{\alpha}{r} (x \cdot B \cdot A)\]Trong đó $\alpha$ là một hằng số scaling (LoRA Alpha) giúp điều chỉnh cường độ tác động của phần trọng số LoRA huấn luyện mới lên đầu ra.
19.3 Định dạng dữ liệu huấn luyện: Instruction Tuning
Để huấn luyện LLM học cách làm theo chỉ dẫn, chúng ta phải chuẩn bị dữ liệu dưới dạng các mẫu hội thoại có cấu trúc chặt chẽ. Định dạng phổ biến nhất là JSON Lines (JSONL), ví dụ:
{"instruction": "Hãy viết email xin nghỉ phép bằng giọng điệu lịch sự.", "input": "Lý do: đi khám bệnh ngày 15/7", "output": "Kính gửi Ban Giám đốc, tôi viết email này để xin phép được nghỉ làm vào ngày 15/7 vì lý do sức khỏe cần đi khám định kỳ..."}
{"instruction": "Hãy viết email xin nghỉ phép bằng giọng điệu lịch sự.", "input": "Lý do: giải quyết việc gia đình ngày 20/7", "output": "Kính gửi anh/chị quản lý, tôi xin phép được nghỉ phép ngày 20/7 để giải quyết một số công việc gia đình đột xuất..."}
19.4 Dự án thực hành bài 19: LoRA từ số 0 bằng NumPy, và bốn khẳng định được kiểm chứng
Mục 19.2 đưa ra bốn khẳng định về LoRA. Dự án này kiểm cả bốn thay vì để bạn phải tin: trọng số gốc không đổi, adapter lúc khởi tạo đóng góp đúng bằng 0, số tham số huấn luyện giảm hai bậc, và adapter gộp lại được vào trọng số gốc sau khi huấn luyện.
"""Lesson 19 project: LoRA from scratch in NumPy, with its claims checked.
Run: python3 lora_simulation.py
Needs: pip install numpy
The lesson makes four claims about LoRA. This file tests all four rather than
asserting them:
1. the base weights W0 never change during training,
2. at initialisation the adapter contributes exactly zero, so the model
starts out identical to the base model,
3. the number of trainable parameters collapses by two orders of magnitude,
4. after training the adapter can be folded back into W0, so inference costs
nothing extra.
"""
import numpy as np
np.random.seed(42)
D_IN, D_OUT = 8, 8
RANK = 2
ALPHA = 4.0
LEARNING_RATE = 0.01
EPOCHS = 100
def init_lora(d_in, d_out, rank):
"""A is random, B is zeros. That asymmetry is deliberate - see claim 2."""
lora_a = np.random.randn(rank, d_out) * 0.1
lora_b = np.zeros((d_in, rank))
return lora_a, lora_b
def forward(x, w0, lora_a, lora_b, rank, alpha):
"""h = x·W0 + (alpha/rank)·(x·B·A).
Note the order: B then A. Since delta_W = B·A, the adapter path must be
x·B·A. Writing x·A·B does not even typecheck - A is (rank, d_out) and x is
(1, d_in), so the shapes do not line up.
"""
base = x @ w0
adapter = (x @ lora_b) @ lora_a * (alpha / rank)
return base + adapter, base, adapter
def train(x, target, w0, lora_a, lora_b, rank, alpha, epochs, lr, verbose=True):
"""Gradient descent on A and B only. W0 is never touched below."""
scaling = alpha / rank
losses = []
for epoch in range(epochs):
h, _, _ = forward(x, w0, lora_a, lora_b, rank, alpha)
loss = np.mean((h - target) ** 2)
losses.append(loss)
d_loss_d_h = 2 * (h - target) / w0.shape[1]
grad_a = scaling * ((x @ lora_b).T @ d_loss_d_h)
grad_b = scaling * (x.T @ (d_loss_d_h @ lora_a.T))
lora_a -= lr * grad_a
lora_b -= lr * grad_b
if verbose and ((epoch + 1) % 25 == 0 or epoch == 0):
print(f" epoch {epoch + 1:3d} | loss {loss:.6f}")
return lora_a, lora_b, losses
def count_parameters(d_in, d_out, rank):
frozen = d_in * d_out
trainable = rank * d_out + d_in * rank
return frozen, trainable, frozen / trainable
def rank_experiment(rank, alpha, epochs=EPOCHS, lr=LEARNING_RATE):
"""Train one adapter from an identical starting point and return its loss."""
np.random.seed(42)
w0 = np.random.randn(D_IN, D_OUT) * 0.1
x = np.random.randn(1, D_IN)
target = np.random.randn(1, D_OUT)
lora_a, lora_b = init_lora(D_IN, D_OUT, rank)
_, _, losses = train(x, target, w0, lora_a, lora_b, rank, alpha,
epochs, lr, verbose=False)
return losses[-1]
def main():
w0 = np.random.randn(D_IN, D_OUT) * 0.1
x = np.random.randn(1, D_IN)
target = np.random.randn(1, D_OUT)
lora_a, lora_b = init_lora(D_IN, D_OUT, RANK)
# --- Claim 2: the adapter starts at exactly zero -----------------------
print("=== Claim: at initialisation the adapter changes nothing ===")
h0, base0, adapter0 = forward(x, w0, lora_a, lora_b, RANK, ALPHA)
print(f" largest value in the adapter path : {np.abs(adapter0).max():.1e}")
print(f" output identical to the base model: {np.array_equal(h0, base0)}")
assert np.array_equal(h0, base0), "the adapter should be inert at t=0"
print(" B is initialised to zeros, so delta_W = B·A is the zero matrix.")
print(" That is why attaching an untrained adapter cannot hurt a model:")
print(" it starts as an exact no-op, then learns away from there.\n")
# --- Claim 3: parameter counts ----------------------------------------
print("=== Claim: far fewer trainable parameters ===")
for d, r in ((D_IN, RANK), (4096, 8), (4096, 64)):
frozen, trainable, ratio = count_parameters(d, d, r)
print(f" d={d:<5} r={r:<3} frozen {frozen:>10,} trainable {trainable:>8,}"
f" {ratio:>6.0f}x fewer")
print(" The 4096 row is one attention projection of a 7B-class model.\n")
# --- Claim 1: W0 is frozen --------------------------------------------
print("=== Training the adapter (W0 must not move) ===")
w0_before = w0.copy()
lora_a, lora_b, losses = train(x, target, w0, lora_a, lora_b,
RANK, ALPHA, EPOCHS, LEARNING_RATE)
print(f" loss {losses[0]:.6f} -> {losses[-1]:.6f}"
f" ({losses[0] / losses[-1]:.0f}x lower)")
print(f" W0 bit-identical after training: {np.array_equal(w0, w0_before)}")
assert np.array_equal(w0, w0_before), "W0 was modified - it must be frozen"
print(f" largest value in B after training: {np.abs(lora_b).max():.4f}"
f" (was exactly 0)\n")
# --- Claim 4: the adapter can be merged into W0 ------------------------
print("=== Claim: the adapter can be folded into W0 for free inference ===")
delta_w = (lora_b @ lora_a) * (ALPHA / RANK)
w_merged = w0 + delta_w
h_adapter, _, _ = forward(x, w0, lora_a, lora_b, RANK, ALPHA)
h_merged = x @ w_merged
gap = np.abs(h_adapter - h_merged).max()
print(f" largest difference between the two paths: {gap:.2e}")
assert gap < 1e-12, "merging changed the output"
print(" Same numbers, one matrix multiply instead of three. This is why")
print(" LoRA adds no inference latency once the adapter is merged - and")
print(" why you can keep many small adapters for one shared base model.\n")
# --- What the rank actually buys, and the trap in measuring it -------
print("=== What does the rank r buy? ===")
print(" Sweep A: alpha fixed at 4.0, which is the obvious experiment")
for rank in (1, 2, 4, 8):
loss = rank_experiment(rank, alpha=ALPHA)
print(f" r={rank} alpha/r={ALPHA / rank:.2f} final loss {loss:.6f}")
print(" Higher rank looks WORSE. That result is an artefact, not a finding:")
print(" the adapter is scaled by alpha/r, so holding alpha fixed quietly")
print(" shrinks every update as r grows. The sweep measured the scaling,")
print(" not the capacity.\n")
print(" Sweep B: alpha scaled with r so alpha/r stays 2.0")
for rank in (1, 2, 4, 8):
loss = rank_experiment(rank, alpha=2.0 * rank)
print(f" r={rank} alpha={2.0 * rank:<5} alpha/r=2.00 final loss {loss:.6f}")
print(" Now rank helps - up to r=4, after which an 8x8 layer with one")
print(" training example has nothing left to gain. This is why the usual")
print(" advice is to raise alpha together with r rather than tune it alone.")
if __name__ == "__main__":
main()
Khẳng định 1: vì sao ma trận B được khởi tạo bằng 0
Chi tiết dễ bị bỏ qua nhất trong LoRA: $A$ khởi tạo ngẫu nhiên nhưng $B$ khởi tạo toàn số 0. Hệ quả là $\Delta W = B \cdot A$ bằng đúng ma trận không, nên tại thời điểm bắt đầu, mô hình có gắn adapter giống hệt mô hình gốc:
=== Claim: at initialisation the adapter changes nothing ===
largest value in the adapter path : 0.0e+00
output identical to the base model: True
B is initialised to zeros, so delta_W = B·A is the zero matrix.
That is why attaching an untrained adapter cannot hurt a model:
it starts as an exact no-op, then learns away from there.
Con số 0.0e+00 không phải "rất nhỏ" mà là đúng bằng không, và True là
phép so sánh bằng chính xác chứ không phải xấp xỉ. Đây là tính chất khiến LoRA an toàn khi triển khai: gắn
một adapter chưa huấn luyện vào mô hình sản xuất không thể làm hỏng gì, vì nó khởi đầu là một phép toán
rỗng rồi mới học đi xa dần.
Khẳng định 2: số tham số cần huấn luyện
=== Claim: far fewer trainable parameters ===
d=8 r=2 frozen 64 trainable 32 2x fewer
d=4096 r=8 frozen 16,777,216 trainable 65,536 256x fewer
d=4096 r=64 frozen 16,777,216 trainable 524,288 32x fewer
The 4096 row is one attention projection of a 7B-class model.
Dòng $d = 4096$, $r = 8$ là kích thước thật của một phép chiếu attention trong mô hình cỡ 7B: từ 16.7 triệu tham số xuống 65.536 — ít hơn 256 lần. Nhưng để ý dòng cuối: nâng $r$ lên 64 thì tỷ lệ chỉ còn 32 lần. Lợi ích của LoRA tỷ lệ nghịch với hạng bạn chọn, nên "cứ để $r$ thật cao cho chắc" là cách nhanh nhất để đánh mất lý do dùng LoRA.
Khẳng định 3: trọng số gốc thật sự bị đóng băng
=== Training the adapter (W0 must not move) ===
epoch 1 | loss 1.740512
epoch 25 | loss 1.531319
epoch 50 | loss 0.619526
epoch 75 | loss 0.029707
epoch 100 | loss 0.001004
loss 1.740512 -> 0.001004 (1734x lower)
W0 bit-identical after training: True
largest value in B after training: 0.3704 (was exactly 0)
Dòng W0 bit-identical after training: True là một assert thật trong code, so
sánh từng bit ma trận gốc trước và sau 100 epoch. Nếu vòng huấn luyện vô tình chạm vào $W_0$, chương trình
sẽ dừng ngay. Còn ma trận $B$ thì đi từ đúng 0 lên $0.3704$ — nó chính là toàn bộ thứ đã học được.
Khẳng định 4: gộp adapter vào trọng số gốc là miễn phí
=== Claim: the adapter can be folded into W0 for free inference ===
largest difference between the two paths: 2.22e-16
Same numbers, one matrix multiply instead of three. This is why
LoRA adds no inference latency once the adapter is merged - and
why you can keep many small adapters for one shared base model.
Sai lệch $2.22 \times 10^{-16}$ chính là sai số làm tròn của số thực 64-bit, tức hai đường tính ra cùng một kết quả. Điều này quan trọng về mặt vận hành: sau khi huấn luyện xong, bạn cộng thẳng $\frac{\alpha}{r} B A$ vào $W_0$ và phục vụ như một mô hình bình thường — LoRA không thêm một mili-giây độ trễ suy luận nào. Và vì adapter chỉ vài chục nghìn tham số, bạn có thể giữ hàng chục adapter cho các tác vụ khác nhau trên cùng một mô hình gốc, đổi qua lại theo yêu cầu.
Hạng $r$ mua cho bạn cái gì — và cái bẫy khi đo nó
Câu hỏi tự nhiên: tăng $r$ thì mô hình học tốt hơn chứ? Thí nghiệm hiển nhiên là giữ nguyên mọi thứ và chỉ đổi $r$. Kết quả gây bất ngờ:
=== What does the rank r buy? ===
Sweep A: alpha fixed at 4.0, which is the obvious experiment
r=1 alpha/r=4.00 final loss 0.000000
r=2 alpha/r=2.00 final loss 0.001004
r=4 alpha/r=1.00 final loss 0.579510
r=8 alpha/r=0.50 final loss 1.516855
Higher rank looks WORSE. That result is an artefact, not a finding:
the adapter is scaled by alpha/r, so holding alpha fixed quietly
shrinks every update as r grows. The sweep measured the scaling,
not the capacity.
Sweep B: alpha scaled with r so alpha/r stays 2.0
r=1 alpha=2.0 alpha/r=2.00 final loss 0.002811
r=2 alpha=4.0 alpha/r=2.00 final loss 0.001004
r=4 alpha=8.0 alpha/r=2.00 final loss 0.000336
r=8 alpha=16.0 alpha/r=2.00 final loss 0.000616
Now rank helps - up to r=4, after which an 8x8 layer with one
training example has nothing left to gain. This is why the usual
advice is to raise alpha together with r rather than tune it alone.
Nguyên nhân nằm ngay trong công thức: adapter được nhân với $\frac{\alpha}{r}$. Giữ $\alpha = 4$ cố định trong khi tăng $r$ từ 1 lên 8 tức là âm thầm chia nhỏ hệ số này từ $4.00$ xuống $0.50$. Sweep A không đo dung lượng của adapter, nó đo hệ số scaling. Sweep B giữ $\frac{\alpha}{r} = 2.0$ cố định, và lúc đó hạng mới thể hiện đúng vai trò: loss giảm dần tới $r = 4$ rồi chững lại — vì một lớp $8 \times 8$ với đúng một mẫu huấn luyện thì hết chỗ để cải thiện.
Bài học vận hành: $\alpha$ không phải một núm vặn độc lập. Quy ước phổ biến là đặt $\alpha = 2r$ chính vì lý do này — để khi bạn đổi $r$, cường độ tác động của adapter không bị đổi theo một cách bạn không nhận ra.
Cách chạy dự án này trên máy bạn
-
pip install numpyrồipython3 lora_simulation.py. Nhờnp.random.seed(42), mọi con số trong bài sẽ ra giống hệt trên máy bạn. -
Rồi thử phá nó theo ba cách:
-
Trong
init_lora, đổinp.zeros((d_in, rank))thànhnp.random.randn(d_in, rank) * 0.1. Phépassertđầu tiên đổ ngay: adapter không còn là phép toán rỗng, tức gắn nó vào mô hình sản xuất sẽ làm đầu ra đổi trước khi học được gì. -
Thêm dòng
w0 -= lr * 0.001vào trong vòng lặptrain. Phépassertđóng băng $W_0$ sẽ bắt được ngay — đây đúng là loại rò rỉ mà một cài đặt LoRA viết vội dễ mắc. -
Đổi
EPOCHStừ 100 lên 500 rồi chạy lại Sweep A. Khoảng cách giữa các hạng thu hẹp lại, xác nhận rằng chênh lệch ở Sweep A là chuyện tốc độ hội tụ chứ không phải dung lượng.
-
Trong
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: Phân biệt được khi nào dùng RAG và khi nào cần Fine-tuning, và biết định dạng dữ liệu Instruction Tuning.
-
Đạt được: Tự cài đặt LoRA bằng NumPy và kiểm chứng bốn khẳng định của nó bằng
assert: adapter khởi đầu bằng đúng 0, $W_0$ không đổi một bit, tham số giảm 256 lần, và gộp adapter lại cho ra cùng kết quả tới sai số $10^{-16}$. - Đạt được: Hiểu vì sao $\alpha$ và $r$ phải đi cùng nhau — và thấy một thí nghiệm trông hợp lý cho ra kết luận ngược hoàn toàn khi quên kiểm soát $\frac{\alpha}{r}$.
Cầu nối bài tiếp theo: Bạn đã có mô hình được tinh chỉnh, có RAG, có Agent. Câu hỏi cuối cùng của một kỹ sư AI là câu khó nhất: làm sao biết hệ thống này thật sự tốt, và làm sao biết ngay khi nó bắt đầu tệ đi? Bài 20 khép lại lộ trình bằng phục vụ mô hình, tracing và đánh giá tự động.
Tải file code thực hành minh họa bài học
File Python lora_simulation.py — mã nguồn NumPy tự xây dựng phép nhân ma trận song song và
thuật toán lan truyền ngược cập nhật tham số LoRA (chạy python lora_simulation.py):
📖 Tài liệu tham khảo
- LoRA: Low-Rank Adaptation of Large Language Models — Bài báo khoa học gốc lịch sử phát minh ra kỹ thuật LoRA của Microsoft (Hu et al., 2021)
- Hugging Face PEFT (Parameter-Efficient Fine-Tuning) — Tài liệu thư viện chuẩn công nghiệp triển khai LoRA, QLoRA, Prefix Tuning
- Microsoft LoRA Repository — Mã nguồn chính thức của tác giả công bố cấu trúc cài đặt LoRA trên PyTorch
Bình luận