Di tengah ledakan AI generatif, satu pertanyaan paling sering muncul di komunitas developer: bagaimana sebenarnya LLM bekerja? Banyak orang langsung melompat ke API atau library siap pakai seperti PyTorch, tapi hanya sedikit yang benar-benar paham mekanisme di dalamnya. Paul Graham bahkan menyarankan bahwa jika kembali berusia 17 tahun, hal pertama yang akan ia pelajari adalah membangun LLM dari nol.
Tutorial ini akan membawamu membangun sebuah LLM mini — sebuah Generative Pre-trained Transformer (GPT) — sepenuhnya dari nol menggunakan Python dan NumPy, tanpa satu pun framework deep learning. Kamu akan menulis sendiri tokenizer, arsitektur transformer, mekanisme attention, hingga backpropagation manual baris demi baris. Di akhir, modelmu akan benar-benar "belajar" dari korpus teks dan menghasilkan kalimat baru yang menyerupai gaya teks sumbernya.
Ada tiga alasan kuat kenapa cara ini jauh lebih berharga daripada sekadar memanggil model.generate() dari sebuah library:
- Pemahaman mendalam — kamu akan melihat sendiri bagaimana token mengalir melalui embedding, attention, dan lapisan feed-forward hingga menjadi prediksi token berikutnya.
- Kendali penuh — setelah paham setiap bagian, kamu bisa mengubah arsitektur, ukuran model, atau fungsi aktivasi sesukamu tanpa kotak hitam.
- Fondasi yang kuat — konsep yang sama persis dipakai oleh GPT-3, Llama, dan model modern lainnya, hanya berbeda skala dan optimisasi.
Kenapa NumPy, bukan PyTorch? Di PyTorch, gradien dihitung otomatis oleh autograd. Di sini kita menulis backward() secara manual, sehingga kamu benar-benar memahami bagaimana sebuah model belajar — bukan sekadar tahu bahwa ia belajar.
Yang kamu butuhkan sangat minim:
- Python 3.9 atau lebih baru (tanpa GPU — cukup CPU laptop).
- NumPy (satu-satunya dependensi).
- Pemahaman dasar Python: list, dict, loop, dan fungsi.
- Sedikit pengetahuan aljabar linear (perkalian matriks) akan sangat membantu, tapi tidak wajib.
pip install numpy
Simpan seluruh kode di tutorial ini ke dalam satu file bernama nanollm.py dalam urutan yang sama. Kamu bisa menyalin setiap blok kode secara berurutan, lalu menjalankan file tersebut di akhir tutorial.
LLM tidak membaca huruf, melainkan token (bilangan bulat). Kita mulai dengan tokenizer paling sederhana: tokenizer karakter, di mana setiap karakter unik dipetakan ke satu bilangan bulat. Ini kurang efisien dibanding Byte-Pair Encoding (BPE) yang dipakai GPT modern, tapi sempurna untuk memahami konsep dasarnya.
Kita akan memakai korpus tiny-shakespeare (sekitar 1 juta karakter teks Shakespeare) yang otomatis diunduh jika belum ada. Kamu bebas menggantinya dengan file input.txt apa pun — misalnya kumpulan artikel berbahasa Indonesia.
import os
import math
import urllib.request
import numpy as np
# --- Hyperparameter (cukup kecil untuk dijalankan di CPU laptop) ---
block_size = 64 # panjang konteks: jumlah token yang dilihat model sekaligus
n_embd = 64 # dimensi embedding
n_head = 4 # jumlah kepala (head) attention
n_layer = 3 # jumlah blok transformer
batch_size = 32 # jumlah sekuens per langkah training
max_steps = 1500 # jumlah iterasi training
learning_rate = 3e-3
rng = np.random.default_rng(1337)
def load_text(max_chars=None):
path = "input.txt"
if not os.path.exists(path):
url = ("https://raw.githubusercontent.com/karpathy/char-rnn/"
"master/data/tinyshakespeare/input.txt")
urllib.request.urlretrieve(url, path)
with open(path, encoding="utf-8") as f:
text = f.read()
return text[:max_chars] if max_chars else text
text = load_text()
chars = sorted(list(set(text)))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)} # string -> index
itos = {i: ch for i, ch in enumerate(chars)} # index -> string
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: "".join(itos[i] for i in ids)
data = np.array(encode(text), dtype=np.int32)
def get_batch():
"""Ambil satu batch pasangan (input, target). Target = input digeser 1."""
ix = rng.integers(0, len(data) - block_size - 1, size=batch_size)
x = np.stack([data[i:i + block_size] for i in ix])
y = np.stack([data[i + 1:i + block_size + 1] for i in ix])
return x, y
Perhatikan konsep kuncinya: tugas model adalah memprediksi token berikutnya. Jadi untuk setiap potongan x sepanjang block_size, target y adalah potongan yang sama tapi digeser satu posisi ke kanan. Inilah self-supervised learning — tidak perlu label manual, korpus menyediakan labelnya sendiri.
Sebelum merakit transformer, kita siapkan tiga fungsi dasar beserta turunannya (karena kita menulis backpropagation manual):
- Softmax — mengubah vektor skor menjadi distribusi probabilitas.
- GELU — fungsi aktivasi non-linear yang dipakai GPT modern.
- LayerNorm — menormalkan aktivasi agar training stabil.
def softmax(x):
e = np.exp(x - x.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def gelu(x):
a = math.sqrt(2.0 / math.pi)
return 0.5 * x * (1.0 + np.tanh(a * (x + 0.044715 * x ** 3)))
def gelu_backward(x, dout):
a = math.sqrt(2.0 / math.pi)
inner = a * (x + 0.044715 * x ** 3)
tanh = np.tanh(inner)
d = 0.5 * (1.0 + tanh) + 0.5 * x * (1.0 - tanh ** 2) * a * (1.0 + 3 * 0.044715 * x ** 2)
return d * dout
def layernorm(x, g, b, eps=1e-5):
mu = x.mean(axis=-1, keepdims=True)
var = x.var(axis=-1, keepdims=True)
xh = (x - mu) / np.sqrt(var + eps)
return g * xh + b, (x, mu, var, xh)
def layernorm_backward(dout, cache, g, eps=1e-5):
x, mu, var, xh = cache
dg = (dout * xh).sum(axis=(0, 1))
db = dout.sum(axis=(0, 1))
dxh = dout * g
inv_std = 1.0 / np.sqrt(var + eps)
dx = inv_std * (dxh - dxh.mean(axis=-1, keepdims=True)
- xh * (dxh * xh).mean(axis=-1, keepdims=True))
return dx, dg, db
def init_params():
"""Inisialisasi semua parameter model (embedding, attention, MLP, head)."""
p = {}
scale = 0.02
p["wte"] = rng.normal(0, scale, (vocab_size, n_embd)) # token embedding
p["wpe"] = rng.normal(0, scale, (block_size, n_embd)) # position embedding
for l in range(n_layer):
p[f"ln1_g{l}"] = np.ones(n_embd)
p[f"ln1_b{l}"] = np.zeros(n_embd)
p[f"ln2_g{l}"] = np.ones(n_embd)
p[f"ln2_b{l}"] = np.zeros(n_embd)
p[f"wq{l}"] = rng.normal(0, scale, (n_embd, n_embd))
p[f"wk{l}"] = rng.normal(0, scale, (n_embd, n_embd))
p[f"wv{l}"] = rng.normal(0, scale, (n_embd, n_embd))
p[f"wo{l}"] = rng.normal(0, scale, (n_embd, n_embd)) # proyeksi attention
p[f"wfc{l}"] = rng.normal(0, scale, (n_embd, 4 * n_embd)) # MLP naik
p[f"wfc2{l}"] = rng.normal(0, scale, (4 * n_embd, n_embd)) # MLP turun
p["lnf_g"] = np.ones(n_embd)
p["lnf_b"] = np.zeros(n_embd)
p["lm_head"] = rng.normal(0, scale, (n_embd, vocab_size)) # kepala output
return p
Fungsi layernorm mengembalikan tuple yang berisi output dan cache; cache ini menyimpan nilai antara yang akan dipakai lagi saat backward pass. Pola simpan-saat-forward, pakai-saat-backward ini adalah jantung dari setiap implementasi backpropagation.
Sebuah blok transformer GPT terdiri dari dua bagian besar: multi-head causal self-attention dan feed-forward network (MLP), keduanya dibungkus dengan residual connection dan pre-LayerNorm — persis arsitektur GPT-2 yang dipopulerkan oleh nanoGPT dari Andrej Karpathy.
Pada attention, setiap token menghasilkan query (q), key (k), dan value (v). Skor perhatian dihitung dari q dan k, lalu digunakan untuk menimbang v. Mask kausal (np.triu) memastikan token hanya boleh "melihat" token di posisi sebelumnya — tidak boleh mengintip masa depan.
def forward(params, x):
B, T = x.shape
hs = n_embd // n_head
cache = {"x": x, "T": T, "B": B, "hs": hs}
emb = params["wte"][x] + params["wpe"][:T] # (B,T,C)
cache["emb"] = emb
h = emb
for l in range(n_layer):
# --- attention dengan pre-LayerNorm ---
xn, ln1c = layernorm(h, params[f"ln1_g{l}"], params[f"ln1_b{l}"])
q = xn @ params[f"wq{l}"]
k = xn @ params[f"wk{l}"]
v = xn @ params[f"wv{l}"]
# ubah ke bentuk multi-head: (B,T,C) -> (B,nh,T,hs)
q = q.reshape(B, T, n_head, hs).transpose(0, 2, 1, 3)
k = k.reshape(B, T, n_head, hs).transpose(0, 2, 1, 3)
v = v.reshape(B, T, n_head, hs).transpose(0, 2, 1, 3)
scores = np.einsum("bhtd,bhsd->bhts", q, k) / math.sqrt(hs)
mask = np.triu(np.ones((T, T), dtype=bool), k=1)
scores = np.where(mask[None, None, :, :], -np.inf, scores)
att = softmax(scores) # (B,nh,T,T)
out = np.einsum("bhts,bhsd->bhtd", att, v) # (B,nh,T,hs)
out = out.transpose(0, 2, 1, 3).reshape(B, T, n_embd)
h = h + out @ params[f"wo{l}"] # residual
# --- MLP dengan pre-LayerNorm ---
mn, ln2c = layernorm(h, params[f"ln2_g{l}"], params[f"ln2_b{l}"])
up = mn @ params[f"wfc{l}"]
g = gelu(up)
mlp = g @ params[f"wfc2{l}"]
h = h + mlp # residual
cache[f"l{l}"] = (xn, ln1c, q, k, v, scores, att, out, mn, ln2c, up, g)
h, lnfc = layernorm(h, params["lnf_g"], params["lnf_b"])
logits = h @ params["lm_head"] # (B,T,vocab)
cache["lnfc"] = lnfc
cache["final_h"] = h
return logits, cache
def cross_entropy(logits, y):
"""Rata-rata cross-entropy untuk semua posisi (B,T)."""
B, T, V = logits.shape
probs = softmax(logits)
idx = (np.arange(B)[:, None], np.arange(T)[None, :], y)
loss = -np.log(probs[idx] + 1e-12).mean()
dlogits = probs.copy()
dlogits[idx] -= 1.0
dlogits /= (B * T)
return loss, dlogits
Output forward adalah logits — skor mentah untuk setiap token di vocabulary pada setiap posisi. Fungsi cross_entropy membandingkannya dengan token target dan mengembalikan loss (yang ingin kita kecilkan) sekaligus gradien dlogits sebagai titik awal backward pass.
Inilah bagian yang biasanya "disembunyikan" oleh framework. Backward pass mengalirkan gradien dari loss ke seluruh parameter dengan chain rule, melintasi setiap operasi dalam urutan terbalik. Setiap operasi di forward punya pasangan gradiennya: perkalian matriks, softmax, GELU, dan LayerNorm. Kamu bisa membuktikan kebenarannya dengan gradient checking (membandingkan gradien analitik dengan selisih hingga).
def backward(params, grads, cache, dlogits):
B, T, hs = cache["B"], cache["T"], cache["hs"]
dh = dlogits @ params["lm_head"].T # (B,T,C)
grads["lm_head"] += cache["final_h"].reshape(B * T, n_embd).T @ dlogits.reshape(B * T, vocab_size)
dh, grads["lnf_g"], grads["lnf_b"] = layernorm_backward(dh, cache["lnfc"], params["lnf_g"])
for l in reversed(range(n_layer)):
xn, ln1c, q, k, v, scores, att, out, mn, ln2c, up, g = cache[f"l{l}"]
# --- backward MLP ---
dmlp = dh
dh_mid = dh # residual: h_out = h_mid + mlp
dg = dmlp @ params[f"wfc2{l}"].T # (B,T,4C)
grads[f"wfc2{l}"] += g.reshape(B * T, 4 * n_embd).T @ dmlp.reshape(B * T, n_embd)
dup = gelu_backward(up, dg)
dmn = dup @ params[f"wfc{l}"].T # (B,T,C)
grads[f"wfc{l}"] += mn.reshape(B * T, n_embd).T @ dup.reshape(B * T, 4 * n_embd)
dx_ln2, dg2, db2 = layernorm_backward(dmn, ln2c, params[f"ln2_g{l}"])
grads[f"ln2_g{l}"] += dg2
grads[f"ln2_b{l}"] += db2
dh_mid += dx_ln2
# --- backward attention ---
dattn_out = dh_mid
dh_in = dh_mid # residual: h_mid = h_in + attn_out
dout = dattn_out @ params[f"wo{l}"].T
grads[f"wo{l}"] += out.reshape(B * T, n_embd).T @ dattn_out.reshape(B * T, n_embd)
dout = dout.reshape(B, T, n_head, hs).transpose(0, 2, 1, 3)
dv = np.einsum("bhts,bhtd->bhsd", att, dout)
datt = np.einsum("bhtd,bhsd->bhts", dout, v)
dscores = att * (datt - (att * datt).sum(axis=-1, keepdims=True))
dscores = dscores / math.sqrt(hs) # batalkan penskalaan 1/sqrt(hs)
dq = np.einsum("bhts,bhsd->bhtd", dscores, k)
dk = np.einsum("bhts,bhtd->bhsd", dscores, q)
dq = dq.transpose(0, 2, 1, 3).reshape(B, T, n_embd)
dk = dk.transpose(0, 2, 1, 3).reshape(B, T, n_embd)
dv = dv.transpose(0, 2, 1, 3).reshape(B, T, n_embd)
grads[f"wq{l}"] += xn.reshape(B * T, n_embd).T @ dq.reshape(B * T, n_embd)
grads[f"wk{l}"] += xn.reshape(B * T, n_embd).T @ dk.reshape(B * T, n_embd)
grads[f"wv{l}"] += xn.reshape(B * T, n_embd).T @ dv.reshape(B * T, n_embd)
dxn = dq @ params[f"wq{l}"].T + dk @ params[f"wk{l}"].T + dv @ params[f"wv{l}"].T
dx_ln1, dg1, db1 = layernorm_backward(dxn, ln1c, params[f"ln1_g{l}"])
grads[f"ln1_g{l}"] += dg1
grads[f"ln1_b{l}"] += db1
dh_in += dx_ln1
dh = dh_in
# --- backward embedding ---
demb = dh
grads["wpe"][:cache["T"]] += demb.sum(axis=0)
np.add.at(grads["wte"], cache["x"], demb)
return grads
Perhatikan pola pada attention: gradien untuk q, k, dan v dihitung dengan np.einsum yang persis mencerminkan operasi forward, hanya dengan sumbu yang ditukar. Itulah inti chain rule untuk perkalian matriks: gradien terhadap satu faktor adalah gradien output dikalikan transpose faktor lainnya.
Setelah forward dan backward siap, kita merakit loop training dengan optimizer Adam — optimizer standar yang mengadaptasi learning rate per parameter. Kita juga menulis fungsi generate yang menyusun teks token demi token menggunakan prediksi model sendiri (autoregresif).
def train(params, max_steps=max_steps):
m = {k: np.zeros_like(v) for k, v in params.items()}
v = {k: np.zeros_like(v) for k, v in params.items()}
b1, b2, eps = 0.9, 0.999, 1e-8
for t in range(max_steps):
xb, yb = get_batch()
logits, cache = forward(params, xb)
loss, dlogits = cross_entropy(logits, yb)
grads = {k: np.zeros_like(v) for k, v in params.items()}
backward(params, grads, cache, dlogits)
for k in params:
g = grads[k]
m[k] = b1 * m[k] + (1 - b1) * g
v[k] = b2 * v[k] + (1 - b2) * g * g
mhat = m[k] / (1 - b1 ** (t + 1))
vhat = v[k] / (1 - b2 ** (t + 1))
params[k] -= learning_rate * mhat / (np.sqrt(vhat) + eps)
if t % 100 == 0 or t == max_steps - 1:
print(f"step {t:5d} loss {loss:.4f}")
return params
def generate(params, idx, max_new_tokens, temperature=1.0):
"""Hasilkan teks baru token demi token secara autoregresif."""
for _ in range(max_new_tokens):
idx_cond = idx[:, -block_size:]
logits, _ = forward(params, idx_cond)
logits = logits[:, -1, :] / temperature
probs = softmax(logits)
nxt = rng.choice(vocab_size, p=probs[0])
idx = np.concatenate([idx, np.array([[nxt]])], axis=1)
return idx
if __name__ == "__main__":
print(f"corpus: {len(text)} chars, vocab_size={vocab_size}")
params = init_params()
train(params)
prompt = encode("\n")
out = generate(params, np.array([prompt]), 300)
print("\n----- hasil generate -----")
print(decode(out[0].tolist()))
Soal temperature: nilai temperature yang lebih rendah (mis. 0.7–0.9) menghasilkan teks yang lebih "aman" dan koheren, sedangkan nilai lebih tinggi (mis. 1.2) membuat output lebih acak dan kreatif. Coba ubah-ubah dan bandingkan hasilnya.
Jalankan file-nya:
python nanollm.py
Kamu akan melihat loss turun dari sekitar 4.2 (tebakan acak, karena ln dari 65 token ≈ 4.17) menuju angka yang jauh lebih kecil dalam beberapa ratus langkah. Itu tanda model benar-benar sedang belajar pola dalam teks. Setelah training selesai, bagian generate akan mencetak sampel teks — awalnya masih kacau, tapi seiring training ia mulai membentuk kata dan struktur kalimat yang menyerupai gaya Shakespeare.
Loss tidak turun atau muncul NaN? Kemungkinan besar learning rate terlalu besar. Turunkan learning_rate ke 1e-3 atau lebih kecil, lalu jalankan ulang. Model kecil seperti ini cukup sensitif terhadap learning rate.
Selamat — kamu baru saja membangun sebuah GPT dari nol: tokenizer, embedding, multi-head causal attention, MLP, residual connection, LayerNorm, hingga backpropagation manual dan optimizer Adam, semuanya murni dengan NumPy. Kode ini hanya sekitar 200 baris, tapi memuat inti dari model bahasa modern berskala miliaran parameter.
Beberapa arah untuk terus berkembang:
- Ganti tokenizer karakter dengan Byte-Pair Encoding (BPE) agar lebih efisien.
- Perbesar
n_layer,n_embd, dan korpus untuk hasil yang lebih baik. - Terapkan ulang arsitektur yang sama di PyTorch dan bandingkan kecepatannya.
- Muat bobot GPT-2 asli dan lihat perbedaannya dengan model buatanmu.
- Pelajari referensi klasik: nanoGPT (Karpathy) dan Let's build GPT.
Memahami sesuatu dari dalam selalu lebih memuaskan daripada memakai blackbox. Selamat bereksperimen, dan semoga tutorial ini jadi batu loncatanmu ke dunia AI generatif! 🚀
BengkelKode