attention & transformer — ให้ token คุยกัน แล้วต่างคนต่างคิด
บท6 เรา graduate จากเขียน autograd ด้วยมือ มาเป็น PyTorch — MLP ของบท4 กลายเป็น nn.Module สั้นๆ ที่ loss.backward() เองได้ แต่ MLP นั้นยังมีจุดอ่อนใหญ่ข้อหนึ่ง: มันมอง context เป็น “ก้อนแบนๆ” — เอา embedding ของ 3 ตัวอักษรมาต่อกันแล้วยัดเข้า Linear ชั้นเดียว มันไม่มีกลไกให้ token แต่ละตัว เลือก ว่าจะสนใจตัวไหนในอดีตเป็นพิเศษ ทุก token ถูกปฏิบัติเท่ากันหมดตามตำแหน่งตายตัว
บทนี้คือหัวใจของทั้งคอร์ส เราจะสร้างกลไกที่แก้จุดอ่อนนั้น — Self-AttentionSelf-Attentionกลไกให้แต่ละ token "มองย้อน" ไป token อื่นแล้วชั่งน้ำหนักว่าอันไหนสำคัญ (Vaswani 2017)Architecture — แล้วประกอบมันขึ้นเป็น TransformerTransformerสถาปัตยกรรมที่ซ้อน self-attention เป็นชั้น ๆ — โครงที่ LLM จริงใช้Architecture ตัวจิ๋ว ซึ่งเป็นโครงเดียวกับที่ LLM จริงหลัง API ใช้ ไอเดียหลักพูดเป็นประโยคเดียวได้ว่า: ให้แต่ละ token “มองย้อน” ไป token ก่อนหน้าทั้งหมด แล้วชั่งน้ำหนักเองว่าอันไหนสำคัญกับมันตอนนี้ — token คำว่า “กิน” อาจอยากดึงข้อมูลจากคำว่า “ฉัน” ที่อยู่ห่างไป 5 ตำแหน่งมากกว่าคำที่ติดกัน attention คือกลไกที่ทำให้ “การเลือกดึงข้อมูลข้ามระยะ” นั้นเรียนรู้ได้ด้วย gradient descent
code บทนี้อยู่ใน repo kaen-nn-from-scratch (code ตัวอย่างกำลังจัดทำ) — บทนี้ใช้ PyTorch (pin torch==2.13.0, ดึงเมื่อ 2026-07-23 · ⚠️ ตรวจ pypi ก่อน rebuild ทุกครั้ง) ต่อจากบท6 โดยมิเรอร์ gpt.py ตัวอ่านง่ายจาก ng-video-lecture ของ Karpathy (ตรงกับต้นฉบับ ไม่ใช่ deviate แบบบท3–5) code ในบทนี้เป็น “ชิ้นส่วน” — Head → MultiHeadAttention → Block → การวางสายทั้ง model — ส่วน loop เทรน + generate() ที่รันจริงบน CPU อยู่ในบท8 (capstone)
จุดเริ่ม: self-attention หัวเดียว (single causal Head)
หัวข้อที่มีชื่อว่า “จุดเริ่ม: self-attention หัวเดียว (single causal Head)”เริ่มจากหน่วยเล็กที่สุด — attention หัวเดียว จาก input ตัวเดียว x ที่มี shape (B, T, C) คือ (batch, time = จำนวน token ในลำดับ, channels = n_embd) แต่ละ token จะยิง projection สามเส้นออกมาจาก x ก้อนเดียวกัน:
- query (q) — “ฉันกำลังมองหาอะไร”
- key (k) — “ฉันมีอะไรให้”
- value (v) — “ถ้าเลือกฉัน จะได้ข้อมูลนี้ไป”
หัวใจคือ: เอา query ของทุก token ไป “จิ้ม” (dot product) กับ key ของทุก token ได้ตาราง affinity (T, T) = คะแนนว่า token คู่ไหนเข้ากันแค่ไหน แล้วเอาคะแนนนั้นไปถ่วงน้ำหนัก value เพื่อรวมข้อมูลกลับมา
import torchimport torch.nn as nnfrom torch.nn import functional as F
# hyperparameters — ค่าเต็มของ config อยู่ในบท8; ที่นี่ตั้งพอให้ code มีความหมายn_embd = 128 # มิติเวกเตอร์ต่อ tokenblock_size = 64 # context ยาวสุดที่มองย้อนได้n_head = 4dropout = 0.0
class Head(nn.Module): """self-attention หัวเดียว แบบ causal (มองย้อนได้อย่างเดียว)""" def __init__(self, head_size): super().__init__() self.key = nn.Linear(n_embd, head_size, bias=False) self.query = nn.Linear(n_embd, head_size, bias=False) self.value = nn.Linear(n_embd, head_size, bias=False) # tril เป็น BUFFER ไม่ใช่ parameter — ไม่มี gradient แต่ย้ายไป device # และถูกบันทึกใน state_dict ไปพร้อม model self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
def forward(self, x): B, T, C = x.shape # (batch, time, channels = n_embd) k = self.key(x) # (B, T, head_size) q = self.query(x) # (B, T, head_size) # affinity: query ทุกตัวจิ้มกับ key ทุกตัว -> คะแนนความเข้ากัน (B, T, T) # transpose(-2, -1) สลับ 'สองแกนท้ายเท่านั้น' (T กับ head_size) ไม่ยุ่งแกน batch wei = q @ k.transpose(-2, -1) * k.shape[-1]**-0.5 # สเกลด้วย √head_size # causal mask: กลบอนาคตเป็น -inf *ก่อน* softmax wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf')) # (B, T, T) wei = F.softmax(wei, dim=-1) # normalize over keys -> แต่ละแถวรวมได้ 1 v = self.value(x) # (B, T, head_size) return wei @ v # (B, T, head_size) ถัวเฉลี่ย value ตามน้ำหนักcode 7 บรรทัดใน forward นี้คือทั้งหมดของ self-attention — แต่มีกับดักที่ ฆ่า model เงียบๆ ซ่อนอยู่หลายจุด มาไล่ทีละอัน
กับดัก #1 (ตัวฆ่าอันดับหนึ่ง): mask ต้องมา ก่อน softmax และต้องเป็น tril
หัวข้อที่มีชื่อว่า “กับดัก #1 (ตัวฆ่าอันดับหนึ่ง): mask ต้องมา ก่อน softmax และต้องเป็น tril”นี่เป็น model ภาษาที่ทาย “token ถัดไป” ตอนเทรน token ตำแหน่งที่ 3 ต้องทายตำแหน่งที่ 4 โดย ห้ามเห็นตำแหน่งที่ 4 เป็นต้นไป ถ้ามันแอบเห็นอนาคตได้ งานทายก็กลายเป็น “ลอกคำตอบ” — train loss จะร่วงเข้าใกล้ 0 อย่างสวยงาม แต่พอเอาไป generate จริง (ตอนนั้น ไม่มี อนาคตให้ลอก) มันคายขยะออกมา นี่คือ bug ที่เจ็บที่สุดเพราะ loss ดู “ดีเกินจริง” จนคุณไม่เอะใจ
# ❌ version ดิบที่พังเงียบ ๆ — สองแบบที่รั่วอนาคต# แบบ A: mask ผิดด้าน (ใช้ triu แทน tril) -> เปิดครึ่งบน = เห็นอนาคตtriu = torch.triu(torch.ones(block_size, block_size))wei = wei.masked_fill(triu[:T, :T] == 0, float('-inf'))# แบบ B: mask 'หลัง' softmax -> softmax เฉลี่ยรวมอนาคตไปแล้ว น้ำหนักที่เหลือไม่รวมเป็น 1 อีกwei = F.softmax(wei, dim=-1)wei = wei.masked_fill(self.tril[:T, :T] == 0, 0.0)ท่าที่ถูกคือ: ใส่ -inf ที่ตำแหน่งอนาคต ก่อน แล้วค่อย softmax — เพราะ exp(-inf) = 0 พอดี softmax จะดันน้ำหนักอนาคตเป็น 0 เป๊ะ แล้ว renormalize เฉพาะอดีตให้รวมได้ 1 ใช้ torch.tril (เก็บสามเหลี่ยมล่าง + เส้นทแยง = อดีต + ตัวเอง)
กับดัก #2: dim=-1 เท่านั้น
หัวข้อที่มีชื่อว่า “กับดัก #2: dim=-1 เท่านั้น”F.softmax(wei, dim=-1) normalize ตามแกนสุดท้าย = แกน “keys” แปลว่าน้ำหนักของแต่ละ query รวมได้ 1 (แต่ละแถวของตาราง (T, T)) ถ้าเผลอใส่ dim=-2 จะไป normalize ผิดแกน — น้ำหนักที่ query แต่ละตัวแจกให้ key จะไม่ใช่การกระจายความน่าจะเป็นอีกต่อไป model เทรนไม่ลง
กับดัก #3: สเกลด้วย √head_size ไม่ใช่ √n_embd
หัวข้อที่มีชื่อว่า “กับดัก #3: สเกลด้วย √head_size ไม่ใช่ √n_embd”ทำไมต้องคูณ k.shape[-1]**-0.5 (= 1/√head_size)? ลองคิดแบบสัญชาตญาณ: q กับ k เริ่มมาแบบ variance ≈ 1 ต่อช่อง เมื่อจิ้ม dot product กัน head_size ช่อง ผลรวมจะมี variance ≈ head_size (ผลบวกของตัวแปรอิสระ variance บวกกัน) ยิ่ง head_size ใหญ่ คะแนนดิบยิ่งกระจายกว้าง — พอเข้า softmax คะแนนที่โดดออกมาจะถูกดันเป็น “แหลม” เกือบ one-hot ตั้งแต่ก้าวแรก token จะจ้องตัวเดียวแข็งๆ และ gradient แทบหาย การหารด้วย √head_size ดึง variance กลับมา ~1 ให้ softmax “นุ่ม” พอที่จะเรียนรู้ได้
“เราสอน ‘สัญชาตญาณ’ ของ gradient กับ chain rule พอให้ code มีความหมาย ไม่ใช่พิสูจน์ทฤษฎีบท — อยากลึกเชิงพิสูจน์ เปิด Goodfellow บทที่อ้างไว้; คณิตตรงนี้เป็นพื้นให้ยืน ไม่ใช่กำแพงกั้นทาง”
แปลว่า: ข้อความ “variance ≈ head_size” ข้างบนไม่ใช่ตัวเลขที่ผมกุ — มันคือเหตุผลที่เปเปอร์ Attention Is All You Need (Vaswani et al., 2017) ให้ไว้เป๊ะใน §3.2.1 เชิงอรรถ 4: dot product ของเวกเตอร์ unit-variance สองตัวบนมิติ d_k มี variance เท่ากับ d_k เขาจึงหารด้วย √d_k อยากได้ความเข้มงวดเชิงพิสูจน์ ก็เปิดเปเปอร์ตรงนั้น — และย้ำว่าสเกลนี้คิดจากมิติของ หัวนั้นเอง (head_size, per-head) ไม่ใช่ n_embd รวมทั้ง model
กับดัก #4: tril เป็น buffer ไม่ใช่ parameter
หัวข้อที่มีชื่อว่า “กับดัก #4: tril เป็น buffer ไม่ใช่ parameter”tril คือ mask คงที่ ไม่มีอะไรให้เรียนรู้ — จึงลงทะเบียนด้วย register_buffer ไม่ใช่ nn.Parameter ความต่างสำคัญ: buffer ไม่มี gradient (backward() ไม่แตะ) แต่ยัง “เดินทาง” ไปกับ model (ย้าย .to(device) และบันทึกใน state_dict) และเรา slice self.tril[:T, :T] เสมอ เพราะตอน generate context จะค่อยๆ ยาวขึ้นจาก 1 จน block_size — เราต้องตัด mask ให้พอดีกับ T ปัจจุบัน
จากหัวเดียว → หลายหัว (multi-head attention)
หัวข้อที่มีชื่อว่า “จากหัวเดียว → หลายหัว (multi-head attention)”หัวเดียวเรียนความสัมพันธ์ได้ แบบเดียว ในทางปฏิบัติเราอยากให้ model มองหลายมุมพร้อมกัน — หัวหนึ่งอาจตามเรื่องไวยากรณ์ อีกหัวตามหัวข้อระยะไกล (นี่เป็นสัญชาตญาณ ไม่ใช่กฎตายตัว) วิธีคือรันหลาย Head ขนานกัน แล้วเอาผลมา concat ตามแกน channel จากนั้น project กลับเป็น n_embd
class MultiHeadAttention(nn.Module): """หลาย Head ทำงานขนานกัน แล้ว concat + project กลับเป็น n_embd""" def __init__(self, num_heads, head_size): super().__init__() self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)]) self.proj = nn.Linear(num_heads * head_size, n_embd) self.dropout = nn.Dropout(dropout)
def forward(self, x): out = torch.cat([h(x) for h in self.heads], dim=-1) # concat ตามแกน channel return self.dropout(self.proj(out)) # (B, T, n_embd)เคล็ดของขนาด: เราตั้ง head_size = n_embd // n_head เสมอ ดังนั้น num_heads * head_size = n_embd พอดี — concat หลายหัวกลับมาได้มิติเท่าเดิม ไม่บวมขึ้น
Block: “คุยกัน” แล้ว “ต่างคนต่างคิด”
หัวข้อที่มีชื่อว่า “Block: “คุยกัน” แล้ว “ต่างคนต่างคิด””หนึ่งชั้นของ transformer (Block) ประกอบจากสองงานที่สลับกัน:
- สื่อสาร (communication) = self-attention — token คุยกัน แลกข้อมูลข้ามตำแหน่ง
- คำนวณ (computation) = feed-forward MLP — หลังคุยเสร็จ แต่ละ token คิดเอง แยกกันทีละตำแหน่ง
feed-forward เป็น MLP ที่ “ขยาย 4 เท่าแล้วหดกลับ” (n_embd → 4·n_embd → n_embd) — ตัวเลข 4× มาจากเปเปอร์ Vaswani โดยตรง (d_ff = 4·d_model) มันคือพื้นที่ให้แต่ละ token ประมวลผลสิ่งที่เพิ่งรวบรวมมา
class FeedForward(nn.Module): """MLP ต่อ token: ขยาย 4× แล้วหดกลับ — ส่วน 'คิดเอง' ของแต่ละ token""" def __init__(self, n_embd): super().__init__() self.net = nn.Sequential( nn.Linear(n_embd, 4 * n_embd), # กว้าง 4× (ตามเปเปอร์ Vaswani) nn.ReLU(), nn.Linear(4 * n_embd, n_embd), nn.Dropout(dropout), )
def forward(self, x): return self.net(x)
class Block(nn.Module): """หนึ่งชั้น transformer: attention (คุยกัน) + feed-forward (คิดเอง)""" def __init__(self, n_embd, n_head): super().__init__() head_size = n_embd // n_head self.sa = MultiHeadAttention(n_head, head_size) self.ffwd = FeedForward(n_embd) self.ln1 = nn.LayerNorm(n_embd) self.ln2 = nn.LayerNorm(n_embd)
def forward(self, x): x = x + self.sa(self.ln1(x)) # pre-norm + residual: normalize ก่อน แล้วบวกทางลัดกลับ x = x + self.ffwd(self.ln2(x)) return xสังเกตสองอย่างในforward:
- residual (
x = x + ...) — บวกทางลัดของ input กลับเข้าไป ทำให้ gradient ไหลย้อนได้ตรงๆ ผ่านหลายชั้นโดยไม่จางหาย (เป็นเหตุผลหลักที่ transformer เทรนลึกๆ ได้) - pre-norm (
sa(self.ln1(x))) — normalize ก่อน เข้า sublayer แล้วบวก residual รอบนอก จำท่านี้ไว้ เดี๋ยวเราจะเทียบกับเปเปอร์ปี 2017 ที่ทำ ตรงข้าม
flowchart TB
x["x เข้า Block<br/>(B, T, n_embd)"]
subgraph COMM["สื่อสาร — token คุยกัน (self-attention)"]
ln1["LayerNorm (ln1)"]
sa["Multi-Head Attention<br/>causal: มองย้อนได้อย่างเดียว"]
end
add1(("+"))
subgraph COMP["คำนวณ — แต่ละ token คิดเอง (MLP 4×)"]
ln2["LayerNorm (ln2)"]
ff["FeedForward<br/>Linear 4× → ReLU → Linear"]
end
add2(("+"))
out["x ออก Block<br/>(มิติเท่าเดิม)"]
x --> ln1 --> sa --> add1
x -->|"residual (ทางลัด)"| add1
add1 --> ln2 --> ff --> add2
add1 -->|"residual (ทางลัด)"| add2
add2 --> out
classDef base fill:#fde68a,stroke:#92400e,color:#451a03;
classDef accent fill:#7c2d12,stroke:#431407,color:#fed7aa;
class x,out,ln1,ln2,sa,ff base;
class add1,add2 accent;
คำบรรยายภาพ: 1 Block = 2 sublayer ที่ต่อกันแบบ pre-norm + residual ครึ่งแรกคือ “การสื่อสาร” (attention: token มองย้อนแล้วชั่งน้ำหนักกันเอง) ครึ่งหลังคือ “การคำนวณ” (แต่ละ token คิดเองผ่าน MLP ที่กว้าง 4 เท่า) LayerNorm วางอยู่ ก่อน แต่ละ sublayer (pre-norm) และเส้น ”+” (residual) พาค่า input อ้อมไปบวกปลายทางเสมอ เพื่อให้ gradient ไหลย้อนได้ไม่จาง — นี่คือรูปทรงสมัยใหม่ ซึ่งต่างจากไดอะแกรมปี 2017 ที่วาง norm ไว้หลัง sublayer
วางสายทั้งกอง: จาก token ไปถึง logits
หัวข้อที่มีชื่อว่า “วางสายทั้งกอง: จาก token ไปถึง logits”ประกอบทุกชิ้นเป็น model เต็ม ลำดับคือ token embedding + position embedding → หลาย Block → LayerNorm ปิดท้าย → lm_head Positional EncodingPositional Encodingตัวบอกลำดับของ token ให้ transformer รู้ว่าอะไรมาก่อนหลัง (ไม่มีมัน = ถุงคำไร้ลำดับ)Architecture เข้ามาตรงนี้ — เราต้องบอก model ว่า token ไหนมาก่อนหลัง
vocab_size = 65 # ตัวอย่าง tinyshakespeare (char-level); ตัวจริงคำนวณจากข้อมูลในบท8n_layer = 4
class GPTLanguageModel(nn.Module): def __init__(self): super().__init__() self.token_embedding_table = nn.Embedding(vocab_size, n_embd) # wte self.position_embedding_table = nn.Embedding(block_size, n_embd) # wpe (learned!) self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)]) self.ln_f = nn.LayerNorm(n_embd) # LayerNorm ปิดท้ายก่อน head self.lm_head = nn.Linear(n_embd, vocab_size) # แปลงกลับเป็น logits ต่อ vocab
def forward(self, idx, targets=None): B, T = idx.shape tok_emb = self.token_embedding_table(idx) # (B, T, n_embd) pos_emb = self.position_embedding_table(torch.arange(T, device=idx.device)) # (T, n_embd) x = tok_emb + pos_emb # *บวก* ตำแหน่งเข้าไป ไม่ใช่ concat (broadcast (T,C) ทุก batch) x = self.blocks(x) # (B, T, n_embd) x = self.ln_f(x) logits = self.lm_head(x) # (B, T, vocab_size) — RAW logits if targets is None: return logits B, T, C = logits.shape loss = F.cross_entropy(logits.view(B * T, C), targets.view(B * T)) return logits, lossตำแหน่งต้อง “บวก” ไม่ใช่ “ต่อท้าย”
หัวข้อที่มีชื่อว่า “ตำแหน่งต้อง “บวก” ไม่ใช่ “ต่อท้าย””จุดนี้เป็น invariant สำคัญ: x = tok_emb + pos_emb attention เพียวๆ นั้น permutation-invariant — สลับลำดับ token แล้วผลไม่เปลี่ยน เพราะมันมองทุกตำแหน่งเป็น “ถุงคำ” ไร้ลำดับ ถ้าไม่ฉีดข้อมูลตำแหน่งเข้าไป model จะแยก “หมากัดคน” กับ “คนกัดหมา” ไม่ออก เราจึงใส่ nn.Embedding(block_size, n_embd) = ตารางเวกเตอร์ประจำ “slot ที่ 0, 1, 2, …” แล้ว บวกเข้ากับ token embedding
ทำไมบวกไม่ต่อท้าย (concat)? บวกคงมิติไว้ที่ n_embd เท่าเดิม — ทุก matmul ข้างในไม่ต้องโตตาม model เรียนเองว่าจะ “แบ่งพื้นที่” ในเวกเตอร์ให้ข้อมูลตำแหน่งอย่างไร ถ้า concat มิติจะเบิ้ลโดยไม่จำเป็น เปลืองทั้ง parameter และการคำนวณ (และเพราะ position_embedding_table นิยามไว้แค่ 0..block_size-1 ตอน generate จึงต้อง crop context ให้ไม่เกิน block_size — รายละเอียดอยู่บท8)
สองรอยต่อ: code ที่เราเขียน ≠ ไดอะแกรมปี 2017
หัวข้อที่มีชื่อว่า “สองรอยต่อ: code ที่เราเขียน ≠ ไดอะแกรมปี 2017”code ข้างบนไม่ได้ลอกเปเปอร์ Attention Is All You Need (2017) มาเป๊ะ — มันคือ version “สมัยใหม่” ที่ต่างไปสองจุด ถ้าคุณเอา forward() ของเราไปเทียบลูกศรในรูปที่ 1 ของเปเปอร์ มันจะไม่ตรง และนั่น ตั้งใจ:
| รอยต่อ | เปเปอร์ 2017 (S14) | code สมัยใหม่ (บทนี้) |
|---|---|---|
| ข้อมูลตำแหน่ง | sinusoidal — สูตร sine/cosine คงที่ ไม่มี parameter เรียนรู้ | learned nn.Embedding(block_size, n_embd) — ตารางที่ gradient descent ขยับ (เหมือน GPT-2) เขียนง่ายกว่าและใช้กันทั่วไป |
| ตำแหน่ง LayerNorm | post-norm — x = LayerNorm(x + sublayer(x)) (norm หลัง residual) | pre-norm — x = x + sublayer(LayerNorm(x)) (norm ก่อน sublayer) เทรนนิ่งกว่า gradient ไหลผ่าน residual ได้สะอาดกว่า จึงเป็น default ของ GPT สมัยใหม่ |
ทั้งสองแบบ “ถูก” ทั้งคู่ในความหมายที่มันทำงานได้ — แต่ของสมัยใหม่เทรนง่ายกว่าในทางปฏิบัติ นี่คือเหตุผลที่เราอ้าง Vaswani สำหรับ ที่มาของไอเดีย แต่ไม่ลอกทุกดีเทลของปี 2017 มา
ทำไม transformer ใช้ LayerNorm ไม่ใช่ BatchNorm
หัวข้อที่มีชื่อว่า “ทำไม transformer ใช้ LayerNorm ไม่ใช่ BatchNorm”ในบท5 เราเจอ Batch NormalizationBatch Normalizationปรับสเกลค่ากลางชั้นให้นิ่ง เทรนง่ายขึ้น (ป้าย: practice สมัยใหม่มีทางเลือกอื่น เช่น LayerNorm)Process และกับดักคลาสสิกของมัน: BatchNorm normalize แต่ละ feature ตาม แกน batch — ค่าของตัวอย่างหนึ่งจึงขึ้นกับ “เพื่อนร่วม batch” ของมัน พอ eval ต้องสลับไปใช้ running stats และถ้า batch มีตัวอย่างเดียว variance = 0 → หารด้วยเกือบศูนย์ → ระเบิด
nn.LayerNorm แก้ปัญหานี้ตรงๆ: มัน normalize ตาม แกน feature ของ แต่ละ token แยกกัน — ไม่พึ่งพา token อื่นในลำดับ ไม่พึ่ง batch เลย จึงไม่มีความต่างระหว่าง train กับ eval (ไม่ต้องเก็บ running stats) สำหรับ transformer ที่ประมวลผลลำดับความยาวไม่เท่ากันและอยากให้ token แต่ละตัวเป็นอิสระต่อกัน นี่คือคุณสมบัติที่ต้องการพอดี — โครงสร้าง γ/β (scale/shift ที่เรียนรู้ได้) ยังเหมือน BatchNorm เป๊ะ ต่างแค่ normalize คนละแกน นี่คือ “ผลตอบแทน” ของหมายเหตุที่เราติดค้างไว้ตั้งแต่บท5
“backprop ตัวเดียวกันนี้แหละที่หมุนอยู่ข้างใน model ที่ agent ของคุณใน #15-#18 เรียกผ่าน API — เข้าใจมันแล้วจะเห็นทั้งกองตั้งแต่ neuron ยัน tool-call; แต่พูดตรงๆ: ไม่รู้ backprop ก็สร้าง agent ได้”
แปลว่า: GPTLanguageModel.forward ที่คุณเพิ่งประกอบด้วยมือ — token+position embedding → หลาย Block ของ attention+MLP → LayerNorm → lm_head → logits — คือ เส้นทางเดียวกัน (แค่สเกลใหญ่กว่ามาก) ที่หมุนอยู่หลัง client.messages.create(...) ทุกครั้งที่ agent ของคุณใน #15-#18 เรียก model จริงแค่มี n_layer/n_embd/vocab_size โตขึ้นเป็นหลักหมื่นเท่า แล้ววน forward นี้แบบ autoregressive (loop generate อยู่บท8) — แต่ย้ำ: นี่คือ สะพานความเข้าใจ ไม่ใช่ prerequisite ของการสร้าง agent
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”self-attention คือกลไกให้ token “มองย้อน” แล้วชั่งน้ำหนักกันเอง — สร้างจาก Q/K/V ของ x ก้อนเดียว, affinity q @ kᵀ, สเกล √head_size (per-head, เหตุผลจาก Vaswani §3.2.1), mask ก่อน softmax ด้วย tril (ตัวฆ่าอันดับหนึ่งถ้าทำผิด), softmax(dim=-1), แล้วถ่วง value; tril เป็น buffer slice [:T,:T] ต่อหัวเดียวเป็น multi-head, ประกอบเป็น Block (attention “คุยกัน” + MLP 4× “คิดเอง”, pre-norm + residual) แล้ววางสายทั้ง model โดย บวก position embedding (ไม่ใช่ concat) เพื่อกัน permutation-invariance; code เราเป็น version สมัยใหม่ (learned positions + pre-norm) ที่ต่างจากไดอะแกรมปี 2017 อย่างจงใจ และใช้ LayerNorm แทน BatchNorm เพราะ normalize per-token ไม่ผูกกับ batch
บท8 (capstone) เราจะเอา model นี้ไป เทรนจริงบน CPU ด้วย config จิ๋ว เขียน loop generate() (crop context, sample ไม่ argmax, temperature), เพิ่ม BPE tokenizer, แล้วลองป้อนภาษาไทยดู — พร้อมปิดคอร์สด้วยเส้นความซื่อสัตย์ครบทั้งสี่เส้น
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Vaswani et al. — Attention Is All You Need (2017) (S14) — ต้นฉบับของ self-attention/transformer; §3.2.1 เชิงอรรถ 4 คือที่มาของการหารด้วย √
d_k(dot product ของเวกเตอร์ unit-variance บนมิติd_kมี variance =d_k); รูปที่ 1 คือไดอะแกรม post-norm + sinusoidal ดั้งเดิมที่ code สมัยใหม่ของเราต่างออกไปอย่างจงใจ — อ้างเปเปอร์นี้สำหรับ ที่มาของไอเดีย ไม่ใช่ทุกดีเทลของปี 2017 - Karpathy — ng-video-lecture (
gpt.py, from-scratch GPT ตัวอ่านง่าย) (S7, commit52201428ed7b46804849dea0b3ccf0de9df1a5c3, 2023-02-07; เข้าถึง 2026-07-23) —Head/Block/การวางสายในบทนี้มิเรอร์ file นี้ (repo ที่ยังมีชีวิต จึง pin ด้วย SHA + วันที่ดึง — เส้น A; หมายเหตุ: HEAD ปัจจุบันขยับไป4c8e902ซึ่งแก้แค่ comment อธิบาย shape code จริงเท่าเดิม) - Karpathy — nanoGPT (GPT สเกลใหญ่ขึ้น) (S4) — version สเกลที่บท8 ยืม CPU recipe มาใช้ ถูก deprecate พ.ย. 2025 → ผู้สืบทอดคือ nanochat (S18); ตัวอย่างสดๆ ของเส้น A: repo ขยับใต้เท้าเรา จึงต้อง pin + ระบุวันที่เสมอ
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ใน code causal Head ทำไม 'ต้อง' mask ด้วย tril *ก่อน* softmax และจะเกิดอะไรถ้าสลับด้าน (เช่นใช้ triu)?