Load tests measure performance. Evals measure quality — did the model give the right answer? A production LLM stack without continuous quality measurement is flying blind: a silent tokenizer bug, a wrong chat template, a bad dtype quantization can drop accuracy without any latency or error symptom.
This repo runs a small eval every 6 hours and pushes results to Prometheus.
evals/
├── prompts-configmap.yaml 12 prompts across 5 categories (JSONL)
├── script-configmap.yaml evaluator.py embedded in a ConfigMap
├── evaluator.py The actual eval script (source of truth for the ConfigMap)
├── workflow-template.yaml WorkflowTemplate model-quality-eval
├── cronworkflow.yaml Every 6 hours schedule
└── tests/test_evaluator.py Unit tests (pytest)
apps/evals.yaml ArgoCD Application → evals/ (wave 12)
evals/prompts-configmap.yaml — 12 prompts across 5 categories:
| Category | Prompts | Example |
|---|---|---|
factual |
3 | "What is the capital of France? Answer in one word." → (?i)paris |
math |
3 | "What is 15 * 23? Answer with only the number." → \\b345\\b |
code |
2 | "Write a Python function called reverse_string..." → regex checking for [::-1] or reversed |
instruction |
2 | "Reply with exactly the word HELLO in uppercase, nothing else." → ^\\s*HELLO\\s*\\.?\\s*$ |
reasoning |
2 | "Alice has 3 brothers and 2 sisters. How many siblings does Alice's brother have?" → \\b4\\b |
Each prompt is:
{
"id": "factual-france",
"category": "factual",
"prompt": "What is the capital of France? Answer in one word.",
"expected_regex": "(?i)paris"
}Design intent:
- Small and cheap — full eval runs in ~1 minute.
- Regex-scored — no LLM-as-judge (deterministic, no external LLM dependency).
- Categories — regression in one category isolates the failure to a capability class.
- Explicit output format instructions — reduces false negatives from chatty responses. "Answer with only the number" makes "\b345\b" reliable.
This is not a benchmark set — it won't tell you if your model is "better" than another. It's a regression detector — quick, cheap, runs constantly, and catches "someone broke it".
evals/evaluator.py. Logic:
for prompt in prompts:
response = call_vllm(prompt.prompt)
passed = re.search(prompt.expected_regex, response) is not None
results.append(EvalResult(
category=prompt.category,
passed=passed,
latency=response_time,
tokens=token_count,
))
# Aggregate
pass_rate = passed_count / total_count
per_category_pass_rate = {cat: cat_passed / cat_total for cat, ...}
# Push to Pushgateway
push_metrics({
"model_eval_pass_rate": pass_rate,
"model_eval_latency_seconds": ...,
"model_eval_response_tokens": ...,
"model_eval_last_run_timestamp": now,
"model_eval_prompts_total": total_count,
}, labels={"category": ..., "model": ...})Calls vLLM via the OpenAI chat completions API, using OPENAI_API_KEY
from the vllm-api-key Secret in the argo namespace.
evals/workflow-template.yaml — parameters:
| Parameter | Default | Meaning |
|---|---|---|
model |
meta-llama/Meta-Llama-3-8B-Instruct |
Model name passed to /v1/chat/completions. |
vllm-url |
http://llama-llama-8b.llama.svc.cluster.local:8000 |
vLLM Service URL. |
pushgateway-url |
in-cluster | Where to push metrics. |
max-tokens |
200 |
vLLM max_tokens. |
temperature |
0.1 |
Near-deterministic; small temp for tie-breaking. |
min-pass-rate |
0.4 |
Below this, the eval Workflow exits non-zero. |
image |
python:3.11-slim |
Slim Python container. |
Container:
- Image:
python:3.11-slim - Command:
pip install requests prometheus_client && python /scripts/evaluator.py - Mounts:
model-eval-promptsConfigMap at/prompts,model-eval-scriptConfigMap at/scripts. - Env:
VLLM_URL,MODEL,MAX_TOKENS,TEMPERATURE,PUSHGATEWAY_URL,VLLM_API_KEY(from Secret). - Resources: 200m/256Mi req, 1000m/512Mi limit.
evals/cronworkflow.yaml:
schedule: "17 */6 * * *" # 00:17, 06:17, 12:17, 18:17 UTC
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5Every 6 hours means 4 samples/day — enough for daily trend, sparse enough
to be cheap. The :17 offset avoids top-of-hour Grafana/Prometheus
cluster scrape spikes.
Under job=model-eval, model=<name>:
| Metric | Labels | Meaning |
|---|---|---|
model_eval_pass_rate |
model |
Overall pass rate (0-1) |
model_eval_pass_rate |
model, category |
Per-category pass rate |
model_eval_latency_seconds |
model, category, quantile |
Per-category p50/p95 latency |
model_eval_response_tokens |
model, category |
Mean tokens in responses |
model_eval_last_run_timestamp |
model |
Unix epoch of last run |
model_eval_prompts_total |
model, category |
Total prompts run |
- Dashboard:
13-dashboards.md - Alerts:
14-alerts.md
evals/tests/test_evaluator.py uses pytest and requests-mock to
test:
- Regex matching (positive and negative cases)
- API response parsing
- Metric formatting
- Pushgateway HTTP layer
Runs as part of CI (.github/workflows/ci.yml).
-
Edit
evals/prompts-configmap.yaml:{"id":"factual-boiling","category":"factual","prompt":"...","expected_regex":"..."} -
Test the regex locally:
python3 -c 'import re; print(bool(re.search("your-regex", "sample response")))' -
Commit. ArgoCD applies the ConfigMap. The next CronWorkflow run picks it up.
Tip: regex-only scoring works best for constrained outputs. If you need semantic evaluation:
- Add an LLM-as-judge step in the evaluator (calls a second, stronger model to grade).
- Use
evaluator.py's existing framework and just swap the scoring function.
- Update prompts with the new
categoryvalue. - The evaluator groups by category automatically — no code change needed.
- Add a corresponding
ModelQualityCategoryRegressedalert filter if you want per-category paging.
The default 12 prompts are a smoke test. For your production use case:
- Sample your traffic: pick 50-100 real prompts spanning your categories. Anonymize.
- Score with regex where possible ("does the answer contain X?").
- Score with LLM-as-judge where not — but budget the extra API calls.
- Set the pass-rate threshold — run the eval once against known-good output to baseline; alert at ~10% below baseline.
ModelQualityLowOverallPassRate firing usually means one of:
- Someone edited
values.yaml—model:orimage:changed. - Prompt template changed — the chat template used by tokenizer differs from the one prompts assume.
- Dtype quantization — swapped bf16 → int8 or fp8 without re-eval.
- vLLM version bump — new version changed sampling defaults.
- Model actually degraded — very rare, but possible after a
--served-model-namechange.
Diagnosis: check model_eval_pass_rate{category=...} — which category
regressed tells you the shape of the failure.
- Model quality dashboard:
13-dashboards.md - Model quality alerts:
14-alerts.md - Pushgateway wiring:
12-observability.md - CI (evaluator.py tests):
17-ci-cd.md