요약
가장 작은 언어 모델을 처음부터 만드는 데는 300줄 미만의 Python 코드가 필요합니다. 이 과정은 토큰화, 어텐션, 추론이 정확히 어떻게 작동하는지 보여주며, 이는 프로덕션 LLM을 애플리케이션에 통합할 때 훨씬 더 나은 API 사용자가 되도록 돕습니다.
서론
대부분의 개발자는 언어 모델을 블랙박스처럼 취급합니다. 텍스트를 보내면 토큰이 나오고, 그 사이 어딘가에서 마법이 일어납니다. 이러한 정신 모델은 깨진 API 통합을 디버깅하거나, 샘플링 매개변수를 조정하거나, 모델이 왜 구조화된 데이터를 계속 환각하는지 알아내야 할 때까지는 잘 작동합니다.
최근 해커뉴스 전면에 842점으로 오른 GuppyLM 프로젝트는 내부를 들여다볼 수 있게 해줍니다. 이것은 8.7M 매개변수의 트랜스포머로, Python으로 처음부터 작성되었습니다. 소비자용 GPU에서 한 시간 이내에 훈련할 수 있습니다. 코드는 단일 파일에 들어갑니다. 목표는 GPT-4와 경쟁하는 것이 아니라, LLM이 실제로 무엇을 하는지 명확히 밝히는 것입니다.
이 기사는 작은 LLM을 구축하는 방법, 각 구성 요소가 하는 일, 그리고 AI API를 전문적으로 다룰 때 내부를 이해하는 것이 무엇을 가르쳐주는지 설명합니다.
💡AI API 통합을 테스트하는 경우, Apidog의 테스트 시나리오를 사용하면 스트리밍 응답을 확인하고, 토큰 구조를 검증하며, 프로덕션 크레딧을 소모하지 않고도 엣지 케이스 완성을 시뮬레이션할 수 있습니다. 자세한 내용은 나중에 설명합니다.
언어 모델을 "작게" 만드는 것은 무엇일까요?
GPT-4와 같은 프로덕션 LLM은 수천억 개의 매개변수를 가집니다. "작은" LLM은 1M에서 25M 매개변수 범위에 있습니다. GuppyLM (8.7M), Karpathy의 nanoGPT (124M), MicroLM (1-2M)과 같은 프로젝트들이 이 범주에 속합니다.
작은 LLM은 다음을 할 수 있습니다:
- 노트북 또는 Google Colab에서 훈련
- CPU 메모리에 완전히 탑재
- 가중치 수준에서 검사, 수정, 디버깅
다음은 할 수 없습니다:
- 복잡한 추론 처리
- 일관된 장문 텍스트를 안정적으로 생성
- 프로덕션 모델의 사실적 깊이와 일치
그 가치는 결과물이 아닙니다. 그것은 하나를 만들면서 얻게 되는 이해입니다.
핵심 구성 요소: LLM이 실제로 작동하는 방식
코드를 작성하기 전에 네 가지 주요 부분이 무엇을 하는지 알아두세요.
토크나이저
토크나이저는 원시 텍스트를 정수 ID로 변환합니다. "Hello, world!"는 [15496, 11, 995, 0]와 같이 됩니다. 각 정수는 고정된 어휘에서 서브워드 단위에 매핑됩니다.
API 작업에 왜 중요한가:
토큰 수는 지연 시간과 비용에 직접적인 영향을 미칩니다. 토크나이저가 텍스트를 분할하는 방식을 이해하면 컨텍스트 창에 맞고 예기치 않은 잘림을 피하는 프롬프트를 작성하는 데 도움이 됩니다.
GuppyLM은 간단한 문자 수준 토크나이저를 사용합니다. GPT-4와 같은 프로덕션 모델은 50K-100K 토큰의 어휘를 가진 BPE(바이트 쌍 인코딩)를 사용합니다.
임베딩 레이어
임베딩 레이어는 토큰 ID를 밀집 벡터로 변환합니다. 각 토큰은 학습된 벡터(예: GuppyLM에서 384차원)를 얻습니다. 이 벡터는 의미론적 의미를 가집니다. 유사한 토큰은 벡터 공간에서 가깝게 위치합니다.
위치 임베딩이 추가되어 모델이 토큰 순서를 알 수 있게 합니다.
트랜스포머 블록
트랜스포머 블록은 다음으로 구성됩니다.
- 셀프 어텐션: 각 토큰이 시퀀스의 다른 모든 토큰을 살펴보고 다음 토큰을 예측하는 데 어떤 토큰이 중요한지 결정합니다. GuppyLM은 6개의 레이어에 걸쳐 6개의 어텐션 헤드를 사용합니다.
- 피드포워드 네트워크: 어텐션 후 각 토큰의 표현에 적용되는 두 계층 MLP입니다. GuppyLM은 ReLU 활성화 함수를 사용합니다.
출력 헤드
최종 트랜스포머 블록 후에, 선형 레이어는 각 토큰의 표현을 어휘와 동일한 크기의 벡터로 투영합니다. 소프트맥스를 적용하여 확률을 얻고, 가장 가능성 있는 다음 토큰을 선택(또는 샘플링)하여 반복합니다.
Python으로 최소 LLM 구축하기
아래는 GuppyLM의 접근법을 바탕으로 한 최소 LLM 구현 예시입니다. 표준 PyTorch로 실행됩니다.
import torch
import torch.nn as nn
import torch.nn.functional as F
# Hyperparameters
VOCAB_SIZE = 256 # character-level: one slot per ASCII char
D_MODEL = 128 # embedding dimension
N_HEADS = 4 # attention heads
N_LAYERS = 3 # transformer blocks
SEQ_LEN = 64 # context window
DROPOUT = 0.1
class SelfAttention(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
self.proj = nn.Linear(d_model, d_model, bias=False)
self.dropout = nn.Dropout(DROPOUT)
def forward(self, x):
B, T, C = x.shape
qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim)
q, k, v = qkv.unbind(dim=2)
q = q.transpose(1, 2)
k = k.transpose(1, 2)
v = v.transpose(1, 2)
# Causal mask: each token can only attend to previous tokens
scale = self.head_dim ** -0.5
attn = (q @ k.transpose(-2, -1)) * scale
mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
attn = attn.masked_fill(mask, float('-inf'))
attn = F.softmax(attn, dim=-1)
attn = self.dropout(attn)
out = (attn @ v).transpose(1, 2).reshape(B, T, C)
return self.proj(out)
class TransformerBlock(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.attn = SelfAttention(d_model, n_heads)
self.ff = nn.Sequential(
nn.Linear(d_model, 4 * d_model),
nn.ReLU(),
nn.Linear(4 * d_model, d_model),
nn.Dropout(DROPOUT),
)
self.ln1 = nn.LayerNorm(d_model)
self.ln2 = nn.LayerNorm(d_model)
def forward(self, x):
x = x + self.attn(self.ln1(x))
x = x + self.ff(self.ln2(x))
return x
class TinyLLM(nn.Module):
def __init__(self):
super().__init__()
self.embed = nn.Embedding(VOCAB_SIZE, D_MODEL)
self.pos_embed = nn.Embedding(SEQ_LEN, D_MODEL)
self.blocks = nn.ModuleList([
TransformerBlock(D_MODEL, N_HEADS) for _ in range(N_LAYERS)
])
self.ln_f = nn.LayerNorm(D_MODEL)
self.head = nn.Linear(D_MODEL, VOCAB_SIZE, bias=False)
def forward(self, idx):
B, T = idx.shape
tok_emb = self.embed(idx)
pos = torch.arange(T, device=idx.device)
pos_emb = self.pos_embed(pos)
x = tok_emb + pos_emb
for block in self.blocks:
x = block(x)
x = self.ln_f(x)
logits = self.head(x)
return logits
# Initialize and count parameters
model = TinyLLM()
total_params = sum(p.numel() for p in model.parameters())
print(f"Model size: {total_params:,} parameters") # ~1.2M
훈련 루프
import torch.optim as optim
def train(model, data, epochs=100, lr=3e-4):
optimizer = optim.AdamW(model.parameters(), lr=lr)
model.train()
for epoch in range(epochs):
# data: tensor of token IDs, shape [batch, seq_len+1]
x = data[:, :-1] # input: all tokens except last
y = data[:, 1:] # target: all tokens shifted by 1
logits = model(x)
loss = F.cross_entropy(logits.reshape(-1, VOCAB_SIZE), y.reshape(-1))
optimizer.zero_grad()
loss.backward()
optimizer.step()
if epoch % 10 == 0:
print(f"Epoch {epoch}, loss: {loss.item():.4f}")
추론 (텍스트 생성)
@torch.no_grad()
def generate(model, prompt_ids, max_new_tokens=50, temperature=1.0, top_k=10):
model.eval()
ids = torch.tensor([prompt_ids])
for _ in range(max_new_tokens):
idx_cond = ids[:, -SEQ_LEN:] # crop to context window
logits = model(idx_cond)
logits = logits[:, -1, :] / temperature # last token only
# top-k sampling
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float('-inf')
probs = F.softmax(logits, dim=-1)
next_id = torch.multinomial(probs, num_samples=1)
ids = torch.cat([ids, next_id], dim=1)
return ids[0].tolist()
이것이 AI API 동작에 대해 가르쳐주는 것
직접 모델을 만들어보면 API 사용에 필요한 여러 핵심 개념을 명확하게 이해할 수 있습니다.
온도(Temperature)와 샘플링은 기계적이며 마법이 아닙니다
온도는 소프트맥스 이전에 로짓을 나눕니다. 온도가 높을수록 더 평평한 분포(무작위성 증가), 온도가 낮을수록 더 날카로운 분포(결정적). 프로덕션 API가 temperature=0.0에서 일관되지 않은 결과를 반환하더라도 버그가 아닙니다. 진정한 0 온도는 탐욕적 argmax이며, 많은 API는 퇴보적 출력을 피하기 위해 이를 약간 낮춥니다.
컨텍스트 창은 엄격한 한계
추론 루프의 idx_cond = ids[:, -SEQ_LEN:] 줄은 컨텍스트 한계에서 정확히 어떤 일이 일어나는지 보여줍니다. 모델은 이전 토큰을 묵묵히 버립니다. API 통합이 모델이 전체 대화 기록을 기억한다고 가정하더라도, 특정 시점 이후에는 그렇지 않습니다. 에이전트가 이 문제를 어떻게 처리하는지 보려면 [internal: how-ai-agent-memory-works]를 참고하세요.
스트리밍 토큰은 실제로 추론 단계의 가시화
스트리밍 API는 아키텍처적으로 다른 일을 하지 않습니다. 추론 루프를 실행하고 생성된 각 토큰을 응답 스트림으로 플러시합니다. 이를 이해하면 재시도 로직을 작성할 때 도움이 됩니다: 생성 중에 중단된 스트림은 재개될 수 없으며, 다시 시작해야 합니다.
로짓은 구조화된 출력의 어려움을 설명
모델은 각 단계에서 어휘의 모든 토큰에 확률을 할당합니다. 유효한 JSON을 생성하려면 모든 위치에서 올바른 토큰이 선택되어야 합니다. Outlines 및 Guidance와 같은 라이브러리는 추론 시 문법을 강제하기 위해 로짓 분포를 제한합니다. AI API가 "구조화된 출력" 모드를 제공할 때, 내부적으로 이런 방식이 동작합니다.
Apidog로 AI API 통합을 테스트하는 방법
LLM 추론이 어떻게 동작하는지 이해하면 더 신뢰성 높은 API 테스트를 구현할 수 있습니다. Apidog의 테스트 시나리오는 다음과 같이 활용할 수 있습니다.
- Apidog에서
/v1/chat/completions엔드포인트로 테스트 시나리오 생성 - 응답 구조를 확인하는 어설션 추가:
response.choices[0].finish_reason == "stop"response.usage.total_tokens < 4096
- 응답을 다음 턴 컨텍스트로 넘겨 다중 턴 대화 시뮬레이션
- Apidog의 Smart Mock으로 AI 엔드포인트를 스텁하고 앱 오류 처리 테스트:
-
finish_reason: "length"(잘린 출력) finish_reason: "content_filter"- 스트림 중간의 네트워크 타임아웃 시뮬레이션
-
이렇게 하면 모든 CI 실행에서 API 크레딧을 소모하지 않고 AI 통합을 테스트할 수 있습니다. 더 넓은 API 테스트 접근법은 [internal: api-testing-tutorial]을 참고하세요.
토큰 수 어설션 테스트
{
"assertions": [
{
"field": "response.usage.completion_tokens",
"operator": "less_than",
"value": 512
},
{
"field": "response.choices[0].finish_reason",
"operator": "equals",
"value": "stop"
},
{
"field": "response.choices[0].message.content",
"operator": "not_empty"
}
]
}
단일 테스트 시나리오에서 여러 모델(GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro)에 대해 실행하면서, 프로덕션에 도달하기 전에 API 스키마 차이를 잡아낼 수 있습니다.
고급: 양자화 및 추론 최적화
작동 가능한 작은 LLM을 손에 넣었다면, 실제 프로덕션 모델 서비스에 바로 적용 가능한 두 가지 기술을 이해해 두세요.
양자화
모델 가중치는 기본적으로 32비트 부동 소수점입니다. 양자화는 이를 8비트 또는 4비트 정수로 줄여, 미미한 정확도 손실로 메모리 사용량을 4~8배 절감합니다.
# 예시: PyTorch에서 동적 INT8 양자화
import torch.quantization
quantized_model = torch.quantization.quantize_dynamic(
model, {nn.Linear}, dtype=torch.qint8
)
프로덕션 API는 양자화된 모델을 실행합니다. 동일한 모델의 버전에 따라 출력 품질 차이가 있다면, 양자화가 영향을 줄 수 있습니다.
KV 캐시
기본 추론 루프에서는 매 스텝마다 전체 시퀀스에 대해 어텐션을 다시 계산합니다. 실제 서비스에서는 이전 토큰의 키-값 쌍(KV 캐시)를 저장하여, 새 토큰마다 어텐션 계산을 1회로 줄입니다. 이 때문에 스트리밍 응답의 첫 토큰이 후속 토큰보다 시간이 더 걸립니다.
작은 LLM vs. 프로덕션 API: 각각 언제 사용해야 할까요?
| 사용 사례 | 소형 LLM | 프로덕션 API |
|---|---|---|
| 모델 내부 학습 | 최적 | 과잉 |
| 새 앱 프로토타이핑 | 품질 불충분 | 최적 |
| 개인/민감 데이터 | 좋은 선택지 | 제공자에 따라 다름 |
| 오프라인/엣지 배포 | 실현 가능 | 불가능 |
| 비용 민감, 고용량 | 절충안으로 가능 | 규모가 커지면 비쌈 |
| 추론 위주 작업 | 실현 불가능 | 필수 |
대부분의 개발자를 위한 현실적인 답변은 다음과 같습니다:
애플리케이션에는 프로덕션 API를 사용하되, 내부 동작을 이해하기 위해 작은 모델을 직접 돌려보세요. 이 둘은 경쟁 구도가 아닙니다. [internal: open-source-coding-assistants-2026]에서는 BYOM 환경에서 경계가 흐려지는 도구들을 다룹니다.
결론
작은 LLM을 처음부터 만드는 데는 주말이면 충분합니다. 얻는 것은 프로덕션 시스템이 아닌, GuppyLM부터 GPT-4o에 이르는 언어모델의 실제 동작에 대한 작동 가능한 정신 모델입니다. 이런 이해는 스트리밍 통합 디버깅, 샘플링 매개변수 조정, AI API 테스트 어설션 설계 등에서 빛을 발합니다.
GuppyLM 프로젝트로 시작해보세요. 코드를 복제하고, 데이터셋으로 직접 훈련해보고, 오후 시간을 추론 루프를 읽으며 보내세요. 이후 프로덕션 API를 다룰 때 더 깊은 통찰을 얻게 될 것입니다.
다른 백엔드 시스템과 동일한 엄격함을 AI API 테스트에도 적용하려면 Apidog의 테스트 시나리오를 활용해보세요.
자주 묻는 질문
"작은" LLM이 일관된 텍스트를 생성하려면 얼마나 많은 매개변수가 필요합니까?
괜찮은 데이터셋 기준 1천만~5천만 매개변수면 지역적으로 일관된 문장을 생성할 수 있습니다. 1백만 미만에서는 대부분 횡설수설합니다. 8.7M 매개변수 GuppyLM은 훈련 도메인(60개 주제)에서 짧은 대화에 효과적입니다.
GPU 없이 작은 LLM을 실행할 수 있나요?
네. 100M 미만 모델은 CPU에서 잘 동작하며, 속도만 다소 느립니다. 위 예제(1.2M)는 노트북 CPU에서 밀리초 단위로 토큰을 생성합니다.
어떤 데이터셋으로 훈련해야 하나요?
문자 수준 모델은 Project Gutenberg, 위키백과 하위 집합, 일반 텍스트 코퍼스에서 잘 동작합니다. GuppyLM은 HuggingFace의 6만 개 대화 데이터셋(arman-bd/guppylm-60k-generic)을 사용합니다. 코드 생성은 The Stack 또는 CodeParrot 추천.
온도(temperature)와 top-k 샘플링의 차이는?
온도는 로짓 분포 전체의 무작위성을 조정합니다. top-k는 샘플링 후보를 k개로 제한합니다. 먼저 top-k로 후보를 거르고, 이후 온도를 적용해 샘플링합니다.
LLM이 가끔 반복하는 이유는?
모델이 최근 생성한 토큰을 컨텍스트에서 인식, 그 토큰에 과도한 확률을 할당하는 실패 모드입니다. 프로덕션 API는 반복 페널티(repetition_penalty=1.1)를 통해 완화합니다.
작은 LLM 훈련에 얼마나 걸리나요?
위 모델은 단일 GPU(RTX 3060 등)에서 2시간 이내에 일관된 출력을 냅니다. GuppyLM도 Colab에서 비슷한 시간이 소요됩니다. 100M 이상 모델은 멀티 GPU와 며칠이 필요할 수 있습니다.
작은 LLM을 실제 API 엔드포인트로 만드는 가장 빠른 방법은?
llama.cpp 변환 스크립트로 GGUF 내보내기 → llama-server로 서비스. 로컬 OpenAI 호환 API 엔드포인트가 생성됩니다. 이후 Apidog로 테스트 가능합니다. [internal: rest-api-best-practices] 참고.
프로덕션 LLM은 훈련 창보다 긴 컨텍스트를 어떻게 처리하나요?
RoPE(Rotary Position Embedding) 스케일링, 슬라이딩 윈도우 어텐션, 검색 증강 생성 등으로 유효 컨텍스트를 확장합니다. 트랜스포머 아키텍처는 그대로이며 위치 인코딩, 어텐션 창 적용 방식에 변화를 줍니다.
Top comments (0)