요약
가장 작은 언어 모델을 처음부터 만드는 데는 300줄 미만의 Python 코드가 필요합니다. 이 과정은 토큰화, 어텐션, 추론이 정확히 어떻게 작동하는지 보여주며, 이는 프로덕션 LLM을 애플리케이션에 통합할 때 훨씬 더 나은 API 사용자가 되도록 돕습니다.
서론
대부분의 개발자는 언어 모델을 블랙박스처럼 취급합니다. 텍스트를 보내면 토큰이 나오고, 그 사이 어딘가에서 마법이 일어납니다. 이러한 정신 모델은 깨진 API 통합을 디버깅하거나, 샘플링 매개변수를 조정하거나, 모델이 왜 구조화된 데이터를 계속 환각하는지 알아내야 할 때까지는 잘 작동합니다.
최근 해커뉴스 전면에 842점으로 오른 GuppyLM 프로젝트는 내부를 들여다볼 수 있게 해줍니다. 이것은 8.7M 매개변수의 트랜스포머로, Python으로 처음부터 작성되었습니다. 소비자용 GPU에서 한 시간 이내에 훈련할 수 있습니다. 코드는 단일 파일에 들어갑니다. 목표는 GPT-4와 경쟁하는 것이 아니라, LLM이 실제로 무엇을 하는지 명확히 밝히는 것입니다.
이 기사는 작은 LLM을 구축하는 방법, 각 구성 요소가 하는 일, 그리고 AI API를 전문적으로 다룰 때 내부를 이해하는 것이 무엇을 가르쳐주는지 설명합니다.
언어 모델을 "작게" 만드는 것은 무엇일까요?
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 활성화 함수를 사용하는데, 이는 최신 아키텍처에서 사용되는 SwiGLU보다 간단합니다.
출력 헤드
최종 트랜스포머 블록 후에, 선형 레이어는 각 토큰의 표현을 어휘와 동일한 크기의 벡터로 투영합니다. 소프트맥스를 적용하여 확률을 얻고, 가장 가능성 있는 다음 토큰을 선택(또는 샘플링)하고, 반복합니다.
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의 테스트 시나리오를 사용하면 API 호출을 연결하고 AI 응답의 구조를 확인할 수 있습니다.
예를 들어, 스트리밍 채팅 API를 테스트할 때:
- 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비트 정수(INT8) 또는 심지어 4비트(INT4)로 줄입니다. 이는 미미한 정확도 손실로 메모리 사용량을 4-8배 절감합니다.
# Example: dynamic INT8 quantization in PyTorch
import torch.quantization
quantized_model = torch.quantization.quantize_dynamic(
model, {nn.Linear}, dtype=torch.qint8
)
프로덕션 API는 양자화된 모델을 실행합니다. 동일한 모델의 다른 "버전"에서 다른 출력 품질을 볼 때, 종종 양자화가 관련되어 있습니다.
KV 캐시
우리의 추론 루프에서, 우리는 모든 단계에서 전체 시퀀스에 걸쳐 어텐션을 재계산합니다. 프로덕션 시스템은 이전 토큰의 키-값 쌍(KV 캐시)을 캐시하여 각 새 토큰이 단 하나의 새로운 어텐션 계산만 필요하도록 합니다. 이것이 스트리밍 응답의 첫 번째 토큰이 후속 토큰보다 시간이 더 오래 걸리는 이유입니다.
작은 LLM vs. 프로덕션 API: 각각 언제 사용해야 할까요?
| 사용 사례 | 소형 LLM | 프로덕션 API |
|---|---|---|
| 모델 내부 학습 | 최적 | 과잉 |
| 새 앱 프로토타이핑 | 품질 불충분 | 최적 |
| 개인/민감 데이터 | 좋은 선택지 | 제공자에 따라 다름 |
| 오프라인/엣지 배포 | 실현 가능 | 불가능 |
| 비용 민감, 고용량 | 절충안으로 가능 | 규모가 커지면 비쌈 |
| 추론 위주 작업 | 실현 불가능 | 필수 |
대부분의 개발자를 위한 실제 답변은 다음과 같습니다: 애플리케이션에는 프로덕션 API를 사용하되, 내부에서 무슨 일이 일어나고 있는지 이해하기 위해 작은 모델을 실행하세요. 이 둘은 경쟁 관계가 아닙니다. [internal: open-source-coding-assistants-2026] 기사는 "bring-your-own-model" 설정으로 이러한 경계를 모호하게 하는 도구들을 다룹니다.
결론
작은 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(arman-bd/guppylm-60k-generic)에 있는 6만 개 항목의 대화 데이터셋을 사용합니다. 코드 생성에는 The Stack 또는 CodeParrot을 사용하세요.
온도(temperature)와 top-k 샘플링의 차이는 무엇인가요?온도는 로짓 분포를 조절합니다(전반적인 무작위성을 제어). Top-k는 온도를 적용하기 전에 샘플링 풀을 k개의 가장 가능성 있는 토큰으로 제한합니다. 이 둘은 함께 적용됩니다: 먼저 top-k가 후보를 필터링하고, 그 다음 온도가 해당 세트 내의 확률을 형성합니다.
LLM이 가끔 반복하는 이유는 무엇인가요?반복은 모델이 방금 생성한 토큰이 컨텍스트에 나타났기 때문에 해당 토큰에 높은 확률을 할당하는 실패 모드입니다. 프로덕션 API는 반복 페널티(최근 생성된 토큰을 할인하는 로짓 조정)를 사용합니다. 이를 줄이려면 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), 슬라이딩 윈도우 어텐션, 검색 증강 생성과 같은 기술은 모두 유효 컨텍스트를 확장합니다. 핵심 트랜스포머 아키텍처는 변경되지 않으며, 이들은 위치 정보가 인코딩되는 방식과 어텐션 창이 적용되는 방식에 대한 수정 사항입니다.
