🔥 0
0
Lesson 7 of 10 20 min +200 XP

A/B Testing AI Agents

You've built a new prompt that seems better in evals. But will it actually improve the customer experience in production? A/B testing lets you find out safely.

However, testing AI agents is trickier than testing button colors. Let's learn how to do it right.

Why Agent A/B Testing is Different

Traditional A/B Test

  • Deterministic outputs
  • Clear success metric (conversion)
  • Independent observations
  • Quick statistical significance

AI Agent A/B Test

  • Non-deterministic outputs
  • Multiple quality dimensions
  • Conversation-level dependencies
  • Larger sample sizes needed

A/B Testing Framework

from dataclasses import dataclass
from typing import Dict, Any, Optional, List
from enum import Enum
import hashlib
import random

class ExperimentStatus(Enum):
    DRAFT = "draft"
    RUNNING = "running"
    PAUSED = "paused"
    COMPLETED = "completed"

@dataclass
class AgentVariant:
    """A single variant in an A/B test"""
    id: str
    name: str
    description: str
    config: Dict[str, Any]  # Agent configuration
    traffic_percentage: float

@dataclass
class Experiment:
    """An A/B test experiment"""
    id: str
    name: str
    hypothesis: str
    primary_metric: str
    secondary_metrics: List[str]
    variants: List[AgentVariant]
    status: ExperimentStatus
    start_date: Optional[str] = None
    end_date: Optional[str] = None
    min_sample_size: int = 1000
    min_duration_days: int = 7


class ExperimentManager:
    """Manage A/B test experiments for agents"""

    def __init__(self):
        self.experiments: Dict[str, Experiment] = {}
        self.assignments: Dict[str, str] = {}  # user_id -> variant_id

    def create_experiment(
        self,
        name: str,
        hypothesis: str,
        control_config: Dict,
        treatment_config: Dict,
        traffic_split: float = 0.5,
        primary_metric: str = "resolution_rate"
    ) -> Experiment:
        """Create a new A/B test"""

        experiment = Experiment(
            id=self._generate_id(name),
            name=name,
            hypothesis=hypothesis,
            primary_metric=primary_metric,
            secondary_metrics=[
                "user_satisfaction",
                "response_time",
                "escalation_rate",
                "cost_per_conversation"
            ],
            variants=[
                AgentVariant(
                    id="control",
                    name="Control",
                    description="Current production agent",
                    config=control_config,
                    traffic_percentage=1 - traffic_split
                ),
                AgentVariant(
                    id="treatment",
                    name="Treatment",
                    description="New agent variant",
                    config=treatment_config,
                    traffic_percentage=traffic_split
                )
            ],
            status=ExperimentStatus.DRAFT
        )

        self.experiments[experiment.id] = experiment
        return experiment

    def get_variant(
        self,
        experiment_id: str,
        user_id: str,
        session_id: str
    ) -> AgentVariant:
        """Deterministically assign user to variant"""

        experiment = self.experiments[experiment_id]

        if experiment.status != ExperimentStatus.RUNNING:
            # Return control if experiment not running
            return experiment.variants[0]

        # Check for existing assignment (sticky sessions)
        assignment_key = f"{experiment_id}:{user_id}"
        if assignment_key in self.assignments:
            variant_id = self.assignments[assignment_key]
            return next(v for v in experiment.variants if v.id == variant_id)

        # Deterministic assignment based on user_id hash
        hash_input = f"{experiment_id}:{user_id}"
        hash_value = int(hashlib.md5(hash_input.encode()).hexdigest(), 16)
        bucket = (hash_value % 100) / 100.0

        # Assign to variant based on traffic split
        cumulative = 0
        for variant in experiment.variants:
            cumulative += variant.traffic_percentage
            if bucket < cumulative:
                self.assignments[assignment_key] = variant.id
                return variant

        return experiment.variants[-1]

Experiment Configuration

# Example: Testing a new prompt strategy
experiment = manager.create_experiment(
    name="concise_responses_v2",
    hypothesis="Shorter, more direct responses will improve resolution rate and satisfaction",
    control_config={
        "model": "gpt-4o",
        "system_prompt": CURRENT_PROMPT,
        "max_tokens": 500,
        "temperature": 0.7
    },
    treatment_config={
        "model": "gpt-4o",
        "system_prompt": CONCISE_PROMPT,  # New shorter prompt
        "max_tokens": 300,  # Encourage brevity
        "temperature": 0.5  # More focused
    },
    traffic_split=0.2,  # 20% to treatment initially
    primary_metric="resolution_rate"
)

# Example: Testing model upgrade
model_experiment = manager.create_experiment(
    name="gpt4o_mini_migration",
    hypothesis="GPT-4o-mini can handle simple queries with equivalent quality at 90% lower cost",
    control_config={
        "model": "gpt-4o",
        "routing": "all_to_same_model"
    },
    treatment_config={
        "model": "gpt-4o-mini",
        "routing": "simple_queries_only",
        "complexity_threshold": 0.3
    },
    traffic_split=0.1,
    primary_metric="user_satisfaction"
)

Metrics Collection

@dataclass
class ExperimentMetrics:
    """Metrics for an experiment variant"""
    variant_id: str
    sample_size: int

    # Primary metrics
    resolution_rate: float  # Conversations resolved without escalation
    user_satisfaction: float  # Average rating

    # Secondary metrics
    avg_response_time_ms: float
    escalation_rate: float
    avg_turns_per_conversation: float
    cost_per_conversation: float
    followup_rate: float  # Questions after initial response

    # Quality metrics
    accuracy_score: float  # From eval pipeline
    helpfulness_score: float
    safety_incidents: int

    # Statistical
    confidence_interval: tuple
    p_value: Optional[float] = None


class MetricsCollector:
    """Collect and aggregate experiment metrics"""

    async def collect_for_experiment(
        self,
        experiment_id: str,
        date_range: tuple
    ) -> Dict[str, ExperimentMetrics]:
        """Collect metrics for all variants in an experiment"""

        experiment = self.experiments[experiment_id]
        results = {}

        for variant in experiment.variants:
            conversations = await self._get_conversations(
                experiment_id=experiment_id,
                variant_id=variant.id,
                date_range=date_range
            )

            metrics = self._calculate_metrics(conversations)
            results[variant.id] = metrics

        return results

    def _calculate_metrics(
        self,
        conversations: List[Conversation]
    ) -> ExperimentMetrics:
        """Calculate metrics from conversation data"""

        n = len(conversations)
        if n == 0:
            return None

        resolved = [c for c in conversations if c.resolved_without_escalation]
        ratings = [c.user_rating for c in conversations if c.user_rating]

        return ExperimentMetrics(
            variant_id=conversations[0].variant_id,
            sample_size=n,
            resolution_rate=len(resolved) / n,
            user_satisfaction=np.mean(ratings) if ratings else None,
            avg_response_time_ms=np.mean([c.response_time_ms for c in conversations]),
            escalation_rate=len([c for c in conversations if c.escalated]) / n,
            avg_turns_per_conversation=np.mean([c.turn_count for c in conversations]),
            cost_per_conversation=np.mean([c.total_cost for c in conversations]),
            followup_rate=len([c for c in conversations if c.had_followup]) / n,
            accuracy_score=np.mean([c.eval_accuracy for c in conversations if c.eval_accuracy]),
            helpfulness_score=np.mean([c.eval_helpfulness for c in conversations if c.eval_helpfulness]),
            safety_incidents=len([c for c in conversations if c.safety_flag]),
            confidence_interval=self._calculate_ci(resolved, n)
        )

Statistical Analysis

from scipy import stats
import numpy as np

class ExperimentAnalyzer:
    """Statistical analysis for experiments"""

    def analyze(
        self,
        control_metrics: ExperimentMetrics,
        treatment_metrics: ExperimentMetrics,
        primary_metric: str = "resolution_rate"
    ) -> Dict[str, Any]:
        """Compare control vs treatment"""

        control_value = getattr(control_metrics, primary_metric)
        treatment_value = getattr(treatment_metrics, primary_metric)

        # Calculate relative lift
        lift = (treatment_value - control_value) / control_value

        # Statistical significance (two-proportion z-test for rates)
        if primary_metric in ["resolution_rate", "escalation_rate", "followup_rate"]:
            p_value = self._proportion_test(
                control_metrics.sample_size,
                int(control_value * control_metrics.sample_size),
                treatment_metrics.sample_size,
                int(treatment_value * treatment_metrics.sample_size)
            )
        else:
            # t-test for continuous metrics
            p_value = self._t_test(control_metrics, treatment_metrics, primary_metric)

        # Determine if significant
        significant = p_value < 0.05

        # Calculate required sample size if not significant
        if not significant:
            required_n = self._calculate_required_sample(
                control_value, treatment_value,
                control_metrics.sample_size + treatment_metrics.sample_size
            )
        else:
            required_n = None

        return {
            "primary_metric": primary_metric,
            "control_value": control_value,
            "treatment_value": treatment_value,
            "lift": lift,
            "lift_percentage": f"{lift * 100:.2f}%",
            "p_value": p_value,
            "significant": significant,
            "required_sample_size": required_n,
            "recommendation": self._get_recommendation(
                lift, p_value, treatment_metrics.safety_incidents
            )
        }

    def _proportion_test(self, n1, x1, n2, x2) -> float:
        """Two-proportion z-test"""
        p1 = x1 / n1
        p2 = x2 / n2
        p_pool = (x1 + x2) / (n1 + n2)

        se = np.sqrt(p_pool * (1 - p_pool) * (1/n1 + 1/n2))
        z = (p1 - p2) / se

        return 2 * (1 - stats.norm.cdf(abs(z)))

    def _get_recommendation(
        self,
        lift: float,
        p_value: float,
        safety_incidents: int
    ) -> str:
        """Generate recommendation based on results"""

        if safety_incidents > 0:
            return "DO_NOT_SHIP - Safety incidents detected"

        if p_value >= 0.05:
            return "CONTINUE_TEST - Not yet significant"

        if lift > 0.05:  # >5% improvement
            return "SHIP - Significant positive impact"
        elif lift > 0:
            return "CONSIDER_SHIPPING - Small but significant improvement"
        elif lift > -0.02:
            return "NEUTRAL - No meaningful difference"
        else:
            return "DO_NOT_SHIP - Significant negative impact"

Experiment Results Dashboard

Experiment: concise_responses_v2

CONTROL (80%)
Resolution
72.3%
Satisfaction
4.1/5
TREATMENT (20%)
Resolution
76.8% (+6.2%)
Satisfaction
4.3/5 (+4.9%)
p-value: 0.003
Sample size: 12,847 conversations
Recommendation: SHIP

Progressive Rollout

class ProgressiveRollout:
    """Safely roll out winning variants"""

    ROLLOUT_STAGES = [
        {"percentage": 5, "duration_hours": 24, "auto_advance": False},
        {"percentage": 25, "duration_hours": 48, "auto_advance": True},
        {"percentage": 50, "duration_hours": 48, "auto_advance": True},
        {"percentage": 100, "duration_hours": None, "auto_advance": False},
    ]

    async def start_rollout(
        self,
        experiment_id: str,
        winning_variant_id: str
    ):
        """Begin progressive rollout of winning variant"""

        for stage in self.ROLLOUT_STAGES:
            # Update traffic allocation
            await self._set_traffic(
                experiment_id,
                winning_variant_id,
                stage["percentage"]
            )

            self.logger.info(
                f"Rollout stage: {stage['percentage']}% traffic to {winning_variant_id}"
            )

            # Monitor for issues
            if stage["duration_hours"]:
                issue_detected = await self._monitor_rollout(
                    experiment_id,
                    duration_hours=stage["duration_hours"]
                )

                if issue_detected:
                    await self._rollback(experiment_id)
                    return {"status": "rolled_back", "stage": stage["percentage"]}

            # Check for auto-advance
            if not stage["auto_advance"]:
                # Wait for manual approval
                await self._wait_for_approval(experiment_id, stage["percentage"])

        return {"status": "completed", "percentage": 100}

    async def _monitor_rollout(
        self,
        experiment_id: str,
        duration_hours: int
    ) -> bool:
        """Monitor for issues during rollout"""

        end_time = datetime.now() + timedelta(hours=duration_hours)

        while datetime.now() < end_time:
            metrics = await self.collector.get_recent_metrics(
                experiment_id,
                window_minutes=30
            )

            # Check for degradation
            if metrics.error_rate > 0.05:
                return True  # Issue detected
            if metrics.escalation_rate > metrics.baseline_escalation * 1.5:
                return True
            if metrics.safety_incidents > 0:
                return True

            await asyncio.sleep(300)  # Check every 5 minutes

        return False  # No issues

Key Takeaways

  • Sticky sessions - Always assign users consistently to variants
  • Multiple metrics - Resolution rate alone doesn't tell the full story
  • Larger sample sizes - Non-determinism requires more data for significance
  • Progressive rollout - Even winners should be rolled out gradually

Next up: Security & Guardrails - Protecting your agent from malicious inputs and ensuring safe outputs.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Why is A/B testing AI agents more complex than testing traditional features?

2

What is 'interleaving' in AI agent A/B testing?

3

Your new agent prompt has 10% higher resolution rate but 15% more escalations. How should you interpret this?

Agent Quality & Evaluation