Course: Course 3 — LLM Fine-Tuning Masterclass Module: FT20 — Serving Stacks Duration: 45–60 minutes Environment: A consumer NVIDIA GPU (RTX 3090/4090 / 16–24GB) OR Apple Silicon (M-series, 16GB+) OR a Colab T4/A100. You must arrive with a quantized model from FT19 (GGUF for Ollama; the same base in FP16/AWQ for vLLM). Python 3.11+. ~10GB free disk.
By the end of this lab you will have:
This lab is the one that makes the Ollama-vs-vLLM decision felt, not theoretical. You will watch the ceiling happen on your own machine.
You need one model in two formats. The simplest path: use a well-known small base that has both a GGUF (for Ollama) and a standard HF checkpoint (for vLLM). This lab uses Qwen/Qwen2.5-1.5B-Instruct — small enough to run on anything, including a free Colab T4. Substitute your FT19 artifact if you have one.
# Clean venv
python3.11 -m venv ft20-env && source ft20-env/bin/activate
# Serving + load-testing stack
pip install -q vllm "openai>=1.0" httpx
# (Ollama is installed as a system binary, not a pip package — see Phase 1)
# For the load test later
pip install -q "numpy" "rich"
Verify vLLM sees your accelerator:
import torch
print(f"PyTorch: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}") # expect True on NVIDIA
print(f"MPS available: {torch.backends.mps.is_available()}") # Apple Silicon
vLLM on Apple Silicon note. vLLM's CUDA-targeted path is the supported one. On a Mac, the vLLM phase of this lab runs in CPU mode (slow but correct) or you substitute
mlx_lm.serverfor the production path — the load-test script is identical because both speak the OpenAI API. If you are Mac-only, do Ollama for Phase 1, then swap vLLM forpython -m mlx_lm.server --model mlx-community/Qwen2.5-1.5B-Instruct-4bitin Phase 3. The lab's lesson is the same.
Install Ollama per the official instructions (curl -fsSL https://ollama.com/install.sh | sh on Linux/macOS). Then pull a small model and confirm the loopback bind:
# Pull a model (uses the network for THIS step only)
ollama pull qwen2.5:1.5b
# CRITICAL: bind loopback. Never 0.0.0.0 without auth.
# The default is 127.0.0.1; verify:
echo $OLLAMA_HOST # should be empty (default 127.0.0.1:11434) or explicitly 127.0.0.1:11434
# Confirm it is listening loopback only:
curl -s http://127.0.0.1:11434/api/tags | head -c 200
Record: the OLLAMA_HOST value and the confirmation that the API responds on loopback. This is the telemetry-posture checkpoint for Ollama — the model is now local, the network can be severed, the bind is correct.
Smoke-test a single generation via the OpenAI-compatible endpoint:
# ft20_smoke_ollama.py
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama") # api_key is required by the client but ignored by Ollama
def gen(prompt, max_tokens=100):
r = client.chat.completions.create(
model="qwen2.5:1.5b",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.0, # deterministic for eval equivalence
)
return r.choices[0].message.content
print(gen("In one sentence, what is PagedAttention?"))
Record: the response. Save it — you will compare it to vLLM's response to the same prompt in Phase 3.
A tiny, deterministic eval suite (5 prompts). Both servers will answer the identical suite so you can compare outputs.
# ft20_eval_suite.py
EVAL_PROMPTS = [
"In one sentence, what is PagedAttention?",
"List the three variables in the serving-stack decision matrix.",
"What does Ollama's official privacy policy say about local prompts?",
"Name two reasons llama.cpp server is the air-gap champion.",
"At roughly how many concurrent users does Ollama collapse, and why?",
]
def run_suite(gen_fn, label):
"""gen_fn: str -> str. Returns dict of prompt -> response."""
print(f"\n=== {label} ===")
results = {}
for i, p in enumerate(EVAL_PROMPTS):
resp = gen_fn(p)
results[p] = resp
print(f"[{i+1}] {p}\n -> {resp[:160]}\n")
return results
Run the suite against Ollama now (using the gen function from Phase 1) and save the dict. These are your baseline outputs.
Stop Ollama for a moment to free VRAM (ollama stop qwen2.5:1.5b, or just leave it idle — it unloads on its own). Start vLLM:
# vLLM serves an OpenAI-compatible API on 127.0.0.1:8000 by default
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-1.5B-Instruct \
--host 127.0.0.1 \
--port 8000 \
--max-model-len 2048
Wait for the line Uvicorn running on http://127.0.0.1:8000. Then point the same OpenAI client at it:
# ft20_smoke_vllm.py
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="vllm") # api_key ignored by vLLM by default
def gen(prompt, max_tokens=100):
r = client.chat.completions.create(
model="Qwen/Qwen2.5-1.5B-Instruct",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.0,
)
return r.choices[0].message.content
print(gen("In one sentence, what is PagedAttention?"))
Now re-run the eval suite from Phase 2 against vLLM, and diff the two output dicts:
# ft20_compare.py
ollama_results = {...} # paste / load from Phase 2
vllm_results = run_suite(gen, "vLLM") # uses Phase 3's gen
print("\n=== OUTPUT EQUIVALENCE CHECK ===")
for p in EVAL_PROMPTS:
same = ollama_results[p].strip() == vllm_results[p].strip()
# Note: with temperature=0, the two servers SHOULD produce near-identical
# outputs for the same base model, but small differences (tokenization,
# sampling impl) can cause minor divergence. Accept "semantically same".
print(f"[{'OK' if same else '~'}] {p[:50]}")
Record: whether the outputs are equivalent. Expect near-identical (minor divergence is normal and fine — the point is that the two servers serve the same model, so the deployment choice is about latency/concurrency/telemetry, not output quality).
This is the heart of the lab. You will fire the same workload at each server with increasing concurrency and record p50/p95 latency. Restart Ollama's model load (ollama run qwen2.5:1.5b "" to warm it), then run:
# ft20_load_test.py
import time, statistics, concurrent.futures
from openai import OpenAI
def make_client(base_url):
return OpenAI(base_url=base_url, api_key="x", timeout=120.0)
def one_request(client, model, prompt="In two sentences, explain continuous batching."):
t0 = time.perf_counter()
client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=80,
temperature=0.0,
)
return time.perf_counter() - t0
def load_test(base_url, model, concurrency, n=20):
"""Fire n requests at the given concurrency; return p50, p95 latencies in seconds."""
client = make_client(base_url)
latencies = []
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
futs = [pool.submit(one_request, client, model) for _ in range(n)]
for f in concurrent.futures.as_completed(futs):
try:
latencies.append(f.result())
except Exception as e:
latencies.append(float("inf")) # timeout / error counts as very slow
latencies.sort()
p50 = statistics.median(latencies)
p95 = latencies[int(0.95 * len(latencies)) - 1] if len(latencies) > 1 else latencies[0]
return p50, p95, sum(1 for l in latencies if l == float("inf")) # errors
# Run both servers, one at a time, at each concurrency level.
CONFIGS = [
("Ollama", "http://127.0.0.1:11434/v1", "qwen2.5:1.5b"),
("vLLM", "http://127.0.0.1:8000/v1", "Qwen/Qwen2.5-1.5B-Instruct"),
]
print(f"{'Server':<8} {'Conc':>4} {'p50(s)':>8} {'p95(s)':>8} {'errors':>7}")
print("-" * 40)
for label, base_url, model in CONFIGS:
for conc in [1, 3, 5, 10]:
# IMPORTANT: only one server should be under load at a time.
# Stop the other (ollama stop / Ctrl-C the vllm server) before each row,
# or run on a machine with enough VRAM to hold both cold.
p50, p95, errs = load_test(base_url, model, conc, n=20)
print(f"{label:<8} {conc:>4} {p50:>8.2f} {p95:>8.2f} {errs:>7}")
Running both at once. A 1.5B model is small; on a 16GB+ GPU you can usually hold both servers resident at once. If VRAM is tight, stop one before testing the other (the load-test script's
CONFIGSloop is meant to be run one server at a time — comment out the row you are not currently running). The latency comparison is only fair if the servers are not contending for the same GPU.
Record: the full table. You are looking for the inflection. Expect something like:
Server Conc p50(s) p95(s) errors
----------------------------------------
Ollama 1 0.8 1.1 0
Ollama 3 2.4 3.8 0
Ollama 5 8.5 18.2 0 <-- THE CEILING
Ollama 10 35.0 60.0+ 2
vLLM 1 0.4 0.6 0
vLLM 3 0.5 0.8 0
vLLM 5 0.7 1.2 0
vLLM 10 1.1 2.0 0
Your exact numbers will vary with hardware, but the shape is what matters: Ollama's p95 blows up somewhere around 5 concurrent users; vLLM's stays bounded. That shape is the deployment decision.
No code. Write 4–6 sentences answering:
Submit ft20-lab-report.md:
OLLAMA_HOST value; confirmation of loopback bind; the Ollama smoke-test response.OLLAMA_HOST is empty or 127.0.0.1:11434. The curl to 127.0.0.1:11434/api/tags returns JSON listing the pulled model. A correct smoke-test produces a one-sentence explanation of PagedAttention (quality depends on the 1.5B model; expect a plausible-but-shallow answer — the point is the request worked, not the answer's depth).Uvicorn running on http://127.0.0.1:8000. The equivalence check shows OK or ~ for all 5 prompts. Minor divergence (a different word, a slightly different sentence boundary) is expected and acceptable with temperature=0 — the two servers run the same model weights, so semantic equivalence is the bar, not byte-identity.ThreadPoolExecutor is sized to concurrency, (b) running the servers against each other so both are starved, or (c) on hardware so fast the ceiling is past 10 — bump the concurrency array to [1, 5, 10, 20, 40].OLLAMA_HOST=127.0.0.1. The honest note that Ollama air-gap means accepting its CVE history unless patched on a schedule via the same media is a strong answer.pip install sglang), serve the same model with python -m sglang.launch_server --model-path Qwen/Qwen2.5-1.5B-Instruct --port 8000, and re-run the load test. SGLang's RadixAttention gives it an edge on workloads with shared prefixes (long shared system prompts) — compare its p95 to vLLM's at 10 concurrent users. If your eval prompts share a long system prompt, you may see SGLang win; if they are all unique, vLLM and SGLang are close.mlx-community/Qwen2.5-1.5B-Instruct-4bit via python -m mlx_lm.server --port 8000, run the eval suite against it, and add an MLX row to your load-test table. Expect single-user latency competitive with vLLM and a ceiling somewhere between Ollama and vLLM. This is the Mac-fleet path from 20.3.docker run -p 4317:4317 otel/opentelemetry-collector), set OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317, restart vLLM, run the load test, and confirm traces arrive at your collector — not at any vendor. This is the telemetry-posture proof that vLLM is air-gap-acceptable.qwen2.5:1.5b, copy ~/.ollama/models to a second machine, set OLLAMA_HOST=127.0.0.1:11434, disconnect the network entirely, and confirm Ollama serves. Then do the same with a local GGUF and llama-server. This is the bridge to FT21/FT22.# Lab Specification — Module FT20: Serving Stacks
**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: FT20 — Serving Stacks
**Duration**: 45–60 minutes
**Environment**: A consumer NVIDIA GPU (RTX 3090/4090 / 16–24GB) OR Apple Silicon (M-series, 16GB+) OR a Colab T4/A100. You must arrive with a quantized model from FT19 (GGUF for Ollama; the same base in FP16/AWQ for vLLM). Python 3.11+. ~10GB free disk.
---
## Learning objectives
By the end of this lab you will have:
1. **Served your FT19 quantized model two ways** — Ollama (dev/single-user path) and vLLM (production path) — both exposing the same OpenAI-compatible API.
2. **Hit both servers with the same eval suite** (identical prompts, identical generation settings) and confirmed output equivalence within sampling noise.
3. **Run a concurrent load test** (1, 3, 5, 10 simulated users) against *both* servers and recorded p50/p95 latency at each concurrency level.
4. **Felt the Ollama ceiling** — the moment around 5 concurrent users where Ollama's latency explodes while vLLM's stays bounded — and written the deployment decision in your own words.
5. **Captured the serving telemetry posture** of each runtime (what reaches the network, what does not) so you can defend the choice for a regulated subnet.
This lab is the one that makes the Ollama-vs-vLLM decision *felt*, not theoretical. You will watch the ceiling happen on your own machine.
---
## Phase 0 — Environment and model prep (5 min)
You need *one model* in two formats. The simplest path: use a well-known small base that has both a GGUF (for Ollama) and a standard HF checkpoint (for vLLM). This lab uses `Qwen/Qwen2.5-1.5B-Instruct` — small enough to run on anything, including a free Colab T4. Substitute your FT19 artifact if you have one.
```bash
# Clean venv
python3.11 -m venv ft20-env && source ft20-env/bin/activate
# Serving + load-testing stack
pip install -q vllm "openai>=1.0" httpx
# (Ollama is installed as a system binary, not a pip package — see Phase 1)
# For the load test later
pip install -q "numpy" "rich"
```
Verify vLLM sees your accelerator:
```python
import torch
print(f"PyTorch: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}") # expect True on NVIDIA
print(f"MPS available: {torch.backends.mps.is_available()}") # Apple Silicon
```
> **vLLM on Apple Silicon note.** vLLM's CUDA-targeted path is the supported one. On a Mac, the vLLM phase of this lab runs in CPU mode (slow but correct) or you substitute `mlx_lm.server` for the production path — the load-test script is identical because both speak the OpenAI API. If you are Mac-only, do Ollama for Phase 1, then swap vLLM for `python -m mlx_lm.server --model mlx-community/Qwen2.5-1.5B-Instruct-4bit` in Phase 3. The lab's lesson is the same.
---
## Phase 1 — Serve with Ollama (the dev path) (10 min)
Install Ollama per the official instructions (`curl -fsSL https://ollama.com/install.sh | sh` on Linux/macOS). Then pull a small model and confirm the loopback bind:
```bash
# Pull a model (uses the network for THIS step only)
ollama pull qwen2.5:1.5b
# CRITICAL: bind loopback. Never 0.0.0.0 without auth.
# The default is 127.0.0.1; verify:
echo $OLLAMA_HOST # should be empty (default 127.0.0.1:11434) or explicitly 127.0.0.1:11434
# Confirm it is listening loopback only:
curl -s http://127.0.0.1:11434/api/tags | head -c 200
```
**Record**: the `OLLAMA_HOST` value and the confirmation that the API responds on loopback. This is the telemetry-posture checkpoint for Ollama — the model is now local, the network can be severed, the bind is correct.
Smoke-test a single generation via the OpenAI-compatible endpoint:
```python
# ft20_smoke_ollama.py
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="ollama") # api_key is required by the client but ignored by Ollama
def gen(prompt, max_tokens=100):
r = client.chat.completions.create(
model="qwen2.5:1.5b",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.0, # deterministic for eval equivalence
)
return r.choices[0].message.content
print(gen("In one sentence, what is PagedAttention?"))
```
**Record**: the response. Save it — you will compare it to vLLM's response to the same prompt in Phase 3.
---
## Phase 2 — The eval suite (5 min)
A tiny, deterministic eval suite (5 prompts). Both servers will answer the identical suite so you can compare outputs.
```python
# ft20_eval_suite.py
EVAL_PROMPTS = [
"In one sentence, what is PagedAttention?",
"List the three variables in the serving-stack decision matrix.",
"What does Ollama's official privacy policy say about local prompts?",
"Name two reasons llama.cpp server is the air-gap champion.",
"At roughly how many concurrent users does Ollama collapse, and why?",
]
def run_suite(gen_fn, label):
"""gen_fn: str -> str. Returns dict of prompt -> response."""
print(f"\n=== {label} ===")
results = {}
for i, p in enumerate(EVAL_PROMPTS):
resp = gen_fn(p)
results[p] = resp
print(f"[{i+1}] {p}\n -> {resp[:160]}\n")
return results
```
Run the suite against Ollama now (using the `gen` function from Phase 1) and save the dict. These are your **baseline outputs**.
---
## Phase 3 — Serve with vLLM (the production path) and re-run the suite (10 min)
Stop Ollama for a moment to free VRAM (`ollama stop qwen2.5:1.5b`, or just leave it idle — it unloads on its own). Start vLLM:
```bash
# vLLM serves an OpenAI-compatible API on 127.0.0.1:8000 by default
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-1.5B-Instruct \
--host 127.0.0.1 \
--port 8000 \
--max-model-len 2048
```
Wait for the line `Uvicorn running on http://127.0.0.1:8000`. Then point the same OpenAI client at it:
```python
# ft20_smoke_vllm.py
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="vllm") # api_key ignored by vLLM by default
def gen(prompt, max_tokens=100):
r = client.chat.completions.create(
model="Qwen/Qwen2.5-1.5B-Instruct",
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.0,
)
return r.choices[0].message.content
print(gen("In one sentence, what is PagedAttention?"))
```
Now re-run the eval suite from Phase 2 against vLLM, and **diff** the two output dicts:
```python
# ft20_compare.py
ollama_results = {...} # paste / load from Phase 2
vllm_results = run_suite(gen, "vLLM") # uses Phase 3's gen
print("\n=== OUTPUT EQUIVALENCE CHECK ===")
for p in EVAL_PROMPTS:
same = ollama_results[p].strip() == vllm_results[p].strip()
# Note: with temperature=0, the two servers SHOULD produce near-identical
# outputs for the same base model, but small differences (tokenization,
# sampling impl) can cause minor divergence. Accept "semantically same".
print(f"[{'OK' if same else '~'}] {p[:50]}")
```
**Record**: whether the outputs are equivalent. Expect near-identical (minor divergence is normal and fine — the point is that the two servers serve *the same model*, so the deployment choice is about latency/concurrency/telemetry, not output quality).
---
## Phase 4 — The load test: 1, 3, 5, 10 concurrent users (15 min)
This is the heart of the lab. You will fire the same workload at each server with increasing concurrency and record p50/p95 latency. Restart Ollama's model load (`ollama run qwen2.5:1.5b ""` to warm it), then run:
```python
# ft20_load_test.py
import time, statistics, concurrent.futures
from openai import OpenAI
def make_client(base_url):
return OpenAI(base_url=base_url, api_key="x", timeout=120.0)
def one_request(client, model, prompt="In two sentences, explain continuous batching."):
t0 = time.perf_counter()
client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=80,
temperature=0.0,
)
return time.perf_counter() - t0
def load_test(base_url, model, concurrency, n=20):
"""Fire n requests at the given concurrency; return p50, p95 latencies in seconds."""
client = make_client(base_url)
latencies = []
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
futs = [pool.submit(one_request, client, model) for _ in range(n)]
for f in concurrent.futures.as_completed(futs):
try:
latencies.append(f.result())
except Exception as e:
latencies.append(float("inf")) # timeout / error counts as very slow
latencies.sort()
p50 = statistics.median(latencies)
p95 = latencies[int(0.95 * len(latencies)) - 1] if len(latencies) > 1 else latencies[0]
return p50, p95, sum(1 for l in latencies if l == float("inf")) # errors
# Run both servers, one at a time, at each concurrency level.
CONFIGS = [
("Ollama", "http://127.0.0.1:11434/v1", "qwen2.5:1.5b"),
("vLLM", "http://127.0.0.1:8000/v1", "Qwen/Qwen2.5-1.5B-Instruct"),
]
print(f"{'Server':<8} {'Conc':>4} {'p50(s)':>8} {'p95(s)':>8} {'errors':>7}")
print("-" * 40)
for label, base_url, model in CONFIGS:
for conc in [1, 3, 5, 10]:
# IMPORTANT: only one server should be under load at a time.
# Stop the other (ollama stop / Ctrl-C the vllm server) before each row,
# or run on a machine with enough VRAM to hold both cold.
p50, p95, errs = load_test(base_url, model, conc, n=20)
print(f"{label:<8} {conc:>4} {p50:>8.2f} {p95:>8.2f} {errs:>7}")
```
> **Running both at once.** A 1.5B model is small; on a 16GB+ GPU you can usually hold both servers resident at once. If VRAM is tight, stop one before testing the other (the load-test script's `CONFIGS` loop is meant to be run one server at a time — comment out the row you are not currently running). The latency comparison is only fair if the servers are not contending for the same GPU.
**Record**: the full table. You are looking for the inflection. Expect something like:
```
Server Conc p50(s) p95(s) errors
----------------------------------------
Ollama 1 0.8 1.1 0
Ollama 3 2.4 3.8 0
Ollama 5 8.5 18.2 0 <-- THE CEILING
Ollama 10 35.0 60.0+ 2
vLLM 1 0.4 0.6 0
vLLM 3 0.5 0.8 0
vLLM 5 0.7 1.2 0
vLLM 10 1.1 2.0 0
```
Your exact numbers will vary with hardware, but the *shape* is what matters: Ollama's p95 blows up somewhere around 5 concurrent users; vLLM's stays bounded. **That shape is the deployment decision.**
---
## Phase 5 — The decision, in your own words (5 min)
No code. Write 4–6 sentences answering:
1. At what concurrency did Ollama's p95 latency exceed 2x its 1-user latency? At what concurrency did it exceed 10x? Where is *your* ceiling on *your* hardware?
2. At the same concurrency levels, how did vLLM's latency behave? Was there an inflection, and if so, where?
3. For a single-user dev workflow, which server would you use, and why? (Hint: DX matters; latency at N=1 matters less than the install friction.)
4. For a 20-person internal tool with bursty concurrent use, which server would you use, and why? What would you put in front of it?
5. If this deploy had to run on a hospital subnet with no internet egress, what would change about your setup, for *each* runtime?
---
## Deliverables
Submit `ft20-lab-report.md`:
- [ ] Phase 1: the `OLLAMA_HOST` value; confirmation of loopback bind; the Ollama smoke-test response.
- [ ] Phase 2: the eval-suite output dict from Ollama (the baseline).
- [ ] Phase 3: the eval-suite output dict from vLLM; the equivalence check (OK / ~ per prompt).
- [ ] Phase 4: the full load-test table (both servers, all four concurrency levels).
- [ ] Phase 5: your 4–6 sentence deployment decision, including the per-runtime air-gap changes.
---
## Solution key
- **Phase 1**: `OLLAMA_HOST` is empty or `127.0.0.1:11434`. The curl to `127.0.0.1:11434/api/tags` returns JSON listing the pulled model. A correct smoke-test produces a one-sentence explanation of PagedAttention (quality depends on the 1.5B model; expect a plausible-but-shallow answer — the point is the *request worked*, not the answer's depth).
- **Phase 2**: the suite returns 5 responses. Save them verbatim for the Phase 3 diff.
- **Phase 3**: vLLM starts and prints `Uvicorn running on http://127.0.0.1:8000`. The equivalence check shows `OK` or `~` for all 5 prompts. Minor divergence (a different word, a slightly different sentence boundary) is expected and acceptable with temperature=0 — the two servers run the same model weights, so semantic equivalence is the bar, not byte-identity.
- **Phase 4**: the table shows Ollama's p95 climbing steeply between 3 and 5 concurrent users, often exceeding 10x its 1-user p95 by 5–10 users. vLLM's p95 stays within ~3x of its 1-user value across all four levels. On a 1.5B model on a 4090, expect Ollama's 5-user p95 in the 5–20s range and vLLM's 5-user p95 under 2s. If a student sees Ollama holding flat, they are either (a) not actually running requests concurrently — check that `ThreadPoolExecutor` is sized to `concurrency`, (b) running the servers against each other so both are starved, or (c) on hardware so fast the ceiling is past 10 — bump the concurrency array to `[1, 5, 10, 20, 40]`.
- **Phase 5**: a correct answer names (a) Ollama for dev/single-user (best DX, fine at N=1); (b) vLLM (or TGI) for the 20-person internal tool, with a reverse proxy (nginx/envoy) in front enforcing rate limits and a queue; (c) for the air-gap: pre-load the GGUF on a connected machine, transfer via approved media, SHA256-verify, serve with llama.cpp server (preferred for air-gap) or a pre-loaded vLLM/Ollama with egress firewalled and `OLLAMA_HOST=127.0.0.1`. The honest note that Ollama air-gap means accepting its CVE history unless patched on a schedule via the same media is a strong answer.
---
## Stretch goals
1. **Substitute SGLang for vLLM.** Install SGLang (`pip install sglang`), serve the same model with `python -m sglang.launch_server --model-path Qwen/Qwen2.5-1.5B-Instruct --port 8000`, and re-run the load test. SGLang's RadixAttention gives it an edge on workloads with shared prefixes (long shared system prompts) — compare its p95 to vLLM's at 10 concurrent users. If your eval prompts share a long system prompt, you may see SGLang win; if they are all unique, vLLM and SGLang are close.
2. **Add the MLX path (Apple Silicon).** On a Mac, serve `mlx-community/Qwen2.5-1.5B-Instruct-4bit` via `python -m mlx_lm.server --port 8000`, run the eval suite against it, and add an MLX row to your load-test table. Expect single-user latency competitive with vLLM and a ceiling somewhere between Ollama and vLLM. This is the Mac-fleet path from 20.3.
3. **Wire up OTel for vLLM.** Run a local OTel collector (`docker run -p 4317:4317 otel/opentelemetry-collector`), set `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317`, restart vLLM, run the load test, and confirm traces arrive at your collector — *not* at any vendor. This is the telemetry-posture proof that vLLM is air-gap-acceptable.
4. **Prove the air-gap recipe.** Pre-load `qwen2.5:1.5b`, copy `~/.ollama/models` to a second machine, set `OLLAMA_HOST=127.0.0.1:11434`, disconnect the network entirely, and confirm Ollama serves. Then do the same with a local GGUF and `llama-server`. This is the bridge to FT21/FT22.