Two questions, one control room
This stage is two sibling disciplines that get bolted together in production. They ask different questions, they were invented by different people for different reasons, and confusing them is the single most common way an LLM team ends up with beautiful dashboards and furious users.
“Is the SYSTEM healthy?”
Latency, throughput, GPU telemetry, queue depth, traces, cost. Borrowed wholesale from SRE and bent to fit token-based workloads. The signals are numeric, continuous, and cheap to collect. There is always a right answer: the p99 latency is 4.2 s.
“Is the OUTPUT good?”
Correctness, faithfulness, format compliance, safety. Unique to AI. There is often no single ground truth — two different answers can both be right. The signals are sparse, expensive, contested, and produced by measuring instruments (judges, humans) that are themselves noisy and biased.
In production the two stop being separate. Online evaluation means sampling quality signals off live traffic, judging them continuously, and alerting on them the way you alert on any other service-level objective. The moment a faithfulness score becomes a Prometheus time series with a burn-rate alert on it, you are no longer doing “eval” or “monitoring” — you are doing one thing. Everything in this stage is building toward that.
The three-layer telemetry pyramid
Every session plugs into one of these layers. Volume falls and cost per signal rises as you climb. Click any layer.
One request, both disciplines
The same request produces both kinds of signal. This is the wiring you will build in S5.
A user asks your gateway to score an earnings excerpt
The FastAPI gateway assigns a request ID, tags the request with its slice labels
(intent=risk_score, tier=high, locale=en-US) and starts a
timer. Nothing has been measured yet — but the slice tags exist, and that decision, made
here, determines every question you can ask later. You cannot retroactively slice data you
never labelled.
The request becomes a tree of spans
The gateway opens a root span. Retrieval opens a child span. The call to your Gemma endpoint
opens a gen_ai.chat span carrying model name, token counts, temperature. A tool call
opens another. The tree is pushed to a collector as it completes. This is the
attribution layer — the only layer
that can tell you which step was slow or wrong.
Prometheus pulls counters and histograms
Every 15 s a scraper hits /metrics on the gateway and on vLLM. It gets cumulative
counters (tokens generated) and histogram buckets (TTFT). No individual request is visible here —
only the shape of the distribution. Cheap, permanent, aggregatable. Pull, not push.
A small slice of traffic is copied for judging
Deterministic checks (does the JSON parse? is risk_score in range?) run on
100% of responses because they cost microseconds. Then 1–5% of requests, stratified so
that high-risk slices are over-sampled, are queued for a judge — asynchronously,
off the user's critical path.
The judge emits a score, and the score is telemetry
A judge model scores faithfulness 1–5 against the source document. That score is attached back
to the original trace and also exported as a metric. OpenTelemetry now has a dedicated shape for
this: a gen_ai.evaluation.result event, parented to the span being evaluated.
This is the join between the two disciplines, standardised.
The dashboard shows both, side by side, on one time axis
p99 TTFT and mean faithfulness on the same board, with a deploy annotation line running through both. That vertical line is the whole point: it lets you ask “did the thing that made us faster also make us wrong?”
An alert fires — and it is allowed to be a quality alert
Faithfulness on the compliance-flag slice drops below its objective for 30 minutes. That pages someone, exactly like a latency breach would, with a runbook attached. Quality became an SLO. That is the finish line for this stage.
What you will be able to defend by the end
- Pick the right metric family for any LLM feature — and say why the other three are wrong for it.
- Build a golden dataset and size it statistically so a 5-point delta means something.
- Gate releases on it in CI with hard blockers, soft gates and canary gates.
- Implement LLM-as-Judge with position, verbosity and self-preference bias mitigated, calibrated against human labels with a chance-corrected metric.
- Stand up the full pyramid on your Gemma service: Prometheus + DCGM, OTel/Langfuse tracing, quality signals.
- Read GPU utilisation vs memory saturation correctly, and know why 98% util can still mean an idle GPU.
- Choose histogram vs summary, control cardinality, and write PromQL you can explain.
- Defend every dashboard panel, every alert threshold, and every burn-rate window you ship.
Which discipline owns this problem?
The map is only useful if you can place a real problem on it. This is the triage question you will ask dozens of times, and getting it wrong sends you to the wrong tooling for a day.
The five sessions
Source ledger & corrections to the plan
| Source | section as assigned | Actual title in the material | Correction |
|---|---|---|---|
| A Practical LLM Evaluation for Production Systems — Mohanna, Kar & Ralte (, June 2026) |
§1 | Foundations of LLM Evaluation: Core Concepts and Primitives | ✓ matches |
| §2 | Building Reliable Text-Only LLMs Through Training-Time Evaluation | ✓ matches | |
| §3 | Controlling Text-Only LLM Behavior at Inference Time | ✓ matches | |
| §4–6 “multimodal, out of scope” | §4 Grounding and Reliability in Vision Language Models During Training; §5 Evaluating Visual Grounding and Reliability at Inference Time; §6 Evaluating Multimodal Conversational LLMs Across Training and Inference | ✓ correctly scoped out. Note the material runs to §11 MoE routing computer-using agents document understanding reasoning models specialized systems. §8 and 10 are the natural follow-ons after your agents stage. | |
| B Generative AI on Kubernetes — applied guides |
§5 “Model Observability” | Model Observability (p.153) | ✓ matches. Sections: Observability Stack (logs/metrics/tracing) → Model Server Metrics (TTFT, TPOT, throughput, latency, queues) → GPU Usage Monitoring → Quality Metrics → Responsible AI → Model Safety & Guardrails. |
| C Generative AI Design Patterns — Lakshmanan & Hapke (, Oct 2025) |
§6 “LLM-as-Judge, Reflection and adjacent patterns” | §6. Improving Reliability | Numbering correction: the section carries four patterns, not three — 17 LLM-as-Judge, 18 Reflection, 19 Dependency Injection, 20 Prompt Optimization. S4 teaches all four; 19 and 20 are the ones people skip and then regret. |
Evaluation is a decision system, not a score
Everything in this session exists to answer one question: what evidence would change what you do? If a number can't change a decision, it isn't evaluation — it's reporting.
Why this session exists
10%A team ships an assistant after a demo that went beautifully. A week later a customer complains that the assistant confirmed a refund which was never processed. The reply sounded confident and correct. The model hadn't lied about anything it knew — it had simply skipped the billing lookup, and nothing in the pipeline checked whether the lookup had happened before the words “your refund has been issued” were allowed to appear.
Note what was not missing: the model was fine, the latency was fine, the service was up. What was missing was an evaluation process that could detect whether a required step had run, whether the final claim was supported by evidence, and whether high-risk cases should block a deploy.
That story sets up the first and most important reframe of the stage. You do not ship a model. You ship a system. The shipped behaviour is a function of the base model plus the prompt, the retrieved context, the tools, the policies, the output contract, and the decoding parameters. Change any one of them and the behaviour changes. Evaluate only the model and you are measuring a component that nobody uses.
Core concepts, from zero
50%A smoke detector is not a smoke measurement device. It is a chain: a sensor that produces evidence (particles per m³), a known uncertainty (it goes off when you toast bread), a threshold committed to in advance, and an action that follows automatically (a very loud noise). Nobody wants a smoke detector with a nice display and no buzzer. That is what a dashboard without thresholds is.
Evaluation is the buzzer, not the display.
Four objects, chained. Each has a distinct owner and a distinct failure mode.
The operating model, in one line: primitives feed evaluators → evaluators produce metrics → metrics are compared to thresholds → thresholds trigger actions.
Thresholds are product policy, not statistics. You set them from historical baselines, stakeholder risk tolerance, business SLOs, and legal constraints — then you revise them as evidence arrives. Getting them perfect on day one is not the goal; making the decision boundary explicit, reviewable and adjustable is.
Separate load-bearing from diagnostic metrics. Load-bearing metrics directly cause a ship/block/rollback. Diagnostic metrics explain why a load-bearing metric moved. A useful rule: two or three load-bearing metrics per production axis, everything else is debugging. When every metric is on the dashboard and none is tied to an action, the dashboard becomes noise and the team stops looking.
Where this breaks: thresholds set on aggregates hide concentrated failures. Overall violation rate 0.08% looks great until you notice all of it lives in the compliance-flag slice, which is 2% of traffic and 100% of your regulatory exposure. Gates must be slice-scoped or they are decorative.
Classical supervised evaluation assumes a stable map: one input, one correct output, compare, score. That works for a spam classifier. It breaks for a system whose job is to write a paragraph, because a paragraph has no single right answer, and it breaks again because the same input can produce different paragraphs on Tuesday.
“Single ground truth” stops existing
Two replies can share almost no wording and both be correct. Exact-match scoring punishes valid behaviour and rewards surface overlap. The fix is to stop asking “does this match the reference string?” and start asking “does this satisfy the task contract?” — did it avoid claiming an unverified action, did it ask for the missing identifier, did it hold the required format.
This is why behavioural checks, rubrics, and trace-based evaluators dominate over string similarity in production.
One run is not one measurement
Temperature, top-p, retries, tool responses and load all move the output. Run the same 50-case suite five times at temperature 0.7 and the mean helpfulness score wanders. A required tool call might fire in four runs out of five.
So the unit of evaluation shifts from an output to a distribution of outputs under a fixed configuration. Repeatability here does not mean identical text; it means bounded variance and stable decisions.
The model is not the biggest lever
A system-prompt edit routinely changes behaviour more than a model version bump. A retrieval index refresh changes factual accuracy with the model untouched. Behaviour depends on prompt × retrieval config × tool schema × policy version × decoding settings — a combinatorial space you cannot test exhaustively.
Consequences: risk-based prioritisation instead of full coverage, slice-based reporting instead of averages, and version everything — including data. A retrieval index refresh is a data change, not a config change, and if you don't version it you will blame the model.
The final text is the last visible step of a longer process
Even a lightly agentic system classifies, decides whether it needs external facts, calls a tool, applies a policy, then writes. The reply can look perfect while the process was wrong: wrong tool, invalid arguments, skipped check, swallowed timeout.
Score only the final text and every one of those stays invisible. Tool choice, argument validity, success rate, retry and fallback behaviour are all part of the evaluable output.
Versioning gives you attribution hypotheses, not causal proof. Knowing the retrieval config changed on the same day accuracy dropped is a lead, not a verdict — you still need a targeted rerun or ablation to confirm it. Teams that skip this step spend weeks “fixing” prompts to compensate for an index regression.
A restaurant that serves brilliant food in ninety minutes at £400 a head, occasionally with a stone in it, is not “working” because the food is brilliant. Quality is one axis of four.
Helpful, correct, complete, right format for the task.
Policy, privacy, risk. Unsupported claims. Data exposure.
Tokens, retrieval, unnecessary tool calls, judge spend, GPU-hours.
p95 latency, uptime, tool success rate, fallback behaviour, run-to-run stability.
Then map product goals onto measurable signals. “Don't claim a refund is
issued unless verified” becomes unsupported_action_claim_rate with scope
slice=refund and threshold = 0.
Trade-offs are not the problem; implicit trade-offs are. Longer prompts and deeper retrieval buy quality with cost and latency. Tighter safety policy buys risk reduction with over-refusal on benign requests. Restricting tool calls buys latency with correctness. Decide the exchange rate before the release meeting, or the release meeting becomes an argument about vibes.
Risk tiers change the shape of the threshold. Two kinds:
| Kind | Definition | Threshold shape | Fintech example |
|---|---|---|---|
| Zero-tolerance | Blocks release regardless of frequency | = 0, any occurrence | A compliance flag asserted with no supporting citation from the filing |
| Risk-budgeted | Tracked against an acceptable rate | ≤ 2%, per slice | Sentiment label off by one adjacent class on low-materiality paragraphs |
A flight recorder doesn't store the pilot's thoughts. It stores the observable event sequence, precisely enough to reconstruct what happened. That's the standard here: log enough to replay and attribute, not enough to violate anyone's privacy.
Two layers. System primitives are the parts of the product that get received, used, produced or recorded. Evaluation constructs turn those into decisions.
The minimum instrumentation boundary is the floor below which you can still make charts but can no longer run an evaluation programme: you will not be able to reproduce a failure, compare fairly, localise a regression, or turn an incident into a test.
Evaluator versioning is the one people forget. If the judge model, judge prompt, rubric or scoring code changes, scores move even when the system didn't. A helpfulness score can rise because the assistant improved, or because the judge got lenient. Without evaluator versions in the run record you cannot tell those apart — and the trend line looks continuous either way.
Privacy shapes the log. Store the observable event sequence, not hidden model reasoning. To evaluate the refund workflow you need: was the tool called, with what arguments, did it succeed, did the reply make a verified claim. That's enough. Chain-of-thought storage buys little and costs a lot in retention and exposure.
A driving test isn't a random sample of roads. It deliberately contains a hill start, a three-point turn and an emergency stop, because those are where people fail. A good eval suite is designed the same way: not representative on average, but complete across the situations that matter.
Then split suites by purpose, because a suite you iterate against cannot also be the suite you gate on:
| Suite | Used for | Churn | Who may edit |
|---|---|---|---|
| Development | Prompt iteration, debugging | High | Anyone |
| Release-gating | Fair version-to-version comparison | Low, versioned | Owner + review |
| Hold-out | Major release decisions only | Frozen; rarely inspected | Owner only |
| Exploration | Finding new failure modes | Very high | Anyone |
Synthetic data's self-reinforcing trap. If the same model family generates your cases and judges the responses, the generator produces cases the evaluator finds natural, and the system looks better than it is. Guardrails: keep a real-data core, sample synthetic cases from real traffic patterns, label synthetic cases separately, and report real and synthetic subsets as separate numbers.
Gold sets leak. Iterate against the same reference cases for three months and your prompt engineering has quietly optimised against those examples without improving general behaviour. This is the same phenomenon as benchmark contamination, applied to your own suite — S2 goes deeper.
Ownership or rot. A suite with no owner goes stale in a quarter: deprecated policies stay in, new intents never arrive, and the gate slowly stops measuring the product.
Four families of measuring instrument, in increasing order of cost and decreasing order of trustworthiness-per-dollar. The rule is simple: use the cheapest evaluator that reliably detects this failure mode, and escalate only when it can't.
Deterministic validators are rule-based checks on things that are objectively true or false: does the output parse as JSON, does a required field exist, is the tool argument the right type, did the policy-required tool call actually happen, does a forbidden claim appear without supporting evidence in the trace. Fast, cheap, stable, trivially debuggable. They are the backbone, and they stop you spending judge tokens on outputs that already violate a structural contract.
The honest limit of the whole family: these metrics look rigorous and mostly measure surface overlap. They are a poor default for open-ended generation. Use them where the target is well defined, or where you have evidence the metric correlates with human judgement on your task.
Metrics computed without a gold answer, by relating the output to something else you have — usually the input, the retrieved context, or the model's own probabilities.
These are the metrics that scale to live traffic, because live traffic has no labels.
Standardised public test sets with a fixed harness — MMLU, GSM8K, HumanEval, GPQA Diamond,
SWE-bench Verified, and so on, usually run through a harness like
lm-evaluation-harness or HELM.
The transfer rule: benchmarks are for model selection, never for shipping decisions. They can shortlist candidates and rule out models that can't do the basic task. They cannot tell you whether your configured product — with your prompt, retrieval, tools, policies, contracts, latency budget and traffic mix — is ready. Details and 2026 status in S2.
Human labels have a second job beyond scoring: they are the calibration set that tells you whether your judge is any good. You cannot skip them entirely, even in a fully automated pipeline.
| Family | Cost / 1k items | Latency | Stability | Catches | Blind to |
|---|---|---|---|---|---|
| Deterministic validator | ~$0 | <1 ms | Perfect | Contract breaks, missing evidence, forbidden claims | Anything requiring judgement |
| Reference metric | ~$0 | ms | Perfect | Label errors, extraction errors | Valid answers phrased differently |
| Reference-free (faithfulness by rule/NLI) | $0–$2 | 10–100 ms | High | Unsupported claims vs context | Style, tone, completeness |
| LLM judge | $2–$20 | 0.5–5 s | Medium, biased | Helpfulness, tone, nuanced policy | Its own biases; fluent-but-wrong |
| Human review | $200–$2 000 | hours–days | Medium, drifts | Everything, in principle | Scale; consistency without calibration |
Weigh yourself on a bathroom scale five times and you get five numbers. Nobody concludes they gained 300 g between readings. But teams do exactly that with eval scores — 8.10 → 8.18 becomes “the new prompt is better” and ships.
A regression is not “the metric went down”. It is four questions, all of which must be checked: is the change bigger than run-to-run variation? does it exceed the declared MMC? does it concentrate in a critical slice? does it cross a gate threshold?
Variance you can't remove, measure separately. Different sources need different fixes. Reply quality moving because the model sampled differently → decoding policy or a stronger prompt contract. Quality moving because a tool timed out → a reliability problem wearing a quality costume. Scores moving because the judge prompt changed → evaluator drift. Track tool failure rate, judge-score variance, fallback rate and timeout rate as their own signals, or all three failures look identical on the quality chart.
Controlling drift costs discipline, not money. Keep a small calibration set. When the judge changes, run old and new judge on the same calibration set before treating the new scores as comparable. For humans, run periodic calibration rounds and double-score a subset.
Offline evaluation is the pre-flight checklist. Canary gates are the first ten minutes after takeoff, watching the instruments. Online evaluation is the whole rest of the flight. All three run on the same aircraft, and findings from the last one rewrite the first one's checklist.
Gates come in three strengths. Each combines a metric, a scope, a threshold and an action:
| Gate | Fires on | Action | Tuning failure |
|---|---|---|---|
| Hard blocker | Zero-tolerance / catastrophic classes, slice-scoped | Release does not ship. No override without a written exception. | Too broad → the gate gets disabled “temporarily”, forever |
| Soft gate | Meaningful regressions that aren't catastrophic | Forces an explicit decision: accept the trade-off, mitigate, narrow the rollout, or block | No decision recorded → becomes a warning nobody reads |
| Canary gate | Live signals during staged rollout | Continue / pause / investigate / roll back / reduce exposure | Too sensitive → rollback fatigue; too loose → misses what it existed for |
Offline can't see production, and that's structural. Real users invent new intents, traffic mixes shift, tools degrade, policies change, indexes refresh, external services time out. Online evaluation has to catch two very different shapes: acute incidents (a visible spike — tool outage, violation jump) and slow-burn regressions (helpfulness down 2%/week; latency creeping as a backend degrades; refusals drifting up after a policy edit). Slow burns evade simple threshold alerts because no single day looks bad. You need trend monitoring over longer windows to see them at all.
The drift usually isn't the model. A new product tier creates a slice that fails badly while overall accuracy holds. A policy update changes what the right answer is. A tool schema change produces failures that look exactly like hallucinations. In all three cases the correct response is to update the suite and the slices — not to patch the prompt.
Too coarse and failures hide; too fine and evaluation gets expensive and noisy. Move up the ladder for product relevance, down for diagnosis.
| Level | Evaluates | Your fintech pipeline | Needs logged |
|---|---|---|---|
| Span | Extracted fields, entities | Extracted ticker, fiscal period, risk_score value | Structured output fields |
| Step | Intermediate decisions and actions | Router complexity classification; retrieval call; tool arguments | Trace events |
| Turn | One request, one response | Is this single analysis helpful, correct, safe, well-formed | Input/output pairs + rubric |
| Task | End-to-end completion of one job | Whole 10-K section → sentiment + risk + signal + flags | Task grouping ID |
| Workflow | A sequence of related tasks | Filing ingested → analysed → flagged → escalated to a human | Session/case ID across turns |
| System | Aggregate behaviour over many tasks | p95 latency, $/filing, cache hit rate, slice trends, incident rate | Production telemetry |
Granularity is a logging decision made in advance. You cannot evaluate tool-call correctness later if tool events were never recorded. You cannot evaluate workflow success if turns were never linked into sessions. You cannot debug drift if slice tags were never written. Choosing the level and choosing the log are the same choice.
Reality check
25%Worked example · how many eval samples do you need to detect a 5-point quality delta?
This is the calculation that decides whether your entire eval programme produces signal or theatre. Pass-rate evals are binomial: each case is a Bernoulli trial, pass or fail, and the score is the sample proportion p̂.
Step 1 — how noisy is a single run? The
standard error of a proportion is SE = √(p(1−p)/n).
A 5-point delta is smaller than half the width of the noise band on one arm. With n=100 you cannot distinguish an 80% system from an 85% system. You will still see the number move between runs, which is worse than seeing nothing, because you will act on it.
Step 2 — size for the delta you care about.
For comparing two proportions at 95% confidence and 80% power, the standard approximation is
n ≈ 2 × (zα/2+zβ)² × p̄(1−p̄) / Δ², with
(1.96+0.84)² = 7.85.
Step 3 — the move that actually rescues you. Nobody hand-labels 927 fintech cases. Three legitimate escapes:
- Make the metric less noisy. A binary pass/fail on a rubric with an anchored definition has far lower variance than a 1–10 subjective score. Coarse scales are not a simplification, they are a variance reduction.
- Use paired comparison. Run both variants on the same cases and test only the cases where they disagree (a McNemar-style paired test). Correlated arms remove most of the between-case variance, and required n drops by roughly an order of magnitude for highly-correlated systems.
- Stop trying to detect 5 points globally. Set your MMC per slice by consequence. Zero-tolerance slices don't need a power calculation at all — you need one violation to block, so the question becomes coverage, not sensitivity.
A May 2026 write-up on eval-set sizing runs exactly the calculation above and lands on the same shape of answer: detecting a 2-point delta around the 80% range needs roughly 6 300 examples per arm, a 4-point delta about 1 580, and a 7-point delta about 480. Its blunt framing — that 100 examples is a coin flip — matches the arithmetic above. The practical prescription is to put a confidence interval next to every number in a deploy note.
Eugene Yan frames the same problem as a release requirement rather than an A/B test: if your product requirement is a defect rate below 5%, and you observe 3% on 200 samples, the interval is roughly 3% ± 2.4% — upper bound 5.4%, which exceeds the requirement, so you cannot claim you met it. Double to 400 samples and the interval tightens to about 3% ± 1.7%, upper bound 4.7%, and now you can. This is the version of the argument that product managers actually accept, because it is phrased as “can we claim this yet”.
When the source material describes “regression gates: turning metrics into deployment decisions”, it describes
a capability, not a product. As of 2026 that capability ships: Langfuse released a GitHub Actions
integration (langfuse/experiment-action) in May 2026 that fails a workflow when
experiment scores fall below a threshold, which converts evaluation from a post-release review into
a deploy gate. In the same launch week Langfuse shipped Code Evaluators —
Python/TypeScript functions written in the UI that run deterministic checks (JSON parseability,
schema validation, required tool arguments) with no judge call and no token cost. That is the
“cheapest evaluator first” principle, productised.
Worth noting for your own build: Langfuse also shipped Score Analytics for measuring evaluator alignment (precision, recall, F1, accuracy) and baseline comparison for flagging a reference run. Those are the drift-control primitives from Concept 7, as product features.
gen_ai.evaluation.result), which §1's “trace storage” section predates.
S3 covers that in detail. Neither changes the framework; both reduce how much of it you write yourself.
Apply to my stack — lab
10%Target: your FastAPI gateway in front of Gemma E4B on Modal, scoring earnings and SEC documents. Deliverable: a golden set schema and a harness skeleton that produces a frozen, replayable run record.
4.1 · The golden fintech case format
Note what this schema encodes: slice tags, risk tier, expected behaviour rather than an expected string, and a per-case list of which evaluators apply. That last field is what stops you paying judge tokens for schema checks.
evals/golden/fin_risk_0007.yamlid: fin_risk_0007 source: {doc: "MSFT_10K_FY25", section: "Item 1A Risk Factors", span: [4120, 5230]} slice: {task: risk_score, sector: tech, doc_type: 10-K, length: long, locale: en-US} risk_tier: high # drives sampling depth, decoding policy, gate strictness input: excerpt: "<verbatim filing text>" expected: # reference-based: only where a single answer genuinely exists fields: risk_score: {min: 6, max: 8} # banded, not exact — the honest target compliance_flags: ["concentration_risk", "fx_exposure"] # behavioural: the contract, checked by rules not strings must: - every compliance_flag carries a char-offset citation into the excerpt - trading_signal is one of [buy, hold, sell, no_signal] - sentiment justification quotes <= 25 words from the source must_not: - assert a numeric figure absent from the excerpt - emit a trading_signal when doc_type == 10-K and section == "Item 1A" evaluators: [schema_v3, citation_binding, field_band, judge_faithfulness_v2] owner: you origin: incident-2026-06-14 # why this case exists
4.2 · Harness structure that survives contact with a real release
Four properties matter: frozen suite snapshot, explicit config (never hidden defaults), pluggable evaluators, and artifacts you can re-read six months later.
evals/harness/run.pyfrom dataclasses import dataclass, asdict import hashlib, json, time, uuid @dataclass(frozen=True) class SystemTuple: """Everything that can change behaviour. If it isn't here, you can't attribute.""" model: str # "gemma-4-e4b" model_endpoint: str # the Modal web URL, pinned per run serving_profile: str # your PROFILE switch: quant | prefix | best ... prompt_id: str prompt_version: str prompt_hash: str # sha256 of the rendered template retrieval_cfg: str index_snapshot: str # a data version, not a config version tool_schema_v: str policy_v: str decoding: dict # {"temperature":0.0,"top_p":1.0,"enable_thinking":false} samples_per_case: int suite_id: str suite_snapshot: str # content hash of the frozen case set evaluator_versions: dict code_commit: str class Evaluator: name = "base"; version = "0" kind = "deterministic" # deterministic | reference | judge | human def score(self, case, output, trace) -> dict: """-> {value, label, explanation, cost_usd}. Never raises; failures are results.""" raise NotImplementedError def run_suite(cases, tup: SystemTuple, evaluators, baseline=None): run_id = f"run_{uuid.uuid4.hex[:10]}" records = [] for case in cases: for k in range(tup.samples_per_case): out, trace = invoke(case, tup) # your gateway, not the raw model # cheap first: if the contract breaks, don't pay a judge for the semantics gate = [e for e in evaluators if e.kind == "deterministic"] res = {e.name: e.score(case, out, trace) for e in gate} if all(r["value"] for r in res.values): for e in evaluators: if e.kind != "deterministic" and e.name in case["evaluators"]: res[e.name] = e.score(case, out, trace) records.append({"case": case["id"], "k": k, "slice": case["slice"], "risk_tier": case["risk_tier"], "scores": res, "output": out, "trace": trace}) report = aggregate(records, bootstrap=1000) # per-slice means + 95% CI return {"run_id": run_id, "config": asdict(tup), "records": records, "report": report, "diff": diff_vs(baseline, report) if baseline else None}
QualityMonitor currently scores format compliance and collects user feedback — that
is one deterministic evaluator plus one human signal, with no suite, no slices and no run record.
Three concrete upgrades: (1) promote format-compliance scoring into an
Evaluator with a version field, so a change to it is visible in the run
record; (2) attach the slice dict to every request at the gateway
edge so every later metric can be sliced; (3) add
bootstrap=1000 to whatever currently reports a mean, and print the interval next to it.
That third one takes about twenty lines and changes more arguments than any other change in this stage.
Take your E2E baseline — 100% success, 91% cache hit, 3-model routing distribution across 11 queries — and answer one question honestly: what is the 95% confidence interval on a 100% success rate observed on n=11? Use the Wilson interval rather than the normal approximation, because the normal approximation degenerates at p̂ = 1. You should land near [74%, 100%]. Then write the one sentence you would put in a README to describe that result truthfully. That sentence is the deliverable.
Two clocks: before the model ships, and every request after
The same operating model from S1 — suites, evaluators, thresholds, actions — applied at two very different moments. Training-time gates stop bad behaviour hardening into weights. Inference-time controls stop everything else, and they run on every request forever.
Why this session exists
10%Two failure stories, one on each clock.
A fine-tune improves your risk-scoring accuracy from 78% to 91%. Everyone celebrates. Three weeks after deploy it's back at 74% in production. The eval set overlapped the training data at the paraphrase level — same filings, reworded. You measured memorisation and called it learning. Loss went down the entire time.
Nothing about the model changed. Someone shortened the system prompt to save tokens, and the phrase enforcing “cite the offset for every compliance flag” went with it. Flags keep appearing. They look identical in the response body. There is no longer anything behind them. Cost went down; the dashboards look great.
Neither failure is a model quality problem in the sense a benchmark would recognise. The first is a data hygiene failure that made your evidence worthless. The second is a missing control — a rule that should have been enforced by code, delegated to a sentence in a prompt.
Core concepts, from zero
50%Imagine reading a sentence aloud with one word covered, and betting on what's underneath. Perplexity is the average number of equally-plausible words you were choosing between at each step. Perplexity 2 means you were basically flipping a coin. Perplexity 50 means you were as lost as if picking from a 50-sided die.
Now the crucial part: this measures how surprised the model was by text. It says nothing about whether the model will follow your JSON schema, refuse an unsafe request, or refrain from inventing a revenue figure. A model can be beautifully unsurprised by financial prose and still confidently fabricate numbers in it.
Training loss for a language model is cross-entropy: the average negative log-probability the model assigned to the tokens that actually came next. Perplexity is just that number exponentiated, which puts it back on a “number of choices” scale humans can reason about.
Two consequences fall straight out of that formula, and both bite people:
- Perplexity is tokenizer-dependent. It is per-token, so two models with different tokenizers are not comparable on it. A tokenizer that splits words more finely gets a flattering number for free.
- Perplexity is corpus-dependent. “Perplexity 8” is meaningless without naming the held-out set. On your own domain corpus it is a genuinely useful relative signal; across papers it is nearly noise.
So the training-time practice is: treat loss monitoring as necessary but incomplete, and add cheap behavioural smoke tests the moment the model can produce coherent text — a handful of routing-style classification prompts, a structured-output adherence check, a couple of refusal-correctness probes. Those catch things loss curves are structurally blind to.
Checkpoint selection is where this gets expensive. The lowest-validation-loss checkpoint is frequently not the best product checkpoint. Loss keeps improving while instruction-following degrades, or while the model gets more verbose (which costs you money at inference forever). Select on a small behavioural scorecard, with validation loss as a sanity-check rather than the objective.
What loss is good for: detecting divergence, detecting a data-mix shift mid-run, and confirming the tokenizer and formatting assumptions are what you think they are. A sudden loss discontinuity almost always means a data problem, not a learning-rate problem. The right action is to pause the run and inspect the mix, not to lower the LR and hope.
Where it breaks entirely: loss on data the model has memorised is meaninglessly low. Contamination and perplexity interact badly — which is the next concept, and it is the one that invalidates evidence rather than merely being unhelpful.
You cannot inspect a cake for flour quality. Everything you failed to check about the ingredients is now distributed irreversibly through the result. Data gates run before mixing because afterwards the only remedy is to start over.
Where did this come from, and does it belong here?
Provenance is a licence question, a privacy question and an attribution question at once. Its practical payoff at eval time is that duplication and toxicity metrics computed per source are actionable, while the same metrics computed globally are not. “Overall toxicity 0.4%” tells you nothing; “source 7 contributes 80% of it” tells you what to drop.
Exact duplicates aggressively, near-duplicates carefully
Repetitive content inflates apparent capability while increasing memorisation and reducing generalisation. Exact duplicates should just go. Near-duplicates are subtler and more dangerous in instruction data, because templated examples look diverse on the surface — same skeleton, different slot values.
Symptom in a deployed assistant: it always opens with the same sentence, or always reaches for the same category phrasing even when the intent shifted slightly. That's template memorisation, and it traces back to here. Compute duplication per source and per slice so the fix stays targeted instead of a blunt filter that throws away long-tail coverage.
Screening is slice-aware and source-aware, or it's decoration
The question is not “how toxic is the corpus” but “where does
unsafe language concentrate, and does that overlap with my high-risk slices?” For a fintech
assistant the analogue of the classic account_access danger zone is anything that
normalises unsupported financial assertions or “helpful” regulatory workarounds. Training
content that models confident unhedged claims will surface later as confident unhedged claims.
Format noise in training becomes format failure in production
If your structured-output examples are inconsistent — sometimes fenced, sometimes not, sometimes with a trailing comment, field order shuffled — the model inherits that instability and emits almost-valid JSON at 2 a.m.
Two cheap checks that eliminate a whole class of production breakage: parse-validity rate and required-field-presence rate across the training examples themselves. Run them on the dataset, not just the model.
The quiet one: instructions that fight themselves
Conflicting instructions inside a single example, unclear constraints, ambiguous success criteria, hidden goals competing with the visible task. The useful artifact is a short rubric that categorises instruction-quality failures, plus a sampling plan that over-samples high-risk instruction subsets. You are not auditing everything; you are auditing where a bad instruction becomes an incident.
Coverage is slice completeness, not volume. More tokens do not fix a missing slice. The artifact worth building is a slice matrix — intent × risk tier × locale — with counts in the cells, owned and maintained. Then define an edge-case budget: an explicit tolerance for failures in rare slices, set by severity. Low-severity long-tail failures can be tolerated at some rate. High-severity ones in your compliance slice get near-zero tolerance, and that decision is what stops “average helpfulness improved” from being an acceptable answer when unsafe compliance also rose.
A student who has seen the exam paper scores brilliantly. The score is real; the inference you want to draw from it is not. Contamination doesn't make your model worse — it makes your measurement worthless, silently, while every number improves.
Leakage is a spectrum, not an event. Three levels, each needing a different detector and a different response.
| Level | What it is | Detection | Why it's dangerous | Response |
|---|---|---|---|---|
| Exact overlap | Same document / prompt / QA pair in both train and eval | Hash match on normalised text, at document and example level | Model can memorise surface form outright | Always high severity. Remove, regenerate the affected eval slices, rerun gates. If it touched a gate-driving set, block progression until the rerun passes. |
| Near-duplicate | Structurally identical, small substitutions — the templated case | Fuzzy match on normalised text (lowercase, whitespace, boilerplate stripped) | Inflates perceived robustness. The model learned the template, not the skill. | Severity depends on slice. Concentrated in a critical slice → dedupe or reweight the source, refresh that slice, rerun. |
| Paraphrase overlap | A rewrite: different wording, same intent and structure | Embedding-similarity scan — expensive, so run it only on high-risk slices and gate-driving sets | Hardest to detect, and it destroys exactly the property you needed: stability under rephrasing | Treat as a reliability risk. Strengthen the eval set with fresh paraphrases and holdouts, add robustness gates, rerun before progression. |
The gating shape, as pseudocode — this is the artifact, not the algorithm:
Treat leakage as a property of a slice, not of a dataset. Small overlap in a low-risk slice during early development is survivable. Overlap in your compliance slice is release-blocking, because that is the slice where you were relying on the measurement most.
Your internal suites are benchmarks too. Benchmark contamination isn't only about public leaderboards. If a training or synthetic-generation dataset ever includes prior versions of your regression suite, your gates weaken every cycle. The model memorises the test and you gradually lose the ability to detect real regressions — while every dashboard says things are improving. Contamination control is not research hygiene; it is a release-readiness requirement.
The output is a versioned artifact. The point isn't which similarity method you pick. It's that the process produces a contamination report, attached to a release, that can block progression. Without the artifact, detection is a one-off analysis someone did once and nobody can find.
“Did it follow instructions?” is like asking whether a contractor did a good job. Did they build the right thing, did they follow building regulations, and did they hand over the paperwork in the required format? Three questions, three failure modes, three different fixes — and they fail independently.
Measured separately, a regression tells you which layer to repair. Merged into one “quality” number, it tells you to rewrite everything. A starter micro-suite of 10–15 cases across your core intents is enough to expose brittle instruction-following before anyone runs a large review cycle.
Structured output deserves its own axis because it predicts operational reliability. Track parse-success rate, required-field-presence rate, field-type validity, allowed enum values, and constraint-violation flags — all deterministic, all cheap, all before any judge runs. This is the single highest-leverage set of checks in the whole training-time programme.
Not every constraint reduces to a rule. For the ones that don't, use a short anchored rubric — pass / borderline / fail — plus a severity field, rather than a numeric score. “Borderline” is not indecision; it is a genuine category meaning “met the intent and the format but ambiguous on a constraint”, and it routes to human review instead of being rounded into a mean.
Optimise a customer service team purely on “no complaints” and you will eventually get a team that refuses to do anything interesting, at length, very politely. Preference tuning shifts the whole output distribution, and the three things it shifts by accident are refusal behaviour, diversity, and length.
Over-refusal is a slice problem, not a global one. A global refusal rate hides the signal, because what you care about is correctness of refusal. The taxonomy that makes it measurable:
| Outcome | Meaning | Where you want it |
|---|---|---|
| Correct refuse | Request is disallowed; model refuses and gives a safe alternative | High in high-risk slices |
| Should comply | Request is allowed; model refuses anyway | Near zero in low-risk slices — this is the over-refusal metric |
| Partial comply | Helps but omits key steps, or bolts on unnecessary refusal language | Low everywhere; it's the polite failure |
| Evasive | Avoids without being helpful or clear | Zero. This is the one users hate most. |
Gate the stage on all four together, or improving one silently pays for it with another.
Extra tokens need a justification. If verbosity rises without improving constrained helpfulness, safety recovery, or high-risk correctness, it is drift, not quality — and it will show up on your infrastructure bill and your p99 latency chart months later, at which point nobody will connect it to a preference-tuning run.
Five things from the training clock that will pay off directly when you fine-tune Gemma, ranked by return:
- Contamination scanning against your golden set — build this first. The moment you have both a fine-tune corpus and an eval set, run exact + near-dupe across all of it and a paraphrase scan on the high-risk slices. Everything downstream depends on this evidence being clean.
- Format-consistency checks on the training data itself. Your pipeline emits structured fintech objects. Inconsistent exemplars are the single most common cause of almost-valid JSON in production, and it is trivially preventable here.
- p95 output length as a first-class gate. You are on an L4 with a KV-cache budget you already had to tune down to 0.92 utilisation. Verbosity drift is not abstract for you; it costs you concurrency.
- Checkpoint selection on a behavioural scorecard, not validation loss. Build the scorecard before you start the run, not after.
- Model card + dataset card + contamination report as release artifacts. Not bureaucracy — this is what lets you reconstruct, six months into an incident, why the model shipped and what evidence supported it.
A factory doesn't inspect finished products and write reports. It puts gauges, interlocks and shutoffs in the line, so the expensive failure is prevented by design rather than discovered afterwards. At inference time you are not evaluating a model call — you are evaluating a pipeline, and failures live in the seams between steps.
Split the request into two coupled threads. The user-facing flow runs from input to rendered answer. The execution flow records retrieval hits, tool calls, failures, retries and verification flags. The rule that prevents the whole class of confident-lie failures is: the user-facing reply may only assert what the execution thread proved.
Production assistants fail on structure constantly: almost-valid JSON, a missing field, a category outside the allowed set. These break downstream automation and pollute your evaluation, because a judge will happily score an unusable reply as helpful.
A contract is a control layer, not a quality metric. Three rules:
- Validate structure, not semantics — is it parseable, are required keys present, are types right, are enums legal.
- Repair only when strictly syntactic and safe. A trailing comma, yes.
Guessing a missing
risk_score, absolutely not. - Fall back when meaning is ambiguous, and cap the retries, because every retry buys structure with tail latency and cost.
What passing guarantees: the output is structurally usable. What it does not guarantee: the category is correct, the reply is safe, the tool calls were appropriate. Keeping those separate is what makes debugging fast.
Decoding parameters amplify or damp variance. In high-risk slices variance is a risk multiplier — it can flip a refusal or a verification claim. So make decoding an explicit, auditable policy rather than a default someone set once:
Then measure stability the right way. Not string similarity across repeated runs — that penalises harmless rewording. Measure decision agreement on the fields that matter: did the category stay the same, did the refusal type stay the same, did a verification-sensitive claim appear or not. High-risk slices must show high decision agreement; low-risk slices can tolerate less, as long as safety and cost hold.
Bind claim classes to evidence flags. If the flag is false or absent, the claim language is not permitted to appear — full stop, enforced in code:
The power comes from pairing it with the trace: the flag is recorded by the execution thread, so the gate does not depend on the model's self-report. When the rate is non-zero, the action is not “tune temperature”. It is: block the response, serve a safe template, and open an incident that becomes a regression test.
Log document IDs and ranks; watch rank instability and coverage drops per slice. Misattributing a retrieval regression to the model is one of the most expensive debugging mistakes available.
| Budget | Bounds | Symptom when it's missing |
|---|---|---|
| Token budget | Generation length | p99 latency owned by a handful of runaway generations |
| Retry budget | Contract repairs + tool retries | Tail latency triples during a partial outage; nobody knows why |
| Timeout budget | Tool calls | A slow dependency silently becomes your latency |
| Judge budget | Online grading spend | Eval costs scale with traffic and surprise you monthly |
| Error budget | Reliability degradation over time | No principled way to say “stop shipping and fix things” |
Once budgets exist, performance analysis becomes causal: p95 rose because retries rose, and retries are a quantity you control.
Four drifts, four different mitigations. Naming them correctly is most of the work:
| Drift | Signal | Action |
|---|---|---|
| Traffic drift | Intent-mix divergence vs baseline | Inspect new traffic, add slices, add cases — your offline suite is no longer representative |
| Tool drift | Rising tool failure rate or latency | Reliability work, not prompt work |
| Retrieval drift | Coverage drop, rank instability | Index / ranking debugging; expand the retrieval eval set |
| Policy drift | Behaviour no longer matches updated policy | Update the expected behaviour in the suite — the model may be fine and the test wrong |
A simple, honest traffic-drift detector: compare the intent distribution of recent traffic against a baseline and flag when total variation distance exceeds a threshold.
And the promotion ladder: shadow (candidate runs on real traffic, output discarded, behaviour compared — zero user impact) → canary (small share of live traffic, hard blockers and canary gates armed) → full ramp only when the gates hold. Rollback criteria and their owner are written down before the rollout starts, precisely so nobody debates them during an incident.
Prompts are the highest-frequency lever and the least governed. A small edit can
change routing, refusal behaviour, verbosity and tool calling. Three things make prompt changes
safe: log PROMPT_ID, PROMPT_VERSION and a template hash on every request;
categorise changes by risk so you know what regression scope they demand; keep diffs reviewable and
reversible. The dangerous edits are not the ones that change wording — they're the ones that change
constraints, output contracts or tool instructions, and those should automatically trigger
full high-risk-slice regression.
Every control costs something. Contract validation costs microseconds. Bounded repair costs a retry. A guardrail model costs a second full inference on the whole conversation, because safety assessment can't be done token by token. Low-temperature decoding on high-risk slices costs you response diversity users sometimes like. The design job is picking which controls earn their price per slice, not applying all of them everywhere.
Reality check
25%Worked example A · what a perplexity improvement is actually worth
A domain-adaptation run on SEC filings takes held-out perplexity from 12.4 to 10.1. The slide says “18.5% improvement”. Convert it to something meaningful.
Worked example B · verbosity drift is a cost regression in disguise
Suppose a tuning run raises mean output from 220 to 265 tokens and p95 from 480 to 620. Your workload: 10 000 requests/day on your L4 profile.
the source material recommends a language model evaluation harness for pre-deployment capability assessment — still correct as tooling. What has moved is which benchmarks carry signal. By 2026 MMLU sits around 88–93% for frontier models, a spread narrow enough that differences fall inside measurement noise; HellaSwag is above 95%; original GSM8K is effectively solved at the top tier. Analyses through 2026 converge on the same advice: keep the classics only for continuity and regression checking, and use contamination-resistant sets — GPQA Diamond, Humanity's Last Exam, SWE-bench Verified, LiveCodeBench, and fresh annual exams like AIME — when you need a benchmark to actually discriminate.
Note also the harness-dependence problem: the same model weights can score materially differently depending on the evaluation harness, which is one more reason a benchmark number in a vendor table is not a measurement of your system.
the source material's contamination section is conceptual. There is now a formal statistical definition to point at: ConStat reframes contamination as an unusual performance lift on a benchmark relative to a related reference benchmark, measured against a panel of reference models — producing a p-value and an estimated effect size in accuracy points, rather than a hand-wave. Published detections included widely-used base models on HellaSwag, GSM8K and ARC-Challenge, with effects in the 3–8 point range, plus the top three models on a major open leaderboard at submission time.
The operational takeaway for you: “this model is popular and scores well” is not evidence of cleanliness. Base-model contamination propagates into every fine-tune built on it.
the source material's runtime-guardrails section describes composing a guardian model into the request flow with custom orchestration code, and notes ongoing work to fold it into gateway components. That work landed. NVIDIA's NeMo Guardrails now ships both as an open-source Python library and as a production microservice sharing the same configuration model, with input / dialog / retrieval / execution / output rails, deployable via the NIM Operator on Kubernetes. The piece that matters for this stage: it emits OpenTelemetry — end-to-end distributed tracing with context propagated across guardrails, inference and downstream services, plus metrics for policy evaluations.
That is the S2 → S3 handoff made concrete: a control that produces a signal, in a standard format, that your observability stack can alert on.
- the source material, “Quality Metrics”: names OpenAI Evals, LangSmith and Arize as the
structured-evaluation options. The current open-source shortlist is broader and more specialised —
lm-evaluation-harnessfor base-model benchmarking, Ragas for RAG-specific metrics, DeepEval for pytest-style CI gates, promptfoo for red-teaming and multi-model comparison, and Phoenix or Langfuse when you want tracing and evaluation in one stack. Most mature programmes run two in parallel: a lightweight framework that blocks bad deploys, plus a platform for monitoring and human review. - the source material's judge example names a specific frontier model as the judge. Treat all named models in both sources as illustrative — the lineup has turned over. Judge choice is a versioned config value in your harness, not a fact about the world.
- the source material on guardian model sizes ("biggest is about 7B or 8B") is a moving target and shouldn't be planned against.
- is June 2026 and holds up; its contamination taxonomy predates none of the above. The one thing it doesn't cover is that eval scores now have a standard telemetry shape — S3.
Apply to my stack — lab
10%4.1 · Risk-tiered decoding, wired to your enable_thinking switch
Your Modal deployment exposes a per-request thinking toggle. That is a decoding-policy dial with a quality/latency price, so it belongs in the risk-tier table rather than being set ad hoc by callers.
gateway/policy.py# One table. Auditable. Logged on every request as part of the system tuple. DECODING_BY_RISK = { # compliance flags / risk scores: stability beats creativity, always "high": {"temperature": 0.0, "top_p": 1.0, "enable_thinking": True, "max_tokens": 900}, # risk scoring on medium-materiality sections "medium": {"temperature": 0.2, "top_p": 0.9, "enable_thinking": False, "max_tokens": 700}, # sentiment / summarisation: variance is cheap here "low": {"temperature": 0.4, "top_p": 0.95, "enable_thinking": False, "max_tokens": 400}, } def decision_agreement(samples: list[dict], fields=("risk_score_band", "trading_signal", "compliance_flag_set")) -> dict: """Stability measured on DECISIONS, not on string similarity. Gate: high-risk slices must hold >= 0.95 agreement at k=5.""" out = {} for f in fields: vals = [tuple(s[f]) if isinstance(s[f], list) else s[f] for s in samples] modal = max(set(vals), key=vals.count) out[f] = vals.count(modal) / len(vals) return out
4.2 · The contamination scan you run before your first fine-tune
Two cheap tiers on everything, one expensive tier on the slices that gate releases. The output is a signed report attached to the run, not a notebook cell.
evals/contamination/scan.pyimport hashlib, json, re from datasketch import MinHash, MinHashLSH # tier 2: near-dupes at scale def norm(t: str) -> str: t = t.lower t = re.sub(r"\s+", " ", t) for boiler in BOILERPLATE: # SEC headers, page furniture, disclaimers t = t.replace(boiler, "") return t.strip def tier1_exact(train, evalset): h = {hashlib.sha256(norm(x["text"]).encode).hexdigest for x in train} return [e["id"] for e in evalset if hashlib.sha256(norm(e["input"]["excerpt"]).encode).hexdigest in h] def tier2_near(train, evalset, thr=0.90): lsh = MinHashLSH(threshold=thr, num_perm=128) for i, x in enumerate(train): lsh.insert(f"t{i}", mh(norm(x["text"]))) return [e["id"] for e in evalset if lsh.query(mh(norm(e["input"]["excerpt"])))] def tier3_paraphrase(train, evalset, thr=0.85): # expensive: embeddings. Run ONLY on high-risk + gate-driving slices. hi = [e for e in evalset if e["risk_tier"] == "high"] return [e["id"] for e in hi if max_cosine(e, train) > thr] def report(train, evalset) -> dict: r = {"exact": tier1_exact(train, evalset), "near": tier2_near(train, evalset), "para": tier3_paraphrase(train, evalset), "train_snapshot": snapshot_hash(train), "eval_snapshot": snapshot_hash(evalset)} r["blocking"] = bool(r["exact"]) or bool(r["para"]) \ or len(r["near"]) > 0.02 * len(evalset) return r # commit this next to the model card. It is release evidence.
4.3 · The prompt-governance three-liner
The cheapest high-value change in this whole session. Without it, every quality movement is unattributable.
gateway/prompts.pyPROMPT_ID = "fin_analyst_v4" PROMPT_VERSION = "4.2.0" PROMPT_TEMPLATE = (...) PROMPT_HASH = hashlib.sha256(PROMPT_TEMPLATE.encode).hexdigest[:16] # Emit all three on every span AND every metric exemplar. # Then a change in faithfulness has a suspect list of length one.
/metrics and your ObservabilityManager tracks
per-request cost. Three additions turn it into a control surface:
(1) a Pydantic output contract with bounded repair — validate, repair only syntactic
damage, cap at one retry, fall back to a safe template and record which path was taken as a labelled
counter; (2) the citations_ok evidence flag computed in the execution
thread and read by a claim gate before the response is released; (3) retry and
timeout budgets as explicit counters so a latency spike is attributable to a controlled quantity
rather than a mystery.
Take your PROFILE switch (worst /
baseline / best / prefix / quant) and run your
golden set against two of them — prefix (bf16) and quant (FP8 weights and
FP8 KV cache). You already know the throughput delta. The question this session asks is different:
did FP8 quantisation move any decision field? Compute decision agreement between the two
profiles on risk_score_band, trading_signal and
compliance_flag_set, per risk tier. If high-risk agreement is below 0.95, you have
bought throughput with correctness and you now have the number to prove it — which is exactly the
kind of trade-off S1 said must be explicit rather than implicit.
Instrumenting a token machine
Kubernetes will happily tell you the pod is Running, the GPU is busy and the API is answering health checks, while every user waits eleven seconds for a first token. This session is about the signals that actually describe an LLM's behaviour, and why the ones you inherited from web services don't.
Why this session exists
10%Three things make LLM serving different from every microservice you've monitored before, and each one breaks a standard assumption:
You have built toy versions of all of this. What follows is not “here is Prometheus” — it is what your toys got wrong, and what changes when the thing is real.
Core concepts, from zero
50%A hospital has a vitals monitor (continuous numbers, cheap, no story), a patient chart tracing one person through admission → theatre → recovery (expensive, complete, one person), a nurse's notes (unstructured, searchable, verbose), and a diagnosis (a judgement about whether the outcome was good). Metrics, traces, logs, quality. Asking a vitals monitor which surgeon was slow is a category error.
| Signal | Shape | Transport | Cardinality | Answers | Cannot answer |
|---|---|---|---|---|---|
| Metrics | Aggregated numbers over time | Pull — a scraper hits /metrics | Must stay low | “Is it slow in general? Is it getting worse?” | “Why was this request slow?” |
| Traces | Tree of timed spans per request | Push — the app exports to a collector | Sampled, high detail | “Which step consumed the 4 seconds?” | “What's the 99th percentile over 30 days?” |
| Logs | Timestamped text events | Written to stdout, shipped | Unbounded | “What exactly did the engine say when it broke?” | Anything aggregate, cheaply |
| Quality | Scores attached to outputs | Both — event on the span, plus a metric | Sampled | “Was the answer any good?” | “Is the service up?” |
The pull/push distinction is not trivia. Metrics are pulled, which means the scraper controls the rate and a dead target is visible as a failed scrape. Traces are pushed, which means the application controls the rate and a dead application is simply silent. That difference determines what you can build an alert on — and it is exactly the property that a scale-to-zero serverless endpoint breaks. Hold that thought for the lab.
Kubernetes gives you neither metrics nor tracing natively; both are
conventions layered on top. For metrics the de-facto standard is Prometheus with the OpenMetrics
exposition format — a CNCF project that standardised and extended the original Prometheus text
format while keeping backward compatibility. For tracing it's OpenTelemetry. Logs in Kubernetes go
to stdout/stderr and land in a node file, which makes kubectl logs easy and long-term
retention someone else's problem — you add Loki or equivalent, or you lose them on pod churn.
A car has an odometer (only goes up, resets when you replace it), a speedometer (goes up and down, reading now), and — if you wanted p95 speed — you'd need to have been recording how long you spent in each speed band. That last one is the histogram, and it is the one people get wrong.
rate over it, and the query engine handles the reset. Tokens generated, requests completed, errors._sum and _count. Quantiles are computed at query time by interpolating within a bucket.Histogram vs summary is the decision, and there is a right answer for LLM serving.
| Histogram | Summary | |
|---|---|---|
| Quantile computed | At query time, from buckets | In the process, at observation time |
| Aggregatable across replicas? | Yes — buckets add | No — you cannot average two p95s and get a p95 |
| Accuracy | Bounded by bucket width | Exact-ish, per instance |
| Cost | One time series per bucket | Cheap on storage, CPU in-process |
| Arbitrary quantiles later? | Yes — ask for p99.9 next year | No — only what you pre-configured |
With two Modal replicas, a summary gives you two p95s and no legal way to combine them. That alone settles it: histograms for anything you will alert on. vLLM agrees — TTFT, time-per-output-token and end-to-end request latency are all exported as histograms.
The query, and the trap inside it:
Bucket boundaries are a design decision you make once.
If your TTFT buckets top out at 2.5 s and reality is 9 s, everything above 2.5 s lands in
+Inf and histogram_quantile extrapolates — your p99 becomes fiction that
looks like data. Pick buckets that straddle your SLO threshold with resolution on both sides.
Cardinality is the thing that takes your monitoring down. Every unique combination of label values is a separate time series, stored and indexed independently. The multiplication is silent until it isn't:
The rule: labels are for values from a small, bounded, slowly-changing set. Anything unbounded — user ID, request ID, prompt text, document ID, error message — belongs in a trace or a log, never a metric label. If you need to jump from a metric spike to the individual requests behind it, that's what exemplars are for: a trace ID attached to a sampled histogram observation, giving you one click from the chart into a trace, without putting the ID in the label set.
Two phases, two completely different bottlenecks, and users feel them differently. Prefill reads the whole prompt and produces the first token — that's the pause before anything appears, and it is compute-bound. Decode produces every subsequent token one at a time — that's the speed of the text scrolling, and it is bound by memory bandwidth. A single “latency” number averages these two unrelated things into a number that describes neither.
The metric names, in both vocabularies. You will see both in the wild, so know the mapping:
| What | Type | vLLM name | OTel GenAI convention | Read it as |
|---|---|---|---|---|
| Time to first token | Histogram (s) | vllm:time_to_first_token_seconds | gen_ai.server.time_to_first_token | Perceived wait. Dominated by queue + prefill. The metric for interactive UX. |
| Time per output token (a.k.a. inter-token latency) | Histogram (s) | vllm:time_per_output_token_seconds | gen_ai.server.time_per_output_token | Streaming smoothness. Humans read ~3 words/s, so roughly 4–5 tok/s is the floor for “no perceived delay”. |
| End-to-end latency | Histogram (s) | vllm:e2e_request_latency_seconds | gen_ai.server.request.duration | Total. Correlated with the two above but useful for trends and for non-streaming callers. |
| Throughput | Counters | vllm:prompt_tokens_total, vllm:generation_tokens_total | — no recommendation | Real system load. Generation tokens/s alone is usually a good enough load indicator, since decode dominates wall-clock. |
| Queue pressure | Gauges | vllm:num_requests_waiting, vllm:num_requests_running | — | Waiting > 0 sustained means you are at capacity. This is your scale-out signal and your leading indicator for TTFT. |
| KV cache | Gauge + counters | cache-usage gauge; prefix-cache queries and hits counters | — | Cache near 100% means preemption is imminent. Hit rate is your prefix-caching payoff, measured rather than assumed. |
Note the shape of that last row: vLLM V1 moved from exporting a prefix-cache hit-rate gauge to exporting queries and hits counters. That is strictly better — you can compute a rate over any window, instead of being stuck with whatever window the server chose.
PrometheusRule example alerts on
max_over_time(time_per_output_token_seconds[5m]) >= 0.3. Two problems today:
the metric name is missing the vllm: prefix, and max_over_time on a
histogram metric name isn't a valid selector — you want
histogram_quantile over the _bucket series. vLLM's V1 engine also
deprecated or removed several legacy metrics, including the swap-based preemption metrics (the
--swap-space flag is gone) and the hit-rate gauge mentioned above. The material's
structure is right — expression, severity labels, a linked runbook URL — and that
runbook link is the part most teams skip and most regret at 3 a.m.
Verify names against your build. Metric names and
semantics change between vLLM releases; you are on 0.21.0. Before copying any query into
production, curl your own /metrics and grep. This is a thirty-second
check that prevents a dashboard that renders beautifully and shows nothing.
Prometheus is a meter reader that walks a fixed round every fifteen seconds. It needs a stable address and a target that's home. Both of those assumptions get interesting in Kubernetes, and one of them is simply false on serverless.
In Kubernetes the wiring is declarative. A ServiceMonitor (or
PodMonitoring on Google's managed service) selects targets by label and sets the
scrape interval; the container just exposes /metrics. For KServe you don't write the
annotations by hand — the controller does it, from annotations on the ServingRuntime
and InferenceService:
qpext) that scrapes every container and exposes one merged endpoint; enable it with
serving.kserve.io/enable-metric-aggregation. In Standard mode the pod is a single
container and you don't need it. If you move your Gemma service to KServe on GKE, this is the
setting that decides whether your queue metrics exist.
Managed collection follows the same shape with the operational burden removed. Google Cloud Managed Service for Prometheus runs collectors as a DaemonSet that scrapes only co-located targets and pushes to Google's backend — you keep PromQL and your existing instrumentation, and you stop running Prometheus servers. There are even predefined dashboards for the LLM serving path: Google documents managed-collection exporters for GKE Inference Gateway, vLLM and llm-d.
Your Gemma service runs on Modal with min_containers=0,
max_containers=2 and a 5-minute scaledown window. vLLM does expose
/metrics on the same port Modal publishes, so the URL is reachable. Three things
then go wrong, and none of them are Modal's fault — they are what pull-based metrics assume:
- Scraping keeps the container alive. A 15-second scrape means the scaledown window never elapses. You have just converted a scale-to-zero deployment into an always-on GPU bill, using your monitoring.
- Counters reset on every cold start.
ratehandles resets, but the gaps between them are real data loss, and cumulative panels will sawtooth. - Two replicas behind one URL. Modal load-balances, so successive scrapes hit different containers. The series are interleaved with no distinguishing label — gauges become nonsense and histogram buckets are a blend of two engines.
The design that works: make the gateway the
source of truth for everything you alert on. Your FastAPI layer sees every request, never scales
to zero, and can attribute correctly. It measures TTFT, e2e latency, tokens, cost, error class
and quality — all with proper labels. Treat vLLM's /metrics as best-effort
per-replica engine telemetry, scraped opportunistically for capacity work (KV-cache pressure,
queue depth, prefix-cache hits), and never as an SLO source. The lab implements this.
A motorway can be “100% occupied” with cars crawling at 8 km/h. Occupancy
and throughput are different measurements, and the first one is the one that's easy to collect.
DCGM_FI_DEV_GPU_UTIL tells you the GPU was busy during the sample window. It
does not tell you it was doing useful work efficiently, and during decode it is routinely near 100%
while the compute units are mostly idle, waiting on memory.
Every vendor uses the same architecture: a management component collects from the device, an
exporter presents it on /metrics in Prometheus format. NVIDIA's is DCGM with
dcgm-exporter, deployed as a DaemonSet on GPU nodes (port 9400), or provisioned
automatically by the GPU Operator. AMD and Intel ship equivalents; there is no common
naming convention across vendors, which matters if you ever run mixed fleets.
The metrics that matter, grouped by the question they answer:
| Question | Metric | How to read it on an L4 running Gemma |
|---|---|---|
| Is the GPU busy? | DCGM_FI_DEV_GPU_UTIL | Occupancy, not efficiency. High during decode almost by definition. Weak signal alone. (Also known to be a heavier metric to collect than most.) |
| Is it doing real compute? | Profiling metrics — SM activity / occupancy, tensor pipe active | The honest efficiency signal. Low SM activity with high util = memory-bound, i.e. normal decode. |
| Is memory the bottleneck? | DCGM_FI_DEV_MEM_COPY_UTIL | Memory-controller utilisation. During decode this is the number that's actually pinned. |
| Am I about to OOM? | DCGM_FI_DEV_FB_USED / FB_FREE / FB_TOTAL | Framebuffer (VRAM). You already learned this the hard way: 0.95 gpu-memory-utilization OOM'd the KV cache on your L4 and 0.92 left headroom. This is the metric that would have told you first. |
| Is the hardware sick? | DCGM_FI_DEV_XID_ERRORS, ECC single/double-bit, retired pages | XID codes and uncorrectable ECC precede real failures. These are the silent-failure detectors nvidia-smi won't show you. |
| Is it throttling? | DCGM_FI_DEV_GPU_TEMP, MEMORY_TEMP, POWER_USAGE | Thermal or power throttling shows up as unexplained TPOT degradation with no change in load. Correlate before blaming the model. |
The diagnostic combination, and the reason you collect more than one: GPU util high + memory-copy util high + SM activity low = memory-bound decode, which is the expected steady state and means you should be looking at batch size and KV cache, not at the model. GPU util high + SM activity high = compute-bound, which for an LLM means you are prefill-dominated and should look at prompt lengths and chunked prefill.
On Modal you do not run a DaemonSet, so DCGM is not available to you today — GPU telemetry is one of the concrete things you gain by moving to KServe on GKE, alongside the GPU Operator provisioning the exporter for you. Design the dashboard so the GPU row degrades gracefully: build the panels against DCGM metric names now, accept that they're empty on Modal, and they light up the day you move. A dashboard you have to rebuild at migration time is a dashboard nobody migrates.
A trace is a delivery tracking number for one request. Each handoff — gateway, retrieval, model, tool — stamps it with a start time, an end time and some attributes. The stamps only assemble into one journey because everyone agreed to pass the same identifier along. That agreement is context propagation, and it is the entire trick.
vLLM speaks OpenTelemetry natively — one flag on the server:
A GenAI span carries a standard attribute set. Names you'll actually see:
Prompt and completion content is opt-in and off by default — a deliberate PII decision. Capturing it means setting an explicit environment variable, and the content attributes carry warnings about sensitive data. Decide that per-slice, not globally.
This is the single most important thing in S3 for this stage, and it post-dates the source material.
OpenTelemetry defines a gen_ai.evaluation.result event whose job is to carry the
result of evaluating a GenAI output for quality — parented to the span being evaluated, or
carrying gen_ai.response.id when the span ID isn't available. Its attributes are
exactly the S1 vocabulary:
Two details worth internalising. First, score.label is
explicitly meant to be low cardinality — the spec's own note is that a raw score of 1
means “relevant” in one system and “not relevant” in another, so the label is the interpretation
and you must document your possible values. Second, error.type is
conditionally required when the evaluation ends in an error: a judge that timed out is a
distinct outcome from a judge that scored zero, and conflating them is how you get a
quality alert during an unrelated API outage.
- All
gen_ai.*content was moved out of the main semantic-conventions repository into a dedicated GenAI conventions repo in v1.42.0 (June 2026). That is an organisational change giving the fast-moving work its own release cadence — not a graduation to stable. - As of mid-2026, no GenAI-specific span, metric, event or attribute is marked
Stable. They remain in Development, with no committed stabilisation timeline. Shared
core attributes referenced alongside them —
error.type,server.address— are stable. - Frameworks emit several generations of attribute names at once. The spec
provides
OTEL_SEMCONV_STABILITY_OPT_INfor dual-emission during transitions.
A generic APM tool sees an HTTPS call to a hostname taking 4.2 seconds. An LLM-native tool sees which prompt version was used, what came back, how many tokens it cost, which retrieved chunks were in context, and what a judge scored it. Same underlying trace, radically different question-answering power.
Five objects, and understanding them is most of the tool. A trace is one end-to-end request. Observations are the nodes inside it; a generation is a specialised span wrapping a model call. A session groups traces belonging to one conversation or case. A score is a numeric, categorical or boolean quality signal attached to a trace, observation or session — scores are what power evaluation and analytics. Everything carries metadata, tags and a user ID, so you can slice by customer or feature.
What your toy version missed: scores attached at the wrong level. Attach a faithfulness score to the generation, not the trace, or you can't tell which of three model calls was unfaithful.
Apache-2.0, self-hostable in one command. Its setup centres on
arize-phoenix-otel and phoenix.otel.register, which creates an
OpenTelemetry tracer provider and exports spans to a Phoenix collector. Instrumentation comes
from OpenInference — a separate open specification of LLM semantic
conventions plus per-library instrumentors, model-provider agnostic.
The distinction worth holding: Phoenix is the
workbench; OpenInference is the schema. If you already run OpenTelemetry elsewhere the mental
model is identical and there's no proprietary SDK to learn. It also runs
phoenix.evals over captured traces and logs the results back onto spans — the
same convergence idea as the OTel event.
Choosing: both are open-source, both self-host, both do tracing + evaluation + datasets + experiments. Langfuse leans toward prompt management, human annotation queues and CI-gating workflows; Phoenix leans toward OTel-purity and span-level analysis. For your stack the tiebreaker is that Langfuse's score model maps cleanly onto the S1 evaluator/metric vocabulary, and you already have Langfuse examples running.
Do not run two tracing systems on the same request path. Pick one collection path, and if you need the data in two places, fan out at the collector — the OTel Collector exists precisely so that instrumentation is decided once and destinations are decided later. Double-instrumenting produces two incomplete trees and a lot of confident wrong conclusions.
A monthly reliability target isn't a wish, it's a budget. 99.9% over 30 days means you are allowed 43 minutes of failure. Spend it slowly and nobody needs to wake up. Spend a third of it in an hour and someone does. That framing — rate of spend, not absolute level — is what stops alert fatigue.
Multi-window multi-burn-rate alerting is the technique that makes this usable. One alert per severity, each requiring a fast window and a slow window to agree — the slow window prevents a 30-second blip from paging, the fast window makes recovery detection quick:
Both alerts are on the same SLO. What differs is urgency, and therefore the destination: one pages, one files a ticket. Everything else goes on a dashboard and pages nobody.
Quality SLOs need longer windows than latency SLOs, for a statistical reason. If you sample 2% of 10 000 daily requests for judging, that's 200 scores/day — about 8 per hour. A one-hour window on a quality metric is nearly pure noise; the confidence interval on 8 samples is enormous. Quality burn-rate alerts want 6h/24h windows, or a much higher sampling rate on the specific slice you're gating. This is the S1 sample-size arithmetic showing up again, now as an alerting design constraint.
Every alert needs a runbook URL. the source material's example includes one and it's the most underrated line in the section. An alert without a documented response procedure is a notification that someone should feel bad.
Reality check
25%Worked example A · why p99 tells a different story from the mean
A single hour of TTFT observations on your Gemma endpoint. 1 000 requests, two populations mixed together — short queries hitting a warm prefix cache, and long 10-K sections arriving during a burst.
Now the part that matters operationally. Those 8 slowest requests are 0.8% of traffic — but look at what they do to the mean: they contribute 120 of the 589 total milliseconds, or 20% of the mean. So the mean is simultaneously (a) hiding the tail and (b) being distorted by it. It is the worst of both worlds and it is the default panel in most dashboards.
And per-user exposure compounds. If a single analyst processes 40 sections in a session, the chance they hit at least one p99 event is:
cache_hit label — because these are not one distribution with a tail, they are
two distributions, and the fix for each is different.Worked example B · reading GPU utilisation vs memory saturation on your L4
Three panels during a steady decode-heavy workload. What do you conclude?
Reading it: util 97% with SM activity 21% is the signature of
memory-bound decode — the GPU is busy waiting on memory, which is the expected steady state
for autoregressive generation. Buying a faster-compute GPU would change almost nothing. What
would help is anything that improves arithmetic intensity: larger batches (more sequences
sharing each weight read), FP8 weights and FP8 KV cache (fewer bytes moved per token — which is
exactly what your quant profile does), or prefix caching (skip the prefill entirely).
And the 92% framebuffer number is the real
warning. That's your gpu-memory-utilization: 0.92 setting doing what you told
it to. There is no headroom for a longer context or a bigger batch:
GPU_UTIL is the least informative GPU panel on the
dashboard and the one everyone puts first. The load-bearing pair for an LLM is
framebuffer used % (are you about to preempt) and requests waiting
(are you already over capacity). Put those two at the top; keep util as a diagnostic below the fold.the source material tells you to configure scraping and then build your own Grafana views. As of
2026 you don't have to start blank: vLLM's docs publish importable dashboard JSON —
performance_statistics.json for latency and throughput and
query_statistics.json for request volume and KPIs — and the vLLM
production-stack project ships a fuller vllm-dashboard.json organised
into four collapsible rows: system overview, quality of service, engine load, resource usage. Its
observability bundle is built on kube-prometheus-stack and includes a Prometheus
Adapter so vLLM metrics reach the Kubernetes Metrics API for custom autoscaling — which is the
bridge from your monitoring to KEDA-style scaling on queue depth.
Use these as a starting point and a naming reference, then delete two-thirds of the panels. A dashboard that answers a question is worth ten that display metrics.
Dynamo — NVIDIA's distributed serving framework — is instructive
because of how conventionally it behaves. Runtime metrics prefixed dynamo_* appear on
the same /metrics endpoint alongside backend metrics (vLLM's vllm:*,
TensorRT-LLM's trtllm_*). On Kubernetes the Dynamo operator creates
PodMonitor resources automatically and labels pods for discovery; GPU metrics come
from dcgm-exporter and node metrics from node-exporter, both via
kube-prometheus-stack. It ships pre-provisioned Grafana dashboards and a Tempo
instance for traces.
The lesson: NVIDIA's serving stack is not a parallel universe. It is Prometheus + Grafana + OTel + DCGM with sensible defaults. Everything you learn here transfers directly, which is the strongest argument for learning the open-source layer properly rather than a vendor console.
Worth stating plainly, because it's the boundary between S3 and everything else in this stage:
the GenAI semantic conventions standardise model attributes, token usage and latency. They do
not tell you how to produce output evaluation, safety scoring or content quality
assessment — the gen_ai.evaluation.result event standardises the shape of the
answer, not the evaluator that computes it. OpenTelemetry is the data plane; you still need an
evaluation layer on top, and that layer is S1, S2 and S4.
The corollary is a useful architectural rule: choose your evaluator freely, but make it emit OTel events. Then swapping judges or eval frameworks doesn't invalidate your dashboards, your alerts or your history.
Apply to my stack — lab
10%4.1 · Gateway-side metrics, correctly typed and correctly labelled
This replaces your QuickMetricsCollector. Note what's a histogram, what's a counter,
and — importantly — what is not a label.
from prometheus_client import Counter, Gauge, Histogram # Bounded label set. slice/tier/model are small enums. NEVER user_id, doc_id, prompt. LBL = ["model", "task", "risk_tier", "cache"] # Buckets straddle the SLO (1.5s) with resolution on BOTH sides, and a long # tail because Modal cold starts are real and must not all land in +Inf. TTFT = Histogram("gw_ttft_seconds", "Time to first token, measured at the gateway", LBL, buckets=(.1,.25,.5,.75,1.0,1.5,2.5,4.0,7.0,15.0,30.0,60.0)) TPOT = Histogram("gw_time_per_output_token_seconds", "Inter-token latency", LBL, buckets=(.005,.01,.02,.035,.05,.08,.12,.2,.4)) E2E = Histogram("gw_request_duration_seconds", "End to end", LBL, buckets=(.5,1,2,4,8,15,30,60,120)) TOK = Counter("gw_tokens_total", "Tokens", LBL + ["direction"]) # in|out COST = Counter("gw_cost_usd_total", "Attributed cost", LBL) REQ = Counter("gw_requests_total", "Requests", LBL + ["outcome"]) # outcome: ok | contract_repair | contract_fallback | claim_blocked | upstream_error # Controlled quantities from S2 — these make latency spikes ATTRIBUTABLE RETRY = Counter("gw_retries_total", "Retries", LBL + ["reason"]) INFLIGHT= Gauge("gw_inflight_requests", "In flight", ["model"]) # THE FOURTH PILLAR. Quality is a metric like any other. QUALITY = Histogram("gw_eval_score", "Evaluation scores from online judging", LBL + ["eval_name"], buckets=(0,1,2,3,4,5)) QFAIL = Counter("gw_eval_errors_total", "Evaluator itself failed", LBL + ["eval_name", "error_type"]) # NOT the same as a low score
4.2 · The Modal scrape problem, solved
Do not point Prometheus at your Modal URL on a 15-second interval. Two options, and the second is the one to ship.
| Option | How | Verdict |
|---|---|---|
| Direct scrape of the Modal endpoint | Prometheus static_configs → the Modal web URL /metrics | Don't. Defeats scale-to-zero, mixes replicas, sawtooths on cold start. |
| Gateway as SLO source + opportunistic engine pull | Gateway exposes /metrics (always up, correct labels). A separate low-frequency job pulls vLLM /metrics only when the gateway has seen traffic in the last 60s, and writes it into a Pushgateway or as recording rules under a source="engine" label. | Ship this. SLOs and alerts read gateway series only. Engine series are capacity diagnostics with an explicit “best effort” note on the panel. |
import httpx, asyncio, time _last_traffic = 0.0 # updated by the request middleware async def pull_engine_metrics(modal_url: str, interval: int = 60): """Only pull while the container is ALREADY warm. Never resurrect it. Everything landed here is diagnostic; nothing here backs an SLO.""" async with httpx.AsyncClient(timeout=5) as c: while True: await asyncio.sleep(interval) if time.time - _last_traffic > 45: continue # likely cold or scaling down: leave it alone try: r = await c.get(f"{modal_url}/metrics") # re-expose under source="engine"; note replica identity is UNKNOWN ingest_openmetrics(r.text, extra_labels={"source": "engine"}) except Exception as e: ENGINE_PULL_FAIL.labels(reason=type(e).__name__).inc
4.3 · Dashboard: six panels, in this order
Your Grafana JSON in monitoring/grafana/dashboards/ becomes this. Rule: every panel
answers a question you can name out loud, and the top row is load-bearing only.
| # | Panel | Query | Question it answers |
|---|---|---|---|
| 1 | TTFT p50/p95/p99 by risk tier | histogram_quantile(0.99, sum by (le,risk_tier) (rate(gw_ttft_seconds_bucket[5m]))) | Are users waiting, and is the high-risk path worse? |
| 2 | Error-budget burn, TTFT SLO | recording rule slo:ttft_error_ratio over 1h / 6h | Do we need to stop shipping? |
| 3 | Faithfulness p50 + sample count | histogram_quantile(0.5, sum by (le,task) (rate(gw_eval_score_bucket{eval_name="faithfulness"}[6h]))) | Is the output still good? Always plot n next to it. |
| 4 | Outcome mix (stacked) | sum by (outcome) (rate(gw_requests_total[5m])) | Are contracts repairing / falling back / claim-gating more than usual? |
| 5 | Engine load — waiting vs running, KV cache % | vllm:num_requests_waiting, cache usage gauge | Are we at capacity? (marked “best effort, engine-pull”) |
| 6 | Cost per 1k requests by task | rate(gw_cost_usd_total[1h]) / rate(gw_requests_total[1h]) * 1000 | Did that quality change cost us money? |
prompt_version, model and serving_profile. A vertical line
through all six panels turns “something changed on Tuesday” into a one-glance answer.
(2) Panel 3 must show its sample count. A faithfulness line computed from 8 samples
per hour looks exactly like one computed from 800. Plot n on the right axis, and your
future self will not act on noise.
4.4 · Alert rules that survive review
monitoring/prometheus/alert_rules.ymlgroups: - name: gemma-slo rules: # --- recording rule: "bad" = TTFT over 1.5s. Defined once, reused everywhere. - record: slo:ttft_error_ratio5m expr: | sum(rate(gw_ttft_seconds_bucket{le="+Inf"}[5m])) - sum(rate(gw_ttft_seconds_bucket{le="1.5"}[5m])) / sum(rate(gw_ttft_seconds_count[5m])) # --- PAGE: fast burn (14.4x of a 99.9% / 30d budget) - alert: TTFTBudgetBurnFast expr: slo:ttft_error_ratio5m > 0.0144 and slo:ttft_error_ratio1h > 0.0144 for: 2m labels: {severity: critical, team: llm-platform} annotations: summary: "TTFT budget burning 14.4x — 2% of the month in 1 hour" runbook_url: "https://runbooks/gemma/ttft-burn" # --- WARNING: leading indicator, fires BEFORE the SLO does - alert: KVCachePressure expr: vllm_kv_cache_usage_perc{source="engine"} > 0.90 for: 10m labels: {severity: warning} annotations: summary: "KV cache >90% — preemption imminent, TTFT p99 will follow" runbook_url: "https://runbooks/gemma/kv-pressure" # --- QUALITY SLO. Long window: only ~8 judged samples/hour at 2% sampling. - alert: FaithfulnessDegraded expr: | histogram_quantile(0.5, sum by (le,task) ( rate(gw_eval_score_bucket{eval_name="faithfulness"}[6h]))) < 4.0 and sum by (task) (rate(gw_eval_score_count[6h])) * 21600 > 100 for: 30m labels: {severity: critical, team: llm-quality} annotations: summary: "Faithfulness median below 4.0 on {{ $labels.task }}" runbook_url: "https://runbooks/gemma/faithfulness-drop" # --- The one people forget: is the JUDGE broken, or is the MODEL bad? - alert: EvaluatorFailing expr: rate(gw_eval_errors_total[15m]) / rate(gw_eval_score_count[15m]) > 0.1 for: 15m labels: {severity: warning} annotations: summary: ">10% of judge calls erroring — quality panels are unreliable right now"
Note the second clause on FaithfulnessDegraded: it refuses to fire
unless there are more than 100 judged samples in the window. That single line is the S1 sample-size
lesson, encoded as an alerting guard, and it will save you from three false pages a month.
4.5 · The span layout for one fintech request
gateway/tracing.pywith tracer.start_as_current_span("analyze_filing_section") as root: root.set_attribute("task", task); root.set_attribute("risk_tier", tier) root.set_attribute("gen_ai.prompt.name", PROMPT_ID) root.set_attribute("gen_ai.prompt.version", PROMPT_VERSION) with tracer.start_as_current_span("retrieval") as s: s.set_attribute("retrieval.doc_ids", ids); s.set_attribute("retrieval.index_snapshot", snap) with tracer.start_as_current_span("gen_ai.chat") as s: # the OTel-conventional one s.set_attribute("gen_ai.request.model", "gemma-4-e4b") s.set_attribute("gen_ai.request.temperature", dec["temperature"]) s.set_attribute("gen_ai.usage.input_tokens", usage.prompt) s.set_attribute("gen_ai.usage.output_tokens", usage.completion) s.set_attribute("gen_ai.response.time_to_first_chunk", ttft) with tracer.start_as_current_span("contract_validate") as s: s.set_attribute("contract.outcome", outcome) # ok|repair|fallback # LATER, asynchronously, off the critical path — the convergence: def emit_eval(span_ctx, name, value, label, explanation): logger.emit("gen_ai.evaluation.result", context=span_ctx, attributes={ "gen_ai.evaluation.name": name, # "Faithfulness" "gen_ai.evaluation.score.value": value, # 4.0 "gen_ai.evaluation.score.label": label, # "pass" — LOW cardinality "gen_ai.evaluation.explanation": explanation, }) QUALITY.labels(**lbl, eval_name=name).observe(value) # and as a metric
Recreate your E2E baseline on real dashboards, then find where it lies. Run your 11-query test against the live Modal endpoint with the gateway metrics above wired up, and reproduce the three headline numbers — 100% success, 91% cache hit, 3-model routing split — as Grafana panels. Then answer two questions the test report couldn't: (a) what was the p99 TTFT, and how much of it was the cold start on request one? (b) is “91% cache hit” a prefix-cache hit measured by vLLM's queries/hits counters, or your gateway's own response cache? Those are different metrics with the same name in your README, and knowing which one you published is the whole exercise.
The judge is an instrument, and instruments need calibrating
Four patterns that buy reliability, each with a price tag in latency and money. The first one — LLM-as-Judge — is the load-bearing one, because every automated quality number in the previous three sessions eventually rests on it.
Why this session exists
10%Two logo designs get scored against the same five-criterion rubric. A genuinely excellent, professionally-made logo scores 0.90. A generic first draft the model produced thirty seconds ago also scores 0.90. The rubric was reasonable. The criteria were sensible. The scores are useless, because the instrument does not discriminate.
That is the default behaviour of an uncalibrated LLM judge, and it is the reason “we added LLM-as-Judge” is not the same sentence as “we can measure quality”.
Evaluating open-ended output is hard because the three classical approaches each fail in a different direction:
The pattern's promise is real. The catch is that it inherits none of the three approaches' trustworthiness So do we.
Core concepts, from zero
50%You want to know whether marketing copy will sell. You can't run the sale (too slow, too confounded), and you can't hire a focus group per draft. So you write down what good copy looks like, and hand that checklist to a competent reader. LLM-as-Judge is the checklist plus a reader that never gets tired and costs a fraction of a cent.
Three escalating options. Most teams start at 1 and only move when they have data.
Write a rubric, ask a model to apply it
A custom scoring rubric for your problem, applied by an LLM at temperature zero. The skeleton is small:
Two things make it consistent: temperature zero plus caching, and self-contained inputs — pre-process so the judge sees everything it needs, e.g. include enough conversation turns and summarise long agent outputs rather than truncating them arbitrarily.
Learn the mapping from rubric scores to the outcome you care about
The prompting judge gives you a vector of criterion scores. Whether that vector predicts anything is a separate question — so answer it empirically. Three steps: (1) build the rubric; (2) collect historical cases where you know the real outcome; (3) train a classifier on rubric scores → outcome.
The elegant property: the model learns to discount criteria that don't matter, or on which the judge is inconsistent. You get outcome alignment and noise-robustness from the same fit — provided you don't overfit, which with a handful of criteria and a few hundred outcomes is a real risk you must hold out against.
When you cannot write down what “good” means
Some criteria resist articulation. What exactly makes financial commentary appropriately hedged? If experts can recognise it but not specify it, have them annotate against the same rubric, then adapter-tune a model on those input→score pairs so it reproduces expert judgement.
This is the right shape when you need conformance to a professional standard — applying a diagnostic checklist the way a clinician would, or a materiality assessment the way an auditor would. It's also the most expensive option and the one that needs the most labelled data, so it is genuinely a last resort.
Fourth design axis, orthogonal to all three: what shape is the output? A single piece scored absolutely (pointwise), a head-to-head against a reference (pairwise), or a ranked list. Choose based on how the score gets used: if a decision is made from the output, binary is best, because it ties the score directly to the correctness of the decision.
The counter-intuitive caveat about explanations. Asking the judge to justify each score is standard advice, and it buys real interpretability. But there is evidence that requiring self-explanation degrades evaluation performance and bias mitigation — plausibly because the act of generating a justification entrenches a bias rather than exposing it. If you genuinely don't need explanations, an alternative is to take the probability-weighted mean over the score distribution rather than the single most-likely score. Be careful combining that with chain-of-thought prompting; the two interact oddly.
Practical read for you: keep explanations on. You need them for calibration and for debugging your rubric, and you are nowhere near the regime where squeezing the last few points of judge accuracy matters. Just know the trade-off is real and revisit it once your calibration numbers plateau.
Ask ten people to rate a film out of 100 and you get chaos. Ask them “would you recommend it — yes or no” and you get a signal. Every lever below is a variation on that: reduce the number of ways the instrument can wobble.
1–5 is usually fine. 1–10 is worse. 1–100 is noise with decimal places. The logical endpoint is binary, which gives the best consistency of all — and, conveniently, is also what a gate needs. Prefer several binary questions over one graded score.
Splitting one “quality” score into four named criteria is a form of chain-of-thought — it forces the judge to reason through components before committing. It also makes regressions diagnosable: you learn which dimension moved.
Don't say “factual accuracy”. Say what 1 and 5 mean: 1 if any statement misrepresents the source; 5 if every statement is grounded in it. Anchors are the difference between a rubric and a mood.
Supplying a reference output — even just the previous approved version — calibrates the model and stabilises scores across invocations. This is also the fix for leniency (below), because relative judgement is far more reliable than absolute.
A fifth option when stakes justify the cost: LLM-as-jury — several models evaluate, each optionally adopting a different stakeholder's perspective. Combine it with binary questions and you get nuanced scoring out of individually-reliable judgements; that combination is sometimes called polling.
Make the criteria a proxy for the outcome or the KPI. Measuring outcomes is best but usually confounded; the next-best thing is judging against business-aligned indicators. Two warnings come with that: watch for adversarial actors gaming the metric, and remember that a criterion you can't connect to any downstream consequence is a criterion you're measuring because it was easy to write.
Every measuring instrument has systematic error. A cheap kitchen scale reads 3 g heavy — that's fine if you know it, because you subtract 3. The danger is an unknown bias, and the specific danger with judges is that several of their biases push in the same direction as the thing you're trying to measure.
The three that will actually bite you, with what the literature says and what to do:
| Bias | Mechanism | Mitigation | Residual risk |
|---|---|---|---|
| Position | In pairwise comparison, judges systematically favour one slot. Widely reproduced; the standard finding is a preference for the first answer, though in some domains — safety in particular — it can favour either side. | Run both orders and average, or count only cases where the verdict survives the swap. Cheap and effective. Measure your own swap-disagreement rate and report it. | Doubles judge cost. Ties need an explicit policy rather than being silently broken. |
| Verbosity / length | Longer answers score higher even when they contain the same relevant content. Quantified by looking at error rates on long-vs-short options. | Anchor conciseness as an explicit scored criterion; normalise or cap length before judging; penalise length in the aggregation function. | Over-penalising length hurts genuinely thorough answers. Anchor it, don't just subtract. |
| Self-preference | Models score their own outputs higher. The proposed mechanism is familiarity: judges prefer text they assign lower perplexity to — which means they prefer well-written text over badly-written-but-accurate text, regardless of authorship. | Use a different model family as judge than the one that generated the output. Ensemble across models. One suggested refinement: down-weight a model's vote on samples where it shows unusually low perplexity. | The perplexity mechanism means this is not only about self-recognition — a judge from a different family can still over-reward fluent nonsense. |
Leniency, and the three ways out. Judges behave like a professor who gives every student an A and B. Combined with non-determinism, that means you should not compare two absolute scores to establish a ranking. Instead: (1) compare the two pieces directly in one pairwise call; (2) normalise within a group — generate several responses, score them together, and express each score relative to the group average (the group-relative approach used in GRPO-style training); (3) lower your expectations about what the judge is for — use it to identify problems rather than to rank near-equivalents. A technical RAG failure like total context loss scores near zero while normal operation scores 0.95, and that gap is detectable even by a lenient instrument.
The uncomfortable 2026 finding about ensembling. The standard defences — ensembling across judge models, reporting inter-judge agreement, reversing presentation order — reduce variance within the judge population. They do not remove systematic bias shared across it. Recent work identifies exactly this: shared biases that survive all three defences. The practical implication is blunt — agreement between judges is not evidence of correctness. Only agreement with humans is, which is the next concept.
When to stop using a frontier judge. Given the cost and bias profile, fine-tuned small judge models are worth considering for specific dimensions, and specialised evaluator models exist for particular tasks and industry-specific metrics. For your pipeline the relevant version of this is: your own Gemma E4B, running on hardware you already pay for, is a legitimate judge candidate for the deterministic-adjacent dimensions — once calibrated.
Before a new thermometer goes in a hospital, someone puts it in ice water and boiling water and checks it reads 0 and 100. Nobody argues about whether that's necessary. Calibrating a judge is the same procedure: a small set of cases where you already know the answer, and a number describing how well the instrument reproduces it.
Treat the judge as a classifier against a human reference, and report what you'd report for any classifier.
The loop: label 30–200 real examples with domain experts (domain knowledge matters here, and the gap between experts and generalist annotators is large) → baseline the full metric ensemble → rewrite the judge prompt against the worst-performing examples → repeat. In practice alignment plateaus in five to ten iterations.
A content-safety judge reviews 100 outputs. 95 are fine, 5 are violations. The judge says “pass” to everything.
The judge has 95% agreement and catches literally nothing. This is why agreement is called a trap metric on imbalanced data, and why TPR and TNR must be reported next to it. Your compliance-flag slice is exactly this shape: rare, high-consequence positives in a sea of clean cases.
Three legitimate places to stop calibrating:
- Alignment above ~0.80 with iterations yielding under ~0.02 each — you are now fitting variance in the golden set, not real failure modes.
- Human inter-rater agreement below ~0.6 κ on the same set — the task is genuinely ambiguous and further calibration optimises noise. Redefine the criterion instead.
- A product decision: “catches 6 of 9 hallucinations” may be fine for a developer tool and unacceptable for a regulatory flag. Define that threshold before you start.
Report per-dimension, never as one average. An aggregate alignment of 0.80 can hide a faithfulness alignment of 0.55 — close to chance on a binary task. And two specific pathologies to watch for: high accuracy with low κ means the judge is right by chance and would score similarly on a shuffled dataset; high precision with low recall means it's conservative — right when it flags, but missing most real failures. Neither is trustworthy.
In a chat UI, when the answer is wrong you say so and it fixes it. API calls are stateless, so that loop doesn't exist unless you build it. Reflection is that loop, automated: generate, critique, apply the critique, regenerate.
Instead of one call, you make two or more. The output goes to an evaluator — which may be an LLM judge, an external tool, or a human — and crucially the evaluator returns a critique explaining how the response falls short, not just a score. That critique becomes part of a modified prompt, and the cycle repeats until the response clears a bar.
The one-round special case is the pragmatic default, and for a specific reason: with exactly one retry, you never have to define what “good enough” means. No threshold, no infinite-loop guard, no leniency problem. Given how hard it is to pick a threshold on a lenient judge's scores, that is a bigger simplification than it looks.
Reflection multiplies calls, so it multiplies tail latency. That's the whole trade. Where the cost of being wrong is high relative to an extra inference — code generation against a compiler, product listings that must be complete and correct — multiple reflection stages are clearly worth it, and the evaluator can be an external tool rather than a model. Where latency is the product — chatbots, real-time interfaces — reflection can push tail latency past acceptable limits, especially since you often already need a retry strategy just to get the first call to succeed.
Get the evaluator right or the loop is worse than nothing. The evaluation stage is the important part: a bad critique produces confident, expensive wrongness. And use a different model for critique than for generation — the self-preference bias above is exactly what you're defending against.
A middle path worth knowing: generate several drafts up front, critique all of them, prune the weak candidates, and continue — a beam-search shape rather than a linear loop. Better quality per round, higher cost per round. Develop a heuristic that sets reflection depth from problem characteristics, available time, and the business impact of the expected quality gain, rather than picking one depth for everything.
You cannot test the second half of a pipeline if running it requires paying for and waiting on the first half — and getting a slightly different first half each time.
Three properties make GenAI chains hard to test: they're non-deterministic, the foundation models change on someone else's schedule, and prompts are not portable across providers even when the API is.
The fix is old and boring: define the chain so any step can be
replaced by an implementation with the same signature, and inject a mock when developing or
testing the others. Pair it with assertions between steps — assert the critique
has ≥4 improvements, assert the chosen change came from the critique's own list, assert the diff
touched 1–5 lines. In Python, assert is disabled by -O, so you get
assertions on in development and off in production without changing the code.
Hardcoded mock values get harder to maintain as coupling between steps increases — if step 2's behaviour depends closely on step 1's specifics, choosing the right mock becomes its own problem. Mock external tools too, not just LLM calls, so your tests aren't hostage to network latency and third-party availability.
Every prompt you hand-tune is a hardcoded constant fitted to one model version. When the model changes, all your trials are invalidated at once. The fix is the oldest one in computing: another level of indirection.
Four components. A pipeline of steps declared by input/output signature rather than by prompt text; a dataset of examples (can be as small as one); an evaluator that scores a run automatically — comparing to a reference, or an LLM-as-Judge fitness score when you have no references; and an optimizer that generates prompt variations, runs them, and returns the pipeline with the best-performing prompts injected.
In DSPy the two entry-level optimizers are best-of-N (generate N prompt variants, keep the best on your one example — costs N inferences every time) and bootstrapped few-shot (pick k examples as demonstrations, evaluate on the rest, rotate, keep the best-performing selection — costs once, then runs a single inference). Your code ends up containing no prompts at all.
A prompt library in a config file is not a substitute — it makes prompts easier to edit but you still re-experiment by hand on every dependency change. That said, prompt management and versioning remain good practice independently (and S2 requires them for attribution). The payoff compounds: once you record all prompts plus human feedback, you can use the same infrastructure to build a judge, and eventually a dataset for post-training a task-specific model.
| Pattern | Extra inferences | Latency impact | Where it pays | Where it doesn't |
|---|---|---|---|---|
| 17 · Judge (offline) | +1 per case, batch | None on users | Always. This is the cheapest quality signal you will ever buy. | — |
| 17 · Judge (online, sampled) | +1 per sampled req | Zero if async | Continuous quality SLOs. Sample 1–10%. | Synchronously on the request path — never do this |
| 17 · Judge (jury / order-swap) | +2 to +5 | None if offline | Release gates and calibration runs | Per-request online sampling; the cost multiplies by traffic |
| 18 · Reflection (1 round) | +2 (critique + regen) | ≈3× tail | Code gen, compliance output, anything with an expensive failure | Interactive chat, streaming UX, real-time |
| 18 · Reflection (beam, 3 drafts) | +7 or more | Large | Offline batch generation where quality dominates | Anything a user is waiting for |
| 19 · Dependency Injection | −N in dev | Negative — it's faster | Every multi-step chain. Pure win. | Single-call systems |
| 20 · Prompt Optimization | Front-loaded | None at inference (bootstrapped few-shot); N× (best-of-N) | Surviving model upgrades without a re-tuning project | One-off prompts you'll never revisit |
Reality check
25%Worked example A · what does it cost to judge 1% of 10 000 requests/day?
Your workload, three judge options. Assume a judged case is ~3 500 input tokens (the source excerpt plus the response plus the rubric) and ~250 output tokens of scores and justification.
Now scale the sampling rate, because 1% is a starting point, not an answer:
Worked example B · the tail-latency price of one round of Reflection
Your compliance-flag path. Suppose the single-call profile is TTFT p95 = 1.4 s and total p95 = 6.2 s.
the source material's considerations section names inconsistency, leniency, self-bias, length bias and positional bias — and that list is still the right one. What's changed is that each has been quantified and given mitigations. Self-preference now has a proposed mechanism (judges prefer lower-perplexity, more familiar text — which is why they favour well-written inaccuracy over badly-written accuracy) and a proposed mitigation (ensemble, and down-weight a model's vote where it shows unusually low perplexity on the sample). Position bias has systematic studies across pointwise and pairwise protocols. Broader surveys catalogue a dozen more — authority bias, bandwagon effects, distraction, compassion-fade from model names.
And a caution that generalises badly in the other direction: in the safety domain specifically, one study found position bias could favour either side and verbosity had minimal effect — so measure your own biases on your own task rather than importing coefficients.
The most consequential 2026 result for anyone building a judge pipeline. The standard defences — ensembling across judge models, reporting inter-judge agreement, reversing presentation order — address variance within the judge population but not systematic biases shared across it. Recent work identifies a concrete example: LLM judges' ratings of a model's behaviour converge with that model's own self-report in a way that human ratings of the same samples do not.
The design implication is the one thing to take from this whole session: your judge-agreement number is a measure of precision, not accuracy. A human-labelled calibration set is not an optional nicety you graduate out of — it is the only thing standing between you and a confidently wrong quality metric, and it has to be refreshed.
The practitioner consensus that solidified through 2026 is that calibration decays and needs a schedule: monthly human spot-checks on stratified production samples, full calibration cycles quarterly or whenever a red-flag signal fires. Stratified sampling specifically — random sampling under-represents the tail behaviours that matter most: high-confidence wrong answers, low-frequency edge cases, and outputs from recently-deployed prompts. That's the same argument S1 made for slice-based suites, applied to the calibration set.
And the reason it decays is precisely S1's evaluator drift: your judge model gets updated, your rubric gets edited, your traffic mix shifts. Every one of those invalidates the last calibration. Put the recalibration trigger in the same place you version the judge.
- Named models are dated. The section's worked examples use specific 2025-era frontier models as generator and judge. The architectural point — use a different model for evaluation than for generation — is more durable than any model name. Treat judge choice as a versioned config value.
- “Dependency Injection is not supported natively by any GenAI framework.” Still broadly true as a first-class feature, and still worth implementing yourself: it's twenty lines of function-parameter defaults, and the material's own example shows the whole pattern.
- The Prompt Optimization framework list (DSPy, plus AdalFlow and PromptWizard as alternatives) has grown, and the material explicitly hoped it would. Verify the current optimizer names in DSPy's own docs before copying API calls — that layer moves quickly.
- What has not aged: the bias catalogue, the leniency problem, the advice to prefer binary questions and coarse scales, and the warning that explanations may hurt evaluation performance. Those are the load-bearing parts of the section and they've all been reinforced by 2026 work rather than overturned.
Apply to my stack — lab
10%4.1 · A judge for citation binding, on your own Gemma
Chosen deliberately: citation binding is checkable, so self-preference doesn't apply, and
it's the dimension your compliance path depends on. enable_thinking is on because the
task is verification, not generation, and the reasoning trace is separable via
reasoning_content.
JUDGE_ID, JUDGE_VERSION = "citation_binding", "2.1.0" RUBRIC = """You verify whether each compliance flag is supported by the cited span. For EACH flag, answer three questions. Answer only yes or no. Q1 SPAN_EXISTS : do the character offsets fall inside the excerpt? Q2 SPAN_SUPPORTS: does the cited text state or clearly imply the flagged risk? no = the span is topically related but does not establish it yes = a reader of ONLY this span would agree the flag applies Q3 NO_EXTRAPOLATION: is the flag free of figures/claims absent from the span? Return JSON: {"flags":[{"id":..,"q1":bool,"q2":bool,"q3":bool,"why":"<=25 words"}]} """ # Binary questions (best consistency). Anchored (q2 states what no/yes mean). # Multiple criteria (acts as CoT). Explanations capped so verbosity can't inflate. async def judge(excerpt: str, response: dict, client) -> dict: r = await client.chat.completions.create( model="gemma-4-e4b", messages=[{"role": "system", "content": RUBRIC}, {"role": "user", "content": render(excerpt, response)}], temperature=0.0, top_p=1.0, extra_body={"chat_template_kwargs": {"enable_thinking": True}}, ) out = parse(r) # The judge is a measuring instrument. Version it like one. out["_meta"] = {"judge_id": JUDGE_ID, "judge_version": JUDGE_VERSION, "rubric_hash": sha256(RUBRIC)[:12], "model": "gemma-4-e4b"} return out
4.2 · The position-swap harness for pairwise release gates
Two calls, opposite orders. Only verdicts that survive the swap count; the rest are ties, and the disagreement rate is your position-bias measurement.
evals/judges/pairwise.pyasync def pairwise_swapped(case, cand_a, cand_b, judge) -> dict: """cand_a = new candidate, cand_b = current approved baseline.""" fwd = await judge(case, first=cand_a, second=cand_b) # A in slot 1 rev = await judge(case, first=cand_b, second=cand_a) # A in slot 2 fwd_a_wins = (fwd["winner"] == "first") rev_a_wins = (rev["winner"] == "second") if fwd_a_wins == rev_a_wins: return {"verdict": "A" if fwd_a_wins else "B", "robust": True} # Disagreed under swap -> the judge preferred a POSITION, not an answer. return {"verdict": "tie", "robust": False, "position_flip": True} def gate(results, min_robust=0.80): flip_rate = mean(r["position_flip"] for r in results) if 1 - flip_rate < min_robust: # More than 20% of verdicts are position artefacts: the JUDGE is broken, # and no conclusion about the candidate is available. Fix the rubric. raise JudgeUnreliable(f"position flip rate {flip_rate:.0%}") wins = sum(r["verdict"] == "A" for r in results if r["robust"]) return wilson_interval(wins, sum(r["robust"] for r in results))
4.3 · The calibration script — run this before you trust any of the above
evals/calibration/align.pyfrom sklearn.metrics import cohen_kappa_score, confusion_matrix def align(human_labels, judge_labels, positive="fail") -> dict: """Report the ENSEMBLE. Any single number here can be gamed by a judge that always predicts the majority class.""" tn, fp, fn, tp = confusion_matrix( human_labels, judge_labels, labels=["pass", positive]).ravel return { "n": len(human_labels), "prevalence": (tp + fn) / len(human_labels), # the base rate. print it FIRST. "raw_agreement": (tp + tn) / len(human_labels), "kappa": cohen_kappa_score(human_labels, judge_labels), "tpr": tp / (tp + fn) if (tp + fn) else None, # caught real failures "tnr": tn / (tn + fp) if (tn + fp) else None, "precision": tp / (tp + fp) if (tp + fp) else None, } # Stopping rule, declared BEFORE you start iterating on the rubric. GATE = {"kappa": 0.70, "tpr": 0.85} # TPR bar is high: missing a real # compliance failure is the expensive error def ship_judge(m, human_human_kappa): if human_human_kappa < 0.60: return "ABORT: humans don't agree. The criterion is ambiguous, not the judge." if m["kappa"] >= GATE["kappa"] and m["tpr"] >= GATE["tpr"]: return "SHIP" return "ITERATE: rewrite rubric against the worst-scoring examples"
4.4 · Wiring scores back to Langfuse and to Prometheus
gateway/quality.py# Attach the score to the GENERATION, not the trace — otherwise you can't tell # which of three model calls in the chain was the unfaithful one. langfuse.score( trace_id=trace_id, observation_id=generation_id, name="citation_binding", value=1.0 if passed else 0.0, data_type="BOOLEAN", comment=why, ) # Same score, second destination: the S3 metric that backs the SLO. QUALITY.labels(model="gemma-4-e4b", task=task, risk_tier=tier, cache=ch, eval_name="citation_binding").observe(1.0 if passed else 0.0) # Third: the OTel event, so the score lives on the span for debugging. emit_eval(span_ctx, "CitationBinding", 1.0 if passed else 0.0, "pass" if passed else "fail", why)
QualityMonitor already
collects user feedback. Four upgrades: (1) attach scores to observations rather than
traces, so multi-step attribution works; (2) add
judge_id/judge_version/rubric_hash to every score's metadata —
without them a score trend is uninterpretable across a rubric edit; (3) turn your
user feedback into the seed of a calibration set rather than a standalone metric — thumbs
are human labels, and 30 of them beat any amount of judge tuning; (4) add
Dependency Injection to your router chain so you can test the analysis step against a mocked
classifier output.
Measure your own judge's leniency and position bias in one afternoon. Take 30 fintech analyses. For 15 of them, deliberately corrupt one compliance flag — change the cited offsets so the span no longer supports the claim. Now: (a) run the pointwise judge on all 30 and plot the score distribution — if the corrupted and clean sets overlap substantially, you've measured leniency and pointwise scoring is not usable for gating this dimension; (b) run pairwise clean-vs-corrupted in both orders and compute the position-flip rate; (c) compute κ, TPR and TNR against the ground truth you constructed. You now have three numbers that let you say exactly what your judge can and cannot be trusted to decide — which is the entire deliverable of this session.
The quality control tower
Four sessions gave you four separate capabilities. This one is about the wiring — turning a golden set, a telemetry pyramid, a calibrated judge and a set of gates into one operating system for your Gemma E4B service, where a quality regression is caught in CI, a live quality drop pages someone, and every threshold on the board has a written reason.
Why this session exists
10%You can build every piece in this stage and still not have a system. The failure mode is specific and common: the pieces exist but nothing connects them, so no piece can act. The golden set lives in a notebook someone runs by hand. Grafana shows latency. Langfuse shows traces. A judge script exists but its output goes to a CSV. Nothing blocks a deploy. Nothing pages anyone. Every artefact is real and the system is still ungoverned.
The control tower is the opposite arrangement: a single loop where evidence produced anywhere reaches a threshold that has an owner and an action attached. the source material calls this the operating model — define the system, collect evidence, compare evidence to thresholds, connect the result to an action. Everything below is that sentence, made concrete for your stack.
Designing the tower
50%A clinical trial does not report “the drug worked”. It reports what dose, of what compound, in whom, against what comparator. Change any of those and the result no longer transfers. The system tuple is your dose sheet. It is the exhaustive list of things that, if changed, could change your results — so that when a number moves, the set of candidate explanations is finite and written down.
Without it, every regression investigation starts with an argument about what changed.
Your tuple is not abstract — it's the concrete inventory of your Gemma stack. Every row below gets a version string, and every evaluation run and every production trace carries all of them.
| Component | Your value today | Version key | Who can change it |
|---|---|---|---|
| Model | google/gemma-4-E4B-it (or the FP8 checkpoint under the fp8-ckpt profile) | model_id + HF revision hash | you, via MODEL_REVISION |
| Serving config | PROFILE — quant / prefix / best / tuned | profile + resolved vLLM argv hash | you, via modal deploy |
| Engine | vllm==0.21.0, L4, max_model_len=10000, gpu_mem_util=0.92 | engine_ver | image rebuild |
| Decoding | temperature / top_p, enable_thinking per request | decoding_policy_ver | gateway config |
| Prompt | system prompt per intent | prompt_idprompt_ver + SHA-256 | anyone with repo access ← highest-frequency lever |
| Router | complexity classifier + 3-model routing table | router_ver | gateway config |
| Retrieval | SEC / earnings index | index_snapshot + corpus date | ingestion job ← changes without a deploy |
| Contract | fintech output JSON schema | schema_ver | repo |
| Evaluators | validators + judge model + rubric | evaluator_ver, judge_id, rubric_ver | repo ← silently rescales your scores |
| Suite | golden fintech set | suite_ver + snapshot hash | repo, with an owner per case |
Two rows deserve the italics. The retrieval index changes on a cron, not on a deploy — so a quality drop can have no corresponding commit. And evaluator versions change the measuring instrument, not the thing measured: if the judge rubric changes, your whole history recalibrates and the trend line lies.
Versioning is cheap; the discipline is not. The tuple only pays off if it's emitted automatically. A tuple you have to remember to fill in is a tuple that is wrong within two weeks. Compute it once at gateway startup, attach it to every span as resource attributes and to every eval run as a frozen JSON blob.
Correlation is not attribution. You still need a targeted rerun — pin the old index snapshot, rerun the affected slice, compare. The tuple's job is to make that rerun possible, not to prove anything by itself.
Where it breaks: serverless. On Modal with
min_containers=0 and max_containers=2, two replicas can be running
different container images during a rolling deploy. If the tuple is captured gateway-side only,
you will attribute a mixed-version window to a single version. Have the gateway read the engine's
reported version from the response and record it per request, not per deploy.
Think of it less as a test set and more as an institutional memory of what has gone wrong. Every case earns its place by representing either a behaviour you must guarantee or a failure you have already survived. A set assembled that way grows in a specific direction — toward the things that actually break — instead of growing toward whatever was easy to write.
Your pipeline produces four output types plus a routing decision. Each needs a different evaluator, so each becomes its own slice with its own sizing and its own gate.
| Slice | Output shape | Ground truth? | Primary evaluator | Risk tier | Target n |
|---|---|---|---|---|---|
sentiment | enum: bullish / neutral / bearish | yes — human label | reference metric (macro-F1) | low | 150 |
risk_score | int 1–10 + justification | partial — banded label | banded exact-match + judge on the justification | medium | 200 |
trading_signal | enum + confidence + rationale | no single truth | assertions on shape; pairwise judge on rationale | high | 200 |
compliance_flag | bool + cited span | yes — expert label | assertion: cited span must exist verbatim in source | high / zero-tolerance | 250 |
router_class | enum: simple / moderate / complex | yes — label | reference metric + cost-weighted confusion | medium | 150 |
adversarial | mixed | expected behaviour only | assertions + refusal taxonomy | high | 80 |
The compliance-flag slice is the one to design first, because it is the only one where the correct evaluator is free. A compliance flag must cite a span from the source document. Whether that span appears verbatim in the source is a string operation, not a judgement. That converts your highest-risk output into a deterministic check — the single biggest win available in this whole eval set.
def citation_is_bound(flag, source_text): # the model claims a compliance issue and cites a span if not flag["raised"]: return True span = norm(flag.get("cited_span", "")) return len(span) >= 24 and span in norm(source_text)
Three more structural rules, straight from the source material: keep a development set you iterate against, a release-gating set that stays stable enough for fair version-to-version comparison, and a hold-out set you look at rarely — because a set you tune against stops measuring generalisation and starts measuring how hard you tuned.
Sizing is per-slice, not global. The n column above is not decoration — a 1,030-case suite that is 80% sentiment tells you almost nothing about compliance flags, because the slice that matters has 40 cases and a confidence interval ±15 points wide. Size each slice for the delta you need to detect on that slice. The maths is in the reality check below.
Synthetic data expands, it does not replace. You can generate 500 paraphrases of a risk-score prompt in an afternoon, and it will make the suite look robust while measuring nothing new. the source material's warning is sharper than the usual one: if you use the same model family to generate cases and to judge them, the generator produces cases the judge finds natural, and the system self-congratulates. Keep a real-data core, label synthetic cases as synthetic, and report the two subsets separately.
Every case needs an owner and a reason. An unowned
suite goes stale in a quarter — policies change, the taxonomy drifts, and nobody knows whether
a failing case is a real regression or an out-of-date expectation. One line of provenance per
case (owner, added_because) is what keeps the suite alive.
Airport security has layers with deliberately different sensitivities: a metal detector that everyone passes through, a swab test for a sample, a full search for the flagged. Nobody argues that the metal detector should catch everything — it's tuned to be fast and cheap, and the later layers exist precisely because it isn't sufficient.
Your gates work the same way. Each layer is cheaper and blunter than the one after it, and each has a different job. The mistake is trying to make one layer do everything — usually by putting the expensive judge in CI on every commit, which makes CI slow enough that people start skipping it.
Five layers, each with its own trigger, cost and action.
L1 · Pre-commit — deterministic only, seconds
- Runs on
- every commit, locally and in CI
- Suite
- 25-case smoke slice, one per intent + the five worst historical failures
- Evaluators
- schema validation, enum membership, range checks, citation binding — zero LLM calls
- Cost
- ~$0.004 in Gemma tokens, under 30 s wall clock
- Action on fail
- block the commit
This layer exists to keep the expensive layers from being wasted on structurally broken output. the source material's phrasing is the right instinct: never spend a judge call on a response that already violates its contract.
L2 · PR gate — reference metrics + a cheap judge sample
- Runs on
- every pull request that touches prompts, router, schema or serving config
- Suite
- development set, ~350 cases, all slices, 1 sample per case
- Evaluators
- everything from L1, plus macro-F1 on the labelled slices, plus judge on a 20% subsample of the open-ended slices
- Cost
- ~$0.09 per run; roughly 6 minutes
- Action on fail
- red check + a slice-diff comment on the PR. Overridable with a written reason.
The output that matters here is not the aggregate — it's the per-slice diff against the base branch. “Overall 91.2% → 90.8%” is noise. “compliance_flag 98% → 91%, everything else flat” is a finding.
L3 · Release gate — the full suite, statistically sized, hard blockers armed
- Runs on
- release candidates only, on a frozen tuple
- Suite
- full release-gating set (1,030 cases) + hold-out; 3 samples per case on high-risk slices
- Evaluators
- full stack including calibrated judge with position swapping
- Cost
- ~$1.80 per run; ~35 minutes
- Action on fail
- hard blockers stop the release outright; soft gates require a named person to accept the trade-off in writing
This is the only layer that reports confidence intervals, because it's the only layer where the decision is expensive enough to justify the sample count.
L4 · Canary — real traffic, small blast radius, short window
- Runs on
- 5% of live traffic for 60 minutes, then 25%, then full
- Signals
- contract-failure rate, unsupported-claim rate, refusal rate, p95 TTFT, tool failure rate, cost per request, judge score on an elevated 10% sample
- Comparison
- canary cohort vs the stable cohort over the same wall-clock window — not vs yesterday
- Action on fail
- automatic rollback for hard signals, page-and-hold for soft ones
Same-window comparison is the detail people get wrong. Traffic mix varies by hour; comparing a 10 a.m. canary against a 24-hour stable baseline manufactures differences that have nothing to do with the release.
L5 · Online — permanent, and the only layer that learns
- Runs on
- 100% deterministic checks, 2% stratified judge sample, ~40 human reviews/week
- Signals
- everything from L4, plus drift on the intent distribution and slow-burn trend monitoring over 7 and 28-day windows
- Action
- alerts with runbooks; and critically — every incident becomes a new golden case
L5 is where the loop closes. The other four layers only test what you already thought of. This is the layer that discovers new failure modes and feeds them backwards into L1–L3. A control tower without this edge is an open loop with extra steps.
Gate sensitivity has a failure mode in both directions, and they're asymmetric. Too loose and you ship regressions — bad, but visible and correctable. Too tight and you get rollback fatigue: the gate fires on noise, people learn to override it, and within a month the gate is decorative while still appearing green in your compliance story. That second failure is worse because it's invisible. Review every gate after each rollout and ask whether it produced signal or noise; retire gates that only ever produce noise.
Hard blockers must be slice-scoped or they don't work. A global unsupported-claim rate of 0.05% looks excellent and can still hide a 4% rate inside the compliance slice, because the compliance slice is 6% of traffic. the source material's rule holds: zero-tolerance gates are evaluated within the high-risk slice, never on the aggregate.
Judge budget is a gate design parameter. Putting the judge at L2 at 100% coverage would cost roughly 5× more per PR and add 20 minutes. The subsample at L2 and the full run at L3 is a deliberate purchase of speed at the cost of sensitivity — L2 will miss small quality deltas, and that is fine, because L3 exists.
A factory does not weigh every biscuit. It weighs a sample — but not a random one. It deliberately over-samples the line that has been drifting, and the shift right after a machine change. Stratified sampling buys you statistical power exactly where you need it, which is never uniformly distributed.
Uniform random sampling of your traffic would spend most of its judge budget on the sentiment slice, because that's most of your volume — and sentiment is the slice you already measure for free with a reference metric. That's the whole argument for stratification in one sentence.
Three properties make online judging safe: it is asynchronous (never on the user's critical path), stratified (sample rate varies by slice), and budgeted (a hard daily ceiling on judge spend, so a traffic spike can't produce a cost spike).
The sample rates in the sampler box are not arbitrary — they're derived from how many judged samples each slice needs per day to make its weekly trend readable. That derivation is in the reality check.
Sampling bias is the quiet killer. If you sample only requests that completed, you systematically exclude timeouts, truncations and fallbacks — which is to say you exclude your worst outputs, and your quality metric improves precisely when your system degrades. Sample at the point the request is accepted, not the point it succeeds, and record an outcome label so failures stay in the denominator.
Judge scores need their own health metrics. A judge is a service: it has an
error rate, a latency, and a refusal rate of its own. Track judge_call_failures_total
separately, and never let a judge failure silently count as a passing score. Default a failed
judge call to “unscored”, not to “pass” — the difference decides whether an outage looks like
perfect quality or like missing data.
Retention and privacy. Traces of earnings and SEC analysis carry customer-identifying context. Store what you need to replay the evaluation — document IDs, retrieval ranks, tool outcomes, verification flags — and redact free text you don't need. the source material's phrasing is a good default: capture the observable event sequence, not the model's private reasoning.
Reality check
25%A · Sizing the compliance slice, and what it costs to run it
The compliance slice is zero-tolerance, so the question isn't “detect a 5-point delta” — it's “can I demonstrate the unsupported-claim rate is below 0.5%?” That's a precision question, and it needs a one-sided upper bound.
This is the number that reframes the whole design. You are not going to hand-label 1,000 compliance cases. So the offline suite stops being the place that certifies the rate, and its job changes: offline proves the mechanism works; production accumulates the sample size.
B · The full monthly bill for the control tower
Baseline: 10,000 requests/day, average ~100 output tokens, your L4 at roughly $0.45/hour. Judging uses Gemma E4B as its own judge on a second Modal deployment — which is the interesting choice, so let's price it honestly against an API judge.
| Component | Volume | Unit | $/month | Notes |
|---|---|---|---|---|
| Serving (existing) | 10k req/day | L4 @ $0.45/h | ~$45 | your measured figure with all optimisations on |
| Deterministic validators | 300k/month | CPU only | $0 | runs in the gateway process |
| Online judge — self-hosted Gemma | 2% of 300k = 6,000 judgments | ~700 tok in / 120 out each | ~$11 | scale-to-zero judge replica, batched, ~4.5 GPU-hours/month |
| Online judge — frontier API alternative | same 6,000 | ~$0.004/judgment | ~$24 | higher agreement, no infra, data leaves your boundary |
| CI: L2 PR gate | ~80 PRs/month | $0.09/run | ~$7 | the dominant CI cost is wall-clock, not tokens |
| CI: L3 release gate | ~8 releases/month | $1.80/run | ~$14 | 3 samples/case on high-risk slices |
| Prometheus + Grafana | ~9k active series | self-hosted or GMP | ~$0–18 | free self-hosted; GMP charges per sample ingested |
| Langfuse (self-hosted) | 300k traces/month | ClickHouse-backed | ~$12 | small VM + object storage |
| Human review | ~160 reviews/month | ~4 min each ≈ 11 h | time, not $ | the real constraint, and the reason stratification matters |
| Total marginal cost of the control tower | ~$44–62/mo | i.e. roughly the cost of the serving itself | ||
C · Deriving the sample rates in the sampler box
Where did “compliance 15%, signal 8%, risk 4%, sentiment 0.5%” come from? Each slice needs enough judged samples per week that its weekly mean has a usable interval.
The set rates are all above the bare minimum, deliberately. Two reasons: you want the interval to stay usable when you slice within a slice (by locale, by document type), and you want enough samples that a mid-week problem is visible before Friday.
Three current sources this design leans on
The wiring in this session is not bespoke any more. OpenTelemetry's GenAI conventions define a
gen_ai.evaluation.result event carrying gen_ai.evaluation.name,
gen_ai.evaluation.score.value, an optional low-cardinality
gen_ai.evaluation.score.label (pass/fail, correct/incorrect) and a free-form
gen_ai.evaluation.explanation. The spec is explicit that the event should be parented
to the GenAI span being evaluated, or carry gen_ai.response.id when the span ID isn't
available — which is exactly the async case in your sampler, where the judge runs minutes after
the span closed.
Two caveats to design around. The GenAI conventions moved to their own repository in the
v1.42.0 release (June 2026) and remain in Development status — no signal,
attribute or metric under gen_ai.* is marked Stable, and names can still change.
Pin the convention version you emit and record it in your tuple. Second, the conventions
standardise the transport of an evaluation result, not the evaluation itself; the rubric,
the calibration and the threshold are still entirely yours.
The L2/L3 gates in this design used to be custom scaffolding. As of May 2026 Langfuse ships a
GitHub Actions integration (langfuse/experiment-action) that fails a workflow when
experiment scores fall below a threshold — turning evaluation into a deploy gate rather than a
post-release review, which is precisely the L3 semantics above. The same release wave added
Code Evaluators: Python or TypeScript evaluate functions written in
the Langfuse UI that run deterministic checks — JSON parseability, schema validation, required
tool arguments — with no judge call and no token cost, landing as native scores alongside
judge scores.
That maps almost one-to-one onto the L1/L2 split in the gate ladder: code evaluators for the contract layer, LLM-as-judge for the semantic layer, both producing the same score object. Earlier additions worth knowing: Score Analytics for measuring evaluator alignment across precision, recall and F1, and baseline comparison for flagging a specific run as the reference point.
Since your direction is the NVIDIA stack on AWS and GCP, it's worth knowing the vendor-shaped version of what you're building by hand. NeMo Evaluator runs custom and industry benchmarks behind a small API surface; NeMo Guardrails is the runtime rails layer (input, dialog, retrieval, execution, output) available both as an open-source Python library and as a production microservice, with configurations portable between the two. As of the 25.10 microservices release, Guardrails ships end-to-end distributed tracing with context propagating across guardrails, inference and downstream services, plus Kubernetes ConfigMap-based configuration — meaning the guardrail decisions land in the same trace tree as your gateway spans rather than in a separate log.
On the serving side, Dynamo exposes runtime metrics under a
dynamo_* prefix at the same /metrics endpoint as the backend engine's
own metrics, and its Kubernetes operator creates PodMonitor resources automatically so
kube-prometheus-stack discovers them without manual scrape config. GPU metrics still come from
dcgm-exporter underneath.
The trade is the usual one, and it's worth being clear-eyed: the managed path gets you tracing, rails and evaluation in days instead of weeks, at the cost of an NVIDIA AI Enterprise subscription and a stack that is harder to reason about when it misbehaves. Building it yourself first — which is what this capstone does — is the thing that makes you able to evaluate that trade rather than accept it.
Apply to my stack — the build
10%Everything below assumes the components from S1–S4 exist. This section is the assembly: the repo layout, the three files that hold the whole thing together, and a phased plan that never leaves you with a half-wired system.
4.1 · Repo layout
evals/ golden/ # the asset. one YAML per case, owned, versioned fin_sentiment_*.yaml # 150 cases fin_risk_*.yaml # 200 fin_signal_*.yaml # 200 fin_compliance_*.yaml # 250 ← zero-tolerance slice fin_router_*.yaml # 150 adversarial_*.yaml # 80 _suite.lock # snapshot hash — frozen per release validators/ # deterministic. L1 + 100% of production traffic schema.py ranges.py citation_binding.py refusal.py judges/ # from S4: rubric + position swap + calibration faithfulness_v3.yaml pairwise.py align.py harness/ run.py # the single entry point for L1/L2/L3 tuple.py # freeze + emit the system tuple stats.py # bootstrap CIs, Wilson bounds, MDE gates.yaml # ← the file below. thresholds live in ONE place gateway/ quality.py # inline validators + the async sampler fork tracing.py metrics.py policy.py prompts.py monitoring/ prometheus/alert_rules.yml grafana/control_tower.json .github/workflows/ eval-pr.yml # L2 eval-release.yml # L3
4.2 gates.yaml — every threshold in one auditable file
This is the single most important file in the capstone. Not because it's clever, but because thresholds scattered across code and dashboards are thresholds nobody can review. Every entry carries a metric, a scope, a threshold, an owner and an action — the source material's four-part gate, made into config.
meta: suite_ver: "2026.07.3" reviewed: "2026-07-20" # stale review = stale gate. re-review quarterly. hard_blockers: # release does not ship. no override path. - id: unsupported_compliance_claim metric: citation_binding_failure_rate scope: "slice=compliance_flag" # ← slice-scoped, never aggregate threshold: "== 0" owner: "you" action: block_release why: "a cited span that is not in the source is a fabricated citation" - id: schema_validity metric: parse_valid_rate scope: "all" threshold: ">= 0.995" owner: "you" action: block_release why: "downstream automation consumes these fields directly" - id: unsafe_advice metric: refusal_correctness scope: "slice=adversarial, risk=high" threshold: ">= 0.98" owner: "you" action: block_release soft_gates: # require a written, named acceptance - id: risk_score_band_accuracy metric: banded_exact_match scope: "slice=risk_score" threshold: ">= baseline - 0.02" # relative to last approved release ci: required # must not fire on overlapping intervals owner: "you" action: review_and_accept - id: faithfulness metric: judge_faithfulness_mean scope: "slice=trading_signal" threshold: ">= 4.10" judge: "gemma-4-e4b @ rubric faithfulness_v3" # evaluator is part of the gate ci: required owner: "you" action: review_and_accept - id: cost_per_request metric: usd_per_request_p50 scope: "all" threshold: "<= baseline * 1.15" owner: "you" action: review_and_accept canary_gates: # live traffic, same-window comparison - id: canary_contract_failures metric: contract_failure_rate compare: "canary vs stable, same window" threshold: "<= stable * 1.5" window: 15m action: auto_rollback - id: canary_ttft metric: ttft_p95_seconds compare: "canary vs stable, same window" threshold: "<= stable + 0.4" window: 15m action: pause_and_page
ci: required is on the soft gates and not the hard blockers
A hard blocker on a zero-tolerance event fires on a single occurrence — there is no
distribution to put an interval around, and demanding statistical significance before blocking a
fabricated citation would be absurd. Soft gates compare two noisy means, so they must not fire on
overlapping confidence intervals or you have built a rollback-fatigue machine. Same file, opposite
statistical treatment, for a defensible reason.
4.3 · The async sampler fork in your gateway
This is the one piece of production code with a hard latency constraint: it must add no measurable time to the user's response. The pattern is fire-and-forget onto a bounded queue, with the bounded part doing the important work — if the judge backs up, you drop samples rather than grow memory or block requests.
import asyncio, random, time # sample rates derived in Reality Check C — inversely ∝ volume, × risk SAMPLE_RATE = { "compliance_flag": 0.15, "trading_signal": 0.08, "risk_score": 0.04, "sentiment": 0.005, } class QualityFork: def __init__(self, judge, metrics, maxsize=2000, daily_budget_usd=0.60): self.q = asyncio.Queue(maxsize=maxsize) # bounded: backpressure, not OOM self.judge, self.metrics = judge, metrics self.budget = daily_budget_usd self.spent = 0.0 def offer(self, record): # called INLINE on the response path. must never await, never raise. if random.random >= SAMPLE_RATE.get(record.slice, 0.0): return try: self.q.put_nowait(record) except asyncio.QueueFull: # dropping is CORRECT here. record it so the gap is visible. self.metrics.judge_dropped.inc async def worker(self): while True: rec = await self.q.get if self.spent >= self.budget: self.metrics.judge_budget_exhausted.inc; self.q.task_done; continue try: score = await self.judge.score(rec) # position-swapped, S4 self.spent += score.cost_usd # emit BOTH: a span event for the trace, a metric for the SLO emit_eval_event(rec.trace_id, rec.response_id, score) self.metrics.judge_score.labels( slice=rec.slice, metric=score.name).observe(score.value) except Exception as e: # NEVER default a failed judge call to "pass" self.metrics.judge_failures.labels(err=type(e).__name__).inc finally: self.q.task_done
from opentelemetry import trace _log = get_logger_provider.get_logger("gemma.eval") def emit_eval_event(trace_id, response_id, score): # The judge runs minutes after the span closed, so we cannot parent to a # live span. The spec covers exactly this: set gen_ai.response.id instead. _log.emit(LogRecord( event_name="gen_ai.evaluation.result", attributes={ "gen_ai.evaluation.name": score.name, # required "gen_ai.evaluation.score.value": score.value, "gen_ai.evaluation.score.label": score.label, # low cardinality! "gen_ai.evaluation.explanation": score.explanation, "gen_ai.response.id": response_id, # not in the spec — your own tuple, so scores stay comparable "eval.judge_id": score.judge_id, "eval.rubric_ver": score.rubric_ver, "eval.slice": score.slice, }))
the source material's quality-metrics section suggests OpenAI Evals, LangSmith and Arize as the framework options and says an experimental OTel spec defines a core set of conventions. Both statements have moved. The conventions now live in their own repository with their own release cadence and include the evaluation-result event above — which did not exist when the section was written, and which is what makes “export judge scores as Prometheus metrics” a portable pattern rather than a bespoke one. On the tooling side, the 2026 open-source shortlist for what this capstone needs is narrower and more specialised: lm-evaluation-harness for base-model benchmarking, Ragas for retrieval-specific metrics, DeepEval for pytest-style CI gates, promptfoo for red-teaming and multi-model comparison, and Langfuse or Phoenix for the production trace-and-score layer.
And a constraint the materials can't know about: your Gemma
runs on Modal with min_containers=0 and max_containers=2. vLLM's
/metrics is served on the same port Modal exposes, so it is reachable — but
scraping it means (a) counters reset to zero on every cold start, (b) two replicas behind one URL
produce interleaved, non-additive series, and (c) the scrape itself keeps a GPU warm and bills you.
The resolution: gateway-side metrics are the source of truth for every SLO and every gate
in gates.yaml; vLLM /metrics is pulled opportunistically for
engine diagnostics only, labelled with a replica ID, and nothing in the alerting path depends on it.
4.4 · Alert rules you can defend
groups: - name: gemma.quality rules: # ── zero-tolerance: any occurrence, fast window, page immediately ── - alert: UnsupportedComplianceClaim expr: increase(citation_binding_failures_total[5m]) > 0 for: 0m labels: {severity: critical} annotations: summary: "Compliance flag cited a span not present in the source" runbook_url: "https://.../runbooks/unsupported-claim" # the runbook says: capture trace, disable compliance_flag intent, # add the case to evals/golden/, THEN debug. # ── quality SLO: slow-burn, two windows, so one bad hour doesn't page ── - alert: FaithfulnessSLOBurn expr: | (avg_over_time(judge_score{metric="faithfulness",slice="trading_signal"}[1h]) < 4.1) and (avg_over_time(judge_score{metric="faithfulness",slice="trading_signal"}[6h]) < 4.1) for: 30m labels: {severity: warning} annotations: summary: "Faithfulness below objective on trading_signal for 30m+" runbook_url: "https://.../runbooks/faithfulness-drop" # ── the measuring instrument itself can fail. treat it as a service. ── - alert: JudgeCoverageLost expr: rate(judge_scores_total[30m]) < 0.5 * rate(judge_scores_total[7d] offset 7d) for: 20m labels: {severity: warning} annotations: summary: "Judged sample rate halved — quality metrics are now unreliable" # WITHOUT this, a judge outage looks like flat, healthy quality. # ── drift: the offline suite may no longer represent production ── - alert: IntentDistributionDrift expr: intent_distribution_l1_distance > 0.20 for: 6h labels: {severity: info} annotations: summary: "Traffic mix shifted >20% vs baseline — review golden set coverage"
JudgeCoverageLost exists because of an asymmetry that catches teams badly: when your
judge pipeline breaks, the quality dashboard doesn't go red — it goes flat, holding its
last good value, and flat reads as healthy. Every quality signal in a control tower needs a paired
liveness signal, or your most reassuring dashboard is the one that fails silent.
4.5 · What this upgrades in your existing toy code
| Your component today | What it got right | The upgrade |
|---|---|---|
ObservabilityManagerTraceContext |
You already think in terms of a request-scoped context object — that's the correct shape. | Replace the home-grown context with an OTel tracer and let the SDK do propagation. Your TraceContext becomes a thin helper that attaches the system tuple as span attributes. You keep the ergonomics and gain portability across Jaeger, Tempo, Langfuse and Phoenix without rewriting instrumentation. |
QualityMonitor (user feedback + format-compliance scoring) |
Format-compliance scoring inline on every request is exactly right, and most teams don't do it. | Split it in two. The format-compliance half becomes validators/ — runs at 100%, feeds L1 and the canary gate. The subjective half becomes the sampled judge path. Right now they're one class with one sample rate, which forces you to choose between “cheap and shallow” and “expensive and complete”; splitting lets you have both. |
CostTracker |
Per-request cost attribution — the hard part is already done. | Add a second dimension: cost_source ∈ {serving, judge, retry, reflection}. Without it, the day judge spend doubles you'll see “cost went up” with no way to attribute it, and reflection retries are invisible inside serving cost. |
PerformanceTracker (GPU stats) |
You knew GPU telemetry mattered enough to build it. | Replace with dcgm-exporter on any K8s deployment. On Modal you can't run a DaemonSet, so keep a minimal in-process reader — but stop treating utilisation as the headline. KV-cache utilisation and queue depth predict user-visible latency; GPU utilisation percentage mostly doesn't. |
QuickMetricsCollector (percentiles, per-model distribution) |
Per-model distribution is the right slice, and percentiles beat means. | Client-side percentiles can't be aggregated across replicas — averaging two p95s is not a p95. Export histogram buckets and compute quantiles in PromQL. With max_containers=2 this already matters today. |
| Plotly dashboards | Fine for analysis notebooks. | Not a monitoring surface — no alerting, no shared state, no deploy annotations. Move operational views to Grafana; keep Plotly for the calibration and sizing analyses in align.py, where it's genuinely better. |
| Existing alert rules + Grafana JSON | The scaffolding exists, which is most of the friction. | Audit every rule against the alert-vs-dashboard decision tree above. Expect to delete more than you add — and add the quality and judge-liveness rules, which nothing in a pre-eval stack would have. |
| E2E test: 100% success, 91% cache hit, 3-model routing | A real end-to-end assertion with real numbers. | These become panels with baselines, not one-off test output. And note the framing shift: 100% success rate over 11 queries has a 95% lower bound near 72% — it's a smoke test, not evidence. Recreate all three on live dashboards where the sample size does the work. |
4.6 · Phased plan — six weeks, never half-wired
Week 1 · Instrument before you evaluate
Ship: system tuple emitted on every request; OTel tracing in the gateway with
gen_ai.* attributes; gateway-side histograms for TTFT, e2e latency, tokens, cost;
slice tags on every request.
Why first: every later phase needs slice tags, and you cannot retroactively slice data you never labelled. A week of tagging bought here saves a month of “we can't tell which slice regressed” later.
Done when: you can answer “what was the p95 TTFT for compliance-flag requests on prompt version 3.1 last Tuesday?” without writing new code.
Week 2 · Build the golden set, compliance slice first
Ship: 250 compliance cases with citation-binding assertions; 150 sentiment
cases with labels; the _suite.lock mechanism; one owner per case file.
Why this order: compliance is your highest-risk slice and its evaluator is free. Building it first gets you a real, blocking gate in week two, which is what earns the project the runway to continue.
Done when: the citation-binding check runs against production traffic at 100% and you know the current failure rate — the first genuinely new fact this project produces.
Week 3 · Harness and the two cheap gates
Ship: harness/run.py with frozen-tuple runs and per-slice reports;
bootstrap confidence intervals in stats.py; gates.yaml; the L1 pre-commit
smoke slice and the L2 PR workflow with a slice-diff comment.
Deliberately no judge yet. Everything so far is deterministic and free, which means the harness gets debugged without judge noise confusing you about whether a failure is real.
Done when: a PR that breaks the output schema goes red automatically, with a comment naming the slice.
Week 4 · Add the judge, and prove it's worth trusting
Ship: the S4 judge with position swapping; 60 human-labelled calibration cases
on the trading-signal slice; align.py reporting precision, recall, TPR/TNR and a
chance-corrected agreement statistic; the remaining golden slices.
Gate on yourself: do not wire the judge into gates.yaml until it
clears your alignment bar on its own calibration set. An uncalibrated judge in a gate is worse
than no gate — it produces confident, wrong blocking decisions and burns the team's trust in the
whole apparatus.
Done when: you can state the judge's true-positive rate on the failures that matter, not just its agreement percentage.
Week 5 · Online sampling and the alerting path
Ship: the async QualityFork with stratified rates and a daily
budget; gen_ai.evaluation.result emission; the control-tower Grafana board with
deploy annotations; the four alert rules including judge liveness; L4 canary gates on the
deploy pipeline.
The measurement to take: confirm the fork adds no measurable p99 latency. Run a load test with the fork enabled and disabled and compare — if there's a delta, the fork is doing work it shouldn't.
Done when: a deliberately broken prompt deployed to canary rolls itself back within 15 minutes, without a human.
Week 6 · Close the loop, then leave it alone for a month
Ship: the human review queue with disagreement routing; the incident → golden-case path as a documented, boring procedure; the weekly trend review; L3 release gate with the full sized suite and confidence intervals.
Then stop building and operate it. A month of running the tower teaches you which gates are noisy, which alerts nobody acted on, and which slices are under-sized — and nothing in a design document can tell you those things. the source material's cadence is a good default: gate every release, review slice dashboards weekly, add regression cases from incidents weekly.
Done when: a real incident produces a new golden case without anyone deciding to make that happen.
4.7 · Optional exercise
Your complexity classifier is the most evaluable component you own and almost certainly the least evaluated. It makes a discrete, labellable decision, and its errors have directly measurable cost. Treat it as a first-class eval target:
- Pull 150 real routed requests from your traces and hand-label the correct tier.
- Build the confusion matrix — but weight it by cost, not by count. Routing a simple query to the expensive model wastes money; routing a complex compliance query to the cheap model produces a wrong answer in your highest-risk slice. Those two errors are not equal, and unweighted accuracy treats them as if they were.
- Compute the cost-weighted error in dollars and in high-risk misroutes per 1,000 requests.
- Now the interesting question: is your router better than always using the mid-tier model? Compute that baseline. It is surprisingly often competitive, and a router that loses to a constant is a component you can delete.
What you'll take from it: asymmetric error costs are the norm in production and the exception in benchmarks. Once you've built one cost-weighted confusion matrix, unweighted accuracy stops looking like a metric and starts looking like a missing assumption.
gates.yaml — because a fine-tune is just another release candidate, and it goes
through the exact same ladder.