nanoGPT로 배우는 GPT 밑바닥 구현 가이드
Andrej Karpathy의 Zero-to-Hero 강연을 바탕으로 문자 단위 토큰화, 바이그램 베이스라인, 인과적 셀프 어텐션과 트랜스포머 블록을 조립해 소형 GPT를 직접 구현하는 과정을 쉽게 설명합니다.
무료 공개 · 최근 수정
다음 글자를 예측하는 자기회귀 모델
ChatGPT처럼 긴 글이나 코드를 자연스럽게 작성하는 대규모 언어 모델(LLM)도 내부에서 수행하는 핵심 작업은 단 하나입니다. 지금까지 주어진 글자(토큰) 배열을 보고 바로 다음에 올 확률이 가장 높은 글자 하나를 맞춘 뒤, 그 글자를 입력 끝에 이어 붙여 다시 다음 글자를 예측하는 과정을 반복합니다.
이처럼 이전 출력 토큰을 다시 입력으로 삼아 한 단계씩 시퀀스를 생성하는 구조를 자기회귀 언어 모델(Autoregressive Language Model)이라고 부릅니다. 전 테슬라 AI 디렉터이자 OpenAI 창립 멤버인 Andrej Karpathy는 "Let's build GPT: from scratch, in code, spelled out" 강연과 공식 저장소(karpathy/ng-video-lecture)에서 약 111만 글자로 이루어진 셰익스피어 희곡 파일(input.txt) 하나만을 사용해 이 원리를 밑바닥부터 구현합니다.
신경망은 문자열을 직접 계산할 수 없으므로 텍스트를 숫자 배열로 바꾸는 토큰화(Tokenization)가 먼저 필요합니다. GPT-2·GPT-3는 자주 쓰이는 글자 묶음을 합치는 BPE(tiktoken 기준 50,257개 어휘)를 쓰지만, 학습용 구현에서는 글자 하나를 정수 하나에 대응시키는 문자 단위(Character-level) 토크나이저를 사용합니다. 셰익스피어 전체 데이터셋의 고유 문자는 총 65개이며, 어휘 사전 크기(vocab_size)가 작아 임베딩 테이블과 출력 계층이 매우 가벼워집니다(토큰 분할과 컨텍스트 한계는 LLM 토큰과 컨텍스트 창 문서를 참고합니다). 먼저 첫 대사 한 줄(고유 문자 27개)만 발췌해 토크나이저 동작을 확인하면 다음과 같습니다.
text = "First Citizen:\nBefore we proceed any further, hear me speak.\n"
chars = sorted(list(set(text)))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: "".join([itos[i] for i in l])
encoded_sample = encode("First")
print("고유 문자 수:", len(chars))
print("인코딩 결과:", encoded_sample)
print("디코딩 복원:", decode(encoded_sample))파이썬 3.12 환경에서 실행한 결과는 다음과 같습니다.
고유 문자 수: 27
인코딩 결과: [7, 14, 20, 21, 22]
디코딩 복원: First정수 배열로 변환한 데이터는 최대 문맥 길이인 block_size(시간 축 T)와 병렬 처리 개수인 batch_size(배치 축 B) 크기로 잘라 모델에 주입합니다. 길이 T=8짜리 조각 하나에는 [18]만 보고 47 맞추기부터 [18, 47, ...]을 보고 다음 숫자 맞추기까지 8개의 학습 문제가 동시에 담깁니다.
트랜스포머를 조립하기 전 가장 단순한 기준점(Baseline)으로 직전 글자 단 하나만 보고 다음 글자를 찍는 바이그램(Bigram) 모델(nn.Embedding(65, 65))을 먼저 돌려 봅니다.
| 비교 항목 | 바이그램(Bigram) 베이스라인 | 디코더 전용 GPT (트랜스포머) |
|---|---|---|
| 참고하는 문맥 범위 | 직전 토큰 1개만 참조 | 최대 block_size까지의 모든 과거 토큰 참조 |
| 토큰 간 정보 교환 | 없음 (조회 테이블에서 즉시 점수 반환) | 셀프 어텐션 행렬 곱셈으로 과거 문맥 가중 결합 |
| 위치 정보 활용 | 순서 개념 없음 | 토큰 임베딩에 위치 임베딩(pos_emb)을 더해 순서 구분 |
| 셰익스피어 검증 손실(Val Loss) | 약 2.45 (단어 형태만 흉내 낸 무의미한 나열) | 약 1.48 (등장인물 대사와 줄바꿈 형식을 갖춘 문장) |
65개 글자를 무작위로 찍을 때의 이론적 초기 교차 엔트로피 손실(-ln(1/65))은 약 4.17입니다. 바이그램 모델을 학습시키면 손실이 2.45까지 내려가지만, 과거 글자들을 서로 연결해 문맥을 파악하는 능력이 없어 "Thandithe" 같은 무의미한 문자열만 생성합니다.
미래를 가리는 인과적 셀프 어텐션
모델이 문맥에 맞는 문장을 쓰려면 현재 위치(t)의 토큰이 과거 위치(0부터 t-1까지)의 토큰들과 정보를 주고받아야 합니다. 단, 미래 위치(t+1 이후)의 토큰은 예측해야 할 정답이므로 절대 미리 훔쳐보면 안 됩니다. 이처럼 미래 위치의 어텐션 가중치를 강제로 0으로 차단하는 기법을 인과적 마스킹(Causal Masking)이라고 합니다.
다음 그림은 입력 토큰이 위치 임베딩과 결합된 뒤 인과적 어텐션과 피드포워드 블록을 거쳐 다음 글자를 반복 생성하는 전체 파이프라인을 보여 줍니다.
그림: 토큰 임베딩부터 인과적 어텐션과 피드포워드 블록을 거쳐 다음 토큰을 반복 생성하는 GPT 파이프라인
Karpathy는 과거 토큰들의 정보를 모으는 과정을 '과거 벡터들의 평균 구하기'에서 출발해 3단계 행렬 연산으로 발전시킵니다.
- 하삼각 행렬(
torch.tril) 곱셈을 통한 누적 평균: 파이썬for반복문으로 매 위치마다 과거 토큰들의 평균을 구하면 매우 느립니다. 대각선과 그 아래쪽만1이고 위쪽(미래)은0인 하삼각 행렬을 만들어 입력 행렬과 곱하면 단 한 번의 행렬 곱셈으로 모든 위치의 과거 평균을 동시에 계산합니다. - 소프트맥스(
softmax)를 이용한 마스킹 정규화: 하삼각 행렬에서0인 자리(미래 위치)를 음의 무한대(-inf)로 채운 뒤 소프트맥스 함수를 통과시키면exp(-inf) = 0이 되어 미래 정보는 완전히 차단되고 과거 위치의 가중치 합만1이 됩니다. - Q·K·V 내적을 통한 문맥 맞춤형 가중합: 모든 과거 글자를 똑같은 비율로 평균 내는 대신, 각 토큰이 쿼리(Query,
q, 내가 찾는 조건), 키(Key,k, 내가 가진 꼬리표), 밸류(Value,v, 실제 전달할 정보) 벡터를 내보냅니다. 쿼리와 키를 내적(q @ k.transpose(-2, -1))해 관련성이 높은 과거 토큰에 높은 가중치를 부여하고 밸류(v)를 곱해 합산합니다(수식 상세는 트랜스포머와 셀프 어텐션 문서를 참고합니다).
[참고] 어텐션 자체에는 위치 개념이 없다
셀프 어텐션의 내적 연산은 입력 토큰들을 순서가 없는 집합(Set)처럼 취급합니다. 따라서 어텐션에 들어가기 전에 각 토큰의 의미 벡터(
token_embedding_table)에0, 1, ..., T-1좌표를 나타내는 위치 임베딩(position_embedding_table)을 반드시 더해주어야 모델이 단어의 순서를 인식합니다.
소통과 숙고를 묶는 트랜스포머 블록
단일 어텐션 헤드 하나만 쓰면 한 가지 관점의 연관성밖에 찾지 못하므로, 임베딩 차원(n_embd)을 여러 개의 작은 채널로 나누어 어텐션을 병렬로 수행하고 결과를 이어 붙이는 멀티헤드 어텐션(Multi-Head Attention)을 사용합니다.
어텐션으로 다른 토큰들의 정보를 모았다면, 각 토큰이 수집한 정보를 바탕으로 비선형 연산을 수행할 차례입니다. Karpathy는 어텐션을 토큰들이 서로 대화하는 소통(Communication) 단계로, 피드포워드 신경망(MLP)을 모은 정보를 각 토큰이 개별적으로 곱씹어 생각하는 숙고(Computation) 단계로 정의합니다.
신경망이 깊어질 때 역전파 기울기(Gradient)가 사라지는 문제를 막기 위해, 각 연산 블록의 출력에 원래 입력을 그대로 더해주는 잔차 연결(Residual Connection, x = x + sublayer(x))과 평균을 0·분산을 1로 맞추는 사전 레이어 정규화(Pre-LayerNorm)를 결합합니다.
다음 그림은 트랜스포머 블록 내부에서 사전 정규화, 멀티헤드 어텐션(소통), 피드포워드 MLP(숙고), 잔차 스킵 고속도로가 결합되는 구조를 보여 줍니다.
그림: 사전 정규화 후 소통(어텐션)과 숙고(MLP)를 거치며 잔차 고속도로로 원본 입력을 더하는 블록 구조
먼저 하삼각 행렬(tril)로 미래 토큰을 차단하는 단일 인과적 어텐션 헤드(Head)의 핵심 코드는 다음과 같습니다.
import torch
import torch.nn as nn
from torch.nn import functional as F
class Head(nn.Module):
def __init__(self, n_embd, head_size, block_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)
self.register_buffer("tril", torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
B, T, C = x.shape
k, q = self.key(x), self.query(x)
wei = q @ k.transpose(-2, -1) * (k.shape[-1] ** -0.5)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float("-inf"))
wei = F.softmax(wei, dim=-1)
return wei @ self.value(x)위 헤드를 묶은 MultiHeadAttention과 FeedForward를 잔차 연결로 조립한 Block 클래스 및 자기회귀 생성 루프(generate)는 다음과 같이 구성됩니다.
class Block(nn.Module):
def __init__(self, n_embd, n_head, block_size):
super().__init__()
head_size = n_embd // n_head
self.sa = MultiHeadAttention(n_head, head_size, n_embd, block_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)) # 소통 단계 + 잔차 연결
x = x + self.ffwd(self.ln2(x)) # 숙고 단계 + 잔차 연결
return x
@torch.no_grad()
def generate(model, idx, max_new_tokens, block_size):
"""자기회귀 토큰 생성 루프 핵심부 (데이터 로더 및 전체 모델 클래스 생략)"""
for _ in range(max_new_tokens):
idx_cond = idx[:, -block_size:] # 최대 문맥 길이만큼 슬라이싱
logits, _ = model(idx_cond) # (B, T, vocab_size) 점수 계산
probs = F.softmax(logits[:, -1, :], dim=-1) # 마지막 위치의 확률 분포 변환
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1) # 예측한 토큰을 입력 끝에 추가
return idx데이터 로더(get_batch), MultiHeadAttention·FeedForward 상세 정의, AdamW 학습 루프가 모두 포함된 전체 코드는 공식 저장소의 gpt.py 전체 소스 코드와 nanoGPT의 model.py에서 확인하고 실행할 수 있습니다.
[참고] 코드 검증 범위와 하이퍼파라미터 확장
앞절의 토크나이저 코드는 로컬 Python 3.12 환경에서 직접 실행하여 출력을 확인했으며,
Head·Block·generate코드는karpathy/ng-video-lecture저장소의 공식gpt.py핵심부를 발췌한 것입니다.n_embd=384,n_head=6,n_layer=6,block_size=256,Dropout(0.2)로 규모를 키우고 A100 GPU에서 5,000 스텝을 학습하면 셰익스피어 데이터셋 검증 손실(Val Loss)이1.48까지 내려갑니다.
심화 과제와 ChatGPT로의 확장
코드를 실행한 뒤 모델 구조를 깊이 체득할 수 있도록 Karpathy가 제안한 4가지 연습 과제(EX1~EX4)는 다음과 같습니다.
- 멀티헤드 텐서 차원 병렬화(EX1):
Head클래스를 리스트(nn.ModuleList)로 순차 실행하는 대신,nanoGPT처럼 헤드 차원(nh)을 배치 차원처럼 취급해(B, nh, T, hs)4차원 텐서 하나로 한 번에 병렬 연산하도록 통합합니다. - 덧셈 계산기 학습과 역순 출력(EX2):
a+b=c덧셈 문제를 학습시킵니다. 사람이 일의 자리부터 받아올림을 계산하듯 결과c의 자릿수를 역순으로 출력하게 하고, 문제 부분(a+b=)의 타깃을-1(ignore_index=-1)로 마스킹하면 덧셈 규칙을 훨씬 빠르게 학습합니다. - 대규모 사전학습 후 파인튜닝(EX3): 더 큰 텍스트 데이터셋으로 먼저 사전학습(Pretraining)한 뒤 셰익스피어 데이터로 낮은 학습률의 파인튜닝을 수행해 검증 손실이 더 낮아지는지 확인합니다.
- 최신 트랜스포머 변형 실험(EX4): 회전 위치 임베딩(RoPE), SwiGLU 활성화 함수, RMSNorm 등 최신 기법을 하나씩 적용해 검증 손실(
1.48) 개선 여부를 실험합니다.
이 실습에서 완성한 모델은 원시 텍스트에서 다음 단어를 예측하도록 훈련된 사전학습 기본 모델(Base Model)입니다. 여기에 질문과 모범 답안 쌍으로 대화 형식을 가르치는 지도 미세조정(SFT)과 인간 피드백 기반 강화학습(RLHF)을 차례로 거치면 질문 의도에 맞춰 답하는 ChatGPT가 완성됩니다(파인튜닝 비교는 RAG와 파인튜닝 비교, 샘플링 확률 조절은 Temperature와 Top-p 샘플링 문서를 참고합니다).
참고 문서
- Andrej Karpathy — Let's build GPT: from scratch, in code, spelled out (YouTube 강연)
- karpathy/ng-video-lecture — 강연 공식 주피터 노트북 및
gpt.py전체 소스 코드 (GitHub) - karpathy/nanoGPT — GPT-2 재현 및 사전학습·파인튜닝용 경량 PyTorch 구현체 (GitHub)
- Attention Is All You Need — Vaswani et al., 2017 원 논문 (arXiv:1706.03762)
- Language Models are Few-Shot Learners — OpenAI GPT-3 논문 (arXiv:2005.14165)
설명이 어렵거나 잘못된 부분을 발견하셨나요?
문서 수정 의견 보내기