top of page

Tutorial 8: Capture and Replay Failures

  • Contributor
  • 4 days ago
  • 3 min read

A production failure is a free, real-world test case. Capture it, replay it, fix it, add it to your eval set forever.

Step 1: Identify Failure Signals (5 min)

A "failure" is:

  • User thumbs-down or negative feedback

  • User retries / rephrases (signal of bad first answer)

  • Escalation to human

  • Detected hallucination (didn't match retrieved source)

  • Crashed/empty response

Each is a signal. Log it.

Step 2: Capture Full Context (10 min)

When a failure happens, save everything needed to reproduce:

def log_failure(event):
    record = {
        "id": event.id,
        "timestamp": event.timestamp,
        "user_input": event.input,
        "system_prompt": event.system_prompt,
        "model": event.model,
        "model_version": event.model_version,
        "rag_chunks": event.rag_chunks,  # if applicable
        "tool_calls": event.tool_calls,
        "output": event.output,
        "failure_signal": event.failure_signal,
        "user_feedback": event.feedback_text,
    }
    save_to_failure_log(record)

Without all this, you can't reproduce.

Step 3: Build a Replay Tool (15 min)

def replay_failure(failure_id):
    record = load_failure(failure_id)
    
    # Re-run with same inputs
    response = call_llm(
        model=record["model"],
        system=record["system_prompt"],
        input=record["user_input"],
        context=record["rag_chunks"],
    )
    
    print("Original output:", record["output"])
    print("Replayed output:", response)
    return response

Does the failure reproduce? Sometimes yes (deterministic failure), sometimes no (noise).

Step 4: Test Mitigations (10 min)

def test_fix(failure_id, new_prompt):
    record = load_failure(failure_id)
    response = call_llm(
        model=record["model"],
        system=new_prompt,
        input=record["user_input"],
        context=record["rag_chunks"],
    )
    return response

Run the failure through a new prompt. Does it work now?

Step 5: Categorize Failures (10 min)

categories = {
    "hallucination": "Output contains false information",
    "format_violation": "Output doesn't match schema",
    "off_topic": "Output unrelated to question",
    "incomplete": "Output truncated or partial",
    "wrong_tone": "Output rude or inappropriate",
    "refusal": "LLM refused to answer (false positive)",
    "tool_misuse": "Wrong tool used",
}

def categorize(failure):
    # Manual or LLM-based categorization
    ...

Categorize so you can address patterns, not just individual cases.

Step 6: Promote to Eval Set (10 min)

For confirmed-reproducible failures:

def promote_to_eval(failure_id):
    failure = load_failure(failure_id)
    
    eval_case = {
        "id": f"prod-{failure_id}",
        "input": failure["user_input"],
        "category": categorize(failure),
        "expected": {
            "must_not_contain": [extract_hallucination(failure)],
            # Or "must_contain", "no_pii", etc.
        },
        "source": "production",
        "added_at": now(),
    }
    
    eval_set.append(eval_case)
    save_eval_set(eval_set)

Eval set grows organically from real failures. Each one prevented forever.

Step 7: Anonymize Personal Info (10 min)

def anonymize(failure):
    # Replace PII before adding to eval
    text = failure["user_input"]
    text = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', 'EMAIL', text)
    text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', 'SSN', text)
    # ... more patterns
    failure["user_input"] = text
    return failure

Don't put customer data in eval set. Anonymize first.

Step 8: Bulk Replay (10 min)

When a new prompt is proposed, replay all known failures:

def bulk_replay(prompt_version):
    failures = load_all_failures(status="open")
    results = []
    for failure in failures:
        new_output = replay_failure_with_prompt(failure["id"], prompt_version)
        fixed = check_if_fixed(failure, new_output)
        results.append({"id": failure["id"], "fixed": fixed})
    
    fixed_count = sum(r["fixed"] for r in results)
    print(f"{fixed_count}/{len(failures)} failures fixed")

Quantify the fix.

Step 9: Track Recurrence (5 min)

When a failure recurs in production, increment its count:

def track_recurrence(failure_event):
    # Match against known failures (semantic similarity or rules)
    similar = find_similar_failures(failure_event)
    if similar:
        similar.count += 1
        similar.last_seen = now()
    else:
        log_new_failure(failure_event)

Frequent failures get priority. Rare/once-only may not be worth chasing.

Step 10: Close the Loop (5 min)

After fixing:

  1. Run the failure replay through new prompt → confirms fix

  2. Add to eval set → prevents regression

  3. Mark failure "fixed" in log

  4. Deploy

  5. Monitor for recurrence

If it recurs: fix again, this time more thoroughly.

What You Just Did

Production failure capture and replay. Real failures become eval cases. Real fixes get validated before deploy.

Common Failure Modes

Lost context. Can't reproduce because logs lack the system prompt or RAG chunks.

No anonymization. PII in eval set; legal risk.

Replay non-determinism. Failure happened with old model version; new version behaves differently.

No prioritization. Treat all failures equally; miss the frequent ones.

Closed loop never run. Fixes deployed without replay verification.

bottom of page