mech.app
Dev Tools

Testing Non-Deterministic AI Agents: A Framework for Evals When Output Varies by Design

Property-based testing, statistical thresholds, and deterministic replay techniques for agents whose outputs are intentionally stochastic.

Source: dev.to
Testing Non-Deterministic AI Agents: A Framework for Evals When Output Varies by Design

AI incidents climbed from 233 in 2024 to 362 in 2025, with hallucination rates across 26 production models ranging from 22% to 94%. Traditional unit tests expect deterministic outputs. Agents produce multiple valid answers, call tools in different orders, and generate text that varies token by token. The gap between “works on my machine” and “safe in production” is widening.

This article exposes the plumbing needed to test agents whose outputs are non-deterministic by design. You will see property-based testing, statistical thresholds, deterministic replay techniques, and how to integrate probabilistic pass/fail criteria into CI/CD pipelines that expect binary outcomes.

Why Exact-Match Assertions Break

Traditional QA relies on fixed inputs producing fixed outputs. You assert that calculateTax(100, 0.08) returns 8.00. Agents operate on probability distributions. The same prompt can yield three equally correct email drafts, two valid SQL queries, or five different tool-call sequences.

String matching fails when an agent generates “The meeting is scheduled for 3 PM” versus “Your meeting starts at 15:00.” Both are correct. Hardcoded assertions flag valid variations as failures, creating false negatives that erode trust in your test suite.

The Non-Deterministic Testing Framework

1. Property-Based Testing Over Output Matching

Instead of asserting exact outputs, define properties that all valid outputs must satisfy.

Example properties for a customer service agent:

  • Response contains a greeting and closing
  • Tone is polite (sentiment score > 0.6)
  • No personally identifiable information (PII) leaks
  • Response length between 50 and 300 tokens
  • At least one actionable next step mentioned
def test_customer_response_properties(agent, test_cases):
    for case in test_cases:
        response = agent.handle_inquiry(case.input)
        
        assert has_greeting_and_closing(response)
        assert sentiment_score(response) > 0.6
        assert not contains_pii(response)
        assert 50 <= token_count(response) <= 300
        assert has_actionable_step(response)

Properties shift focus from “did it say exactly this” to “does it behave correctly.” You can run the same test 100 times and get 100 different responses that all pass.

2. Statistical Thresholds for Flaky Behavior

A single run tells you nothing about reliability. Run the same test 50 times and measure pass rate. If your agent passes 48/50 times, that is a 96% success rate. You define acceptable thresholds based on risk tolerance.

Threshold examples:

  • Critical safety checks: 100% (zero tolerance)
  • Tone and style: 95%
  • Performance optimization: 80%
def statistical_test(agent, test_case, runs=50, threshold=0.95):
    passes = 0
    for _ in range(runs):
        result = agent.execute(test_case.input)
        if validate_properties(result):
            passes += 1
    
    pass_rate = passes / runs
    assert pass_rate >= threshold, f"Pass rate {pass_rate} below {threshold}"

This approach surfaces intermittent failures that single-run tests miss. If your agent hallucinates 10% of the time, you need 20+ runs to detect it reliably.

3. Deterministic Replay for Debugging

When a test fails, you need to reproduce it. Non-deterministic systems make this hard. Capture the full execution trace: prompt, model parameters, tool calls, retrieved context, and random seeds.

Replay infrastructure:

  • Log every LLM call with temperature, top_p, seed
  • Snapshot vector store state at test time
  • Record tool call responses (API results, database queries)
  • Store intermediate reasoning steps
class ReplayableAgent:
    def __init__(self, trace_id=None):
        self.trace_id = trace_id or generate_trace_id()
        self.trace_log = []
    
    def call_llm(self, prompt, **kwargs):
        if self.replay_mode:
            return self.load_cached_response(self.trace_id, prompt)
        
        response = llm.generate(prompt, **kwargs)
        self.trace_log.append({
            "prompt": prompt,
            "response": response,
            "params": kwargs,
            "timestamp": now()
        })
        return response

When a test fails, you replay the exact sequence using cached responses. This eliminates non-determinism during debugging while preserving it during normal test runs.

4. Tool Call Validation

Agents call tools in different orders. A travel agent might check flights before hotels or vice versa. Both sequences are valid if the final itinerary is correct.

Validation approach:

  • Assert required tools were called (not order)
  • Validate tool inputs meet constraints
  • Check final state, not intermediate steps
def test_travel_booking(agent, request):
    result = agent.plan_trip(request)
    
    # Check required tools were called
    assert "search_flights" in result.tools_called
    assert "search_hotels" in result.tools_called
    
    # Validate tool inputs
    flight_call = result.get_tool_call("search_flights")
    assert flight_call.params["origin"] == request.origin
    assert flight_call.params["destination"] == request.destination
    
    # Check final state
    assert result.itinerary.has_valid_flights()
    assert result.itinerary.has_valid_accommodation()

Order independence matters. If your test breaks because the agent called tools in a different sequence, you are testing implementation details instead of behavior.

5. Semantic Similarity for Text Outputs

When exact matching fails, measure semantic similarity. Embed expected and actual outputs, compute cosine similarity, and set a threshold.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

def test_semantic_similarity(agent, test_case, threshold=0.85):
    expected = test_case.expected_output
    actual = agent.generate(test_case.input)
    
    expected_embedding = model.encode(expected)
    actual_embedding = model.encode(actual)
    
    similarity = cosine_similarity(expected_embedding, actual_embedding)
    assert similarity >= threshold

This catches semantic drift (agent starts answering differently) while allowing stylistic variation.

CI/CD Integration with Probabilistic Tests

CI/CD pipelines expect binary pass/fail. Statistical tests return probabilities. You need an adapter layer.

Integration strategies:

ApproachWhen to UseTrade-off
Fail on threshold breachProduction-critical pathsSlower builds (50+ runs per test)
Sample subset in CI, full suite nightlyLarge test suitesDelayed detection of rare failures
Flakiness budgetAcceptable failure rate definedRequires monitoring dashboard
Canary deployments with live trafficHigh-risk changesNeeds rollback automation
# GitHub Actions example
- name: Run statistical agent tests
  run: |
    pytest tests/agents/ \
      --runs=50 \
      --threshold=0.95 \
      --fail-fast=false \
      --report=json
  
- name: Check pass rate
  run: |
    python scripts/check_pass_rate.py \
      --report=test_results.json \
      --min-rate=0.95

The key is separating test execution (which may have acceptable failures) from build gating (which enforces thresholds).

Common Failure Modes

1. Over-constraining properties

Setting too many rigid properties recreates the exact-match problem. If you assert response length must be exactly 150 tokens, you have not solved anything.

2. Insufficient sample size

Running 5 iterations and declaring success is statistical malpractice. You need 30+ runs minimum to detect 10% failure rates with confidence.

3. Ignoring temporal drift

Your agent passes tests today. Three months later, the underlying model updates and behavior shifts. Continuous monitoring catches this. One-time test runs do not.

4. Testing implementation instead of behavior

Asserting the agent called search_database() before format_response() is brittle. Test that the final response contains accurate data, not how it was retrieved.

Observability Requirements

Testing non-deterministic systems requires observability infrastructure:

  • Trace storage: Persist full execution traces for failed runs
  • Metrics aggregation: Track pass rates, latency distributions, tool call patterns
  • Diff tooling: Compare outputs across runs to detect drift
  • Alerting: Trigger when pass rates drop below thresholds

Without observability, you are flying blind. A test that passes 95% of the time today might drop to 70% tomorrow, and you will not notice until users complain.

Technical Verdict

Use this framework when:

  • Your agent produces multiple valid outputs for the same input
  • You need to ship agents to production with measurable quality guarantees
  • Traditional unit tests create more false positives than real bug detection
  • You can afford the infrastructure cost of trace storage and replay

Avoid this approach when:

  • Your system is deterministic (use standard unit tests)
  • You cannot tolerate the latency of running 50+ iterations per test
  • Your team lacks the observability infrastructure to debug probabilistic failures
  • Regulatory requirements demand 100% reproducibility (consider constraining model parameters instead)

The gap between “it works in the demo” and “it works in production” is filled with non-deterministic behavior. Property-based testing, statistical thresholds, and deterministic replay give you the tools to measure and control that behavior without forcing agents into rigid boxes they were never designed to fit.

Tags

agentic-ai orchestration infrastructure

Primary Source

dev.to