Serve your trained through a real . Write runner.py, start it in the background and call and /generate with .
8
Runner & API
The loads the checkpoint once, accepts and calls generate. handles requests sequentially. checks the process; /generate returns generated text. This is a teaching , not a public service.
is the ’s loopback address. Run here in the ; your address bar points to a different computer. and & leave the server in the background while this tab runs. Reloading or closing the tab ends it. This environment does not require .
Why make it a server — load once, use many times
Running generate.py as a reads the checkpoint and rebuilds the every time. With many questions, the same preparation repeats each time. The Runner loads the model only once, keeps it in , and just calls generate whenever a request comes in.
Splitting into a server also separates roles.
Runner (server): holds the model and only computes.
(): handles only the screen that talks with a person.
The agreement between them (): what JSON you send to which address and what JSON comes back.
As long as the agreement is kept, the client can be anything. Instead of the terminal XChat, a web page, a phone app or another server can call the same Runner. A like this, which loads trained and does the computation, is also called an . The Runner is a very small runtime; in practice, runtimes such as , and do the same job fast.
HTTP basics — the shape of requests and responses
is an agreement in which a client sends a request and a server returns a response.
Request: method (GET to fetch, POST to send), (/health, /generate), headers (the kind of body, like : application/json) and body (the JSON of a POST).
Response: , headers and body.
Status codes: success, 400 bad request (the client’s fault), no such path, 500 server error (the server’s fault), 503 not ready yet.
You can build requests yourself with curl.
curl -s address: sends a GET request and quietly shows only the body.
-H 'Content-Type: application/json': adds a header.
-d '{…}': adds a body. With a body, curl sends a POST.
-i: also shows the response status line and headers. Try curl -i http://127.0.0.1:8765/health. You will see the HTTP/1.0 200 OK, Content-Type and Content-Length lines.
The Runner API — addresses, fields and limits
The Runner opens only two addresses.
GET /health: checks that it is alive. The response is {"status": "ok", "model": ""}.
POST /generate: generates text. The response is {"text": "generated text", "model": "XSLM"}.
Any other address: 404 and {"error": "not found"}.
These are the fields of the /generate body.
(required): a string, 1–512 characters.
: an integer, 1–64 (max_tokens). 24 if missing.
: a number 0 or greater. 0.8 if missing.
: an integer 0 or greater. 8 if missing.
seed: an integer. 7 if missing.
For a bad request, the server does not crash; it answers 400 and {"error": "invalid request"}. A body over 4,096 , a body that is not JSON, a field of the wrong type or a missing prompt all give 400. The limits are there to protect the server. A single request with tokens set to 1 million could tie up the server for hours. A public server must have checks like these.
127.0.0.1 and ports — who can connect
The Runner waits for requests on 8765 at 127.0.0.1.
127.0.0.1 (): the loopback address, meaning "this computer itself". Only programs on the same computer can connect.
Port: a number that tells apart several servers on one computer. 8765 is port in settings.json.
0.0.0.0: means accepting from every . You open it this way to let other computers connect, but be careful: opening it to the internet without lets anyone use your server.
Here, "this computer" is the virtual inside the browser. So always run curl and XChat inside the terminal on the right. Typing localhost:8765 in your browser’s address bar points to your own computer, not the virtual Linux, so it will not connect.
Two programs cannot use the same port at the same time. If the Runner is already running and you start another one, you get OSError: .
Running in the background — &, nohup, PID, kill
The Runner is a program that never ends and keeps waiting for requests. Run it normally and it takes over the terminal so you cannot type other commands, so run it in the background.
&: added to the end of a command, it runs the command in the background and gives back the prompt right away.
nohup: keeps the program from ending even if the terminal closes.
-u: writes output right away instead of holding it. are not delayed.
> runner.log : writes both normal output and error output to the runner.log file.
$!: the (PID) of the program just started in the background. Save it with $! > runner.pid.
"$(cat runner.pid)": sends a signal to end the program with the saved .
ps or jobs: shows the programs running now.
A real server must restart automatically when the computer reboots or the program dies. Linux does this with a systemd service (Restart=always) and with launchd. The Runner in this virtual Linux runs only while this tab is open.
Concurrent requests — one at a time and queuing
XSLM’s HTTPServer handles one request at a time. A request that arrives before a generation finishes waits until the earlier request is done. For a class, this is simple and safe.
Servers used by many people do it differently.
ThreadingHTTPServer: creates a separate thread for each request and accepts them at the same time. It is in the .
FastAPI + uvicorn: accepts many connections asynchronously (), while actual generation is queued one at a time with Semaphore(1). There is only one memory, so running several generations at once can overflow it.
Batch processing: grouping requests that arrive within a short time (for example 100ms) and computing them at once uses the GPU far more efficiently. It is a core technique of big services.
FastAPI automatically provides request format checks (pydantic) and API documentation (Swagger) at /docs. XSLM writes the same checks by hand in runner.py.
Going to a real service — speed, size and safety
To grow the Runner into a real service, add things like these.
A fast runtime: run the weights with an engine that optimizes the calculation, such as llama.cpp (strong on and Apple chips), ONNX Runtime (many devices) or vLLM (GPU servers).
: store weights as 8- or 4-bit integers instead of 32- or 16-bit floats to save memory and time. 8 bits express a number in 256 levels, and integer × scale value brings it back close to the original number. Accuracy drops a little.
: store the keys and values of earlier tokens so the whole is not recomputed for every token.
: send each token as soon as it comes out so the user does not wait.
Memory math: running memory = weights + KV cache + . Even an model reduced to 4 bits actually needs 5–8GB.
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.
One model instance is shared by requests. Validate the body, generate, then return JSON. Binding to the guest loopback keeps this exercise to your Linux .
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()
Line-by-line walkthrough
runner.py Loading the model only once
def main(): model, tokenizer, cfg = load()
When main starts, load() reads the model, and settings once. The below is defined inside main and uses the outer model, tokenizer and cfg directly (a closure). It does not rebuild the model for each request.
runner.py reply — sending a response
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)
Handler inherits from BaseHTTPRequestHandler and handles one request. reply turns a into JSON and then into UTF-8 bytes (with ensure_ascii=False, Korean and other non-ASCII text stays as it is), and sends it in the order status code → headers (Content-Type, Content-Length) → blank line (end_headers) → body. Content-Length is the number of bytes, not characters, so it uses the length of the encoded .
When a GET request comes in, do_GET is called. If self.path is /health, it sends 200 and the status; otherwise 404. The health-check address answers right away without any model computation, so it is used as a light check that the server is alive.
runner.py POST /generate — reading and checking the body
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')
If the path is not /generate, it is 404. Then inside try it checks things one by one, and if any is wrong, it raises .
It learns the body length from the Content-Length header and rejects it unless it is 1–4,096 bytes. It reads only that many bytes from rfile.
json.loads turns the body into a dictionary. If it is not JSON, that is a ValueError.
prompt must be present ( if missing); the rest get default values with get.
It checks with type(x) is not int because isinstance(True, int) is true. This blocks requests that send true as the token count.
temperature accepts both integers and floats, and generate rejects negatives and again.
If the checks pass, it calls generate and answers 200 with {"text": …}. ValueError, KeyError and TypeError all mean a bad request, so it answers 400 and the server keeps running. If the server died on every error, one request could stop the whole service.
The address is 127.0.0.1 and the port from the settings. It prints the startup line right away with =True so you can tell from runner.log that it started. HTTPServer(…).serve_forever() endlessly repeats taking one request at a time and passing it to Handler. Stop it with kill or .
Check your understanding
Expect status ok from health and a JSON text field from generation. Send an empty JSON object: the API should return 400 instead of crashing. The response must come from your checkpoint, not a canned answer.
If you get stuck
: inspect runner.log and confirm a checkpoint exists. Address already in use: keep the existing Runner or stop its recorded PID before restarting. After new weights, restart the Runner to load them.
Frequently asked questions
Why doesn’t opening localhost:8765/health in the browser address bar work?
The Runner runs in the virtual Linux inside the browser and waits only on that Linux’s 127.0.0.1. localhost in the address bar is your real computer, and there is no Runner there. Run curl in the terminal on the right.
I get Address already in use.
Another Runner is already using port 8765. Keep using the running Runner, or stop it with kill "$(cat runner.pid)" and start it again. If there is no PID file, find the ID of python3 runner.py with ps and kill it.
I trained more, but the Runner’s answer is unchanged.
The Runner reads checkpoints/model.json only once when it starts and keeps it in memory. It does not notice when the file changes. Stop it and start it again to use the new weights. Real services switch without stopping by starting the new model alongside and moving requests over to it.