모듈 01 · 기초 원리

퍼셉트론에서 Transformer까지:
현대 LLM의 토대가 되는 것

이 장에서는 최소한의 고등 수학으로 "AI가 실제로 어떻게 동작하는가"를 끝까지 파헤칩니다. 프로덕트, 엔지니어, 영업 어느 쪽이든 이 장을 끝까지 읽고 나면, 이후 모든 모델과 프레임워크에 대한 논의를 따라갈 수 있게 됩니다.

1. 인공 신경망: 생물학적 뉴런에서 수학 함수로

1.1 신경망의 최소 단위

인공 신경망(Neural Network)은 뇌의 뉴런이 동작하는 방식에서 영감을 받았습니다. 단일 뉴런은 본질적으로 다음과 같은 수학 함수입니다:

output = activation(w · x + b)

여기서 x는 입력 벡터, w는 가중치, b는 편향, activation은 활성화 함수(주로 사용되는 것은 Sigmoid, ReLU, GELU)입니다. 이런 "뉴런"을 수천 개씩 층으로 쌓고 서로 연결하면, 현대 딥러닝의 토대가 만들어집니다.

1.2 신경망 훈련의 3대 요소

💡 핵심 직관: 신경망 훈련이란 본질적으로 "수십억 차원의 parameter space에서 손실 함수의 값을 최소화하는 가중치 조합을 찾는" 작업입니다. 그래서 LLM에는 엄청난 양의 데이터와 컴퓨팅 자원이 필요합니다 — parameter space가 너무 거대하기 때문입니다.

2. Transformer: 현대 LLM의 초석

2017년 Google이 Attention Is All You Need라는 논문에서 Transformer 아키텍처를 제안하며 AI의 발전 궤적을 완전히 바꾸어 놓았습니다. 핵심 혁신은 순환 구조를 완전히 버리고 어텐션 메커니즘(Attention Mechanism)만으로 시퀀스 데이터를 처리한다는 점입니다.

2.1 왜 Transformer가 NLP를 평정했는가

특성RNN / LSTMTransformer
장거리 의존성초기 컨텍스트를 잊어버리기 쉬움임의의 위치끼리 직접 연결
병렬 계산반드시 순차 처리모든 위치를 동시에 처리
훈련 속도느림빠름(GPU를 충분히 활용)
확장성1000억 파라미터 이상으로 확장하기 어려움1000억 파라미터 이상을 네이티브로 지원

2.2 셀프 어텐션(Self-Attention)

셀프 어텐션의 핵심 아이디어는, 시퀀스 안의 모든 단어에 대해 다른 모든 단어를 "살펴보고" 각 단어가 자신에게 얼마나 중요한지 판단하는 것입니다.

예를 들어 다음 문장을 봅시다 — "철수는 어제 서울로 출장을 갔고, 오늘 는 돌아왔다":

  1. 각 단어는 세 개의 벡터를 만듭니다: Query(Q, 질의 벡터), Key(K, 키 벡터), Value(V, 값 벡터).
  2. "그"에 대해, 그 Q와 다른 모든 단어의 K의 내적을 계산해 관련도 점수를 얻습니다.
  3. 점수에 Softmax 정규화를 적용해 어텐션 가중치를 얻습니다(예: "그"의 "철수"에 대한 가중치는 0.8, "서울"에 대한 가중치는 0.1).
  4. 이 가중치를 사용해 모든 단어의 V를 가중 합산해, "그"라는 단어의 컨텍스트가 풍부한 새 표현을 얻습니다.

수식은 다음과 같이 간결하게 쓸 수 있습니다:

Attention(Q, K, V) = softmax(Q · K^T / √d_k) · V

여기서 d_k는 Key 벡터의 차원이고, 그 제곱근으로 나누는 이유는 내적이 너무 커져서 Softmax의 그래디언트가 소실되는 것을 막기 위해서입니다.

2.3 멀티 헤드 어텐션(Multi-Head Attention)

멀티 헤드 어텐션은 셀프 어텐션의 업그레이드 버전입니다. 모델이 서로 다른 "표현 부분공간"에서 여러 셀프 어텐션을 병렬로 수행하고, 그 결과를 이어 붙입니다. 이를 통해 모델은 여러 종류의 관계를 동시에 포착할 수 있습니다:

2.4 위치 인코딩(Positional Encoding)

Transformer는 모든 단어를 병렬로 처리하기 때문에 본질적으로 "순서" 정보를 잃습니다. 예를 들어 "개가 사람을 물었다"와 "사람이 개를 물었다"는 글자 그대로의 토큰은 완전히 같고 순서만 다릅니다. 위치 인코딩은 각 위치의 단어 임베딩에 고유한 "위치 벡터"를 더해 모델이 각 단어의 위치를 알 수 있게 합니다.

최초의 Transformer는 고정된 사인/코사인 함수로 위치 인코딩을 생성했습니다. 이후의 모델(LLaMA, Qwen 등)은 RoPE(Rotary Position Embeddings, 회전 위치 인코딩)를 채택해 더 긴 시퀀스를 더 잘 처리합니다.

3. Transformer의 전체 아키텍처

표준 Transformer 블록은 두 부분으로 구성됩니다:

입력 → [LayerNorm → 멀티 헤드 어텐션 → 잔차 연결]
    → [LayerNorm → 피드포워드 네트워크(FFN) → 잔차 연결]
    → 출력

3.1 Transformer 전체 아키텍처 다이어그램(한눈에 파악)

Decoder-only Transformer(GPT 스타일)
입력 임베딩 + 위치 인코딩
×N 층 ►
Linear (LM Head) + Softmax
P(token₁), P(token₂), ...

핵심 3종 세트:

GPT 계열 모델(ChatGPT, DeepSeek, Qwen 등)은 이런 블록을 수십~수백 층 쌓아 만든 Decoder-only Transformer입니다.

4. 사전학습, SFT, RLHF: 대규모 모델의 "3단계 훈련법"

"말할 수 있는" 상태에서 "들을 수 있는", 그리고 "사랑받는" 상태가 되기까지, 대규모 모델은 세 단계를 거칩니다:

4.0 3단계 훈련법의 흐름도

원시 말뭉치(수조 토큰) 사람 라벨링 Q&A(10만 건)
① 사전학습 Pre-Training 과제: Next-Token- Prediction
데이터: 인터넷 텍스트 14.8T tokens 비용:~557만$
[Base Model] [Chat Model] (이어 쓰기 가능, 대화 불가) (질문에 답할 수 있음)
③ RLHF / DPO 인간 피드백 강화 학습
과제: "사랑받기" 배우기
방식: 인간이 여러 응답에 순위를 매김
[Aligned Model] (인간 가치관에 정렬)

3단계는 점진적으로 쌓아 올린다: "세계를 이해" → "사람의 말을 이해" → "인간 가치관에 정렬". 어느 한 단계라도 빠지면 모델은 "쓸모가 없다".

4.1 사전학습(Pre-Training)

방대한 인터넷 텍스트(수조 토큰)로 모델을 훈련해 "다음 단어를 예측"하는 법을 가르칩니다. 이 단계가 가장 오래 걸리고 비용도 가장 큽니다 — DeepSeek-V3는 14.8조 토큰 훈련에 약 557만 달러(GPU 임대료)를 썼습니다.

4.2 지도 미세조정(Supervised Fine-Tuning, SFT)

사람이 라벨링한 고품질 "지시-응답" 쌍으로 모델을 훈련해 "사람의 지시를 듣고 그에 맞게 답하는" 법을 가르칩니다. 이 단계의 데이터 양은 사전학습보다 훨씬 적지만(수만~수십만 건) 품질 요구 수준은 극도로 높습니다.

4.3 인간 피드백 기반 강화학습(RLHF)

사람이 모델이 만든 여러 응답에 순위를 매기고, 그 순위 데이터로 "보상 모델"(Reward Model)을 훈련합니다. 그다음 강화학습(주로 PPO 또는 DPO 알고리즘)으로 모델이 더 선호되는 응답을 생성하도록 조정합니다.

5. 왜 GPU 클러스터가 필요한가?

1000억 파라미터 모델을 훈련하려면, 순전파 + 역전파 한 번에 다음이 필요합니다:

단일 GPU의 메모리(80GB)에는 도저히 들어가지 않으므로, 여러 병렬 전략을 조합해야 합니다:

병렬 방식핵심 아이디어대표 프레임워크
데이터 병렬데이터를 여러 GPU에 분할, 각 GPU가 모델 전체 실행PyTorch DDP
모델 병렬모델의 서로 다른 층을 서로 다른 GPU에 배치Megatron-LM
파이프라인 병렬모델을 단계로 나누고, 각 GPU가 서로 다른 마이크로 배치를 처리GPipe, PipeDream
텐서 병렬단일 행렬 연산을 여러 GPU에 분할Megatron-LM

6. LLM에서 Agent로: 능력의 도약

LLM이 충분히 강해지자 사람들은 여기에 "확장 기능"을 붙이기 시작했습니다: 모델이 "말하는" 것만이 아니라 "행동하는" 것까지 가능하도록. 이것이 AI Agent의 출발점입니다:

이 부분에 대해서는 다음 장 "Agent 진화"에서 깊이 있게 다룹니다.

7. 이 장의 요약

8. 핸즈온: Mini Transformer를 처음부터 구축하기

이론을 모두 다뤘으니, 이번 섹션에서는 PyTorch로 6층 Decoder-only Transformer를 처음부터 만들어 작은 말뭉치에서 훈련과 추론을 시연합니다. 전체 코드는 약 250줄, CPU/GPU에서 모두 동작하며, Transformer의 톱니바퀴 하나하나가 눈에 보이게 만드는 것이 목표입니다.

8.1 목표와 환경

환경 설정(2줄):

pip install torch numpy
python -c "import torch; print('torch', torch.__version__)"

8.2 데이터 준비

짧은 "한시 스타일" 더미 데이터를 사용합니다(자신만의 input.txt로 교체해도 좋습니다):

cat > data/input.txt << 'EOF'
밤의 생각
침상 앞에 밝은 달빛, 의심하건대 이는 땅 위의 서리.
머리 들면 산의 달을 바라보고, 고개 숙이면 고향을 생각하네.
봄 새벽
봄잠은 새벽을 모르니,处处에서 울새 소리를 듣네.
어젯밤 풍우 소리, 꽃이 떨어진 줄 몇이나 알랴.
백구작루
해는 산에 기대어 다하고, 황하는 바다로 흘러가네.
千里의 눈을 다하려거든, 한 층 더 올라갈지니.
EOF
wc -c data/input.txt

문자 단위 언어 모델은 파일 전체를 하나의 긴 문자열로 읽으면 됩니다, tokenizer는 필요 없습니다.

8.3 전체 코드: train.py

"""Mini Transformer: 6층 Decoder-only, 문자 단위 언어 모델.

사용법:
    python train.py
"""
from pathlib import Path



# ---------- 1. 데이터 로더 ----------
class CharDataset:
    """텍스트 파일을 (block_size) 길이의 학습 샘플로 자른다."""

    def __init__(self, path: str, block_size: int = 64):
        self.text = Path(path).read_text(encoding="utf-8")
        self.chars = sorted(set(self.text))
        self.vocab_size = len(self.chars)
        self.stoi = {c: i for i, c in enumerate(self.chars)}
        self.itos = {i: c for i, c in enumerate(self.chars)}
        self.block_size = block_size
        self.data = torch.tensor([self.stoi[c] for c in self.text], dtype=torch.long)
        print(f"[data] chars={self.vocab_size} len={len(self.data)}")

    def get_batch(self, batch_size: int = 32):
        ix = torch.randint(0, len(self.data) - self.block_size - 1, (batch_size,))
        x = torch.stack([self.data[i : i + self.block_size] for i in ix])
        y = torch.stack([self.data[i + 1 : i + 1 + self.block_size] for i in ix])
        return x, y


# ---------- 2. 모델 컴포넌트 ----------
class PositionalEncoding(nn.Module):
    """사인 위치 인코딩. 고정되어 학습되지 않음."""

    def __init__(self, d_model: int, max_len: int = 512):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len).unsqueeze(1).float()
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        self.register_buffer("pe", pe.unsqueeze(0))  # (1, max_len, d_model)

    def forward(self, x):
        return x + self.pe[:, : x.size(1)]


class CausalSelfAttention(nn.Module):
    """멀티 헤드 인과 셀프 어텐션(Decoder에서 사용)."""

    def __init__(self, d_model: int, n_heads: int, max_len: int = 512, dropout: float = 0.1):
        super().__init__()
        assert d_model % n_heads == 0
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model)
        self.proj = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
        # causal mask: 위쪽 삼각(대각선 제외)을 -inf로
        mask = torch.triu(torch.ones(max_len, max_len), diagonal=1).bool()
        self.register_buffer("mask", mask)

    def forward(self, x):
        B, T, C = x.shape
        qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim).permute(2, 0, 3, 1, 4)
        q, k, v = qkv[0], qkv[1], qkv[2]  # (B, h, T, head_dim)
        att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
        att = att.masked_fill(self.mask[:T, :T], float("-inf"))
        att = F.softmax(att, dim=-1)
        att = self.dropout(att)
        out = (att @ v).transpose(1, 2).reshape(B, T, C)
        return self.proj(out)


class FeedForward(nn.Module):
    """Transformer FFN: GELU + Linear + Dropout."""

    def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)


class Block(nn.Module):
    """Pre-LN Decoder 블록: Attention -> FFN, 각각 잔차 연결 포함."""

    def __init__(self, d_model: int, n_heads: int, d_ff: int, max_len: int, dropout: float):
        super().__init__()
        self.ln1 = nn.LayerNorm(d_model)
        self.attn = CausalSelfAttention(d_model, n_heads, max_len, dropout)
        self.ln2 = nn.LayerNorm(d_model)
        self.ffn = FeedForward(d_model, d_ff, dropout)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.ffn(self.ln2(x))
        return x


class MiniTransformer(nn.Module):
    """6층 Decoder-only Transformer, 문자 단위 언어 모델."""

    def __init__(
        self,
        vocab_size: int,
        d_model: int = 128,
        n_heads: int = 4,
        d_ff: int = 512,
        n_layers: int = 6,
        max_len: int = 256,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, d_model)
        self.pos_enc = PositionalEncoding(d_model, max_len)
        self.blocks = nn.ModuleList(
            [Block(d_model, n_heads, d_ff, max_len, dropout) for _ in range(n_layers)]
        )
        self.ln_f = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, vocab_size, bias=False)
        # 가중치 공유: 입력 임베딩과 출력 head가 가중치를 공유 (BERT/GPT에서 자주 쓰이는 트릭)
        self.head.weight = self.tok_emb.weight
        self.apply(self._init_weights)

    def _init_weights(self, m):
        if isinstance(m, nn.Linear):
            nn.init.normal_(m.weight, std=0.02)
            if m.bias is not None:
                nn.init.zeros_(m.bias)
        elif isinstance(m, nn.Embedding):
            nn.init.normal_(m.weight, std=0.02)

    def forward(self, idx, targets=None):
        x = self.tok_emb(idx)
        x = self.pos_enc(x)
        for blk in self.blocks:
            x = blk(x)
        x = self.ln_f(x)
        logits = self.head(x)
        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
        return logits, loss

    @torch.no_grad()
    def generate(self, idx, max_new_tokens: int = 100, temperature: float = 1.0, top_k: int = 20):
        """자기회귀 생성. temperature + top-k 샘플링 포함."""
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -self.pos_enc.pe.size(1) :]
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :] / temperature
            if top_k:
                v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
                logits[logits < v[:, [-1]]] = float("-inf")
            probs = F.softmax(logits, dim=-1)
            nxt = torch.multinomial(probs, num_samples=1)
            idx = torch.cat([idx, nxt], dim=1)
        return idx


# ---------- 3. 훈련 루프 ----------
def main():
    torch.manual_seed(0)
    ds = CharDataset("data/input.txt", block_size=64)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model = MiniTransformer(vocab_size=ds.vocab_size).to(device)
    print(f"[model] params={sum(p.numel() for p in model.parameters()) / 1e6:.2f}M device={device}")

    opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
    steps = 1000
    batch_size = 32
    t0 = time.time()
    for step in range(steps):
        x, y = ds.get_batch(batch_size)
        x, y = x.to(device), y.to(device)
        _, loss = model(x, y)
        opt.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        if step % 100 == 0 or step == steps - 1:
            dt = time.time() - t0
            print(f"step {step:4d} loss={loss.item():.4f} elapsed={dt:.1f}s")
    torch.save({"model": model.state_dict(), "stoi": ds.stoi, "itos": ds.itos}, "ckpt.pt")
    print("[done] saved ckpt.pt")


if __name__ == "__main__":
    main()

8.4 추론 스크립트: sample.py

"""훈련된 ckpt.pt로 텍스트를 생성한다."""
from train import MiniTransformer

ckpt = torch.load("ckpt.pt", map_location="cpu")
stoi, itos = ckpt["stoi"], ckpt["itos"]
model = MiniTransformer(vocab_size=len(itos))
model.load_state_dict(ckpt["model"])
model.eval()

prompt = "밤의 생각\n"
idx = torch.tensor([[stoi[c] for c in prompt]], dtype=torch.long)
out = model.generate(idx, max_new_tokens=120, temperature=0.8, top_k=10)[0].tolist()
print("=== generated ===")
print("".join(itos[i] for i in out))

8.5 실행과 예상 출력

mkdir -p data
# (8.2절의 input.txt를 작성)
python train.py

예상 출력(CPU에서 약 30-60초, MPS/GPU에서는 수 초):

[data] chars=28 len=87
[model] params=1.21M device=cpu
step    0 loss=3.3321 elapsed=0.0s
step  100 loss=2.7102 elapsed=2.1s
step  200 loss=2.3504 elapsed=4.0s
...
step  900 loss=1.8421 elapsed=32.5s
step  999 loss=1.7903 elapsed=36.0s
[done] saved ckpt.pt

python sample.py
=== generated ===
밤의 생각
침상 앞에 밝은 달빛, 의심하건대 이는 땅 위의 서리.
머리 들면 산의 달을 바라보고, 고개 숙이면 고향을 생각하네.
봄잠은 새벽을 모르니,处处에서 울새 소리를 듣네.

스크린샷: 훈련 곡선(loss vs step) — tensorboard 또는 matplotlib으로 loss.item()을 그려 loss_curve.png로 저장. 생성 샘플 — 터미널 텍스트를 그대로 복사.

8.6 튜닝 팁

8.7 다음 단계

📚 이전 섹션: 7. 이 장의 요약 · 다음 장: Agent 진화 - LLM에서 Agentic Agent로