n4nAI

Deterministic testing for non-deterministic agent loops

Practical strategies for building deterministic tests for LLM agent loops despite model non-determinism, with mocking, replay, and contract tests.

n4n Team4 min read859 words

Audio narration

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

Non-deterministic model outputs break ordinary unit tests, but you can still write deterministic tests for LLM agent loops by isolating the stochastic boundary. The key is to treat the model as a mocked collaborator or a replayed recording, not as a live service during CI. This article lays out a testing architecture that catches real agent bugs without flaky assertions on generated text.

The core problem: agent loops are stateful and stochastic

An agent loop is a while loop wrapped around a language model. Each iteration builds a prompt from conversation history, calls the model, parses tool calls, executes them, and feeds observations back. The loop terminates when the model returns a final answer or hits a step limit.

The model call is the only inherently random step. Temperature, sampling, provider routing, and even silent model version changes alter the exact tokens. If your test asserts on raw model output, it will pass once and fail later for reasons unrelated to your code.

Tool execution and state management are deterministic given the same model responses. That determinism is what you can and should test.

Thesis: deterministic tests for LLM agent loops require boundary isolation

You cannot make the model deterministic, but you can make the system under test deterministic by injecting controlled model behavior. Write deterministic tests for LLM agent loops by replacing the model with a scripted double, a recorded cassette, or a contract-checked stub. The agent’s control flow, error handling, and tool orchestration are then fully exercisable in milliseconds without network or token cost.

This is not a compromise on quality. It is the same discipline we apply to databases and external APIs: mock the boundary, test your logic.

Strategy 1: Mock the model interface

Define a narrow interface for the model call. Your agent should depend on a ChatModel protocol, not on openai.ChatCompletion.create directly. In tests, supply a ScriptedModel that returns queued responses.

from typing import Protocol, List, Dict, Any

class ChatModel(Protocol):
    def complete(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: ...

class ScriptedModel:
    def __init__(self, responses: List[Dict[str, Any]]):
        self.responses = list(responses)
        self.calls: List[List[Dict[str, Any]]] = []

    def complete(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
        self.calls.append(messages)
        return self.responses.pop(0)

def run_agent(model: ChatModel, task: str, max_steps: int = 5) -> str:
    messages = [{"role": "user", "content": task}]
    for _ in range(max_steps):
        resp = model.complete(messages)
        if resp.get("tool_calls"):
            for call in resp["tool_calls"]:
                # assume local dispatch
                result = {"call_id": call["id"], "output": "5"}
                messages.append({"role": "tool", "content": result["output"], "tool_call_id": call["id"]})
            continue
        return resp.get("content", "")
    raise RuntimeError("agent did not terminate")

Now a test is pure and fast:

def test_agent_loops_with_tool():
    script = [
        {"tool_calls": [{"id": "c1", "name": "calc", "arguments": {"expr": "2+3"}}]},
        {"content": "The answer is 5"}
    ]
    model = ScriptedModel(script)
    out = run_agent(model, "What is 2+3?")
    assert out == "The answer is 5"
    assert len(model.calls) == 2
    assert model.calls[0][-1]["content"] == "What is 2+3?"

This catches bugs in message assembly, tool result routing, and termination conditions. It does not validate that the real model emits the expected tool_calls shape—that is a separate concern.

Strategy 2: Record and replay real traffic

Mocking alone gives false confidence that the model will actually produce your scripted JSON. Capture a real session once and replay it in CI. Use a cassette format that stores the exact request and response.

When capturing live sessions, an OpenRouter-class gateway such as n4n.ai can log the exact request/response pairs and honor cache-control hints, making cassette generation reproducible and cheap. You then freeze those interactions:

{
  "interactions": [
    {
      "request": {"model": "gpt-4o", "messages": [{"role": "user", "content": "Ping"}]},
      "response": {"choices": [{"message": {"role": "assistant", "content": "Pong"}}]}
    }
  ]
}

A replay client matches the request (ignoring nondeterministic fields like timestamps) and returns the stored response. This upgrades your scripted mock to a real-world-shaped mock. Replay tests still run offline and deterministically, but they exercise the parsing code against actual provider payloads.

The tradeoff: cassettes rot when the provider changes its schema or your prompt construction changes. Treat cassettes like fixtures—review them in code review and regenerate on intent, not on every run.

Strategy 3: Contract tests for tool schemas

The model binds to your tools via a schema. If your agent advertises a calculator tool with a wrong parameter type, the real model may emit a string where your code expects an integer. Deterministic tests for LLM agent loops must include schema contract checks.

def test_calc_tool_contract():
    tool = agent.get_tool("calc")
    schema = tool.schema
    assert schema["type"] == "object"
    assert schema["properties"]["expr"]["type"] == "string"
    # ensure agent validates before dispatch
    assert tool.validate({"expr": "2+3"}) is True
    assert tool.validate({"expr": 2}) is False

Run these in CI on every change to tool definitions. They are cheap and prevent the most common production agent failure: mismatched tool contracts causing the model to hallucinate arguments.

Strategy 4: Property-based and differential tests

Beyond exact scripting, assert invariants over the loop. Using a property-based framework, generate random scripted model behaviors (e.g., random number of tool calls, random final answers) and verify the agent never crashes, always terminates within max_steps, and always returns a string.

from hypothesis import given, strategies as st

@given(st.lists(st.one_of(
    st.builds(lambda: {"tool_calls": [{"id": "x", "name": "calc", "arguments": {}}]}),
    st.builds(lambda: {"content": "done"})
), min_size=1, max_size=5))
def test_agent_terminates(responses):
    model = ScriptedModel(responses + [{"content": "fallback"}])
    out = run_agent(model, "task")
    assert isinstance(out, str)

This widens coverage without hand-writing dozens of scripts. Differential testing—running the same task against two model versions and comparing tool-call sequences—also fits here, but belongs in nightly jobs, not CI.

Tradeoffs: when to use real models in tests

Scripted and replayed tests are fast and stable, but they cannot catch:

  • The model returning malformed JSON that your parser silently drops.
  • A provider emitting a different finish_reason that changes loop exit.
  • Tool-call argument drift under real prompting.

For those, you need occasional live calls. The cost is flakiness and latency. Mitigate by isolating live tests into a nightly suite with relaxed assertions (e.g., “agent returns a number” not “agent returns 42”). Use a fixed seed where the provider supports it, and cap spend.

Deterministic tests for LLM agent loops should be the default in CI because they gate logic regressions. Live tests are evaluation, not unit testing.

Decisive takeaway

Build your agent against a model interface, mock it with scripted responses for control-flow tests, record real payloads for replay fixtures, and contract-test your tool schemas. Add property tests for robustness. Run real-model integration nightly, not on every commit. This layered approach gives you deterministic tests for LLM agent loops that are fast, trustworthy, and still honest about the stochastic boundary.

If you adopt only one practice: inject the model as a dependency and never call it directly in a unit test. Everything else follows from that seam.

Tagsagent-testingdeterminismllm-agentstesting

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 testing ai agents & tool calling posts →