A/B Testing & Shadow Traffic for Model Upgrades

9 min read Module 8 of 10 Lesson 24 of 28

What you'll learn

  • Implement shadow traffic that sends a copy of production requests to a candidate deployment without affecting what the user sees
  • Implement a weighted A/B split using LiteLLM's load-balancing weights, routing a controlled fraction of real traffic to a candidate
  • Explain the specific risk shadow traffic eliminates that a live A/B split cannot
  • Decide, for a given rollout, whether shadow traffic or a live A/B split is the appropriate first step
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Lesson 23 detected drift in a deployment already in production. This lesson covers the other side of change: deliberately introducing a new candidate deployment, a newer model version, a different provider, a self-hosted replacement, without risking production quality while you find out whether it’s actually as good as it looks on a benchmark. Two techniques, used in sequence, get this done: shadow traffic first, then a live A/B split.

Shadow Traffic: Zero Risk to Real Users

A shadow call sends a copy of a real production request to the candidate deployment purely for comparison, the user’s actual response always comes from the trusted, incumbent deployment:

import asyncio

async def handle_request_with_shadow(gateway_client, primary_model: str, candidate_model: str, messages: list[dict]):
    # The real, user-facing call: this response is what the caller receives.
    primary_response = await gateway_client.chat.completions.create(
        model=primary_model, messages=messages,
    )

    # Fire the shadow call concurrently, but never await it in the response
    # path and never let its result (or a failure in it) affect what the
    # user gets back. A shadow call failing should be invisible to the caller.
    asyncio.create_task(
        run_shadow_comparison(candidate_model, messages, primary_response)
    )

    return primary_response  # user only ever sees this

async def run_shadow_comparison(candidate_model, messages, primary_response):
    try:
        candidate_response = await gateway_client.chat.completions.create(
            model=candidate_model, messages=messages,
        )
        # Reuse Lesson 23's judge to score both, or compare them directly
        judge_result = judge_response(
            user_request=messages[-1]["content"],
            response_text=candidate_response.choices[0].message.content,
        )
        log_shadow_comparison(
            candidate_model=candidate_model,
            candidate_score=judge_result["score"],
            candidate_latency=candidate_response.response_ms,
        )
    except Exception as e:
        log_shadow_failure(candidate_model=candidate_model, error=str(e))

Because asyncio.create_task fires the shadow call without the primary response path ever waiting on or depending on its outcome, a candidate that crashes, times out, or returns malformed output has zero effect on any real user, that failure is exactly the information this stage exists to surface safely, before it could ever reach a live caller.

Live A/B Split: Weighted Traffic

Once shadow results look good, a live A/B split gives the candidate real (but limited and controlled) production exposure, using the same load-balancing weight mechanism from Lesson 9:

model_list:
  - model_name: production-chat
    litellm_params:
      model: anthropic/claude-sonnet-4-5      # incumbent
      api_key: os.environ/ANTHROPIC_API_KEY
      weight: 0.9                              # ~90% of live traffic

  - model_name: production-chat
    litellm_params:
      model: anthropic/claude-opus-4-6         # candidate: a newer model version
      api_key: os.environ/ANTHROPIC_API_KEY
      weight: 0.1                              # ~10% of live traffic, real users, real answers

At this stage, unlike shadow traffic, some real users are genuinely receiving the candidate’s actual answers, which is why it only happens after shadow traffic has already ruled out the most obvious failure modes. Lesson 22’s tracing and Lesson 23’s quality scoring both need gen_ai.request.model/served_by_model broken out per deployment here, so the candidate’s real-traffic latency and quality can be compared against the incumbent’s directly, not just against its own shadow-traffic numbers.

The Two-Stage Rollout

flowchart LR
    Candidate["New candidate deployment"] --> Shadow["Stage 1: Shadow traffic\nzero user-facing risk,\nreal request patterns"]
    Shadow -->|"failure modes found"| Fix["Fix candidate,\nretry shadow stage"]
    Shadow -->|"looks good"| AB["Stage 2: Live A/B split\n(weight: 0.1 or similar)"]
    AB -->|"quality/latency regression\n(Lesson 23 scoring)"| Rollback["Roll back to weight: 0"]
    AB -->|"comparable or better"| FullRollout["Increase weight toward 1.0,\neventually promote to primary"]

    style Candidate fill:#EEF0F7,stroke:#6366F1,color:#0F172A,rx:8px,ry:8px
    style Shadow fill:#4338CA,stroke:#4338CA,color:#ffffff,rx:8px,ry:8px
    style AB fill:#4338CA,stroke:#4338CA,color:#ffffff,rx:8px,ry:8px
    style FullRollout fill:#F7F8FC,stroke:#0D9488,color:#0F172A,rx:8px,ry:8px
    style Rollback fill:#F7F8FC,stroke:#94A3B8,color:#0F172A,rx:8px,ry:8px

Shadow traffic is not a substitute for a live A/B split, it can validate that a candidate doesn’t crash or produce obviously broken output, but it cannot fully validate real-world quality perception, since the judge scoring in Lesson 23 is itself an imperfect proxy for what real users actually think of an answer. The two stages are complementary: shadow traffic derisks the “does this even work” question before any user is exposed, and a live A/B split answers the “is this actually better” question with real signal, at a scale small enough to roll back cheaply if it isn’t. With Module 8’s observability and evaluation toolkit complete, tracing, drift detection, and safe rollout, Module 9 turns to running all of this in production infrastructure: Docker, Kubernetes, and multi-region deployment.

Knowledge Check

3 questions to test your understanding

1 What does shadow traffic mean in the context of rolling out a candidate model, and what is the user-facing effect of a shadow call?

2 Why would a team run shadow traffic before ever moving to a live A/B split, rather than going straight to a small live percentage?

3 In LiteLLM's model_list, how does assigning weight: 0.9 to the incumbent deployment and weight: 0.1 to a candidate deployment, both sharing the same model_name, implement a live A/B split?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan