자, 이제 여러분들만의 인공지능을 만들어 봅시다.
이 브라우저에서 직접 타이핑하며 언어 모델을 만듭니다. 데이터를 준비하고 작은 GPT를 학습한 뒤, 내가 만든 Runner와 XChat으로 대화합니다. 미리 학습된 가중치나 외부 AI API는 쓰지 않습니다.
XSML 개요
첫 코드를 쓰기 전에 인공지능을 만드는 전체 흐름을 이해합니다.
XSML은 Extra Small 이라는 뜻으로, SONUB에서 개발을 배우기 위해 만든 이름입니다. 발음은 엑세스엠엘입니다. 영어 단어의 일반적인 머리글자 순서는 XSLM이지만 이 과정에서는 지정한 이름 XSML을 씁니다. 목표는 말을 잘하는 거대 이 아니라 처음부터 끝까지 내가 만든 학습용 모델입니다.
글을 번호로 바꾸고, GPT가 다음 번호를 예측하도록 합니다. 는 학습한 숫자를 읽어 실행하고, XChat은 Runner에 질문을 보냅니다. 각 부분을 직접 만듭니다. 이 제공하는 것은 계산과 파일 기능이지, 이미 학습된 두뇌가 아닙니다.
직접 타이핑하며 실습하기
을 열고 아래 명령을 직접 입력하세요. 다섯 부분이 하는 일을 말로 설명해 보세요. 첫 실험은 타이핑을 줄이려고 짧은 영어 자료를 씁니다. 나중에 원하는 언어의 자료로 바꿀 수 있습니다.
python3 -c "print('XSML: text -> tokens -> GPT -> Runner -> XChat')"
여기까지 이해했나요?
·모델·Runner·대화 화면의 역할을 구별할 수 있나요? 내가 학습한 를 저장하고 다시 읽어 XChat에서 사용하면 완성입니다. 답이 엉뚱해도 전체 개발 과정을 배운 것입니다.
막혔을 때 확인하기
Python이 처음이라면 파이썬 과정에서 변수·리스트·반복문·함수·클래스를 먼저 연습하세요. 아래 준비 운동에서 단어를 세어 다음 말을 고르는 원리를 먼저 볼 수도 있습니다.
선택 준비 운동: 바이그램으로 단어 세어 보기
직접 개발로 이어지는 계획
처음에는 장난감처럼 작게 만들고, 원리를 이해한 뒤 점점 키웁니다.
문장 모으기
내가 배우고 싶은 주제의 문장을 모읍니다. 처음에는 10문장만 있어도 됩니다.
다음 단어 횟수 세기
어떤 단어 뒤에 어떤 단어가 몇 번 나왔는지 표로 만듭니다.
확률 점수 계산하기
고른 낱말 바로 뒤에 어떤 낱말이 몇 번 왔는지 셉니다 — 문장이 끝나는 것도 한 번으로 셉니다. 그다음 각 횟수를 전체 횟수로 나눕니다. “하늘은” 뒤에 “파랗다”가 세 번, “흐리다”가 한 번 왔다면 파랗다는 3 ÷ 4 = 75%, 흐리다는 25% 입니다.
두 선택 방식 비교하기
매번 가장 높은 후보를 고르거나(탐욕 선택), 비율대로 제비를 뽑습니다(샘플링) — 제비뽑기라도 같은 낱말이 다시 나올 수 있습니다. 문장 끝이 뽑히면 멈춥니다. 이 단계 어디에도 그 문장이 사실인지 확인하는 부분은 없습니다.
내 기준으로 개선하기
더 좋은 문장, 더 많은 데이터, 다른 선택 규칙을 넣어 나만의 인공지능으로 키웁니다.
처음이면 이렇게 따라 해 보세요
이 실습은 바로 앞 단어 하나만 보는 아주 작은 을 만들어요. 어떤 단어 뒤에 어떤 단어가 왔는지 세고, 그 횟수를 로 바꾼 뒤, 확률만큼 기운 주사위를 굴려 다음 단어를 골라요.
- 훈련 문장을 그대로 두고 "다시 생성"을 누르세요. 훈련 문장에 있던 단어로만 이루어진 새 문장이 나와요.
- 확률 공식 칸을 보세요. 앞 단어 뒤에 그 단어가 온 횟수를 앞 단어가 나온 전체 횟수로 나눈 값이 확률이에요.
- 이어짐 횟수 표에서 줄은 앞 단어, 칸은 다음 단어예요. 칸이 진할수록 그 짝이 자주 나왔어요.
- 방식을 으로 바꾸고 다시 누르면 늘 같은 문장이 나와요. 으로 돌아가 를 올리면 문장이 점점 엉뚱해져요.
- 훈련 문장에 내 문장을 한 줄 더해 보세요. 은 이 횟수가 전부라서 표와 확률이 바로 바뀌어요.
더 깊이 배우고 싶다면 파이썬으로 직접 만들기 (AI 과정) 다음 단어는 어떻게 고를까
작은 확률 계산기
표의 비율은 온도 1 일 때예요. 온도는 뽑힐 확률만 바꿀 뿐, 센 횟수나 사실 여부는 바꾸지 않아요.
시작 문장의 마지막 단어 뒤에 이어진 후보가 아직 없습니다.
| 앞 단어다음 단어 | |
|---|---|
단어가 많아 처음 나온 8개만 보여요
핵심 개념
생각은 간단합니다. 문장을 많이 읽고, 어떤 낱말 바로 뒤에 어떤 낱말이 오는지 — 문장이 끝나는 자리까지 — 세어 봅니다. 그 횟수가 작은 표가 되고, 이 표만으로도 이 다음 낱말을 짐작할 수 있습니다. 이 화면에서 만드는 모델이 바로 그것입니다.
진짜 도 같은 생각으로 움직입니다. 다만 훨씬 큽니다. 작은 횟수 표 대신 예시에서 수십억 개의 숫자()를 익히고, 바로 앞 낱말 하나만이 아니라 질문 전체와 지금까지의 대화를 봅니다. 저장해 둔 문장을 찾아 꺼내는 것도 아닙니다. 그래도 마지막 단계는 여러분이 만드는 것과 같습니다 — 후보마다 점수를 매기고, 하나를 고릅니다.
이 실습과 실제 언어모델 비교
| 이 실습 (바이그램) | 신경망 언어모델 | |
|---|---|---|
| 보는 범위 | 바로 앞 단어 하나 | 주어진 긴 문맥 |
| 배우는 것 | 이어진 횟수 표 | 숫자 가중치 |
| 임베딩·어텐션 | 아니요 | 예 |
| 스스로 사실 확인 | 아니요 | 아니요 |
바탕이 된 FAI와 참고 문서
직접 만든 FAI 프로젝트를 바탕으로, 브라우저 Python에서 실행되도록 예제를 새로 작성했습니다. FAI는 PyTorch·MPS와 더 큰 모델을 사용합니다. 환경 관련 공식 문서 확인일: 2026-10-18.
개발 환경
이 브라우저 안에서 실제 파일과 프로세스를 쓰는 Python 환경을 확인합니다.
터미널은 v86으로 실행되는 32bit Alpine 입니다. Python은 이미 설치되어 있습니다. 이번 과정은 기본 제공 만 사용하므로 pip 설치·계정··가 필요 없습니다.
FAI의 PyTorch는 계산과 자동미분을 맡습니다. MPS는 지원되는 Apple 컴퓨터에서 PyTorch를 GPU로 가속하는 기능으로, 이 가상 리눅스에서는 쓸 수 없습니다. Pyodide도 Python 계산을 할 수 있지만 일반적인 와 백그라운드 서비스에는 제약이 있습니다. 따라서 필수 실습 전체를 이 Alpine 터미널에서 마치고, 작은 자동미분 엔진도 직접 작성합니다.
직접 타이핑하며 실습하기
세 명령을 차례대로 입력하세요. Python 버전, 32bit x86 계열 구조, ready가 나오는지 봅니다. 이후의 명령도 모두 이 터미널에서 실행합니다. 내 컴퓨터의 터미널을 따로 열 필요가 없습니다.
python3 --version
uname -m
python3 -c "import json, math, random, http.server; print('ready')"
여기까지 이해했나요?
여기서 torch 설치와 MPS 설정이 필요 없는 이유를 설명해 보세요. 실제 학습 계산은 가 흉내 내는 리눅스 CPU에서 이루어집니다.
막혔을 때 확인하기
torch가 없다고 pip install torch를 시도하지 마세요. 이번 소스는 torch를 불러오지 않습니다. 터미널을 받지 못하면 연결을 확인하고 다시 시도하세요. 성공 문구만 흉내 낸 것은 Python 실행 환경이 아닙니다.
개발 구조
학습에 앞서 프로젝트 폴더, 데이터, 설정 파일을 직접 만듭니다.
모든 작업은 ~/xsml에서 합니다. data에는 원본·학습 자료·검증 자료·토크나이저가, checkpoints에는 학습된 가중치가 들어갑니다. Python 소스는 프로젝트 바로 아래에 둡니다. 실행도 항상 이 폴더에서 합니다.
원본 한 줄은 Q: 질문 | A: 답변 형식입니다. prepare.py는 중복 줄을 없앤 뒤 서로 다른 다섯 줄 중 한 줄을 검증용으로 남깁니다. 구분자는 줄바꿈으로 바꿉니다. 검증 자료는 처음 보는 시험지이므로 토크나이저 학습에도 쓰지 않습니다. 사용할 권리가 있는 글을 쓰고, 개인정보 대신 직접 지어낸 예문을 사용하세요.
직접 타이핑하며 실습하기
먼저 폴더를 만드세요. 아래 소스를 하나씩 펼쳐 nano에서 같은 파일명으로 작성하고 저장한 뒤 닫습니다. settings.json은 이라 쉼표와 따옴표를 정확히 씁니다. 데이터와 소스를 모두 저장한 뒤 prepare.py를 실행하세요.
mkdir -p ~/xsml/data ~/xsml/checkpoints
cd ~/xsml
nano settings.json
nano data/raw.txt
nano prepare.py
python3 prepare.py
wc -l data/train.txt data/val.txt
settings.json직접 작성할 전체 소스
작게 시작하는 수업 기본값입니다. width는 토큰 벡터 크기, context는 볼 수 있는 토큰 수, learning_rate는 한 번 바꾸는 정도입니다. 이어서 학습할 때는 같은 설정을 유지하세요.
{
"width": 8,
"context": 12,
"merges": 12,
"steps": 20,
"learning_rate": 0.03,
"seed": 7,
"tokens": 24,
"max_tokens": 64,
"port": 8765
}
data/raw.txt직접 작성할 전체 소스
한 줄에 질문·답변 한 쌍을 씁니다. 첫 실험을 짧게 하려고 만든 영어 예문입니다. 이후에는 같은 형식으로 내 자료를 넣어 보세요.
Q: hello | A: hello friend.
Q: hi | A: hi friend.
Q: name | A: i am xsml.
Q: sun | A: the sun is warm.
Q: cat | A: a cat can nap.
Q: dog | A: a dog can run.
Q: red | A: red is a color.
Q: blue | A: blue is a color.
Q: learn | A: i learn from text.
Q: code | A: i can learn code.
Q: moon | A: the moon is in the sky.
Q: rain | A: rain is water.
Q: book | A: i can read a book.
Q: tea | A: tea is warm.
Q: bye | A: bye friend.
prepare.py직접 작성할 전체 소스
UTF-8 읽기 → 중복 제거 → 문장쌍 나누기 → 학습·검증 파일 저장입니다. 토큰으로 나누기 전에 문장쌍을 분리해야 똑같은 예제가 양쪽에 들어가지 않습니다.
from pathlib import Path
lines = [line.strip() for line in Path('data/raw.txt').read_text(encoding='utf-8').splitlines() if line.strip()]
lines = list(dict.fromkeys(lines))
assert len(lines) >= 5, 'write at least five different Q/A lines'
assert all(' | A:' in line and line.startswith('Q:') for line in lines)
train = [line for i, line in enumerate(lines) if i % 5 != 0]
val = [line for i, line in enumerate(lines) if i % 5 == 0]
for name, rows in [('train', train), ('val', val)]:
text = '\n'.join(row.replace(' | A:', '\nA:') for row in rows) + '\n'
Path('data/' + name + '.txt').write_text(text, encoding='utf-8')
print(name, len(rows))
여기까지 이해했나요?
pwd와 ls를 입력하세요. settings.json과 prepare.py가 보이나요? 제공한 자료는 학습 12개, 검증 3개 문장쌍으로 나뉩니다. 내 문장쌍을 하나 추가하면 어떤 파일부터 다시 만들어야 할까요?
막혔을 때 확인하기
FileNotFoundError가 나면 폴더와 파일명을 확인하세요. cd ~/xsml 다음 ls data를 실행합니다. IndentationError는 들여쓰기 문제입니다. 공백 네 칸을 맞추세요. 완성 예제는 ~/xsml-reference에 비교용으로만 있으며 내 프로젝트에 자동으로 채우지 않습니다.
BPE 토크나이저
내 학습 자료에서 BPE 어휘 사전을 직접 학습합니다.
토크나이저는 글 조각마다 번호를 붙입니다. 이 문자 기반 BPE는 한 글자씩 시작해 붙어 있는 두 조각의 횟수를 세고, 가장 자주 나온 쌍을 합칩니다. 합친 순서가 중요하므로 글을 번호로 바꿀 때도 같은 순서로 규칙을 적용합니다.
이 작은 교육용 구현은 줄바꿈을 합치지 않고 모르는 글자에 0번을 씁니다. 모든 를 다루는 상용 토크나이저와는 다릅니다. tokenizer.json에 어휘와 병합 규칙을 함께 저장합니다. 특정 사전으로 학습한 모델에 다른 사전을 끼우면 번호의 뜻이 달라집니다.
직접 타이핑하며 실습하기
tokenizer.py를 직접 작성하고 실행해 JSON을 확인하세요. merge를 종이에 따라가 봅니다. 두 번호가 쌍과 맞으면 새 번호를 넣고 두 칸 전진하며, 아니면 한 칸 전진합니다. 학습 문장을 encode한 뒤 decode해 보세요.
cd ~/xsml
nano tokenizer.py
python3 tokenizer.py
python3 -m json.tool data/tokenizer.json
tokenizer.py직접 작성할 전체 소스
merge는 겹치지 않게 한 쌍을 합칩니다. train은 순서 있는 규칙을 배우고, encode는 그 규칙을 다시 적용합니다. decode는 조각을 이어 붙입니다. 마지막 assert는 원문 복원을 검사합니다.
import json
from collections import Counter
from pathlib import Path
def merge(ids, pair, new_id):
result, i = [], 0
while i < len(ids):
if tuple(ids[i:i + 2]) == tuple(pair):
result.append(new_id)
i += 2
else:
result.append(ids[i])
i += 1
return result
class BPE:
def __init__(self, vocab, merges):
self.vocab, self.merges = vocab, merges
self.chars = {c: i for i, c in enumerate(vocab) if len(c) == 1}
@classmethod
def train(cls, text, count):
vocab = ['\ufffd'] + sorted(set(text) - {'\ufffd'})
ids = [vocab.index(c) for c in text]
merges = []
for _ in range(count):
pairs = Counter((a, b) for a, b in zip(ids, ids[1:])
if '\n' not in vocab[a] + vocab[b])
if not pairs:
break
pair, frequency = pairs.most_common(1)[0]
if frequency < 2:
break
new_id = len(vocab)
vocab.append(vocab[pair[0]] + vocab[pair[1]])
merges.append([list(pair), new_id])
ids = merge(ids, pair, new_id)
return cls(vocab, merges)
def encode(self, text):
ids = [self.chars.get(c, 0) for c in text]
for pair, new_id in self.merges:
ids = merge(ids, pair, new_id)
return ids
def decode(self, ids):
return ''.join(self.vocab[i] for i in ids)
def state(self):
return {'vocab': self.vocab, 'merges': self.merges}
if __name__ == '__main__':
settings = json.loads(Path('settings.json').read_text())
text = Path('data/train.txt').read_text(encoding='utf-8')
tokenizer = BPE.train(text, settings['merges'])
Path('data/tokenizer.json').write_text(
json.dumps(tokenizer.state(), ensure_ascii=False), encoding='utf-8')
assert tokenizer.decode(tokenizer.encode(text)) == text
print('vocab:', len(tokenizer.vocab), 'merges:', len(tokenizer.merges))
print('roundtrip: OK', tokenizer.encode('Q: hi\nA:'))
여기까지 이해했나요?
roundtrip: OK는 전체 학습 자료를 번호로 바꿨다가 원문 그대로 되돌렸다는 뜻입니다. settings.json의 병합 횟수를 바꾸어 토큰 수를 비교하세요. 처음 보는 글자는 새로 배우지 않고 �로 바뀝니다.
막혔을 때 확인하기
되풀이되는 쌍이 없으면 병합이 없어도 정상입니다. 글이나 병합 규칙을 바꾸었다면 토크나이저와 모델 가중치를 새로 학습해야 합니다. 이전 체크포인트는 먼저 내보내 보관하세요.
GPT 구조와 코드
아주 작은 디코더 Transformer와 역전파를 직접 작성합니다.
은 토큰 번호로 숫자 를 찾는 표입니다. 위치 벡터를 더하면 몇 번째 조각인지 알 수 있습니다. Query는 찾을 정보, Key는 각 앞자리의 특징, Value는 가져올 정보입니다. 은 관련도를 로 바꾸어 Value를 섞습니다.
모델은 인과적 어텐션 헤드 하나와 블록 하나입니다. keys와 values에는 현재와 앞자리만 들어가므로 미래 정답을 훔쳐볼 수 없습니다. 잔차 덧셈은 원래 정보를 남기고, 두 층 MLP는 특징을 바꿉니다. Layer normalization은 숫자 크기를 안정시키고 출력층은 각 토큰의 점수를 냅니다. 읽기 쉽게 , 학습 변수가 없는 정규화, dropout 없는 구조로 줄였습니다.
직접 타이핑하며 실습하기
engine.py부터 쓰고 미분 검사를 실행한 뒤 model.py를 작성하세요. Value는 계산마다 해당 연산의 미분값을 기억합니다. backward는 계산 그래프를 거꾸로 따라가며 기울기를 더하므로 같은 가중치를 여러 번 쓴 영향도 합쳐집니다. forward에서 토큰 → 위치 → 어텐션 → MLP → 출력층을 찾아보세요.
cd ~/xsml
nano engine.py
nano model.py
python3 engine.py
python3 -c "from model import GPT; m=GPT(10, 8, 12); print(len(m.forward([1, 2])), len(m.forward([1, 2])[0]))"
engine.py직접 작성할 전체 소스
data는 숫자, grad는 그 숫자가 손실에 미치는 영향, parents는 연산별 미분 기록입니다. 덧셈은 기울기를 나누어 보내고 곱셈은 상대 입력만큼 곱해 보냅니다. softmax는 점수를 확률로 바꿉니다.
import math
class Value:
def __init__(self, data, parents=()):
self.data = float(data)
self.grad = 0.0
self.parents = parents
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, ((self, 1), (other, 1)))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data,
((self, other.data), (other, self.data)))
def __pow__(self, power):
return Value(self.data ** power,
((self, power * self.data ** (power - 1)),))
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __truediv__(self, other):
return self * (other ** -1)
__radd__ = __add__
__rmul__ = __mul__
def exp(self):
result = math.exp(self.data)
return Value(result, ((self, result),))
def log(self):
return Value(math.log(self.data), ((self, 1 / self.data),))
def relu(self):
return Value(max(0, self.data), ((self, float(self.data > 0)),))
def backward(self):
order, seen, stack = [], set(), [(self, False)]
while stack:
node, expanded = stack.pop()
if expanded:
order.append(node)
elif node not in seen:
seen.add(node)
stack.append((node, True))
stack.extend((parent, False) for parent, _ in node.parents)
for node in order:
node.grad = 0.0
self.grad = 1.0
for node in reversed(order):
for parent, derivative in node.parents:
parent.grad += derivative * node.grad
def softmax(values):
peak = max(v.data for v in values)
exps = [(v - peak).exp() for v in values]
total = sum(exps)
return [v / total for v in exps]
def linear(x, weights):
return [sum(a * b for a, b in zip(x, row)) for row in weights]
def norm(x):
mean = sum(x) / len(x)
variance = sum((v - mean) ** 2 for v in x) / len(x)
return [(v - mean) * (variance + 1e-5) ** -0.5 for v in x]
if __name__ == '__main__':
x = Value(3)
y = x * x + 2 * x
y.backward()
print('y =', y.data, 'dy/dx =', x.grad)
assert y.data == 15 and x.grad == 8
model.py직접 작성할 전체 소스
shapes에서 크기를 본 뒤 forward를 읽으세요. weights의 Value는 모두 학습 대상입니다. 리스트로 벡터와 행렬을 표현합니다. loss는 각 위치의 다음 토큰과 비교하며 state와 load는 Python 객체 대신 숫자를 저장하고 읽습니다.
import random
from engine import Value, linear, norm, softmax
class GPT:
def __init__(self, vocab_size, width, context, seed=7):
self.width, self.context = width, context
rng = random.Random(seed)
shapes = {
'token': (vocab_size, width), 'position': (context, width),
'query': (width, width), 'key': (width, width),
'value': (width, width), 'project': (width, width),
'expand': (2 * width, width), 'contract': (width, 2 * width),
'head': (vocab_size, width),
}
self.weights = {
name: [[Value(rng.gauss(0, 0.1)) for _ in range(cols)]
for _ in range(rows)] for name, (rows, cols) in shapes.items()
}
self.parameters = [v for matrix in self.weights.values()
for row in matrix for v in row]
def forward(self, ids):
assert 0 < len(ids) <= self.context
w, keys, values, output = self.weights, [], [], []
for position, token in enumerate(ids):
x = [a + b for a, b in zip(w['token'][token], w['position'][position])]
h = norm(x)
q = linear(h, w['query'])
keys.append(linear(h, w['key']))
values.append(linear(h, w['value']))
scores = [sum(a * b for a, b in zip(q, k)) / self.width ** 0.5
for k in keys]
attention = softmax(scores)
mixed = [sum(p * v[i] for p, v in zip(attention, values))
for i in range(self.width)]
x = [a + b for a, b in zip(x, linear(mixed, w['project']))]
hidden = [v.relu() for v in linear(norm(x), w['expand'])]
x = [a + b for a, b in zip(x, linear(hidden, w['contract']))]
output.append(linear(norm(x), w['head']))
return output
def loss(self, inputs, targets):
logits = self.forward(inputs)
losses = [-softmax(row)[target].log()
for row, target in zip(logits, targets)]
return sum(losses) / len(losses)
def state(self):
return {name: [[v.data for v in row] for row in matrix]
for name, matrix in self.weights.items()}
def load(self, state):
assert set(state) == set(self.weights), 'checkpoint keys'
for name, matrix in self.weights.items():
assert len(state[name]) == len(matrix), 'checkpoint rows'
for row, saved in zip(matrix, state[name]):
assert len(saved) == len(row), 'checkpoint columns'
for value, number in zip(row, saved):
value.data = float(number)
여기까지 이해했나요?
engine.py는 y = 15.0, dy/dx = 8.0을 출력해야 합니다. 모델 크기 검사는 2 10을 출력합니다. 두 위치마다 어휘 10개의 점수를 냈다는 뜻입니다. 뒤에 토큰을 더해도 앞자리 점수가 바뀌면 안 되는 이유를 설명하세요.
막혔을 때 확인하기
행렬의 행 크기와 zip으로 묶는 벡터 길이를 확인하세요. 단어를 세는 바이그램으로 이 층들을 대신하지 않습니다. 작지만 임베딩·어텐션·MLP·출력층의 모든 가중치를 역전파로 학습합니다.
학습·체크포인트
다음 토큰을 예측하고 가중치를 바꾼 뒤 저장 지점에서 이어 합니다.
토큰이 [a,b,c]라면 입력 [a,b]의 정답은 [b,c]입니다. 교차 엔트로피 은 정답 토큰 확률의 음수 로그입니다. 손실이 작으면 정답에 더 높은 확률을 주었다는 뜻이며 사실을 말한다는 보장은 아닙니다.
매 반복은 짧은 구간 선택 → 이전 기울기 지우기 → 순전파·역전파 → 기울기 크기 제한 → SGD 갱신 순서입니다. 한 번의 갱신을 마칠 때마다 가중치·설정·토크나이저·진행 횟수·난수 상태를 저장합니다. 처음과 끝의 은 같은 검증 자료 첫 구간을 봅니다. 작은 상태 확인용이며 전체 가 아닙니다.
직접 타이핑하며 실습하기
train.py를 작성하고 짧은 학습 명령을 실행하세요. 실제 step과 loss가 나오는지 봅니다. 이어서 5회 더 실행해 resume 값을 확인하세요. 기기마다 속도가 다르므로 작게 시작합니다. 기본 반복은 동작 확인용이고, 반복을 늘리며 변화를 실험합니다.
cd ~/xsml
nano train.py
python3 train.py --steps 20
python3 train.py --steps 5
train.py직접 작성할 전체 소스
자료 해시는 자료가 바뀐 줄 모르고 이어 학습하는 일을 막습니다. 임시 파일에 쓴 뒤 os.replace로 바꾸므로 저장 도중 중단되어도 이전 완성 체크포인트가 남습니다. SGD에는 따로 저장할 모멘텀 상태가 없습니다.
import argparse
import hashlib
import json
import math
import os
import random
from pathlib import Path
from model import GPT
from tokenizer import BPE
def main():
cfg = json.loads(Path('settings.json').read_text())
parser = argparse.ArgumentParser()
parser.add_argument('--steps', type=int, default=cfg['steps'])
args = parser.parse_args()
assert args.steps > 0, '--steps must be positive'
token_state = json.loads(Path('data/tokenizer.json').read_text())
tokenizer = BPE(**token_state)
train_text = Path('data/train.txt').read_text(encoding='utf-8')
val_text = Path('data/val.txt').read_text(encoding='utf-8')
fingerprint = hashlib.sha256((train_text + '\0' + val_text).encode()).hexdigest()
train, val = tokenizer.encode(train_text), tokenizer.encode(val_text)
model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
rng, step = random.Random(cfg['seed']), 0
checkpoint = Path('checkpoints/model.json')
if checkpoint.exists():
saved = json.loads(checkpoint.read_text())
assert saved['settings'] == cfg and saved['tokenizer'] == token_state, 'changed settings/tokenizer: start a new checkpoint'
assert saved['data_hash'] == fingerprint, 'changed data: start a new checkpoint'
model.load(saved['weights'])
step = saved['step']
rng.setstate((saved['random'][0], tuple(saved['random'][1]), saved['random'][2]))
n = model.context
assert len(train) > n and len(val) > n, 'add more text'
print('parameters:', len(model.parameters), 'resume:', step, flush=True)
print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
checkpoint.parent.mkdir(exist_ok=True)
for _ in range(args.steps):
start = rng.randrange(len(train) - n)
window = train[start:start + n + 1]
for p in model.parameters:
p.grad = 0
loss = model.loss(window[:-1], window[1:])
loss.backward()
size = math.sqrt(sum(p.grad * p.grad for p in model.parameters))
scale = min(1.0, 1.0 / (size + 1e-8))
for p in model.parameters:
p.data -= cfg['learning_rate'] * p.grad * scale
step += 1
state = {'settings': cfg, 'tokenizer': token_state, 'data_hash': fingerprint,
'weights': model.state(), 'step': step, 'random': rng.getstate()}
temporary = checkpoint.with_suffix('.tmp')
temporary.write_text(json.dumps(state, ensure_ascii=False), encoding='utf-8')
os.replace(temporary, checkpoint)
print('step:', step, 'loss:', round(loss.data, 4), flush=True)
print('validation:', round(model.loss(val[:n], val[1:n + 1]).data, 4), flush=True)
print('saved:', checkpoint, flush=True)
if __name__ == '__main__':
main()
여기까지 이해했나요?
checkpoints/model.json이 생겼나요? 두 번째 실행은 이전 횟수에서 이어져야 합니다. 학습 전후 validation을 비교하세요. 매번 다른 구간을 뽑으므로 loss는 오르기도 합니다. 한 번의 수치로 결론 내리지 마세요.
막혔을 때 확인하기
Ctrl+C로 멈추면 마지막으로 마친 갱신까지 남습니다. 설정·토크나이저·데이터를 바꾸면 기존 체크포인트를 일부러 거부합니다. 먼저 내보내고 model.json을 다른 이름으로 옮겨 새로 시작하세요. 이미 실행 중인 Runner는 다시 시작해야 새 가중치를 읽습니다.
텍스트 생성
다음 토큰의 점수를 실제 생성 문장으로 바꿉니다.
생성은 최근 을 GPT에 넣고 예측한 토큰 하나를 붙이는 일을 반복합니다. 가 0이면 가장 높은 점수를 고릅니다. 양수면 점수를 조절한 뒤 무작위로 뽑으며, 높을수록 확률이 더 고르게 퍼집니다. top_k는 상위 후보만 남기고, 0이면 전부 씁니다.
seed는 무작위 선택을 재현하는 번호이며 정답을 보장하는 번호가 아닙니다. 줄바꿈이나 요청한 토큰 수에서 생성을 멈추고 새로 만든 글만 돌려줍니다. 입력은 학습 때의 Q:/A: 형식을 맞춥니다.
직접 타이핑하며 실습하기
generate.py를 작성하세요. 같은 체크포인트로 세 명령을 비교합니다. 먼저 temperature만 바꾸고, 다음에는 top_k와 seed를 바꾸어 보세요. 토크나이저와 모델 크기는 체크포인트에 저장된 값을 읽습니다.
cd ~/xsml
nano generate.py
python3 generate.py --temperature 0 --top-k 1
python3 generate.py --temperature 0.8 --top-k 8 --seed 7
python3 generate.py --temperature 1.2 --top-k 0 --seed 8
generate.py직접 작성할 전체 소스
load는 모델 구조를 다시 만들고 숫자를 복원합니다. generate는 문맥을 자르고 후보 점수를 계산해 반복해서 뽑습니다. 같은 seed로 비교 실험을 재현할 수 있습니다.
import argparse
import json
import math
import random
from pathlib import Path
from model import GPT
from tokenizer import BPE
def load():
saved = json.loads(Path('checkpoints/model.json').read_text())
cfg = saved['settings']
tokenizer = BPE(**saved['tokenizer'])
model = GPT(len(tokenizer.vocab), cfg['width'], cfg['context'], cfg['seed'])
model.load(saved['weights'])
return model, tokenizer, cfg
def generate(model, tokenizer, prompt, tokens=24, temperature=0.8, top_k=8, seed=7):
if not prompt or tokens < 1 or temperature < 0 or not math.isfinite(temperature) or top_k < 0:
raise ValueError('invalid sampling parameters')
ids, result, rng = tokenizer.encode(prompt), [], random.Random(seed)
for _ in range(tokens):
logits = model.forward(ids[-model.context:])[-1]
ranked = sorted(range(len(logits)), key=lambda i: logits[i].data, reverse=True)
if temperature == 0:
token = ranked[0]
else:
candidates = ranked[:top_k] if top_k else ranked
peak = logits[candidates[0]].data
weights = [math.exp((logits[i].data - peak) / temperature) for i in candidates]
token = rng.choices(candidates, weights=weights)[0]
ids.append(token)
if tokenizer.vocab[token] == '\n':
break
result.append(token)
return tokenizer.decode(result)
if __name__ == '__main__':
model, tokenizer, cfg = load()
parser = argparse.ArgumentParser()
parser.add_argument('--prompt', default='Q: hi\nA:')
parser.add_argument('--tokens', type=int, default=cfg['tokens'])
parser.add_argument('--temperature', type=float, default=0.8)
parser.add_argument('--top-k', type=int, default=8)
parser.add_argument('--seed', type=int, default=cfg['seed'])
args = parser.parse_args()
assert 1 <= args.tokens <= cfg['max_tokens']
print(generate(model, tokenizer, args.prompt, args.tokens,
args.temperature, args.top_k, args.seed))
여기까지 이해했나요?
같은 체크포인트·질문·seed·설정이면 같은 글이 나와야 합니다. 가중치를 읽는 것과 학습하는 것의 차이를 설명해 보세요. 첫 선택이 줄바꿈이면 빈 답이 될 수 있습니다.
막혔을 때 확인하기
작은 모델을 잠깐 학습하면 이상한 글이나 빈 답이 나올 수 있습니다. 자료 형식·모르는 문자·학습 손실부터 확인하세요. 무작위성을 높인다고 부족한 학습이 해결되지는 않습니다.
Runner·API
학습한 모델을 실제 HTTP API로 실행하는 서버를 만듭니다.
Runner는 체크포인트를 한 번 읽고 JSON 요청을 받아 generate를 호출합니다. HTTPServer는 요청을 하나씩 처리합니다. GET /health는 실행 상태를, POST /generate는 생성 문장을 돌려줍니다. 공개 서비스용이 아닌 수업용 서버입니다.
127.0.0.1은 가상 리눅스 자신의 주소입니다. 아래 은 반드시 이 터미널에서 실행합니다. 브라우저 주소창의 는 다른 컴퓨터를 가리킵니다. nohup과 &로 뒤에서 실행할 수 있지만 이 탭이 살아 있는 동안뿐입니다. 새로 고침하거나 닫으면 종료됩니다. 여기서는 systemd가 필요 없습니다.
직접 타이핑하며 실습하기
runner.py를 작성하고 명령대로 시작하세요. 바로 다음 줄에서 프로세스 번호를 저장합니다. runner.log를 확인한 뒤 /health와 /generate를 요청합니다. 첫 확인이 너무 빠르면 시작 문구가 나올 때까지 기다렸다 다시 요청하세요.
cd ~/xsml
nano runner.py
nohup python3 -u runner.py > runner.log 2>&1 &
echo $! > runner.pid
cat runner.log
curl -s http://127.0.0.1:8765/health
curl -s http://127.0.0.1:8765/generate -H 'Content-Type: application/json' -d '{"prompt":"Q: hi\nA:","tokens":8,"temperature":0.8}'
runner.py직접 작성할 전체 소스
요청마다 모델을 새로 만들지 않습니다. 본문을 검사하고 문장을 생성한 뒤 UTF-8 JSON으로 응답합니다. 가상 리눅스의 loopback에 연결하여 이 리눅스 세션 안에서만 씁니다.
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from generate import load, generate
def main():
model, tokenizer, cfg = load()
class Handler(BaseHTTPRequestHandler):
def reply(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path == '/health':
self.reply(200, {'status': 'ok', 'model': 'XSML'})
else:
self.reply(404, {'error': 'not found'})
def do_POST(self):
if self.path != '/generate':
return self.reply(404, {'error': 'not found'})
try:
length = int(self.headers.get('Content-Length', '0'))
if not 0 < length <= 4096:
raise ValueError('request size')
data = json.loads(self.rfile.read(length))
prompt = data['prompt']
tokens = data.get('tokens', cfg['tokens'])
temperature = data.get('temperature', 0.8)
top_k = data.get('top_k', 8)
seed = data.get('seed', cfg['seed'])
if not isinstance(prompt, str) or not 0 < len(prompt) <= 512:
raise ValueError('prompt')
if type(tokens) is not int or not 1 <= tokens <= cfg['max_tokens']:
raise ValueError('tokens')
if type(top_k) is not int or top_k < 0 or type(seed) is not int:
raise ValueError('top_k/seed')
if type(temperature) not in (int, float):
raise ValueError('temperature')
result = generate(model, tokenizer, prompt, tokens, temperature, top_k, seed)
self.reply(200, {'text': result, 'model': 'XSML'})
except (ValueError, KeyError, TypeError):
self.reply(400, {'error': 'invalid request'})
address = ('127.0.0.1', cfg['port'])
print('XSML Runner:', address, flush=True)
HTTPServer(address, Handler).serve_forever()
if __name__ == '__main__':
main()
여기까지 이해했나요?
상태 확인은 status ok, 생성 요청은 text가 들어 있는 JSON이어야 합니다. 빈 JSON을 보내면 서버가 죽지 않고 400을 반환하나요? 응답은 정해 놓은 문장이 아니라 내 체크포인트에서 나와야 합니다.
막혔을 때 확인하기
Connection refused라면 runner.log와 체크포인트 파일을 확인하세요. Address already in use라면 기존 Runner를 쓰거나 저장한 PID로 종료한 뒤 다시 시작합니다. 새로 학습했다면 Runner를 다시 시작해야 바뀐 가중치를 읽습니다.
XChat 만들기
터미널 대화 프로그램을 직접 만들어 전체 흐름을 완성합니다.
XChat은 대화 화면, Runner는 실행 서비스, XSML은 학습한 모델입니다. xchat.py는 질문을 읽어 학습 형식을 붙이고 요청을 보낸 뒤 결과를 보여 줍니다. 모델을 직접 불러오거나 답을 스스로 만들어 내지 않습니다.
첫 XChat은 브라우저 터미널 안에서 실행합니다. 지금은 대화 기록을 보내지 않아 질문마다 독립적입니다. 다음 도전은 작은 문맥 길이에 맞게 대화 기록을 붙이는 것입니다. 유창함보다 내가 만든 모델까지 요청이 실제로 왕복하는지가 완성 기준입니다.
직접 타이핑하며 실습하기
Runner를 켜 둔 채 xchat.py를 작성하고 실행하세요. You>에서 hi를 치고 Enter를 누릅니다. /exit로 bash로 나온 다음에만 마지막 kill 명령으로 Runner를 끝냅니다. 완성한 프로젝트를 내보내세요.
cd ~/xsml
nano xchat.py
python3 xchat.py
hi
/exit
kill "$(cat runner.pid)"
xchat.py직접 작성할 전체 소스
input은 한 줄을 읽고, Request는 JSON POST를 만들고, urlopen은 실제 응답을 읽습니다. 연결이 실패하면 가짜 답을 넣지 않고 오류를 알려 줍니다.
import json
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError
def ask(question):
cfg = json.loads(Path('settings.json').read_text())
body = {'prompt': 'Q: ' + question + '\nA:', 'tokens': cfg['tokens']}
request = Request('http://127.0.0.1:' + str(cfg['port']) + '/generate',
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'})
with urlopen(request, timeout=120) as response:
return json.load(response)['text']
if __name__ == '__main__':
print('XChat -> Runner -> XSML | /exit')
while True:
try:
question = input('You> ').strip()
if question == '/exit':
break
if question:
print('XSML>', ask(question))
except (EOFError, KeyboardInterrupt):
break
except (URLError, TimeoutError) as error:
print('Runner:', error)
여기까지 이해했나요?
XChat → HTTP Runner → 저장한 가중치 → 생성한 글의 흐름을 보여 주세요. Runner를 끄고 대화하면 연결 오류가 나야 합니다. 다시 켜면 대화가 돌아오나요? 각 파일이 맡은 일을 다른 사람에게 설명해 보세요.
막혔을 때 확인하기
XChat 안에서 kill을 입력하면 명령이 아니라 질문으로 보내집니다. 먼저 /exit로 나가세요. 내용이 어설퍼도 실제 모델의 응답이 돌아오면 연결은 완성입니다. 다음에는 자료와 학습을 개선해 보세요.