n4nAI

How to test LLM outputs when results aren't reproducible

A practical guide to testing non-reproducible LLM outputs using deterministic sampling, statistical evaluation, property-based checks, and production drift monitoring.

n4n Team4 min read958 words

Audio narration

Coming soon — every post will get a voice note here.

Testing non-reproducible LLM outputs requires a fundamentally different mindset than traditional software testing. You cannot assert exact string equality when the system under test samples from a probability distribution. Instead, you need deterministic sampling controls, statistical evaluation harnesses, property-based assertions that capture structural correctness, and continuous monitoring to catch drift before users do. This guide walks through each layer, from local development to production observability.

Step 1: Lock down what you can control

Start by eliminating variance sources that are not the model itself. Temperature, top-p, and top-k are the obvious knobs, but the seed parameter is the one that actually buys you reproducibility — when the provider honors it.

# openai_client.py
from openai import OpenAI
import os

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def complete_deterministic(
    prompt: str,
    model: str = "gpt-4o-mini",
    seed: int = 42,
    temperature: float = 0.0,
    max_tokens: int = 512,
) -> str:
    """
    Request deterministic completion. Not all providers honor seed;
    treat this as best-effort and verify in Step 2.
    """
    resp = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=temperature,
        top_p=1.0,
        seed=seed,
        max_tokens=max_tokens,
    )
    return resp.choices[0].message.content

Set temperature=0.0 and a fixed seed. Most major providers (OpenAI, Anthropic, Google) now respect seed for a given model version, but the contract is “best effort.” Model updates, infrastructure changes, or provider-side batching can still produce different tokens. Treat deterministic sampling as a variance reduction technique, not a guarantee.

Verify success: Run the same prompt 10 times with the same seed. If you get identical outputs, the provider honored the seed for that model version. Log the model fingerprint (system_fingerprint in OpenAI responses) to detect silent model upgrades.

Step 2: Build a statistical evaluation harness

When exact matching fails, you need to evaluate distributions. Define a metric that captures task correctness, then measure its mean and confidence interval over N samples.

# eval_harness.py
import statistics
from dataclasses import dataclass
from typing import Callable
from openai_client import complete_deterministic

@dataclass
class EvalResult:
    metric_name: str
    mean: float
    stdev: float
    ci95_low: float
    ci95_high: float
    n: int
    passed: bool

def evaluate_distribution(
    prompt: str,
    metric_fn: Callable[[str], float],
    n_samples: int = 30,
    threshold: float = 0.8,
    model: str = "gpt-4o-mini",
    seed_base: int = 42,
) -> EvalResult:
    """
    Sample N times with different seeds, compute metric per sample,
    return mean + 95% CI. Passes if CI lower bound >= threshold.
    """
    scores = []
    for i in range(n_samples):
        # Vary seed per sample to explore the distribution
        output = complete_deterministic(prompt, model=model, seed=seed_base + i)
        scores.append(metric_fn(output))

    mean = statistics.mean(scores)
    stdev = statistics.stdev(scores) if n_samples > 1 else 0.0
    # 95% CI via t-distribution approximation
    margin = 1.96 * stdev / (n_samples ** 0.5)
    ci_low, ci_high = mean - margin, mean + margin

    return EvalResult(
        metric_name=metric_fn.__name__,
        mean=mean,
        stdev=stdev,
        ci95_low=ci_low,
        ci95_high=ci_high,
        n=n_samples,
        passed=ci_low >= threshold,
    )

Choose metrics that reflect your actual task:

  • Exact match / F1 / ROUGE for extraction and summarization
  • Code execution pass rate for code generation
  • Schema validation pass rate for structured output
  • Custom rubric scored by a stronger model (LLM-as-judge)

Verify success: Run the harness on a known-good prompt. The CI lower bound should clear your threshold with comfortable margin. If the CI straddles the threshold, increase n_samples or improve the prompt.

Step 3: Add property-based tests for structural guarantees

Statistical metrics miss structural failures — invalid JSON, missing required fields, hallucinated enum values. Property-based testing (à la Hypothesis) generates diverse inputs and asserts invariants that must hold for every output.

# test_properties.py
import json
import pytest
from hypothesis import given, strategies as st, settings
from openai_client import complete_deterministic
from pydantic import BaseModel, ValidationError

class ExtractionSchema(BaseModel):
    entities: list[str]
    relations: list[tuple[str, str, str]]
    confidence: float

SCHEMA_PROMPT = """
Extract entities and relations from the text. Return ONLY valid JSON matching:
{"entities": ["..."], "relations": [["subj", "pred", "obj"]], "confidence": 0.0-1.0}
Text: {text}
"""

@settings(max_examples=50, deadline=None)
@given(text=st.text(min_size=10, max_size=500))
def test_schema_validity(text: str):
    """Every output must parse as valid JSON and validate against schema."""
    prompt = SCHEMA_PROMPT.format(text=text)
    output = complete_deterministic(prompt, seed=123, temperature=0.0)
    
    # Property 1: Valid JSON
    parsed = json.loads(output)
    
    # Property 2: Schema compliance
    validated = ExtractionSchema(**parsed)
    
    # Property 3: Confidence in range
    assert 0.0 <= validated.confidence <= 1.0
    
    # Property 4: Relations reference declared entities
    entity_set = set(validated.entities)
    for subj, _, obj in validated.relations:
        assert subj in entity_set, f"Subject {subj} not in entities"
        assert obj in entity_set, f"Object {obj} not in entities"

@settings(max_examples=20, deadline=None)
@given(text=st.text(min_size=10, max_size=500))
def test_idempotence_at_temp_zero(text: str):
    """Same prompt + same seed + temp=0 should produce identical output."""
    prompt = SCHEMA_PROMPT.format(text=text)
    out1 = complete_deterministic(prompt, seed=42, temperature=0.0)
    out2 = complete_deterministic(prompt, seed=42, temperature=0.0)
    assert out1 == out2, "Deterministic sampling not honored"

Run these in CI with a small sample budget. They catch regressions that statistical metrics miss: a prompt change that breaks JSON syntax, a model upgrade that drops a required field, a provider that stops honoring seed.

Verify success: CI passes consistently. Introduce a deliberate bug (remove a required field from the prompt) and confirm the test fails.

Step 4: Create golden datasets with fuzzy matching

Golden datasets are your regression anchor. Store (input, expected_output) pairs, but compare with fuzzy matching — not exact strings. Use semantic similarity or task-specific equivalence.

# golden_dataset.py
import json
from pathlib import Path
from dataclasses import dataclass
from typing import Callable
from openai_client import complete_deterministic
from sentence_transformers import SentenceTransformer

EMBEDDER = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

@dataclass
class GoldenCase:
    name: str
    input: str
    expected: str
    threshold: float = 0.85  # cosine similarity threshold

def load_golden_dataset(path: Path) -> list[GoldenCase]:
    with open(path) as f:
        data = json.load(f)
    return [GoldenCase(**item) for item in data]

def cosine_similarity(a: str, b: str) -> float:
    emb_a = EMBEDDER.encode(a, normalize_embeddings=True)
    emb_b = EMBEDDER.encode(b, normalize_embeddings=True)
    return float((emb_a * emb_b).sum())

def run_golden_evaluation(
    cases: list[GoldenCase],
    model: str = "gpt-4o-mini",
    seed: int = 42,
) -> dict:
    results = {}
    for case in cases:
        output = complete_deterministic(case.input, model=model, seed=seed)
        score = cosine_similarity(output, case.expected)
        results[case.name] = {
            "passed": score >= case.threshold,
            "score": score,
            "threshold": case.threshold,
            "output": output,
        }
    return results

Store golden cases as JSON:

[
  {
    "name": "extract_entities_simple",
    "input": "Extract entities: Apple acquired Beats for $3B in 2014.",
    "expected": "Entities: Apple, Beats. Relation: Apple acquired Beats.",
    "threshold": 0.85
  },
  {
    "name": "summarize_technical",
    "input": "Summarize in 2 sentences: The transformer architecture uses self-attention...",
    "expected": "Transformers use self-attention to process sequences in parallel. This enables training on massive datasets.",
    "threshold": 0.80
  }
]

Semantic similarity via embeddings handles paraphrase variance. For code or structured output, replace cosine_similarity with a task-specific comparator (AST equivalence, JSON deep-diff with ignored fields, SQL execution equivalence).

Verify success: All golden cases pass. When you change a prompt, run the golden set first — any regression shows up immediately.

Step 5: Implement LLM-as-judge for nuanced quality

Some qualities (tone, helpfulness, safety) resist programmatic metrics. Use a stronger model as a calibrated judge. The key is structured output and few-shot calibration.

# llm_judge.py
from pydantic import BaseModel, Field
from openai_client import complete_deterministic
import json

class JudgeScore(BaseModel):
    score: int = Field(ge=1, le=5)
    reasoning: str

JUDGE_PROMPT = """You are an expert evaluator. Score the response on {criterion} from 1-5.
Return ONLY JSON: {{"score": int, "reasoning": "..."}}

Criterion: {criterion}
Definition: {definition}

Examples:
{examples}

Response to evaluate:
{response}
"""

def judge_response(
    response: str,
    criterion: str,
    definition: str,
    examples: list[tuple[str, int, str]],
    model: str = "gpt-4o",
    seed: int = 42,
) -> JudgeScore:
    ex_text = "\n".join(
        f'Response: "{r}"\nScore: {s}\nReasoning: "{rea}"'
        for r, s, rea in examples
    )
    prompt = JUDGE_PROMPT.format(
        criterion=criterion,
        definition=definition,
        examples=ex_text,
        response=response,
    )
    output = complete_deterministic(prompt, model=model, seed=seed, temperature=0.0)
    return JudgeScore.model_validate_json(output)

Calibrate with 5-10 examples spanning the score range. Run the judge over your golden set outputs to establish baseline scores. Track judge scores over time alongside your programmatic metrics.

Verify success: Judge scores on golden set are stable (variance < 0.5 points across runs). Human spot-check agrees with judge ≥ 90% of the time.

Step 6: Monitor production drift with shadow evaluation

Testing non-reproducible LLM outputs doesn’t stop at deploy. You need continuous visibility into whether the model’s behavior has shifted. Shadow evaluation runs your evaluation pipeline on a sample of production traffic.

# shadow_eval.py
import random
import time
from dataclasses import dataclass
from typing import Optional
from openai_client import complete_deterministic
from eval_harness import evaluate_distribution

@dataclass
class ShadowConfig:
    sample_rate: float = 0.01  # 1% of requests
    min_samples_per_hour: int = 10
    alert_threshold_ci_drop: float = 0.05  # CI lower bound drop

class ShadowEvaluator:
    def __init__(self, config: ShadowConfig, metric_fn, threshold: float):
        self.config = config
        self.metric_fn = metric_fn
        self.threshold = threshold
        self.baseline_ci_low: Optional[float] = None

    def maybe_evaluate(self, prompt: str, production_output: str) -> Optional[dict]:
        if random.random() > self.config.sample_rate:
            return None

        # Re-run with fixed seed to get distribution
        result = evaluate_distribution(
            prompt=prompt,
            metric_fn=self.metric_fn,
            n_samples=self.config.min_samples_per_hour,
        )

        if self.baseline_ci_low is None:
            self.baseline_ci_low = result.ci95_low
            return {"status": "baseline_established", "ci_low": result.ci95_low}

        drop = self.baseline_ci_low - result.ci95_low
        alert = drop > self.config.alert_threshold_ci_drop

        return {
            "status": "evaluated",
            "current_ci_low": result.ci95_low,
            "baseline_ci_low": self.baseline_ci_low,
            "drop": drop,
            "alert": alert,
        }

Wire this into your request path (async, non-blocking). Alert when the CI lower bound drops more than 5 percentage points from baseline. This catches silent model upgrades, provider degradation, or prompt injection attacks that degrade quality without breaking the API.

Verify success: Deploy shadow evaluation for one week. Baseline stabilizes. Inject a known-bad prompt variant and confirm alert fires.

Step 7: Automate the full loop in CI/CD

Wire everything into a pipeline that runs on every prompt or model change:

# .github/workflows/llm-eval.yml
name: LLM Evaluation
on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'eval/**'
  schedule:
    - cron: '0 6 * * *'  # daily drift check

jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt
      - name: Run property tests
        run: pytest test_properties.py -v
      - name: Run golden dataset
        run: python -m eval_golden
      - name: Run statistical harness
        run: python -m eval_statistical
      - name: Run LLM judge
        run: python -m eval_judge
      - name: Comment PR with results
        uses: actions/github-script@v7
        with:
          script: |
            // post summary to PR

Require all checks to pass before merge. The daily scheduled run catches provider-side changes (model updates, infrastructure shifts) that no code change triggered.

Verify success: Open a PR that degrades a prompt. CI fails. Check the daily run logs — they should show stable baselines with no alerts.

Putting it together: a mental model for testing non-reproducible LLM outputs

Layer What it catches Cost Frequency
Deterministic sampling (seed + temp=0) Variance from sampling Free Every request
Property-based tests Structural invalidity Low (CI) Every PR
Golden dataset + fuzzy match Semantic regression Medium (CI) Every PR
Statistical harness (N=30) Quality distribution shift Medium (CI) Every PR
LLM-as-judge Nuanced quality (tone, safety) Higher (CI) Every PR
Shadow evaluation Production drift Continuous 1% of traffic

Start with deterministic sampling and property tests — they’re fast, cheap, and catch the loudest failures. Add golden datasets once you have stable prompts. Layer statistical harnesses and LLM judges for the quality dimensions that matter to your product. Shadow evaluation is the safety net that catches everything else.

The n4n.ai gateway forwards provider system_fingerprint and cache-control headers, which makes it easier to correlate evaluation results with specific model versions and detect when a provider silently swaps the underlying model. If your gateway doesn’t surface these, you’re flying blind on reproducibility.

Testing non-reproducible LLM outputs is not about eliminating variance — it’s about measuring it, bounding it, and alerting when it crosses your tolerance. Build the harness once, run it everywhere, and treat every model change (yours or the provider’s) as a potential regression.

Tagsreproducibilitytestingdeterminism

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All determinism, seeds & reproducibility in llms posts →