본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research7 min read

주간 리뷰 (Week 7): Tiny GPT를 "돌아간다"에서 "믿을 수 있다"로 바꾸는 테스트 4종

이번 주에 만든 Tiny GPT 프로젝트를 마무리하는 날이다. 새로운 개념은 배우지 않고, 코드를 정리한 뒤 shape·마스크·생성·KV 캐시 일치를 확인하는 테스트를 작성한다. 마지막으로 실험 결과 표를 확정하고 이번 주에 틀린 알고리즘 문제를 다시 푼다.

By 송민성

오늘의 목표

Project 1(Tiny GPT)을 "내 컴퓨터에서 한 번 돌아갔다" 상태에서 "다른 사람이 받아서 돌려도 같은 결과가 나온다" 상태로 만든다. 코드를 정리하고, 핵심 동작 네 가지(shape, 마스크, 생성, KV 캐시 일치)를 테스트로 고정하고, 최종 결과 표를 확정한다.

개념과 直관

오늘은 새로 배우는 개념이 없다. 대신 "테스트"라는 습관을 익힌다.

비유: 건물 준공 검사. 건물이 서 있다고 안전한 건 아니다. 기둥 간격, 소방 설비, 전기 배선을 하나씩 확인해야 한다. 모델 코드도 마찬가지다. 학습이 돌고 loss가 내려가도 코드 안에 조용한 버그가 있을 수 있다. 에러 없이 틀린 답을 내는 버그가 가장 위험하다.

오늘 확인할 네 가지는 다음과 같다.

  1. shape 테스트: 입력이 (batch, seq_len)이면 출력 logits은 (batch, seq_len, vocab_size)여야 한다. 차원이 하나라도 어긋나면 이후 계산이 다 틀어진다.
  2. 마스크 테스트: GPT는 "다음 글자를 맞히는" 모델이다. 위치 t의 출력이 t보다 뒤에 있는 토큰을 보면 정답을 훔쳐보는 셈(미래 정보 누수)이다. 검증법은 간단하다. 뒤쪽 토큰을 바꿔도 앞쪽 위치의 출력이 그대로여야 한다.
  3. 생성 테스트: 같은 시드(seed, 난수를 고정하는 값)와 같은 설정이면 같은 문장이 나와야 한다. 또 길이, 토큰 id 범위 같은 기본 조건을 확인한다.
  4. KV 캐시 일치 테스트: KV 캐시(KV cache, 이미 계산한 key·value 값을 저장해 두고 생성할 때 새 토큰 부분만 계산하는 기법)를 쓰든 안 쓰든 결과는 같아야 한다. 캐시는 속도를 위한 최적화일 뿐, 답을 바꾸면 안 된다.

수식으로 쓰면 간단하다.

text
마스크 테스트:  x1[:, :k] == x2[:, :k]  이면  out1[:, :k] ≈ out2[:, :k]
캐시 테스트:    |logits_캐시 - logits_전체| < 1e-5

"≈"를 쓰는 이유는 부동소수점(floating point) 계산에서 계산 순서가 달라지면 아주 작은 오차가 생기기 때문이다. 완전히 같음(==) 대신 허용 오차 안에서 같음(allclose)으로 비교한다.

코드로 직접 해보기

프로젝트 폴더를 아래처럼 정리한다. 이름은 본인 구조에 맞게 바꿔도 된다.

text
tiny_gpt/
  model.py        # 모델 정의
  train.py        # 학습 스크립트
  generate.py     # 생성 함수
  tests/
    test_model.py

테스트에는 pytest를 쓴다. 설치는 pip install pytest이고, 실행은 폴더에서 pytest -q다. 아래 코드는 모델이 TinyGPT(vocab_size, ...) 형태이고 model(x)가 logits을 돌려준다고 가정한다. 본인 코드의 이름에 맞게 고쳐 쓴다.

1단계: shape와 마스크

python
import torch from model import TinyGPT def make_model(): torch.manual_seed(0) m = TinyGPT(vocab_size=50) # 본인 생성자 인자에 맞게 수정 m.eval() # dropout 같은 무작위성 끄기 return m def test_output_shape(): m = make_model() x = torch.randint(0, 50, (4, 16)) out = m(x) assert out.shape == (4, 16, 50) def test_causal_mask(): m = make_model() x1 = torch.randint(0, 50, (2, 16)) x2 = x1.clone() x2[:, 10:] = torch.randint(0, 50, (2, 6)) # 10번째 이후만 바꿈 with torch.no_grad(): o1, o2 = m(x1), m(x2) assert torch.allclose(o1[:, :10], o2[:, :10], atol=1e-5)

2단계: 생성 재현성

python
from generate import generate def test_generate_deterministic(): m = make_model() prompt = torch.tensor([[1, 2, 3]]) torch.manual_seed(123) a = generate(m, prompt, max_new_tokens=20) torch.manual_seed(123) b = generate(m, prompt, max_new_tokens=20) assert torch.equal(a, b) assert a.shape[1] == 3 + 20 assert a.min() >= 0 and a.max() < 50

3단계: KV 캐시 일치. 가장 확실한 방법은 "캐시 없이 전체를 매번 다시 계산한 결과"와 "캐시를 쓴 결과"를 비교하는 것이다. 무작위가 끼면 비교가 안 되므로 가장 큰 확률의 토큰을 고르는 그리디(greedy, 항상 확률 최고 토큰 선택) 방식으로 한다.

python
def test_kv_cache_matches_full(): m = make_model() prompt = torch.tensor([[1, 2, 3]]) a = generate(m, prompt, max_new_tokens=20, use_cache=False, greedy=True) b = generate(m, prompt, max_new_tokens=20, use_cache=True, greedy=True) assert torch.equal(a, b)

use_cache, greedy 인자 이름은 본인 구현에 맞춘다. 더 엄밀하게 보려면 매 스텝의 logits도 allclose로 비교한다. 토큰이 같아도 logits이 조금씩 어긋나다가 나중에 갈라지는 경우가 있기 때문이다.

4단계: 코드 정리. 테스트가 통과한 상태에서만 정리한다. 순서가 중요하다. 정리 후 pytest -q를 다시 돌려서 초록불이면 안전하다.

  • 쓰지 않는 코드와 주석 처리된 실험 코드 삭제
  • 하드코딩된 숫자(hidden size, 학습률 등)를 설정 한곳으로 모으기
  • 시드 고정 코드를 한 함수로 합치기
  • README에 "설치 → 학습 → 생성 → 테스트" 실행 명령 4줄 적기

오늘의 실험

Project 1의 최종 결과 표를 확정한다. 이미 돌린 실험을 정리하는 날이므로 새 실험을 마구 추가하지 말고, 빠진 칸만 채운다.

표 예시는 다음과 같다. 숫자는 비워둔 칸이니 본인이 측정한 값만 적는다.

실험바꾼 것학습 loss검증 loss생성 속도(토큰/초)비고
baseline-
모델 크기 변경layer 수
KV 캐시 off/onuse_cache--결과 토큰 동일 확인

기록 규칙은 이렇다.

  • 한 번에 하나만 바꾼다. 두 개를 동시에 바꾸면 무엇이 효과였는지 알 수 없다.
  • 시드, 데이터 크기, 학습 step 수를 표 아래에 적는다. 나중에 재현하려면 필요하다.
  • 검증 loss를 기준으로 비교한다. 학습 loss만 내려가면 외운 것일 수 있다.
  • 속도는 여러 번 재서 평균을 쓴다. 한 번 잰 값은 흔들린다.
  • 표 아래에 "이 표에서 알 수 있는 것 3줄 / 아직 모르는 것 1줄"을 쓴다. 숫자만 있는 표는 반쪽짜리다.

왜 이걸 배우나 (LLM / Post-training 연결)

Post-training(사전학습 이후 모델을 미세조정하고 정렬하는 단계)에서는 학습 코드를 계속 고치고 확장한다. 그때 "이전 코드가 여전히 맞는가"를 알려주는 것이 테스트다. 특히 마스크 누수와 KV 캐시 불일치는 loss가 이상하게 좋아지거나 생성 품질이 조용히 나빠지는 식으로 나타나서, 테스트 없이는 찾기 어렵다. 결과 표를 깔끔하게 남기는 습관도 실험이 수십 개로 늘어나는 연구 업무의 기본기다.

초보자가 막히는 지점

  • `model.eval()`을 빼먹음: dropout이 켜져 있으면 같은 입력도 매번 출력이 달라져서 마스크·캐시 테스트가 불규칙하게 실패한다. 테스트 모델은 항상 eval()로 둔다.
  • `==`로 float 비교: 작은 오차 때문에 실패한다. torch.allclose(a, b, atol=1e-5)를 쓴다.
  • 마스크 테스트가 항상 통과함: 바꾼 위치와 비교한 위치를 혼동하는 경우가 많다. 일부러 마스크를 꺼 보고(미래를 보게 만들고) 테스트가 실패하는지 확인해야 테스트가 쓸모 있다는 증거가 된다.
  • 캐시 테스트에서 토큰은 같은데 logits이 다름: 위치 인덱스(position)를 캐시 길이만큼 밀어서 넣지 않았을 가능성이 크다. 새 토큰의 위치가 "지금까지 쌓인 길이"부터 시작하는지 확인한다.
  • import 에러: tests/ 폴더에서 model을 못 찾으면 프로젝트 루트에서 pytest를 실행하거나, 루트에 빈 conftest.py를 둔다.
  • 정리하다가 망가뜨림: 테스트가 없는 상태에서 리팩터링하지 않는다. 테스트 → 정리 → 테스트 순서를 지킨다.

알고리즘 (병행 1시간)

이번 주에 틀린 문제를 다시 푼다. 새 문제는 풀지 않는다.

  1. 이번 주 틀렸거나 힌트를 본 문제 목록을 만든다.
  2. 풀이를 보지 않고 처음부터 다시 푼다. 막히면 20분 안에 끊고 풀이를 본 뒤, 덮고 다시 쓴다.
  3. 문제마다 아래 세 가지를 기록한다.

- 접근: 어떤 아이디어로 푸는지 한두 문장 - 시간복잡도: 입력 크기 n에 대해 어느 정도 걸리는지 (예: O(n), O(n log n)) - 틀린 이유: 개념 부족 / 경계 조건(빈 입력, 크기 1) 누락 / 구현 실수 / 복잡도 초과 중 무엇인지

같은 유형의 이유가 두 번 이상 나오면 다음 주 풀이 전에 확인할 체크 항목으로 따로 적어 둔다.