Skip to content

10 · Project — Self-Hosted LLM Stack

This capstone assembles Level 3 into one running stack: a local/served open model, a serving layer built for real concurrency, guardrails on input and output, and observability tying it all together — a complete alternative to calling a hosted API, end to end.

Architecture

Client ──► FastAPI gateway ──► Input guardrail (module 8)
        vLLM server (module 5) ──► Open model (quantized, module 7)
        Output guardrail (module 8)
        Tracer + usage log (module 9) ──► traces.jsonl / usage.db

Serving layer

Start vLLM with prefix caching and quantization for a good throughput/quality balance on modest hardware:

python -m vllm.entrypoints.openai.api_server \
    --model TheBloke/Llama-3-8B-Instruct-AWQ \
    --quantization awq \
    --gpu-memory-utilization 0.9 \
    --enable-prefix-caching \
    --port 8000

The gateway

Combines guardrails and tracing around every request, presenting one clean API to callers regardless of what's happening underneath:

# gateway.py
from fastapi import FastAPI
from openai import OpenAI
from pydantic import BaseModel
import uuid, sqlite3, json, time

app = FastAPI()
llm = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
MODEL = "TheBloke/Llama-3-8B-Instruct-AWQ"

class ChatRequest(BaseModel):
    message: str

def check_policy(text: str) -> dict:
    resp = llm.chat.completions.create(
        model=MODEL, max_tokens=100,
        messages=[{"role": "user", "content":
            f"Reply with exactly one word, 'BLOCK' or 'ALLOW', for whether "
            f"this text requests something harmful or illegal:\n\n{text}"}],
    )
    return {"blocked": "BLOCK" in resp.choices[0].message.content.upper()}

def log_trace(trace_id: str, spans: list[dict]) -> None:
    with open("traces.jsonl", "a") as f:
        f.write(json.dumps({"trace_id": trace_id, "spans": spans}) + "\n")

@app.post("/chat")
def chat(req: ChatRequest):
    trace_id = str(uuid.uuid4())
    spans = []

    t0 = time.monotonic()
    input_check = check_policy(req.message)
    spans.append({"name": "input_guardrail", "duration_ms": (time.monotonic() - t0) * 1000, "result": input_check})
    if input_check["blocked"]:
        log_trace(trace_id, spans)
        return {"reply": "I can't help with that.", "trace_id": trace_id}

    t0 = time.monotonic()
    resp = llm.chat.completions.create(model=MODEL, max_tokens=600, messages=[{"role": "user", "content": req.message}])
    reply = resp.choices[0].message.content
    spans.append({"name": "llm_call", "duration_ms": (time.monotonic() - t0) * 1000,
                  "tokens": resp.usage.total_tokens})

    t0 = time.monotonic()
    output_check = check_policy(reply)
    spans.append({"name": "output_guardrail", "duration_ms": (time.monotonic() - t0) * 1000, "result": output_check})
    if output_check["blocked"]:
        log_trace(trace_id, spans)
        return {"reply": "I'm not able to share that response.", "trace_id": trace_id}

    log_trace(trace_id, spans)
    return {"reply": reply, "trace_id": trace_id}

Usage tracking

# usage.py
import sqlite3

def init_db():
    conn = sqlite3.connect("usage.db")
    conn.execute("""CREATE TABLE IF NOT EXISTS usage
                     (trace_id TEXT, tokens INT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""")
    conn.commit()

def log_usage(trace_id: str, tokens: int) -> None:
    conn = sqlite3.connect("usage.db")
    conn.execute("INSERT INTO usage VALUES (?, ?, CURRENT_TIMESTAMP)", (trace_id, tokens))
    conn.commit()

Since a self-hosted stack has no per-token API bill, "cost" here is compute/GPU-time — track total tokens and requests per day as a proxy for capacity planning (when do you need a second GPU) rather than a literal invoice.

Running it end to end

# Terminal 1: serving layer
python -m vllm.entrypoints.openai.api_server --model TheBloke/Llama-3-8B-Instruct-AWQ --quantization awq --port 8000

# Terminal 2: gateway
uvicorn gateway:app --port 9000

# Terminal 3: try it
curl -X POST http://localhost:9000/chat -H "Content-Type: application/json" \
     -d '{"message": "Explain what a self-hosted LLM stack is."}'

Inspect traces.jsonl afterward to see the full span breakdown — input guardrail, main call, output guardrail, each with real latency — for that exact request.

Why self-host at all

Weigh this against a hosted API honestly before committing to the operational burden:

Factor Self-hosted Hosted API
Data residency Full control — nothing leaves your infra Data sent to provider
Cost at low volume GPU cost even when idle Pay only per token used
Cost at high, steady volume Can be cheaper at scale Scales linearly with usage
Model quality ceiling Bounded by best available open weights Access to frontier closed models
Operational burden You own uptime, scaling, security patching Provider owns it

A hybrid is common in practice: sensitive or high-volume, latency-tolerant workloads on a self-hosted stack; everything needing frontier capability or minimal ops burden on a hosted API.

How It Actually Works

Nothing in this capstone introduces a new mechanism — it is Level 3's five components (a real transformer, a trained tokenizer, embeddings for any retrieval step you add, a memory-efficient serving layer, a quantized model) wired together behind the same guardrail and tracing patterns Level 3's later modules built independently. The gateway's job is purely orchestration: call the policy check, call the model, call the policy check again, record what happened — every one of those calls resolves to the identical stateless request→response cycle from Level 1, now pointed at localhost:8000 instead of a hosted provider's endpoint.

The properties that differ from a hosted setup are entirely about who operates each piece: you now own the GPU provisioning that determines throughput (module 5), the quantization tradeoff that determines quality-per-dollar (module 7), and the uptime of every layer in the diagram — none of which the model's own behavior changes one bit from how it behaved when called through a hosted API in earlier levels.

Cheat sheet

Layer Module it came from
Open model, quantized Modules 4, 7
vLLM serving Module 5
Input/output guardrails Module 8
Tracing + usage logging Module 9
Gateway orchestration Plain FastAPI, no new mechanism

Exercise

Stand up the full stack locally with a small AWQ-quantized model, and run 20 varied requests through the gateway (a mix of benign requests and a few that should trigger the guardrail). Confirm every request produces a trace with distinguishable spans, and that blocked requests never reach the main model call (verify this from the trace, not just the response). Then swap the guardrail's model for a smaller/faster one than the main chat model and measure the latency improvement on the two guardrail spans.