← Notes

2026-07-05 · 18 min read

The Stateful Agent Problem: Why LLM Agents Are Just Distributed Systems in Disguise

Picture this. You ship an AI agent that handles customer refund requests end-to-end. It reads the order database, checks the refund policy, calls an internal API to issue the credit, then sends a confirmation email. Works perfectly in staging. Users love it. Then one Tuesday, the email service times out midway through. The agent crashes.

The credit was already issued. The email never sent.

You now have a customer with a refund they don’t know about, support tickets asking where the money went, and an agent that (if you retry it naively) will issue the credit a second time.

This is not an AI problem. This is the dual-write problem. Engineers solved this in distributed systems fifteen years ago. But nobody told the agent framework.

The Lie That Agent Frameworks Tell You

Every popular agent framework (LangChain, LlamaIndex, CrewAI, AutoGen) makes multi-step reasoning look trivial. You define tools, hand the framework a goal, and it figures out the steps. It feels like magic.

What they don’t show you is what happens when step 3 fails after step 2 already had side effects. They don’t show you what happens when the same task gets queued twice. They don’t show you what happens when two agent instances run in parallel and both try to update the same record. The demos are always single-threaded, always successful, and always short.

Running a multi-step LLM agent in production is a distributed systems problem. The LLM is just the decision-making layer. The hard problems are exactly the same ones that made microservices painful: state consistency, partial failures, idempotency, and distributed coordination.

The good news: distributed systems has decades of battle-tested patterns for exactly this. The bad news: most teams building agents right now are rediscovering them from scratch, painfully, in production.

What “State” Actually Means in an Agent

Before mapping patterns, it’s worth being precise about what state a multi-step agent actually carries. There are three layers, and they fail in completely different ways.

Working memory is the context window: the conversation history, the system prompt, the tool schemas. In the refund scenario, this is the agent knowing it’s handling order #4821 for $47. When this breaks, the agent loses its reasoning thread and gives a bad answer. Annoying. Recoverable.

Execution memory is the record of what steps ran and what they returned. This is the agent knowing it already called the orders DB and got a pending status back. When this breaks, you get duplicate tool calls. The agent re-issues the credit because it forgot it already did that. Also recoverable, if you built idempotency in.

World state is everything the agent mutated in external systems: the credit issued in the payment API, the status updated in the orders DB, the email queued in SendGrid. When this breaks mid-sequence (say, the credit is issued but the email service times out), you have inconsistency that lives outside your system entirely. No rollback. No undo. The payment API doesn’t know your agent crashed.

This is the one that matters. Everything that follows is about world state.

Pattern 1: The Saga — Replacing Transactions with Compensations

In a traditional monolith, you wrap a multi-step operation in a database transaction. Either all steps succeed and commit, or all steps fail and roll back. Clean.

In a distributed system (or an agent calling multiple external tools), there is no global transaction. Each tool call is its own atomic operation with its own success or failure. If step 3 fails, steps 1 and 2 have already committed to their respective systems.

The solution is the Saga pattern. Instead of rollback, you define explicit compensation actions for each step. If the process fails at step N, you run the compensation for steps N-1, N-2, down to step 1 in reverse order.

sequenceDiagram
    participant A as Agent Orchestrator
    participant ODB as Orders DB
    participant PAY as Payment API
    participant EMAIL as Email Service

    A->>ODB: 1. Mark order as refund-pending
    ODB-->>A: OK

    A->>PAY: 2. Issue credit $47.00
    PAY-->>A: OK (credit_id: cr_8x2k)

    A->>EMAIL: 3. Send confirmation email
    EMAIL-->>A: TIMEOUT

    Note over A: Step 3 failed. Run compensations in reverse.

    A->>PAY: COMPENSATE: Reverse credit cr_8x2k
    PAY-->>A: OK

    A->>ODB: COMPENSATE: Mark order as refund-failed
    ODB-->>A: OK

    Note over A: State is consistent. Alert support team.

Here’s what this looks like in actual agent infrastructure. Compensation logic lives alongside the forward action. Not as an afterthought. As a first-class part of every tool definition.

# agents/saga.py

import logging
from dataclasses import dataclass, field
from typing import Callable, Any, Optional

log = logging.getLogger("agent.saga")


@dataclass
class SagaStep:
    name: str
    action: Callable[..., Any]
    compensate: Callable[..., Any]
    # If compensation itself fails, do we abort or keep rolling back?
    compensation_is_critical: bool = True


@dataclass
class SagaResult:
    success: bool
    completed_steps: list[str]
    failed_step: Optional[str]
    compensation_ran: bool
    output: Optional[Any] = None
    error: Optional[Exception] = None


class SagaOrchestrator:
    """
    Runs a sequence of steps with automatic compensation on failure.
    Each step's result is passed as context to subsequent steps,
    so the compensation actions have access to IDs and references
    they need to undo the work (e.g., the credit_id from step 2).
    """

    def __init__(self, steps: list[SagaStep]):
        self.steps = steps

    async def run(self, initial_context: dict) -> SagaResult:
        context = dict(initial_context)
        completed: list[tuple[SagaStep, Any]] = []

        for step in self.steps:
            try:
                log.info(f"saga.step.start: {step.name}")
                result = await step.action(context)
                context[step.name] = result
                completed.append((step, result))
                log.info(f"saga.step.success: {step.name}")

            except Exception as exc:
                log.error(f"saga.step.failed: {step.name} -- {exc}")

                # Run compensations in reverse order
                compensation_ran = await self._compensate(completed, context)

                return SagaResult(
                    success=False,
                    completed_steps=[s.name for s, _ in completed],
                    failed_step=step.name,
                    compensation_ran=compensation_ran,
                    error=exc,
                )

        return SagaResult(
            success=True,
            completed_steps=[s.name for s, _ in completed],
            failed_step=None,
            compensation_ran=False,
            output=context,
        )

    async def _compensate(
        self,
        completed: list[tuple[SagaStep, Any]],
        context: dict,
    ) -> bool:
        all_compensated = True

        for step, _ in reversed(completed):
            try:
                log.info(f"saga.compensate.start: {step.name}")
                await step.compensate(context)
                log.info(f"saga.compensate.success: {step.name}")

            except Exception as exc:
                log.error(f"saga.compensate.failed: {step.name} -- {exc}")
                all_compensated = False

                if step.compensation_is_critical:
                    # Compensation itself failed. Alert immediately.
                    # Do NOT continue silently -- this is a data consistency incident.
                    await alert_on_call(
                        f"CRITICAL: Saga compensation failed for step '{step.name}'. "
                        f"Manual intervention required. Context: {context}"
                    )
                    break

        return all_compensated


# Usage: wiring up the refund agent as a saga

async def handle_refund_request(order_id: str, amount: float):
    saga = SagaOrchestrator(steps=[
        SagaStep(
            name="mark_pending",
            action=lambda ctx: orders_db.update_status(ctx["order_id"], "refund_pending"),
            compensate=lambda ctx: orders_db.update_status(ctx["order_id"], "refund_failed"),
        ),
        SagaStep(
            name="issue_credit",
            action=lambda ctx: payment_api.issue_credit(ctx["order_id"], ctx["amount"]),
            compensate=lambda ctx: payment_api.reverse_credit(
                ctx["issue_credit"]["credit_id"]  # credit_id from the forward action
            ),
        ),
        SagaStep(
            name="send_confirmation",
            action=lambda ctx: email_service.send_refund_email(
                ctx["order_id"], ctx["issue_credit"]["credit_id"]
            ),
            # Email sends are not reversible. Log and alert instead.
            compensate=lambda ctx: log.warning(
                f"Email may have sent before failure. order_id={ctx['order_id']}"
            ),
            compensation_is_critical=False,
        ),
    ])

    return await saga.run({"order_id": order_id, "amount": amount})

Compensations are not rollbacks. A database rollback undoes the write atomically, as if it never happened. A compensation is a new forward action that logically reverses the first one. The credit reversal creates a new transaction in the payment system. The status update writes a new row. The audit trail shows everything. That’s not a bug in the design, it’s the point.

Pattern 2: Idempotency Keys — Because Agents Will Retry

Agents retry. Networks are flaky, tools time out, and LLMs occasionally hallucinate a failure that didn’t happen. The agent doesn’t know whether the action succeeded or failed. It just knows the response was dirty.

If the action isn’t idempotent, retrying creates duplicates. The customer gets charged twice. The Jira ticket gets created twice. The email sends twice. You’ve seen this class of bug before, just not wearing an agent costume.

The fix is the same one Stripe uses for payments: idempotency keys. Every tool call gets a deterministic key derived from the agent run ID and the step identity. Same key twice, you get the original result. No second execution.

# agents/idempotent_tools.py

import hashlib
import json
import redis
import time
from typing import Any, Callable, Optional

r = redis.Redis(host="localhost", port=6379, db=2, decode_responses=True)

IDEMPOTENCY_TTL = 86_400  # 24 hours -- long enough to cover any realistic retry window


def make_idempotency_key(run_id: str, step_name: str, inputs: dict) -> str:
    """
    Derive a stable key from the run context + step identity + inputs.
    Same inputs on the same step in the same run always produce the same key.
    """
    payload = json.dumps(
        {"run_id": run_id, "step": step_name, "inputs": inputs},
        sort_keys=True,
    )
    return "idem:" + hashlib.sha256(payload.encode()).hexdigest()


async def idempotent_call(
    run_id: str,
    step_name: str,
    inputs: dict,
    action: Callable[..., Any],
) -> dict:
    """
    Wraps any tool action with idempotency guarantees.
    On first call: execute the action, store the result.
    On retry: return the stored result, skip execution.
    """
    key = make_idempotency_key(run_id, step_name, inputs)

    # Check if this call already succeeded
    cached = r.get(key)
    if cached:
        result = json.loads(cached)
        result["idempotent_replay"] = True
        return result

    # Lock to prevent concurrent duplicate execution
    # (two retries arriving simultaneously)
    lock_key = f"{key}:lock"
    lock_acquired = r.set(lock_key, "1", nx=True, ex=30)

    if not lock_acquired:
        # Another instance is currently executing this step.
        # Wait briefly and check for the result.
        time.sleep(1)
        cached = r.get(key)
        if cached:
            return json.loads(cached)
        raise RuntimeError(
            f"Concurrent execution detected for step '{step_name}'. Retry later."
        )

    try:
        result = await action(**inputs)
        result_payload = {
            "result": result,
            "idempotent_replay": False,
            "executed_at": time.time(),
        }
        r.setex(key, IDEMPOTENCY_TTL, json.dumps(result_payload))
        return result_payload

    except Exception:
        # Do NOT cache failures. Allow the next retry to try again.
        raise

    finally:
        r.delete(lock_key)

One subtlety in the failure case: when the tool action raises an exception, the idempotency record is not written. The next retry executes again. This is correct. You only cache successful outcomes. Caching failures means the retry gets the cached failure back, which is exactly backwards from what you want.

Pattern 3: Checkpoint and Replay — Resuming Long-Running Agents

A short agent run (three or four tool calls) can afford to restart from scratch on failure. An agent that’s been running for ten minutes, made twenty tool calls, and is halfway through a complex research task absolutely cannot.

Checkpoints save agent state after each successful step. On failure, the agent picks up from the last checkpoint instead of step one.

flowchart TD
    START([Agent Start]) --> S1[Step 1: Gather Requirements]
    S1 --> CP1[(Checkpoint 1)]
    CP1 --> S2[Step 2: Search Knowledge Base]
    S2 --> CP2[(Checkpoint 2)]
    CP2 --> S3[Step 3: Draft Response]
    S3 -->|CRASH| FAIL([Process Killed])

    FAIL --> RESUME[Agent Resumes]
    RESUME --> LOAD[Load Latest Checkpoint]
    LOAD --> CP2_RESTORE[(Restore from Checkpoint 2)]
    CP2_RESTORE --> S3_RETRY[Retry Step 3 Only]
    S3_RETRY --> S4[Step 4: Review and Send]

    style CP1 fill:#3b82f6,color:#fff
    style CP2 fill:#3b82f6,color:#fff
    style FAIL fill:#ef4444,color:#fff
    style RESUME fill:#10b981,color:#fff
    style CP2_RESTORE fill:#10b981,color:#fff
# agents/checkpoint.py

import json
import time
import redis
from dataclasses import dataclass, asdict
from typing import Optional

r = redis.Redis(host="localhost", port=6379, db=3, decode_responses=True)

CHECKPOINT_TTL = 3600 * 6  # 6 hours -- covers any realistic agent run duration


@dataclass
class AgentCheckpoint:
    run_id: str
    last_completed_step: int
    step_name: str
    context: dict       # full context dict at time of checkpoint
    tool_call_log: list # ordered log of every tool call made so far
    created_at: float


class CheckpointManager:
    def save(self, checkpoint: AgentCheckpoint):
        key = f"agent:checkpoint:{checkpoint.run_id}"
        r.setex(key, CHECKPOINT_TTL, json.dumps(asdict(checkpoint)))

    def load(self, run_id: str) -> Optional[AgentCheckpoint]:
        key = f"agent:checkpoint:{run_id}"
        data = r.get(key)
        if not data:
            return None
        return AgentCheckpoint(**json.loads(data))

    def clear(self, run_id: str):
        r.delete(f"agent:checkpoint:{run_id}")


checkpointer = CheckpointManager()


class CheckpointedAgent:
    """
    Wraps a list of agent steps with checkpoint-and-resume logic.
    On startup, checks for an existing checkpoint and skips already-completed steps.
    """

    def __init__(self, run_id: str, steps: list):
        self.run_id = run_id
        self.steps = steps

    async def run(self, initial_context: dict) -> dict:
        # Try to resume from checkpoint
        checkpoint = checkpointer.load(self.run_id)

        if checkpoint:
            context = checkpoint.context
            tool_call_log = checkpoint.tool_call_log
            resume_from = checkpoint.last_completed_step + 1
            print(
                f"Resuming run {self.run_id} from step {resume_from} "
                f"({checkpoint.step_name})"
            )
        else:
            context = dict(initial_context)
            tool_call_log = []
            resume_from = 0

        for i, step in enumerate(self.steps):
            if i < resume_from:
                continue  # already completed in a previous run

            try:
                result = await step.execute(context)
                context[step.name] = result
                tool_call_log.append({
                    "step": i,
                    "name": step.name,
                    "result": result,
                    "timestamp": time.time(),
                })

                # Save checkpoint after every successful step
                checkpointer.save(AgentCheckpoint(
                    run_id=self.run_id,
                    last_completed_step=i,
                    step_name=step.name,
                    context=context,
                    tool_call_log=tool_call_log,
                    created_at=time.time(),
                ))

            except Exception as exc:
                # Checkpoint is already saved from the last successful step.
                # The next run will resume from there.
                raise RuntimeError(
                    f"Agent run {self.run_id} failed at step '{step.name}' (index {i}). "
                    f"Checkpoint saved. Retry will resume from step {i}."
                ) from exc

        # Run completed. Clean up the checkpoint.
        checkpointer.clear(self.run_id)
        return context

The context dict is what gets checkpointed. It grows step by step, each tool call’s output stored under the step name. Restoring from a checkpoint means restoring that exact dict and skipping the steps that already populated it. The agent has no idea it ever crashed.

Pattern 4: Distributed Locking — Preventing Parallel Agents from Stepping on Each Other

This one happens more than you’d expect. A user submits a request. The agent starts. The user refreshes the page. Your frontend retries. Now two agent instances are running in parallel for the same goal, both reading and writing the same records.

One instance issues the refund. The other started a millisecond earlier, already read the pre-refund state, and issues it again.

You need a lock on the resource, held for the full duration of the agent run, acquired before the first step fires.

# agents/distributed_lock.py

import time
import redis
import uuid
from contextlib import asynccontextmanager
from typing import Optional

r = redis.Redis(host="localhost", port=6379, db=4, decode_responses=True)


class AgentLock:
    """
    Distributed lock using Redis SET NX with expiry.
    Uses a unique token per lock acquisition to prevent accidental release
    by a different holder (e.g., if the lock expires and is re-acquired
    while the original holder is still running slowly).
    """

    def __init__(self, resource_id: str, ttl_seconds: int = 300):
        self.resource_id = resource_id
        self.ttl = ttl_seconds
        self.key = f"agent:lock:{resource_id}"
        self.token: Optional[str] = None

    def acquire(self) -> bool:
        self.token = str(uuid.uuid4())
        acquired = r.set(self.key, self.token, nx=True, ex=self.ttl)
        return bool(acquired)

    def release(self) -> bool:
        # Only release if we still hold the lock (token matches).
        # Lua script makes the check-and-delete atomic.
        lua = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0
        end
        """
        result = r.eval(lua, 1, self.key, self.token)
        return bool(result)

    def extend(self) -> bool:
        """Extend the lock TTL for long-running agents."""
        lua = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('EXPIRE', KEYS[1], ARGV[2])
        else
            return 0
        end
        """
        result = r.eval(lua, 1, self.key, self.token, self.ttl)
        return bool(result)


@asynccontextmanager
async def agent_exclusive_run(resource_id: str, timeout_seconds: int = 300):
    """
    Context manager that ensures only one agent runs for a given resource at a time.
    Raises if the lock cannot be acquired (another agent is already running).
    """
    lock = AgentLock(resource_id, ttl_seconds=timeout_seconds)

    if not lock.acquire():
        raise RuntimeError(
            f"Agent already running for resource '{resource_id}'. "
            f"Reject this request or queue it for later."
        )

    try:
        yield lock
    finally:
        lock.release()


# Usage
async def run_refund_agent(order_id: str, amount: float):
    async with agent_exclusive_run(f"order:{order_id}"):
        # Only one instance can enter this block for a given order_id at a time
        saga = SagaOrchestrator(steps=[...])
        return await saga.run({"order_id": order_id, "amount": amount})

The Lua script on release is not optional. Without it, there’s a race: your token check passes, someone else’s lock expires and gets re-acquired by a third process, and you delete their lock instead of yours. Making it atomic inside Redis closes that window. One round trip, no gap.

Pattern 5: Event Sourcing — The Observability Foundation

The saga, checkpoints, and locks keep your agent from corrupting state. Event sourcing is what lets you understand what actually happened when something goes wrong anyway.

In most agent implementations, state is a dict that gets mutated as steps run. When something breaks, you have the final state and maybe a stack trace. Not the reasoning chain. Not which tool call returned what. Not whether the compensation ran or quietly failed.

Event sourcing flips this. You store a log of every event that changed state rather than the state itself. Pure event sourcing derives current state entirely by replaying the log. In practice, most agent implementations use a hybrid: the event log as a permanent audit trail, checkpoints for fast recovery. You get the observability benefits without the replay overhead. For agents this means appending a record to a durable store (Postgres, not Redis) for every meaningful action: run_started, tool_called, tool_succeeded, tool_failed, step_compensated, run_completed. Every record carries the run ID, step index, step name, full payload, and timestamp.

When a production incident happens, querying all events for a run_id gives you the complete ordered sequence. Every LLM call, every tool call, every failure, every compensation attempt. No guesswork. No reconstructing state from scattered application logs. The event log is the ground truth.

It also makes replay possible. A bug in your tool implementation corrupted ten agent runs? Fix the bug and replay the event logs through it. The inputs are all there. Only the execution changes.

One distinction that matters: the event log lives in Postgres, not Redis. Redis is ephemeral. Locks expire. Checkpoints get cleared on completion. The event log is permanent. You want durable storage you can query six months from now when a customer disputes a charge and you need to know exactly what the refund agent did on that run.

Designing for Failure

The compensation that can’t be undone. Emails, SMS messages, Slack notifications: not reversible. Once sent, they’re out. Design your saga so notification steps are always last, and treat them as best-effort with explicit logging when they fail after earlier steps succeeded. You can’t unsend the email, but you can alert your support team that the confirmation never arrived.

The checkpoint that grows too large. If your agent’s context dict swells to megabytes (raw document content, full API responses), Redis checkpointing becomes slow and expensive. Store large payloads in S3. Put only the reference (a key or URL) in the context. The checkpoint stores the pointer, not the data.

The idempotency key collision. If your run ID isn’t truly unique (weak UUIDs, reused sequence integers), two different agent runs can share keys and get each other’s cached results. Use ULIDs or UUID v4. Never sequential integers for run IDs.

The lock that never releases. If the agent process dies hard (OOM, power loss) while holding a lock and the Redis TTL was set too long, the resource stays locked for minutes or hours. Set TTLs aggressively short and extend them explicitly for long-running steps. The extend() method on the lock above exists for exactly this. Call it in a background heartbeat every 60% of the TTL duration.

The compensation that also fails. Worst case. The forward action succeeded, the compensation failed, and you now have inconsistent world state with no automatic recovery path. Do not swallow these errors. Do not log them quietly and move on. Page someone. This is a data consistency incident.

How This Maps to Classical Distributed Systems

Distributed Systems PatternAgent EquivalentWhat It Solves
Database transactionSaga + compensation chainMulti-step consistency without a global coordinator
Idempotency keyPer-step idempotent tool callsDuplicate execution from retries
Write-ahead log (WAL)Event store in PostgresFull audit trail, crash recovery, replay
CheckpointAgent checkpoint in RedisResume long-running agents without restarting
Distributed lockRedis lock on resource IDPrevent parallel agents from stomping each other
Circuit breakerPer-tool failure trackingStop retrying a broken external service
Two-phase commitNot applicableToo slow and fragile for async agent workflows

Two-phase commit is notably absent. Theoretically the right answer for distributed atomic operations. In practice it requires all participants to support the protocol and blocks during the commit phase. Tool APIs don’t implement it. And even if they did, blocking on a commit phase across external APIs with real-world latency is a non-starter. Sagas are the right model here.

The Honest Tradeoff Table

The designing-for-failure section above covers the nuance. Here’s the summary version.

ConsiderationNaive Agent (LangChain default)Production Agent (Patterns Applied)
Implementation complexityLow — framework handles orchestrationHigh — saga, locks, checkpoints, event store
Failure behaviorNon-deterministic, often corrupts statePredictable, compensates or checkpoints
Retry safetyDangerous — duplicates side effectsSafe — idempotency prevents double execution
DebuggabilityDict state at time of crashFull event log with every decision
Parallel execution safetyRaces and conflictsSerialized per resource via distributed lock
Infrastructure dependenciesJust the LLM APILLM API + Redis + Postgres
Recovery from crashesRestart from scratchResume from last checkpoint

The infrastructure cost is real. You’re adding Redis and Postgres as hard dependencies of your agent runtime. For a weekend prototype, that’s genuinely absurd overhead. For a production system making real-world mutations (financial transactions, data writes, external API calls) these patterns aren’t optional extras. They’re the difference between a demo and something you can actually trust at 2 AM when it breaks.

What Comes Next

The patterns here cover single-agent workflows. Multi-agent systems add another layer on top: orchestration vs. choreography, shared state protocols, agent-level circuit breakers. Every one of those problems maps to a distributed systems pattern you’ve already seen. That’s a post on its own.

Get single-agent reliability right first. Every multi-agent bug is harder to debug than its single-agent equivalent. That’s not a guess.

The reason this all feels new is that agent frameworks are young and most people building them come from ML backgrounds, not systems engineering. The distributed systems community spent a decade learning these lessons through painful production incidents. The agent community is about to rediscover all of them.

There’s no shortcut. The physics of distributed systems don’t change just because the decision-making layer is an LLM.