DEV Community

Cover image for Cách xây dựng LLM từ đầu (và những bài học rút ra)
Sebastian Petrus
Sebastian Petrus

Posted on • Originally published at apidog.com

Cách xây dựng LLM từ đầu (và những bài học rút ra)

TL;DR (Tóm tắt)

Xây dựng một mô hình ngôn ngữ tối giản từ đầu chỉ mất dưới 300 dòng mã Python. Quá trình này giúp bạn hiểu chi tiết về tokenization, cơ chế attention và inference, từ đó nâng cao khả năng tích hợp các LLM sản xuất vào ứng dụng của mình.

Hãy thử Apidog ngay hôm nay

Giới thiệu

Hầu hết các nhà phát triển xem mô hình ngôn ngữ như một hộp đen: nhập văn bản, nhận token, và điều kỳ diệu xảy ra ở giữa. Tuy nhiên, khi bạn cần gỡ lỗi tích hợp API, tinh chỉnh sampling hoặc xử lý lỗi dữ liệu đầu ra, hiểu rõ bên trong là bắt buộc.

GuppyLM là một dự án transformer 8,7 triệu tham số, viết hoàn toàn bằng Python, có thể huấn luyện trên GPU thông thường trong vòng chưa đầy một giờ. Mục tiêu của nó không phải cạnh tranh với GPT-4 mà là giúp bạn hiểu sâu về cách LLM thực sự hoạt động.

Bài viết này hướng dẫn bạn từng bước xây dựng một LLM nhỏ, giải thích chức năng từng thành phần, và cung cấp những kiến thức thực tiễn khi làm việc với API AI.

💡 Nếu bạn đang kiểm thử tích hợp API AI, Tính năng Kịch bản Kiểm thử (Test Scenarios) của Apidog cho phép xác minh phản hồi theo luồng, kiểm tra cấu trúc token và mô phỏng các trường hợp hoàn thành đặc biệt mà không tốn tài nguyên môi trường thật. Chi tiết hơn bên dưới.

Điều gì làm cho một mô hình ngôn ngữ trở nên "nhỏ gọn"?

Một LLM sản xuất như GPT-4 thường có hàng trăm tỷ tham số. Trong khi đó, LLM nhỏ gọn nằm trong khoảng 1 triệu đến 25 triệu tham số. Ví dụ: GuppyLM (8,7 triệu), nanoGPT (124 triệu), MicroLM (1-2 triệu).

LLM nhỏ gọn có thể:

  • Huấn luyện trên laptop hoặc Google Colab
  • Chạy hoàn toàn trên CPU
  • Dễ kiểm tra, sửa đổi, gỡ lỗi ở cấp trọng số

LLM nhỏ gọn không thể:

  • Xử lý các suy luận phức tạp
  • Sinh văn bản dài mạch lạc ổn định
  • Đối chiếu chiều sâu kiến thức như các mô hình sản xuất

Giá trị lớn nhất là kiến thức bạn học được từ việc tự xây dựng một mô hình như vậy.

Các thành phần cốt lõi: LLM hoạt động như thế nào

Trước khi bắt đầu mã hóa, hãy nắm rõ 4 thành phần chính sau.

Bộ mã hóa token (Tokenizer)

Tokenizer chuyển văn bản thô thành các ID số nguyên. Ví dụ, "Hello, world!" sẽ thành [15496, 11, 995, 0]. Mỗi số nguyên tượng trưng cho một đơn vị từ vựng cố định.

Lưu ý khi dùng API: Số token ảnh hưởng trực tiếp đến chi phí và độ trễ. Hiểu cách tokenizer tách văn bản giúp bạn tối ưu prompt và tránh cắt văn bản ngoài ý muốn.

GuppyLM dùng tokenizer cấp ký tự đơn giản. Các mô hình lớn như GPT-4 sử dụng BPE với từ vựng 50K-100K.

Lớp nhúng (Embedding layer)

Lớp nhúng chuyển các ID token thành vector dày đặc (ví dụ: 384 chiều với GuppyLM). Token tương tự sẽ gần nhau trong không gian vector. Thêm vị trí nhúng (position embeddings) để mô hình biết thứ tự token.

Các khối Transformer

Cốt lõi gồm 2 phần:

  • Tự chú ý (Self-attention): Cho phép mỗi token nhìn toàn chuỗi và xác định token nào quan trọng. GuppyLM dùng 6 head attention trên 6 lớp.
  • Mạng truyền thẳng (Feed-forward): Một MLP hai lớp được áp dụng sau attention.

Đầu ra (Output head)

Sau khối transformer cuối, lớp tuyến tính chiếu biểu diễn mỗi token thành vector có chiều bằng kích thước từ vựng. Áp dụng softmax để lấy xác suất, chọn token tiếp theo (hoặc lấy mẫu), và lặp lại.

Xây dựng một LLM tối giản bằng Python

Dưới đây là ví dụ LLM tối giản dựa trên GuppyLM, chạy với PyTorch:

import torch
import torch.nn as nn
import torch.nn.functional as F

# Hyperparameters
VOCAB_SIZE = 256     # character-level: one slot per ASCII char
D_MODEL = 128        # embedding dimension
N_HEADS = 4          # attention heads
N_LAYERS = 3         # transformer blocks
SEQ_LEN = 64         # context window
DROPOUT = 0.1

class SelfAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.proj = nn.Linear(d_model, d_model, bias=False)
        self.dropout = nn.Dropout(DROPOUT)

    def forward(self, x):
        B, T, C = x.shape
        qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim)
        q, k, v = qkv.unbind(dim=2)
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)
        # Causal mask: each token can only attend to previous tokens
        scale = self.head_dim ** -0.5
        attn = (q @ k.transpose(-2, -1)) * scale
        mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
        attn = attn.masked_fill(mask, float('-inf'))
        attn = F.softmax(attn, dim=-1)
        attn = self.dropout(attn)
        out = (attn @ v).transpose(1, 2).reshape(B, T, C)
        return self.proj(out)

class TransformerBlock(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.attn = SelfAttention(d_model, n_heads)
        self.ff = nn.Sequential(
            nn.Linear(d_model, 4 * d_model),
            nn.ReLU(),
            nn.Linear(4 * d_model, d_model),
            nn.Dropout(DROPOUT),
        )
        self.ln1 = nn.LayerNorm(d_model)
        self.ln2 = nn.LayerNorm(d_model)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.ff(self.ln2(x))
        return x

class TinyLLM(nn.Module):
    def __init__(self):
        super().__init__()
        self.embed = nn.Embedding(VOCAB_SIZE, D_MODEL)
        self.pos_embed = nn.Embedding(SEQ_LEN, D_MODEL)
        self.blocks = nn.ModuleList([
            TransformerBlock(D_MODEL, N_HEADS) for _ in range(N_LAYERS)
        ])
        self.ln_f = nn.LayerNorm(D_MODEL)
        self.head = nn.Linear(D_MODEL, VOCAB_SIZE, bias=False)

    def forward(self, idx):
        B, T = idx.shape
        tok_emb = self.embed(idx)
        pos = torch.arange(T, device=idx.device)
        pos_emb = self.pos_embed(pos)
        x = tok_emb + pos_emb
        for block in self.blocks:
            x = block(x)
        x = self.ln_f(x)
        logits = self.head(x)
        return logits

# Khởi tạo và đếm số tham số
model = TinyLLM()
total_params = sum(p.numel() for p in model.parameters())
print(f"Model size: {total_params:,} parameters")  # ~1.2M
Enter fullscreen mode Exit fullscreen mode

Vòng lặp huấn luyện

import torch.optim as optim

def train(model, data, epochs=100, lr=3e-4):
    optimizer = optim.AdamW(model.parameters(), lr=lr)
    model.train()
    for epoch in range(epochs):
        # data: tensor of token IDs, shape [batch, seq_len+1]
        x = data[:, :-1]   # input: all tokens except last
        y = data[:, 1:]    # target: all tokens shifted by 1
        logits = model(x)
        loss = F.cross_entropy(logits.reshape(-1, VOCAB_SIZE), y.reshape(-1))
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if epoch % 10 == 0:
            print(f"Epoch {epoch}, loss: {loss.item():.4f}")
Enter fullscreen mode Exit fullscreen mode

Suy luận (tạo văn bản)

@torch.no_grad()
def generate(model, prompt_ids, max_new_tokens=50, temperature=1.0, top_k=10):
    model.eval()
    ids = torch.tensor([prompt_ids])
    for _ in range(max_new_tokens):
        idx_cond = ids[:, -SEQ_LEN:]  # crop to context window
        logits = model(idx_cond)
        logits = logits[:, -1, :] / temperature  # last token only
        # top-k sampling
        v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
        logits[logits < v[:, [-1]]] = float('-inf')
        probs = F.softmax(logits, dim=-1)
        next_id = torch.multinomial(probs, num_samples=1)
        ids = torch.cat([ids, next_id], dim=1)
    return ids[0].tolist()
Enter fullscreen mode Exit fullscreen mode

Điều này dạy bạn điều gì về hành vi của API AI

Xây dựng mô hình này sẽ giúp bạn hiểu rõ hơn cách các API AI hoạt động khi tích hợp vào ứng dụng.

Temperature và sampling là cơ chế, không phải phép màu

Temperature chia các logit trước khi áp dụng softmax. Temperature cao hơn = phân phối phẳng hơn = đầu ra ngẫu nhiên hơn. Thấp hơn = sắc nét hơn = đầu ra xác định hơn. Khi API trả về kết quả không nhất quán với temperature=0.0, đó là do temperature 0 thực chất là argmax tham lam; nhiều API sẽ đặt cao hơn một chút để tránh kết quả suy biến.

Cửa sổ ngữ cảnh là giới hạn cứng

Dòng mã idx_cond = ids[:, -SEQ_LEN:] trong vòng lặp suy luận thể hiện rõ: khi vượt quá cửa sổ ngữ cảnh, token cũ bị bỏ qua. Nếu bạn giả định API ghi nhớ toàn bộ lịch sử hội thoại, hãy kiểm tra lại! Xem [internal: how-ai-agent-memory-works].

Các token truyền trực tuyến thực chất là các bước inference lặp lại

API streaming chỉ đơn giản là chạy vòng lặp inference và gửi từng token ra luồng phản hồi ngay khi tạo xong. Nếu luồng bị ngắt, không thể tiếp tục mà phải khởi động lại từ đầu.

Logit lý giải vì sao đầu ra có cấu trúc lại khó

Mô hình gán xác suất cho từng token ở mỗi bước. Để sinh JSON hợp lệ, token hợp lệ phải thắng ở mọi vị trí. Các thư viện như Outlines và Guidance giới hạn logit để áp đặt ngữ pháp khi suy luận. Khi API AI cung cấp chế độ "đầu ra có cấu trúc", bên trong chúng cũng chỉ như vậy.

Cách kiểm thử tích hợp API AI với Apidog

Hiểu rõ inference của LLM giúp bạn viết test case API tốt hơn. Tính năng Kịch bản Kiểm thử (Test Scenarios) của Apidog cho phép bạn chuỗi các lệnh gọi API và xác nhận cấu trúc phản hồi AI.

Ví dụ kiểm thử API chat streaming:

  1. Tạo Kịch bản Kiểm thử với endpoint /v1/chat/completions
  2. Đặt xác nhận: response.choices[0].finish_reason == "stop", response.usage.total_tokens < 4096
  3. Thêm bước tiếp theo gửi phản hồi như ngữ cảnh cho lượt tiếp theo (mô phỏng chat đa lượt)
  4. Sử dụng Smart Mock của Apidog để giả lập endpoint AI và kiểm thử xử lý lỗi: mô phỏng finish_reason: "length", "content_filter" và timeout mạng.

Cách này giúp kiểm thử tích hợp AI mà không tốn API credit cho mỗi lần CI. Xem [internal: api-testing-tutorial] để biết thêm về phương pháp kiểm thử API.

Kiểm thử xác nhận số lượng token

{
  "assertions": [
    {
      "field": "response.usage.completion_tokens",
      "operator": "less_than",
      "value": 512
    },
    {
      "field": "response.choices[0].finish_reason",
      "operator": "equals",
      "value": "stop"
    },
    {
      "field": "response.choices[0].message.content",
      "operator": "not_empty"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Chạy bộ kiểm thử này trên nhiều mô hình (GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro) trong một Kịch bản duy nhất để phát hiện sự khác biệt về schema API trước khi lên production.

Nâng cao: Lượng tử hóa và tối ưu hóa inference

Sau khi có một LLM nhỏ hoạt động, thử hai kỹ thuật sau vì chúng rất thực tiễn khi triển khai mô hình sản xuất.

Lượng tử hóa (Quantization)

Mặc định, trọng số là số thực 32-bit. Lượng tử hóa giảm xuống 8-bit (INT8) hoặc 4-bit (INT4), giúp giảm 4-8 lần dung lượng bộ nhớ với tổn thất độ chính xác vừa phải.

# Ví dụ: lượng tử hóa động INT8 trong PyTorch
import torch.quantization
quantized_model = torch.quantization.quantize_dynamic(
    model, {nn.Linear}, dtype=torch.qint8
)
Enter fullscreen mode Exit fullscreen mode

Nhiều API sản xuất chạy mô hình đã lượng tử hóa. Nếu bạn thấy chất lượng đầu ra khác nhau giữa các phiên bản model, lượng tử hóa thường là nguyên nhân.

Bộ nhớ đệm KV (KV cache)

Trong vòng lặp inference ở ví dụ trên, attention được tính lại toàn chuỗi mỗi bước. Hệ thống sản xuất lưu trữ cặp key-value (KV cache) cho các token trước, giúp mỗi token mới chỉ cần tính attention cho phần mới thêm. Đây là lý do token đầu tiên lâu hơn các token tiếp theo khi streaming.

LLM nhỏ gọn vs API sản xuất: Khi nào nên dùng cái nào

Trường hợp sử dụng LLM nhỏ gọn API sản xuất
Tìm hiểu cơ chế bên trong mô hình Tốt nhất Quá mức cần thiết
Tạo mẫu ứng dụng mới Chất lượng không đủ Tốt nhất
Dữ liệu riêng tư/nhạy cảm Lựa chọn tốt Tùy nhà cung cấp
Triển khai ngoại tuyến/tại biên Khả thi Không thể
Chi phí nhạy cảm, khối lượng lớn Có thể nếu đánh đổi Đắt đỏ ở quy mô lớn
Tác vụ đòi hỏi suy luận cao Không khả thi Bắt buộc

Thực tế với đa số developer: dùng API sản xuất cho ứng dụng; dùng LLM nhỏ để học cách nó hoạt động bên dưới. Hai hướng này bổ sung, không loại trừ nhau. Xem thêm ở [internal: open-source-coding-assistants-2026].

Kết luận

Xây dựng một LLM nhỏ gọn từ đầu chỉ mất một cuối tuần. Bạn sẽ không có một hệ thống sản xuất, nhưng sẽ sở hữu tư duy và kiến thức thực tiễn về cách mọi LLM (từ GuppyLM đến GPT-4o) vận hành. Những hiểu biết này cực hữu ích khi gỡ lỗi tích hợp streaming, tối ưu sampling, hoặc thiết kế test case cho kiểm thử API AI.

Hãy thử sao chép dự án GuppyLM, huấn luyện trên một bộ dữ liệu văn bản bất kỳ, đọc kỹ vòng lặp inference. Sau đó, quay lại với các API sản xuất – bạn sẽ nhìn chúng khác đi.

Đừng quên thử Tính năng Kịch bản Kiểm thử (Test Scenarios) của Apidog để nâng cấp kiểm thử API AI của bạn lên mức chặt chẽ như backend thông thường.

Câu hỏi thường gặp

Một LLM "nhỏ gọn" cần bao nhiêu tham số để sinh văn bản mạch lạc?

Khoảng 10-50 triệu tham số với bộ dữ liệu huấn luyện tốt có thể sinh văn bản mạch lạc cục bộ. Dưới 1 triệu, kết quả thường là từ vô nghĩa. GuppyLM (8,7 triệu) đủ cho chat ngắn trong phạm vi huấn luyện (~60 chủ đề).

Có thể chạy LLM nhỏ gọn mà không cần GPU không?

Có. Mô hình dưới 100 triệu tham số chạy tốt trên CPU, chỉ chậm hơn. Mô hình trên (~1,2 triệu) có thể sinh token trong vài ms trên laptop.

Nên huấn luyện trên bộ dữ liệu nào?

Mô hình ký tự hoạt động tốt với Gutenberg, Wikipedia, hoặc kho văn bản thuần tuý. GuppyLM dùng bộ hội thoại 60K trên HuggingFace (arman-bd/guppylm-60k-generic). Với sinh mã, hãy dùng The Stack hoặc CodeParrot.

Sự khác biệt giữa temperature và top-k sampling là gì?

Temperature điều chỉnh phân phối logit (độ ngẫu nhiên tổng thể). Top-k lọc chỉ lấy k token xác suất cao nhất rồi mới áp dụng temperature. Thường dùng kết hợp: top-k lọc ứng cử viên, temperature định hình xác suất.

Tại sao LLM đôi khi lặp lại chính nó?

Đây là chế độ failure: model gán xác suất cao cho token vừa tạo (vì xuất hiện trong ngữ cảnh). API sản xuất dùng hình phạt lặp lại (repetition penalty). Thêm repetition_penalty=1.1 vào lệnh gọi API để giảm lặp lại.

Mất bao lâu để huấn luyện một LLM nhỏ gọn?

Model trên có thể huấn luyện ra đầu ra mạch lạc trong chưa đến 2 giờ trên GPU đơn (RTX 3060 hoặc tương đương). GuppyLM cũng vậy trên Colab. Model lớn hơn (100M+) cần multi-GPU và nhiều ngày huấn luyện.

Cách nhanh nhất đưa LLM nhỏ gọn thành endpoint API thực tế?

Xuất sang định dạng GGUF với script của llama.cpp, sau đó phục vụ qua llama-server. Bạn sẽ có endpoint OpenAI-compatible cục bộ, có thể test với Apidog. Xem [internal: rest-api-best-practices].

LLM sản xuất xử lý ngữ cảnh dài hơn cửa sổ huấn luyện như thế nào?

Dùng kỹ thuật như RoPE (Rotary Position Embedding) mở rộng tỷ lệ, sliding window attention, hoặc retrieval-augmented generation. Kiến trúc transformer không đổi; chỉ thay đổi cách mã hóa vị trí và áp dụng attention.

Top comments (0)