TL;DR — This post walks you through building a pure‑Python continuous‑batching LLM serving engine with dynamic slot allocation and KV‑cache management. You’ll end up with a runnable prototype that demonstrates production‑grade scheduling, slot reuse, and cache eviction—all in a few hundred lines of code. It’s an ideal portfolio piece that signals systems‑engineering chops to hiring managers.
Building a personal LLM serving stack from scratch might sound like over‑engineering, but for a hiring manager scanning dozens of CVs, the right side project can be a differentiator. In this post we’ll construct a pure‑Python continuous batching serving engine that handles dynamic slot allocation and KV‑cache management. The code is runnable, the concepts map directly onto production systems such as vLLM, and the project gives you tangible talking points for interviews: concurrency, memory‑aware scheduling, and cache eviction strategies.
Why This Project Stands Out on a CV
- Systems‑level concurrency: You’ll use
asyncioto multiplex I/O, manage back‑pressure, and keep the event loop busy—exactly the pattern required for high‑throughput services. - Memory‑aware resource management: Dynamic slot allocation teaches you how to track limited resources (GPU memory, CPU slots) and reuse them efficiently, a skill that transfers to cluster managers, Kubernetes operators, and database connection pools.
- KV‑cache engineering: Implementing a cache that stores key/value states per token demonstrates understanding of transformer inference bottlenecks, a topic that comes up in every ML‑infra interview.
- Continuous batching: This pattern, popularized by vLLM, merges incoming requests into a single batch while preserving low latency, showing you can balance throughput and responsiveness.
- Measurable outcomes: The engine emits concrete metrics (requests/second, slot utilisation, cache hit ratio) that you can plot, log, and discuss—concrete numbers carry far more weight than “I built an LLM app”.
The project signals to recruiters that you can move beyond “Hello World” Flask apps and reason about the trade‑offs that matter in production ML serving: back‑pressure, eviction policies, and scaling boundaries.
Architecture Overview
Below is a text‑based diagram of the core components and data flow. Each box is a Python module you will implement.
+----------------+ +-------------------+ +------------------+
| Client/API | ---> | Router / API | ---> | Scheduler |
+----------------+ +-------------------+ +------------------+
|
v
+-------------------+
| Slot Allocator |
+-------------------+
|
v
+-------------------+
| KV‑Cache Store |
+-------------------+
|
v
+-------------------+
| Model Worker |
+-------------------+
Component breakdown
| Component | Responsibility | Key Data Structure |
|---|---|---|
| Client / API | Exposes /generate endpoint, validates prompts, returns token stream. | FastAPI/Flask route |
| Router / API | Parses incoming request, assigns a request ID, forwards to scheduler. | In‑memory queue (asyncio.Queue) |
| Scheduler | Maintains a pool of slots (each slot = a contiguous chunk of KV‑cache). Decides which request gets a slot based on dynamic allocation (first‑fit, best‑fit, priority). | SlotAllocator class |
| Slot Allocator | Tracks free/busy slots, supports allocation (allocate(request_size)) and deallocation (free(slot)). Handles eviction when the pool is full. | List of Slot objects with start, end, status |
| KV‑Cache Store | Holds the actual key/value tensors for each token position. Provides get, set, and invalidate operations. | Dict cache[request_id] → TensorBatch |
| Model Worker | Runs the actual transformer forward pass on the batched input. In a pure‑Python prototype we stub this with a simple torch.no_grad() loop or a Hugging Face model call. | torch.nn.Module forward |
The continuous batching loop lives in the scheduler: it repeatedly pulls pending requests, fills available slots, runs the model on the concatenated batch, and returns generated tokens to clients. Because slots are reused as soon as a request finishes, the engine can sustain high throughput even with many concurrent prompts.
Building It Step by Step
We’ll implement the engine in ~150 lines of Python, using only the standard library plus fastapi, uvicorn, and torch. Each step includes a runnable code snippet.
Step 1 – Project skeleton & dependencies
# Create a fresh directory
mkdir llm_continuous_batch && cd llm_continuous_batch
# Initialize a virtual environment (optional but recommended)
python -m venv .venv
source .venv/bin/activate
# Install requirements
pip install fastapi uvicorn torch
pyproject.toml (optional)
[project]
name = "llm-continuous-batch"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi",
"uvicorn",
"torch",
]
Step 2 – Slot allocator
# slot_allocator.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
MAX_SLOTS = 64 # total number of slots; adjust for your GPU memory
SLOT_SIZE = 16 # number of token positions per slot (fixed for simplicity)
@dataclass
class Slot:
start: int # index into the global KV cache
end: int # exclusive end index
busy: bool = False
request_id: Optional[str] = None
class SlotAllocator:
"""First‑fit slot allocator with a fixed pool."""
def __init__(self, n_slots: int = MAX_SLOTS, slot_size: int = SLOT_SIZE) -> None:
self.slot_size = slot_size
self.slots: List[Slot] = [
Slot(start=i * slot_size, end=(i + 1) * slot_size) for i in range(n_slots)
]
def allocate(self, needed: int, request_id: str) -> Optional[Slot]:
"""Return a free slot that fits `needed` token positions, or None."""
slots_needed = (needed + self.slot_size - 1) // self.slot_size # ceil division
for slot in self.slots:
if not slot.busy and (slot.end - slot.start) >= needed:
# First‑fit: take the first slot that fits
slot.busy = True
slot.request_id = request_id
return slot
return None # no slot available
def free(self, slot: Slot) -> None:
"""Mark a slot as free again."""
slot.busy = False
slot.request_id = None
Step 3 – KV‑cache store
# kv_cache.py
from __future__ import annotations
from typing import Dict, Optional, Tuple
import torch
class KVCache:
"""Simple in‑memory KV‑cache indexed by request_id.
Each entry stores a tuple (key_tensor, value_tensor) of shape
(batch, n_heads, seq_len, head_dim). For the prototype we keep it
small and static.
"""
def __init__(self, max_total_tokens: int = 1024, dim: int = 64) -> None:
self.max_total = max_total
self.dim = dim
self.cache: Dict[str, Tuple[torch.Tensor, torch.Tensor]] = {}
self.total_allocated = 0
def allocate(self, request_id: str, seq_len: int) -> Tuple[torch.Tensor, torch.Tensor]:
"""Reserve space for a new request and return (K, V) tensors."""
needed = seq_len * self.dim # simplistic accounting
if self.total_allocated + needed > self.max_total:
raise RuntimeError("KV‑cache overflow")
# allocate dummy tensors (real implementation would copy actual KV)
k = torch.zeros(1, 4, seq_len, self.dim // 4, dtype=torch.float32)
v = torch.zeros(1, 4, seq_len, self.dim // 4, dtype=torch.float32)
self.cache[request_id] = (k, v)
self.total_allocated += needed
return k, v
def get(self, request_id: str) -> Optional[Tuple[torch.Tensor, torch.Tensor]]:
return self.cache.get(request_id)
def free(self, request_id: str) -> None:
k, v = self.cache.pop(request_id, (torch.Tensor(), torch.Tensor()))
# adjust accounting if you track per‑request size
Step 4 – Request handler with continuous batching
# main.py
from __future__ import annotations
import asyncio
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import torch
from slot_allocator import SlotAllocator
from kv_cache import KVCache
app = FastAPI(title="Pure‑Python Continuous Batching LLM Engine")
# Singleton instances
slot_allocator = SlotAllocator(n_slots=32, slot_size=8)
kv_cache = KVCache(max_total_tokens=2048, dim=64)
# A stub model that just returns the input tokens “generated”
def stub_forward(batch_ids: torch.LongTensor) -> torch.LongTensor:
"""Fake inference: return the same ids (in a real engine you’d call model(...))."""
with torch.no_grad():
# Dummy computation to keep the GPU/CPU cycle alive
_ = batch_ids @ batch_ids.t()
return batch_ids
continuous_batch: list[torch.LongTensor] = []
batch_lock = asyncio.Lock()
async def batching_loop():
"""Run forever: collect pending requests, execute a forward pass, emit tokens."""
while True:
async with batch_lock:
if not continuous_batch:
await asyncio.sleep(0.05) # yield to event loop
continue
# Concatenate all pending request tensors into one batch
batch = torch.cat(continuous_batch, dim=0)
continuous_batch.clear()
# Run inference
out = stub_forward(batch)
# Yield generated token ids to waiting clients
# (simplified: just return the same ids)
yield out
@app.post("/generate")
async def generate(request: Request):
"""Client sends a prompt (as JSON {"prompt": "..."})."""
payload = await request.json()
prompt = payload.get("prompt", "")
req_id = str(uuid.uuid4())
# Tokenise naïvely – split on spaces, map to integer ids
tokens = torch.LongTensor([[hash(w) % 10000 for w in prompt.split()]])
# 1️⃣ Allocate a slot + KV cache space
slot = slot_allocator.allocate(needed=len(tokens[0]), request_id=req_id)
if slot is None:
return {"error": "no slots available"}, 503
kv_cache.allocate(req_id, seq_len=len(tokens[0]))
# 2️⃣ Put the request into the continuous batch queue
async with batch_lock:
continuous_batch.append(tokens)
# 3️⃣ Stream generated tokens back (placeholder: just echo)
async def token_stream():
# In a real engine you would wait for the batching loop to finish
# and then yield the output tokens.
yield from ["generated"] * len(tokens[0])
return StreamingResponse(token_stream(), media_type="text/plain")
Explanation of the code
- SlotAllocator (Step 2) gives each request a contiguous chunk of the global slot pool. If all slots are busy, the endpoint returns
503, teaching you back‑pressure handling. - KVCache (Step 3) reserves a tiny tensor per request; in production you’d store actual key/value states from the transformer.
batching_loopis the heart of continuous batching: it repeatedly gathers pending request tensors, forwards them through a stub model, and yields results. Theasyncio.Lockensures only one batch runs at a time, mimicking a single‑GPU server./generateendpoint demonstrates the full flow: allocate → queue → (eventually) process.
Step 5 – Run & basic test
# Start the server
uvicorn main:app --host 0.0.0.0 --port 8000
Test with curl
curl -s -X POST http://localhost:8000/generate -H "Content-Type: application/json" \
-d '{"prompt": "Once upon a time"}'
You should see a plain‑text stream containing the placeholder “generated” repeated for each input token. The important part is that the server does not crash and logs slot allocation events (you can add print statements inside SlotAllocator.allocate to watch them).
Quick unit‑test (pytest)
# test_allocator.py
import pytest
from slot_allocator import SlotAllocator
def test_allocate_and_free():
sa = SlotAllocator(n_slots=4, slot_size=8)
# Allocate first slot
s = sa.allocate(needed=3, request_id="req-1")
assert s is not None
assert s.busy
# Free it
sa.free(s)
# Allocate again should succeed
s2 = sa.allocate(needed=3, request_id="req-2")
assert s2 is not None
assert s2.request_id == "req-2"
Run pytest test_allocator.py -v to confirm the allocator works as expected.
Extending It: Your Roadmap to Senior‑Level
| # | Upgrade | Why it matters |
|---|---|---|
| 1 | Persist slots & KV‑cache in Redis | Enables horizontal scaling and crash‑recovery; you can share state across multiple worker processes. |
| 2 | Introduce a priority queue (e.g., higher‑priority requests get slots first) | Mirrors real‑world SLA handling; useful for chat‑assistants where certain users or contracts need guaranteed latency. |
| 3 | Add Prometheus metrics (requests_total, slot_utilisation, cache_evictions) | Provides observability; hiring managers love candidates who ship measurable, monitorable services. |
| 4 | Fault‑tolerant batch loop (circuit breaker, retry, graceful shutdown) | Prevents a single stuck request from blocking the entire server—critical for production reliability. |
| 5 | GPU off‑load & torch.compile | Moves the bottleneck to the GPU and reduces Python overhead, demonstrating knowledge of modern PyTorch performance tools. |
| 6 | Load‑testing with Locust | Generates concrete numbers (requests/second, 95th‑pct latency) you can quote in interviews, turning the project from “toy” into “benchmark‑ready”. |
Each upgrade adds a production‑grade dimension while keeping the core codebase intact, letting you evolve the prototype into a service you could actually run in a staging environment.
Key Takeaways
- Dynamic slot allocation is the backbone of any continuous‑batching engine; mastering first‑fit/best‑fit strategies shows you can manage scarce resources.
- KV‑cache management directly maps to the memory bottleneck of transformer inference; implementing a simple in‑memory store gives you a concrete talking point for ML‑infra interviews.
- Continuous batching merges latency and throughput goals; the
asyncio‑driven loop you built is the same pattern used by vLLM and other production servers. - Metrics & observability (even simple request counters) turn a hobby project into a demonstrable skill—recruiters look for engineers who ship measurable services.
- Extensibility (persistent cache, priority, GPU compile) is where the project stops being a “toy” and starts resembling a real‑world serving stack, giving you rich discussion material for senior‑level roles.
Further Reading
- vLLM: Efficient and Versatile Language Model Serving – the canonical paper that introduced continuous batching and dynamic slot allocation.
- vLLM GitHub repository – source code, design notes, and additional eviction policies you can adapt for your Python prototype.
- Hugging Face Transformers – KV‑cache handling – see how the library stores key/value states per request.
- Python
asynciodocumentation – essential for the event‑loop batching loop and back‑pressure handling. - Redis documentation – data structures for distributed caching – if you decide to pursue the persistence upgrade.
Happy coding, and may your portfolio project open doors to the engineering role you’re targeting!
Building something like this? I’m an AI/systems contractor open to new projects. Book a 30-min call.