runner.py 모델을 한 번만 읽기
def main():
model, tokenizer, cfg = load()
main 이 시작할 때 load() 로 모델··설정을 한 번 읽습니다. 아래 는 main 안에 정의되어 바깥의 model·tokenizer·cfg 를 그대로 씁니다(클로저). 요청마다 모델을 다시 만들지 않습니다.
한 을 실제 로 실행하는 를 만듭니다. runner.py 를 백그라운드로 띄우고 로 와 /generate 를 불러 보세요.
는 체크포인트를 한 번 읽고 요청을 받아 generate를 합니다. 는 요청을 하나씩 처리합니다. 는 실행 상태를, /generate는 생성 문장을 돌려줍니다. 공개 서비스용이 아닌 수업용 입니다.
은 자신의 주소입니다. 아래 은 반드시 이 에서 실행합니다. 주소창의 는 다른 컴퓨터를 가리킵니다. 과 &로 뒤에서 실행할 수 있지만 이 탭이 살아 있는 동안뿐입니다. 새로 고침하거나 닫으면 종료됩니다. 여기서는 가 필요 없습니다.
generate.py 를 으로 실행하면 실행할 때마다 체크포인트를 읽고 을 다시 만듭니다. 질문이 많으면 그때마다 같은 준비를 되풀이합니다. Runner 는 모델을 한 번만 읽어 에 두고, 요청이 올 때마다 generate 만 부릅니다.
서버로 나누면 역할도 갈립니다.
약속만 지키면 클라이언트는 무엇이든 될 수 있습니다. 터미널 XChat 대신 웹 페이지·휴대폰 앱·다른 서버가 같은 Runner 를 부를 수 있습니다. 한 를 읽어 계산을 맡는 이런 을 이라고도 합니다. Runner 는 아주 작은 런타임이고, 실제로는 ·· 같은 런타임이 같은 일을 빠르게 합니다.
는 클라이언트가 요청을 보내고 서버가 응답을 돌려주는 약속입니다.
curl 로 요청을 직접 만들 수 있습니다.
Runner 는 두 주소만 엽니다.
/generate 본문의 필드입니다.
잘못된 요청에는 서버가 죽지 않고 400 과 {"error": "invalid request"} 로 답합니다. 본문이 4,096를 넘거나, JSON 이 아니거나, 필드 형식이 틀리거나, prompt 가 없으면 모두 400 입니다. 제한을 두는 까닭은 서버를 지키기 위해서입니다. tokens 를 100만으로 보내는 요청 하나가 서버를 몇 시간 붙잡아 둘 수 있습니다. 공개 서버라면 이런 검사가 반드시 필요합니다.
Runner 는 127.0.0.1 의 8765번 에서 요청을 기다립니다.
여기서 "이 컴퓨터"는 브라우저 안의 가상 리눅스입니다. 그래서 curl 과 XChat 은 반드시 오른쪽 터미널 안에서 실행합니다. 의 브라우저 주소창에 localhost:8765 를 치면 가상 리눅스가 아니라 내 컴퓨터를 가리켜 접속되지 않습니다.
같은 포트를 두 프로그램이 동시에 쓸 수는 없습니다. Runner 가 이미 켜져 있는데 또 켜면 OSError: 가 납니다.
Runner 는 끝나지 않고 계속 요청을 기다리는 프로그램입니다. 그냥 실행하면 터미널을 차지해 다른 명령을 칠 수 없으므로 뒤에서() 실행합니다.
실제 서버는 컴퓨터가 다시 켜져도, 프로그램이 죽어도 자동으로 다시 켜야 합니다. 는 systemd 서비스(Restart=always)로, 는 launchd 로 이 일을 합니다. 이 가상 리눅스의 Runner 는 이 탭이 열려 있는 동안만 돕니다.
XSLM 의 HTTPServer 는 요청을 한 번에 하나씩 처리합니다. 생성이 끝나기 전에 들어온 요청은 앞 요청이 끝날 때까지 기다립니다. 수업용으로는 단순하고 안전합니다.
여러 사람이 쓰는 서버는 다르게 합니다.
FastAPI 는 요청 형식 검사(pydantic)와 /docs 의 API 문서(Swagger)를 자동으로 만들어 줍니다. XSLM 은 같은 검사를 runner.py 에 손으로 씁니다.
Runner 를 실제 서비스로 키우려면 이런 것들을 더합니다.
runner.py를 작성하고 명령대로 시작하세요. 바로 다음 줄에서 프로세스 번호를 저장합니다. runner.log를 확인한 뒤 /health와 /generate를 요청합니다. 첫 확인이 너무 빠르면 시작 문구가 나올 때까지 기다렸다 다시 요청하세요.
cd ~/xslm
micro 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}'
터미널에서 실습하기
요청마다 모델을 새로 만들지 않습니다. 본문을 검사하고 문장을 생성한 뒤 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()
def main():
model, tokenizer, cfg = load()
main 이 시작할 때 load() 로 모델··설정을 한 번 읽습니다. 아래 는 main 안에 정의되어 바깥의 model·tokenizer·cfg 를 그대로 씁니다(클로저). 요청마다 모델을 다시 만들지 않습니다.
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 는 BaseHTTPRequestHandler 를 이어받아 요청 하나를 맡습니다. reply 는 사전을 JSON 으로 바꿔 UTF-8 바이트로 만들고(ensure_ascii=False 라 한글도 그대로), 상태 코드 → 헤더(Content-Type·Content-Length) → 빈 줄(end_headers) → 본문 순서로 보냅니다. Content-Length 는 글자 수가 아니라 바이트 수라서 인코딩한 의 길이를 씁니다.
def do_GET(self):
if self.path == '/health':
self.reply(200, {'status': 'ok', 'model': 'XSLM'})
else:
self.reply(404, {'error': 'not found'})
GET 요청이 오면 do_GET 이 불립니다. self.path 가 /health 면 200 과 상태를, 아니면 404 를 보냅니다. 상태 확인 주소는 모델 계산 없이 바로 답하므로 서버가 살아 있는지 가볍게 확인하는 데 씁니다.
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')
경로가 /generate 가 아니면 404 입니다. 그다음 try 안에서 하나씩 검사하고, 어느 하나라도 틀리면 를 일으킵니다.
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'})
검사를 통과하면 generate 를 불러 200 과 {"text": …} 로 답합니다. ValueError·KeyError·TypeError 는 모두 잘못된 요청이므로 400 으로 답하고 서버는 계속 돕니다. 오류마다 서버가 죽으면 요청 하나로 서비스 전체가 멈추기 때문입니다.
address = ('127.0.0.1', cfg['port'])
print('XSLM Runner:', address, flush=True)
HTTPServer(address, Handler).serve_forever()
주소는 127.0.0.1 과 설정의 포트입니다. 시작 문구를 =True 로 바로 찍어 runner.log 에서 켜졌는지 알 수 있게 합니다. HTTPServer(…).serve_forever() 는 요청을 하나씩 받아 Handler 에 넘기기를 끝없이 되풀이합니다. kill 이나 로 멈춥니다.
상태 확인은 status ok, 생성 요청은 text가 들어 있는 JSON이어야 합니다. 빈 JSON을 보내면 서버가 죽지 않고 400을 반환하나요? 응답은 정해 놓은 문장이 아니라 에서 나와야 합니다.
라면 runner.log와 체크포인트 파일을 확인하세요. Address already in use라면 기존 Runner를 쓰거나 저장한 PID로 종료한 뒤 다시 시작합니다. 새로 학습했다면 Runner를 다시 시작해야 바뀐 가중치를 읽습니다.
Runner 는 브라우저 안의 가상 리눅스에서 돌고, 그 리눅스의 127.0.0.1 에서만 기다립니다. 주소창의 localhost 는 내 진짜 컴퓨터라서 거기에는 Runner 가 없습니다. curl 은 오른쪽 터미널에서 실행하세요.
8765번 포트를 이미 다른 Runner 가 쓰고 있습니다. 켜진 Runner 를 그대로 쓰거나, kill "$(cat runner.pid)" 로 끈 뒤 다시 켭니다. PID 파일이 없으면 ps 로 python3 runner.py 의 번호를 찾아 kill 합니다.
Runner 는 켤 때 checkpoints/model.json 을 한 번만 읽어 메모리에 둡니다. 파일이 바뀌어도 모릅니다. 끄고 다시 켜야 새 가중치를 씁니다. 실제 서비스는 새 모델을 옆에 띄운 뒤 요청을 옮기는 방식으로 멈추지 않고 바꿉니다.