Shipping a new model version or prompt chain to 100% of users is how you cause incidents. User segmentation for canary AI rollouts lets you expose experimental LLM behavior to a controlled slice of traffic, measure impact, and roll back without broad damage. This guide walks through a concrete implementation you can drop into an existing service.
Step 1: Define deterministic segmentation logic
Random assignment per request is unstable and breaks caching. Hash the user ID with a fixed salt so the same user always lands in the same bucket. Keep an override map for explicit inclusion or exclusion.
import hashlib
def assign_bucket(user_id: str, salt: str, buckets: int = 100) -> int:
h = hashlib.sha256(f"{salt}:{user_id}".encode()).hexdigest()
return int(h[:8], 16) % buckets
def segment_for(user_id: str, salt: str, rollout_pct: int = 5) -> str:
# Explicit overrides win
OVERRIDES = {"user_123": "canary", "user_456": "control"}
if user_id in OVERRIDES:
return OVERRIDES[user_id]
bucket = assign_bucket(user_id, salt, 100)
return "canary" if bucket < rollout_pct else "control"
Use a stable salt ("prod-2024") across deployments. Increasing rollout_pct from 5 to 20 expands the canary without reshuffling existing users, because bucket indices remain fixed.
Pitfalls
- Never use
random.random()keyed on session — you will lose stickiness. - Store overrides in a config file or DB, not hardcoded in multiple services.
Step 2: Attach segment to the request context
Resolve the segment early in the request path and stash it on the request state. Below is a FastAPI dependency; Express or Gin follow the same shape.
from fastapi import Request, Depends
SALT = "prod-2024"
def get_segment(request: Request) -> str:
user_id = request.headers.get("x-user-id")
if not user_id:
return "control" # safe default
return segment_for(user_id, SALT)
@app.post("/v1/summarize")
async def summarize(payload: dict, segment: str = Depends(get_segment)):
request.state.segment = segment
# downstream handlers read request.state.segment
If you run multiple services, propagate the segment in an internal header (x-segment) so backend workers inherit the same assignment.
Step 3: Route model calls by segment
The segment decides which model or prompt template to use. For a canary, you may point at a newer model or a different provider. When fronting models through n4n.ai, its OpenAI-compatible endpoint addresses 240+ models and honors client routing directives, so you can pin the canary segment to a specific provider while the control segment relies on automatic fallback when a provider is rate-limited or degraded.
from openai import OpenAI
client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="sk-...")
def call_llm(messages: list, segment: str):
if segment == "canary":
model = "anthropic/claude-3-5-sonnet"
extra = {"extra_headers": {"x-route-pin": "anthropic"}} # gateway-specific
else:
model = "openai/gpt-4o-mini"
extra = {}
resp = client.chat.completions.create(
model=model,
messages=messages,
**extra
)
return resp
The gateway forwards provider cache-control hints, so prompt caching works identically across segments if you structure prefixes consistently. Do not change the system prompt shape between segments unless that is the variable under test.
Step 4: Meter usage per segment
Cost and latency must be attributed to the segment, not just the endpoint. Capture token counts from the response and tag them.
import json, time
def emit_usage(segment: str, usage) -> None:
record = {
"segment": segment,
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
"ts": int(time.time())
}
# replace with Kafka / OTel exporter
print(json.dumps(record))
If your gateway already provides per-token usage metering, join its billing logs with your segment map via request ID. That avoids double counting and gives finance a clean split.
Step 5: Monitor quality and set rollback triggers
Canary health is not just “does it return 200”. Track:
- Completion error rate (timeouts, moderation blocks)
- p95 latency per segment
- Task-specific eval score (e.g., JSON parse rate, hallucination flag)
def eval_response(segment: str, resp, golden: dict) -> float:
score = 1.0 if resp.choices[0].message.content == golden["answer"] else 0.0
# log score tagged with segment
return score
Wire these metrics to an alert: if canary error rate exceeds control by 2x for 10 minutes, auto-disable the canary by setting rollout_pct=0 in config.
Step 6: Expand or kill the rollout
Bump rollout_pct in increments: 5 → 20 → 50 → 100. Because bucketing is deterministic, users who were canary at 5% stay canary at 20%. When you hit 100%, remove the branch and delete overrides.
If the experiment fails, flip rollout_pct to 0 and ship the old model path. No user ID migration needed.
How to verify success
- Unit test the bucketing
assert segment_for("user_123", "salt", 5) == "canary" # override assert segment_for("stable_user", "salt", 5) in ("canary", "control") - Integration test routing: Send two requests with different
x-user-idheaders; assert the canary ID hits the experimental model (mock the LLM endpoint or check emittedmodelfield in logs). - Check metering: Confirm usage logs contain
segmentfield and counts match the responseusageobject. - Shadow compare: Run both segments side-by-side for one user via override and diff outputs manually before trusting metrics.
User segmentation for canary AI rollouts is boring infrastructure that prevents exciting outages. Implement the hash, pin the route, meter the tokens, and keep the rollback switch within arm’s reach.