Free lesson · GenAI Solutions Architecture
Build eval gate component with pluggable evaluator registry
You will build an EvalGateRegistry that provides a pluggable evaluation framework where eval gates intercept LLM responses before delivery to users, with configurable evaluator chains and threshold policies. Implement EvalGate as a Pydantic model with fields: gate_id: str, name: str, description: str, pipeline_position: PipelinePosition (PRE_RETRIEVAL, POST_RETRIEVAL, POST_GENERATION, PRE_DELIVERY), evaluators: list[EvaluatorConfig], threshold_policy: ThresholdPolicy with min_score: float, action_on_fail: FailAction (BLOCK, RETRY, FALLBACK, LOG_ONLY), max_retries: int, fallback_response: Optional[str], timeout_ms: int, is_enabled: bool. Define EvaluatorConfig with evaluator_type: EvaluatorType (LLM_AS_JUDGE, RULE_BASED, EMBEDDING_SIMILARITY, REGEX_MATCH), evaluator_id: str, weight: float (contribution to overall gate score), config: dict[str, Any], timeout_ms: int. Build EvaluatorPlugin base class with abstract evaluate(request: EvalRequest) -> EvalResult method where EvalRequest has response_text: str, query: str, context: Optional[list[str]], metadata: dict and EvalResult Pydantic model has score: float, passed: bool, evidence: str, evaluator_id: str, latency_ms: int, details: dict[str, Any]. Implement LLMJudgeEvaluator that calls litellm.completion() with a configurable rubric prompt template and uses Instructor instructor.from_litellm() to extract a JudgmentResult with score: float, reasoning: str, issues: list[str], strengths: list[str]. Implement RuleBasedEvaluator that checks configurable rules defined in RuleConfig Pydantic model: max_response_length: int, min_response_length: int, required_format_regex: Optional[str], banned_phrases: list[str], required_sections: list[str], max_repetition_ratio: float. Implement EmbeddingSimilarityEvaluator that computes cosine similarity between response embedding (via openai.embeddings.create()) and reference embeddings stored in pgvector, returning similarity as the score. Build EvalGateMiddleware as FastAPI middleware registered via app.middleware('http') that intercepts responses at the configured pipeline_position, runs all evaluators in the gate's chain via asyncio.gather(), computes weighted score as sum(eval.score * eval.weight) / sum(eval.weight), and applies threshold_policy. Store gate definitions in PostgreSQL eval_gates table with gate_id VARCHAR(64) PRIMARY KEY, name VARCHAR(128), pipeline_position VARCHAR(32), evaluators_config JSONB, threshold_policy JSONB, is_enabled BOOLEAN, created_at TIMESTAMPTZ and evaluation results in eval_results table with result_id VARCHAR(64) PRIMARY KEY, gate_id VARCHAR(64), request_id VARCHAR(64), overall_score FLOAT, passed BOOLEAN, evaluator_results JSONB, action_taken VARCHAR(16), evaluated_at TIMESTAMPTZ. Emit eval_gate_evaluations_total{gate_id,result}, eval_gate_latency_seconds{gate_id}, eval_gate_score{gate_id} Prometheus metrics. Expose POST /api/v1/eval-gates, GET /api/v1/eval-gates/{id}, and GET /api/v1/eval-gates/{id}/results FastAPI endpoints.
Course: GenAI Architecture & Design Patterns · Chapter 4 · Eval-First Architecture Engine
Free to read — no subscription required.
Introduction
Teams that ship GenAI features into production quickly find that response quality is not a property of the model alone — it emerges from the pipeline that wraps every call. When eval gates are bolted on as an afterthought, hallucinations leak into customer-facing surfaces, expensive judge calls run on requests a cheap regex would have rejected, and there is no controlled path to add a new check without touching every caller. By the end of this lesson you'll be able to build an evaluator registry that wires rule-based, embedding-based, and LLM-as-judge checks behind a single pluggable contract and orders them in a cost-ascending cascade with early termination on failure.
Key Terminology
- Eval gate: A load-bearing pipeline component that inspects a prompt-response pair and emits a pass/fail verdict before the response is allowed to propagate downstream.
- Evaluator registry: A central object that maps string names to evaluator instances, enforces uniqueness, and orchestrates cost-ordered execution of evaluator chains.
- Cost-ascending cascade: An execution strategy that runs the cheapest evaluator first and short-circuits on the first failure, so expensive LLM-as-judge calls only run on responses that survived rule-based and embedding-based checks.
Concepts
Core ideas the lesson teaches: a uniform evaluator contract (BaseEvaluator returning EvalResult), a cost-tier taxonomy that orders evaluators from rule-based to LLM-as-judge, and a registry that composes them into cascades with a latency budget and an explicit over-budget policy.
Wiring the Registry
Wiring the registry means three concrete steps that the Code Walkthrough below makes operational. First, every evaluator family — rule-based, embedding-based, LLM-as-judge — subclasses BaseEvaluator and declares a cost_tier, so the registry can sort callers' name lists into cheapest-first order regardless of how they were specified. Second, EvalGateRegistry.register binds an evaluator instance to a unique name, giving callers a stable handle that survives swapping the underlying implementation (e.g. moving from a Haiku-class judge to an Opus-class judge without touching call sites). Third, run_cascade drives the chain end-to-end: it consults cost_tier for ordering, applies the budget_ms ceiling between gates, and breaks on the first failing EvalResult so the expensive tiers never run on responses that have already been rejected.
Code Walkthrough
Core Abstractions: EvalResult and BaseEvaluator
Before building the registry, you need stable contracts for what an evaluator accepts and returns. The two foundational abstractions are the EvalResult dataclass that carries verdict metadata and the BaseEvaluator abstract class that all evaluator families implement. Every evaluator — regardless of whether it executes a regex or calls GPT-4 — must return the same EvalResult structure so that downstream routing logic remains decoupled from evaluator internals.
The following code defines the EvalResult dataclass with fields for the binary pass/fail verdict, a continuous confidence score, the evaluator name for traceability, and an optional metadata dictionary. It also defines the BaseEvaluator abstract base class with an evaluate method that accepts a prompt-response pair and returns an EvalResult, plus a cost_tier property that the registry uses to order evaluators within a cascade.
Code snippet python
1from abc import ABC, abstractmethod 2from dataclasses import dataclass, field 3from enum import IntEnum 4from typing import Optional 5 6class CostTier(IntEnum): 7 NEGLIGIBLE = 0 # rule-based: regex, length checks 8 LOW = 1 # embedding: vector similarity 9 MEDIUM = 2 # small LLM judge (Haiku-class) 10 HIGH = 3 # large LLM judge (Opus-class) 11 12@dataclass(frozen=True) 13class EvalResult: 14 passed: bool 15 score: float # 0.0 to 1.0 continuous 16 evaluator_name: str 17 latency_ms: float = 0.0 18 metadata: dict = field(default_factory=dict) 19 20 def __post_init__(self): 21 if not 0.0 <= self.score <= 1.0: 22 raise ValueError(f"Score {self.score} outside [0, 1]") 23 24class BaseEvaluator(ABC): 25 def __init__(self, name: str, threshold: float = 0.5): 26 self.name = name 27 self.threshold = threshold 28 29 @property 30 @abstractmethod 31 def cost_tier(self) -> CostTier: 32 ... 33 34 @abstractmethod 35 async def evaluate( 36 self, prompt: str, response: str, context: Optional[dict] = None 37 ) -> EvalResult: 38 ... 39 40 def make_result(self, score: float, **meta) -> EvalResult: 41 return EvalResult( 42 passed=score >= self.threshold, 43 score=score, 44 evaluator_name=self.name, 45 metadata=meta, 46 )
- Lines 1-3: Import the ABC and abstractmethod machinery for the evaluator contract, dataclass with field for immutable result objects, and IntEnum for cost tier ordering.
- Lines 5-9: Define CostTier as an IntEnum with four levels. Using integer enumeration lets the registry sort evaluators by cost with a simple comparison — rule-based evaluators at tier 0 always execute before LLM judges at tier 3.
- Lines 11-17: The EvalResult dataclass is frozen (immutable) to prevent accidental mutation as results flow through pipeline stages. The score field carries a continuous 0.0–1.0 value that enables ROC analysis, while passed provides the binary gate decision.
- Lines 19-20: The post_init validator raises a ValueError if the score falls outside the normalized range, catching miscalibrated evaluators at the point of result creation rather than downstream.
- Lines 22-25: BaseEvaluator stores a name for traceability in logs and dashboards, and a threshold that converts the continuous score into a binary verdict. Different operating points for the same evaluator are achieved by adjusting this threshold.
- Lines 27-29: The cost_tier abstract property forces every evaluator subclass to declare its cost category, which the registry reads during cascade construction.
- Lines 31-34: The evaluate method accepts the original prompt, the model response, and an optional context dictionary carrying retrieval chunks, conversation history, or tool call results. Returning an
asynccoroutine enables concurrent evaluation across multiple gates. - Lines 36-41: The make_result helper centralizes the threshold comparison logic so subclasses never implement pass/fail logic themselves — they only compute a score.
Evaluator Registry Architecture
The registry serves as the single point of registration, resolution, and orchestration for all evaluators in the system. It maps string names to evaluator instances, enforces uniqueness constraints, and provides a run_cascade method that executes evaluators in cost-tier order with early termination on failure.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a left-to-right flowchart layout, which visually represents the cascade as a horizontal pipeline matching the request flow direction.
- Lines 2-6: Define the EvalGateRegistry subgraph containing three evaluator nodes arranged top-to-bottom by cost tier: Rule-Based at CostTier.NEGLIGIBLE, Embedding-Based at CostTier.LOW, and LLM-as-Judge at CostTier.HIGH.
- Lines 8-9: Define the entry point where a prompt-response pair arrives at the Eval Gate decision node, which routes into the first evaluator in the registry.
- Lines 10-12: Define the PASS path through the cascade — each evaluator forwards to the next costlier tier only when it passes, culminating in Forward Response when all three tiers pass.
- Lines 13-15: Define the FAIL short-circuit paths — any evaluator that fails immediately routes to Reject / Fallback, bypassing all remaining tiers regardless of their position in the cascade.
This cascade pattern is the core value proposition of a registry-based design. When a rule-based evaluator detects an obvious format violation — a JSON response missing required fields, a response exceeding length limits, or a safety keyword trigger — the system short-circuits immediately without incurring embedding computation or LLM judge latency. In production systems processing thousands of requests per second, this early termination can reduce average eval overhead by 60–80% because the majority of failures are catchable by cheap heuristics.
The following implementation of EvalGateRegistry provides the register method for adding evaluators, a get method for resolving evaluators by name, and the critical run_cascade method that orchestrates evaluation in cost-ascending order. The cascade accepts an optional budget_ms parameter that enforces a latency ceiling — if cumulative evaluation time approaches the budget, remaining expensive evaluators are skipped and the gate defaults to the configured over_budget_policy.
Code snippet python
1import asyncio 2import time 3from typing import Dict, List 4 5class EvalGateRegistry: 6 def __init__(self, over_budget_policy: str = "pass"): 7 self._evaluators: Dict[str, BaseEvaluator] = {} 8 self.over_budget_policy = over_budget_policy # "pass" or "fail" 9 10 def register(self, evaluator: BaseEvaluator) -> None: 11 if evaluator.name in self._evaluators: 12 raise KeyError(f"Evaluator '{evaluator.name}' already registered") 13 self._evaluators[evaluator.name] = evaluator 14 15 def get(self, name: str) -> BaseEvaluator: 16 if name not in self._evaluators: 17 raise KeyError(f"No evaluator registered as '{name}'") 18 return self._evaluators[name] 19 20 def _sorted_evaluators(self, names: List[str]) -> List[BaseEvaluator]: 21 evals = [self.get(n) for n in names] 22 return sorted(evals, key=lambda e: e.cost_tier) 23 24 async def run_cascade( 25 self, 26 names: List[str], 27 prompt: str, 28 response: str, 29 context: dict = None, 30 budget_ms: float = float("inf"), 31 ) -> List[EvalResult]: 32 ordered = self._sorted_evaluators(names) 33 results: List[EvalResult] = [] 34 elapsed_ms = 0.0 35 36 for evaluator in ordered: 37 if elapsed_ms >= budget_ms: 38 default_passed = self.over_budget_policy == "pass" 39 results.append(EvalResult( 40 passed=default_passed, score=0.0, 41 evaluator_name=evaluator.name, 42 metadata={"skipped": True, "reason": "budget_exceeded"}, 43 )) 44 continue 45 46 t0 = time.perf_counter() 47 result = await evaluator.evaluate(prompt, response, context) 48 dt = (time.perf_counter() - t0) * 1000 49 elapsed_ms += dt 50 results.append(EvalResult( 51 passed=result.passed, score=result.score, 52 evaluator_name=result.evaluator_name, 53 latency_ms=dt, metadata=result.metadata, 54 )) 55 56 if not result.passed: 57 break # early termination on first failure 58 59 return results
- Lines 1-3: Import asyncio for coroutine support, time for high-resolution latency measurement via perf_counter, and typing constructs for the evaluator dictionary and result lists.
- Lines 5-8: The constructor initializes an empty evaluator dictionary and accepts an over_budget_policy parameter. Setting this to "fail" means that when the latency budget is exhausted, unevaluated gates default to rejection — a conservative choice appropriate for safety-critical pipelines.
- Lines 10-13: The register method enforces name uniqueness. Allowing duplicate names would create silent overwrites where a newly registered evaluator replaces an existing one, breaking cascade configurations that reference the original.
- Lines 15-18: The get method raises a KeyError with a descriptive message rather than returning None, making misconfiguration errors explicit at startup rather than producing silent AttributeError exceptions at evaluation time.
- Lines 20-22: The _sorted_evaluators helper resolves names to instances and sorts by cost_tier. This guarantees that even if callers specify names in arbitrary order, the cascade always executes cheapest-first.
- Lines 24-31: The run_cascade signature accepts a list of evaluator names, the prompt-response pair, optional context, and a budget_ms ceiling defaulting to infinity (no budget constraint).
- Lines 36-43: The budget check runs before each evaluator. When the budget is exhausted, the system creates a synthetic EvalResult with passed determined by the over_budget_policy and metadata marking the evaluator as skipped. This ensures callers always receive a result for every requested evaluator, simplifying downstream aggregation.
- Lines 45-52: The evaluation block measures wall-clock latency with perf_counter, awaits the evaluator coroutine, and wraps the result with observed latency. Capturing latency at the registry level rather than inside each evaluator ensures consistent measurement methodology.
- Lines 54-55: Early termination on failure is the cascade's primary latency optimization. Once a cheap rule-based evaluator rejects a response, there is no value in running the expensive LLM judge — the response is already marked for rejection or fallback.
Concrete Evaluator Implementations
With the registry and base abstractions in place, the three evaluator families become straightforward implementations. A rule-based evaluator executes deterministic checks — JSON schema validation, response length bounds, regex pattern matching for PII or prohibited content. An embedding-based evaluator computes cosine similarity between the response embedding and a reference distribution, catching semantic drift from expected answer patterns. An LLM-as-judge evaluator sends the prompt-response pair to a judge model with a structured rubric and parses the verdict from the judge's output.
Each family subclasses BaseEvaluator and declares its cost_tier. RuleBasedEvaluator accepts a list of Callable[[str], bool] predicates (length bounds, regex matches, PII detectors) at construction, reports CostTier.NEGLIGIBLE, and scores as the fraction of predicates that return True against the response — feeding make_result so threshold comparison stays centralized. EmbeddingEvaluator stores a precomputed reference centroid (the mean embedding of known-good responses) normalized once at construction, takes an async embed_fn that maps text to a numpy vector (so OpenAI, Cohere, or local backends are swappable), and at evaluation time embeds the response, normalizes it, and returns the dot product (cosine similarity for unit vectors) at CostTier.LOW — a similarity of ~0.7 typically indicates semantic alignment with the reference distribution. LLMJudgeEvaluator stores an async judge_fn callable plus a rubric string, formats a judge prompt that interpolates the rubric and prompt-response pair and requests structured JSON (score plus reasoning), parses the verdict, and reports CostTier.HIGH. Separating the rubric from the evaluator class means the same judge class scores faithfulness, coherence, or safety by swapping the rubric — and the reasoning string flows through EvalResult.metadata to support precision-recall analysis when triaging false positives and false negatives on the eval dashboard. You'll know the cascade works when registering all three families and calling run_cascade short-circuits the LLM judge whenever a cheap rule check fails first.
Do's and Don'ts
Do's
- ✓Do implement the
cost_tierabstract property on everyBaseEvaluatorsubclass — TheEvalGateRegistryreads thisCostTierinteger value to sort evaluators before building the cascade; a subclass that omits it raisesTypeErrorat instantiation, and one that declares the wrong tier may execute an LLM-as-judge (CostTier.HIGH) before a rule-based check (CostTier.NEGLIGIBLE), burning large-model tokens on requests a regex would have terminated with early failure. - ✓Do call
self.make_result(score, **meta)inside everyevaluate()implementation rather than constructingEvalResultdirectly —make_resultcentralizes thescore >= self.thresholdcomparison so that all evaluator families share identical pass/fail logic, and adjusting an evaluator's operating point requires changing only thethresholdconstructor argument rather than editing subclass bodies. - ✓Do normalize every evaluator's output to a continuous
scorein[0.0, 1.0]before returning —EvalResult.__post_init__raisesValueErrorfor out-of-range values, catching miscalibrated evaluators at result-creation time rather than silently corrupting downstream routing; the normalized range also lets you compare rule-based, embedding, and LLM-judge evaluators on the same ROC axis when tuning cascade thresholds.
Don'ts
- ✗Don't run evaluators outside cost-ascending
CostTierorder in the registry cascade — Placing aCostTier.HIGHLLM-as-judge before aCostTier.NEGLIGIBLErule-based check defeats early termination on failure: every request pays large-model latency and token cost even when a cheap regex would have rejected it first, which is the primary failure mode the cascade architecture exists to prevent. - ✗Don't encode pass/fail logic inside the
evaluate()method itself — Evaluators must compute and return only a continuousscore; the binarypassedfield is derived exclusively bymake_result()'sscore >= self.thresholdcheck. Duplicating that comparison insideevaluate()breaks the single-knob threshold guarantee and causes evaluators sharing a threshold to diverge silently when the operating point is tuned. - ✗Don't register two evaluators under the same string name in
EvalGateRegistry— The registry enforces uniqueness constraints precisely becauseEvalResult.evaluator_nameis the traceability key in production logs and dashboards; a collision overwrites one evaluator's entry with another's, making it impossible to attribute a gate rejection to the correct evaluator family when diagnosing quality regressions.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Solutions Architecture subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in GenAI Architecture & Design Patterns
- Ch 1Validate ADR decisions against production telemetry
- Ch 1Implement ADR recommendation engine using historical outcomes
- Ch 1Create ADR governance dashboard and compliance audit
- Ch 4Build eval gate component with pluggable evaluator registryYou are here
- Ch 4Measure eval gate effectiveness with precision-recall tracking
- Ch 4Create eval architecture audit report with coverage analysis
- Ch 6Implement Istio service mesh for AI microservice communication