메인 콘텐츠로 건너뛰기

LLM(Large Language Model) 처음부터 만들기 학습 노트

요약

파인만 기법으로 배우는 LLM의 핵심 원리

LLM을 처음부터 만들어 보기!


1. LLM의 핵심 개념

LLM이 하는 일을 한 문장으로 말하면: "다음 토큰 예측"이 전부입니다.

"오늘 날씨가 정말 ___" 이라는 문장이 있으면, 모델은 "좋다", "덥다", "춥다" 같은 단어가 올 확률을 계산합니다. 이걸 수십억 번 반복 학습하면, 결국 문맥을 이해하고 적절한 응답을 생성하는 것처럼 보이게 됩니다.


2. LLM의 전체 구조

입력부터 출력까지의 흐름:

  1. 토큰화: "오늘" → [5, 12] (글자에 번호표 붙이기)

  2. 임베딩: [5, 12] → [[0.2, -0.5, ...], [0.8, 0.1, ...]] (숫자를 의미 있는 벡터로)

  3. Transformer 블록 통과: 단어들이 서로 정보 교환 (여러 번 반복)

  4. 출력: 다음 토큰 확률 계산 → "좋" 30%, "춥" 25%, "덥" 20%...

  5. 샘플링: 확률에 따라 하나 선택 → 이어붙이고 반복


3. 핵심 개념 상세 설명

3.1 토큰화 vs 임베딩

  • 토큰화: 글자 → 숫자 (ID만 붙임, 의미 없음)

  • 임베딩: 숫자 → 의미 있는 벡터 (비슷한 단어는 비슷한 벡터)

3.2 Q, K, V (Attention)

도서관에서 책 찾는 것에 비유:

  • Q (Query, 질문): "파이썬 프로그래밍 관련 책 찾고 싶어"

  • K (Key, 열쇠): 각 책의 라벨/태그 ("요리", "역사", "파이썬"...)

  • V (Value, 값): 책의 실제 내용

과정: Q와 K를 비교 → 관련 높은 책 찾기 → 그 책의 V(내용) 가져오기

3.3 Head (Multi-Head Attention)

Head는 "관점" 또는 "눈" 하나입니다. 여러 눈으로 다양한 패턴을 파악합니다:

  • 눈 1: 문법을 봄 (주어-동사 관계)

  • 눈 2: 의미를 봄 (고양이-생선 관계)

  • 눈 3: 거리를 봄 (가까운 단어)

  • 눈 4: 다른 패턴...

3.4 Transformer Block

레고 조각처럼 같은 구조를 반복해서 쌓습니다:

  1. Self-Attention (단어들이 서로 정보 교환)

  2. Add & Normalize (안정화)

  3. Feed Forward (정보 처리/변환)

  4. Add & Normalize (안정화)

블록을 많이 쌓을수록: 1-2층 = 단순 문법, 10-20층 = 문장 관계, 50층+ = 복잡한 추론

3.5 잔차 연결 (Residual Connection)

코드: x = x + attention(x)

잔차의 의미: "남은 것", 즉 원본과의 차이

일반 방식: 완전히 새로운 결과 만들기 (어려움)
잔차 방식: 원본 + 차이만 학습 (쉬움, 원본 보존)

왜 필요한가? 레이어가 깊어지면 원본 정보가 사라집니다. 잔차 연결은 "고속도로"를 만들어 원본 정보를 보존합니다.

  • 일반: 서울 → 대전 → 대구 → 부산 (중간에 막히면 끝)

  • 잔차: 서울 → 부산 직통 고속도로 + 중간 경유지


4. 학습 vs 예측

4.1 학습 (Training)

흐름: 입력 → 모델 → 예측 → 정답 비교 → 모델 수정

다음 글자 맞추기 게임을 수십억 번 반복합니다:

  • x (입력): "오늘날씨가좋"

  • y (정답): "늘날씨가좋다" (한 칸 뒤)

  • 모델이 예측한 것과 y를 비교해서 틀린 정도(loss)를 줄이는 방향으로 학습

4.2 예측 (Inference)

흐름: 입력 → 모델 → 예측 → 출력 (정답 비교 없음)

한 토큰씩 생성해서 이어붙입니다:

  1. "날씨" → 모델 → "가" 선택 → "날씨가"

  2. "날씨가" → 모델 → " " 선택 → "날씨가 "

  3. "날씨가 " → 모델 → "좋" 선택 → "날씨가 좋"

  4. ... 반복 ...

핵심 차이: 모델을 통과하는 과정은 동일. 학습은 정답과 비교해서 모델을 수정하고, 예측은 그냥 출력합니다.


5. 핵심 코드 구조 (PyTorch)

5.1 토큰화

char_to_idx = {ch: i for i, ch in enumerate(chars)}
encode = lambda s: [char_to_idx[c] for c in s]

5.2 임베딩

self.token_embedding = nn.Embedding(vocab_size, n_embed)
self.position_embedding = nn.Embedding(block_size, n_embed)

5.3 Self-Attention

Q = self.query(x)  # 질문
K = self.key(x)    # 열쇠
V = self.value(x)  # 값
scores = Q @ K.transpose(-2, -1)  # 유사도
attention = softmax(scores)
out = attention @ V  # 가중 합

5.4 Transformer Block

x = x + self.attention(self.ln1(x))  # 잔차 연결
x = x + self.ff(self.ln2(x))         # 잔차 연결

5.5 학습 루프

logits, loss = model(xb, yb)  # 예측 & 손실 계산
loss.backward()               # gradient 계산
optimizer.step()              # 파라미터 업데이트

5.6 텍스트 생성

logits, _ = model(idx)           # 모델 통과
probs = softmax(logits[:, -1, :]) # 마지막 위치 확률
idx_next = multinomial(probs)     # 샘플링
idx = cat((idx, idx_next))        # 이어붙이기ㅈ

전체 코드

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

# GPU 사용
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"Using: {device}")

# ============ 1. 데이터 준비 ============
# 간단한 한글 텍스트 (실제론 더 많은 데이터 필요)
text = """
오늘 날씨가 좋다. 하늘이 맑고 바람이 시원하다.
내일도 날씨가 좋을 것 같다. 기분이 좋아진다.
날씨가 좋으면 산책을 하고 싶다. 공원에 가고 싶다.
하늘이 파랗고 구름이 예쁘다. 정말 좋은 날이다.
"""

# 글자 단위 토큰화
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(f"어휘 크기: {vocab_size}")
print(f"어휘: {''.join(chars)}")

char_to_idx = {ch: i for i, ch in enumerate(chars)}
idx_to_char = {i: ch for i, ch in enumerate(chars)}

encode = lambda s: [char_to_idx[c] for c in s]
decode = lambda l: ''.join([idx_to_char[i] for i in l])

data = torch.tensor(encode(text), dtype=torch.long, device=device)

# ============ 2. 모델 정의 ============
class Head(nn.Module):
    """하나의 Attention Head"""
    def __init__(self, head_size):
        super().__init__()
        self.query = nn.Linear(n_embed, head_size, bias=False)
        self.key = nn.Linear(n_embed, head_size, bias=False)
        self.value = nn.Linear(n_embed, head_size, bias=False)
        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
    
    def forward(self, x):
        B, T, C = x.shape
        q = self.query(x)
        k = self.key(x)
        v = self.value(x)
        
        # Attention 계산
        scores = q @ k.transpose(-2, -1) * (C ** -0.5)
        scores = scores.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
        attention = F.softmax(scores, dim=-1)
        out = attention @ v
        return out

class MultiHeadAttention(nn.Module):
    """여러 Head를 병렬로"""
    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(n_embed, n_embed)
    
    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)
        return self.proj(out)

class FeedForward(nn.Module):
    def __init__(self, n_embed):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embed, 4 * n_embed),
            nn.GELU(),
            nn.Linear(4 * n_embed, n_embed),
        )
    
    def forward(self, x):
        return self.net(x)

class Block(nn.Module):
    """Transformer Block"""
    def __init__(self, n_embed, n_head):
        super().__init__()
        head_size = n_embed // n_head
        self.attention = MultiHeadAttention(n_head, head_size)
        self.ff = FeedForward(n_embed)
        self.ln1 = nn.LayerNorm(n_embed)
        self.ln2 = nn.LayerNorm(n_embed)
    
    def forward(self, x):
        x = x + self.attention(self.ln1(x))
        x = x + self.ff(self.ln2(x))
        return x

class MiniLLM(nn.Module):
    def __init__(self):
        super().__init__()
        self.token_embedding = nn.Embedding(vocab_size, n_embed)
        self.position_embedding = nn.Embedding(block_size, n_embed)
        self.blocks = nn.Sequential(*[Block(n_embed, n_head) for _ in range(n_layer)])
        self.ln_f = nn.LayerNorm(n_embed)
        self.lm_head = nn.Linear(n_embed, vocab_size)
    
    def forward(self, idx, targets=None):
        B, T = idx.shape
        
        tok_emb = self.token_embedding(idx)
        pos_emb = self.position_embedding(torch.arange(T, device=device))
        x = tok_emb + pos_emb
        x = self.blocks(x)
        x = self.ln_f(x)
        logits = self.lm_head(x)
        
        if targets is None:
            loss = None
        else:
            B, T, C = logits.shape
            logits = logits.view(B*T, C)
            targets = targets.view(B*T)
            loss = F.cross_entropy(logits, targets)
        
        return logits, loss
    
    def generate(self, idx, max_new_tokens):
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -block_size:]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :]
            probs = F.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, idx_next), dim=1)
        return idx
    
    def generate_verbose(model, idx, max_new_tokens):
        print("시작:", decode(idx[0].tolist()))
        print("-" * 40)
        
        for step in range(max_new_tokens):
            idx_cond = idx[:, -block_size:]
            logits, _ = model(idx_cond)
            logits = logits[:, -1, :]
            probs = F.softmax(logits, dim=-1)
            idx_next = torch.multinomial(probs, num_samples=1)
            idx = torch.cat((idx, idx_next), dim=1)
            
            new_char = decode([idx_next[0].item()])
            current_text = decode(idx[0].tolist())
            print(f"{step+1}회: +'{new_char}' → {current_text}")
        
        return idx

# ============ 3. 하이퍼파라미터 ============
block_size = 32   # 한 번에 보는 글자 수
n_embed = 64      # 임베딩 차원
n_head = 4        # Attention Head 수
n_layer = 4       # Transformer Block 수
batch_size = 16
learning_rate = 1e-3
max_iters = 1000

# ============ 4. 학습 ============
model = MiniLLM().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)

# 배치 생성 함수
def get_batch():
    ix = torch.randint(len(data) - block_size, (batch_size,))
    x = torch.stack([data[i:i+block_size] for i in ix])
    y = torch.stack([data[i+1:i+block_size+1] for i in ix])
    return x, y

# 학습 루프
print("학습 시작...")
for iter in range(max_iters):
    xb, yb = get_batch()
    logits, loss = model(xb, yb)
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    if iter % 100 == 0:
        print(f"Step {iter}: loss = {loss.item():.4f}")

print("학습 완료!")

# ============ 5. 텍스트 생성 ============
print("\n--- 생성 결과 ---")
context = torch.tensor([encode("날씨")], device=device)
generated = model.generate(context, max_new_tokens=50)
print(decode(generated[0].tolist()))

실행 결과

LLM(Large Language Model) 처음부터 만들기 학습 노트 image 1

6. 복습 퀴즈

  1. LLM이 하는 일을 한 문장으로?

  2. 토큰화와 임베딩의 차이는?

  3. Q, K, V를 도서관 비유로 설명하면?

  4. Head를 여러 개 쓰는 이유는?

  5. 잔차 연결이 필요한 이유는?

  6. 학습과 예측의 차이는?

  7. Transformer Block을 여러 개 쌓으면 왜 더 똑똑해지나?

정답

  1. 다음 토큰 예측

  2. 토큰화는 숫자만 매김, 임베딩은 의미 있는 벡터로 변환

  3. Q는 찾고자 하는 것, K는 책의 태그, V는 실제 값

  4. 여러 눈으로 다양한 패턴을 파악하기 위해

  5. 레이어가 깊어지면 원본 정보가 사라지므로 원본 보존

  6. 학습은 모델 수정, 예측은 다음 토큰 출력

  7. 여러 층에서 더 복잡하고 추상적인 패턴을 이해


7. 다음 학습 단계

  • Andrej Karpathy의 "Let's build GPT from scratch" 유튜브 시리즈

  • nanoGPT GitHub 코드 직접 실행해보기

  • Hugging Face NLP Course

  • Transformer 원 논문 "Attention Is All You Need" 읽기