Production serving with vLLM
Ollama got you running; vLLM gets you serving. The IC6 production engine — batching, paging, quantization — applied to self-hosting open models behind an OpenAI-compatible API.
- a GPU +
pip install vllm(serving engine)
Learning objectives
- Explain why production self-hosting needs vLLM, not Ollama.
- Stand up vLLM with an OpenAI-compatible API for an open model.
- Size the GPU and set the key serving flags.
- Connect your app with a one-line change.
30-second recap
- The KV-cache: During decoding the model stores each token's attention keys and values so it doesn't recompute them; that cache is what fills GPU memory.
- Why containers: A container packages an app with its dependencies so it runs the same everywhere; an image is the blueprint, a container is a running copy.
code/lm3-vllm-serving/ in the course, with a README. Run the scripts or copy the configs directly.From laptop to server intermediate
Here's the trap that catches almost everyone the first time. You wire up Ollama on your laptop in the last chapter, the model answers, everything feels production-ready — so you copy that same setup onto a GPU box, point your app at it, and open it to a handful of colleagues. It's great for the first person. It's fine for the second. By the tenth simultaneous user it's crawling, requests are timing out, and the GPU is somehow only 20% busy while everyone waits. Nothing is broken. You just used a single-user tool for a many-user job, and those are two genuinely different problems.
The reason is worth internalizing, because it's the whole justification for this chapter. Think of Ollama as your own home kitchen: perfect for cooking one meal for yourself, on demand, right now. A production serving engine is a restaurant line — the same stoves, but organized so one crew can plate fifty orders at once without any single diner waiting fifty times as long. The bottleneck in LLM serving is almost never raw compute; it's how cleverly you share the GPU across concurrent requests. A naive server processes requests one at a time, so the expensive card sits mostly idle between tokens. The fix is to keep many requests in flight together — and that is exactly the machinery a dev tool doesn't bother building.
The common mistake is assuming "it works locally" means "it will scale," and reaching for more GPUs when throughput sags. More hardware rarely helps a single-stream server, because the problem is utilization, not capacity — you're paying for a whole card and using a sliver of it. The right move is a smarter engine on the same card. That engine is vLLM, and it earns its place through three techniques you already met in the Inference track: continuous batching (slot new requests into the running batch the instant a slot frees up, instead of waiting for a fixed batch to finish), PagedAttention (manage the KV-cache in small pages like an operating system manages RAM, so memory isn't wasted on padding and you fit far more concurrent sequences), and quantization (compress the weights so a bigger model — or more cache headroom — fits on the card you have).
Ollama got you running; vLLM gets you serving. It's the production engine from IC6 — PagedAttention, continuous batching, quantization — behind the same OpenAI-compatible API. This chapter is the self-hosting view of the inference track.
This pipeline shows how a raw open model becomes a real service that many people can use at once. Read it left to right — each box feeds the next.
- The first box (Open model weights) is just the downloaded model files, usually from Hugging Face ("HF") — data, not yet a running service.
- The second box (vLLM server) is the engine that loads those weights and runs them efficiently. "batching+paging" means it serves many requests together and manages GPU memory cleverly, so it's fast under load.
- The third box (OpenAI-compatible API) is the doorway vLLM opens — a
/v1/chatendpoint that looks exactly like OpenAI's, so existing code works against it. - The last box (Your app + many users) is the point: real traffic hits your app, which calls the API — and the client code didn't have to change.
In short: Weights → vLLM engine → OpenAI-style API → your app. vLLM is the middle piece that turns a model file into a fast service many users can share.
Stand it up intermediate
Brace yourself for an anticlimax: the single hardest-sounding thing in this chapter — running a production LLM server — is one command. Not a Dockerfile, not a Kubernetes manifest, not a framework you have to learn. You install vLLM, you point it at a model name, you pick a port. That's genuinely it for a first server. The complexity people expect here mostly lives in the flags, and the flags are just the IC-track levers wearing command-line clothes.
It helps to picture what that one command sets in motion. vLLM downloads the open weights from Hugging Face (once — they're cached after that), loads them onto the GPU, and then stands up a web server that speaks the OpenAI wire protocol. That last part is the quiet genius of the design: instead of inventing its own API that you'd have to write a new client for, vLLM mimics the exact HTTP shape OpenAI uses — same /v1/chat/completions path, same JSON request and response. So every tool, SDK, and snippet in the world that already talks to OpenAI can talk to your box with zero code changes beyond the address. You'll see that pay off in the very next lab.
The mistake to avoid is treating the flags as magic incantations to copy blindly. Each one is a deliberate memory-versus-throughput decision you're making on the model's behalf: --quantization trades a little quality for a much smaller memory footprint; --max-num-seqs sets how wide the continuous batch runs (bigger = more concurrent users, but more KV-cache memory); --gpu-memory-utilization hands vLLM a slice of the card and tells it "budget weights and cache inside this." Read the launch command below not as boilerplate but as four tuning knobs — and know that when something goes wrong, it's almost always one of these two: not enough memory (turn the dials down) or not enough throughput (turn them up, if memory allows). That single tension is the entire craft of serving.
Read it left-to-right: the flags on the left all feed the single server in the middle, which lands you somewhere on the memory-vs-throughput trade-off on the right.
- The blue column on the left is the four flags from the launch command — the only things you actually type.
- The middle box is the one vLLM server running on one GPU: every flag is really an instruction to it.
- The red and green boxes on the right are the two outcomes in tension — a bigger batch or memory budget buys more throughput (green) but costs more VRAM (red). The bottom caption is the rule of thumb: dials down when you OOM, up when you have room.
In short: The flags aren't magic — they're four dials on a single memory-versus-throughput slider.
serve.shpip install vllm
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--quantization awq \ # 4-bit (IC2)
--max-num-seqs 128 \ # continuous-batch width (IC4)
--gpu-memory-utilization 0.90 \ # weights + KV-cache budget (IC3)
--host 0.0.0.0 --port 8000
This one command turns an open model into a production web service. It's a python -m ... launcher with several flags — the \ at the end of each line just means "the command continues on the next line". Read the flags top to bottom; each is one knob.
pip install vllminstalls the serving engine (needs a GPU).--model meta-llama/Llama-3.1-8B-Instructnames which open model to serve — this is its Hugging Face path, which vLLM downloads.--quantization awqloads a 4-bit compressed version so the model fits in less GPU memory (the idea from the quantization lesson, IC2).--max-num-seqs 128sets how many requests it will batch together at once — higher means more throughput for many users.--gpu-memory-utilization 0.90lets vLLM use 90% of the GPU's memory, split between the model weights and the working cache.--host 0.0.0.0 --port 8000exposes the API on port 8000 so other machines can reach it.
What the output means: A long-running server: it prints startup logs, loads the model, then waits for requests at http://your-server:8000/v1.
Try this: If you hit an out-of-memory error, lower --gpu-memory-utilization (say to 0.80) or --max-num-seqs — those two flags are your main memory dials.
client.pyfrom openai import OpenAI
client = OpenAI(base_url="http://your-server:8000/v1", api_key="none")
r = client.chat.completions.create(
model="meta-llama/Llama-3.1-8B-Instruct",
messages=[{"role": "user", "content": "Hello from my own server."}])
print(r.choices[0].message.content)
This is the same lesson as Ollama, one level up: connecting to your production vLLM server is also just a base_url change. The code you already wrote keeps working against a model you now host yourself.
OpenAI(base_url="http://your-server:8000/v1", api_key="none")points the standard client at your vLLM server (port 8000) instead of a cloud provider. No real key is needed, so"none"is fine.client.chat.completions.create(model="meta-llama/Llama-3.1-8B-Instruct", messages=[...])sends a chat request. Themodelname must match what the server was started with in the previous lab.r.choices[0].message.contentpulls the reply text out and prints it — identical to how you'd read any OpenAI-style response.
What the output means: The model's greeting prints — served by your own GPU box, not a paid API.
Try this: Replace your-server with the real host/IP of your vLLM machine. If it's on the same computer, use localhost.
Sizing the GPU advanced
The question that will actually decide your bill. "Will this model fit on this GPU, and how many people can it serve at once?" — you cannot answer it by eyeballing the model's parameter count, and getting it wrong is expensive in both directions. Guess too small and vLLM crashes with an out-of-memory error the moment real traffic arrives. Guess too big and you rent a monster card that runs at 15% and quietly bleeds money. Sizing is the difference between a service that survives launch day and one that either falls over or wastes your budget.
Here's the mental model that makes it click. GPU memory (VRAM) is a fixed drawer, and three things compete for the space: the weights (the model itself, loaded once), the KV-cache (scratch memory that grows with every concurrent request and every token of context), and a bit of overhead (the framework, activations, CUDA itself). Most beginners fixate on the weights because that's the number on the model card — but the weights are the easy part; they're a one-time, fixed cost. It's the KV-cache that's the surprise, because it scales with concurrency. Picture a parking lot: the building (weights) takes a fixed footprint, but every car you let in (each concurrent request) needs its own space, and the context length is how big each car is. Run out of parking and the next car — the next user — simply can't get in.
The common trap is sizing only for the weights and then being baffled when the server OOMs under load even though "the model clearly fits." It fit when idle; it didn't fit once thirty users each demanded their own slice of KV-cache. So the real formula is: weights are your entry ticket, and whatever VRAM is left over — divided by the per-request cache cost — is your concurrency ceiling. That's precisely why --max-num-seqs and --gpu-memory-utilization are the two dials you reach for: one caps how many cars you admit, the other decides how much of the drawer vLLM is allowed to claim. Work the numbers below rather than trusting a vibe — the Practice ladder turns this exact reasoning into a calculator you can reuse on any card.
Memory must hold weights + KV-cache + overhead. Use the IC1–IC3 math: a 4-bit 8B model is ~5–6 GB of weights, leaving the rest of the card for KV-cache (which sets how many concurrent requests you can serve). gpu-memory-utilization tunes that split.
Read the whole rectangle as the GPU's fixed memory drawer, filled left-to-right until it's full.
- The purple block (weights) and the amber block (overhead) on the left are fixed costs — they take the same space no matter how many users show up.
- The green slices are the KV-cache: each concurrent request needs its own slice, and a longer context makes each slice bigger.
- The dashed red slice is the request that doesn't fit — the drawer is full, so the next user is turned away (an out-of-memory error under load). That's why KV-cache, not the weights, sets your concurrency ceiling.
In short: Weights + overhead are your fixed entry ticket; whatever VRAM is left, divided by the per-request KV slice, is how many users you can serve at once.
Exercise LM3.1 — Serve and load-test
Context: The point of vLLM is throughput under load — and a load test against Ollama on the same model makes the gap unmistakable.
Your task: Serve an 8B model with vLLM (quantized, batched), point a course lab at it, then run a load test at rising concurrency and record throughput and p95 latency; compare to Ollama.
Requirements:
- Serve the model with vLLM (quantized + batched)
- Drive rising concurrency with a load test
- Record throughput and p95 latency at each level
- Run the same test against Ollama on the same model
- Confirm vLLM wins badly under load
💡 Hint: The divergence shows up as concurrency climbs — single requests look similar, but batching pulls ahead under real load.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Ollama runs; vLLM serves. The production engine adds continuous batching, PagedAttention, and quantization — exactly what a single-user tool doesn't optimize for.
Your task: Write a helper that names what vLLM adds for production versus Ollama's single-user focus.
Requirements:
- Name continuous batching, PagedAttention, and quantization
- Give the production benefit of each
- Note the OpenAI-compatible API so existing clients work unchanged
- Print the feature→benefit list
💡 Hint: These are the IC6 throughput concepts; the framing is why each matters under concurrent load, not the implementation.
Show solution
vLLM is the production engine (IC6 concepts). Runnable:
ADDS = {
"continuous batching": "serve many requests together, high GPU utilization",
"PagedAttention": "manage KV-cache memory in pages, fit more concurrency",
"quantization": "run bigger models in less VRAM",
"OpenAI-compatible API":"existing client code works unchanged",
}
def why_vllm():
for feature, benefit in ADDS.items():
print(f"- {feature}: {benefit}")
why_vllm()
These throughput features are exactly what a single-user tool like Ollama does not optimize for.
Context: Standing up vLLM behind an OpenAI-compatible API comes down to a launch command with a few key flags — model, quantization, batch width, context length.
Your task: Write the vLLM launch command with those key flags, labeled as needing a GPU.
Requirements:
- Show
python -m vllm.entrypoints.openai.api_server(or equivalent) - Include the model, a quantization flag, a batch-width flag (
--max-num-seqs), and a context-length flag - Include a GPU-memory-utilization flag
- Comment what each flag controls
- Label it as needing a GPU +
pip install vllm
💡 Hint: The flags map to concepts: quantization shrinks weights, --max-num-seqs is the continuous-batch width, --max-model-len caps context — it exposes a /v1/chat/completions endpoint.
Show solution
This is serving config (needs a GPU + pip install vllm):
pip install vllm
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--quantization awq \ # 4-bit weights (IC2) -> less VRAM
--max-num-seqs 128 \ # continuous-batch width (concurrency)
--max-model-len 8192 \ # context length ceiling
--gpu-memory-utilization 0.90 # fraction of VRAM vLLM may use
It opens a /v1/chat/completions endpoint that looks exactly like OpenAI's — the whole point of the engine.
Context: VRAM must hold weights plus KV cache for every concurrent sequence — and it's usually the KV cache, not the weights, that forces a concurrency cap.
Your task: Write a calculator: weights (bytes/param by quant) + per-sequence KV cache × max_num_seqs, and say whether it fits a given GPU.
Requirements:
- Weight bytes/param vary by quant (fp16 / awq / int8)
- Per-sequence KV cache scales with layers, hidden size, and context length (K and V)
- Total = weights + per-seq KV × concurrency
- Return the total and whether it fits the GPU
- Show a realistic 8B AWQ config on a mid-size card
💡 Hint: KV per sequence is ~2 (K and V) × layers × hidden × ctx × bytes; concurrency multiplies it, which is why it dominates.
Show solution
Total VRAM = weights + KV cache * concurrency. Runnable:
BYTES = {"fp16": 2, "awq": 0.5, "int8": 1} # ~bytes per weight param
def weights_gb(params_b, quant):
return params_b * 1e9 * BYTES[quant] / (1024**3)
def kv_gb_per_seq(layers, hidden, ctx_len, bytes_per=2):
# 2 (K and V) * layers * hidden * ctx * bytes, in GB
return 2 * layers * hidden * ctx_len * bytes_per / (1024**3)
def fits(gpu_gb, params_b, quant, layers, hidden, ctx, seqs):
total = weights_gb(params_b, quant) + kv_gb_per_seq(layers, hidden, ctx) * seqs
return total, total <= gpu_gb
# 8B AWQ, 32 layers, hidden 4096, ctx 8192, 32 concurrent seqs, on a 24GB card
total, ok = fits(24, 8, "awq", 32, 4096, 8192, 32)
print(f"needs ~{total:.1f} GB -> {'FITS' if ok else 'TOO BIG'} on 24GB")
KV cache scales with concurrency and context — it, not the weights, is what usually forces you to cap --max-num-seqs.
Context: You should set --max-num-seqs from the VRAM math, not by guessing — too high and vLLM OOMs under load, too low and the GPU sits idle.
Your task: Write a solver that returns the largest batch width (max_num_seqs) that fits a fixed GPU and model.
Requirements:
- Compute the VRAM budget left after weights (with a reserve)
- Divide the remaining budget by the per-sequence KV cost
- Return the integer max concurrency (0 if the weights alone don't fit)
- Show the recommended
--max-num-seqsfor a card
💡 Hint: It's the fit calculation solved for concurrency: (usable VRAM − weights) / KV-per-seq, floored.
Show solution
Solve for the concurrency ceiling. Runnable:
BYTES = {"fp16": 2, "awq": 0.5}
def weights_gb(pb, q): return pb * 1e9 * BYTES[q] / (1024**3)
def kv_per_seq(layers, hidden, ctx): return 2*layers*hidden*ctx*2 / (1024**3)
def max_seqs(gpu_gb, params_b, quant, layers, hidden, ctx, reserve=0.10):
budget = gpu_gb * (1 - reserve) - weights_gb(params_b, quant)
if budget <= 0:
return 0 # weights alone don't fit
return int(budget // kv_per_seq(layers, hidden, ctx))
n = max_seqs(24, 8, "awq", 32, 4096, 8192)
print(f"set --max-num-seqs {n} to stay within 24GB")
Set --max-num-seqs from the math, not by guessing — too high and vLLM OOMs under load; too low and you waste the GPU.
Context: Because vLLM speaks the OpenAI API, connecting an app is a base_url swap — one line.
Your task: Show the one-line change (needs the server) and validate the request payload offline.
Requirements:
- Build/validate the chat payload with the standard library, no server
- Show the real call pointing an OpenAI client at the vLLM host's
/v1endpoint - Only the
base_urlchanges in app code - Label the real call as needing the vLLM server running
💡 Hint: Same OpenAI client, new base_url; the self-host swap is one line because vLLM mimics the API.
Show solution
Validate the payload offline (runnable):
def chat_payload(model, prompt):
return {"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 256}
print(chat_payload("meta-llama/Llama-3.1-8B-Instruct", "hi"))
The only app change (needs the vLLM server running):
from openai import OpenAI
client = OpenAI(base_url="http://your-vllm-host:8000/v1", api_key="not-needed")
r = client.chat.completions.create(**chat_payload("meta-llama/Llama-3.1-8B-Instruct", "hi"))
print(r.choices[0].message.content)
Same OpenAI client, new base_url — the self-host swap is one line because vLLM mimics the API.
Context: Capacity planning turns vLLM's per-GPU throughput into a replica count and a monthly bill — the input to the LM5 build-vs-buy decision.
Your task: Given a target requests/second, per-GPU throughput, and headroom, compute how many GPU replicas you need and the monthly GPU cost.
Requirements:
- Never plan a GPU at 100% — apply a headroom factor to per-GPU QPS
- Replicas = ceil(target QPS / effective per-GPU QPS)
- Monthly cost = replicas × hourly rate × hours/month
- Show a worked example with the replica count and dollar figure
💡 Hint: math.ceil the ratio after de-rating per-GPU QPS by headroom, then multiply replicas by the hourly rate over a month.
Show solution
Capacity = ceil(target QPS / per-GPU QPS), then cost it. Runnable:
import math
def plan(target_qps, per_gpu_qps, gpu_hourly_usd, headroom=0.30):
effective = per_gpu_qps * (1 - headroom) # never run a GPU flat out
replicas = math.ceil(target_qps / effective)
monthly = replicas * gpu_hourly_usd * 24 * 30
return replicas, round(monthly, 2)
replicas, cost = plan(target_qps=40, per_gpu_qps=12, gpu_hourly_usd=1.20)
print(f"{replicas} GPU replicas, ~${cost}/mo")
# 40 / (12*0.7)=~4.76 -> 5 replicas; 5*1.20*720 = $4320/mo
vLLM's batching sets per-GPU QPS; capacity planning turns that into replica count and a bill — the input to the LM5 build-vs-buy call.
✓ Checkpoint — you can move on when you can…
- Explain why prod self-hosting uses vLLM over Ollama.
- Stand up vLLM with an OpenAI-compatible API.
- Size the GPU with the IC1–IC3 memory math.
- Connect an app with a one-line change.
Knowledge check check yourself
Why does production self-hosting call for vLLM rather than Ollama, and which IC-track techniques does vLLM apply?
Show answer
What two vLLM flags are the main memory dials when you hit an out-of-memory error, and what does each control?
Show answer
--gpu-memory-utilization (e.g. lower to 0.80) sets the fraction of GPU memory vLLM may use for weights + KV-cache, and --max-num-seqs sets the continuous-batch width (how many requests are batched at once). Lowering either reduces memory pressure.