GEMINI LABJP
NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaplyOMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflowsAGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactionsMEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuouslyTHROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and regionDEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaplyOMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflowsAGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactionsMEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuouslyTHROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and regionDEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Articles/API / SDK
API / SDK/2026-03-25Advanced

Building a Prompt Evaluation & Optimization Pipeline with Gemini API — Automated Quality Scoring with LLM-as-Judge

Learn how to build a prompt evaluation pipeline using Gemini API. Covers the LLM-as-Judge pattern, A/B testing prompts, automated quality scoring, and cost-quality optimization for production systems.

gemini-api277prompt-engineering15evaluation4llm-as-judge5python104automation51production140

Setup and context — Why Prompt Evaluation Matters

When running Gemini API applications in production, you've probably experienced that nagging feeling: "Did my prompt change actually improve things, or does it just seem better?" Relying on gut feeling doesn't scale when you're serving thousands of users.

Just as software engineering requires testing, LLM applications need systematic ways to measure prompt quality. Without quantitative evaluation, you're flying blind — unable to detect quality regressions, justify prompt changes to stakeholders, or optimize the cost-quality tradeoff.

In this guide, we'll build a complete evaluation pipeline that enables:

  • LLM-as-Judge scoring: Use Gemini itself as an evaluator to automatically score output quality
  • A/B testing infrastructure: Compare prompt variants with statistical rigor
  • Cost-quality optimization: Visualize and optimize the tradeoff between token usage and quality
  • Continuous monitoring: Detect quality degradation before your users do

This guide is aimed at developers who are comfortable with the Gemini API basics and are looking to bring production-grade quality assurance to their LLM applications.

Prerequisites & Setup

What You'll Need

Installation

pip install google-genai pandas scipy numpy

Initializing the API Client

import os
from google import genai
 
# Initialize the API client
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
 
# Models used in the evaluation pipeline
EVAL_MODEL = "gemini-2.5-pro"       # Judge model (evaluator)
TARGET_MODEL = "gemini-2.5-flash"   # Model being evaluated

A key principle: the Judge model should have equal or greater capability than the model being evaluated. Gemini 2.5 Pro's strong reasoning abilities make it well-suited for evaluation tasks.

Implementing the LLM-as-Judge Pattern

LLM-as-Judge is an approach where you use one LLM to evaluate the output quality of another (or the same) LLM. Research has shown high correlation with human judgments, and it can process large test suites far faster than manual review.

Defining Evaluation Criteria

Clear, specific rubrics are essential. Vague criteria lead to inconsistent scores. Here's a battle-tested rubric design:

EVALUATION_RUBRIC = """
You are an expert evaluator of AI assistant outputs.
Score the following response on 4 dimensions, each from 1 to 5.
 
## Evaluation Criteria
 
### 1. Accuracy
- 5: Completely factually accurate. No misinformation
- 4: Mostly accurate. One minor inaccuracy
- 3: Generally accurate but contains several minor inaccuracies
- 2: Contains significant errors
- 1: Mostly inaccurate
 
### 2. Relevance
- 5: Perfectly addresses the user's question
- 4: Mostly addresses the question with minor gaps or extras
- 3: Partially addresses the question
- 2: Mostly off-topic
- 1: Completely irrelevant
 
### 3. Clarity
- 5: Exceptionally clear and well-structured
- 4: Clear with minor room for improvement in structure
- 3: Understandable but some confusing sections
- 2: Difficult to follow
- 1: Nearly incomprehensible
 
### 4. Completeness
- 5: Thoroughly covers all necessary information
- 4: Nearly complete with minor omissions
- 3: Covers key points but has significant gaps
- 2: Mostly incomplete
- 1: Barely any useful information
 
## Output Format
You MUST respond in the following JSON format:
{
  "accuracy": <1-5>,
  "relevance": <1-5>,
  "clarity": <1-5>,
  "completeness": <1-5>,
  "overall": <weighted average of 1-5>,
  "reasoning": "<2-3 sentence justification>"
}
"""

Building the Auto-Evaluation Function

import json
from google.genai import types
 
def evaluate_response(
    user_query: str,
    ai_response: str,
    reference_answer: str | None = None,
) -> dict:
    """
    Use Gemini as a Judge to automatically evaluate AI response quality.
 
    Args:
        user_query: The original user question
        ai_response: The AI output to evaluate
        reference_answer: Optional gold-standard answer for comparison
 
    Returns:
        Dictionary containing scores for each criterion and reasoning
    """
    judge_prompt = f"""{EVALUATION_RUBRIC}
 
## Response to Evaluate
 
**User Query:**
{user_query}
 
**AI Response:**
{ai_response}
"""
    if reference_answer:
        judge_prompt += f"""
**Reference Answer (ideal response):**
{reference_answer}
"""
 
    response = client.models.generate_content(
        model=EVAL_MODEL,
        contents=judge_prompt,
        config=types.GenerateContentConfig(
            temperature=0.0,  # Maximize evaluation consistency
            response_mime_type="application/json",
        ),
    )
 
    try:
        scores = json.loads(response.text)
        # Record token usage for cost tracking
        scores["usage"] = {
            "prompt_tokens": response.usage_metadata.prompt_token_count,
            "output_tokens": response.usage_metadata.candidates_token_count,
        }
        return scores
    except json.JSONDecodeError:
        return {"error": "Failed to parse JSON", "raw": response.text}
 
# Usage example
result = evaluate_response(
    user_query="How do I write a list comprehension in Python?",
    ai_response="A list comprehension is written as [expr for var in iterable]."
)
print(json.dumps(result, indent=2))
# Expected output:
# {
#   "accuracy": 5,
#   "relevance": 5,
#   "clarity": 4,
#   "completeness": 3,
#   "overall": 4.25,
#   "reasoning": "Syntax is correct but lacks examples of conditional
#                 comprehensions and nested expressions."
# }

Techniques for More Reliable Evaluation

LLM-as-Judge is powerful, but it's susceptible to certain biases — most notably position bias (preferring whichever response appears first). Here's how to mitigate it:

import random
 
def evaluate_with_debiasing(
    user_query: str,
    response_a: str,
    response_b: str,
    num_rounds: int = 3,
) -> dict:
    """
    Mitigate position bias by randomly swapping response order
    across multiple evaluation rounds and averaging the scores.
    """
    scores_a = []
    scores_b = []
 
    for i in range(num_rounds):
        # Randomly swap presentation order
        if random.random() < 0.5:
            first, second = response_a, response_b
            order = "AB"
        else:
            first, second = response_b, response_a
            order = "BA"
 
        comparison_prompt = f"""
Compare these two AI responses and evaluate each one.
 
**User Query:** {user_query}
 
**Response 1:**
{first}
 
**Response 2:**
{second}
 
Score each response from 1-5 and return JSON:
{{"score_1": <int>, "score_2": <int>, "reasoning": "<str>"}}
"""
        response = client.models.generate_content(
            model=EVAL_MODEL,
            contents=comparison_prompt,
            config=types.GenerateContentConfig(
                temperature=0.0,
                response_mime_type="application/json",
            ),
        )
 
        result = json.loads(response.text)
        if order == "AB":
            scores_a.append(result["score_1"])
            scores_b.append(result["score_2"])
        else:
            scores_a.append(result["score_2"])
            scores_b.append(result["score_1"])
 
    return {
        "response_a_avg": sum(scores_a) / len(scores_a),
        "response_b_avg": sum(scores_b) / len(scores_b),
        "response_a_scores": scores_a,
        "response_b_scores": scores_b,
    }

Building an A/B Testing Framework for Prompts

To verify whether a prompt change is genuinely better, you need statistical rigor — not just a higher average score.

Managing Test Cases and Variants

from dataclasses import dataclass, field
from datetime import datetime
 
@dataclass
class PromptVariant:
    """A prompt variant to test"""
    name: str
    system_instruction: str
    template: str
    model: str = "gemini-2.5-flash"
    temperature: float = 0.7
    max_tokens: int = 2048
 
@dataclass
class TestCase:
    """An evaluation test case"""
    query: str
    reference_answer: str | None = None
    category: str = "general"
    metadata: dict = field(default_factory=dict)
 
@dataclass
class EvalResult:
    """A single evaluation result"""
    variant_name: str
    test_case_query: str
    scores: dict
    latency_ms: float
    token_count: int
    cost_usd: float
    timestamp: str = field(
        default_factory=lambda: datetime.now().isoformat()
    )

The A/B Test Runner

import time
import pandas as pd
from scipy import stats
 
# Gemini API pricing (approximate, as of March 2026)
PRICING = {
    "gemini-2.5-pro": {"input": 1.25 / 1_000_000, "output": 10.0 / 1_000_000},
    "gemini-2.5-flash": {"input": 0.15 / 1_000_000, "output": 0.60 / 1_000_000},
}
 
def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
    """Estimate API cost from token usage"""
    pricing = PRICING.get(model, PRICING["gemini-2.5-flash"])
    return (input_tokens * pricing["input"]) + (output_tokens * pricing["output"])
 
def run_ab_test(
    variants: list[PromptVariant],
    test_cases: list[TestCase],
) -> pd.DataFrame:
    """
    Run all prompt variants against all test cases,
    evaluate each response, and return results as a DataFrame.
    """
    results = []
 
    for variant in variants:
        print(f"\n🔄 Testing variant: {variant.name}")
 
        for i, tc in enumerate(test_cases):
            prompt = variant.template.format(query=tc.query)
 
            # Measure latency
            start = time.time()
            response = client.models.generate_content(
                model=variant.model,
                contents=prompt,
                config=types.GenerateContentConfig(
                    system_instruction=variant.system_instruction,
                    temperature=variant.temperature,
                    max_output_tokens=variant.max_tokens,
                ),
            )
            latency_ms = (time.time() - start) * 1000
 
            # Evaluate quality
            scores = evaluate_response(
                user_query=tc.query,
                ai_response=response.text,
                reference_answer=tc.reference_answer,
            )
 
            # Calculate cost
            input_tokens = response.usage_metadata.prompt_token_count
            output_tokens = response.usage_metadata.candidates_token_count
            cost = estimate_cost(variant.model, input_tokens, output_tokens)
 
            results.append(EvalResult(
                variant_name=variant.name,
                test_case_query=tc.query,
                scores=scores,
                latency_ms=latency_ms,
                token_count=input_tokens + output_tokens,
                cost_usd=cost,
            ))
 
            print(f"  [{i+1}/{len(test_cases)}] "
                  f"Score: {scores.get('overall', 'N/A')} | "
                  f"Latency: {latency_ms:.0f}ms | "
                  f"Cost: ${cost:.6f}")
 
    # Convert to DataFrame
    df = pd.DataFrame([
        {
            "variant": r.variant_name,
            "query": r.test_case_query,
            "overall_score": r.scores.get("overall", 0),
            "accuracy": r.scores.get("accuracy", 0),
            "relevance": r.scores.get("relevance", 0),
            "clarity": r.scores.get("clarity", 0),
            "completeness": r.scores.get("completeness", 0),
            "latency_ms": r.latency_ms,
            "tokens": r.token_count,
            "cost_usd": r.cost_usd,
        }
        for r in results
    ])
 
    return df

Statistical Testing and Analysis

def analyze_ab_results(df: pd.DataFrame) -> dict:
    """
    Statistically analyze A/B test results using Welch's t-test
    (does not assume equal variance).
    """
    variants = df["variant"].unique()
    if len(variants) < 2:
        return {"error": "Need at least 2 variants for comparison"}
 
    analysis = {}
    baseline = variants[0]
    baseline_scores = df[df["variant"] == baseline]["overall_score"]
 
    for variant in variants[1:]:
        variant_scores = df[df["variant"] == variant]["overall_score"]
 
        # Welch's t-test
        t_stat, p_value = stats.ttest_ind(
            variant_scores, baseline_scores, equal_var=False
        )
 
        # Effect size (Cohen's d)
        pooled_std = (
            (baseline_scores.std() ** 2 + variant_scores.std() ** 2) / 2
        ) ** 0.5
        cohens_d = (
            (variant_scores.mean() - baseline_scores.mean()) / pooled_std
            if pooled_std > 0 else 0
        )
 
        # Cost comparison
        baseline_cost = df[df["variant"] == baseline]["cost_usd"].sum()
        variant_cost = df[df["variant"] == variant]["cost_usd"].sum()
 
        analysis[f"{baseline}_vs_{variant}"] = {
            "baseline_mean": round(baseline_scores.mean(), 3),
            "variant_mean": round(variant_scores.mean(), 3),
            "improvement": round(variant_scores.mean() - baseline_scores.mean(), 3),
            "p_value": round(p_value, 4),
            "significant": p_value < 0.05,
            "cohens_d": round(cohens_d, 3),
            "effect_size": (
                "large" if abs(cohens_d) > 0.8
                else "medium" if abs(cohens_d) > 0.5
                else "small"
            ),
            "cost_ratio": round(variant_cost / baseline_cost, 2) if baseline_cost > 0 else None,
            "recommendation": _get_recommendation(
                p_value, cohens_d, variant_cost / baseline_cost if baseline_cost > 0 else 1
            ),
        }
 
    return analysis
 
def _get_recommendation(p_value: float, cohens_d: float, cost_ratio: float) -> str:
    """Generate an actionable recommendation based on the analysis"""
    if p_value >= 0.05:
        return "No significant difference. Increase sample size or make larger prompt changes."
    if cohens_d > 0.5 and cost_ratio <= 1.2:
        return "✅ Recommended. Significant quality improvement with acceptable cost increase."
    if cohens_d > 0.2 and cost_ratio <= 1.0:
        return "✅ Recommended. Quality improvement with no cost increase."
    if cohens_d > 0.5 and cost_ratio > 1.5:
        return "⚠️ Quality improves but cost increases significantly. Evaluate ROI carefully."
    return "△ Marginal improvement only. Consider alternative approaches."

Putting It All Together

# Define prompt variants
variant_a = PromptVariant(
    name="baseline",
    system_instruction="You are a helpful AI assistant.",
    template="Please answer the following question.\n\n{query}",
)
 
variant_b = PromptVariant(
    name="structured",
    system_instruction=(
        "You are a technical expert who provides accurate, well-structured answers. "
        "Always state the conclusion first, then provide detailed explanation."
    ),
    template=(
        "Answer the following technical question.\n"
        "Think step by step and include code examples where applicable.\n\n"
        "Question: {query}"
    ),
)
 
# Define test cases
test_cases = [
    TestCase(
        query="How do I make async HTTP requests in Python?",
        category="python",
    ),
    TestCase(
        query="How do I receive streaming responses from Gemini API?",
        category="gemini",
    ),
    TestCase(
        query="What types of authentication are used in REST APIs?",
        category="api",
    ),
    # In production, aim for 30+ test cases
]
 
# Run the A/B test
df = run_ab_test([variant_a, variant_b], test_cases)
 
# Analyze results
analysis = analyze_ab_results(df)
print(json.dumps(analysis, indent=2))
# Expected output:
# {
#   "baseline_vs_structured": {
#     "baseline_mean": 3.667,
#     "variant_mean": 4.333,
#     "improvement": 0.667,
#     "p_value": 0.0312,
#     "significant": true,
#     "cohens_d": 0.89,
#     "effect_size": "large",
#     "cost_ratio": 1.15,
#     "recommendation": "✅ Recommended. Significant quality improvement
#                        with acceptable cost increase."
#   }
# }

Visualizing the Cost-Quality Tradeoff

In production, quality alone isn't enough — you need to balance it against cost and latency. The following function identifies Pareto-optimal configurations across model and prompt combinations.

def compute_pareto_frontier(df: pd.DataFrame) -> pd.DataFrame:
    """
    Compute the Pareto frontier of quality vs. cost.
    Returns only the Pareto-optimal combinations.
    """
    summary = df.groupby("variant").agg(
        avg_score=("overall_score", "mean"),
        avg_cost=("cost_usd", "mean"),
        avg_latency=("latency_ms", "mean"),
        total_tokens=("tokens", "sum"),
    ).reset_index()
 
    # Extract Pareto-optimal points
    # (higher quality at same cost, or lower cost at same quality)
    pareto = []
    for _, row in summary.iterrows():
        dominated = False
        for _, other in summary.iterrows():
            if (other["avg_score"] >= row["avg_score"] and
                other["avg_cost"] <= row["avg_cost"] and
                (other["avg_score"] > row["avg_score"] or
                 other["avg_cost"] < row["avg_cost"])):
                dominated = True
                break
        if not dominated:
            pareto.append(row)
 
    return pd.DataFrame(pareto)
 
# Example: compare multiple model × prompt combinations
variants_multi = [
    PromptVariant(name="flash-simple", model="gemini-2.5-flash",
                  system_instruction="Be concise.",
                  template="{query}"),
    PromptVariant(name="flash-detailed", model="gemini-2.5-flash",
                  system_instruction="Be thorough and detailed.",
                  template="Please explain in detail.\n{query}"),
    PromptVariant(name="pro-simple", model="gemini-2.5-pro",
                  system_instruction="Be concise.",
                  template="{query}"),
    PromptVariant(name="pro-detailed", model="gemini-2.5-pro",
                  system_instruction="Be thorough and detailed.",
                  template="Please explain in detail.\n{query}"),
]

Implementing Continuous Monitoring

Prompt evaluation isn't a one-time activity. Continuous monitoring lets you catch quality regressions early — before users notice.

from datetime import datetime, timedelta
 
class QualityMonitor:
    """
    Continuously monitor prompt quality in production.
    Triggers alerts when the rolling average score drops below a threshold.
    """
 
    def __init__(
        self,
        alert_threshold: float = 3.5,
        window_size: int = 50,
        min_samples: int = 10,
    ):
        self.alert_threshold = alert_threshold
        self.window_size = window_size
        self.min_samples = min_samples
        self.scores: list[dict] = []
 
    def record(self, query: str, response: str, metadata: dict = None):
        """Record a response and run automated evaluation"""
        scores = evaluate_response(user_query=query, ai_response=response)
        entry = {
            "timestamp": datetime.now().isoformat(),
            "query": query,
            "scores": scores,
            "metadata": metadata or {},
        }
        self.scores.append(entry)
        self._check_alerts()
        return scores
 
    def _check_alerts(self):
        """Calculate rolling average and check against threshold"""
        if len(self.scores) < self.min_samples:
            return
 
        recent = self.scores[-self.window_size:]
        avg = sum(
            s["scores"].get("overall", 0) for s in recent
        ) / len(recent)
 
        if avg < self.alert_threshold:
            self._send_alert(avg, len(recent))
 
    def _send_alert(self, avg_score: float, sample_size: int):
        """Send quality degradation alert (connect to Slack, email, etc.)"""
        print(f"⚠️ Quality Alert: Average score over last {sample_size} "
              f"samples dropped to {avg_score:.2f} "
              f"(threshold: {self.alert_threshold})")
 
    def get_daily_report(self) -> dict:
        """Generate a daily quality report"""
        today = datetime.now().date()
        today_scores = [
            s for s in self.scores
            if datetime.fromisoformat(s["timestamp"]).date() == today
        ]
 
        if not today_scores:
            return {"date": str(today), "message": "No data for today"}
 
        overall_scores = [
            s["scores"].get("overall", 0) for s in today_scores
        ]
 
        return {
            "date": str(today),
            "total_evaluations": len(today_scores),
            "average_score": round(sum(overall_scores) / len(overall_scores), 2),
            "min_score": min(overall_scores),
            "max_score": max(overall_scores),
            "below_threshold": sum(
                1 for s in overall_scores if s < self.alert_threshold
            ),
        }
 
# Usage example
monitor = QualityMonitor(alert_threshold=3.5, window_size=50)
 
# Sample a fraction of production requests for evaluation
# (evaluating every request would be too costly — 5-10% is recommended)
import random
SAMPLING_RATE = 0.05  # 5%
 
def handle_request(query: str) -> str:
    """Production request handler with quality sampling"""
    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=query,
    )
 
    # Probabilistically sample for quality monitoring
    if random.random() < SAMPLING_RATE:
        monitor.record(query=query, response=response.text)
 
    return response.text

Wrapping Up

In this guide, we built a complete prompt evaluation and optimization pipeline using the Gemini API.

The LLM-as-Judge pattern is a powerful approach for quantitatively measuring prompt quality. While it doesn't fully replace human evaluation, it provides rapid feedback loops that are essential during the development cycle.

By combining the A/B testing framework with continuous monitoring, you can move beyond "it feels better" to genuinely data-driven prompt optimization. This is how production LLM applications maintain their competitive edge.

Start small — pick a handful of representative test cases, run your first A/B test, and gradually integrate the evaluation pipeline into your team's workflow. Investing in prompt quality management pays dividends as your application scales.

Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API / SDK2026-05-06
Building a RAG Evaluation Framework with Gemini API: RAGAS, LLM-as-Judge, and Custom Metrics Production Masterclass
Complete guide to building a quantitative RAG evaluation framework using RAGAS, LLM-as-Judge with Gemini API, and custom domain metrics — including CI/CD integration and production monitoring.
API / SDK2026-06-25
The Morning a Preview Image Model Went Dark — Migrating to GA Gemini Image Models and Building a Deprecation-Resilient Pipeline
With gemini-3.1-flash-image-preview and gemini-3-pro-image-preview retired, here is how to migrate to the GA models and design an image pipeline that no longer gets caught off guard by deprecation dates — with code and cost math, plus video-to-image thumbnail automation.
API / SDK2026-06-15
When the Default Model Silently Upgrades: Catching Prompt Regressions in Numbers
Gemini 3.5 Flash is now the default and you can no longer turn it off. Assuming your responses can shift without you touching the prompt, here is how to bundle prompt, model, and sampling into one variant and catch regressions with canaries and an LLM judge — in working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →