243줄로 만든 GPT, 이 코드를 읽으면 ChatGPT가 어떻게 작동하는지 보입니다

2026년 2월 11일, Andrej Karpathy가 GitHub Gist에 파일 하나를 올렸습니다. 파일 이름은 microgpt.py, 파이썬 243줄짜리 코드입니다. 이 안에 GPT를 학습시키고 텍스트를 생성하는 알고리즘이 전부 들어 있습니다. PyTorch도, TensorFlow도, NumPy도 쓰지 않았습니다. import한 건 os, math, random 세 개뿐입니다.
Karpathy는 이 코드를 공개하면서 이렇게 말했습니다. "이것이 필요한 알고리즘의 전부입니다. 나머지는 모두 효율을 위한 것일 뿐입니다. 더 이상 단순하게 만들 수 없습니다."
내가 이 코드를 처음 봤을 때 든 생각은 "진짜 이게 GPT의 전부인가?"였습니다. ChatGPT를 매일 쓰면서도 그 안에서 무슨 일이 벌어지는지 모르는 개발자가 대부분입니다. 나도 처음에는 그랬습니다. 수학이 복잡할 것 같고, 뭔가 대단한 비밀이 숨어 있을 것 같았습니다. 그런데 이 243줄을 한 줄씩 읽고 나니 생각이 바뀌었습니다. GPT에는 마법이 없습니다. 수학 함수를 조합한 거고, 핵심 아이디어는 고등학교 수학이면 충분합니다. 구현 디테일을 따라가려면 기초 미적분과 선형대수가 좀 필요하긴 합니다.
이 기사에서는 microGPT 코드를 한 줄씩 뜯어봅니다. 끝까지 읽고 나면 ChatGPT가 어떻게 다음 단어를 만들어내는지 감이 잡힐 겁니다.
GPT는 결국 다음 글자를 맞히는 기계입니다
GPT가 하는 일을 한 문장으로 줄이면 이렇습니다. 앞에 나온 글자를 보고, 다음에 올 글자를 예측합니다.
"안녕하세"라는 텍스트가 주어지면, GPT는 다음에 "요"가 올 확률이 높다고 판단합니다. 이게 전부입니다. ChatGPT가 길고 유창한 문장을 만들어내는 것도 결국 이 과정을 수백, 수천 번 반복한 결과입니다. 한 글자를 예측하고, 그 글자를 붙이고, 다시 다음 글자를 예측하고. 이 반복이 문장이 되고, 문단이 되고, 에세이가 됩니다.
microGPT는 이 과정을 이름 데이터로 보여줍니다. 32,000개의 영어 이름을 학습한 뒤, 실제로는 존재하지 않지만 그럴듯한 새 이름을 만들어냅니다. "Emmajean", "Jayla", "Kael" 같은 이름이 생성됩니다. 이름 학습과 ChatGPT 학습의 차이는 데이터 양과 모델 크기뿐입니다. 알고리즘은 동일합니다.
전체 구조를 먼저 그려봅니다
다음 그림은 microGPT의 전체 흐름을 5단계로 요약한 것입니다.

데이터를 토큰으로 바꾸고, 자동 미분 엔진을 만들고, GPT 모델을 정의하고, 1,000번 학습한 뒤, 새 이름을 생성합니다. 이 다섯 단계가 GPT의 전부입니다.
microGPT 코드를 펼치면 크게 다섯 덩어리로 나뉩니다. 요리에 비유하면 이렇습니다.
첫째, 재료 준비입니다. 이름 데이터를 불러와서 글자 단위로 쪼갭니다. "Alice"라는 이름은 [A, l, i, c, e]가 됩니다. 이걸 코드에서는 토큰화라고 부릅니다.
둘째, 조리 도구를 만듭니다. Value라는 클래스를 정의하는데, 이게 자동 미분 엔진입니다. 모든 계산을 기록해두었다가, 나중에 "이 결과를 바꾸려면 어떤 숫자를 얼마나 조절해야 하는가"를 자동으로 계산해줍니다. PyTorch가 내부에서 하는 일을 40줄로 직접 만든 것입니다.
셋째, 요리 레시피를 정의합니다. 이것이 GPT 모델 아키텍처입니다. 토큰 임베딩, 위치 임베딩, 어텐션, MLP를 조합하여 입력 글자를 받아 다음 글자의 확률을 출력합니다.
넷째, 요리를 반복합니다. 학습 루프에서 이름 하나를 가져와 모델에 통과시키고, 틀린 정도를 계산하고, 파라미터를 조금씩 조절합니다. 이걸 1,000번 반복합니다.
다섯째, 맛을 봅니다. 학습이 끝나면 모델이 새 이름을 생성합니다. 이것이 추론 단계입니다.
이제 각 단계를 코드와 함께 살펴봅니다.
1단계. 데이터와 토크나이저 - 글자에 번호를 매기기
docs = [line.strip() for line in open('input.txt') if line.strip()] random.shuffle(docs) uchars = sorted(set(''.join(docs))) # 데이터에 등장하는 고유 글자들 BOS = len(uchars) # 문서 시작/끝을 알리는 특수 토큰 vocab_size = len(uchars) + 1 # 전체 어휘 크기: 글자 26개 + BOS 1개 = 27
이름 파일에는 "emma", "olivia", "ava" 같은 이름이 한 줄에 하나씩 적혀 있습니다. 컴퓨터는 글자를 직접 처리할 수 없으므로 숫자로 바꿔야 합니다. uchars는 데이터에 등장하는 모든 고유 글자를 알파벳 순으로 정렬한 리스트입니다. a는 0, b는 1, c는 2 ... z는 25가 됩니다.
BOS는 Beginning of Sequence의 약자로, 원래는 시작만 표시하고 끝에는 EOS(End of Sequence)를 별도로 쓰는 것이 관례입니다. 하지만 microGPT는 어휘 크기를 최소화하기 위해 하나의 특수 토큰(번호 26)으로 시작과 끝을 모두 표시합니다. "emma"라는 이름은 토큰으로 변환하면 [26, 4, 12, 12, 0, 26]이 됩니다. 앞뒤에 26이 붙어서 "여기서 이름이 시작되고 여기서 끝난다"를 알려줍니다.
ChatGPT도 같은 원리입니다. 다만 글자 단위가 아니라 단어 조각(subword) 단위로 쪼개고, 어휘 크기가 27이 아니라 수만 개라는 차이가 있을 뿐입니다.
2단계. 자동 미분 엔진 - "결과를 바꾸려면 어디를 조절해야 하는가"
이 부분이 microGPT에서 가장 중요한 코드입니다. 처음 보면 겁이 나는데, 비유부터 시작하면 괜찮습니다.
요리 비유로 이해하는 자동 미분
케이크를 만든다고 해봅시다. 밀가루 200g, 설탕 100g, 달걀 2개를 섞어서 오븐에 구웠는데, 케이크가 너무 달았습니다. 다음에 만들 때 덜 달게 하려면 어떻게 해야 할까요? 설탕을 줄이면 됩니다.
수학으로 바꾸면 "설탕(입력)이 케이크의 단맛(결과)에 미치는 영향"을 구하는 겁니다. 이 영향도가 그래디언트고, 자동 미분은 그래디언트를 자동으로 계산해주는 시스템입니다.
신경망 학습은 이 과정의 반복입니다. 모델이 예측을 잘못하면(케이크가 너무 달면), 어떤 파라미터를 얼마나 조절해야 하는지(설탕을 얼마나 줄여야 하는지) 그래디언트로 알아낸 뒤, 실제로 조절합니다.
Value 클래스의 핵심
class Value: def __init__(self, data, children=(), local_grads=()): self.data = data # 이 노드의 실제 값 self.grad = 0 # 손실에 대한 이 노드의 그래디언트 self._children = children # 이 값을 만드는 데 사용된 입력값들 self._local_grads = local_grads # 각 입력값에 대한 로컬 미분값
Value는 숫자를 감싸는 포장지입니다. 일반 숫자와 다른 점은, 자신이 어떤 계산을 거쳐 만들어졌는지 기억한다는 겁니다. a = 3, b = 4일 때 c = a * b = 12를 계산하면, c는 "나는 a와 b를 곱해서 만들어졌다"는 사실을 기록합니다. 이 기록이 나중에 역전파를 할 때 쓰입니다.
def __add__(self, other): other = other if isinstance(other, Value) else Value(other) return Value(self.data + other.data, (self, other), (1, 1)) def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) return Value(self.data * other.data, (self, other), (other.data, self.data))
덧셈을 보겠습니다. a + b를 하면 결과값은 a.data + b.data이고, 자식(children)은 (a, b), 로컬 그래디언트는 (1, 1)입니다. 로컬 그래디언트가 (1, 1)이라는 뜻은 "결과를 1 늘리려면 a를 1 늘리면 되고, b를 1 늘려도 된다"입니다. 덧셈의 미분이 항상 1인 건 직관적이죠.
곱셈은 다릅니다. a * b의 로컬 그래디언트는 (b.data, a.data)입니다. a에 대한 미분은 b이고, b에 대한 미분은 a입니다. a = 3, b = 4일 때 결과는 12입니다. a를 3에서 4로 바꾸면 결과는 16이 됩니다. 변화량 4는 b의 값과 같습니다. 곱셈의 미분 규칙이 이겁니다.
backward - 체인 룰을 타고 역류하기
def backward(self): topo = [] visited = set() def build_topo(v): if v not in visited: visited.add(v) for child in v._children: build_topo(child) topo.append(v) build_topo(self) self.grad = 1 for v in reversed(topo): for child, local_grad in zip(v._children, v._local_grads): child.grad += local_grad * v.grad
이 코드가 역전파의 전부입니다. 먼저 위상 정렬로 계산 순서를 정리합니다. 그 다음 결과부터 시작해서 거꾸로 올라가면서, 각 노드의 그래디언트를 자식 노드에 전파합니다. 전파 공식은 자식의 그래디언트 += 로컬 그래디언트 * 부모의 그래디언트이고요.
숫자를 넣어서 따라가봅시다.
a = Value(2) b = Value(3) c = a * b # c = 6, 로컬 그래디언트: a에 대해 3, b에 대해 2 d = c + Value(1) # d = 7, 로컬 그래디언트: c에 대해 1
d에서 backward를 시작하면 d.grad = 1입니다. d에서 c로 전파하면 c.grad = 1 * 1 = 1입니다. c에서 a로 전파하면 a.grad = 3 * 1 = 3입니다. c에서 b로 전파하면 b.grad = 2 * 1 = 2입니다.
풀어서 말하면, a를 1 늘리면 d가 3 늘어나고, b를 1 늘리면 d가 2 늘어납니다. 미분이라는 게 이겁니다. microGPT의 4,192개 파라미터 각각에 대해 이 계산을 수행하면, 어떤 파라미터를 어느 방향으로 얼마나 조절해야 손실이 줄어드는지 알 수 있습니다.
3단계. 모델 아키텍처 - 글자를 받아서 다음 글자의 확률을 출력하기
모델 아키텍처는 입력(현재 글자)을 받아서 출력(다음 글자의 확률 분포)을 만드는 함수입니다. 다음 그림은 microGPT 모델이 글자 하나를 처리하는 전체 흐름을 보여줍니다.

입력 글자가 토큰 임베딩과 위치 임베딩을 거쳐 벡터가 되고, 멀티헤드 어텐션으로 관련 정보를 모은 뒤, MLP에서 가공하여 최종적으로 softmax를 통해 다음 글자의 확률 분포를 출력합니다. 양쪽의 잔차 연결이 학습 안정성을 유지합니다.
microGPT의 아키텍처를 한 단계씩 따라가봅니다.
임베딩: 숫자를 의미 있는 벡터로 바꾸기
n_embd = 16 # 임베딩 차원 block_size = 16 # 최대 컨텍스트 길이 n_head = 4 # 어텐션 헤드 수 state_dict = { 'wte': matrix(vocab_size, n_embd), # 토큰 임베딩 'wpe': matrix(block_size, n_embd), # 위치 임베딩 'lm_head': matrix(vocab_size, n_embd), # 출력 행렬 }
글자 "a"의 토큰 번호는 0입니다. 하지만 0이라는 숫자만으로는 모델이 "a"의 특성을 학습할 수 없습니다. 그래서 각 글자를 16차원 벡터로 바꾸는데, 이걸 임베딩이라고 부릅니다.
16차원 벡터란, 16개의 숫자로 이루어진 리스트입니다. 학습 전에는 이 숫자들이 랜덤이지만, 학습이 진행되면서 비슷한 글자는 비슷한 벡터를 갖게 됩니다. 예를 들어 모음 a, e, i는 서로 비슷한 벡터를, 자음 b, c, d끼리도 서로 비슷한 벡터를 가질 수 있습니다.
위치 임베딩은 "이 글자가 이름의 몇 번째 위치에 있는가"를 알려줍니다. 같은 "a"라도 이름의 첫 글자인지 마지막 글자인지에 따라 다음에 올 글자가 달라지기 때문입니다.
def gpt(token_id, pos_id, keys, values): tok_emb = state_dict['wte'][token_id] # 토큰 임베딩 가져오기 pos_emb = state_dict['wpe'][pos_id] # 위치 임베딩 가져오기 x = [t + p for t, p in zip(tok_emb, pos_emb)] # 둘을 더하기
gpt 함수의 인자로 keys와 values가 전달되는 것이 보입니다. 이것은 KV 캐시입니다. 어텐션은 현재 위치의 글자를 예측할 때 이전 모든 위치의 Key와 Value를 참조합니다. 매번 이전 위치의 Key와 Value를 다시 계산하면 낭비이므로, 한 번 계산한 결과를 리스트에 저장해두고 재사용합니다. 실제 LLM 서빙에서도 이 KV 캐시가 메모리의 핵심 병목이거든요. GPT-4급 모델에서 긴 대화를 처리할 때 GPU 메모리를 가장 많이 차지하는 게 바로 이 캐시입니다.
토큰 임베딩과 위치 임베딩을 더해서 하나의 벡터를 만듭니다. 이 벡터에는 "어떤 글자인지"와 "몇 번째 위치인지" 정보가 모두 담겨 있습니다.
어텐션: "이 글자를 예측하려면 앞의 어떤 글자를 봐야 하는가"
어텐션은 트랜스포머의 핵심이고, microGPT에서 가장 이해하기 어려운 부분입니다. 하지만 원리는 단순합니다.
"Michael"이라는 이름에서 마지막 글자 "l"을 예측한다고 생각해봅니다. 앞의 모든 글자 M, i, c, h, a, e를 똑같이 참고할 필요가 없습니다. "e" 다음에는 "l"이 오는 경우가 많으므로, "e"에 더 많은 관심을 기울여야 합니다. 어텐션은 이 "관심의 정도"를 자동으로 학습합니다.
q = linear(x, state_dict[f'layer{li}.attn_wq']) # Query: "나는 이런 정보가 필요해" k = linear(x, state_dict[f'layer{li}.attn_wk']) # Key: "나는 이런 정보를 갖고 있어" v = linear(x, state_dict[f'layer{li}.attn_wv']) # Value: "내가 제공할 실제 정보는 이거야"
어텐션에는 세 가지 벡터가 등장합니다. 도서관으로 생각하면 쉽습니다.
Query는 "나는 이런 책이 필요해"라고 말하는 사람입니다. Key는 각 책의 표지에 적힌 분류 정보입니다. Value는 책의 실제 내용입니다. 도서관에서 필요한 책을 찾을 때, 원하는 주제(Query)와 각 책의 분류(Key)를 비교해서, 가장 관련 있는 책의 내용(Value)을 가져옵니다.
attn_logits = [ sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5 for t in range(len(k_h)) ] attn_weights = softmax(attn_logits) head_out = [ sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h))) for j in range(head_dim) ]
Query와 Key의 내적(dot product)을 구하면 두 벡터가 얼마나 비슷한지 알 수 있습니다. 이 값을 softmax에 통과시키면 0과 1 사이의 가중치가 됩니다. 그 가중치로 Value를 가중 합산하면, 현재 위치에서 필요한 정보가 추출됩니다.
head_dim**0.5로 나누는 건 스케일링 때문입니다. 내적 값이 너무 크면 softmax 결과가 한쪽으로 쏠리는 문제가 있어서, 차원의 제곱근으로 나눠 안정시킵니다.
멀티헤드: 여러 관점에서 동시에 바라보기
for h in range(n_head): # n_head = 4 hs = h * head_dim # head_dim = 4 q_h = q[hs:hs+head_dim] k_h = [ki[hs:hs+head_dim] for ki in keys[li]] v_h = [vi[hs:hs+head_dim] for vi in values[li]]
microGPT는 4개의 어텐션 헤드를 사용합니다. 16차원 벡터를 4차원씩 4개로 나눠서 각각 독립적으로 어텐션을 수행합니다. 한 헤드는 "직전 모음이 뭔지"에 집중하고, 다른 헤드는 "이름의 첫 글자가 뭔지"에 집중하는 식입니다. 이렇게 여러 관점에서 정보를 모으면 하나의 관점만 볼 때보다 풍부한 표현이 가능합니다.
MLP: 수집한 정보를 가공하기
# MLP block x_residual = x x = rmsnorm(x) x = linear(x, state_dict[f'layer{li}.mlp_fc1']) # 16차원 -> 64차원으로 확장 x = [xi.relu() for xi in x] # ReLU: 음수는 0으로 x = linear(x, state_dict[f'layer{li}.mlp_fc2']) # 64차원 -> 16차원으로 축소 x = [a + b for a, b in zip(x, x_residual)] # 잔차 연결
어텐션이 "어디를 봐야 하는가"를 결정한다면, MLP는 "모은 정보를 어떻게 가공할 것인가"를 담당합니다. 16차원을 64차원으로 늘렸다가 다시 16차원으로 줄이는데, 이 과정에서 비선형 변환(ReLU)이 들어갑니다. 차원을 넓히는 이유는 더 넓은 공간에서 복잡한 패턴을 잡아내기 위해서고요.
ReLU는 단순합니다. 양수는 그대로 두고, 음수는 0으로 만듭니다. 이 단순한 연산이 신경망에 비선형성을 부여해서, 직선으로는 표현할 수 없는 복잡한 패턴을 학습할 수 있게 합니다.
잔차 연결(x = [a + b for a, b in zip(x, x_residual)])은 입력을 그대로 출력에 더하는 겁니다. "어텐션과 MLP를 거쳐 변환된 정보"에 "원래 정보"를 더합니다. 이렇게 하면 깊은 신경망에서도 그래디언트가 잘 전파되어 학습이 안정됩니다.
RMSNorm: 숫자 크기를 일정하게 유지하기
def rmsnorm(x): ms = sum(xi * xi for xi in x) / len(x) # 제곱 평균 scale = (ms + 1e-5) ** -0.5 # 스케일 계수 return [xi * scale for xi in x] # 각 원소를 스케일링
신경망에서 숫자들이 계속 곱해지다 보면 값이 폭발적으로 커지거나 0에 수렴할 수 있습니다. RMSNorm은 벡터의 크기를 일정 수준으로 유지해줍니다. 벡터의 제곱 평균(Root Mean Square)을 구한 뒤, 그 값으로 나누는 것입니다. GPT-2에서는 LayerNorm을 쓰지만, microGPT는 더 단순한 RMSNorm을 씁니다. 학습 가능한 파라미터가 없어서 코드가 짧아지거든요.
최종 출력: softmax로 확률 만들기
logits = linear(x, state_dict['lm_head'])
모든 처리가 끝난 16차원 벡터를 27차원(어휘 크기)으로 변환합니다. 이 27개의 숫자(로짓)는 각 글자가 다음에 올 확률의 원시 점수입니다. softmax를 통과시키면 모든 값이 0~1 사이가 되고, 합이 1인 확률 분포가 됩니다.
def softmax(logits): max_val = max(val.data for val in logits) exps = [(val - max_val).exp() for val in logits] total = sum(exps) return [e / total for e in exps]
softmax 전에 최댓값을 빼는 이유는 수치 안정성 때문입니다. exp 함수에 큰 수를 넣으면 오버플로가 발생할 수 있는데, 최댓값을 빼면 가장 큰 값이 0이 되므로 exp(0) = 1로 안전합니다. 결과 확률 분포는 동일합니다.
4단계. 학습 - 1,000번 반복해서 틀린 만큼 고치기
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8 m = [0.0] * len(params) # 1차 모멘텀 버퍼 v = [0.0] * len(params) # 2차 모멘텀 버퍼 num_steps = 1000 for step in range(num_steps): doc = docs[step % len(docs)] tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS] n = min(block_size, len(tokens) - 1)
학습 루프는 이름 하나를 가져와서 토큰으로 바꾼 뒤, 앞뒤에 BOS를 붙입니다. "emma"는 [BOS, e, m, m, a, BOS]가 됩니다.
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)] losses = [] for pos_id in range(n): token_id, target_id = tokens[pos_id], tokens[pos_id + 1] logits = gpt(token_id, pos_id, keys, values) probs = softmax(logits) loss_t = -probs[target_id].log() losses.append(loss_t) loss = (1 / n) * sum(losses)
각 위치에서 현재 토큰을 모델에 넣고, 다음 토큰의 확률을 구합니다. 정답 토큰의 확률에 -log를 취한 것이 손실(loss)입니다. 왜 -log일까요? 확률이 1에 가까우면 -log(1) = 0으로 손실이 작고, 확률이 0에 가까우면 -log(0)은 무한대로 손실이 커집니다. 맞힐수록 벌점이 적고, 틀릴수록 벌점이 큰 것입니다.
loss.backward() lr_t = learning_rate * (1 - step / num_steps) # 학습률 선형 감쇠 for i, p in enumerate(params): m[i] = beta1 * m[i] + (1 - beta1) * p.grad v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2 m_hat = m[i] / (1 - beta1 ** (step + 1)) v_hat = v[i] / (1 - beta2 ** (step + 1)) p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam) p.grad = 0
loss.backward()를 호출하면 앞서 설명한 역전파가 실행되어, 4,192개 파라미터 각각의 그래디언트가 계산됩니다. 그 다음 Adam 옵티마이저가 파라미터를 업데이트합니다.
Adam은 SGD(확률적 경사 하강법)를 개선한 버전입니다. 단순한 SGD는 파라미터 -= 학습률 * 그래디언트인데, Adam은 여기에 두 가지를 추가합니다. 첫째, 모멘텀(m)입니다. 그래디언트의 이동 평균을 유지해서 방향이 갑자기 바뀌지 않게 합니다. 둘째, 적응적 학습률(v)입니다. 그래디언트가 큰 파라미터는 조금만 움직이고, 작은 파라미터는 크게 움직입니다.
학습률은 0.01에서 시작해서 0까지 선형으로 줄어듭니다. 처음에는 크게 움직여서 빠르게 좋은 영역을 찾고, 나중에는 조금씩 움직여서 미세 조정하는 것입니다.
5단계. 추론 - 학습된 모델이 새 이름을 만들어내기
temperature = 0.5 for sample_idx in range(20): keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)] token_id = BOS sample = [] for pos_id in range(block_size): logits = gpt(token_id, pos_id, keys, values) probs = softmax([l / temperature for l in logits]) token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0] if token_id == BOS: break sample.append(uchars[token_id]) print(f"sample {sample_idx+1:2d}: {''.join(sample)}")
추론은 학습의 반대 방향입니다. 학습에서는 정답을 알고 있었지만, 추론에서는 모델이 스스로 다음 글자를 결정합니다. BOS 토큰으로 시작해서, 모델이 출력한 확률 분포에서 글자를 하나 뽑고, 그 글자를 다시 모델에 넣어 다음 글자의 확률을 구합니다. BOS가 다시 나오면 이름이 끝났다는 뜻입니다.
1,000스텝 학습 후 생성한 결과는 이런 모습입니다.
step 100 | loss 2.8410 step 500 | loss 2.3192 step 1000 | loss 2.0715 --- sample 1: emmajean sample 2: jayla sample 3: kael sample 4: delian sample 5: kaeli sample 6: shirin sample 7: mckenn sample 8: sori sample 9: tamsin sample 10: ryn
실제로 존재하지 않지만 영어 이름처럼 들리는 단어들이 생성됩니다. 손실이 2.84에서 2.07로 줄어드는 과정이 학습이고, 그 결과물이 이 이름들입니다.
temperature는 생성의 다양성을 조절합니다. 로짓을 temperature로 나누는데, temperature가 낮으면(0.5) 확률이 높은 글자가 더 높은 확률을 갖게 되어 보수적인 결과가 나옵니다. temperature가 높으면 확률이 균등해져서 다양한 글자가 선택됩니다.
이 코드의 한계, 그리고 실제 GPT까지의 거리
다음 그림은 microGPT와 실제 GPT의 스케일 차이를 보여줍니다.

파라미터 수, 학습 데이터 양, 아키텍처 깊이 모두 수만 배에서 수억 배까지 차이가 나지만, 핵심 알고리즘은 동일합니다.
microGPT의 파라미터는 4,192개입니다. GPT-2는 15억 개, GPT-4는 공개되지 않았지만 수천억 개로 추정됩니다. microGPT와 실제 GPT 사이에는 알고리즘의 차이가 아니라, 스케일의 차이가 있습니다.
속도 문제
microGPT는 모든 연산을 파이썬 스칼라 단위로 수행합니다. 행렬 곱셈 하나를 하려면 중첩 반복문을 돌아야 합니다. 커뮤니티 벤치마크 기준으로 1,000스텝 학습에 약 298초(약 5분)가 걸립니다. 같은 연산을 NumPy로 벡터화하면 약 250배 빨라지고, Rust로 포팅하면 4,580배 빨라집니다. microGPT의 Rust 포팅본인 ZeroclawGPT는 같은 학습을 0.065초에 끝냅니다.
여기서 한 가지 짚고 넘어갈 게 있습니다. GPT 학습에 수천 대의 GPU가 필요한 이유는 알고리즘이 복잡해서가 아니라, 같은 알고리즘을 거대한 스케일로 돌려야 하기 때문입니다.
아키텍처 차이
microGPT는 1레이어, 16차원입니다. GPT-2는 12레이어, 768차원입니다. GPT-4는 그보다 훨씬 깊고 넓습니다. 레이어가 깊어지면 더 추상적인 패턴을 잡을 수 있고, 차원이 넓어지면 더 미세한 차이를 구분할 수 있습니다. 하지만 한 레이어 안에서 일어나는 일은 동일합니다. 어텐션으로 정보를 모으고, MLP로 가공하고, 잔차 연결로 안정성을 유지합니다.
학습 데이터의 차이
microGPT는 32,000개 이름으로 학습합니다. GPT-3는 인터넷에서 수집한 수천억 개의 토큰으로 학습했습니다. 데이터가 많아지면 모델은 문법, 논리, 상식, 코드, 수학 등 다양한 패턴을 학습하게 됩니다. 결국 데이터의 양과 다양성이 모델의 능력을 결정짓습니다.
ChatGPT까지 남은 단계
microGPT에서 ChatGPT까지 가려면 두 단계가 더 필요합니다.
첫째, 사전 학습의 스케일 업입니다. 수천억 토큰의 텍스트 데이터로 수천억 개의 파라미터를 수천 대의 GPU에서 수주간 학습합니다. 알고리즘은 microGPT와 동일하지만, 규모가 완전히 다릅니다.
둘째, 후처리입니다. 사전 학습만으로는 모델이 질문에 대답하는 법을 모릅니다. 사람이 작성한 대화 데이터로 미세 조정(SFT)하고, 인간 피드백을 통한 강화학습(RLHF)으로 유해한 답변을 줄입니다. 이 후처리 과정이 "텍스트 생성기"를 "대화형 어시스턴트"로 바꾸는 겁니다.
마무리: 243줄의 가치
내가 이 코드에서 가장 인상적이었던 건 Value 클래스입니다. 40줄도 안 되는 코드로 자동 미분 엔진을 만들 수 있다니, 솔직히 놀랐습니다. PyTorch를 쓸 때는 loss.backward() 한 줄이 무슨 일을 하는지 신경 쓰지 않았는데, 이 코드를 보고 나서야 비로소 그 한 줄 뒤에서 무슨 일이 벌어지는지 이해했습니다.
Karpathy는 이전에도 micrograd(자동 미분 엔진), makemore(문자 단위 언어 모델), nanoGPT(PyTorch 기반 GPT 재현)를 차례로 만들어 공개했습니다. microGPT는 이 여정의 최종 목적지입니다. micrograd의 자동 미분과 makemore의 이름 생성과 nanoGPT의 트랜스포머 아키텍처를 전부 합쳐서, 외부 의존성 없이 하나의 파일에 담았습니다.
이 코드를 직접 실행해보기를 추천합니다. Karpathy의 GitHub Gist(https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95)에서 microgpt.py를 다운로드하고, 같은 Gist에 있는 이름 데이터를 input.txt로 저장한 뒤, python microgpt.py를 실행하면 됩니다. 약 5분 뒤에 학습이 끝나고, 모델이 만들어낸 새 이름들이 출력됩니다. 코드를 수정해보는 것도 추천합니다. n_embd를 32로 늘려보거나, n_layer를 2로 바꿔보거나, learning_rate를 0.001로 낮춰보면서 결과가 어떻게 달라지는지 관찰하면, 하이퍼파라미터의 의미가 와닿을 겁니다.
GPT에는 마법이 없습니다. 243줄의 파이썬 코드가 그 증거입니다.
참고 자료
- Andrej Karpathy의 microGPT 블로그 포스트: http://karpathy.github.io/2026/02/12/microgpt/
- microGPT 소스 코드: https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95
- Karpathy의 트윗: https://x.com/karpathy/status/2021694437152157847
- Neural Networks: Zero to Hero 시리즈: https://karpathy.ai/zero-to-hero.html
- ZeroclawGPT (Rust 포팅): https://github.com/rustystack/zeroclawgpt
- Analytics Vidhya 해설: https://www.analyticsvidhya.com/blog/2026/02/andrej-karpathy-microgpt/






댓글
댓글을 작성하려면 이 필요합니다.