들어가며

네트워크 엔지니어로 일하다가 요즘 AI, 그중에서도 LLM 서빙/인프라 쪽으로 공부 범위를 넓히고 있다. 그러다 보니 신경망 모델 자체를 코드 레벨로 뜯어본 경험은 사실 거의 없었는데, LLM Serving and Optimization 스터디 1주차 과제로 Andrej Karpathy가 올해 2월에 공개한 microGPT를 직접 실행해보고 코드를 처음부터 끝까지 읽어보게 됐다. 스터디 첫 주니까 거창한 서빙 최적화 이야기보다는 “애초에 GPT가 뭘 하는 애인지”부터 확실히 짚고 넘어가자는 취지였는데, 결과적으로 이 선택이 꽤 좋았다.

microGPT는 외부 라이브러리를 단 하나도 쓰지 않고, 순수 Python 표준 기능만으로 GPT를 학습시키고 텍스트를 생성하는 코드를 200줄 안에 담아낸 프로젝트다. PyTorch도, NumPy도 없다. import torch 한 줄 없이 GPT가 동작한다는 게 처음엔 잘 안 믿겼는데, 코드를 직접 실행해보고 나니 왜 가능한지, 그리고 왜 이게 교육적으로 의미가 있는지 이해가 됐다.

솔직히 처음 코드를 열었을 때는 술술 읽히지 않았다. Value 클래스 부분에서 한참 멈춰 있었고, 어텐션 수식도 종이에 직접 숫자를 넣어서 계산해보고 나서야 감이 왔다. 그래도 네트워크 엔지니어로 일하면서 캐싱, 큐잉, 지연시간(latency) 같은 개념은 늘 다뤄왔던 터라, 코드 안에서 익숙한 개념(특히 KV 캐시)을 발견했을 땐 반가웠다. 이 글은 그렇게 코드를 한 줄 한 줄 따라가며 이해한 내용을, 비슷한 배경을 가진 사람도 따라올 수 있게 정리한 것이다. 숙제인 만큼 그냥 “요약”이 아니라 실제로 숫자를 손으로 계산해보고, 코드를 로컬에서 직접 돌려보면서 확인한 내용을 담았다. (참고로 초안은 AI 도움을 받아 작성했고, 내용과 표현은 직접 코드를 돌려보면서 다시 손을 봤다.)

microGPT가 뭔가

한 줄로 요약하면: “ChatGPT를 만드는 데 필요한 알고리즘의 뼈대만 남긴 미니어처 버전”이다.

Karpathy는 이걸 두고 스스로 “미술 작품에 가깝다”고 표현했다. 실용성보다는, GPT라는 게 결국 어떤 원리로 작동하는지를 가장 적나라하게 보여주기 위해 만든 코드라는 뜻이다. 그는 지난 10년 동안 micrograd, makemore, nanoGPT 같은 프로젝트로 신경망을 “가장 작은 단위까지 쪼개서” 설명해왔는데, microGPT는 그 시리즈의 사실상 마지막 정착지에 가깝다.

숫자로 보면 감이 더 잘 온다.

항목 microGPT 실제 ChatGPT급 모델
파라미터 수 4,192개 수천억 개
학습 데이터 이름 32,033개 텍스트 수조 개 토큰
토크나이저 알파벳 한 글자 단위, 어휘 27개 서브워드 단위(BPE), 어휘 약 10만 개
연산 방식 CPU에서 숫자 하나씩 순차 계산 GPU 수천 대 병렬 연산
학습 후 단계 없음 지도 미세조정 + 강화학습 등

즉 microGPT는 “장난감”이 맞다. 다만 이 장난감이 흉내 내는 알고리즘의 핵심 구조는 실제 거대 모델과 동일하다는 게 포인트다. 아래에서는 실제 코드를 기준으로 각 부분이 무슨 일을 하는지 순서대로 뜯어본다.

1. 데이터와 토크나이저: 27개의 기호만으로 시작한다

학습 데이터는 makemore 저장소에 있는 이름 3만 2,033개다. emma, olivia, noah 같은 흔한 영어 이름들이 한 줄에 하나씩 들어있는 평범한 텍스트 파일이다.

여기서 토크나이저는 정말 극단적으로 단순하다.

uchars = sorted(set(''.join(docs)))  # 데이터셋에 등장하는 모든 고유 문자를 모아 0..n-1번 토큰으로
BOS = len(uchars)                    # "이름의 시작/끝"을 나타내는 특수 토큰
vocab_size = len(uchars) + 1         # 알파벳 26개 + BOS 1개 = 27개

실제로 실행해보면 vocab size: 27이 찍힌다. a부터 z까지 26개 알파벳에, “여기서 이름이 시작하고 끝난다”는 걸 표시하는 특수 토큰(BOS) 하나를 더한 값이다. ChatGPT 같은 실제 모델은 “서브워드(subword)” 단위로 텍스트를 쪼개서 약 10만 개의 토큰을 쓰는데, microGPT는 그냥 “글자 하나 = 토큰 하나”로 처리한다. 개념을 이해하는 데는 이걸로 충분하고, 오히려 이렇게 해야 “토큰화가 뭘 하는 작업인지”가 더 명확하게 보인다.

2. Autograd: “학습한다”는 게 실제로 뭘 계산하는 건지

개인적으로 코드를 읽으면서 가장 눈여겨본 부분이 여기다. 신경망이 “학습”한다는 건 결국 이런 과정이다.

  1. 지금 파라미터로 예측을 해본다 (순전파, forward)
  2. 예측이 정답과 얼마나 다른지 오차(손실, loss)를 계산한다
  3. 그 오차를 줄이려면 각 파라미터를 어느 방향으로, 얼마나 움직여야 하는지 계산한다 (역전파, backward)
  4. 계산된 방향으로 파라미터를 아주 조금씩 이동시킨다

PyTorch를 쓰면 3번 과정, 즉 “역전파”가 loss.backward() 한 줄 뒤에 완전히 숨어버린다. microGPT는 이걸 Value라는 클래스 하나로 직접 구현한다.

class Value:
    def __init__(self, data, children=(), local_grads=()):
        self.data = data                 # 이 노드의 실제 값
        self.grad = 0                    # 역전파(backward pass)로 채워질 그래디언트(gradient)
        self._children = children        # 이 값을 만드는 데 쓰인 입력들
        self._local_grads = local_grads  # 각 입력에 대한 로컬 그래디언트(local gradient)

    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))

곱셈 하나를 예로 들면 이해가 쉽다. z = x * y라는 계산을 하면, z는 자기 값(x.data * y.data)뿐 아니라 “누가 나를 만들었는지”(x, y)와 “각각에 대한 미분값”(y.data, x.data — 곱셈의 미분은 상대방 값이라는 고등학교 수학 그대로다)까지 함께 기억해둔다.

이렇게 모든 연산이 “계산 그래프”라는 나무 구조를 만들어두면, 마지막에 나온 손실값에서 backward()를 한 번 호출하는 것만으로 그래프를 거꾸로 훑으면서 연쇄법칙(chain rule)을 자동으로 적용할 수 있다.

def backward(self):
    ...
    self.grad = 1  # 자기 자신에 대한 미분값은 1에서 출발
    for v in reversed(topo):  # 출력 → 입력 방향으로 거꾸로
        for child, local_grad in zip(v._children, v._local_grads):
            child.grad += local_grad * v.grad  # 연쇄법칙 적용, 분기점은 누적(+=)

주목할 점은 +=다. 만약 어떤 파라미터가 계산 그래프 안에서 여러 번 쓰였다면(예를 들어 같은 가중치가 여러 위치의 어텐션 계산에 반복해서 쓰이는 경우), 그 파라미터가 최종 손실에 미치는 영향은 “각 경로에서의 영향을 모두 더한 것”이어야 한다. 이 한 줄이 그걸 정확히 처리해준다. (사실 이 += 하나가 왜 필요한지는 처음엔 그냥 넘어갔다가, 나중에 어텐션 부분에서 같은 가중치가 여러 번 재사용되는 걸 보고 나서야 “아, 그래서 누적이 필요했던 거구나” 하고 뒤늦게 이해했다.)

원리 자체는 PyTorch 내부에서 일어나는 일과 완전히 동일하다. 차이는 PyTorch가 벡터/행렬 단위로 GPU에서 병렬 계산하는 반면, microGPT는 숫자(스칼라) 하나하나에 대해 이 과정을 순서대로 반복한다는 것뿐이다. 그래서 원리는 같지만 훨씬, 훨씬 느리다.

3. 모델 구조: 4,192개의 파라미터는 어디서 왔나

모델 크기를 결정하는 설정은 이렇다.

n_layer = 1     # 레이어(층) 개수
n_embd = 16     # 임베딩 차원
block_size = 16 # 한 번에 볼 수 있는 최대 문맥 길이
n_head = 4      # 어텐션 헤드 개수

실행하면 num params: 4192가 출력되는데, 이 숫자가 어떻게 나오는지 직접 더해봤다.

구성 요소 계산 파라미터 수
토큰 임베딩(wte) 27 × 16 432
위치 임베딩(wpe) 16 × 16 256
출력층(lm_head) 27 × 16 432
어텐션 Q/K/V/O (각 16×16, 4개) 4 × (16×16) 1,024
MLP 1층 (16→64) 64 × 16 1,024
MLP 2층 (64→16) 16 × 64 1,024
합계   4,192

레이어가 1개뿐이라 계산이 단순하지만, 실제 GPT-2(레이어 12~48개)나 그 이상의 모델도 이 표의 구성 요소들이 레이어 수만큼 반복되고, 각 차원이 훨씬 커질 뿐 뼈대는 똑같다.

어텐션: “지금 이 글자를 예측하는 데 뭘 더 참고할까”

어텐션 블록의 핵심 코드는 이렇다.

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: 실제로 전달할 내용
keys[li].append(k)    # 이전 위치들의 Key를 계속 쌓아둔다
values[li].append(v)  # 이전 위치들의 Value를 계속 쌓아둔다

여기서 keys[li]values[li]에 계속 값을 쌓아두는 부분이 바로 KV 캐시다. 이 대목에서 나도 모르게 반가웠다. 네트워크 쪽에서 일하다 보면 “한 번 조회한 걸 다시 계산하지 않고 저장해뒀다가 재사용한다”는 캐싱 개념을 매일같이 다루는데(ARP 캐시, DNS 캐시, CDN 캐시 등), LLM 서빙 스터디에서 자주 듣던 “KV 캐시”라는 용어가 사실은 이 익숙한 개념과 똑같은 아이디어였다는 걸 코드를 보고 나서야 제대로 실감했다. 다만 여기서는 최적화 목적으로 일부러 캐시를 추가한 게 아니라, “지금까지 나온 모든 글자의 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를 하나씩 내적해서 “얼마나 관련 있는지” 점수를 매기고(attn_logits), 그 점수를 확률처럼 정규화한 다음(softmax), 그 확률(가중치)만큼 각 위치의 Value를 섞어서 최종 결과를 만든다(head_out). head_dim**0.5로 나눠주는 부분은 값이 너무 커져서 softmax가 한쪽으로 쏠리는 걸 막기 위한 스케일링이다. n_head=4이므로 이 과정이 서로 다른 4세트의 Q/K/V로 독립적으로 4번 일어나고, 그 결과를 이어붙인다 — “같은 문맥을 4가지 다른 관점에서 동시에 본다”는 게 멀티헤드 어텐션의 의미다.

RMSNorm과 Residual Connection

모델 전체에서 반복적으로 등장하는 두 가지 장치가 있다.

rmsnorm은 값들이 학습 도중 너무 커지거나 작아지지 않도록 크기를 일정하게 맞춰주는 정규화(normalization)다. 원래 GPT-2는 LayerNorm을 쓰지만, microGPT는 평균을 빼는 과정 없이 “제곱 평균의 역제곱근”만 곱해주는 더 단순한 RMSNorm을 쓴다. 계산이 더 가볍고 코드도 짧아진다.

x = [a + b for a, b in zip(x, x_residual)]  # residual connection

이 한 줄은 어텐션 블록과 MLP 블록 끝에 각각 등장한다. 블록에 입력으로 들어온 값을 계산 결과에 다시 더해주는 건데, 이게 없으면 층을 깊게 쌓을수록 학습 신호(그래디언트)가 점점 희미해져서 학습이 잘 안 된다. “이번 층에서 계산한 걸 완전히 새로 대체하는 게 아니라, 원래 값에 살짝 얹어준다”는 방식이라 residual connection(직역하면 “잔차 연결”이지만, 실무에서는 대부분 영어 표현을 그대로 쓴다) 또는 skip connection이라고 부른다.

4. 학습 루프: Adam은 왜 이렇게 복잡한가

학습 루프는 한 번에 이름 하나씩 처리한다.

tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]  # 예: "anna" -> [BOS, a, n, n, a, BOS]
...
loss_t = -probs[target_id].log()  # 정답 확률이 낮을수록 손실이 커진다 (교차 엔트로피)

“anna”라는 이름이라면, 모델은 BOS 다음에 a가 올 확률, a 다음에 n이 올 확률… 이런 식으로 매 위치마다 “다음 글자 맞히기” 문제를 푼다. 손실 함수(-log(정답 확률))는 정답을 맞힐 확률이 낮을수록 큰 값을 갖게 설계되어 있어서, 이 값을 줄이는 방향으로 학습하면 자연히 예측이 정확해진다.

파라미터 업데이트는 Adam이라는 알고리즘을 쓴다.

m[i] = beta1 * m[i] + (1 - beta1) * p.grad        # 1차 모멘트(모멘텀): 최근 그래디언트의 이동 평균
v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2    # 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)  # 실제 업데이트

사실 이 부분은 처음 봤을 때 변수가 4개(m, v, m_hat, v_hat)나 등장해서 그냥 넘기고 싶었는데, 하나씩 뜯어보니 아이디어 자체는 생각보다 단순했다. 그냥 “그래디언트만큼 조금씩 이동”하는 게 아니라, 각 파라미터마다 “최근에 얼마나 꾸준히, 얼마나 크게” 움직였는지를 기억해뒀다가 그에 맞춰 이동 폭을 자동으로 조절하는 것이다. m은 방향에 모멘텀(momentum, 관성)을 주는 역할(비슷한 방향으로 계속 움직이면 더 크게 밀어줌), v는 값이 요동이 심한 파라미터는 조심스럽게, 안정적인 파라미터는 과감하게 움직이도록 브레이크를 거는 역할이라고 이해했다. m_hat, v_hat은 학습 극초반에 m, v가 아직 충분히 쌓이지 않아 0에 가깝게 편향(bias)되는 걸 보정해주는 장치라고 하는데(bias correction), 이 부분은 솔직히 왜 굳이 이렇게까지 보정을 해줘야 하는지 완전히 납득하지는 못했다. 다음 주차에 시간 내서 좀 더 파봐야 할 것 같다.

실제로 스터디원 노트북에서 돌려본 결과, 1000스텝 학습 후 손실은 약 2.65까지 떨어졌다(27개 기호 중 아무거나 찍었을 때 나오는 기준값은 -log(1/27) ≈ 3.3이니, 확실히 “그냥 찍는 것”보다는 잘 맞히게 됐다는 뜻이다). 전체 과정은 노트북 환경에서 1분 남짓이면 끝난다.

5. 생성 결과로 확인하기

학습이 끝난 모델에 BOS 토큰부터 시작해서 “다음 글자”를 계속 예측하게 하면 새로운 이름이 만들어진다.

probs = softmax([l / temperature for l in logits])  # temperature로 확률 분포 조절
token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]

실제로 나온 결과 중 일부: kamon, karai, jaire, vialan, karia, yeran, anna, areli, konna, keylen. 진짜 이름 같지만 원본 3만 2천 개 데이터셋에는 없는, 모델이 “지어낸” 이름들이다. temperature가 낮으면 확률이 가장 높은 선택지 위주로 보수적으로 고르고, 높으면 더 다양하고 의외의 결과가 나온다. “다음에 올 확률이 높은 토큰을 계속 이어 붙이면 그럴듯한 결과가 나온다”는 GPT 계열 모델의 기본 원리를, 이보다 더 작은 스케일로 눈으로 확인하기는 어려울 것 같다.

6. 왜 실행할 때마다 완전히 똑같은 결과가 나올까

여기서 한 가지 짚고 넘어갈 부분이 있다. 이 코드는 신경망을 무작위로 초기화하고, 학습 데이터 순서도 무작위로 섞고, 마지막에 이름을 생성할 때도 확률에 따라 무작위로 토큰을 뽑는다. “무작위”가 세 군데나 들어가는데, 그런데도 코드를 몇 번을 다시 실행하든 결과는 토씨 하나 안 틀리고 완전히 똑같다. 직접 로컬에서 같은 코드를 처음부터 끝까지 다시 돌려봤는데, 손실값(loss 2.6497)부터 20개의 생성된 이름 순서까지 원본 노트북과 한 글자도 다르지 않았다.

이유는 코드 맨 윗줄에 있다.

random.seed(42)  # 혼돈 속에 질서가 있으라

컴퓨터가 만들어내는 “무작위” 숫자는 사실 완전한 무작위가 아니다. 정확히는 어떤 시작값(seed)을 넣으면 그 다음부터 나올 숫자들이 정해진 규칙(의사난수 생성 알고리즘)에 따라 100% 예측 가능하게 이어지는 “겉보기에만 무작위인” 수열이다. random.seed(42)는 바로 이 수열의 시작점을 고정하는 코드다. 시작점이 같으면, 그 뒤에 random.gauss()(가중치 초기화), random.shuffle()(데이터 순서 섞기), random.choices()(다음 글자 뽑기)를 몇 번을, 어떤 순서로 호출하든 매번 정확히 같은 값이 나온다.

이 코드는 다음 세 가지가 항상 보장된다.

  • 초기 가중치가 항상 같다: matrix() 함수가 random.gauss()로 파라미터를 초기화하는데, 시드가 고정되어 있어 4,192개 파라미터의 시작값이 매번 동일하다.
  • 데이터가 등장하는 순서가 항상 같다: random.shuffle(docs)로 이름 목록을 섞지만, 이 셔플 결과 자체가 시드에 의해 고정된 순서다. 즉 1번째 스텝에서 어떤 이름을 학습하는지부터 1000번째 스텝까지 전부 동일하다.
  • 생성 결과가 항상 같다: 추론 단계의 random.choices()도 같은 수열에서 숫자를 뽑기 때문에, 어떤 토큰이 샘플링될지도 고정된다.

여기에 더해, 이 코드에는 GPU 연산의 비결정성(부동소수점 덧셈 순서가 스레드마다 달라져 미세하게 다른 값이 나오는 현상)이나 멀티스레딩 경쟁 조건 같은, “시드를 고정해도 결과가 살짝 흔들리는” 다른 원인이 아예 없다. 모든 연산이 단일 스레드에서 파이썬 숫자 하나하나를 순서대로 처리하기 때문에, 완전히 같은 입력이 완전히 같은 순서로 들어가면 완전히 같은 출력이 나올 수밖에 없는 구조다.

이건 사실 실제 머신러닝 실험에서도 중요한 개념이다. “시드를 고정한다”는 게 바로 이런 재현성(reproducibility)을 만들기 위한 관례다. 다만 실제 대규모 학습에서는 GPU 병렬 연산의 특성상 시드를 고정해도 완벽하게 동일한 결과가 나오는 걸 보장하기 어려운 경우가 많은데, microGPT는 순수 Python 단일 스레드 스칼라 연산이라 오히려 “시드 고정 = 완벽하게 동일한 결과”라는 이상적인 상황을 그대로 보여준다.

그런데 추론 부분만 다시 실행하면 결과가 달라진다

여기서 헷갈리기 쉬운 지점이 하나 있다. 스크립트 전체를 처음부터 다시 실행하면 매번 똑같은 결과가 나오는데, Colab에서 학습이 끝난 상태로 두고 맨 아래 추론(inference) 부분만 다시 실행하면 이번엔 다른 이름들이 나온다. 왜 이런 차이가 생길까.

핵심은 random.seed(42)가 “이 프로그램은 항상 똑같이 동작해라”라는 마법의 스위치가 아니라, 딱 그 줄이 실행되는 순간에 난수 생성기의 내부 상태를 특정 지점으로 되돌려놓는 일회성 명령이라는 데 있다. 그 뒤로 random.gauss(), random.shuffle(), random.choices() 같은 함수를 호출할 때마다, 난수 생성기는 정해진 수열에서 “다음 숫자”를 하나씩 꺼내 쓰고 내부 상태를 한 칸씩 전진시킨다. 즉 난수 생성기는 매번 초기화되는 게 아니라, 마치 되감기 없이 계속 재생되는 테이프처럼 호출될 때마다 자기 위치를 기억하고 앞으로 나아간다.

정리하면 이렇다.

  1. 스크립트를 처음 실행하면 seed(42) → 파라미터 초기화(gauss 4,192번 호출) → 데이터 셔플(shuffle 1번) → 학습 1000스텝(이 구간은 난수를 쓰지 않는다) → 추론 20개 샘플링(choices 호출), 순서로 난수 생성기의 상태가 쭉 전진한다.
  2. 여기서 코드를 처음부터 다시 실행하지 않고 추론 부분만 한 번 더 실행하면, 난수 생성기는 1번에서 이미 전진해 있던 바로 그 지점부터 다음 숫자들을 이어서 꺼내 쓴다. 시드를 다시 부르지 않았으니 처음 지점으로 되돌아가지 않고, 완전히 다른(하지만 여전히 그 자체로는 정해진) 숫자들이 나오는 것이다.

직접 이 상황만 떼어내서 재현해봤다.

random.seed(42)
# ... 파라미터 초기화 + 데이터 셔플에 해당하는 난수 소비 ...

first_run  = infer()  # 추론 1회차
second_run = infer()  # 같은 실행 안에서, 시드 재설정 없이 추론을 한 번 더
print(first_run == second_run)  # False — 서로 다르다

# 여기서 seed(42)부터 다시 실행한 뒤 추론을 하면
random.seed(42)
# ... 동일하게 파라미터 초기화 + 셔플을 다시 재현 ...
third_run = infer()
print(first_run == third_run)   # True — 처음 결과와 정확히 일치

즉 “실행할 때마다 결과가 같다”는 건 정확히는 “seed(42) 시점부터 그 뒤에 일어나는 모든 난수 호출의 순서와 횟수가 완전히 같을 때만 결과가 같다”는 뜻이다. 학습까지 포함해서 스크립트를 처음부터 다시 돌리면 이 조건이 정확히 재현되니까 매번 같은 이름들이 나오고, 이미 한 번 추론을 실행해서 난수 생성기가 전진해버린 상태에서 추론만 다시 돌리면 이 조건이 깨지니까 다른 이름들이 나온다.

이걸 스터디 관점에서 다시 보면, “재현성(reproducibility)”이라는 게 생각보다 까다로운 개념이라는 걸 보여주는 좋은 예시였다. 단순히 “시드를 고정했다”가 아니라 “어떤 지점의 상태를 기준으로, 그 이후 어떤 연산들이 어떤 순서로 일어났는지까지 전부 같아야” 진짜로 같은 결과가 재현된다. 이후 서빙 최적화를 공부하면서 배치 순서, 캐시 상태, 요청 처리 순서 같은 것들이 결과에 영향을 줄 수 있다는 이야기가 나오면, 이번에 겪은 “추론만 다시 돌렸더니 결과가 달라졌다”는 경험이 좋은 참고가 될 것 같다.

7. 단계적으로 쌓아 올리는 5단계 학습 경로

원문에서 인상 깊었던 부분은, 이 최종 코드(위에서 다룬 버전, 원문 기준 train5.py)가 하늘에서 뚝 떨어진 게 아니라 5단계에 걸쳐 점진적으로 완성된다는 점이다.

단계 내용 핵심 변화
train0.py 바이그램 카운트 테이블 신경망 자체가 없음. “이 글자 다음에 저 글자가 몇 번 나왔는지” 그냥 센다
train1.py MLP + 수동 그래디언트 계산 신경망 등장. 하지만 미분을 손으로 직접 계산해서 넣어줌
train2.py Autograd(Value 클래스) 도입 미분 계산을 자동화. 이후 신경망 구조를 자유롭게 바꿀 수 있게 됨
train3.py 위치 임베딩 + 단일헤드 어텐션 “순서”와 “문맥 참고”라는 개념이 처음 등장
train4.py 멀티헤드 어텐션 + 완전한 GPT 구조 여러 관점에서 동시에 보는 어텐션, 레이어 쌓기
train5.py Adam 옵티마이저 (최종본) 단순 경사하강법 대신 더 똑똑한 최적화 알고리즘 적용

이 순서대로 읽으면 “왜 이 구성 요소가 필요한가”가 자연스럽게 이해된다. 예를 들어 train0의 카운트 테이블만으로는 “본 적 없는 조합”에 대해 아무것도 예측할 수 없는데, 이 한계를 신경망(train1)이 해결하고, 신경망을 손으로 미분하는 게 지긋지긋해질 때쯤 autograd(train2)가 등장하는 식이다. 다음 주차 학습 때는 이 diff를 하나씩 직접 비교해보면서 각 단계에서 정확히 뭐가 바뀌었는지 표로 정리해볼 계획이다.

8. 1주차 과제를 하면서 든 생각

네트워크 엔지니어 입장에서 이번 과제를 하면서 가장 크게 느낀 건, 앞으로 스터디에서 다룰 “서빙 최적화”라는 게 무엇을 최적화하는 건지 이 코드를 직접 뜯어보고 나서야 감이 잡혔다는 점이다. KV 캐시, 배치 처리, 양자화 같은 개념들은 결국 “이 기본 구조(임베딩 → 어텐션 → MLP → 다음 토큰 예측)를 어떻게 하면 더 빠르고 싸게 돌릴까”에 대한 답인데, 흥미롭게도 KV 캐시 같은 개념은 microGPT의 학습 코드 안에도 이미 (최적화 목적이 아니라 구조상 자연스럽게) 존재하고 있었다. 그동안 LLM은 그냥 가져다 쓰기만 했었는데, 막상 이렇게 파고들어 공부해보니 알면 알수록 오히려 더 어려워지는 게 LLM이라는 걸 새삼 느꼈다.

Karpathy가 원문에서 강조하듯, 실제 ChatGPT와 microGPT의 차이는 알고리즘이 아니라 규모다. 데이터가 3만 개냐 수조 개냐, 토크나이저가 글자 단위냐 서브워드 단위냐, 연산이 CPU 순차 계산이냐 GPU 수천 대 병렬 계산이냐, 그리고 학습 후 SFT나 RL 같은 후처리 단계가 있느냐 없느냐. 이 “규모의 차이를 메우는 방법들”이 바로 다음 주차부터 다룰 서빙/최적화 이야기일 거라 예상한다.

다음 주에는 실제로 코드를 로컬 환경에서 단계별로(train0train5) 직접 돌려보고, 각 단계에서 손실이 어떻게 달라지는지, 그리고 레이어 수나 임베딩 차원을 바꾸면 파라미터 수와 학습 속도가 어떻게 변하는지 실험해서 정리해볼 계획이다.

10줄 요약

내용이 길고 어려울 수 있어서, 핵심만 10줄로 정리했다.

  1. microGPT는 외부 라이브러리 없이 순수 Python만으로 짠 200줄짜리 GPT 구현체다.
  2. 이름 32,033개를 글자 단위로 토큰화해서 학습한다 (어휘 27개 = a~z + BOS).
  3. Value 클래스가 계산 그래프와 연쇄법칙을 자동으로 처리하는 autograd(역전파) 엔진이다.
  4. 모델은 임베딩 → 어텐션 → MLP → 다음 토큰 예측 구조를 가진, 파라미터 4,192개짜리 미니어처 트랜스포머다.
  5. 어텐션에서 이전 토큰들의 Key/Value를 저장해두고 재사용하는 부분이 바로 KV 캐시의 원형이다.
  6. RMSNorm(정규화)과 residual connection(잔차 연결)이 학습을 안정적으로 만들어준다.
  7. Adam 옵티마이저는 모멘텀과 편향 보정으로 파라미터 업데이트 폭을 자동 조절한다.
  8. random.seed(42) 덕분에 스크립트를 처음부터 다시 실행하면 항상 같은 결과가 나온다.
  9. 단, 추론 부분만 다시 실행하면 난수 생성기 상태가 이미 앞으로 진행돼 있어 다른 결과가 나온다.
  10. train0.py~train5.py 5단계를 따라가면 카운트 테이블에서 완전한 GPT까지 어떻게 발전하는지 순서대로 볼 수 있다.

9. 용어 정리 (부록)

글에 나온 영어 용어들을 굳이 억지로 한글로 옮기지 않고 원어 그대로 쓴 경우가 많다. 실제로 개발자/연구자들 사이에서도 번역어보다 원어를 그대로 쓰는 경우가 더 흔하기 때문인데, 처음 접하는 사람을 위해 뜻을 한 번에 모아 정리해둔다.

데이터 & 토큰화

  • 토큰(token): 모델이 다루는 가장 작은 단위. microGPT에서는 알파벳 한 글자가 토큰 하나다.
  • 토크나이저(tokenizer): 문자열을 토큰(정수) 시퀀스로 바꾸고, 다시 문자열로 되돌리는 도구.
  • 어휘(vocabulary), 어휘 크기(vocab size): 모델이 알고 있는 전체 토큰의 종류와 그 개수. microGPT는 27개(a~z + BOS).
  • BOS(Beginning of Sequence): “문장(이름)이 여기서 시작하고 끝난다”를 표시하는 특수 토큰.

모델 구조

  • 파라미터(parameter): 학습을 통해 값이 조정되는 숫자들. 이 숫자들의 조합이 곧 “모델이 학습한 지식”이다.
  • 임베딩(embedding): 토큰이나 위치 같은 정보를 숫자 벡터로 바꾼 표현.
  • 어텐션(attention): “지금 이 토큰을 처리하는 데 다른 어떤 토큰들을 얼마나 참고해야 하는가”를 계산하는 메커니즘.
  • Query, Key, Value(Q/K/V): 어텐션 계산에 쓰이는 세 종류의 벡터. Query는 “무엇을 찾는가”, Key는 “어떤 정보를 갖고 있는가”, Value는 “실제로 전달할 내용”에 해당한다.
  • 멀티헤드 어텐션(multi-head attention): 어텐션을 여러 세트(헤드)로 나눠 동시에 계산해서, 여러 관점에서 문맥을 보게 만드는 방식.
  • KV 캐시(KV cache): 이전 토큰들의 Key와 Value를 저장해뒀다가 재사용하는 것. 서빙 최적화에서 자주 등장하는 개념이다.
  • 소프트맥스(softmax): 여러 개의 숫자를 “합이 1인 확률 분포”로 바꿔주는 함수.
  • MLP(Multi-Layer Perceptron): 입력을 한 번 확장했다가 다시 축소하는 식으로 가공하는 가장 기본적인 형태의 신경망 층.
  • ReLU(Rectified Linear Unit): “0보다 작으면 0, 크면 그대로”라는 아주 단순한 비선형 활성화 함수(activation function).
  • 정규화(normalization) / RMSNorm / LayerNorm: 값의 크기(스케일)를 일정하게 맞춰주는 처리. RMSNorm은 LayerNorm보다 계산이 더 단순한 버전이다.
  • residual connection(스킵 커넥션, skip connection): 이번 층의 계산 결과에 층에 들어오기 전 원래 값을 다시 더해주는 구조. 층을 깊게 쌓아도 학습이 잘 되도록 돕는다.

학습 & 최적화

  • 순전파(forward pass) / 역전파(backward pass): 순전파는 입력을 넣어 예측(출력)을 계산하는 과정, 역전파는 그 예측이 틀린 정도(손실)로부터 거꾸로 각 파라미터를 어떻게 조정해야 할지 계산하는 과정.
  • 오토그라드(autograd): 역전파에 필요한 미분 계산을 자동으로 해주는 장치.
  • 그래디언트(gradient): “이 파라미터를 조금 바꾸면 손실이 얼마나, 어느 방향으로 변하는가”를 나타내는 값. 이 값을 보고 파라미터를 어느 쪽으로 움직일지 정한다.
  • 로컬 그래디언트(local gradient): 계산 그래프에서 어떤 노드가 “자신의 바로 다음 입력”에 대해 갖는 미분값.
  • 연쇄법칙(chain rule): “여러 단계를 거친 계산의 미분은, 각 단계별 미분을 곱해서 구할 수 있다”는 미적분의 기본 법칙. 역전파의 핵심 원리다.
  • 계산 그래프(computation graph): 어떤 계산이 어떤 입력들로부터 만들어졌는지를 나무 구조로 표현한 것.
  • 손실(loss), 교차 엔트로피(cross-entropy): 모델의 예측이 정답과 얼마나 다른지 나타내는 값. 이 글에서는 “정답 토큰에 부여한 확률이 낮을수록 커지는” 교차 엔트로피 손실을 쓴다.
  • 학습률(learning rate): 한 번에 파라미터를 얼마나 크게 움직일지 정하는 값.
  • Adam: 가장 널리 쓰이는 파라미터 최적화(optimization) 알고리즘 중 하나.
  • 모멘텀(momentum): 파라미터가 최근에 움직여온 방향을 어느 정도 유지하려는 “관성” 같은 성질.
  • 편향 보정(bias correction): 학습 초반에 모멘텀 값들이 아직 충분히 쌓이지 않아 실제보다 작게 나오는 현상을 보정해주는 계산.
  • 에포크(epoch) / 스텝(step): 스텝은 파라미터를 한 번 업데이트하는 단위. 에포크는 전체 데이터셋을 한 바퀴 다 도는 단위인데, microGPT는 스텝 기준으로 학습한다.

기타

  • 시드(seed), 의사난수 생성기(PRNG): 컴퓨터의 “무작위” 값은 사실 시드로부터 정해진 규칙에 따라 이어지는 수열이다. 시드가 같으면 이어지는 값들도 항상 같다.
  • 온도(temperature): 텍스트 생성 시 다음 토큰을 얼마나 “과감하게” 고를지 조절하는 값. 낮을수록 보수적, 높을수록 다양한 결과가 나온다.
  • 스칼라(scalar) / 벡터(vector) / 행렬(matrix): 스칼라는 숫자 하나, 벡터는 숫자들이 한 줄로 늘어선 것, 행렬은 벡터들이 여러 줄로 쌓인 것. microGPT는 벡터·행렬 연산을 스칼라 하나하나로 풀어서 계산한다.

참고 자료

  • Andrej Karpathy, microGPT, 2026.02.12
  • 실습용 Colab 노트북 — 위 코드에 한글 주석을 달아 정리한 버전이다. 별도 설치 없이 브라우저에서 바로 열어 실행해보고, 이 글에 나온 손실값(2.6497)과 생성된 이름들을 그대로 재현해볼 수 있다.