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.

# 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.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:

# 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.


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.

# 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:

# 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).


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:

# 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:


Solution key


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.
# 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.