Serve an LLM with vLLM and Monitor Cost and Quality
Serve an open LLM with vLLM on CPU or GPU, monitor TTFT, queueing and KV cache, sample live answer quality, and estimate compute cost per request.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project serves a small open model, Qwen2.5-0.5B-Instruct, with vLLM's OpenAI-compatible server, CPU first and GPU optional. It then operates the model the way a platform team would:
- Explicit memory sizing: the container gets a hard memory limit.
- Server metrics: Prometheus watches time to first token (TTFT), queueing and KV cache pressure.
- Load testing: concurrency is swept to find where latency breaks.
- Cost: a capacity-based compute cost estimate per 1,000 requests.
- Live quality: a quality signal is sampled from real traces, grouped by prompt version and model.
The support assistant, MLflow tracing and golden-set gate come from "LLM Observability and Prompt Versioning with MLflow". This project reuses a compact version of them and adds what serving needs: redaction before tracing, and a CI gate whose harness the pull request cannot change.
The problem
acme-shop wants to stop paying a hosted API for its support assistant and run an open model itself. Three questions decide whether that works:
- How many requests can one machine handle before users wait?
- What does a request cost at that load?
- How do we notice, in production, that a prompt change made the assistant invent policies?
This project answers each one with a measurement, not a guess.
What you build
assistant.py] R --> V[vLLM
OpenAI API :8000] R -.->|redacted spans| M[(MLflow traces)] V -->|/metrics| P[Prometheus rules] M --> Q[sample_review.py
by prompt version] B[bench.py sweep] --> V classDef app fill:#DBEAFE,color:#1E293B,stroke:#326CE5 classDef serve fill:#10B981,color:#FFFFFF,stroke:#10B981 classDef obs fill:#EDE9FE,color:#1E293B,stroke:#8B5CF6 class U,R,B app class V serve class M,P,Q obs
How a change is judged in CI
and PR (candidate) CI->>CI: published prompts unchanged? CI->>V: base app + base prompt CI->>V: PR app + PR prompt CI-->>PR: base gate decides
Repository layout
acme-support-llm/├── compose.yaml compose.gpu.yaml # vLLM (CPU default, GPU override) + Prometheus├── monitoring/ # prometheus.yml, alerts.yml, check_metrics.sh├── bench/bench.py # concurrency sweep, TTFT, compute cost estimate├── app/assistant.py app/redact.py # reused assistant + redaction before tracing├── prompts/ knowledge/ golden/ # prompt versions (immutable), policies, 16 golden cases├── eval/run_golden.py scoring.py gate.py # the harness CI takes from the base commit├── quality/ # simulate_live.py, sample_review.py, retention.py├── tests/ # test_gate.py, fake_llm.py (offline stand-in)└── .github/workflows/llm-eval.ymlPrerequisites (by module title)
- Serving LLMs with vLLM and GPU Inference (required: this project assumes its concepts).
- LLM Observability and Prompt Versioning with MLflow (the assistant, tracing and golden-set gate are reused from it).
- Monitoring and Logging: Prometheus to Tracing, AI Evaluation and Testing.
- Tools: Docker with Compose, at least 8 GB of memory for Docker's VM (a minimum to try cautiously, not a guarantee), about 3 GB of disk, Python 3.12.
Versions used
| Tool | Version | Checked |
|---|---|---|
| vLLM (CPU / GPU images) | v0.33.0: vllm-openai-cpu:v0.33.0-x86_64 / vllm-openai:v0.33.0 |
GitHub tag and vLLM CPU docs, 5 Nov 2026 |
| MLflow / openai SDK | 3.18.0 / 3.46.0 | PyPI, 5 Nov 2026 |
| Prometheus | v3.15.1 | GitHub tag, 5 Nov 2026 |
| Model | Qwen/Qwen2.5-0.5B-Instruct | Hugging Face (not downloaded here) |
SecurityvLLM itself was NOT run for this guide. The sandbox cannot download model weights from Hugging Face or pull Docker images. Everything else ran against
tests/fake_llm.py, an OpenAI-compatible stand-in that is not a language model, shown in full in Milestone 2. The stand-in ran:
- redaction, verified on stored traces
- MLflow tracing
- the golden set and gate, including the base versus candidate harness
- the immutable-prompt check
- live quality sampling and trace retention
- the concurrency sweep and cost arithmetic
- the 7 tests
Prometheus rules passed
promtool, which checks syntax only. Metric names were read from vLLM v0.33.0's source code, not from a running server. The CI workflow was checked for YAML validity only. Until you run the CPU deployment yourself and record your machine, this is not a verified vLLM serving project. Every stand-in number below says so.
Milestone 1: Serve Qwen2.5-0.5B with vLLM on CPU
This milestone starts vLLM's OpenAI-compatible server on CPU with a hard container memory limit, and explains where that memory goes.
Where the memory goes
| Consumer | Rough size here | Set by |
|---|---|---|
| Model weights (0.5B parameters, bf16) | about 1 GB | the model and --dtype |
| KV cache | 2 GiB | --kv-cache-memory-bytes |
Python, PyTorch, vLLM runtime, /dev/shm |
1 to 3 GB | the image; shm_size |
mem_limit: 6g caps the whole container. shm_size: 2g is not a cap: it sizes the shared-memory filesystem, which is RAM and counts toward the limit. If the container is killed with exit code 137, it hit mem_limit. Lower the KV cache or --max-model-len before raising the limit. These sizes are estimates for planning; watch docker stats on your machine.
The KV cache is where concurrency comes from. Every running request holds cache blocks for its tokens, so a larger cache or a smaller --max-model-len means more requests in flight. --kv-cache-memory-bytes sets it on CPU; vLLM's CPU docs call the older VLLM_CPU_KVCACHE_SPACE variable legacy. On GPU, --gpu-memory-utilization plays that role.
Step 1: Write the Compose files
compose.yaml:
# CPU path (default, free): vLLM's CPU image + Prometheus. First start downloads ~1 GB of weights.# Needs Docker's VM to have more than mem_limit (8 GB is a minimum to try, not a guarantee).services: vllm: image: vllm/vllm-openai-cpu:v0.33.0-x86_64 # arm64 hosts: v0.33.0-arm64 command: - --model=Qwen/Qwen2.5-0.5B-Instruct - --served-model-name=acme-support - --max-model-len=4096 - --kv-cache-memory-bytes=2147483648 # 2 GiB of KV cache; size it to your RAM - --dtype=bfloat16 # if your CPU lacks bf16 support, try float32 shm_size: 2g # /dev/shm for worker communication; it is RAM and counts toward mem_limit mem_limit: 6g # hard cap for the whole container: weights + KV cache + runtime + shm ports: ["8000:8000"] volumes: - ~/.cache/huggingface:/root/.cache/huggingface # keep the weights between runs prometheus: image: prom/prometheus:v3.15.1 command: ["--config.file=/etc/prometheus/prometheus.yml"] volumes: - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro ports: ["9090:9090"]compose.gpu.yaml (optional; costs money on a cloud GPU; the memory limit does not apply to GPU memory):
# OPTIONAL GPU path (costs money on a cloud GPU). Use: docker compose -f compose.yaml -f compose.gpu.yaml upservices: vllm: image: vllm/vllm-openai:v0.33.0 command: - --model=Qwen/Qwen2.5-0.5B-Instruct - --served-model-name=acme-support - --max-model-len=4096 - --gpu-memory-utilization=0.85 # share of GPU memory for weights + KV cache deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu]Step 2: Start and check
docker compose up -d vllmdocker compose logs -f vllm # wait for "Application startup complete"curl -s localhost:8000/v1/modelscurl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' \ -d '{"model":"acme-support","messages":[{"role":"user","content":"Say hello in five words."}],"max_tokens":20}'docker stats --no-stream # memory in use vs the 6 GiB limitExpected output shape (not run for this guide): /v1/models lists acme-support, and the chat call returns choices[0].message.content with a usage block. Write down CPU model, core count, RAM and docker stats memory: every later number depends on them.
Acceptance check
-
docker statsshows the container under its limit after a few requests, and your machine spec is in the README.
Milestone 2: Connect the Assistant, Redacted and Traced
This milestone points the assistant from the MLflow module at vLLM, adds redaction before anything is traced or sent to the model, and verifies the stored traces contain no personal data.
Step 1: Reuse the assistant and add redaction
app/redact.py drops emails, order ids, Indian mobile numbers and card numbers. The policy assistant never needs them, so they are removed, not masked later:
"""Remove personal data from a customer message BEFORE it reaches the model or a trace. The FAQ assistant never needs an email, phone number or order id to answer a policyquestion, so we drop them (data minimisation) instead of masking them later."""import re PATTERNS = [ ("EMAIL", re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")), ("ORDER", re.compile(r"\bACME-\d{6,}\b", re.IGNORECASE)), ("PHONE", re.compile(r"(?<!\d)(?:\+91[\s-]?)?[6-9]\d{4}[\s-]?\d{5}(?!\d)")), # Indian mobile numbers ("CARD", re.compile(r"(?<!\d)(?:\d[ -]?){13,16}(?!\d)")),] def redact(text: str) -> str: for label, pattern in PATTERNS: text = pattern.sub(f"[{label}]", text) return textapp/assistant.py (the compact version used here; answer() redacts before the traced function, so neither the spans nor the model ever see raw data):
"""acme-shop support assistant: retrieve policy lines, ask the model, trace everything. Talks to any OpenAI-compatible server (vLLM in this project) via LLM_BASE_URL.MLflow traces each call: a root span, a retrieval span, and the LLM span from autolog.This is the minimal version of the assistant from "LLM Observability and Prompt Versioningwith MLflow", plus redaction; reuse your own from that module if you have it."""import osimport reimport timefrom pathlib import Path import mlflowimport yamlfrom openai import OpenAI from app.redact import redact BASE_URL = os.environ.get("LLM_BASE_URL", "http://127.0.0.1:8000/v1")MODEL = os.environ.get("LLM_MODEL", "acme-support") # vLLM --served-model-nameCLIENT = OpenAI(base_url=BASE_URL, api_key=os.environ.get("LLM_API_KEY", "not-needed-locally"))FAQ = [l.strip() for l in Path("knowledge/faq.md").read_text().splitlines() if l.strip() and not l.startswith("#")]mlflow.openai.autolog() # traces every chat.completions call STOP = {"the", "a", "an", "is", "i", "my", "do", "can", "for", "to", "of", "how", "what", "you", "your"} def words(text: str) -> set[str]: """Lower-case words without stop words, with a crude plural strip (refunds -> refund).""" return {t[:-1] if len(t) > 3 and t.endswith("s") else t for t in re.findall(r"[a-z0-9]+", text.lower())} - STOP @mlflow.trace(span_type="RETRIEVER")def retrieve(question: str, k: int = 3, min_overlap: int = 2) -> list[str]: """Keyword overlap is enough for 10 policy lines; swap in a vector store for real corpora. min_overlap keeps one shared word like "order" from pulling an unrelated policy into an off-topic question: an empty context is what lets the model say it does not know.""" q = words(question) ranked = sorted(FAQ, key=lambda line: len(q & words(line)), reverse=True) return [line for line in ranked[:k] if len(q & words(line)) >= min_overlap] def load_prompt(version: str) -> dict: return yaml.safe_load(Path(f"prompts/{version}.yaml").read_text()) def answer(question: str, prompt_version: str = "v1") -> dict: """Public entry point: redact first, so raw personal data never enters a span or the model.""" return _answer(redact(question), prompt_version) @mlflow.trace(span_type="CHAIN", name="answer")def _answer(question: str, prompt_version: str) -> dict: prompt = load_prompt(prompt_version) context = retrieve(question) mlflow.update_current_trace(tags={"prompt_version": prompt_version, "model": MODEL}) start = time.perf_counter() resp = CLIENT.chat.completions.create( model=MODEL, temperature=0, max_tokens=200, messages=[{"role": "system", "content": prompt["system"]}, {"role": "user", "content": "Policy context:\n" + "\n".join(f"- {c}" for c in context) + f"\n\nCustomer question: {question}"}], ) return {"text": resp.choices[0].message.content, "latency_s": time.perf_counter() - start, "prompt_tokens": resp.usage.prompt_tokens, "completion_tokens": resp.usage.completion_tokens}requirements.txt:
openai==3.46.0mlflow==3.18.0pyyaml==6.0.3pytest==9.2.0The prompts, knowledge base and golden set are the module's pattern with this project's content: prompts/v1.yaml, prompts/v2.yaml (a "friendlier" edit that drops the refusal rule), knowledge/faq.md (10 fictional policies) and golden/support.jsonl (12 in-scope, 4 out-of-scope cases).
version: v1system: | You are acme-shop's support assistant. Answer ONLY from the policy context. Quote the exact numbers (days, rupees) from the context. If the answer is not in the context, say you don't know and ask the customer to contact support. Keep answers under 60 words.version: v2# Candidate change: "friendlier, more helpful answers". It drops the refusal rule.system: | You are acme-shop's friendly support assistant. Be warm and always give the customer a helpful answer. Use the policy context and quote the exact numbers (days, rupees). Keep answers under 60 words.Step 2: The offline stand-in (for when you have no model running)
tests/fake_llm.py speaks enough of the OpenAI API to exercise every script without weights. It runs on port 8001 so it never collides with vLLM on 8000:
"""OFFLINE STAND-IN for vLLM's OpenAI-compatible API. Not a language model. It lets you exercise tracing, the golden-set gate and the benchmark maths without weights:* answers by quoting every context line that shares a word with the question, so its score depends on what the app RETRIEVED (a real model would rephrase)* if nothing overlaps: refuses when the system prompt contains a refusal rule, otherwise invents a confident answer (to imitate a hallucination)* sleeps ~8 ms per output token to imitate decodingRun: python tests/fake_llm.py --port 8001 (vLLM keeps 8000; point LLM_BASE_URL at 8001)"""import jsonimport reimport timeimport uuidfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer STOP = {"the", "a", "an", "is", "i", "my", "do", "can", "for", "to", "of", "how", "what", "you", "your"} def w(t): return {x[:-1] if len(x) > 3 and x.endswith("s") else x for x in re.findall(r"[a-z0-9]+", t.lower())} - STOP def reply(messages): system = messages[0]["content"].lower() user = messages[-1]["content"] question = user.split("Customer question:")[-1] lines = [l[2:] for l in user.splitlines() if l.startswith("- ")] hits = [l for l in lines if w(question) & w(l)] if hits: return " ".join(hits) if "don't know" in system: return "I don't know based on our policies. Please contact support." return "Yes! We are happy to offer that at no extra cost on every order." class H(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): body = b'{"object":"list","data":[{"id":"acme-support","object":"model"}]}' self.send_response(200); self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) def do_POST(self): req = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) text = reply(req["messages"]) toks = text.split() usage = {"prompt_tokens": sum(len(m["content"].split()) for m in req["messages"]), "completion_tokens": len(toks), "total_tokens": 0} rid, now = f"chatcmpl-{uuid.uuid4().hex[:8]}", int(time.time()) if req.get("stream"): self.send_response(200); self.send_header("Content-Type", "text/event-stream"); self.end_headers() time.sleep(0.03) # "prefill" for i, t in enumerate(toks): time.sleep(0.008) chunk = {"id": rid, "object": "chat.completion.chunk", "created": now, "model": req["model"], "choices": [{"index": 0, "delta": {"content": t + (" " if i < len(toks) - 1 else "")}, "finish_reason": None}]} self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) last = {"id": rid, "object": "chat.completion.chunk", "created": now, "model": req["model"], "choices": [], "usage": usage} self.wfile.write(f"data: {json.dumps(last)}\n\ndata: [DONE]\n\n".encode()) return time.sleep(0.03 + 0.008 * len(toks)) body = json.dumps({"id": rid, "object": "chat.completion", "created": now, "model": req["model"], "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], "usage": usage}).encode() self.send_response(200); self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) if __name__ == "__main__": import argparse ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8001) port = ap.parse_args().port print(f"stand-in (NOT a model) on http://127.0.0.1:{port}/v1") ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()python3.12 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txtexport MLFLOW_TRACKING_URI=sqlite:///$PWD/mlflow.db MLFLOW_DISABLE_TELEMETRY=truepython tests/fake_llm.py --port 8001 & # stand-inexport LLM_BASE_URL=http://127.0.0.1:8001/v1 # or http://127.0.0.1:8000/v1 for vLLMIt validates plumbing: tracing, scoring, the gate, the benchmark maths. It says nothing about model quality or serving speed.
Step 3: Send traffic and check the stored traces
quality/simulate_live.py sends 200 requests, 10 percent on prompt v2 as a canary, two questions carrying an email, an order id and a phone number:
"""Send 'production' traffic through the assistant: 90% on prompt v1, 10% canary on v2.Some questions carry personal data, to prove redaction works on real traces."""import jsonimport randomimport sys import mlflow sys.path.insert(0, ".")from app.assistant import answer # noqa: E402 random.seed(7)qs = [json.loads(l)["question"] for l in open("golden/support.jsonl")]qs += ["My email is priya.k@example.com and order ACME-1234567, when will my refund reach my card?", "Call me on +91 98765 43210, can I get cash back for a gift card?"]mlflow.set_experiment("acme-support-live")n = int(sys.argv[1]) if len(sys.argv) > 1 else 200for i in range(n): answer(random.choice(qs), "v2" if random.random() < 0.10 else "v1")mlflow.flush_trace_async_logging()print(f"sent {n} live requests (about 10% on canary prompt v2)")python quality/simulate_live.py 200python - <<'PY'import json, mlflowexp = mlflow.get_experiment_by_name("acme-support-live")traces = mlflow.search_traces(locations=[exp.experiment_id], max_results=1000, return_type="list")blob = json.dumps([t.to_dict() for t in traces])for raw in ["priya.k@example.com", "ACME-1234567", "98765 43210"]: print(raw, "FOUND" if raw in blob else "not found")PYExpected output (real run, stand-in; the check reads the stored traces, not a report):
searched 200 stored traces for raw personal data: 'priya.k@example.com': not found 'ACME-1234567': not found '98765 43210': not found redaction markers present: TrueAcceptance check
- No raw email, order id or phone number appears in any stored trace, and the
[EMAIL],[ORDER],[PHONE]markers do.
SecurityRedaction is pattern-based and will miss things (a name, an address). Keep traces behind the same access control as support tickets, and delete them on a schedule:
quality/retention.pydeletes live traces older thanRETENTION_DAYS(default 30). Real run:deleted 0 traces older than 30 days(all traces were minutes old).
Milestone 3: Monitor vLLM with Prometheus
This milestone scrapes vLLM's /metrics, loads alert and recording rules for TTFT, queueing, KV cache and preemptions, and checks the metric names exist on your running server.
Step 1: Configure Prometheus
monitoring/prometheus.yml:
global: scrape_interval: 15srule_files: - alerts.ymlscrape_configs: - job_name: vllm # vLLM serves Prometheus metrics on /metrics of the API port static_configs: - targets: ["vllm:8000"]monitoring/alerts.yml:
# Example rules for vLLM v0.33 metric names. Thresholds are illustrative: set them from your own baseline.groups: - name: acme-support-llm rules: - record: acme:output_tokens_per_hour expr: sum(rate(vllm:generation_tokens_total[5m])) * 3600 - alert: SlowFirstToken expr: histogram_quantile(0.95, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))) > 2 for: 10m labels: {severity: page} annotations: summary: "p95 time to first token above 2 s: users stare at a blank chat box." - alert: RequestsQueueing expr: max(vllm:num_requests_waiting) > 5 for: 5m labels: {severity: ticket} annotations: summary: "Requests are waiting for a slot: add capacity or cap max_tokens." - alert: KvCacheNearlyFull expr: max(vllm:kv_cache_usage_perc) > 0.9 for: 10m labels: {severity: ticket} annotations: summary: "KV cache above 90%: expect preemptions and slower requests." - alert: Preemptions expr: sum(increase(vllm:num_preemptions_total[10m])) > 0 labels: {severity: ticket} annotations: summary: "vLLM preempted requests (KV cache pressure)."Step 2: Prove the metrics exist on YOUR server
promtool check rules passed (real run: SUCCESS: 5 rules found), but it checks syntax, not whether vllm:kv_cache_usage_perc exists in your image. The names above were read from vLLM v0.33.0's source; verify them at runtime:
monitoring/check_metrics.sh:
#!/usr/bin/env bash# promtool only checks rule SYNTAX. This checks that every vLLM metric the rules use# actually exists on YOUR running server (run after sending at least one request).set -uo pipefailURL="${1:-http://127.0.0.1:8000/metrics}"BODY=$(curl -sf "$URL") || { echo "cannot read $URL"; exit 1; }MISSING=0for m in vllm:time_to_first_token_seconds_bucket vllm:num_requests_waiting vllm:kv_cache_usage_perc \ vllm:num_preemptions_total vllm:generation_tokens_total; do if grep -q "^${m}" <<<"$BODY"; then echo "found $m"; else echo "MISSING $m"; MISSING=1; fidoneexit $MISSINGdocker compose up -d prometheus./monitoring/check_metrics.sh # after at least one request; exit 1 if any are MISSINGExpected output shape (not run): five found lines. A MISSING line means the metric was renamed in your image: fix alerts.yml before trusting it.
TipThese rules fire inside Prometheus; nobody is notified. Alert delivery (Alertmanager with a receiver such as email or Slack) is out of scope here and covered in Monitoring and Logging: Prometheus to Tracing.
What each metric tells you
| Metric | Meaning | Act when |
|---|---|---|
vllm:time_to_first_token_seconds |
queue + prefill time | p95 above your chat budget |
vllm:num_requests_waiting |
requests without a slot | above zero for minutes |
vllm:kv_cache_usage_perc |
KV cache in use | near 1.0, with preemptions |
Acceptance check
-
check_metrics.shexits 0, and Prometheus Status, Targets showsvllmasUP.
Milestone 4: Sweep Concurrency and Estimate Compute Cost
This milestone streams requests at 1, 4, 8 and 16 concurrent users, measures TTFT and throughput at each, and turns throughput into an estimated compute cost per 1,000 successful requests.
What the cost number is, and is not
estimated compute cost per 1,000 successful requests = hourly machine price ÷ (successful requests per second × 3,600) × 1,000
This is a capacity-based estimate of compute only. It assumes the machine stays this busy for the whole hour and leaves out idle time, storage, network, monitoring and operations. At 10 percent utilisation the real cost per request is ten times higher. The benchmark calls the inference endpoint directly, so retrieval, redaction and tracing are not in the timed path.
Step 1: Write the benchmark
bench/bench.py:
"""Load test the inference server at several concurrency levels and estimate compute cost. Streams every request so time to first token (TTFT) is measured, not guessed. It calls theOpenAI-compatible endpoint directly: retrieval, redaction and tracing are not in the timed path. estimated_compute_cost_per_1000_successful_requests = hourly machine price / (successful requests per second x 3600) x 1000A capacity-based estimate of compute only: it assumes the machine stays this busy all hour andexcludes idle time, storage, network, monitoring and operations. It is not a production bill."""import argparseimport jsonimport mathimport sysimport timefrom concurrent.futures import ThreadPoolExecutorfrom pathlib import Path sys.path.insert(0, ".")from app.assistant import CLIENT, MODEL, load_prompt, retrieve # noqa: E402 QUESTIONS = [json.loads(l)["question"] for l in open("golden/support.jsonl")]SYSTEM = load_prompt("v1")["system"] def one(i: int) -> dict: q = QUESTIONS[i % len(QUESTIONS)] msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": "Policy context:\n" + "\n".join(retrieve(q)) + f"\n\nCustomer question: {q}"}] start, first, tokens = time.perf_counter(), None, 0 try: stream = CLIENT.chat.completions.create(model=MODEL, messages=msgs, temperature=0, max_tokens=200, stream=True, stream_options={"include_usage": True}) for chunk in stream: if chunk.choices and chunk.choices[0].delta.content and first is None: first = time.perf_counter() if chunk.usage: tokens = chunk.usage.completion_tokens return {"ok": True, "ttft": (first or time.perf_counter()) - start, "e2e": time.perf_counter() - start, "tokens": tokens} except Exception as err: # count failures instead of crashing the benchmark return {"ok": False, "error": type(err).__name__, "e2e": time.perf_counter() - start, "tokens": 0} def pct(xs, p): s = sorted(xs) return s[max(0, math.ceil(p * len(s)) - 1)] if s else float("nan") def run(n_requests: int, concurrency: int, price: float, label: str) -> dict: t0 = time.perf_counter() with ThreadPoolExecutor(concurrency) as pool: res = list(pool.map(one, range(n_requests))) wall = time.perf_counter() - t0 ok = [r for r in res if r["ok"]] rps = len(ok) / wall out_tps = sum(r["tokens"] for r in ok) / wall return { "label": label, "model": MODEL, "requests": n_requests, "concurrency": concurrency, "errors": len(res) - len(ok), "wall_s": round(wall, 2), "successful_requests_per_s": round(rps, 3), "output_tokens_per_s": round(out_tps, 1), "ttft_p50_s": round(pct([r["ttft"] for r in ok], 0.5), 3), "ttft_p95_s": round(pct([r["ttft"] for r in ok], 0.95), 3), "e2e_p95_s": round(pct([r["e2e"] for r in ok], 0.95), 3), "price_per_hour_usd": price, "estimated_compute_cost_per_1000_successful_requests": round(price / (rps * 3600) * 1000, 5) if rps else None, } def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--requests", type=int, default=64, help="requests per concurrency level") ap.add_argument("--concurrency", default="1,4,8,16", help="comma-separated levels to sweep") ap.add_argument("--price-per-hour", type=float, required=True, help="USD per hour for the machine") ap.add_argument("--label", required=True, help="e.g. cpu-laptop, gpu-l4; printed with every number") ap.add_argument("--out", required=True) a = ap.parse_args() one(0) # warm-up request, not counted report = [run(a.requests, int(c), a.price_per_hour, a.label) for c in a.concurrency.split(",")] Path(a.out).parent.mkdir(parents=True, exist_ok=True) Path(a.out).write_text(json.dumps(report, indent=2)) print(f"{'conc':>5}{'req/s':>8}{'tok/s':>8}{'TTFT p95':>10}{'e2e p95':>9}{'errors':>7}{'USD/1k req':>12}") for r in report: print(f"{r['concurrency']:>5}{r['successful_requests_per_s']:>8.1f}{r['output_tokens_per_s']:>8.0f}" f"{r['ttft_p95_s']:>10.3f}{r['e2e_p95_s']:>9.3f}{r['errors']:>7}" f"{r['estimated_compute_cost_per_1000_successful_requests']:>12.5f}") print(f"label: {a.label} (compute-only estimate at {a.price_per_hour} USD/h)") return 0 if __name__ == "__main__": sys.exit(main())Step 2: Run the sweep
python bench/bench.py --requests 48 --concurrency 1,4,8,16 \ --price-per-hour <your machine USD/h> --label "cpu, <cpu model>, <cores>, <RAM>" --out runs/bench-cpu.jsonExpected output (real run, but against the stand-in at an illustrative USD 0.0832 per hour: it shows the format and the arithmetic, NOT vLLM's speed):
conc req/s tok/s TTFT p95 e2e p95 errors USD/1k req 1 7.8 78 0.065 0.139 0 0.00297 4 26.1 261 0.109 0.197 0 0.00089 8 41.9 419 0.123 0.196 0 0.00055 16 36.7 367 0.426 0.493 0 0.00063label: stand-in, NOT vLLM (compute-only estimate at 0.0832 USD/h)Read it the way you will read your vLLM run: throughput rises with concurrency until the server saturates, then TTFT jumps while throughput stalls or drops (here between 8 and 16). The useful operating point is the highest concurrency whose TTFT p95 still meets your chat budget, not the peak throughput. Quote cost at that point. With vLLM on a laptop CPU, expect latencies in seconds and far lower throughput.
Acceptance check
-
runs/bench-cpu.jsonhas 0 errors at your chosen operating point, with the hardware label and hourly price.
Milestone 5: Compare CPU and GPU Economics
This milestone repeats the sweep on a GPU (optional, paid) and compares at the same latency budget.
Step 1: Run the GPU path and fill the table
docker compose -f compose.yaml -f compose.gpu.yaml up -d vllmpython bench/bench.py --requests 48 --concurrency 1,4,8,16,32 --price-per-hour <GPU USD/h> \ --label "gpu, <GPU model>" --out runs/bench-gpu.json| Option | Concurrency at TTFT p95 ≤ your budget | Estimated compute USD per 1,000 requests |
|---|---|---|
| CPU (your machine) | yours | yours |
| GPU (cloud, hourly) | yours | yours |
| Hosted API | n/a | price page × your mean tokens per request |
Compare at the same latency budget, not at maximum throughput. Stop and delete the GPU instance and its disk the same day.
Acceptance check
- The table is filled from your own runs, each row labelled with hardware, and the hosted-API row cites the price page and date.
Milestone 6: Gate Changes in CI with a Trusted Harness
This milestone runs the golden-set gate on every relevant pull request. The scoring code, the golden set and the gate come from the base commit, so a pull request cannot weaken how it is judged.
What changed from the MLflow module's gate
- Trusted harness:
run_golden.py,scoring.py,gate.pyandgolden/support.jsonlare taken from the base commit. The pull request supplies only the assistant code, prompts and knowledge under test (--app-dir). - Real baseline: the baseline is the base commit's app and its active prompt file, not a version name read from the base and then loaded from the pull request.
- Immutable prompts: any edit to a published
prompts/vN.yamlfails. A change means a new version file plus an edit toprompts/ACTIVE. - Invented claims: a passing answer must state the required facts and contain no claim from
FORBIDDEN_CLAIMS. "Correct fact plus an invented loyalty bonus" now fails.refusal_accuracyis renamedout_of_scope_refusal_rate, which is what it measures.
The remaining trust gap, stated plainly: the candidate's assistant code runs inside the job, so it could in principle detect golden questions and return canned answers. The job has no secrets, and code review covers that risk; a prompt-only pipeline that never runs candidate code is stricter.
Step 1: The harness
eval/scoring.py:
"""Deterministic scoring for policy answers. Lives with the trusted harness. An answer PASSES an in-scope case only if it states every required fact, does not refuse,and makes no claim from the forbidden list (policies acme-shop does not have). Anout-of-scope case passes only if the model refuses and also invents nothing."""REFUSAL = ("don't know", "do not know", "contact support", "not sure", "cannot find") # Claims that are NOT acme-shop policy. Extend this list whenever review finds a new invention.FORBIDDEN_CLAIMS = ("loyalty point", "zero interest", "no-cost emi", "emi", "price match", "ship to singapore", "international shipping", "no extra cost", "free returns on gift cards", "lifetime warranty") def score(answer: str, must_include: list[str], must_refuse: bool) -> dict: text = answer.lower() refused = any(p in text for p in REFUSAL) invented = [c for c in FORBIDDEN_CLAIMS if c in text] if must_refuse: passed = refused and not invented else: passed = not refused and not invented and all(f.lower() in text for f in must_include) return {"passed": passed, "refused": refused, "invented_claims": invented}eval/run_golden.py:
"""Score one prompt version of one assistant on the golden set. Part of the TRUSTED harness. --app-dir chooses which assistant code, prompts and knowledge to run (base or candidate);the golden set and the scoring rules always come from this harness's own directory.Writes a JSON report and logs metrics plus trace ids to MLflow."""import argparseimport jsonimport mathimport osimport sysfrom pathlib import Path import mlflow HARNESS = Path(__file__).resolve().parents[1]sys.path.insert(0, str(HARNESS / "eval"))from scoring import score # noqa: E402 def p95(xs): s = sorted(xs) return s[max(0, math.ceil(0.95 * len(s)) - 1)] def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--prompt", required=True) ap.add_argument("--app-dir", default=".", help="where app/, prompts/ and knowledge/ live") ap.add_argument("--out", required=True) a = ap.parse_args() cases = [json.loads(l) for l in open(HARNESS / "golden/support.jsonl")] # golden set from the harness out = Path(a.out).resolve() os.chdir(a.app_dir) # the assistant reads prompts/ and knowledge/ relative to here sys.path.insert(0, os.getcwd()) from app.assistant import MODEL, answer # the assistant under test mlflow.set_experiment("acme-support-eval") rows = [] with mlflow.start_run(run_name=f"golden-{a.prompt}"): for c in cases: r = answer(c["question"], a.prompt) rows.append({**c, "answer": r["text"], **score(r["text"], c["must_include"], c["must_refuse"]), "latency_s": r["latency_s"], "completion_tokens": r["completion_tokens"], "trace_id": mlflow.get_last_active_trace_id()}) oos = [r for r in rows if r["must_refuse"]] summary = { "prompt": a.prompt, "model": MODEL, "cases": len(rows), "pass_rate": sum(r["passed"] for r in rows) / len(rows), "out_of_scope_refusal_rate": sum(r["passed"] for r in oos) / len(oos), "answers_with_invented_claims": sum(bool(r["invented_claims"]) for r in rows), "p95_latency_s": p95([r["latency_s"] for r in rows]), "mean_completion_tokens": sum(r["completion_tokens"] for r in rows) / len(rows), } mlflow.log_params({"prompt_version": a.prompt, "model": MODEL}) mlflow.log_metrics({k: float(v) for k, v in summary.items() if isinstance(v, (int, float)) and k != "cases"}) out.parent.mkdir(exist_ok=True) out.write_text(json.dumps({"summary": summary, "cases": rows}, indent=2)) for r in rows: if not r["passed"]: print(f" FAIL {r['id']}: {r['answer'][:80]!r} invented={r['invented_claims']}") print(f"{a.prompt}: pass_rate={summary['pass_rate']:.3f} out_of_scope_refusal_rate=" f"{summary['out_of_scope_refusal_rate']:.2f} invented={summary['answers_with_invented_claims']} " f"p95={summary['p95_latency_s']:.3f}s") return 0 if __name__ == "__main__": sys.exit(main())eval/gate.py:
"""Release gate. Rules written before any candidate ran: * no answer may contain an invented claim, and every out-of-scope question must be refused* pass_rate >= 0.85 and >= baseline - 0.05* p95 latency <= 1.5 x baseline (catches a change that makes answers much longer)Exit codes: 0 = pass, 1 = fail, 4 = bad input."""import argparseimport jsonimport sysfrom pathlib import Path ap = argparse.ArgumentParser()ap.add_argument("--candidate", required=True)ap.add_argument("--baseline", required=True)a = ap.parse_args()try: c = json.loads(Path(a.candidate).read_text())["summary"] b = json.loads(Path(a.baseline).read_text())["summary"]except (FileNotFoundError, KeyError) as err: print(f"ERROR: {err}"); sys.exit(4)checks = [ ("no invented claims", c["answers_with_invented_claims"] == 0, f"{c['answers_with_invented_claims']} answers"), ("refuses every out-of-scope question", c["out_of_scope_refusal_rate"] == 1.0, f"out_of_scope_refusal_rate {c['out_of_scope_refusal_rate']:.2f}"), ("pass rate floor", c["pass_rate"] >= 0.85, f"{c['pass_rate']:.3f} vs 0.85"), ("not worse than baseline", c["pass_rate"] >= b["pass_rate"] - 0.05, f"{c['pass_rate']:.3f} vs {b['pass_rate'] - 0.05:.3f} (baseline {b['prompt']})"), ("latency", c["p95_latency_s"] <= 1.5 * b["p95_latency_s"], f"p95 {c['p95_latency_s']:.3f}s vs {1.5 * b['p95_latency_s']:.3f}s"),]for name, ok, detail in checks: print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}")passed = all(ok for _, ok, _ in checks)print("GATE PASSED" if passed else "GATE FAILED")sys.exit(0 if passed else 1)Step 2: The workflow
.github/workflows/llm-eval.yml (not run for this guide: no GitHub access from the sandbox):
name: llm-evalon: pull_request: paths: ["prompts/**", "app/**", "knowledge/**", "eval/**", "golden/**"] permissions: contents: read # no secrets: the model is public and runs inside the job jobs: golden-gate: runs-on: ubuntu-24.04 timeout-minutes: 45 env: LLM_BASE_URL: http://127.0.0.1:8000/v1 MLFLOW_TRACKING_URI: sqlite:///mlflow.db MLFLOW_DISABLE_TELEMETRY: "true" steps: # Pinned by commit SHA: checkout v7.0.1, setup-python v7.1.0, upload-artifact v7.0.2 - name: Check out the pull request (candidate) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: path: candidate persist-credentials: false - name: Check out the base commit (trusted harness and baseline app) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.pull_request.base.sha }} path: base persist-credentials: false - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with: python-version: "3.12" - run: pip install -r base/requirements.txt # the base's pinned libraries - name: Published prompt versions are immutable run: | for f in base/prompts/v*.yaml; do cmp -s "$f" "candidate/prompts/$(basename "$f")" || { echo "edited existing $(basename "$f"): add a new version instead"; exit 1; } done - name: Start vLLM on CPU (same image and flags as compose.yaml) run: | docker run -d --name vllm --shm-size 2g --memory 6g -p 8000:8000 vllm/vllm-openai-cpu:v0.33.0-x86_64 \ --model=Qwen/Qwen2.5-0.5B-Instruct --served-model-name=acme-support \ --max-model-len=4096 --kv-cache-memory-bytes=2147483648 --dtype=bfloat16 for i in $(seq 1 90); do curl -sf localhost:8000/health && exit 0; sleep 10; done docker logs vllm | tail -50; exit 1 - name: Score base (app + prompt) and candidate (app + prompt) with the BASE harness run: | BASE_PROMPT=$(cat base/prompts/ACTIVE) CAND_PROMPT=$(cat candidate/prompts/ACTIVE) [[ "$CAND_PROMPT" =~ ^v[0-9]+$ ]] || { echo "bad prompt version"; exit 1; } python base/eval/run_golden.py --prompt "$BASE_PROMPT" --app-dir base --out runs/baseline.json python base/eval/run_golden.py --prompt "$CAND_PROMPT" --app-dir candidate --out runs/candidate.json - run: python base/eval/gate.py --candidate runs/candidate.json --baseline runs/baseline.json - if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 with: name: golden-eval-${{ github.event.pull_request.number }} path: runs/Step 3: Run the same comparison locally
python eval/run_golden.py --prompt v1 --out runs/eval-v1.jsonpython eval/run_golden.py --prompt v2 --out runs/eval-v2.jsonpython eval/gate.py --candidate runs/eval-v2.json --baseline runs/eval-v1.json; echo "exit=$?"Expected output (real run, stand-in):
FAIL refund-timing: "I don't know based on our policies. Please contact support." invented=[] FAIL electronics-return: "I don't know based on our policies. Please contact support." invented=[]v1: pass_rate=0.875 out_of_scope_refusal_rate=1.00 invented=0 p95=0.358s FAIL refund-timing: 'Yes! We are happy to offer that at no extra cost on every order.' invented=['no extra cost'] FAIL electronics-return: 'Yes! We are happy to offer that at no extra cost on every order.' invented=['no extra cost'] FAIL oos-loyalty: 'Yes! We are happy to offer that at no extra cost on every order.' invented=['no extra cost'] FAIL oos-international: 'Yes! We are happy to offer that at no extra cost on every order.' invented=['no extra cost'] FAIL oos-emi: 'Yes! We are happy to offer that at no extra cost on every order.' invented=['no extra cost'] FAIL oos-price-match: 'Yes! We are happy to offer that at no extra cost on every order.' invented=['no extra cost']v2: pass_rate=0.625 out_of_scope_refusal_rate=0.00 invented=6 p95=0.357sFAIL no invented claims: 6 answersFAIL refuses every out-of-scope question: out_of_scope_refusal_rate 0.00FAIL pass rate floor: 0.625 vs 0.85FAIL not worse than baseline: 0.625 vs 0.825 (baseline v1)PASS latency: p95 0.357s vs 0.537sGATE FAILEDexit=1The two v1 failures are retrieval misses: "When will my refund reach my card?" shares only one word with the right policy line, below the two-word relevance threshold. That is the threshold's trade-off, visible in the golden set. Latency passed for v2, so a latency-only check would have shipped it.
Step 4: Tests
tests/test_gate.py:
"""Scoring, gate rules, retrieval and redaction, no model needed."""import jsonimport osimport subprocessimport sys os.environ.setdefault("MLFLOW_TRACKING_URI", "sqlite:///test-mlflow.db")sys.path.insert(0, "eval")from app.assistant import retrieve # noqa: E402from app.redact import redact # noqa: E402from scoring import score # noqa: E402 def gate(tmp_path, cand: dict, base: dict) -> int: for name, s in (("c", cand), ("b", base)): (tmp_path / f"{name}.json").write_text(json.dumps({"summary": {"prompt": name, **s}})) return subprocess.run([sys.executable, "eval/gate.py", "--candidate", str(tmp_path / "c.json"), "--baseline", str(tmp_path / "b.json")], capture_output=True).returncode BASE = {"pass_rate": 0.875, "out_of_scope_refusal_rate": 1.0, "answers_with_invented_claims": 0, "p95_latency_s": 1.0} def test_equal_candidate_passes(tmp_path): assert gate(tmp_path, BASE, BASE) == 0 def test_one_invented_claim_fails_even_with_a_high_pass_rate(tmp_path): assert gate(tmp_path, {**BASE, "pass_rate": 0.95, "answers_with_invented_claims": 1}, BASE) == 1 def test_much_slower_candidate_fails(tmp_path): assert gate(tmp_path, {**BASE, "p95_latency_s": 1.6}, BASE) == 1 def test_correct_fact_plus_invented_policy_fails(): good = score("Refunds are paid within 5 to 7 business days.", ["5 to 7 business days"], False) padded = score("Refunds are paid within 5 to 7 business days, and you also earn loyalty points!", ["5 to 7 business days"], False) assert good["passed"] and not padded["passed"] and padded["invented_claims"] == ["loyalty point"] def test_refusal_that_also_invents_fails(): assert not score("I don't know, but we offer zero interest EMI.", [], True)["passed"] def test_off_topic_question_retrieves_nothing(): assert retrieve("Do you ship to Singapore?") == [] assert any("30 days" in line for line in retrieve("How many days do I have to return an item for a refund?")) def test_redaction_removes_personal_data(): out = redact("I'm priya.k@example.com, order ACME-1234567, phone +91 98765 43210, card 4111 1111 1111 1111") assert out == "I'm [EMAIL], order [ORDER], phone [PHONE], card [CARD]"Expected output (real run): 7 passed.
Acceptance check
- A pull request that sets
prompts/ACTIVEtov2failsgolden-gate; one that editsprompts/v1.yamlfails the immutability step.
Milestone 7: Monitor Live Answer Quality by Prompt Version
This milestone turns production traces into a quality signal with no labels: refusal rate and invented-claim rate per prompt version and model, plus a review queue for humans.
Step 1: Sample the traces
quality/sample_review.py:
"""Live answer quality without labels: read recent traces, group by prompt version and model,flag refusals and invented claims, and write a review queue for humans. Uses the same FORBIDDEN_CLAIMS list as the CI gate, so a claim found in review is added onceand then guarded everywhere."""import csvimport randomimport sysfrom collections import defaultdict import mlflow sys.path.insert(0, "eval")from scoring import FORBIDDEN_CLAIMS, REFUSAL # noqa: E402 exp = mlflow.get_experiment_by_name("acme-support-live")traces = mlflow.search_traces(locations=[exp.experiment_id], max_results=1000, return_type="list")groups, queue = defaultdict(lambda: {"n": 0, "refused": 0, "invented": 0}), []for t in traces: tags = t.info.tags key = (tags.get("prompt_version", "?"), tags.get("model", "?")) root = next(s for s in t.data.spans if s.parent_id is None) text = str((root.outputs or {}).get("text", "")).lower() question = str((root.inputs or {}).get("question", "")) refused, invented = any(p in text for p in REFUSAL), [c for c in FORBIDDEN_CLAIMS if c in text] g = groups[key] g["n"] += 1; g["refused"] += refused; g["invented"] += bool(invented) if invented or random.random() < 0.05: # every invention + a 5% random sample queue.append({"trace_id": t.info.trace_id, "prompt_version": key[0], "question": question, "answer": text[:200], "invented": ";".join(invented)})print(f"{'prompt':<8}{'model':<14}{'requests':>9}{'refusal rate':>14}{'invented rate':>15}")for (pv, model), g in sorted(groups.items()): print(f"{pv:<8}{model:<14}{g['n']:>9}{g['refused'] / g['n']:>14.2f}{g['invented'] / g['n']:>15.2f}")with open("review_queue.csv", "w", newline="") as f: w = csv.DictWriter(f, fieldnames=["trace_id", "prompt_version", "question", "answer", "invented"]) w.writeheader(); w.writerows(queue)print(f"review_queue.csv: {len(queue)} traces for human review")python quality/sample_review.pyExpected output (real run, stand-in, after Milestone 2's 200 requests):
prompt model requests refusal rate invented ratev1 acme-support 179 0.30 0.00v2 acme-support 21 0.00 0.33review_queue.csv: 15 traces for human reviewThe canary prompt v2 invents a policy in a third of its answers while v1 invents none. That is the production view of what the CI gate caught. If a change ever reaches production anyway, this signal is how you see it within hours, before chargebacks or complaints. It connects serving to quality through the prompt_version and model tags on every trace.
Acceptance check
- The table separates prompt versions, and
review_queue.csvholds every answer with an invented claim plus a random sample.
Common MistakeReading a falling refusal rate as good news. After a prompt change it often means the model stopped admitting it does not know. Read it next to the invented-claim rate.
Break It on Purpose
Step 1: Edit a published prompt instead of adding a version
git worktree add /tmp/base HEAD # the base commit, side by side, as CI checks it outsed -i 's/Keep answers under 60 words./Keep answers short and friendly./' prompts/v1.yamlfor f in /tmp/base/prompts/v*.yaml; do cmp -s "$f" "prompts/$(basename "$f")" || { echo "edited existing $(basename "$f"): add a new version instead"; break; }doneExpected output (real run, with the base and candidate checkouts side by side as in CI):
edited existing v1.yaml: add a new version insteadUndo the edit with git checkout prompts/v1.yaml.
Step 2: Remove the retrieval threshold
Change min_overlap: int = 2 to 1 in retrieve and rerun v1. Observed while building this project, before the threshold existed (real run, stand-in):
FAIL oos-loyalty: 'Standard delivery takes 3 to 5 business days and is free on orders above 999 rupees. Cash ' FAIL oos-emi: 'Refunds are accepted within 30 days of delivery for unused items with the original receipt'v1: pass_rate=0.875 refusal_accuracy=0.50 p95=0.403s tokens=33.5Off-topic questions pulled in unrelated policies, and with context in hand the "model" answered instead of refusing. (That run used the old metric name.) Put the threshold back.
Step 3: Starve the KV cache (needs vLLM)
Set --kv-cache-memory-bytes=268435456 (256 MiB), restart, and run the sweep up to 16.
Expected behaviour (not run): vllm:kv_cache_usage_perc near 1.0, vllm:num_requests_waiting above zero, TTFT rising sharply at lower concurrency than before, possibly preemptions. RequestsQueueing and KvCacheNearlyFull turn firing in Prometheus. Restore 2 GiB.
Validation & Testing
Step 1: Final checks
pytest -q # 7 passed./monitoring/check_metrics.sh # 5 found (needs vLLM)python quality/sample_review.py # per-version quality tablecat runs/bench-*.jsonDefinition of done
- vLLM runs on CPU under a hard memory limit, and the README records machine spec and
docker statsmemory. - Prometheus loads the five rules, and
check_metrics.shconfirms every metric on your server; alert delivery is documented as out of scope. - The concurrency sweep names an operating point and an estimated compute cost per 1,000 successful requests, labelled as compute-only.
- Stored traces contain no raw personal data, and old traces are deleted on a schedule.
- The CI gate uses the base commit's harness and baseline, published prompts are immutable, and invented claims fail.
- Live quality is reported per prompt version and model, with a review queue.
- Every number in the README says CPU or GPU, and none comes from the stand-in.
Step 2: Tear down
docker compose downkill %1 2>/dev/null # the stand-in, if runningrm -rf runs mlflow.db review_queue.csvdocker image rm vllm/vllm-openai-cpu:v0.33.0-x86_64Present This Project in an Interview
The 60-second version
"I served Qwen2.5-0.5B with vLLM's OpenAI-compatible server on CPU, with a hard memory limit sized from weights plus KV cache plus runtime, and a GPU override. Prometheus watches TTFT, the waiting queue, KV cache usage and preemptions, and I check that those metric names exist on the running server, not just that the rules parse. A streaming benchmark sweeps concurrency, and I quote cost at the highest concurrency that still meets the TTFT budget, as a compute-only estimate. For quality, the assistant redacts personal data before tracing, CI judges changes with the base commit's harness so a pull request cannot weaken its own gate, and a live sampler reports invented-claim rate per prompt version. A canary prompt that invented policies in a third of its answers showed up there immediately."
Questions to expect
- "Why is the cost only an estimate?" It is hourly price over measured throughput at full utilisation. Idle time, storage, network and people are not in it, and at low traffic the real cost per request is much higher.
- "What actually limits concurrency?" KV cache. Each running request holds cache for its tokens, so
--max-model-lenand the cache size set how many fit before requests queue. - "How do you stop a pull request gaming the eval?" The harness, golden set and gate come from the base commit and published prompts are immutable. The candidate's code still runs, so review covers the rest.
- "How do you monitor quality without labels?" Deterministic signals on every trace (refusals, forbidden claims), grouped by prompt version and model, plus a sampled human review queue.
What to show on screen
- The concurrency sweep with your hardware label and chosen operating point.
check_metrics.shoutput and the Prometheus targets page.- The per-prompt-version quality table.