Study guides / CCAR-P / Domain 3

Evaluation, Testing & Optimisation · Lesson 3 of 6

3.3 - A/B Testing and Iterative Improvement

Compare prompts, models and configurations safely, offline first and then on live traffic, with sound randomisation, guardrail metrics, a staged rollout and a pre-agreed rollback.

Every change to a Claude system is a hypothesis: this prompt is better, this cheaper model is good enough, this retrieval setting reduces hallucination. An architect’s job is to choose how much evidence a change needs before it reaches users, and to make the test cheap and reversible enough that the team actually runs it. The usual shape is a funnel. The offline eval (Lessons 3.1 and 3.2) is fast, cheap and safe but only tests the inputs you thought to include. Shadow or canary exposure shows real traffic with limited blast radius. A proper online A/B test measures real user outcomes. A staged rollout with rollback converts the winner into the default.

Anthropic’s agent-evals write-up is candid about the trade-off. Automated evals give fast iteration. A/B testing measures real outcomes but is slow, taking days or weeks to reach significance, needs sufficient traffic, and only tests changes you actually deploy. Production monitoring (Lesson 3.6) is reactive: problems reach users before you know. No layer is enough alone, so a sound experiment plan states which question each layer answers.

Offline comparison: change one thing, keep everything else fixed

A valid offline comparison holds the dataset, grader, rubric and judge model fixed and changes exactly one variable: the prompt, the model, the effort setting, a retrieval parameter. If you change a prompt and a model together, you cannot say which caused the difference. If you change the dataset between runs, you cannot say anything. Record the model ID, prompt version, dataset hash, effort and other parameters with every run so any two runs can be diffed.

Three refinements matter at the Professional level:

Large offline evaluations are also a natural fit for the Message Batches API, which Anthropic lists as a use case: asynchronous, discounted, and results are keyed by custom_id rather than returned in submission order. Cost and caching mechanics are in Lesson 3.5.

Online A/B tests: design decisions that decide validity

Key concept: decide the decision rule before you look

An experiment plan is complete when it states the hypothesis, the randomisation unit, the primary metric, the guardrails with tolerances, the sample or duration, the stopping rule, and the rollback trigger. Writing these down first is what separates an A/B test from picking whichever variant looked better on the day someone checked.

Claude-specific confounders to control

Rollout, rollback and the iteration loop

Serve prompts, model IDs and effort settings from versioned configuration, not from code constants, so a change is a config flip and a rollback takes seconds. A safe path is: offline gate, then shadow mode (run the candidate on a copy of real traffic, log its output, serve the incumbent’s), then a small canary share, then a staged ramp with each step gated on the guardrails. Define rollback triggers up front (for example, any guardrail beyond tolerance, or a spike in a specific error type) and make them automatic where you can. Shadow mode doubles the model spend for the shadowed share, which is a cost to price in.

The iteration loop is: sample failures from production and review them (Lesson 3.6), classify the failure cause (Lesson 3.4), form a hypothesis, make one change, run the offline eval, run the online test if the change is user-visible, then add the failure that started the loop to the dataset. Any fix that comes from a production failure should leave a permanent test behind.

Common exam distractor

Three tempting answers: declaring a winner from a short run or a small sample, bundling a prompt rewrite with a model swap in one experiment “to save time”, and shipping on an equal offline score without any live-traffic stage. The exam favours single-variable changes, guardrail metrics alongside the primary metric, staged exposure, and a rollback path defined before launch.

Exam traps

Practice question

A team wants to move a high-volume support assistant to a cheaper Claude model and a shortened system prompt. On the offline evaluation set the candidate scores about the same as the current version. Leadership wants the saving immediately. Which plan best reflects sound practice?

  • A Switch all traffic now, because equal offline scores prove the candidate is at least as good in production, and a full switch captures the cost saving that leadership wants immediately rather than after a long test.

    An offline set only covers the inputs it contains, and cannot show real user outcomes, behaviour under production load, or rare failure modes. Equal offline scores justify moving to a controlled live stage, not skipping it.

  • B Send half of all requests to the candidate, randomised per request, and pick whichever variant gets more thumbs-up in the first afternoon, since that gives a fast answer directly from real users and needs no extra tooling.

    Per-request randomisation contaminates multi-turn conversations, thumbs-up feedback is sparse and self-selected, and a single afternoon is neither sized nor pre-specified.

  • C Run the candidate in shadow mode for a week, comparing its outputs with the incumbent's on real traffic, then switch fully if they look similar in spot checks and the team is comfortable with the results.

    Shadow mode is a useful safety stage but it measures no user outcomes. Similar-looking spot checks are not a decision rule and no rollback or guardrail thresholds are defined.

  • D Stage the rollout: shadow or a small canary, then a sticky per-user A/B split with a pre-registered primary metric, guardrails (escalations, p95 latency, safety flags, cost) and automatic rollback; test model and prompt changes separately. Correct

    This limits blast radius, randomises at a valid unit, measures real outcomes with guardrails, controls for cache warm-up, and keeps a defined rollback. Separating the two changes preserves attribution.

Build exercise: Plan and simulate a safe A/B comparison: sticky bucketing, a paired offline comparison and a rollout rule

Advanced · 75 minutes

You'll practice:

  1. Choose a change you might really make (a shorter prompt, a cheaper model tier, a lower effort setting). Write an experiment plan covering: the single variable being changed, the hypothesis, the randomisation unit, the primary metric, at least three guardrail metrics with tolerances, how you will decide the duration, and the rollback trigger.

    Writing the decision rule before running anything is what prevents post-hoc rationalisation. It is also the artefact an exam scenario expects you to be able to produce.

    You should see: A one-page plan in which every guardrail has a stated tolerance, the change is a single variable, and the duration or sample is justified by the baseline rate and the smallest effect you care about (not a rule of thumb).

    Hints
    1. If this change went badly wrong, which user-visible metric would move first, and which would move last?
    2. List the primary metric and the ways the change could hurt others: latency, cost per completed task, safety flags, escalation to humans, error rate. Give each a tolerance you would accept.
    3. Example: Variable: model tier only (prompt unchanged). Hypothesis: candidate keeps resolution rate within the agreed margin while lowering cost per resolved conversation. Unit: user, sticky. Primary: resolution rate. Guardrails: safety-flag rate (no increase beyond tolerance), escalation rate, p95 latency, error rate, cost per resolved conversation. Duration: from a power calculation using the current resolution rate and the smallest drop we would accept; no early stop. Rollback: any guardrail beyond tolerance for two consecutive windows, or any critical safety incident.
  2. Implement deterministic bucketing: given an experiment name and a user id, return control or candidate with a configurable canary share, so the same user always lands in the same arm and changing the experiment name reshuffles users. Test that the observed split is close to the configured share over many ids.

    Sticky assignment prevents contamination in multi-turn products, and hashing on experiment name plus user id makes the assignment reproducible without storing state.

    You should see: A function that returns the same arm for the same inputs on every call, and a quick simulation over thousands of ids whose split is near the configured share.

    Hints
    1. Why would calling random.random() per request be a problem for a conversation that spans several requests?
    2. Hash the string experiment:user_id to a number in [0, 1) and compare it to the cumulative arm weights.
    3. import hashlib
      
      def bucket(experiment: str, user_id: str, arms=("control", "candidate"), weights=(0.95, 0.05)) -> str:
          h = int(hashlib.sha256(f"{experiment}:{user_id}".encode()).hexdigest(), 16) % 10_000 / 10_000
          cum = 0.0
          for arm, w in zip(arms, weights):
              cum += w
              if h < cum:
                  return arm
          return arms[-1]
      
      counts = {}
      for i in range(20_000):
          a = bucket("tier-swap-01", f"user-{i}")
          counts[a] = counts.get(a, 0) + 1
      print(counts)   # candidate share should be close to 5%
  3. Run the same evaluation set through the current and candidate configurations (same dataset, grader and judge; only the one variable differs), three trials each. Compute the per-case score difference and a bootstrap 95% interval for the mean difference. Report it per slice as well as overall.

    A paired comparison with an interval shows whether an apparent gain is distinguishable from noise, and per-slice results reveal regressions the overall mean hides.

    You should see: For the overall set and each slice: a mean difference, a lower and upper bound, and a plain-language reading (clear gain, clear loss, or not distinguishable).

    Hints
    1. If the interval for the mean difference includes zero, what is the honest conclusion?
    2. Average each configuration's trials per case first, subtract per case, then resample those per-case differences with replacement many times and take percentiles of the resampled means.
    3. import random
      
      def paired_bootstrap(a_scores, b_scores, n=5000, seed=1):
          """a_scores, b_scores: per-case mean scores in the same case order."""
          rng = random.Random(seed)
          diffs = [b - a for a, b in zip(a_scores, b_scores)]
          means = sorted(
              sum(rng.choice(diffs) for _ in diffs) / len(diffs) for _ in range(n)
          )
          return sum(diffs) / len(diffs), means[int(0.025 * n)], means[int(0.975 * n) - 1]
      
      delta, lo, hi = paired_bootstrap(control_by_case, candidate_by_case)
      print(f"mean diff {delta:+.3f}  95% interval [{lo:+.3f}, {hi:+.3f}]")
  4. Compare cost and latency for the two arms from recorded usage. Separate the first requests (cold cache) from later requests (warm cache) and report cache_read_input_tokens as a share of total input tokens for each arm. State whether any cost difference is explained by cache warmth rather than by the variable under test.

    Prompt variants warm their caches independently, so an unfair comparison can make a good candidate look expensive or slow. Controlling for it is the Claude-specific part of A/B analysis.

    You should see: A table of cost per completed task and p95 latency for warm-cache requests only, plus each arm's cache-read share, and one sentence on how much of any gap disappears after warm-up.

    Hints
    1. If the candidate arm only receives a small share of traffic, how often will its cached prefix expire between requests?
    2. Compute total input as input_tokens + cache_creation_input_tokens + cache_read_input_tokens for each request, then the cache-read share per arm, and repeat the cost comparison on requests after each arm's first cache write.
    3. def cache_read_share(records):
          total = sum(r["input_tokens"] + r["cache_write"] + r["cache_read"] for r in records)
          return sum(r["cache_read"] for r in records) / total if total else 0.0
      
      warm = [r for r in arm_records if not r["is_first_in_window"]]   # exclude cold-start requests
      print("cache-read share, warm requests:", round(cache_read_share(warm), 3))
  5. Write the final ship, hold or roll back decision rule as a table: for each metric, the observed result, the tolerance, and the verdict. Then write the rollout schedule (shadow, canary, ramp steps) and the exact condition that triggers automatic rollback.

    The decision table forces an explicit trade-off between the primary metric and guardrails, and a pre-agreed rollback removes argument during an incident.

    You should see: A completed table with a verdict per metric and an overall decision, and a rollout schedule whose every step names the gate that must pass before the next.

    Hints
    1. If the primary metric improved but one guardrail moved outside tolerance, is the overall answer ship, hold or roll back, and who owns that decision?
    2. A rule that reads 'ship only if the primary metric meets its margin and no guardrail is outside tolerance; otherwise hold and investigate' avoids ad hoc judgement.
    3. Example rollout: Step 1 shadow for the agreed window, gate: no format or error regressions. Step 2 canary share, gate: all guardrails within tolerance after cache warm-up. Step 3 stepwise ramp, gate: same, checked at each step. Rollback trigger: config flip to the previous prompt and model version if any guardrail is outside tolerance for two consecutive measurement windows.

Sources