Skip to main content
Views 345

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

Summary

파인만 기법으로 배우는 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" 읽기

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