개념
GPT 아키텍처(The GPT Architecture)
GPT는 자기회귀 언어 모델(autoregressive language model)입니다. "자기회귀"는 토큰을 한 번에 하나씩 생성하며, 각 토큰이 이전의 모든 토큰에 조건부로 생성된다는 뜻입니다. 아키텍처는 트랜스포머 디코더 블록(transformer decoder blocks)을 쌓은 구조입니다.
토큰 ID에서 다음 토큰 확률까지의 전체 계산 그래프는 다음과 같습니다.
- 토큰 ID가 들어옵니다. 형태(shape):
(batch_size, seq_len).
- 토큰 임베딩 조회(token embedding lookup). 각 ID가 768차원 벡터에 매핑됩니다. 형태:
(batch_size, seq_len, 768).
- 위치 임베딩 조회(position embedding lookup). 각 위치(0, 1, 2, ...)가 768차원 벡터에 매핑됩니다. 같은 형태입니다.
- 토큰 임베딩과 위치 임베딩을 더합니다.
- 12개의 트랜스포머 블록을 통과합니다.
- 최종 층 정규화(layer normalization)를 적용합니다.
- 어휘 크기로 선형 투영(linear projection)합니다. 형태:
(batch_size, seq_len, vocab_size).
- 소프트맥스(softmax)로 확률을 얻습니다.
이것이 모델 전체입니다. 합성곱(convolution)도 없고, 순환(recurrence)도 없습니다. 임베딩, 어텐션, 피드포워드 네트워크(feedforward networks), 층 정규화(layer norms)를 12번 쌓은 것입니다.
graph TD
A["토큰 ID\n(batch, seq_len)"] --> B["토큰 임베딩\n(batch, seq_len, 768)"]
A --> C["위치 임베딩\n(batch, seq_len, 768)"]
B --> D["더하기(Add)"]
C --> D
D --> E["Transformer Block 1"]
E --> F["Transformer Block 2"]
F --> G["..."]
G --> H["Transformer Block 12"]
H --> I["Layer Norm"]
I --> J["Linear Head\n(768 -> 50257)"]
J --> K["Softmax\n다음 토큰 확률"]
style A fill:#1a1a2e,stroke:#e94560,color:#fff
style B fill:#1a1a2e,stroke:#0f3460,color:#fff
style C fill:#1a1a2e,stroke:#0f3460,color:#fff
style D fill:#1a1a2e,stroke:#16213e,color:#fff
style E fill:#1a1a2e,stroke:#e94560,color:#fff
style F fill:#1a1a2e,stroke:#e94560,color:#fff
style H fill:#1a1a2e,stroke:#e94560,color:#fff
style I fill:#1a1a2e,stroke:#16213e,color:#fff
style J fill:#1a1a2e,stroke:#0f3460,color:#fff
style K fill:#1a1a2e,stroke:#51cf66,color:#fff
12개의 블록은 모두 같은 패턴을 따릅니다. GPT-2는 원래 트랜스포머처럼 후정규화(post-norm)가 아니라 사전 정규화(pre-norm) 아키텍처를 사용합니다.
- LayerNorm
- Multi-Head Self-Attention
- 잔차 연결(residual connection, 입력을 다시 더함)
- LayerNorm
- 피드포워드 네트워크(Feed-Forward Network, MLP)
- 잔차 연결(입력을 다시 더함)
잔차 연결은 매우 중요합니다. 잔차 연결이 없으면 역전파 중 그래디언트가 1번 블록까지 도달하기 전에 사라집니다. 잔차 연결이 있으면 그래디언트가 "스킵(skip)" 경로를 통해 손실에서 어떤 층으로든 직접 흐를 수 있습니다. 그래서 12개, 32개, 심지어 96개 블록도 쌓을 수 있습니다. GPT-4는 120개 블록을 사용한다는 소문이 있습니다.
어텐션: 핵심 메커니즘(Attention: The Core Mechanism)
셀프 어텐션(self-attention)은 모든 토큰이 이전의 모든 토큰을 바라보고, 각 토큰에 얼마나 주의를 기울일지 결정하게 합니다. 수식은 다음과 같습니다.
각 토큰 위치마다 입력에서 세 벡터를 계산합니다.
- 쿼리(Query, Q): "나는 무엇을 찾고 있는가?"
- 키(Key, K): "나는 무엇을 담고 있는가?"
- 값(Value, V): "나는 어떤 정보를 나르는가?"
Q = input @ W_q (768 -> 768)
K = input @ W_k (768 -> 768)
V = input @ W_v (768 -> 768)
attention_scores = Q @ K^T / sqrt(d_k)
attention_scores = mask(attention_scores) # causal mask: future positions are -inf
attention_weights = softmax(attention_scores)
output = attention_weights @ V
인과 마스크(causal mask)는 GPT를 자기회귀적으로 만듭니다. 5번 위치는 0-5번 위치를 볼 수 있지만 6, 7, 8번 이후는 볼 수 없습니다. 이 때문에 학습 중 모델이 미래 토큰을 보고 "속이는" 일이 막힙니다.
멀티 헤드 어텐션(Multi-head attention)은 768차원 공간을 64차원짜리 12개 헤드로 나눕니다. 각 헤드는 서로 다른 어텐션 패턴을 학습합니다. 어떤 헤드는 구문 관계(syntactic relationships, 예: 주어-동사 일치)를 추적할 수 있습니다. 다른 헤드는 의미 유사성(semantic similarity, 예: 동의어)을 추적할 수 있습니다. 또 다른 헤드는 위치 근접성(positional proximity, 가까운 단어)을 추적할 수 있습니다. 12개 헤드의 출력은 이어 붙인 뒤 다시 768차원으로 투영됩니다.
graph LR
subgraph MultiHead["Multi-Head Attention (12 heads)"]
direction TB
I["Input (768)"] --> S1["12개 헤드로 분할"]
S1 --> H1["Head 1\n(64 dims)"]
S1 --> H2["Head 2\n(64 dims)"]
S1 --> H3["..."]
S1 --> H12["Head 12\n(64 dims)"]
H1 --> C["Concat (768)"]
H2 --> C
H3 --> C
H12 --> C
C --> O["Output Projection\n(768 -> 768)"]
end
subgraph SingleHead["각 헤드의 계산"]
direction TB
Q["Q = X @ W_q"] --> A["scores = Q @ K^T / 8"]
K["K = X @ W_k"] --> A
A --> M["인과 마스크 적용"]
M --> SM["Softmax"]
SM --> MUL["weights @ V"]
V["V = X @ W_v"] --> MUL
end
style I fill:#1a1a2e,stroke:#e94560,color:#fff
style O fill:#1a1a2e,stroke:#e94560,color:#fff
style Q fill:#1a1a2e,stroke:#0f3460,color:#fff
style K fill:#1a1a2e,stroke:#0f3460,color:#fff
style V fill:#1a1a2e,stroke:#0f3460,color:#fff
sqrt(d_k)로 나누는 것, 즉 sqrt(64) = 8로 스케일링(scaling)하는 것이 중요합니다. 이 과정이 없으면 고차원 벡터의 내적(dot product)이 너무 커져 소프트맥스(softmax)가 그래디언트가 거의 0인 영역으로 밀려납니다. 이것은 원래 "Attention Is All You Need" 논문의 핵심 통찰 중 하나였습니다.
KV Cache: 추론이 빠른 이유
학습 중에는 전체 시퀀스를 한 번에 처리합니다. 추론 중에는 한 번에 토큰 하나를 생성합니다. 최적화가 없다면 N번째 토큰을 생성할 때 이전 N-1개 토큰 전체의 어텐션을 다시 계산해야 합니다. 생성 토큰당 O(N), 전체 길이 N 시퀀스에는 O(N^2) 계산이 필요하고, 입력까지 반복 계산하면 실제 비용은 빠르게 커집니다.
KV Cache가 이를 해결합니다. 각 토큰에 대한 K와 V를 계산한 뒤 저장합니다. N+1번째 토큰을 생성할 때는 새 토큰의 Q만 계산하고, 이전 모든 토큰의 K와 V는 캐시에서 읽으면 됩니다. K와 V 계산의 토큰당 비용이 O(N)에서 O(1)로 줄어듭니다. 어텐션 점수 계산은 여전히 모든 이전 위치에 어텐션해야 하므로 O(N)이지만, 입력에 대한 중복 행렬 곱셈은 피할 수 있습니다.
GPT-2의 12개 층, 12개 헤드에서는 KV cache가 토큰당 2(K + V) x 12 layers x 12 heads x 64 dims = 18,432개 값을 저장합니다. 1024토큰 시퀀스라면 32비트 부동소수점(FP32) 기준 약 75MB입니다. Llama 3 405B처럼 128개 층을 가진 모델에서는 단일 시퀀스의 KV cache가 10GB를 넘을 수 있습니다. 긴 컨텍스트 추론(long-context inference)이 메모리에 묶이는(memory-bound) 이유입니다.
Prefill과 Decode: 추론의 두 단계
LLM에 프롬프트를 보내면 추론은 서로 다른 두 단계로 일어납니다.
Prefill은 전체 프롬프트를 병렬로 처리합니다. 모든 토큰이 이미 알려져 있으므로 모델은 모든 위치의 어텐션을 동시에 계산할 수 있습니다. 이 단계는 연산에 묶입니다(compute-bound). GPU가 행렬 곱셈을 최대 처리량으로 수행합니다. A100에서 1000토큰 프롬프트의 prefill은 대략 20-50ms가 걸립니다.
Decode는 토큰을 하나씩 생성합니다. 새 토큰은 이전 모든 토큰에 의존합니다. 이 단계는 메모리에 묶입니다(memory-bound). 병목은 행렬 연산 자체가 아니라 GPU 메모리에서 모델 가중치와 KV cache를 읽는 일입니다. GPU의 연산 코어는 대부분 메모리 읽기를 기다리며 놀고 있습니다. GPT-2에서 decode 단계는 행렬 곱셈에 필요한 부동소수점 연산량(FLOPs)이 얼마나 큰지와 무관하게 비슷한 시간이 걸립니다. 제약은 메모리 대역폭(memory bandwidth)이기 때문입니다.
이 구분은 프로덕션 시스템(production system)에서 중요합니다. Prefill 처리량은 GPU 연산 성능(FLOPS)에 따라 확장됩니다. Decode 처리량은 메모리 대역폭에 따라 확장됩니다. NVIDIA의 H100이 A100보다 메모리 대역폭 개선에 집중한 이유도 여기에 있습니다. 토큰 생성 속도를 직접 빠르게 만들기 때문입니다.
graph LR
subgraph Prefill["Phase 1: Prefill"]
direction TB
P1["전체 프롬프트\n(모든 토큰이 알려짐)"]
P2["병렬 계산\n(compute-bound)"]
P3["KV Cache 생성"]
P1 --> P2 --> P3
end
subgraph Decode["Phase 2: Decode"]
direction TB
D1["토큰 N 생성"]
D2["KV Cache 읽기\n(memory-bound)"]
D3["KV Cache에 추가"]
D4["토큰 N+1 생성"]
D1 --> D2 --> D3 --> D4
D4 -.->|repeat| D1
end
Prefill --> Decode
style P1 fill:#1a1a2e,stroke:#51cf66,color:#fff
style P2 fill:#1a1a2e,stroke:#51cf66,color:#fff
style P3 fill:#1a1a2e,stroke:#51cf66,color:#fff
style D1 fill:#1a1a2e,stroke:#e94560,color:#fff
style D2 fill:#1a1a2e,stroke:#e94560,color:#fff
style D3 fill:#1a1a2e,stroke:#e94560,color:#fff
style D4 fill:#1a1a2e,stroke:#e94560,color:#fff
학습 루프(The Training Loop)
LLM 학습은 다음 토큰 예측입니다. 토큰 [0, 1, 2, ..., N-1]이 주어졌을 때 [1, 2, 3, ..., N]을 예측합니다. 손실 함수는 모델이 예측한 확률 분포와 실제 다음 토큰 사이의 교차 엔트로피(cross-entropy)입니다.
학습 단계(step) 하나는 다음으로 이루어집니다.
- 순전파(Forward pass): 배치를 12개 블록 전체에 통과시킵니다. 각 위치의 로짓(logits, 소프트맥스 전 점수)을 얻습니다.
- 손실 계산(Compute loss): 로짓과 타깃 토큰(target tokens, 입력을 한 칸 밀어 만든 다음 토큰) 사이의 교차 엔트로피를 계산합니다.
- 역전파(Backward pass): 역전파(backpropagation)로 124M 파라미터 전체의 그래디언트를 계산합니다.
- 옵티마이저 갱신(Optimizer step): 가중치를 갱신합니다. GPT-2는 학습률 워밍업(learning rate warmup)과 코사인 감쇠(cosine decay)를 함께 쓰는 Adam을 사용합니다.
학습률 스케줄(learning rate schedule)은 생각보다 훨씬 중요합니다. GPT-2는 처음 2,000 단계(step) 동안 학습률을 0에서 최고 학습률(peak learning rate)까지 올린 뒤 코사인 곡선을 따라 낮춥니다. 처음부터 높은 학습률로 시작하면 모델이 발산합니다. 높은 학습률을 계속 유지하면 후반 학습에서 진동합니다. 워밍업 후 감쇠(warmup-then-decay) 패턴은 모든 주요 LLM이 사용합니다.
GPT-2 Small: 숫자들
| 구성요소 | 형태(Shape) | 파라미터 |
|---|
| 토큰 임베딩(Token embeddings) | (50257, 768) | 38,597,376 |
| 위치 임베딩(Position embeddings) | (1024, 768) | 786,432 |
| 블록당 어텐션(W_q, W_k, W_v, W_out) | 4 x (768, 768) | 2,359,296 |
| 블록당 FFN(up + down) | (768, 3072) + (3072, 768) | 4,718,592 |
| 블록당 LayerNorms(2x) | 2 x 768 x 2 | 3,072 |
| 최종 LayerNorm | 768 x 2 | 1,536 |
| 블록당 합계 | | 7,080,960 |
| 전체(12 blocks) | | 85,054,464 + 39,383,808 = 124,438,272 |
출력 투영(output projection, logits head)은 토큰 임베딩 행렬과 가중치를 공유합니다. 이를 가중치 묶기(weight tying)라고 합니다. 파라미터 수를 38M 줄이고, 모델이 입력과 출력에 같은 표현 공간을 쓰게 강제하기 때문에 성능도 개선합니다.
직접 만들기
Step 1: 임베딩 층(Embedding Layer)
토큰 임베딩은 가능한 50,257개 토큰 각각을 768차원 벡터에 매핑합니다. 위치 임베딩은 각 토큰이 시퀀스 어디에 있는지에 대한 정보를 더합니다. 두 값은 더해집니다.
import numpy as np
class Embedding:
def __init__(self, vocab_size, embed_dim, max_seq_len):
self.token_embed = np.random.randn(vocab_size, embed_dim) * 0.02
self.pos_embed = np.random.randn(max_seq_len, embed_dim) * 0.02
def forward(self, token_ids):
seq_len = token_ids.shape[-1]
tok_emb = self.token_embed[token_ids]
pos_emb = self.pos_embed[:seq_len]
return tok_emb + pos_emb
초기화(initialization)에 표준편차 0.02를 쓰는 것은 GPT-2 논문에서 온 값입니다. 너무 크면 초기 순전파가 극단값을 만들어 학습을 불안정하게 합니다. 너무 작으면 모든 입력에 대한 초기 출력이 거의 같아져 초기 그래디언트 신호가 쓸모없어집니다.
Step 2: 인과 마스크가 있는 셀프 어텐션(Self-Attention with Causal Mask)
먼저 단일 헤드 어텐션(single-head attention)입니다. 인과 마스크는 소프트맥스 전에 미래 위치를 음의 무한대(negative infinity)로 설정해, 각 위치가 자기 자신과 이전 위치만 볼 수 있게 합니다.
def attention(Q, K, V, mask=None):
d_k = Q.shape[-1]
scores = Q @ K.transpose(0, -1, -2 if Q.ndim == 4 else 1) / np.sqrt(d_k)
if mask is not None:
scores = scores + mask
weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights = weights / weights.sum(axis=-1, keepdims=True)
return weights @ V
소프트맥스 구현은 지수(exp)를 취하기 전에 최댓값을 뺍니다. 이 과정이 없으면 exp(large_number)가 무한대(infinity)로 오버플로(overflow)됩니다. softmax(x - c) = softmax(x)이므로 출력은 바뀌지 않는 수치 안정성(numerical stability) 기법입니다.
Step 3: 멀티 헤드 어텐션(Multi-Head Attention)
768차원 입력을 64차원짜리 12개 헤드로 나눕니다. 각 헤드는 독립적으로 어텐션을 계산합니다. 결과를 이어 붙이고 다시 768차원으로 투영합니다.
class MultiHeadAttention:
def __init__(self, embed_dim, num_heads):
self.num_heads = num_heads
self.head_dim = embed_dim // num_heads
self.W_q = np.random.randn(embed_dim, embed_dim) * 0.02
self.W_k = np.random.randn(embed_dim, embed_dim) * 0.02
self.W_v = np.random.randn(embed_dim, embed_dim) * 0.02
self.W_out = np.random.randn(embed_dim, embed_dim) * 0.02
def forward(self, x, mask=None):
batch, seq_len, d = x.shape
Q = (x @ self.W_q).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
K = (x @ self.W_k).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
V = (x @ self.W_v).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
scores = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(self.head_dim)
if mask is not None:
scores = scores + mask
weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights = weights / weights.sum(axis=-1, keepdims=True)
attn_out = weights @ V
attn_out = attn_out.transpose(0, 2, 1, 3).reshape(batch, seq_len, d)
return attn_out @ self.W_out
reshape-transpose-reshape 과정이 멀티 헤드 어텐션에서 가장 헷갈리는 부분입니다. (batch, seq_len, 768) 텐서가 (batch, seq_len, 12, 64)가 되고, 다시 (batch, 12, seq_len, 64)가 됩니다. 이제 12개 헤드 각각이 자기만의 (seq_len, 64) 행렬로 어텐션을 수행합니다. 어텐션 후에는 반대로 되돌립니다. (batch, 12, seq_len, 64)가 (batch, seq_len, 12, 64)가 되고, 최종적으로 (batch, seq_len, 768)이 됩니다.
완전한 트랜스포머 블록 하나입니다. LayerNorm, 잔차 연결이 있는 멀티 헤드 어텐션, LayerNorm, 잔차 연결이 있는 피드포워드 네트워크입니다.
class LayerNorm:
def __init__(self, dim, eps=1e-5):
self.gamma = np.ones(dim)
self.beta = np.zeros(dim)
self.eps = eps
def forward(self, x):
mean = x.mean(axis=-1, keepdims=True)
var = x.var(axis=-1, keepdims=True)
return self.gamma * (x - mean) / np.sqrt(var + self.eps) + self.beta
class FeedForward:
def __init__(self, embed_dim, ff_dim):
self.W1 = np.random.randn(embed_dim, ff_dim) * 0.02
self.b1 = np.zeros(ff_dim)
self.W2 = np.random.randn(ff_dim, embed_dim) * 0.02
self.b2 = np.zeros(embed_dim)
def forward(self, x):
h = x @ self.W1 + self.b1
h = np.maximum(0, h)
return h @ self.W2 + self.b2
class TransformerBlock:
def __init__(self, embed_dim, num_heads, ff_dim):
self.ln1 = LayerNorm(embed_dim)
self.attn = MultiHeadAttention(embed_dim, num_heads)
self.ln2 = LayerNorm(embed_dim)
self.ffn = FeedForward(embed_dim, ff_dim)
def forward(self, x, mask=None):
x = x + self.attn.forward(self.ln1.forward(x), mask)
x = x + self.ffn.forward(self.ln2.forward(x))
return x
피드포워드 네트워크는 768차원 입력을 3,072차원(4배)으로 확장하고 비선형성(nonlinearity)을 적용한 뒤 다시 768차원으로 투영합니다. 이 확장-수축(expansion-contraction) 패턴은 각 위치에서 모델이 더 "넓은" 내부 표현을 사용할 수 있게 합니다. GPT-2는 GELU 활성화(activation)를 사용하지만, 여기서는 아키텍처 이해를 위해 ReLU를 사용합니다. 핵심 구조를 이해하는 데에는 큰 차이가 아닙니다.
Step 5: 전체 GPT 모델(Full GPT Model)
12개 트랜스포머 블록을 쌓습니다. 앞에는 임베딩 층을 붙이고, 뒤에는 출력 투영을 붙입니다.
class MiniGPT:
def __init__(self, vocab_size=50257, embed_dim=768, num_heads=12,
num_layers=12, max_seq_len=1024, ff_dim=3072):
self.embedding = Embedding(vocab_size, embed_dim, max_seq_len)
self.blocks = [
TransformerBlock(embed_dim, num_heads, ff_dim)
for _ in range(num_layers)
]
self.ln_f = LayerNorm(embed_dim)
self.vocab_size = vocab_size
self.embed_dim = embed_dim
def forward(self, token_ids):
seq_len = token_ids.shape[-1]
mask = np.triu(np.full((seq_len, seq_len), -1e9), k=1)
x = self.embedding.forward(token_ids)
for block in self.blocks:
x = block.forward(x, mask)
x = self.ln_f.forward(x)
logits = x @ self.embedding.token_embed.T
return logits
def count_parameters(self):
total = 0
total += self.embedding.token_embed.size
total += self.embedding.pos_embed.size
for block in self.blocks:
total += block.attn.W_q.size + block.attn.W_k.size
total += block.attn.W_v.size + block.attn.W_out.size
total += block.ffn.W1.size + block.ffn.b1.size
total += block.ffn.W2.size + block.ffn.b2.size
total += block.ln1.gamma.size + block.ln1.beta.size
total += block.ln2.gamma.size + block.ln2.beta.size
total += self.ln_f.gamma.size + self.ln_f.beta.size
return total
가중치 묶기에 주목합니다. logits = x @ self.embedding.token_embed.T입니다. 출력 투영이 토큰 임베딩 행렬을 전치(transposed)해서 재사용합니다. 이것은 단순한 파라미터 절약 기법만이 아닙니다. 모델이 토큰을 이해하는 공간(embeddings)과 토큰을 예측하는 공간(output)에 같은 벡터 공간을 쓰게 만든다는 뜻입니다.
Step 6: 학습 루프(Training Loop)
124M 파라미터를 실제로 학습하려면 GPU와 PyTorch가 필요합니다. 이 학습 루프는 순수 numpy에서 돌아가는 작은 모델로 메커니즘을 보여줍니다. 다루기 쉽게 4개 층, 4개 헤드, 128차원짜리 작은 모델을 사용합니다.
def cross_entropy_loss(logits, targets):
batch, seq_len, vocab_size = logits.shape
logits_flat = logits.reshape(-1, vocab_size)
targets_flat = targets.reshape(-1)
max_logits = logits_flat.max(axis=-1, keepdims=True)
log_softmax = logits_flat - max_logits - np.log(
np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
)
loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
return loss
def train_mini_gpt(text, vocab_size=256, embed_dim=128, num_heads=4,
num_layers=4, seq_len=64, num_steps=200, lr=3e-4):
tokens = np.array(list(text.encode("utf-8")[:2048]))
model = MiniGPT(
vocab_size=vocab_size, embed_dim=embed_dim, num_heads=num_heads,
num_layers=num_layers, max_seq_len=seq_len, ff_dim=embed_dim * 4
)
print(f"모델 파라미터(Model parameters): {model.count_parameters():,}")
print(f"학습 토큰(Training tokens): {len(tokens):,}")
print(f"구성(Config): {num_layers} layers, {num_heads} heads, {embed_dim} dims")
print()
for step in range(num_steps):
start_idx = np.random.randint(0, max(1, len(tokens) - seq_len - 1))
batch_tokens = tokens[start_idx:start_idx + seq_len + 1]
input_ids = batch_tokens[:-1].reshape(1, -1)
target_ids = batch_tokens[1:].reshape(1, -1)
logits = model.forward(input_ids)
loss = cross_entropy_loss(logits, target_ids)
if step % 20 == 0:
print(f"Step {step:4d} | Loss: {loss:.4f}")
return model
손실은 ln(vocab_size) 근처에서 시작합니다. 256토큰 바이트 단위 어휘라면 ln(256) = 5.55입니다. 무작위 모델은 모든 토큰에 같은 확률을 부여합니다. 학습이 진행되면 모델이 흔한 패턴을 학습하기 때문에 손실이 내려갑니다. 예를 들어 t 다음의 h, 마침표 뒤의 공백 같은 패턴입니다.
프로덕션에서는 Adam 옵티마이저(optimizer), 그래디언트 누적(gradient accumulation), 학습률 워밍업(learning rate warmup), 그래디언트 클리핑(gradient clipping)을 사용합니다. 하지만 순전파-손실-역전파-업데이트 루프는 같습니다. 옵티마이저가 더 정교해질 뿐입니다.
Step 7: 텍스트 생성(Text Generation)
생성은 학습된 모델로 토큰을 한 번에 하나씩 예측합니다. 각 예측은 출력 분포에서 샘플링되거나, 가장 큰 값(argmax)을 탐욕적으로 선택합니다.
def generate(model, prompt_tokens, max_new_tokens=100, temperature=0.8):
tokens = list(prompt_tokens)
seq_len = model.embedding.pos_embed.shape[0]
for _ in range(max_new_tokens):
context = np.array(tokens[-seq_len:]).reshape(1, -1)
logits = model.forward(context)
next_logits = logits[0, -1, :]
next_logits = next_logits / temperature
probs = np.exp(next_logits - next_logits.max())
probs = probs / probs.sum()
next_token = np.random.choice(len(probs), p=probs)
tokens.append(next_token)
return tokens
온도(temperature)는 무작위성을 제어합니다. 온도 1.0은 원래 분포(raw distribution)를 그대로 사용합니다. 온도 0.5는 분포를 날카롭게 만들어 더 결정적으로 동작합니다. 모델이 상위 선택지를 더 자주 고릅니다. 온도 1.5는 분포를 평평하게 만들어 더 무작위적입니다. 낮은 확률의 토큰도 더 많은 기회를 얻습니다. 온도 0.0은 탐욕 디코딩(greedy decoding)입니다. 항상 가장 확률이 높은 토큰을 고릅니다.
tokens[-seq_len:] 창은 모델에 최대 컨텍스트 길이(maximum context length)가 있기 때문에 필요합니다. GPT-2에서는 1024입니다. 이를 넘으면 가장 오래된 토큰을 버려야 합니다. 이것이 사람들이 말하는 "컨텍스트 창(context window)"입니다.
사용해보기
전체 학습과 생성 데모(Full Training and Generation Demo)
corpus = """The transformer architecture has revolutionized natural language processing.
Attention mechanisms allow the model to focus on relevant parts of the input.
Self-attention computes relationships between all pairs of positions in a sequence.
Multi-head attention splits the representation into multiple subspaces.
Each attention head can learn different types of relationships.
The feedforward network provides nonlinear transformations at each position.
Residual connections enable gradient flow through deep networks.
Layer normalization stabilizes training by normalizing activations.
Position embeddings give the model information about token ordering.
The causal mask ensures autoregressive generation during training.
Pre-training on large text corpora teaches the model general language understanding.
Fine-tuning adapts the pre-trained model to specific downstream tasks."""
model = train_mini_gpt(corpus, num_steps=200)
prompt = list("The transformer".encode("utf-8"))
output_tokens = generate(model, prompt, max_new_tokens=100, temperature=0.8)
generated_text = bytes(output_tokens).decode("utf-8", errors="replace")
print(f"\n생성 결과(Generated): {generated_text}")
작은 말뭉치와 작은 모델에서 생성된 텍스트는 기껏해야 반쯤 일관된 정도입니다. 학습 텍스트의 바이트 단위 패턴은 일부 배우겠지만, 40GB 학습 데이터와 전체 124M 파라미터 아키텍처를 사용하는 GPT-2처럼 일반화하지는 못합니다. 핵심은 출력 품질이 아닙니다. 임베딩 조회, 어텐션 계산, 피드포워드 변환, 로짓 투영, 소프트맥스, 샘플링까지 모든 단계를 추적할 수 있다는 점입니다. 모든 연산이 보입니다.
산출물 만들기
이 lesson은 outputs/prompt-gpt-architecture-analyzer.md를 산출합니다. GPT 스타일 모델의 아키텍처 선택을 분석하는 프롬프트입니다. 모델 카드(model card)나 기술 보고서(technical report)를 넣으면 파라미터 할당(parameter allocation), 어텐션 설계(attention design), 스케일링 결정(scaling decisions)을 분해해줍니다.
연습문제
-
(쉬움) 모델을 12/12 대신 24개 층과 16개 헤드로 바꿉니다. 파라미터를 세어봅니다. 깊이(depth)를 두 배로 늘리는 것과 너비(width, embedding dimension)를 두 배로 늘리는 것은 어떻게 다를까요?
-
(중간) GELU 활성화 함수 GELU(x) = x * 0.5 * (1 + erf(x / sqrt(2)))를 구현하고 피드포워드 네트워크의 ReLU를 대체합니다. 각 활성화 함수로 500 단계(step) 학습하고 최종 손실을 비교합니다.
-
(중간) 생성 함수에 KV cache를 추가합니다. 첫 순전파 후 각 층의 K와 V 텐서를 저장하고, 이후 토큰에서 재사용합니다. 200토큰을 cache 없이 생성한 경우와 cache를 사용한 경우의 벽시계 시간(wall-clock time)을 비교해 속도 향상을 측정합니다.
-
(중간) top-k 샘플링(확률이 가장 높은 k개 토큰만 고려)과 top-p 샘플링(누클리어스 샘플링(nucleus sampling): 누적 확률이 p를 넘는 가장 작은 토큰 집합을 고려)을 구현합니다. 온도 0.8에서 top-k=50과 top-p=0.95의 출력 품질을 비교합니다.
-
(어려움) 학습 손실 곡선 플로터(training loss curve plotter)를 만듭니다. 모델을 1000 단계(step) 학습하고 단계별 손실(loss vs step)을 그립니다. 세 단계를 식별합니다. 빠른 초기 하강(흔한 바이트 학습), 느린 중간 단계(바이트 패턴 학습), 정체(plateau, 작은 말뭉치에 대한 과적합)입니다. 이 곡선의 형태는 128차원 모델을 학습하든 GPT-4를 학습하든 같습니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 자기회귀(Autoregressive) | "단어를 하나씩 생성한다" | 각 출력 토큰이 이전 모든 토큰에 조건부로 생성됩니다. 모델은 P(token_n | token_0, ..., token_{n-1})을 예측합니다. |
| 인과 마스크(Causal mask) | "미래를 못 본다" | 학습 중 미래 위치에 대한 어텐션을 막기 위해 위삼각 행렬(upper-triangular matrix)에 음의 무한대 값을 넣은 마스크입니다. |
| 멀티 헤드 어텐션(Multi-head attention) | "여러 어텐션 패턴" | Q, K, V를 병렬 헤드로 나눕니다. GPT-2는 64차원짜리 12개 헤드를 사용해 각 헤드가 다른 관계 유형을 학습할 수 있게 합니다. |
| KV Cache | "속도를 위한 캐싱" | 자기회귀 생성 중 이전 토큰에서 계산한 키(Key)와 값(Value) 텐서를 저장해 중복 계산을 피합니다. |
| Prefill | "프롬프트 처리" | 모든 프롬프트 토큰을 병렬 처리하는 첫 추론 단계입니다. GPU FLOPS에 묶이는 연산 병목 단계입니다. |
| Decode | "토큰 생성" | 토큰을 하나씩 생성하는 두 번째 추론 단계입니다. GPU 메모리 대역폭에 묶이는 메모리 병목 단계입니다. |
| 가중치 묶기(Weight tying) | "임베딩 공유" | 입력 토큰 임베딩과 출력 투영 헤드에 같은 행렬을 사용합니다. GPT-2에서는 38M 파라미터를 절약합니다. |
| 잔차 연결(Residual connection) | "스킵 연결(Skip connection)" | 하위 층(sublayer)의 출력에 입력을 직접 더합니다. x + sublayer(x) 형태이며 깊은 네트워크에서 그래디언트 흐름을 가능하게 합니다. |
| 층 정규화(Layer normalization) | "활성값 정규화" | 특성(feature) 차원 전체를 평균 0, 분산 1로 정규화하고 학습 가능한 스케일(scale)과 편향(bias)을 적용합니다. |
| 교차 엔트로피 손실(Cross-entropy loss) | "예측이 얼마나 틀렸는지" | 올바른 다음 토큰에 할당한 확률의 음의 로그값입니다. 모든 위치에 평균을 내며 표준 LLM 학습 목적입니다. |
더 읽을거리