자, 이제 여러분들만의 인공지능을 만들어 봅시다.
XSLM 개요
은 Extra Small Language Model이라는 뜻으로, SONUB에서 개발을 배우기 위해 직접 만든 용어입니다. 발음은 엑스에스엘엠입니다. 목표는 말을 잘하는 거대 이 아니라 처음부터 끝까지 내가 만든 학습용 모델입니다.
글을 로 바꾸고, 가 다음 번호를 예측하도록 합니다. 는 학습한 숫자를 읽어 실행하고, 은 Runner에 질문을 보냅니다. 각 부분을 직접 만듭니다. 이 제공하는 것은 계산과 파일 기능이지, 이미 학습된 두뇌가 아닙니다.
직접 타이핑하며 실습하기
을 열고 아래 을 직접 입력하세요. 다섯 부분이 하는 일을 말로 설명해 보세요. 첫 실험은 타이핑을 줄이려고 짧은 영어 자료를 씁니다. 나중에 원하는 언어의 자료로 바꿀 수 있습니다.
python3 -c "print('XSLM: text -> tokens -> GPT -> Runner -> XChat')"
터미널에서 실습하기
여기까지 이해했나요?
·모델·Runner·대화 화면의 역할을 구별할 수 있나요? 내가 학습한 를 저장하고 다시 읽어 XChat에서 사용하면 완성입니다. 답이 엉뚱해도 전체 개발 과정을 배운 것입니다.
막혔을 때 확인하기
Python이 처음이라면 파이썬 과정에서 ····를 먼저 연습하세요. 아래 준비 운동에서 단어를 세어 다음 말을 고르는 원리를 먼저 볼 수도 있습니다.
선택 준비 운동: 바이그램으로 단어 세어 보기
직접 개발로 이어지는 계획
처음에는 장난감처럼 작게 만들고, 원리를 이해한 뒤 점점 키웁니다.
문장 모으기
내가 배우고 싶은 주제의 문장을 모읍니다. 처음에는 10문장만 있어도 됩니다.
다음 단어 횟수 세기
어떤 단어 뒤에 어떤 단어가 몇 번 나왔는지 표로 만듭니다.
확률 점수 계산하기
고른 낱말 바로 뒤에 어떤 낱말이 몇 번 왔는지 셉니다 — 문장이 끝나는 것도 한 번으로 셉니다. 그다음 각 횟수를 전체 횟수로 나눕니다. “하늘은” 뒤에 “파랗다”가 세 번, “흐리다”가 한 번 왔다면 파랗다는 3 ÷ 4 = 75%, 흐리다는 25% 입니다.
두 선택 방식 비교하기
매번 가장 높은 후보를 고르거나(), 비율대로 제비를 뽑습니다() — 제비뽑기라도 같은 낱말이 다시 나올 수 있습니다. 뽑히면 멈춥니다. 이 단계 어디에도 그 문장이 사실인지 확인하는 부분은 없습니다.
내 기준으로 개선하기
더 좋은 문장, 더 많은 , 다른 선택 규칙을 넣어 나만의 으로 키웁니다.
처음이면 이렇게 따라 해 보세요
이 실습은 바로 앞 단어 하나만 보는 아주 을 만들어요. 어떤 단어 뒤에 어떤 단어가 왔는지 세고, 그 횟수를 로 바꾼 뒤, 확률만큼 기운 주사위를 굴려 다음 단어를 골라요.
- 을 그대로 두고 "다시 생성"을 누르세요. 훈련 문장에 있던 단어로만 이루어진 새 문장이 나와요.
- 확률 공식 칸을 보세요. 앞 단어 뒤에 그 단어가 온 횟수를 앞 단어가 나온 전체 횟수로 나눈 값이 확률이에요.
- 이어짐 횟수 표에서 줄은 앞 단어, 칸은 다음 단어예요. 칸이 진할수록 그 짝이 자주 나왔어요.
- 방식을 으로 바꾸고 다시 누르면 늘 같은 문장이 나와요. 으로 돌아가 를 올리면 문장이 점점 엉뚱해져요.
- 훈련 문장에 내 문장을 한 줄 더해 보세요. 은 이 횟수가 전부라서 표와 확률이 바로 바뀌어요.
더 깊이 배우고 싶다면 파이썬으로 직접 만들기 (AI 과정) 다음 단어는 어떻게 고를까
작은 확률 계산기
표의 비율은 온도 1 일 때예요. 온도는 뽑힐 확률만 바꿀 뿐, 센 횟수나 사실 여부는 바꾸지 않아요.
시작 문장의 마지막 단어 뒤에 이어진 후보가 아직 없습니다.
| 앞 단어다음 단어 | |
|---|---|
단어가 많아 처음 나온 8개만 보여요
핵심 개념
생각은 간단합니다. 문장을 많이 읽고, 어떤 낱말 바로 뒤에 어떤 낱말이 오는지 — 문장이 끝나는 자리까지 — 세어 봅니다. 그 횟수가 작은 표가 되고, 이 표만으로도 이 다음 낱말을 짐작할 수 있습니다. 이 화면에서 만드는 모델이 바로 그것입니다.
진짜 도 같은 생각으로 움직입니다. 다만 훨씬 큽니다. 작은 횟수 표 대신 예시에서 수십억 개의 숫자()를 익히고, 바로 앞 낱말 하나만이 아니라 질문 전체와 지금까지의 대화를 봅니다. 저장해 둔 문장을 찾아 꺼내는 것도 아닙니다. 그래도 마지막 단계는 여러분이 만드는 것과 같습니다 — 후보마다 점수를 매기고, 하나를 고릅니다.
이 실습과 실제 언어모델 비교
| 이 실습 () | ||
|---|---|---|
| 보는 범위 | 바로 앞 단어 하나 | 주어진 긴 |
| 배우는 것 | 이어진 횟수 표 | 숫자 |
| · | 아니요 | 예 |
| 스스로 사실 확인 | 아니요 | 아니요 |
바탕이 된 FAI와 참고 문서
직접 만든 FAI 프로젝트를 바탕으로, Python에서 실행되도록 예제를 새로 작성했습니다. FAI는 ·와 더 큰 모델을 사용합니다. 환경 관련 공식 문서 확인일: 2026-10-18.
개발 환경
은 으로 실행되는 입니다. 은 이미 설치되어 있습니다. 이번 과정은 만 사용하므로 설치·계정··가 필요 없습니다.
FAI의 는 계산과 을 맡습니다. 는 지원되는 Apple 컴퓨터에서 PyTorch를 GPU로 가속하는 기능으로, 이 에서는 쓸 수 없습니다. 도 Python 계산을 할 수 있지만 일반적인 와 서비스에는 제약이 있습니다. 따라서 필수 실습 전체를 이 Alpine 터미널에서 마치고, 작은 자동미분 엔진도 직접 작성합니다.
직접 타이핑하며 실습하기
세 을 차례대로 입력하세요. Python , 32bit 계열 구조, ready가 나오는지 봅니다. 이후의 명령도 모두 이 터미널에서 실행합니다. 의 터미널을 따로 열 필요가 없습니다.
python3 --version
uname -m
python3 -c "import json, math, random, http.server; print('ready')"
터미널에서 실습하기
여기까지 이해했나요?
여기서 torch 설치와 MPS 설정이 필요 없는 이유를 설명해 보세요. 실제 계산은 가 흉내 내는 에서 이루어집니다.
막혔을 때 확인하기
torch가 없다고 pip install torch를 시도하지 마세요. 이번 는 torch를 불러오지 않습니다. 터미널을 받지 못하면 연결을 확인하고 다시 시도하세요. 성공 문구만 흉내 낸 것은 Python 이 아닙니다.
개발 구조
모든 작업은 ~/xslm에서 합니다. 에는 원본···가, checkpoints에는 된 가 들어갑니다. 는 프로젝트 바로 아래에 둡니다. 실행도 항상 이 폴더에서 합니다.
원본 한 줄은 Q: 질문 | A: 답변 형식입니다. prepare.py는 중복 줄을 없앤 뒤 서로 다른 다섯 줄 중 한 줄을 검증용으로 남깁니다. 구분자는 으로 바꿉니다. 검증 자료는 처음 보는 시험지이므로 토크나이저 학습에도 쓰지 않습니다. 사용할 권리가 있는 글을 쓰고, 개인정보 대신 직접 지어낸 예문을 사용하세요.
직접 타이핑하며 실습하기
먼저 폴더를 만드세요. 아래 소스를 하나씩 펼쳐 에서 같은 파일명으로 작성하고 저장한 뒤 닫습니다. settings.json은 이라 쉼표와 따옴표를 정확히 씁니다. 데이터와 소스를 모두 저장한 뒤 prepare.py를 실행하세요.
mkdir -p ~/xslm/data ~/xslm/checkpoints
cd ~/xslm
nano settings.json
nano data/raw.txt
nano prepare.py
python3 prepare.py
wc -l data/train.txt data/val.txt
터미널에서 실습하기
settings.json직접 작성할 전체 소스
작게 시작하는 수업 기본값입니다. width는 크기, 는 볼 수 있는 토큰 수, 는 한 번 바꾸는 정도입니다. 이어서 학습할 때는 같은 설정을 유지하세요.
{
"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 xslm.
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직접 작성할 전체 소스
읽기 → 중복 제거 → 문장쌍 나누기 → 학습·검증 파일 저장입니다. 토큰으로 나누기 전에 문장쌍을 분리해야 똑같은 예제가 양쪽에 들어가지 않습니다.
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))
여기까지 이해했나요?
와 를 입력하세요. settings.json과 prepare.py가 보이나요? 제공한 자료는 학습 12개, 검증 3개 문장쌍으로 나뉩니다. 내 문장쌍을 하나 추가하면 어떤 파일부터 다시 만들어야 할까요?
막혔을 때 확인하기
가 나면 폴더와 파일명을 확인하세요. ~/xslm 다음 ls data를 실행합니다. 는 문제입니다. 공백 네 칸을 맞추세요. 완성 예제는 ~/xslm-reference에 비교용으로만 있으며 내 프로젝트에 자동으로 채우지 않습니다.
BPE 토크나이저
는 글 조각마다 번호를 붙입니다. 이 문자 기반 는 한 글자씩 시작해 붙어 있는 두 조각의 횟수를 세고, 가장 자주 나온 쌍을 합칩니다. 합친 순서가 중요하므로 글을 번호로 바꿀 때도 같은 순서로 규칙을 적용합니다.
이 작은 교육용 구현은 을 합치지 않고 모르는 글자에 0번을 씁니다. 모든 를 다루는 상용 토크나이저와는 다릅니다. tokenizer.json에 와 을 함께 저장합니다. 특정 사전으로 한 에 다른 사전을 끼우면 번호의 뜻이 달라집니다.
직접 타이핑하며 실습하기
tokenizer.py를 직접 작성하고 실행해 을 확인하세요. merge를 종이에 따라가 봅니다. 두 번호가 쌍과 맞으면 새 번호를 넣고 두 칸 전진하며, 아니면 한 칸 전진합니다. 학습 문장을 한 뒤 decode해 보세요.
cd ~/xslm
nano tokenizer.py
python3 tokenizer.py
python3 -m json.tool data/tokenizer.json
터미널에서 실습하기
tokenizer.py직접 작성할 전체 소스
merge는 겹치지 않게 한 쌍을 합칩니다. train은 순서 있는 규칙을 배우고, encode는 그 규칙을 다시 적용합니다. decode는 조각을 이어 붙입니다. 마지막 는 원문 복원을 검사합니다.
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:'))
여기까지 이해했나요?
: OK는 전체 를 번호로 바꿨다가 원문 그대로 되돌렸다는 뜻입니다. settings.json의 병합 횟수를 바꾸어 수를 비교하세요. 처음 보는 글자는 새로 배우지 않고 �로 바뀝니다.
막혔을 때 확인하기
되풀이되는 쌍이 없으면 병합이 없어도 정상입니다. 글이나 병합 규칙을 바꾸었다면 토크나이저와 모델 를 새로 학습해야 합니다. 는 먼저 내보내 보관하세요.
GPT 구조와 코드
은 로 숫자 를 찾는 표입니다. 를 더하면 몇 번째 조각인지 알 수 있습니다. 는 찾을 정보, 는 각 앞자리의 특징, Value는 가져올 정보입니다. 은 관련도를 로 바꾸어 Value를 섞습니다.
은 인과적 어텐션 하나와 하나입니다. keys와 values에는 현재와 앞자리만 들어가므로 미래 정답을 훔쳐볼 수 없습니다. 은 원래 정보를 남기고, 두 층 는 특징을 바꿉니다. 은 숫자 크기를 안정시키고 은 각 의 점수를 냅니다. 읽기 쉽게 , 가 없는 정규화, 없는 구조로 줄였습니다.
직접 타이핑하며 실습하기
engine.py부터 쓰고 검사를 실행한 뒤 model.py를 작성하세요. Value는 계산마다 해당 연산의 미분값을 기억합니다. 는 를 거꾸로 따라가며 를 더하므로 같은 를 여러 번 쓴 영향도 합쳐집니다. 에서 토큰 → 위치 → 어텐션 → MLP → 출력층을 찾아보세요.
cd ~/xslm
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직접 작성할 전체 소스
는 숫자, grad는 그 숫자가 에 미치는 영향, parents는 연산별 미분 기록입니다. 덧셈은 기울기를 나누어 보내고 곱셈은 상대 입력만큼 곱해 보냅니다. 는 점수를 확률로 바꿉니다.
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는 대신 숫자를 저장하고 읽습니다.
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]입니다. 은 정답 토큰 의 입니다. 손실이 작으면 정답에 더 높은 확률을 주었다는 뜻이며 사실을 말한다는 보장은 아닙니다.
매 은 짧은 구간 선택 → 이전 지우기 → · → → 갱신 순서입니다. 한 번의 갱신을 마칠 때마다 ·설정··진행 횟수·를 저장합니다. 처음과 끝의 은 같은 검증 자료 첫 구간을 봅니다. 작은 상태 확인용이며 전체 가 아닙니다.
직접 타이핑하며 실습하기
train.py를 작성하고 짧은 을 실행하세요. 실제 과 loss가 나오는지 봅니다. 이어서 5회 더 실행해 resume 값을 확인하세요. 기기마다 속도가 다르므로 작게 시작합니다. 기본 반복은 동작 확인용이고, 반복을 늘리며 변화를 실험합니다.
cd ~/xslm
nano train.py
python3 train.py --steps 20
python3 train.py --steps 5
터미널에서 실습하기
train.py직접 작성할 전체 소스
자료 는 자료가 바뀐 줄 모르고 이어 학습하는 일을 막습니다. 에 쓴 뒤 로 바꾸므로 저장 도중 중단되어도 이전 가 남습니다. 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는 오르기도 합니다. 한 번의 수치로 결론 내리지 마세요.
막혔을 때 확인하기
로 멈추면 마지막으로 마친 갱신까지 남습니다. 설정·토크나이저·를 바꾸면 기존 체크포인트를 일부러 거부합니다. 먼저 내보내고 model.json을 다른 이름으로 옮겨 새로 시작하세요. 이미 실행 중인 는 다시 시작해야 새 가중치를 읽습니다.
텍스트 생성
생성은 최근 을 에 넣고 예측한 하나를 붙이는 일을 합니다. 가 0이면 가장 높은 점수를 고릅니다. 양수면 점수를 조절한 뒤 무작위로 뽑으며, 높을수록 이 더 고르게 퍼집니다. 는 상위 후보만 남기고, 0이면 전부 씁니다.
는 무작위 선택을 하는 번호이며 정답을 보장하는 번호가 아닙니다. 이나 요청한 토큰 수에서 생성을 멈추고 새로 만든 글만 돌려줍니다. 입력은 때의 Q:/A: 형식을 맞춥니다.
직접 타이핑하며 실습하기
generate.py를 작성하세요. 로 세 을 비교합니다. 먼저 temperature만 바꾸고, 다음에는 top_k와 seed를 바꾸어 보세요. 와 크기는 체크포인트에 저장된 값을 읽습니다.
cd ~/xslm
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
는 체크포인트를 한 번 읽고 요청을 받아 generate를 합니다. 는 요청을 하나씩 처리합니다. 는 실행 상태를, /generate는 생성 문장을 돌려줍니다. 공개 서비스용이 아닌 수업용 입니다.
은 자신의 주소입니다. 아래 은 반드시 이 에서 실행합니다. 주소창의 는 다른 컴퓨터를 가리킵니다. 과 &로 뒤에서 실행할 수 있지만 이 탭이 살아 있는 동안뿐입니다. 새로 고침하거나 닫으면 종료됩니다. 여기서는 가 필요 없습니다.
직접 타이핑하며 실습하기
runner.py를 작성하고 명령대로 시작하세요. 바로 다음 줄에서 를 저장합니다. runner.log를 확인한 뒤 /health와 /generate를 요청합니다. 첫 확인이 너무 빠르면 시작 문구가 나올 때까지 기다렸다 다시 요청하세요.
cd ~/xslm
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직접 작성할 전체 소스
요청마다 을 새로 만들지 않습니다. 본문을 검사하고 문장을 생성한 뒤 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': 'XSLM'})
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': 'XSLM'})
except (ValueError, KeyError, TypeError):
self.reply(400, {'error': 'invalid request'})
address = ('127.0.0.1', cfg['port'])
print('XSLM Runner:', address, flush=True)
HTTPServer(address, Handler).serve_forever()
if __name__ == '__main__':
main()
여기까지 이해했나요?
상태 확인은 status ok, 생성 요청은 text가 들어 있는 JSON이어야 합니다. 빈 JSON을 보내면 서버가 죽지 않고 400을 반환하나요? 응답은 정해 놓은 문장이 아니라 에서 나와야 합니다.
막혔을 때 확인하기
라면 runner.log와 체크포인트 파일을 확인하세요. 라면 기존 Runner를 쓰거나 저장한 PID로 종료한 뒤 다시 시작합니다. 새로 했다면 Runner를 다시 시작해야 바뀐 를 읽습니다.
XChat 만들기
은 대화 화면, 는 실행 서비스, 은 한 입니다. xchat.py는 질문을 읽어 학습 형식을 붙이고 요청을 보낸 뒤 결과를 보여 줍니다. 모델을 직접 불러오거나 답을 스스로 만들어 내지 않습니다.
첫 XChat은 안에서 실행합니다. 지금은 을 보내지 않아 질문마다 독립적입니다. 다음 도전은 작은 길이에 맞게 대화 기록을 붙이는 것입니다. 유창함보다 내가 만든 모델까지 요청이 실제로 왕복하는지가 완성 기준입니다.
직접 타이핑하며 실습하기
Runner를 켜 둔 채 xchat.py를 작성하고 실행하세요. You>에서 hi를 치고 Enter를 누릅니다. /exit로 로 나온 다음에만 마지막 으로 Runner를 끝냅니다. 완성한 프로젝트를 내보내세요.
cd ~/xslm
nano xchat.py
python3 xchat.py
hi
/exit
kill "$(cat runner.pid)"
터미널에서 실습하기
xchat.py직접 작성할 전체 소스
input은 한 줄을 읽고, Request는 를 만들고, 은 실제 응답을 읽습니다. 연결이 실패하면 가짜 답을 넣지 않고 오류를 알려 줍니다.
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 -> XSLM | /exit')
while True:
try:
question = input('You> ').strip()
if question == '/exit':
break
if question:
print('XSLM>', ask(question))
except (EOFError, KeyboardInterrupt):
break
except (URLError, TimeoutError) as error:
print('Runner:', error)
여기까지 이해했나요?
XChat → HTTP Runner → 저장한 → 생성한 글의 흐름을 보여 주세요. Runner를 끄고 대화하면 연결 오류가 나야 합니다. 다시 켜면 대화가 돌아오나요? 각 파일이 맡은 일을 다른 사람에게 설명해 보세요.
막혔을 때 확인하기
XChat 안에서 kill을 입력하면 명령이 아니라 질문으로 보내집니다. 먼저 /exit로 나가세요. 내용이 어설퍼도 실제 모델의 응답이 돌아오면 연결은 완성입니다. 다음에는 자료와 학습을 개선해 보세요.
불러오는 중…
취소할 대기 예약을 선택하세요. 각 예약은 확인 후 취소됩니다.
내 :