Running a controlled experiment on a support bot is harder than it looks. To A/B test AI customer support agent behavior without polluting your data, you need explicit variant assignment, isolated prompt chains, and metric instrumentation from day one. Skip any of these and you’ll ship a model change based on noise.
Step 1: Define the hypothesis and target metrics
Before writing code, pin down what you’re testing. A vague goal like “make the bot better” produces unreadable results. Pick a primary metric: containment rate (tickets resolved without human handoff), CSAT, or median response latency.
Example hypothesis: “Switching from GPT-4o to a smaller fine-tuned model for tier-1 billing questions will keep containment above 80% while cutting token cost 40%.” That’s testable.
Secondary metrics matter for guardrails. Track hallucination rate via post-hoc sampling, escalation rate, and average turns per conversation.
Step 2: Implement deterministic variant assignment
Random assignment per request causes repeat users to see both variants, confounding learning. Assign by stable user or conversation ID using a hash bucket.
import hashlib
def assign_variant(user_id: str, experiment: str, buckets: int = 100) -> str:
h = hashlib.sha256(f"{experiment}:{user_id}".encode()).hexdigest()
bucket = int(h[:8], 16) % buckets
# 50/50 split: 0-49 control, 50-99 treatment
return "control" if bucket < 50 else "treatment"
Store the assignment at conversation start. Never re-assign mid-session.
Step 3: Isolate agent logic per variant
Each variant should be a pure function of (conversation_state, variant). Don’t branch on model name inside a shared prompt. Define separate prompt templates and tool sets.
PROMPTS = {
"control": "You are a support agent. Use tools: lookup_order, refund.",
"treatment": "You are a concise support agent. Only use lookup_order unless refund explicitly requested."
}
MODELS = {
"control": "openai/gpt-4o",
"treatment": "anthropic/claude-3-haiku"
}
If you route both variants through a single OpenAI-compatible endpoint that addresses 240+ models, you can swap MODELS values without redeploying. n4n.ai forwards provider cache-control hints and meters per token, so cost comparison is exact.
Step 4: Route requests with fallback safety
Provider outages will bias your test if only one variant fails. Use a gateway that automatically falls back when a provider is rate-limited. Keep the fallback model consistent across variants to avoid confounding.
import openai
client = openai.OpenAI(base_url="https://api.n4n.ai/v1", api_key="KEY")
def chat(variant: str, messages: list):
try:
return client.chat.completions.create(
model=MODELS[variant],
messages=messages,
extra_headers={"x-n4n-route": "prefer:cheapest"}
)
except openai.RateLimitError:
# fallback handled by gateway; log the event
raise
Step 5: Instrument every conversation
Emit structured events to a warehouse. At minimum: variant, conversation_id, user_id, model, prompt_tokens, completion_tokens, latency_ms, resolved (bool), csat (if given).
import json, time
def log_event(conn, **fields):
conn.execute(
"INSERT INTO ab_events (ts, variant, conv_id, user_id, model, ptok, ctok, lat, resolved) "
"VALUES (now(), %(variant)s, %(conv)s, %(user)s, %(model)s, %(ptok)s, %(ctok)s, %(lat)s, %(resolved)s)",
fields
)
Run this synchronously after each bot turn. Missing telemetry silently kills experiments.
Step 6: Run the experiment with enough traffic
Power analysis first. If baseline containment is 80% and you want to detect a 3-point drop at 95% confidence, you need roughly 2,000 conversations per arm. Use a simple calculator or statsmodels.
from statsmodels.stats.proportion import proportion_effectsize
from statsmodels.stats.power import NormalIndPower
es = proportion_effectsize(0.80, 0.77)
n = NormalIndPower().solve_power(effect_size=es, alpha=0.05, power=0.8, ratio=1)
print(int(n)) # ~2350 per arm
Don’t peek daily and stop early without sequential testing correction.
Step 7: Analyze results with proper statistics
After the pre-registered sample size, pull the data and run a two-proportion z-test on containment.
import pandas as pd
from statsmodels.stats.proportion import proportions_ztest
df = pd.read_sql("SELECT variant, resolved FROM ab_events", conn)
ctrl = df[df.variant=="control"].resolved
trt = df[df.variant=="treatment"].resolved
count = [trt.sum(), ctrl.sum()]
nobs = [len(trt), len(ctrl)]
stat, pval = proportions_ztest(count, nobs)
print(f"p={pval:.3f}")
If p < 0.05 and treatment containment is within guardrails, you have a winner. Check token cost deltas from the same table.
Step 8: Verify success and roll out
Verification means replaying a fixed golden set of 100 historical tickets through both variants and confirming treatment matches production metrics. Automate this as a regression test.
def replay_golden(variant):
results = []
for ticket in GOLDEN:
out = run_agent(variant, ticket)
results.append(out.resolved)
return sum(results)/len(results)
Only then flip the default variant in assign_variant to treatment for 100% of new conversations. Keep the control code path for a week as a quick rollback.
Common pitfalls
- Cross-contamination: a user talks to control then treatment because assignment used session ID not user ID.
- Prompt drift: editing the control prompt mid-test invalidates the baseline.
- Cost blindness: not metering tokens per variant leads to false “cheaper” claims.
What good looks like
A clean A/B test AI customer support agent rollout shows a flat or improved containment line, a visible token cost drop, and no spike in escalation. The experiment code stays in the repo as a permanent harness for the next model swap.