Module 15 · Practice

Evals in production and CI

An offline eval suite tells you whether a change is safe to ship. Production tells you whether you were right. This module wires the two together: tracing, online judges, user signals, CI gates, staged rollouts and dashboards, arranged as one loop that makes your agent a little better every lap.

IntermediateAdvanced~90 minPrereqs: Modules 04, 12, 13

By the end of this module you can

  • Draw the eval flywheel for a real product and explain what flows between each stage.
  • Instrument an agent with traces, spans and sessions using the OpenTelemetry GenAI conventions or a platform SDK such as Langfuse, and say exactly what to log.
  • Design an online evaluation system: sampling strategy, LLM judges on live traffic under a budget, implicit feedback signals, guardrails vs async scoring.
  • Turn evals into a CI gate that is statistically sound (paired bootstrap), not flaky, cached and cost-bounded, with tiered suites.
  • Run a model or provider migration, detect drift, build a useful dashboard, and handle agent-specific failures such as runaway loops and cost explosions.
  • Run evals as a program: assign owners, place your team on a maturity ladder, budget eval cost, set a review cadence, report to stakeholders with confidence intervals, and pay down eval debt.

The big picture: the eval flywheel

So far in this course, an eval has mostly been something you run on your laptop: a fixed dataset, a system, a grader, a number. In a real product, that offline suite is only one station on a loop. The loop looks like this:

  1. Offline suite. A curated dataset of tasks, built by error analysis (Module 12), graded by code and calibrated judges (Module 03, Module 13).
  2. CI gate. Every change to prompts, tools, model or scaffold runs (part of) the suite automatically. Regressions block the merge.
  3. Staged rollout. The change reaches real users gradually: shadow traffic, then a canary, then an A/B test, then everyone.
  4. Trace everything. Every production run is recorded as a structured trace: model calls, tool calls, tokens, latency, cost, errors, feedback.
  5. Online evals and monitors. Cheap checks run on all traffic; LLM judges score a sample; implicit user signals are aggregated; alerts fire on drops.
  6. Review queue. Humans look at the most informative traces: low scores, judge/human disagreements, a random slice.
  7. Error analysis. Reviewers cluster failures into a taxonomy and count them.
  8. Grow the dataset. Each new failure mode becomes new test cases. The offline suite now covers something it did not cover last week. Back to step 1.
Offline eval suite golden + regression cases CI gate block regressions per PR Staged rollout shadow, canary, A/B Trace everything spans, tokens, cost, errors Online evals sampled judges + signals Review queue humans label hard cases Error analysis cluster failure modes Grow the dataset failures become tests The eval flywheel before shipping: prevent known failures after shipping: discover unknown ones each lap adds cases and sharpens judges prevent discover
The eval flywheel. The right half (blue, amber) prevents known failures from shipping; the bottom and left (green, violet) discover failures you did not know about and feed them back as new test cases. A team that only runs the right half stops learning; a team that only runs the left half keeps shipping regressions.
Analogy: aviation safety

Before a plane flies, it passes a checklist and inspections (offline evals, CI gate). During flight, the black box records everything (traces) and the instruments watch key readings (monitors). After an incident, investigators read the recording (error analysis) and the finding becomes a new checklist item for every future flight (grow the dataset). Aviation got safe not because the first checklist was perfect, but because every failure made the checklist longer.

Two properties make the flywheel work. First, the offline suite and the online monitors share graders. If your production judge for "did the agent resolve the refund correctly?" is the same prompt and model as your CI judge, a failure seen in production can be turned into a CI case and graded identically. Second, traces are replayable: a production trace contains enough information (inputs, tool results, configuration) to rebuild the case offline. We will design for both.

Key idea: offline and online answer different questions

Offline evals answer "is this version better than that version, on cases we chose?". They are controlled and repeatable but only cover what you thought of. Online evals answer "what is actually happening to real users right now?". They cover the real distribution, including things you never imagined, but they are noisy, delayed and cannot compare versions unless you run an experiment. You need both. Anthropic's agent-eval guidance makes the same point: start with 20 to 50 tasks drawn from real failures, and keep the suite growing from production.

Tracing: the raw material

You cannot evaluate what you did not record. Tracing is structured logging for multi-step programs. It comes from distributed-systems observability, where one web request might touch ten services, and it fits agents perfectly because an agent run is also a tree of nested operations.

Spans, traces and sessions

  • A span is one timed operation with a name, a start and end time, a status (ok or error), and a bag of key-value attributes. Examples: one model call, one tool execution, one retrieval.
  • A trace is a tree of spans that share a trace ID. For an agent, one trace is usually one user turn or one task: the top-level "invoke agent" span, with model calls and tool calls nested inside it.
  • A session (also called a conversation or thread) groups the traces of one multi-turn interaction. Many failures only show up at session level: the agent forgot something from turn 2 by turn 6, or the user gave up after three tries.
Session gen_ai.conversation.id = c_81f trace 1: "where's my order?" trace 2: "refund it" trace 3: "thanks" expanded below invoke_agent support_bot chat (plan) execute_tool lookup_order chat (decide) execute_tool issue_refund execute_tool issue_refund chat (final answer) 0 s 3 s 6 s 9 s 12 s attributes on a "chat" span gen_ai.operation.name = chat gen_ai.request.model = <model id> gen_ai.usage.input_tokens = 1840 attributes on the failed tool span gen_ai.operation.name = execute_tool gen_ai.tool.name = issue_refund error.type = TimeoutError
Session, trace, span. A session groups turns; each turn is a trace; each trace is a waterfall of spans. Reading this waterfall you can see where the 12 seconds went (a 4-second tool timeout), that the agent retried correctly, and how many tokens each model call used. Timings are illustrative.

The OpenTelemetry GenAI semantic conventions

OpenTelemetry (OTel) is the vendor-neutral open standard for traces, metrics and logs. Its semantic conventions are agreed attribute names, so that a span produced by one library can be understood by any backend. The GenAI semantic conventions define names for model calls, agent invocations and tool executions. As of mid-2026 they live in their own repository (open-telemetry/semantic-conventions-genai), and every gen_ai.* attribute is still at Development stability, meaning names can change between releases. Pin the version of the instrumentation library you use, not just "the spec".

ConceptConventionNotes
Span nameschat {model}, execute_tool {tool name}, invoke_agent {agent name}Format is {operation} {target}.
Operationgen_ai.operation.nameRequired. Values include chat, execute_tool, invoke_agent.
Provider and modelgen_ai.provider.name, gen_ai.request.model, gen_ai.response.modelLog the response model too: aliases can silently resolve to a new snapshot.
Tokensgen_ai.usage.input_tokens, gen_ai.usage.output_tokensThe raw material for cost.
Stop reasongen_ai.response.finish_reasonsSpikes in "length" or "max_tokens" are a classic silent failure.
Sessiongen_ai.conversation.idLets you group traces into sessions.
Toolsgen_ai.tool.name, gen_ai.tool.call.id, gen_ai.tool.typeOne execute_tool span per call.
Agentgen_ai.agent.name, gen_ai.agent.id, gen_ai.agent.versionVersion is what lets you slice metrics by release.
Errorserror.typeInherited from core OTel and stable.
Metricsgen_ai.client.operation.duration, gen_ai.client.token.usageHistograms, cheap to keep at 100%.

Message content (prompts, outputs, tool arguments) is deliberately not captured by default because it routinely contains personal data. Instrumentations expose an opt-in, commonly the environment variable OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, though implementations vary. This is a real tension: you need content to evaluate quality, and content is where the privacy risk lives. We return to it in the privacy section.

What to log for an agent

Anthropic's guidance on writing tools for agents recommends tracking runtime, number of tool calls, token consumption and tool errors alongside accuracy. In production add configuration and feedback, because you will want to slice every metric by them. A practical checklist:

Per trace

Trace and session IDs; hashed user ID; agent version, prompt version, model (requested and returned); environment (prod, canary, shadow); experiment arm; total steps, tokens, cost, wall time; final status (completed, gave up, step cap hit, error); user-facing outcome if known.

Per model call

Model, parameters (temperature, max tokens), input and output token counts (including cached tokens), latency and time to first token, finish reason, and content if policy allows.

Per tool call

Tool name, arguments, result (or a truncated or hashed version), latency, error type, retries, and whether it had side effects (read vs write). Write actions deserve extra detail: they are what you audit after an incident.

Feedback and outcomes

Explicit feedback (thumbs, ratings, comments) and implicit signals (rephrase, edit, escalation, abandonment), attached to the trace by ID, often arriving minutes or days later. Online judge scores attach the same way.

Code: tracing an agent with Langfuse

Platforms such as Langfuse, LangSmith, Braintrust, Arize Phoenix, MLflow and Weave all give you a tracing SDK plus a UI; most speak OpenTelemetry underneath (Module 14). Here is a tool-using agent loop instrumented with the Langfuse Python SDK. The pattern is identical elsewhere: a root span per turn, a generation span per model call with token usage, a tool span per tool call, and session and user attributes propagated to everything inside.

import anthropic
from langfuse import get_client, observe, propagate_attributes

langfuse = get_client()          # reads LANGFUSE_PUBLIC_KEY / SECRET_KEY / HOST from env
claude = anthropic.Anthropic()
MODEL = "claude-sonnet-5-5"
AGENT_VERSION = "support-bot@2026-09-28"
MAX_STEPS = 20

@observe(name="lookup_order", as_type="tool")      # one tool span per call, args + result captured
def lookup_order(order_id: str) -> dict:
    return orders_api.get(order_id)

@observe(name="issue_refund", as_type="tool")
def issue_refund(order_id: str, amount: float) -> dict:
    return payments_api.refund(order_id, amount)   # a WRITE action: audit these carefully

TOOLS = {"lookup_order": lookup_order, "issue_refund": issue_refund}

def call_model(messages, tool_schemas):
    with langfuse.start_as_current_observation(as_type="generation", name="chat", model=MODEL) as gen:
        resp = claude.messages.create(model=MODEL, max_tokens=1024,
                                      messages=messages, tools=tool_schemas)
        gen.update(output=resp.content,
                   usage_details={"input_tokens": resp.usage.input_tokens,
                                  "output_tokens": resp.usage.output_tokens},
                   metadata={"stop_reason": resp.stop_reason})
        return resp

def handle_turn(user_hash: str, conversation_id: str, messages: list, tool_schemas: list):
    with langfuse.start_as_current_observation(as_type="span", name="support-agent") as root:
        # everything nested below inherits user, session and tags, so you can slice later
        with propagate_attributes(user_id=user_hash, session_id=conversation_id,
                                  tags=["prod", AGENT_VERSION]):
            for step in range(MAX_STEPS):
                resp = call_model(messages, tool_schemas)
                messages.append({"role": "assistant", "content": resp.content})
                if resp.stop_reason != "tool_use":
                    root.update(output=resp.content, metadata={"steps": step + 1, "status": "done"})
                    return resp
                results = []
                for block in resp.content:
                    if block.type == "tool_use":
                        try:
                            out = TOOLS[block.name](**block.input)
                            results.append({"type": "tool_result", "tool_use_id": block.id,
                                            "content": str(out)})
                        except Exception as e:          # tool errors are data, not crashes
                            results.append({"type": "tool_result", "tool_use_id": block.id,
                                            "content": f"error: {type(e).__name__}", "is_error": True})
                messages.append({"role": "user", "content": results})
            root.update(metadata={"steps": MAX_STEPS, "status": "step_cap_hit"})   # alert on this!
            return None

The same structure written directly against OpenTelemetry, using the GenAI attribute names, looks like this. In practice you would usually install an auto-instrumentation package for your model SDK instead of setting attributes by hand; the snippet shows what those packages produce.

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

tracer = trace.get_tracer("support-agent")

def run_agent(conversation_id, messages):
    with tracer.start_as_current_span("invoke_agent support_bot") as agent_span:
        agent_span.set_attribute("gen_ai.operation.name", "invoke_agent")
        agent_span.set_attribute("gen_ai.agent.name", "support_bot")
        agent_span.set_attribute("gen_ai.agent.version", AGENT_VERSION)
        agent_span.set_attribute("gen_ai.conversation.id", conversation_id)

        with tracer.start_as_current_span(f"chat {MODEL}") as span:
            span.set_attribute("gen_ai.operation.name", "chat")
            span.set_attribute("gen_ai.provider.name", "anthropic")
            span.set_attribute("gen_ai.request.model", MODEL)
            resp = claude.messages.create(model=MODEL, max_tokens=1024, messages=messages, tools=SCHEMAS)
            span.set_attribute("gen_ai.response.model", resp.model)
            span.set_attribute("gen_ai.usage.input_tokens", resp.usage.input_tokens)
            span.set_attribute("gen_ai.usage.output_tokens", resp.usage.output_tokens)
            span.set_attribute("gen_ai.response.finish_reasons", [resp.stop_reason])

        for block in (b for b in resp.content if b.type == "tool_use"):
            with tracer.start_as_current_span(f"execute_tool {block.name}") as tool_span:
                tool_span.set_attribute("gen_ai.operation.name", "execute_tool")
                tool_span.set_attribute("gen_ai.tool.name", block.name)
                tool_span.set_attribute("gen_ai.tool.call.id", block.id)
                try:
                    TOOLS[block.name](**block.input)
                except Exception as e:
                    tool_span.record_exception(e)
                    tool_span.set_attribute("error.type", type(e).__name__)
                    tool_span.set_status(Status(StatusCode.ERROR))
Common mistake: logging only the final answer

If you log only "user asked X, agent said Y", you can judge the answer but not the trajectory. You will not know that the agent called issue_refund twice, or that it looked up the wrong order and got lucky. For agents, the tool spans are often more informative than the final message (Module 05).

Online evaluation

Online evaluation means scoring live production traffic, as opposed to a fixed offline dataset. It has three ingredients: deterministic checks on everything, model-based judges on a sample, and user signals wherever users give them. Before the details, one distinction that confuses many teams.

Guardrails vs evals

A guardrail is a check that runs inline, before the user sees the output or before a tool executes, and can block or change what happens. An online eval runs asynchronously, after the fact, and only measures. They can use similar checks (a PII detector can be either), but their engineering constraints are opposite.

Guardrail: inline, can block budget: milliseconds, high recall, every request User Agent Guardrail checkPII, policy, tool allowlist response delivered blocked / fallback pass fail user waits for this check Online eval: async, only measures budget: minutes, nuanced, sampled Responsealready sent Trace store Sampler LLM judgerubric, reasoning Scoresdashboards, alerts copy of trace e.g. 2% + flagged user never waits
Guardrail vs online eval. A guardrail is part of the product: it adds latency to every request and its false positives annoy users, so it must be fast, cheap and tuned for recall on severe harms. An online eval is part of your measurement system: it can be slow and expensive per call, because it runs on a sample, after the fact.
GuardrailOnline eval
WhenBefore output reaches the user, or before a tool executesAfter the fact, seconds to days later
Coverage100% of the traffic it protectsUsually a sample
Latency budgetTens to hundreds of millisecondsEffectively unlimited
Typical implementationRegex, classifiers, small fine-tuned models, allowlists, schema validation, policy rules on tool argumentsLarge LLM judges with rubrics, trajectory analysis, human review
Cost of a false positiveA blocked legitimate userA wasted review minute
OutputAn action (allow, block, rewrite, escalate)A score attached to a trace

Guardrails still need evaluating: measure their precision and recall offline on labeled examples, just like any grader, and monitor their trigger rate online. A guardrail whose trigger rate doubles overnight is telling you something changed, either in the traffic or in the model upstream of it. Eugene Yan notes that fine-tuned small evaluators are a good fit for low-latency guardrails, while bigger judges suit async scoring.

Sampling: what to judge

Running an LLM judge on every production trace is usually wasteful. A good sampling design mixes three streams with different purposes:

  1. Deterministic checks on 100%. Free or nearly free: did any tool error? Did the run hit the step cap? Is the output valid JSON? Did the cost exceed a threshold? Did a PII regex fire? These catch the loud failures.
  2. A uniform random sample (say 1 to 5%). This is your unbiased estimate of quality over the true traffic distribution. It is the only stream whose average you can put on a dashboard as "the pass rate".
  3. Targeted (flagged) samples. Always judge traces with a thumbs-down, an escalation, an error, an unusually long trajectory, a new feature flag, or a slice you are worried about. This stream is where you find failures fastest, but it is biased by design: never average it together with the random stream.
All production traces (100%): deterministic checks tool errors, step cap hit, cost > limit, invalid output schema, PII regex, finish_reason = length Flagged stream (biased) thumbs-down, escalation, errors, long runs Uniform random (unbiased) hash(trace_id) < 2%: the dashboard number LLM judge, rate-limited and budget-capped same rubric as the CI judge; score written back to the trace Human review queue ~50 to 200 traces / week ~3 to 8% ~1 to 5% low scores, judge unsure, plus a small random slice
A sampling funnel. Cheap checks see everything; judges see two streams kept strictly apart (flagged for discovery, random for measurement); humans see the most informative handful. Percentages are illustrative starting points, not standards.

Sizing the random stream (worked example)

How big does the random sample need to be? Use the standard error of a proportion from Module 04. Suppose your agent's true pass rate is about 80% and you judge 4,000 random traces a day:

SE = sqrt(0.8 × 0.2 / 4000) ≈ 0.0063  so the 95% interval is about ± 1.96 × 0.63 ≈ ± 1.2 points.

That is precise enough to see a 3-point daily drop. If you only care about weekly trends, 600 a day (4,200 a week) gives the same precision per week. If you need per-slice numbers (say 10 customer segments), each slice needs its own few hundred to thousand samples, which is where stratified sampling helps: sample small slices at a higher rate, then reweight each slice by its true traffic share when computing the overall number.

Judge cost (worked example)

Say you have 200,000 traces a day, and a judge call reads about 4,000 input tokens (the transcript plus rubric) and writes 300 output tokens. At an illustrative price of $1 per million input tokens and $5 per million output tokens (check your provider's current price sheet):

StrategyJudge calls / dayInput costOutput costTotal / day
Judge everything200,000800M tok = $80060M tok = $300$1,100
2% random + 3% flagged10,00040M tok = $403M tok = $15$55

A twenty-fold saving for almost the same information. Other levers: truncate or summarize long transcripts before judging (but check the judge still agrees with humans on the shortened input), use a cheaper model for a first-pass triage and a stronger one only on uncertain cases, and use provider-side prompt caching for the fixed rubric part of the judge prompt.

Code: a budget-capped online judge sampler

This worker pulls finished traces, decides whether to judge each one, enforces a rate limit and a daily dollar budget, calls a judge, and writes the score back to the trace in Langfuse. Note the deterministic hash sampling: the same trace ID always gets the same decision, so reruns and backfills are reproducible, and the stream label is stored with every score so the dashboard never mixes biased and unbiased samples.

import hashlib, json, time, datetime as dt
import anthropic
from langfuse import get_client

JUDGE_MODEL = "claude-haiku-4-5"       # pick a cheap judge you have CALIBRATED against humans (Module 13)
PRICE_IN, PRICE_OUT = 1.00, 5.00       # USD per million tokens; set from your provider's price sheet
RANDOM_RATE = 0.02                     # 2% uniform sample: the unbiased stream
DAILY_BUDGET_USD = 60.0
MAX_CALLS_PER_MIN = 60

claude = anthropic.Anthropic()
langfuse = get_client()

RUBRIC = """You are grading a customer-support agent transcript.
PASS only if the agent (1) resolved the user's actual request, (2) took no
unauthorised write action, and (3) stated nothing contradicted by tool results.
Think step by step, then answer with JSON: {"pass": true|false, "reason": "..."}"""

class TokenBucket:
    """Allow at most `rate` calls per minute, smoothing bursts."""
    def __init__(self, rate_per_min):
        self.capacity = rate_per_min
        self.tokens = rate_per_min
        self.refill = rate_per_min / 60.0
        self.last = time.monotonic()
    def acquire(self):
        while True:
            now = time.monotonic()
            self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill)
            self.last = now
            if self.tokens >= 1:
                self.tokens -= 1
                return
            time.sleep((1 - self.tokens) / self.refill)

class DailyBudget:
    def __init__(self, usd):
        self.usd, self.spent, self.day = usd, 0.0, dt.date.today()
    def can_spend(self, estimate):
        if dt.date.today() != self.day:            # reset at midnight
            self.spent, self.day = 0.0, dt.date.today()
        return self.spent + estimate <= self.usd
    def charge(self, usage):
        self.spent += usage.input_tokens / 1e6 * PRICE_IN + usage.output_tokens / 1e6 * PRICE_OUT

def in_random_sample(trace_id: str, rate: float) -> bool:
    # deterministic: the same trace always gets the same decision
    h = int(hashlib.sha256(trace_id.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
    return h < rate

def choose_stream(t: dict):
    """Return 'random', 'flagged' or None. Random wins so the unbiased stream stays unbiased."""
    if in_random_sample(t["id"], RANDOM_RATE):
        return "random"
    if t["error"] or t["feedback"] == "down" or t["escalated"] or t["steps"] > 15:
        return "flagged"
    return None

bucket, budget = TokenBucket(MAX_CALLS_PER_MIN), DailyBudget(DAILY_BUDGET_USD)

def judge_trace(t: dict):
    stream = choose_stream(t)
    if stream is None:
        return
    transcript = t["transcript"][-24_000:]                  # cap the input: cost and context control
    est = (len(transcript) / 4 + 600) / 1e6 * PRICE_IN + 400 / 1e6 * PRICE_OUT
    if not budget.can_spend(est):
        if stream == "flagged":
            enqueue_for_human_review(t["id"], reason="judge budget exhausted")
        return                                              # degrade gracefully, never crash
    bucket.acquire()
    resp = claude.messages.create(
        model=JUDGE_MODEL, max_tokens=400, temperature=0,
        system=RUBRIC,
        messages=[{"role": "user", "content": f"<transcript>\n{transcript}\n</transcript>"}],
    )
    budget.charge(resp.usage)
    text = resp.content[0].text
    try:
        verdict = json.loads(text[text.index("{"): text.rindex("}") + 1])
    except ValueError:
        verdict = {"pass": None, "reason": "unparseable judge output"}   # count these; alert if they rise
    if verdict["pass"] is not None:
        langfuse.create_score(
            name=f"judge_resolved_{stream}",           # keep streams separate on dashboards
            value=1.0 if verdict["pass"] else 0.0,
            trace_id=t["id"], data_type="NUMERIC",
            comment=f"{JUDGE_MODEL}: {verdict['reason'][:500]}",
        )
    if verdict["pass"] is not True:
        enqueue_for_human_review(t["id"], reason=f"judge fail ({stream})")
Expert nuance: the judge drifts too

An online judge is a measuring instrument that lives for months. If the judge model is an alias that the provider updates, your "quality dropped 4 points" alert might be the judge changing, not your agent. Pin an exact judge model version (never a moving alias such as "latest"), log it on every score, and keep a small anchor set of human-labeled traces that you re-judge weekly: if the judge's agreement with humans on the anchor set moves, recalibrate before trusting the trend (Module 13).

Implicit feedback signals

Explicit feedback (thumbs up or down, star ratings) is precious but rare: typically only a small fraction of users ever click, and those who do are not representative (angry users click more). Implicit signals are behaviors users show anyway, which correlate with success or failure. They are free, available on 100% of traffic, and noisy.

Session transcript Signal it produces User: Move my flight to Friday. Agent: Done, rebooked you on Thursday 09:00. User (12 s later): No. FRIDAY. Agent: Sorry! Friday 09:00 booked, $75 fee. User clicks thumbs-down User: Why a fee?? Get me a human. (tab closed, no confirmation) judge: date mismatch (hidden error) quick rephrase = correction judge: unannounced fee explicit negative feedback escalation request abandonment
One bad session, six signals. The first failure (wrong day) produced no user signal at all; only a judge or a correction a turn later reveals it. Implicit signals are cheap and plentiful, but they arrive late, are ambiguous one by one, and must be validated against judged or human labels before you trust them.
SignalHow to detectUsually meansWatch out for
Retry / regenerateRegenerate button, or same question rephrased within N secondsThe answer missedSome users regenerate for variety (creative tasks)
Correction"No", "I said", "that's wrong" in the next turn (a cheap classifier)Misunderstanding or wrong actionUsers also say "no" to answer the agent's own question
Edit distance on draftsFor agents that draft emails, code or documents: how much the user changed before using itLarge edits = low qualityUsers personalize good drafts too; track trends, not single values
AcceptanceSuggested code kept, PR merged, action confirmed, draft sentSuccessAccepted-then-reverted later is a delayed failure
Escalation"Talk to a human", handoff tool called, support ticket openedAgent could not resolveSome escalations are correct policy (the agent should hand off)
AbandonmentSession ends mid-task, no confirmation, no returnGave upAlso happens when the user simply got what they needed
Explicit thumbs / ratingsUI buttonsDirect judgmentLow and biased response rate; rating the tone, not the correctness
Return rateUser comes back with the same issue within daysThe "resolution" did not stickNeeds joining across sessions
Goodhart's law applies to user signals

If you optimize directly for fewer thumbs-downs, an agent can learn to be agreeable and flattering (sycophancy) rather than correct. If you optimize for fewer escalations, it can learn to refuse to hand off when it should. Use implicit signals as alarms and samplers (which traces to look at), and use calibrated judges and human labels as the target.

Shipping safely: shadow, canary, A/B, interleaving

Offline evals cannot see everything, so you release changes in stages, each exposing more users and measuring more directly.

Offline + CIfixed dataset Shadowmirrored traffic,output discarded Canary 1%errors, cost,latency, safety Canary 10%+ judge scores A/B 50/50outcome metricswith CIs Full rolloutmonitors stay on 0% of users 0% (mirror) 1% 10% 50% 100% user exposure and measurement realism increase left to right automatic rollback if a guardrail metric breaches
The rollout staircase. Each step trades a little more risk for more realistic measurement. Early steps watch cheap "guardrail metrics" (errors, cost, latency, safety flags); later steps can measure slow outcome metrics such as resolution rate. Percentages are typical examples, not rules.

Shadow deployments

In a shadow (or "dark launch") deployment, the new version receives a copy of real production requests, but its output is thrown away; users only see the old version. You then compare the two on the same inputs, which is a paired comparison with real traffic, the best of both worlds. The catch for agents: agents act. A shadow agent must not send the email or issue the refund. You have to stub every write tool (return a plausible fake success) and point read tools at real or replayed data. That also means shadow runs diverge from reality after the first write: if the real agent refunded and the shadow did not, turn 2 is no longer comparable. Shadow mode is most informative for single-turn behavior and early trajectory steps.

Canaries

A canary release sends a small fraction of real users (often 1% then 5 to 10%) to the new version, with automated checks that roll back if error rate, cost per trace, p95 latency, step-cap hits or safety-guardrail triggers exceed limits relative to the control. Canaries are for catching breakage quickly, not for measuring small quality differences: 1% of traffic rarely gives enough samples for a precise quality comparison in hours.

A/B tests for agents

An A/B test randomly assigns users to control (A) or treatment (B) and compares outcome metrics. It is the only way to measure causal impact on what the business actually cares about (resolution, retention, revenue). Agent-specific design choices:

  • Randomize by user, not by request. Agents have memory and multi-turn sessions; switching versions mid-conversation contaminates both arms. Hash the user ID into an arm and keep it sticky.
  • Pick one primary metric (for example "session judged resolved" or "no human escalation within 7 days") and several guardrail metrics that must not degrade (cost per session, p95 latency, safety flags).
  • Size it up front. Agents often change behavior by small amounts, and small effects need many users.

Worked example: how many users?

Your baseline resolution rate is 60%. You want 80% power to detect a 2-point absolute improvement at a two-sided 5% significance level. The usual approximation for two proportions:

n per arm ≈ (zα/2 + zβ)² × 2p(1−p) / d² = (1.96 + 0.84)² × 2 × 0.6 × 0.4 / 0.02² ≈ 9,400

So about 9,400 users per arm, around 19,000 in total, and more if users have several sessions (sessions from the same user are correlated, so cluster your standard errors by user, as in Module 04). If you only have 2,000 users a week, a 2-point A/B test takes ten weeks. That is why most day-to-day decisions are made offline, with A/B tests reserved for big changes.

Users per arm vs detectable effect 1001,00010,000100,000 123510 smallest improvement to detect (points, absolute) 2 pts → 9,400 per arm 1 pt → 37,600 5 pts → 1,500 10 pts → 380 Weeks needed at 2,000 users/week 2 pts~9.4 wk 3 pts~4.2 wk 5 pts~1.5 wk 10 pts~3 days two arms, so total users = 2 × n
Small effects are expensive. Halving the effect you want to detect roughly quadruples the users you need, because n grows with 1/d². Computed with the formula above (baseline 60%, 80% power, two-sided 5%); the log scale on the left hides how steep this really is.

Interleaving and side-by-side

Interleaving is a trick from search and recommendation: instead of showing user group A ranker A and group B ranker B, you show every user a merged list with results from both rankers and see which ranker's items get clicked. Because each user compares both, it needs far fewer users than an A/B test. For agents that produce one answer, pure interleaving rarely applies, but two related ideas do:

  • Interleave components. If your agent has a retrieval step or a tool that ranks options, you can interleave those rankings.
  • Side-by-side preference. Occasionally show two responses and ask the user which is better. This is a pairwise human preference signal (the same statistics as LMArena, Module 04), but it costs user attention, so use it sparingly and only where both responses are safe to show.

Evals in CI: the gate

Continuous integration (CI) runs automated checks on every code change, typically when a pull request (PR) is opened. Adding evals to CI turns "please remember to run the eval" into "the merge button is disabled until the eval passes". It is where the flywheel's ratchet effect (Module 01) is enforced.

Tiered suites

Agent evals are slow and cost money, so you cannot run everything on every PR. Split into tiers, each with a different trigger, size and job:

Mon Tue Wed Thu Fri Sat Sun Smokeevery PR, ~50 cases< 10 min, gates Fullnightly, 500+ cases~1 h, alerts Capabilityweekly / pre-releasehard tasks, trend blocked capability run
Three tiers, three cadences. Smoke runs on every PR and blocks merges (one PR is blocked here). The full suite runs nightly on the main branch and refreshes the baseline. The capability suite is big, slow and hard, and is read as a trend rather than a gate. Sizes and times are typical examples.
TierTriggerContentsDecision rule
SmokeEvery PR touching prompts, tools, agent code, model config~30 to 100 fast cases: critical flows, past incidents, safety must-pass cases; mostly code gradersHard gate: must-pass cases at 100%; aggregate compared with baseline
Full regressionNightly on main; also before a releaseHundreds to thousands of cases across every slice, several epochs, judges includedPaired statistical comparison with the previous night; page a human on a credible drop
CapabilityWeekly, or on model upgradesHard, long-horizon tasks, often in sandboxes; pass rate well below 100%No gate: tracked as a trend to decide what to work on and whether a new model unlocks new features

This mirrors the capability vs regression distinction from Anthropic's agent-eval guidance: regression evals should stay near 100% and a drop is a signal; capability evals start low and are about headroom. When a capability task becomes reliably solved, "graduate" it into the regression suite.

Thresholds vs statistical regression tests

The simplest gate is an absolute threshold: "pass rate must be at least 90%". It is easy to explain and good for must-pass cases (safety, critical flows), where a single failure really is a blocker. For aggregate quality it has two problems. It ignores noise: with 100 cases, a system with a true pass rate of 91% will score below 90% fairly often just by chance, so the gate flaps. And it ignores change: a drop from 97% to 91% passes the gate even though something clearly broke.

The better gate for aggregates is a statistical regression test: compare the candidate with the baseline on the same cases and ask whether the candidate is credibly worse by more than a tolerance. Because both versions run on the same cases, use a paired method (Module 04): per-case differences cancel out the "some cases are just hard" variance and make the test far more sensitive than comparing two independent averages.

The rule used here: compute the 95% confidence interval of the mean per-case difference (candidate minus baseline) with a paired bootstrap, and fail the build if the lower bound is below −1 point. This is a non-inferiority test: it passes when we are confident the candidate is not more than 1 point worse.

−6 −4 −2 0 +2 +4 +6 candidate − baseline (points), 95% paired-bootstrap CI gate: lower bound ≥ −1 PR A PASS: better PR B FAIL: too noisy add cases / epochs PR C PASS: no real regression PR D FAIL: worse
Reading a non-inferiority gate. PR C has a slightly negative average but a tight interval that stays above −1, so it passes (a naive "score must not drop" rule would have blocked it). PR B's average is positive but the interval is so wide that a 3-point regression cannot be ruled out, so it fails until more evidence is gathered. Numbers are illustrative.
Choosing the tolerance

The −1 point tolerance is a product decision, not a statistical one: how much regression would you accept in exchange for, say, a 30% cost cut? Make the tolerance explicit, write it in the repo next to the eval, and use a stricter gate (0 tolerance, or must-pass at 100%) for safety-critical slices. Also check the gate is powered: if the smoke suite is so small that its CI is always ±5 points, every PR will fail as "too noisy". Size the suite (cases × epochs) so the typical interval half-width is comfortably below your tolerance.

Code: a paired-bootstrap gate over Inspect logs

This script reads an Inspect eval log for the baseline and one for the candidate, averages each case over its epochs, aligns cases by ID, bootstraps the mean difference, prints a summary to the GitHub job page, and exits non-zero if the lower bound is below the tolerance. It also reports per-slice differences using a slice field in each sample's metadata.

#!/usr/bin/env python3
"""evals/compare.py: fail CI if the candidate is credibly worse than the baseline."""
import argparse, os, sys, glob
from collections import defaultdict
import numpy as np
from inspect_ai.log import read_eval_log

def to_float(v):
    # Inspect scorers return numbers or letters: C = correct, I = incorrect, P = partial
    return {"C": 1.0, "I": 0.0, "P": 0.5}.get(v, v) if isinstance(v, str) else float(v)

def load(path, scorer):
    if os.path.isdir(path):                                   # newest log in a directory
        path = max(glob.glob(os.path.join(path, "*.json")), key=os.path.getmtime)
    log = read_eval_log(path)
    assert log.status == "success", f"{path}: eval status {log.status}"
    per_case, slices = defaultdict(list), {}
    for s in log.samples:
        if s.error:                                           # infra errors are not model failures
            continue
        per_case[s.id].append(to_float(s.scores[scorer].value))
        slices[s.id] = (s.metadata or {}).get("slice", "all")
    return {k: float(np.mean(v)) for k, v in per_case.items()}, slices

def paired_bootstrap(diffs, n_boot=10_000, seed=0):
    rng = np.random.default_rng(seed)                         # fixed seed: reproducible CI
    idx = rng.integers(0, len(diffs), size=(n_boot, len(diffs)))
    boots = diffs[idx].mean(axis=1)                           # resample CASES, keep pairs intact
    return diffs.mean(), *np.percentile(boots, [2.5, 97.5])

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--baseline", required=True)
    ap.add_argument("--candidate", required=True)
    ap.add_argument("--scorer", default="includes")
    ap.add_argument("--max-drop", type=float, default=0.01)   # 1 point on a 0-1 scale
    ap.add_argument("--min-coverage", type=float, default=0.95)
    a = ap.parse_args()

    base, _ = load(a.baseline, a.scorer)
    cand, slices = load(a.candidate, a.scorer)
    ids = sorted(set(base) & set(cand))
    coverage = len(ids) / max(len(base), 1)
    if coverage < a.min_coverage:                             # silently missing cases = a broken eval
        sys.exit(f"Only {coverage:.0%} of baseline cases present in candidate run")

    d = np.array([cand[i] - base[i] for i in ids])
    mean, lo, hi = paired_bootstrap(d)
    lines = [f"### Eval gate: {len(ids)} paired cases",
             f"Baseline {np.mean([base[i] for i in ids]):.1%} | Candidate {np.mean([cand[i] for i in ids]):.1%}",
             f"Mean diff **{mean*100:+.1f} pts**, 95% CI [{lo*100:+.1f}, {hi*100:+.1f}]",
             "", "| slice | n | diff (pts) |", "|---|---|---|"]
    by_slice = defaultdict(list)
    for i, x in zip(ids, d):
        by_slice[slices[i]].append(x)
    for s, xs in sorted(by_slice.items()):
        lines.append(f"| {s} | {len(xs)} | {np.mean(xs)*100:+.1f} |")
    passed = lo >= -a.max_drop
    lines.append("\n**PASS**" if passed else
                 f"\n**FAIL**: lower bound {lo*100:+.1f} < {-a.max_drop*100:.1f} pts")
    report = "\n".join(lines)
    print(report)
    if os.environ.get("GITHUB_STEP_SUMMARY"):
        with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
            f.write(report + "\n")
    sys.exit(0 if passed else 1)

if __name__ == "__main__":
    main()

Why bootstrap cases rather than individual epochs? Epochs of the same case are correlated (a hard case is hard every time), so treating them as independent would make the interval too narrow. Averaging within a case first and resampling cases is the simple, correct version of the "cluster your standard errors" advice from Evan Miller's error-bars paper.

Code: the GitHub Actions workflow

The workflow below runs three tiers from one file. On a PR it runs the Inspect smoke suite, compares against the committed baseline with the script above, and also runs a promptfoo prompt-regression matrix with a pass-rate threshold. Nightly it runs the full suite. Weekly it runs the capability suite. PROMPTFOO_PASS_RATE_THRESHOLD sets the minimum pass rate as a percentage (it defaults to 100%); when the rate falls below it, promptfoo eval exits with code 100, which fails the job.

name: agent-evals

on:
  pull_request:
    paths: ["agent/**", "prompts/**", "tools/**", "evals/**"]
  schedule:
    - cron: "0 3 * * *"        # nightly full regression suite
    - cron: "0 4 * * 6"        # weekly capability suite (Saturday)
  workflow_dispatch:

concurrency:                    # a new push cancels the old run: saves money
  group: evals-${{ github.ref }}
  cancel-in-progress: true

jobs:
  smoke:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 20         # hard wall-clock cap
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r evals/requirements.txt
      - name: Run smoke suite on the candidate
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          inspect eval evals/smoke.py --model anthropic/claude-sonnet-5-5 \
            --epochs 3 --max-connections 8 --message-limit 40 \
            --log-format json --log-dir logs/candidate
      - name: Paired-bootstrap gate vs baseline (fail if lower CI bound < -1 pt)
        run: |
          python evals/compare.py --baseline evals/baselines/smoke.json \
            --candidate logs/candidate --scorer includes --max-drop 0.01
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: smoke-logs, path: logs/ }

  prompt-regressions:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - uses: actions/cache@v4
        with:
          path: ~/.cache/promptfoo
          key: ${{ runner.os }}-promptfoo-v1
      - name: promptfoo matrix, 95% pass-rate gate
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          PROMPTFOO_PASS_RATE_THRESHOLD: "95"
        run: npx promptfoo@latest eval -c evals/promptfooconfig.yaml -o results.json

  nightly-full:
    if: github.event_name == 'schedule' && github.event.schedule == '0 3 * * *'
    runs-on: ubuntu-latest
    timeout-minutes: 120
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r evals/requirements.txt
      - env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          inspect eval evals/full.py --model anthropic/claude-sonnet-5-5 \
            --epochs 5 --log-format json --log-dir logs/nightly
          python evals/compare.py --baseline evals/baselines/full.json \
            --candidate logs/nightly --max-drop 0.01   # non-zero exit = alert on-call
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: nightly-logs, path: logs/ }

  weekly-capability:
    if: github.event_name == 'schedule' && github.event.schedule == '0 4 * * 6'
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r evals/requirements.txt
      - env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: inspect eval evals/capability.py --model anthropic/claude-sonnet-5-5 --log-dir logs/cap
      # no gate: results are uploaded and charted as a trend

A few design notes. The baseline is a committed log that the team refreshes deliberately (for example by a bot PR after a green nightly run on main). That keeps the baseline reviewable, but it means the baseline was produced at a different time from the candidate; if the provider changed the model in between, the difference includes that drift. The more rigorous option is to run baseline and candidate in the same job, which doubles cost but controls for time-varying factors. Many teams do the cheap version per PR and the rigorous version nightly. --message-limit caps each sample's conversation length so a looping agent cannot burn the CI budget (Inspect also supports token and time limits per sample).

Flaky evals

A flaky eval is one that passes and fails on identical code. In ordinary software, a flaky test is a bug. In agent evals, some flakiness is inherent (sampling), so the goal is to measure and contain it rather than eliminate it. Sources and fixes:

SourceFix
Model sampling varianceSeveral epochs per case; statistical gate instead of exact thresholds; report pass^k for reliability-critical cases (Module 04)
Judge variancePinned judge model version, low randomness (temperature 0 where the model accepts it), binary rubrics, majority vote of 3 for borderline cases
External tools and APIs (rate limits, live web, changing data)Record and replay tool responses ("cassettes"), local fakes, frozen snapshots of data; mark live-web tasks as a separate, non-gating suite
Infrastructure errors (timeouts, sandbox failures)Count errors separately from failures; retry infra errors, never retry model failures until they pass
Time-dependent tasks ("what's today's date?")Inject a fixed clock in the environment
Genuinely ambiguous casesMeasure each case's flip rate across runs of the same version; send high-flip cases to review; fix the spec or move them to a quarantine list that is reported but not gated
Anti-pattern: re-run until green

If the team's habit is to click "re-run" on a failing eval job until it passes, the gate is decorative and you are selecting for lucky samples. With five re-runs, a version that truly fails a gate 60% of the time will pass at least once about 92% of the time (1 − 0.6⁵). Make the gate statistically sound instead, and log every run so re-runs are visible.

Caching and cost budgets

  • Response caching. Cache model responses keyed on (model, full prompt, parameters). promptfoo caches by default (the ~/.cache/promptfoo directory in the workflow). Caching is safe for components that did not change, such as the judge scoring an unchanged output. It is dangerous for the system under test: a cached response hides sampling variance, and if the cache key misses something (a tool schema, a system prompt file) you evaluate a stale answer. Rule of thumb: cache judges and fixed fixtures, never cache the candidate agent's own calls in the gating run.
  • Provider prompt caching. Agent evals re-send long, identical prefixes (system prompt, tool definitions). Provider-side prompt caching can cut input cost substantially on these runs.
  • Budgets at three levels. Per sample (message, token and time limits so one looping case cannot eat the run), per run (a job timeout and a maximum case count), and per month (track spend by suite; a smoke suite that costs $40 per PR will quietly become the largest line item once there are twenty PRs a day).
  • Skip what did not change. Use path filters so a README edit does not trigger evals, and consider running only the slices whose tools or prompts were touched, with the full suite nightly as the safety net.

Model and provider migrations

Few changes are as tempting or as risky as swapping the model: a new release is cheaper, faster or smarter on public benchmarks. Public benchmarks do not measure your task (Module 01), and agents are sensitive to behavioral details that benchmarks ignore: how verbose the model is, how eagerly it calls tools, how it formats arguments, when it asks clarifying questions. A migration playbook:

  1. Freeze everything else. Same prompts, tools, dataset and graders. Change one variable.
  2. Run the full suite with epochs on both models, in the same job, and compare with the paired method. Also run the capability suite: a new model may unlock tasks you had given up on.
  3. Compare per slice, not just overall. A flat average can hide a big gain on easy slices and a big loss on a critical one.
  4. Compare behavior, not just scores. Tool calls per task, tokens per task, cost per success, latency, refusal rate, clarifying-question rate, finish reasons, output length. These "shape" metrics catch shifts that the pass/fail grader misses and often explain score changes.
  5. Read transcripts where the two models disagree. This is where you find out that the new model now asks for confirmation before every refund, which your grader counts as success but your users will hate.
  6. Re-tune, then re-evaluate. Prompts are often overfitted to the old model. Allow a round of prompt adjustment, but evaluate the tuned version on a held-out split so you are not overfitting to the eval.
  7. Re-validate judges. If the new model is also your judge, or the judge is from the same family, check judge-human agreement again: self-preference bias changes with the pairing (Module 13).
  8. Roll out in stages with the new model as the treatment arm, and keep the old one available as a fallback until online metrics confirm.

Worked example: a migration report

Slice (n cases)Old modelNew modelPaired diff, 95% CIRead
Order lookup (180)94%96%+2.0 [−0.4, +4.3]Fine
Refunds (120)88%81%−7.0 [−11.5, −2.6]Regression: block
Policy questions (150)79%87%+8.0 [+3.9, +12.1]Clear gain
Overall (450)87.3%88.4%+1.1 [−1.2, +3.4]Looks neutral, hides both
Tool calls / task3.14.6New model double-checks: more cost, more latency

The overall number says "ship it". The slices say "the new model is better at explaining policy but worse at refunds". Transcript reading shows it calls lookup_order again before refunding and sometimes hits a rate limit. That is fixable (cache lookups within a session), and fixing it is a better outcome than either blindly shipping or rejecting the model. Illustrative numbers.

New model minus old model, per slice read Order lookup (180)+2.0fineRefunds (120)-7.0blockPolicy questions (150)+8.0clear gainOverall (450)+1.1looksneutral -10-50+5+10 paired difference in points, with 95% CI ← new is worse new is better →
The average hides both stories. The same migration report drawn as a forest plot: each dot is a slice's paired difference and each bar its 95% interval. Refunds is clearly worse and policy questions clearly better, yet the overall diamond sits near zero. Illustrative numbers from the table above.

Drift: when nothing changed but everything changed

Drift means the world your agent operates in changes after you evaluated it. Your code did not change, but your quality did. Three sources matter for agents:

Drift sourceExampleHow you detect itResponse
Data / input driftNew product launched, users ask about it; a marketing campaign brings a new user segment; seasonalityTopic or intent classifier distribution over time; embedding-cluster shifts; rising "unknown intent" rate; new slices with low judge scoresError analysis on the new cluster; add cases; the offline suite's slice mix should track production's mix
Model / provider driftAn alias such as "latest" points to a new snapshot; provider changes safety filters, tokenization or defaults; a region fails over to another deploymentLog gen_ai.response.model; alert on change; nightly suite on a fixed dataset shows step changes; shape metrics (output length, tool-call count) jumpPin exact model versions rather than aliases; treat any provider change as a migration
Tool / API / environment driftA downstream API adds a required field; a website your browser agent uses is redesigned; search results changeTool error rate by tool; schema-validation failures; per-tool latency; sudden step-count increases (the agent is flailing)Contract tests for tools (Module 06); versioned tool schemas; circuit breakers

A fourth, subtler kind is criteria drift: what "good" means changes as you look at more outputs. Shankar and colleagues found that people need criteria to grade outputs, but grading outputs changes their criteria. That is healthy, but it means your rubrics and judges need versioning just like your prompts, and a score series should note when the rubric changed.

Input drift last monththis month Model / provider drift alias "latest" → new snapshot safety filters, defaults, region failover Tool / environment drift + "currency" required API contract changes, website redesigned, search results shift Criteria drift reading outputs changes what you call "good" rubric v3 → v4 Your agent no commit since Monday quality drops anyway intent mix,new clusters response.modelchanges tool error rate,steps per task score series notesrubric version
Four ways quality moves without a commit. Each source of drift has its own detector, written on its arrow: shifts in the intent mix, a change in the model the provider actually served, rising tool errors, or a rubric update that changes the yardstick. Log all four so a drop can be traced to its cause. Illustrative.

Dashboards: what to chart

A dashboard is where the flywheel becomes visible to the whole team. The most common failure is a wall of numbers nobody acts on. Chart a few things, each with a clear owner and a clear action when it moves.

Success rate by slice (random-sample judge) 10060 model update billing general refunds weeks Cost per successful task loops? weeks Latency per session (p50 and p95) p50 p95 tool outage days Judge score with 95% CI vs target target weeks
Four charts that earn their place. Slice-level success reveals the refunds drop that the overall average hides (the shaded band is its CI); cost per success exposes a runaway-loop problem before the invoice does; p95 latency shows a tool outage that p50 does not; judge scores are drawn with intervals, so a one-week wobble is not mistaken for a trend. All data is illustrative.
ChartWhy it mattersCommon mistake
Success rate by slice over time (random stream), with CIsThe main quality signal; slices localise problemsOnly the global average; mixing flagged and random samples
Cost per successful taskCost per call hides that a cheaper model needs twice the retriesCharting total spend (grows with traffic, tells you nothing)
p50 / p95 / p99 latency per session and per toolAgents have long tails; users feel p95Averages; per-call instead of per-task latency
Steps per task distribution; step-cap hit rateEarly warning of loops and flailingNot logging it at all
Tool error rate by toolLocalises environment driftLumping tool errors into "agent failures"
Implicit signal rates (escalation, correction, abandonment)100% coverage, cheapTreating them as ground truth
Judge health: human agreement on anchor set, unparseable rateTells you whether the other charts can be trustedAssuming the judge is constant
Offline suite results per commit on mainLinks releases to quality changesNo annotation of releases on charts

Humans in the loop: review queues and annotation

Every stream above eventually ends in a person reading traces. A review queue (tools such as LangSmith and Langfuse call them annotation queues) is a to-do list of traces with a labeling interface. Done well, it is the highest-value hour of the team's week; done badly, it is a backlog nobody opens. Hamel Husain's advice applies directly: remove all friction from looking at data.

  • What goes in: judge failures, cases where the judge was uncertain or unparseable, disagreements between judge and user signal (thumbs-up but judge fail, or the reverse), a small uniform random slice (to find failures no filter catches), and traces from new features.
  • What reviewers produce: a binary pass/fail with a short written critique (not a 1-to-5 score), a failure-mode tag from the taxonomy, and a flag "add to dataset". This is exactly the open-coding and axial-coding workflow from Module 12.
  • Where labels go: three places. (1) New dataset cases, with the trace converted into a replayable input and the reviewer's critique turned into the expected behavior. (2) Judge calibration data: every human label is also a test of the judge (TPR and TNR, Module 13). (3) Product bugs, when the failure is not the model's fault at all (a broken tool, missing data).
  • Quality control: have two reviewers label an overlapping 10% and track agreement (Cohen's kappa). Low agreement means the rubric is ambiguous, not that reviewers are careless.
WHAT GOES IN judge FAIL judge unsure or unparseable judge vs user disagree small random slice new-feature traces Review queue ~50 to 200 / week Reviewer writes ✓ PASS or FAIL (binary) ✓ critique: why ✓ failure-mode tag ✓ "add to dataset?" 2 reviewers on 10%: kappa WHERE LABELS GO New test casesreplayable, in the suite Judge calibrationTPR, TNR vs human Product bugsbroken tool, missing data every human label pays three times: a test, a judge check, and sometimes a bug fix
The review queue as a machine. The inputs are chosen to be informative, not representative; the output is a small set of carefully written labels that feed the dataset, the judge's validation set and the bug tracker at once. Volumes are illustrative.
Worked example: turning a production trace into a regression case

The flight-change session from the implicit-signals figure lands in the queue (escalation plus a judge fail). The reviewer writes: "FAIL. Agent booked Thursday when the user said Friday; later added a $75 fee without warning. First failure: date parsing." Tags: wrong_entity, unannounced_cost.

The engineer converts it into a test case: input = the first user message plus the booking tool's recorded responses (so it replays deterministically); graders = a code check that the rebook tool was called with a Friday date, and a judge check "if the action has a fee, the agent states the fee and asks for confirmation before acting". The case goes into the smoke suite as a must-pass case because it touches money. The failure mode also gets a synthetic variant set ("move it to next Tuesday", "the 14th", "two days later") so the fix generalizes rather than memorizing one phrasing.

Privacy and PII in traces

Traces are the most sensitive data many AI teams hold: full conversations, tool results containing customer records, sometimes credentials pasted by users. The same traces are what you want to put in datasets, send to third-party judge models and show to reviewers. Treat this as a design problem from day one:

Minimize at capture

Content capture is opt-in in the OTel GenAI conventions for good reason. Capture content only where you will use it; redact known PII patterns (emails, phone numbers, card numbers, government IDs) before export, for example in an OpenTelemetry Collector processor or an SDK hook; hash user IDs.

Control access and retention

Role-based access to trace content; short default retention for raw content with longer retention for metrics and scores; audit logs of who viewed what; region and self-hosting choices (for example self-hosted Langfuse) where data residency rules apply.

Mind the judge

Sending a transcript to an external judge model sends it to a processor. Check your data-processing agreements, prefer judges covered by the same terms as your production model, and redact before judging when the rubric does not need the PII.

Datasets are forever

A production trace copied into an eval dataset escapes normal retention and deletion. Record provenance (which trace, which user) so deletion requests can reach datasets too; anonymize or synthesise variants for long-lived regression cases; get consent where required.

Redaction can break evals

If you replace every number with [REDACTED], you can no longer check that the agent refunded the right amount. Prefer consistent pseudonymisation (the same real value always maps to the same fake value within a trace) so that relationships survive, and test your redactor's precision and recall like any other classifier.

risk control raw PII in prompts redact too much,evals break everyone can readeverything, forever text leaves to athird-party model escapes deletion 1. CaptureSDK / OTel 2. Redactcollector hook 3. Storetrace DB 4. Judgemodel API 5. Datasetlong-lived cases content captureopt-in; hash user IDs consistentpseudonyms role-based access,short raw retention DPA covers judge;redact if not needed record provenance;synthesise variants 4471 → ORDER_A same real value, same fake value: amounts and IDs still check out
Follow one trace through its life. Privacy risk appears at every stage, and so does a matching control. Two are easy to miss: the judge call (graded text goes to a model provider) and the eval dataset (a copied trace outlives normal retention).

Agent-specific operations: loops, caps and cost explosions

A chatbot that misbehaves writes one bad paragraph. An agent that misbehaves can call a tool 400 times, spend $80 of tokens on one request, or repeat a write action. Production agents need hard limits that are enforced in code (not requested in the prompt) and monitored like any other SLO.

Model picksnext action Guard checklimits + loop detector Execute toolobserve result Stop gracefullyhandoff + alert ok limit hit steps ≤ 30 tokens ≤ 300k cost ≤ $2.00 wall time ≤ 5 min same call ×3 write needs OK tool errs ×5 steps cumulative cost cost cap normal: done runaway: killed each step re-sends a growing context
Guard every step. Limits sit between the model's decision and the tool's execution, so a runaway loop is stopped by code, with a graceful message to the user and an alert to you. Runaway runs grow super-linearly in cost because every step re-sends an ever-longer context. Limits shown are examples; set yours from the p99 of healthy runs.
FailureWhat it looks like in tracesControlMetric to alert on
Runaway loopThe same tool called with the same arguments again and again; alternating between two actionsStep cap; repeated-call detector (hash of tool name + arguments); "you have tried this 3 times" injected message, then stopStep-cap hit rate; steps per task p99
Cost explosionHuge input token counts late in a trajectory; giant tool outputs pasted into contextPer-task token and dollar budget; truncate or summarize tool outputs; context compactionCost per task p99; daily spend vs forecast
Tool errorsError spans; the agent retrying with guessed arguments; hallucinated success after an errorStructured error messages the model can act on; retries with backoff in code; circuit breaker that disables a failing tool and tells the agentError rate per tool; "claimed success after error" judge check
Duplicate side effectsTwo issue_refund spans for one request after a timeoutIdempotency keys on write tools; confirmation step for irreversible actionsWrite actions per task; duplicates detected by a code check
Silent truncationfinish_reason = length; half-written JSONSchema validation; raise max tokens or split the taskTruncation rate
Stuck sessionsLong wall-clock with no progress, waiting on an external systemTimeouts per tool and per taskWall time p95

These controls are themselves things to evaluate. Put a "loop bait" task in the smoke suite (a tool that always returns a retryable error) and assert that the agent stops within the cap and explains itself; put a "large tool output" task in and assert cost stays within budget. Anthropic's tool-writing guidance recommends exactly this kind of tracking of tool calls, tokens and errors alongside accuracy, and notes that what agents omit in transcripts can matter as much as what they include.

Putting it together: a minimum viable production eval stack

If you are starting from nothing, do these in order. Each step is useful on its own.

  1. Week 1: tracing with sessions, tool spans, tokens, cost and agent version. Hard step and cost caps in code. Read 50 traces yourself.
  2. Week 2: error analysis on those traces; a first offline suite of 30 to 50 cases from real failures; a smoke job in CI with must-pass cases.
  3. Week 3: deterministic checks on 100% of traffic; implicit-signal capture; a dashboard with slice success, cost per success, p95 latency, step-cap rate.
  4. Week 4: a calibrated LLM judge on a 2% random sample plus flagged traces, with a budget; a weekly review queue that feeds the dataset.
  5. Later: paired statistical gate, nightly full suite, canary automation, A/B tests for big changes, anchor-set monitoring of the judge, a capability suite.
WEEK 1tracing + hard capsread 50 traces WEEK 2error analysis30 to 50 casessmoke job in CI WEEK 3checks on 100%implicit signalsfirst dashboard WEEK 4calibrated judge on2% + flaggedweekly review queue LATERpaired stat gatenightly full suitecanaries, A/B testsjudge anchor setcapability suite Build the stack one step at a time each step is useful on its own
A minimum viable production eval stack. The staircase from the list above: observe first, then test, then monitor, then judge, then add statistics and experiments. Timings are a suggested pace for a small team, not a rule.

Running an eval program

Everything above is machinery: traces, judges, gates, dashboards. Eval programs rarely fail because the machinery is missing. They fail for organizational reasons. Nobody owns the suite, so it goes stale. Nobody reads the dashboard, so drift goes unnoticed. The judge was calibrated once, two models ago. Finance is surprised by the bill, and evals get cut. This section is about running evals as an ongoing program rather than a one-off project.

Analogy: the gym membership and the training plan

Buying eval tools is like buying a gym membership: necessary, and nothing happens until someone turns up every week with a plan. An eval program is the training plan: who comes, how often, what gets measured, how progress is reported, and when the plan itself is revised.

Who owns evals

The most common failure is an eval suite that is one engineer's side project. When that engineer moves team, the suite quietly dies. Spread ownership across clear roles:

RoleOwnsTypically
Quality ownerWhat "good" means: success criteria, rubrics, and the final say when labelers disagreeOne product manager or domain expert. Hamel Husain calls this role the "benevolent dictator": one person with taste and authority beats a committee.
Domain experts and annotatorsLabels, rubric examples, the weekly review queueSupport leads, clinicians, lawyers, analysts: whoever knows a good answer when they see one
Eval engineersHarness, CI gate, dashboards, judge calibration, cost controlsML or platform engineers; a central team once several products share infrastructure
Feature teamsAdding cases with every feature and bug fix; fixing regressions they causeEvery engineer who changes a prompt, tool or model setting
LeadershipLaunch criteria, eval budget, reading the monthly report and acting on itEngineering and product leads

Two organizational shapes work. A central eval team builds shared infrastructure (harness, judges, dashboards) and the evaluation methods. Embedded owners in each product team write the cases and rubrics, because only they know the product. Most mature organizations are a hybrid: platform central, content local. A simple rule keeps either shape honest: every eval has a named owner, and every metric on a dashboard has an action attached ("if this drops below X, Y investigates within a day").

Eval maturity levels

It helps to know where you are. The ladder below is a practical synthesis, not an industry standard. Each level makes the next one possible; skipping levels (for example buying a production monitoring platform before you have read fifty traces yourself) usually produces dashboards nobody trusts.

Where is your eval program? most teams start at 0 or 1; level 3 is a good first target 0 · Vibes try a few prompts, ship if it looks ok 1 · Spot checks a sheet of cases run by hand before big changes 2 · Suite + gate versioned dataset from real failures, smoke gate per PR 3 · Prod loop tracing, online judges on samples, review feeds suite 4 · Trusted calibrated judges, paired stats + CIs, slices, cost, A/B 5 · Eval-driven evals written first, healthy suite, offline predicts online; reported up results depend on who ran them a PR can fail on an eval new failures reach the suite monthly headline has a CI, judge is calibrated a decision changed because of an eval first target the flywheel turns each level makes the next one possible: observe, then test, then trust, then steer
An eval maturity ladder. A practical synthesis of common practice, not a formal standard. The gray lines at the foot of each step are the signal that you have reached it. Level 3 is where the eval flywheel from the start of this module actually turns: production failures flow back into the suite without heroics. Levels 4 and 5 are about making the numbers trustworthy enough that leadership steers by them.

A quick self-assessment: you are at level 2 if a pull request can fail because of an eval; at level 3 if last month's suite contains cases that came from last month's production failures; at level 4 if you can state your headline metric with a confidence interval and know your judge's agreement with humans; at level 5 if a new feature's success criteria are written as eval cases before the feature is built, and a product decision was changed because of an eval result.

Budgeting eval cost

Evals cost money in four places: per-PR runs, scheduled runs (nightly or weekly full suites), online judging of production samples, and human time for labeling and review. Sandbox compute for agent evals is a fifth line if you run containers at scale. Write the budget down; it turns "evals are expensive" into a list of levers.

"""Monthly eval budget. All prices and volumes are illustrative; plug in your own."""
AGENT_RUN = 0.12      # $ per agent run on one case (tokens, mostly cached prefix)
JUDGE_CALL = 0.01     # $ per judge call
PER_CASE = AGENT_RUN + JUDGE_CALL

def monthly_budget(prs_per_day=15, work_days=22,
                   smoke_cases=40, smoke_epochs=3,
                   nightly_cases=400, nightly_epochs=3,
                   traces_per_day=50_000, judge_sample=0.02,
                   review_hours_per_week=4, hourly_rate=80):
    lines = {
        "smoke gate (per PR)": prs_per_day * work_days * smoke_cases * smoke_epochs * PER_CASE,
        "nightly full suite":  30 * nightly_cases * nightly_epochs * PER_CASE,
        "online judge":        30 * traces_per_day * judge_sample * JUDGE_CALL,
        "human review":        review_hours_per_week * 52 / 12 * hourly_rate,
    }
    lines["total"] = sum(lines.values())
    return lines

for name, dollars in monthly_budget().items():
    print(f"{name:22s} ${dollars:9,.0f}")
# smoke gate (per PR)    $    5,148
# nightly full suite     $    4,680
# online judge           $      300
# human review           $    1,387
# total                  $   11,515

The illustrative result is typical in shape: the per-PR gate is the biggest line, because it multiplies by the number of pull requests, and online judging on a small sample is cheap. The levers follow directly. Skip evals on changes that cannot affect behavior (path filters). Run only the slices whose prompts or tools changed, with the full suite nightly as the safety net. Use one epoch on must-pass smoke cases and save repeats for the statistical gate. Cache judge calls on unchanged outputs. Swap in a cheaper judge model only after checking it agrees with your humans as well as the expensive one (Module 13). There is no standard "right" eval spend; compare it with the cost of one bad release reaching users, which is usually the more persuasive number for a budget holder.

Review cadence

Evals decay without a rhythm. A cadence that works for many teams:

Every PR smoke gate, automatic Weekly, 60 min read traces, triage queue Monthly suite health + report report report report Quarterly recalibrate, reset targets quarter review month 1 month 2 month 3
A review rhythm for one quarter. The fast loop is automatic; the slower loops need people and a calendar invite. Frequencies are a common starting point, not a rule: a high-traffic product may need the trace review twice a week, and a regulated one may need formal sign-off at the monthly step.
CadenceWhoLooks atProduces
Every PRCI, the PR authorSmoke and statistical gate resultsMerge or fix
Weekly (30 to 60 minutes)Quality owner, one engineer, a domain expert20 to 50 traces from the random and flagged streams; the review queueNew labeled cases, new failure categories, bugs filed
MonthlyEval owner plus product leadDashboard trends with CIs; suite health (saturated, noisy, stale cases); cost versus budgetThe stakeholder report; dataset refresh
QuarterlyLeadership, eval and product ownersJudge agreement on a fresh human-labeled sample; whether offline results predicted online ones; targetsRecalibrated judges, new targets, budget, retired evals

Reporting to stakeholders

Executives do not need your eval log. They need to know whether quality is good enough, whether it is moving, what is wrong, and what decision is needed. The rules that make a report trustworthy:

  • Every number with its n and its confidence interval. Worked example: 420 of 500 randomly sampled traces pass, so p = 0.84. The standard error is √(0.84 × 0.16 / 500) ≈ 0.016, and the 95% interval is 0.84 ± 1.96 × 0.016, about 81% to 87%. (Module 04 covers intervals.)
  • Compare with a target and with last period, honestly. If the target is 85% and the interval is 81% to 87%, you cannot say you missed it, or that you met it. Say exactly that. Distinguishing 84% from 85% would need many thousands of samples, so set targets as bands and act on clear movements.
  • Show the slices that matter. An 84% average can hide a 71% slice that is the most expensive to get wrong.
  • Translate into business terms. "Tickets resolved without a human", "cost per resolved ticket", "incidents traced to the agent", next to the quality number.
  • Say how the number was made. Which judge, when it was last calibrated against humans, which dataset version. One line is enough, but its absence should make readers suspicious.
  • End with the decision. What you recommend and what you need from the reader.
Refund agent: monthly eval report illustrative numbers task success (random sample) 84% 95% CI 81 to 87 (n = 500) target 85%: inside the CI last month 81% (78 to 84) judge calibrated 3 weeks ago 6-month trend with 95% CIs target Apr Jun Aug Sep Cost and speed $0.41 per resolved ticket p95 latency 38 s step-cap hits 0.8% eval spend on budget By slice (95% CI) status questions 91% [87-95] n=180 partial refunds 71% [64-78] n=160 international 88% [83-93] n=160 the average hides the slice that costs most Top failure modes, and the ask 1. partial refunds: wrong amount (23 traces) 2. re-asks for an order id already given (11) 3. slow tool retries (6) Decision: approve the partial-refund fix before holiday traffic
A one-page eval report for stakeholders. Illustrative numbers (the slices add up to the 500 sampled traces and to the 84% headline). Everything a reader needs to act is on one page: the headline with its interval against a target, the trend, cost and speed, the slice that is failing, the top failure modes from error analysis, and the decision being asked for.

Eval debt and refresh

Like technical debt in code (Sculley et al. described "hidden technical debt" in ML systems in 2015), eval suites accumulate eval debt: small shortcuts and neglect that make the numbers less meaningful every month. Symptoms to look for:

  • Saturation. Everything passes every run. The suite still catches regressions, but it no longer tells you whether a change made things better.
  • Staleness. The product, policies or tools changed and old cases now test behavior nobody wants (the refund window changed from 30 to 60 days, and 40 cases still expect 30).
  • Mix drift. The suite's mix of slices no longer resembles production, so the headline number is weighted toward yesterday's users.
  • Uncalibrated judges. The judge prompt or judge model changed, or production outputs changed style, and nobody re-measured agreement with humans.
  • Tolerated noise. Cases that flip between runs for environment reasons (timeouts, sandbox errors) and have been ignored for so long that real failures are ignored too.
  • Orphans and leakage. Evals nobody owns; cases copied into prompts or few-shot examples, so the system is being tested on its own answer key.
New case from a real failure Active sometimes fails Saturated always passes Regression archive cheap, nightly joins suite every system passes leaves the gate Stale tests unwanted behavior Noisy flips between runs product changed environment errors rewrite as new fix the environment, or quarantine outside the gate Frozen anchor set never edited, same cases every month, so trend lines stay comparable
The lifecycle of an eval case. Healthy suites keep cases moving: new ones arrive from production failures, saturated ones leave the gate (but keep running cheaply as regression checks), stale ones are rewritten, noisy ones are fixed or quarantined. A small frozen anchor set sits apart and is never edited, so month-to-month trends compare like with like.

A refresh policy turns this into routine. A common shape: every month, add cases mined from the last month's production failures and review queue; every quarter, move saturated cases to the regression archive, rewrite or delete stale ones, re-weight slices to match production, and recalibrate judges on a fresh human-labeled sample. Version the dataset (refund-suite v14) and print the version next to every number, because a score on v14 is not comparable with a score on v9. The monthly suite-health step can be a short script over your run history:

"""Suite health: flag saturated, always-failing and noisy cases from run history.
history: one row per (case_id, run_id) of the SAME baseline system, passed in {0, 1},
and error_type (None, "timeout", "sandbox", ...) for environment failures."""
import pandas as pd

def suite_health(history: pd.DataFrame, last_n_runs: int = 20) -> pd.DataFrame:
    runs = history["run_id"].drop_duplicates().sort_values().tail(last_n_runs)
    h = history[history["run_id"].isin(runs)]
    stats = h.groupby("case_id").agg(
        pass_rate=("passed", "mean"),
        runs=("passed", "size"),
        env_errors=("error_type", lambda s: s.notna().mean()),
    )

    def status(r) -> str:
        if r.runs < 5:            return "too new"
        if r.env_errors > 0.2:    return "noisy: fix environment"   # not the agent's fault
        if r.pass_rate == 1.0:    return "saturated: archive?"
        if r.pass_rate == 0.0:    return "always fails: broken case or capability target?"
        return "active"          # between 0 and 1: informative (agents are stochastic)

    stats["status"] = stats.apply(status, axis=1)
    return stats.sort_values(["status", "pass_rate"])

# print(suite_health(history)["status"].value_counts())

Note the design choice: on an agent, a case that passes 60% of the time is not "flaky" in the software-testing sense; it is measuring real variance, and it is often the most informative case you have (Module 04). Only failures caused by the environment, not the agent, belong in the "noisy" bucket.

Expert habit: check that the suite still predicts production

Once a quarter, line up each release's offline delta against its online delta (random-sample judge score, resolution rate, complaints). If offline improvements stop showing up online, your suite has drifted from reality, and that is the most important form of eval debt. The fix is almost always more cases from recent production, weighted to production's slice mix.

Go deeper

The capstone (Module 19) walks through building a program like this end to end, and Module 28 covers the governance side: when evals become evidence for regulators and auditors, not just for your own team.

Key takeaways

  1. Production evals are a loop: offline suite, CI gate, staged rollout, tracing, online evals, review, error analysis, and new test cases, then round again.
  2. Trace everything as sessions, traces and spans (OpenTelemetry GenAI names where possible), including tool calls, tokens, cost, model version and agent version.
  3. Guardrails act inline and must be fast; online evals measure after the fact and can be slow. Evaluate the guardrails too.
  4. Judge a uniform random sample for the dashboard number and a flagged stream for discovery, and never average the two together.
  5. Gate CI with a paired, statistically sound comparison against a baseline, tiered into smoke, nightly and capability suites, with budgets at every level.
  6. Treat any model or provider change as a migration: compare per slice and by behavior, read disagreeing transcripts, and pin exact versions.
  7. Agents need hard limits in code (steps, tokens, dollars, time, repeated calls), and those limits are themselves things to test.
  8. An eval program needs owners: one quality owner with the final say on what "good" means, domain experts for labels, engineers for the harness, and every feature team adding cases. Every dashboard metric needs an owner and an action.
  9. Budget evals line by line (per-PR gate, scheduled suites, online judging, human time), report every number with its n and confidence interval against a target, and pay down eval debt: refresh from production, archive saturated cases, fix noisy ones, keep a frozen anchor set.

Check yourself

1. You judge a 2% uniform random sample and also every thumbs-down trace. The dashboard shows "pass rate" as the average over all judged traces. What is wrong?

Flagged traces are chosen because they look bad. Report the random stream as the quality estimate and use the flagged stream for discovery; or, if you must combine, reweight by inverse sampling probability.

2. A PII detector that blocks responses containing card numbers before they reach users is best described as…

Guardrails act; evals measure. The same detector could run asynchronously as an eval, but here it blocks, so latency and false positives are product concerns.

3. A PR's paired-bootstrap result is mean diff −0.4 points, 95% CI [−0.8, +0.0]. The gate fails if the lower bound is below −1 point. Result?

This is a non-inferiority gate. The lower bound (−0.8) is above the −1 tolerance. A "must not drop" rule would block harmless changes and train the team to ignore the gate.

4. Why randomize an agent A/B test by user rather than by request?

Per-user randomization is actually less statistically efficient, but it is the only design that keeps each session coherent. Cluster standard errors by user when users have several sessions.

5. Your nightly suite drops 5 points overnight with no code change. The traces show gen_ai.response.model changed. What is the best next step?

This is model/provider drift. Pinning versions prevents surprises; the migration playbook tells you whether to adopt, adapt or reject the new version.

6. Which chart best reveals a runaway-loop problem before the invoice arrives?

Total spend moves with traffic; averages hide tails. Cost per success and the rate of hitting the step cap move directly when agents start looping.

7. Your team has a versioned offline suite and a CI gate, but no production tracing, and the suite has not changed in six months. What is the most valuable next step?

This team is at maturity level 2. Level 3 is the production loop: without it the suite drifts away from what users actually do, and more epochs only measure the stale suite more precisely.

8. A random sample of 500 traces has 420 passes. The target is 85%. Which statement belongs in the report to leadership?

The standard error is about 1.6 points, so the interval spans the target. Report the interval, avoid claiming a miss or a hit you cannot support, and point to the actionable slice.
Expert corner: questions you should be able to answer
  • How do you check that offline scores predict online outcomes? Track, per release, the offline suite delta and the online delta (random-stream judge score, resolution rate). If they disagree repeatedly, your dataset's slice mix or graders do not match production; reweight the suite to production's mix and mine new cases from the disagreement.
  • How do you combine flagged and random streams correctly? With inverse-probability weighting: each judged trace gets weight 1 / P(sampled). A random-only trace sampled at 2% has weight 50; a flagged trace sampled with probability 1 has weight 1. Deterministic hash sampling makes these probabilities known exactly.
  • Why not use the thumbs-up rate as the CI gate? It does not exist in CI (no users), it is biased and sparse, and optimizing it invites sycophancy. Use it to find and weight cases, not as the target.
  • What breaks a shadow deployment for agents? Side effects (must be stubbed), divergence after the first write (later turns stop being comparable), and cost (you pay for two agents). It is best for early-step and single-turn comparisons.
  • Sequential testing. If you peek at an A/B test every day and stop as soon as p < 0.05, your false-positive rate is much higher than 5%. Use a fixed horizon, or sequential methods designed for peeking (for example always-valid confidence sequences).
  • What makes an eval gate trustworthy to engineers? It is fast, it rarely fails for reasons unrelated to their change (low flake rate), it explains itself (per-slice diffs and links to failing transcripts in the job summary), and its tolerance is written down and agreed. A gate that fails randomly gets bypassed within a month.

Sources and further reading