Now, let's build your very own AI.
Type your own language model in this browser: prepare text, train a tiny GPT, then run your Runner and XChat. No pretrained weights or external AI API.
XSML overview
Understand the whole journey before writing the first line.
XSML means Extra Small , a name created by SONUB for learning to build . Pronunciation: 엑세스엠엘. The usual initials would be XSLM; this course uses the requested name XSML. We aim for a complete learning project, not fluent answers.
Text becomes IDs; a GPT learns to predict the next ID; the loads its learned numbers; XChat sends questions to the Runner. You write each part yourself. supplies arithmetic and files, not a pretrained brain.
Type and try
Open the and type the command below. Then explain the five parts aloud. The small English sample keeps typing short; you can later replace it with text in your own language.
python3 -c "print('XSML: text -> tokens -> GPT -> Runner -> XChat')"
Check your understanding
Can you distinguish a , a , a Runner and a chat ? Completion means your own are trained, saved, reloaded and used by XChat, even if the response is nonsense.
If you get stuck
New to Python? Review variables, lists, loops, functions and classes in the Python course. Use the optional bigram warm-up to see next-word prediction before tackling a neural network.
Optional warm-up: count words with a bigram
A Plan That Leads to Real Development
Start small like a toy, understand the principles, then grow it bigger over time.
Collect sentences
Collect sentences about the topic you want to learn. Just 10 sentences is enough to start.
Count next words
Make a table of how many times each word follows another word.
Compute probability scores
Count how many times each word comes right after the chosen word — the end of the sentence counts too. Then divide each count by the total. If “is” is followed by “blue” three times and “gray” once, blue gets 3 ÷ 4 = 75% and gray gets 25%.
Compare two selection methods
Take the top candidate every time (greedy), or draw by the percentages (sampling) — a draw can still land on the same word again. When the end of the sentence is chosen, it stops. Nothing in this step checks whether the sentence is true.
Improve it your way
Add better sentences, more data, and different selection rules to grow it into your own AI.
First time here? Follow these steps
This lab builds a tiny that only looks at the word right before. It counts which word came after which, turns those counts into and then rolls a weighted die to pick the next word.
- Leave the sentences as they are and press "Generate again". A new sentence appears, made only of words from the training text.
- Look at the probability formula: the number of times the next word came after the previous word, divided by the number of times the previous word appeared, is the probability.
- In the count table, each row is a previous word and each column is a next word. The darker the cell, the more often that pair appeared.
- Switch the mode to and press again: you get the same sentence every time. Go back to and raise the — the sentences become stranger.
- Add one sentence of your own to the training text. The table and the probabilities change right away, because the is only these counts.
Want to go further? Build it yourself in Python (AI Course) How the next word is chosen
A Tiny Probability Calculator
The percentages in the table are at temperature 1. Temperature only changes how likely each word is to be drawn — not the counts or whether anything is true.
There are no candidates yet following the last word of the starting sentence.
| Previous wordNext word | |
|---|---|
Many words — showing only the first 8
Key Concept
The idea is simple: read many sentences and count which word comes right after which — including where a sentence ends. Those counts become a small table, and the table already lets the guess the next word. That is the whole model you build on this page.
Real works on the same idea, only far bigger. Instead of a small table of counts it learns billions of numbers, called , from examples, and it looks at much more than the one word before — your whole question and the conversation so far. It does not look up a stored sentence. But the last step is the same as yours: give every candidate a score, then choose.
This lab vs a real language model
| This lab (bigram) | Neural language model | |
|---|---|---|
| What it looks at | Only the previous word | The long context it is given |
| What it learns | A table of counts | Numeric weights |
| Embeddings and attention | No | Yes |
| Checks facts by itself | No | No |
FAI and reference documents
Based on your FAI project; the examples here are rewritten for browser Python. FAI uses PyTorch, MPS and a larger model. Environment references checked on 2026-10-18.
Environment
Use Python inside this browser, including real files and processes.
The terminal runs 32bit Alpine through v86. Python is already installed. This course uses only its standard : no pip installation, account, or is required.
FAI uses PyTorch for and automatic differentiation. MPS accelerates PyTorch on supported Apple computers; it is not available in this emulated Linux. Pyodide can run Python calculations, but ordinary listening and background services are constrained. We therefore complete every required step in this Alpine terminal. We write a small differentiation engine ourselves.
Type and try
Type the three checks. Look for the Python version, a 32bit x86 architecture and ready. All later commands belong in this terminal, not your computer’s terminal.
python3 --version
uname -m
python3 -c "import json, math, random, http.server; print('ready')"
Check your understanding
Explain why importing torch or enabling MPS is unnecessary here. Python performs the on the CPU emulated by your .
If you get stuck
Do not try to fix a missing torch package with pip install torch: this lesson does not import it. If the terminal will not load, check the connection and retry. A simulated success message is not a working Python environment.
Project structure
Build your folder, data and settings before training anything.
Keep everything in ~/xsml. data holds raw text, training text, text and the tokenizer. checkpoints holds learned weights. The Python files live at the project root. Run all commands from that folder.
Each raw line contains Q: question | A: answer. prepare.py removes duplicate lines and keeps every fifth distinct line for validation. It replaces the separator with a newline. Validation is an unseen quiz; do not train the tokenizer on it. Use text you are allowed to use and invent examples instead of copying personal data.
Type and try
Create the folders first. Open each source below, type it into nano with exactly that filename, save and close. settings.json is , so copy its punctuation carefully. Run prepare.py only after saving both the data and the script.
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.jsonComplete source to type
These are the small course defaults. width is the size of a token vector; context is the number of visible tokens; learning_rate is the update size. Keep the same settings when resuming.
{
"width": 8,
"context": 12,
"merges": 12,
"steps": 20,
"learning_rate": 0.03,
"seed": 7,
"tokens": 24,
"max_tokens": 64,
"port": 8765
}
data/raw.txtComplete source to type
Type one Q/A pair per line. These invented English examples keep the first experiment short. Replace them later with your own consistent text.
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.pyComplete source to type
Read UTF-8 text → remove duplicates → split whole records → write train and validation files. Separating records before tokenization avoids sharing the exact same example between both sets.
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))
Check your understanding
Run pwd and ls. You should see settings.json and prepare.py. The sample produces 12 training records and 3 validation records. Add your own Q/A line and explain which file must be rebuilt.
If you get stuck
FileNotFoundError usually means the wrong folder or filename. Run cd ~/xsml and ls data. IndentationError means Python indentation differs: use four spaces. Finished reference files are in ~/xsml-reference for comparison; they do not fill your project automatically.
BPE tokenizer
Learn a BPE vocabulary from your own training text.
A tokenizer gives each text piece a number. Our character-based BPE starts with individual characters, counts adjacent pairs and repeatedly merges the most frequent pair. The order of merges matters: encoding must replay that order.
This small teaching version keeps newline separate and reserves ID 0 for unknown characters. It is not a full -level production tokenizer. It saves both the vocabulary and merge rules to tokenizer.json. A model trained with one vocabulary cannot safely use a different one.
Type and try
Type tokenizer.py, run it and inspect the JSON. Follow merge with a pencil: when two IDs match, append the new ID and advance two positions; otherwise advance one. Encode then decode a training sentence.
cd ~/xsml
nano tokenizer.py
python3 tokenizer.py
python3 -m json.tool data/tokenizer.json
tokenizer.pyComplete source to type
merge combines one pair without overlapping. train learns the ordered rules. encode replays them; decode joins vocabulary pieces. The final assertion checks reversibility.
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:'))
Check your understanding
roundtrip: OK means the entire training text survives encode → decode. Change the merge count in settings.json and compare token counts. Test an unseen character: it becomes �, not a newly learned token.
If you get stuck
No merges can be valid when no pair repeats. If you change text or merge rules, rebuild the tokenizer and start new model weights; keep old checkpoints in an exported backup.
GPT architecture
Write a complete tiny decoder Transformer and its gradients.
looks up a of numbers for each token. A position vector records where it appears. Query asks what to look for, key describes each earlier position, and value carries its information. weights combine those values.
The model uses one causal attention head and one block. keys and values contain only the current and earlier positions, so the model cannot peek ahead. Residual additions preserve the input; a two-layer MLP changes features. Layer normalization stabilizes their scale; the output head scores vocabulary tokens. For readable , this version uses , -free normalization and no dropout.
Type and try
Write engine.py first and run its derivative check; then write model.py. In Value, each operation remembers its local derivative. backward visits the graph in reverse order and adds contributions, so shared parameters learn from every use. Trace token → position → attention → MLP → head in forward.
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.pyComplete source to type
data is a number; grad is its effect on the loss; parents store local derivatives. Addition distributes a gradient; multiplication scales it by the other input. softmax converts scores to probabilities.
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.pyComplete source to type
Read shapes first, then forward. Every Value in weights is trainable. Lists store vectors and matrices. loss compares each position with its next-token target. state and load serialize numbers, not Python objects.
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)
Check your understanding
engine.py must print y = 15.0 and dy/dx = 8.0. The model shape check prints 2 10: two positions, ten vocabulary scores each. Explain why appending a future token must not change the earlier scores.
If you get stuck
Keep matrix rows and zip inputs aligned. A count-based bigram is not a substitute for these layers. This model is deliberately tiny; all embedding, attention, MLP and head weights still learn through backpropagation.
Training & checkpoints
Predict the next token, update weights and resume from a checkpoint.
For tokens [a,b,c], inputs [a,b] target [b,c]. Cross-entropy is the negative log of the correct next token. A smaller means the model assigned it more probability; it does not guarantee a truthful answer.
Each iteration chooses a short window, clears old gradients, runs forward and backward, clips the norm and applies SGD. A checkpoint stores weights, settings, tokenizer, step and random state after each completed update. The initial and final validation values use the same first held-out window: a small diagnostic, not a complete evaluation.
Type and try
Type train.py and run the short training command. Watch real step and loss values. Run five more steps and check resume. Start small because browser speed varies. Repeat training to explore changes; the default run only checks that the pipeline works.
cd ~/xsml
nano train.py
python3 train.py --steps 20
python3 train.py --steps 5
train.pyComplete source to type
The data hash prevents resuming on silently changed data. Saving through a temporary file and os.replace preserves the previous complete checkpoint if writing is interrupted. SGD has no momentum state to save.
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()
Check your understanding
Find checkpoints/model.json. The second run continues the previous step count. Compare validation before and after. Loss may rise on different random windows; test a longer run before drawing conclusions.
If you get stuck
Ctrl+C stops training; the last completed update remains saved. Changed settings, tokenizer or data deliberately reject the old checkpoint. Export first, then move model.json to another name to start again. A Runner already running keeps its old weights until restarted.
Text generation
Turn next-token scores into generated text.
Generation repeatedly feeds the latest to GPT and appends one predicted token. 0 picks the highest score. Positive temperatures rescale scores before random : higher values spread probability more broadly. top_k keeps only the strongest candidates; 0 keeps all.
A seed repeats the random choices. It does not make answers correct. Generation stops at a newline or the requested token limit. Only the newly generated text is returned. Keep the Q:/A: format used during training.
Type and try
Write generate.py. Compare the three commands while keeping the same checkpoint. Change only temperature first, then top_k and seed. The checkpoint supplies its own tokenizer and model dimensions.
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.pyComplete source to type
load rebuilds the architecture and restores numbers. generate crops context, scores candidates and samples repeatedly. A fixed seed makes comparisons reproducible.
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))
Check your understanding
The same checkpoint, , seed and parameters should reproduce the same text. Explain why loading weights differs from training them and why an empty answer can occur when newline is selected first.
If you get stuck
Strange or empty output is expected from a tiny, briefly trained model. Check data format, unknown characters and training loss. More sampling randomness is not a fix for insufficient training.
Runner & API
Serve your trained model through a real HTTP API.
The Runner loads the checkpoint once, accepts JSON and calls generate. HTTPServer handles requests sequentially. GET /health checks the process; POST /generate returns generated text. This is a teaching , not a public service.
127.0.0.1 is the Linux guest’s loopback address. Run here in the terminal; your browser address bar points to a different computer. nohup and & leave the server in the background while this tab runs. Reloading or closing the tab ends it. This Alpine environment does not require systemd.
Type and try
Write runner.py and start it with the command shown. Save its process ID immediately. Check runner.log, then request /health and /generate. If the first health check is early, wait for the startup line and try again.
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.pyComplete source to type
One model instance is shared by requests. Validate the body, generate, then return UTF-8 JSON. Binding to the guest loopback keeps this exercise local to your Linux session.
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()
Check your understanding
Expect status ok from health and a JSON text field from generation. Send an empty JSON object: the should return 400 instead of crashing. The response must come from your checkpoint, not a canned answer.
If you get stuck
Connection refused: inspect runner.log and confirm a checkpoint exists. Address already in use: keep the existing Runner or stop its recorded PID before restarting. After training new weights, restart the Runner to load them.
XChat
Create your own terminal chat client and complete the entire chain.
XChat is the interface, Runner is the execution service, and XSML is the learned model. xchat.py reads a question, adds the training format, sends an request and displays the text. It does not import the model or generate an answer itself.
This first client runs inside the browser terminal. Each question is independent: it does not yet send conversation history. A useful extension is to add history, while respecting the model’s small context. Fluency is not the graduation requirement; an actual request through your own trained model is.
Type and try
Leave the Runner running. Type xchat.py and launch it. At You>, type hi and press Enter. Type /exit to return to bash. Only then run the final kill command to stop the Runner. Export your complete project.
cd ~/xsml
nano xchat.py
python3 xchat.py
hi
/exit
kill "$(cat runner.pid)"
xchat.pyComplete source to type
input reads a line; Request creates the JSON POST; urlopen reads the real response. Connection errors are reported instead of replaced with a fake answer.
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)
Check your understanding
Show the chain XChat → HTTP Runner → loaded weights → generated text. Stop the Runner and try another chat: it must report a connection error. Restart it and verify chat works again. Explain each file to someone else.
If you get stuck
Do not type the kill command inside XChat; it would be sent as a question. Exit first. A successful network response with poor text still demonstrates the pipeline; improve data and training as your next experiment.