Đế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.

✅ Bạn cần gì trước khi bắt đầu
Thư viện: 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).
⚠️ Cạm bẫy: Fine-tune mô hình chỉ để nạp kiến thức tĩnh
Lỗi phổ biến nhất của các kỹ sư mới vào nghề là cố gắng Fine-tune mô hình chỉ để bắt nó nhớ danh sách sản phẩm mới của doanh nghiệp hoặc tài liệu chính sách nội bộ. Việc này cực kỳ tốn kém GPU, mất nhiều thời gian, dễ gây ra hiện tượng quên lãng thảm họa (Catastrophic Forgetting) khiến mô hình mất đi khả năng suy luận cơ bản, và tri thức học được sẽ nhanh chóng bị lỗi thời. Hãy dùng RAG cho bài toán nạp tri thức, chỉ dùng Fine-tuning khi cần thay đổi định dạng phản hồi hoặc hành vi suy luận logic chuyên ngành.

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.

🔢 Vì sao demo NumPy ở mục 19.4 không giảm được 250 lần tham số?
Con số "giảm hơn 250 lần" ở trên chỉ đúng ở quy mô mô hình thật ($d = k = 4096$). Dự án thực hành bên dưới cố tình dùng kích thước rất nhỏ ($d_{in} = d_{out} = 8$, $r = 2$) để chạy tức thời trên máy cá nhân. Tính lại tỷ lệ ở quy mô này (đã kiểm chứng): $W_0$ có $8 \times 8 = 64$ tham số, còn LoRA $A$ + $B$ chỉ có $2 \times 8 + 8 \times 2 = 32$ tham số — tỷ lệ giảm chỉ còn 2 lần, không hề ấn tượng như con số 250 lần ở trên. Đây không phải mâu thuẫn: tỷ lệ tiết kiệm của LoRA tăng theo cấp số nhân khi kích thước ma trận gốc $d \times k$ tăng lên (vì tham số LoRA chỉ tăng tuyến tính theo $d + k$ trong khi tham số gốc tăng theo tích $d \times k$), nên lợi ích thực sự chỉ bộc lộ rõ rệt ở quy mô mô hình production hàng tỷ tham số, không phải ở ví dụ minh họa nhỏ này.

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ụ:

dataset.jsonl
{"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.

lora_simulation.py
"""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:

Terminal
=== 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

Terminal
=== 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

Terminal
=== 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í

Terminal
=== 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ờ:

Terminal
=== 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.
⚡ Đây là lý do phải nghi ngờ chính thí nghiệm của mình
Sweep A cho thấy hạng càng cao thì loss càng tệ — ngược hoàn toàn với trực giác, và ngược với thứ mọi tài liệu nói. Nếu dừng ở đây và viết "hạng cao không giúp gì" thì đó là một kết luận sai rút ra từ một phép đo hỏng.

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

  1. pip install numpy rồi python3 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.
  2. Rồi thử phá nó theo ba cách:
    • Trong init_lora, đổi np.zeros((d_in, rank)) thành np.random.randn(d_in, rank) * 0.1. Phép assert đầ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.001 vào trong vòng lặp train. Phép assert đó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 EPOCHS từ 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.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt đượ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 về lora_simulation.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 18: Đại lý có trạng thái với LangGraph Bài 20: MLOps: Deploy, Tracing & Đánh giá Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận