Durable Agent State: Why We Serialize Every Action to Postgres Before the Next Step
Eliminating non-determinism and enabling recovery in autonomous agent swarms
When you run an autonomous agent swarm, the single hardest problem is state. Not the model weights—those are frozen. The runtime state: which tools were called, what arguments were passed, what the LLM decided at step 7, and what it decided at step 8. Lose that, and the agent becomes a black box that you can neither debug nor replay.
We moved to a strict pattern: every action an agent takes is serialized to Postgres before the next step begins. This isn't logging—it's the source of truth for the agent's entire execution. Here's why, and how we do it.
The Problem: Stateless LLMs, Stateful Agents
An LLM call is stateless. You send a prompt, you get a response. But an agent is not stateless—it accumulates context, tool outputs, and decisions. If you lose that accumulated state, the agent either fails entirely or produces inconsistent results.
Before this pattern, our agents kept state in memory. A single pod restart, a transient network error, or even a race condition in the swarm coordinator would wipe out the agent's progress. We'd see agents repeat tool calls, forget previous outputs, or hallucinate results because they lost the chain.
We needed a way to make agent state durable—surviving crashes, restarts, and scaling events—without sacrificing performance.
The Pattern: Serialize Before Every Step
Here's the core loop. Every agent step follows this sequence:
- Receive the LLM's response (text, tool call, or both).
- Serialize the entire action: the prompt, the response, the tool call details, the timestamp, and the agent's internal state (including any accumulated context).
- Commit to Postgres in a single row, using a unique
step_idandrun_id. - Wait for the commit acknowledgment.
- Execute the next action (e.g., run a tool, make another LLM call).
If the agent crashes after step 4, the previous action is safely stored. On recovery, we load the last committed step and resume from there. No lost context.
Schema Design
We use a single table for all actions. The schema is deliberately simple:
CREATE TABLE agent_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL,
step_id INT NOT NULL,
agent_id TEXT NOT NULL,
action_type TEXT NOT NULL, -- 'llm_call', 'tool_call', 'observation', 'decision'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(run_id, step_id)
);
CREATE INDEX idx_agent_actions_run_id ON agent_actions(run_id);The payload JSONB contains everything: the full prompt, the response, tool arguments, tool outputs, and any metadata. We never normalize this—it's a write-once, read-many log.
Why Postgres, Not a Queue or Object Store
We evaluated Redis, Kafka, and S3. Here's why we chose Postgres:
- Transactional guarantees: We need atomic writes. A single agent step must be fully persisted or not at all. Postgres gives us ACID out of the box.
- Queryability: We can
SELECTbyrun_id,agent_id, oraction_typeto debug specific agents. Try that with Kafka. - No additional infrastructure: We already run Postgres. Adding Redis or Kafka would increase operational complexity.
- Performance: With proper indexing and connection pooling, a single
INSERTtakes 1-5ms. That's acceptable for most agent loops (which already spend hundreds of milliseconds on LLM calls).
We did consider S3 for long-term storage, but for active agent state, Postgres's latency is fine.
Handling Failures: Exactly-Once Semantics via Idempotency Keys
Serializing before the next step introduces a subtle problem: what if the agent crashes after committing but before the client receives the acknowledgment? The agent might retry and create a duplicate step.
We solve this with idempotency keys. Each step carries a unique (run_id, step_id) pair. The UNIQUE constraint ensures that retrying the same step simply returns the existing row. The application code checks for ON CONFLICT DO NOTHING and reads back the existing row.
async def record_action(pool, run_id, step_id, agent_id, action_type, payload):
async with pool.acquire() as conn:
result = await conn.execute("""
INSERT INTO agent_actions (run_id, step_id, agent_id, action_type, payload)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (run_id, step_id) DO NOTHING
RETURNING id
""", run_id, step_id, agent_id, action_type, payload)
# If conflict, fetch existing row
if result is None:
row = await conn.fetchrow(
"SELECT * FROM agent_actions WHERE run_id=$1 AND step_id=$2",
run_id, step_id
)
return row['id']
return resultRecovery: Loading the Last State
When an agent restarts (e.g., after a pod crash), we load the last committed step for that run_id:
async def load_last_state(pool, run_id):
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT * FROM agent_actions
WHERE run_id=$1
ORDER BY step_id DESC
LIMIT 1
""", run_id)
if row is None:
return None # No state found, start fresh
# Reconstruct agent state from payload
return AgentState.from_payload(row['payload'])The agent then continues from step last_step.step_id + 1. Because every action is recorded, the agent can rebuild its entire context by replaying all steps from the beginning (or more efficiently, by loading a cached intermediate state we store every N steps).
Performance Trade-offs
Writing every action to Postgres adds latency. In our production setup (AWS RDS db.r6g.large, connection pool of 20), a single INSERT takes ~2ms on average (p99 ~8ms). That's acceptable because:
- LLM calls take 500-3000ms.
- Tool calls (e.g., API requests) take 100-2000ms.
- The serialization itself is async and non-blocking (we use
asyncpgwithawait).
We batch writes only for observability (e.g., logging), but for state durability, we explicitly avoid batching—we need the write to be confirmed before proceeding.
Real-World Impact
Since adopting this pattern, we've eliminated:
- State loss on pod restart: Zero incidents in the last 6 months.
- Duplicate tool calls: Idempotency keys prevent retries from causing side effects.
- Debugging time: We can replay any agent run step-by-step by querying the table. We built a simple UI that shows the exact prompt and response at each step.
We also use the serialized actions to build training datasets. Every successful agent run becomes a candidate for fine-tuning. The structured JSONB payloads are easy to extract and convert to training examples.
When This Pattern Might Not Work
This pattern is not free. Consider:
- Very high throughput: If you have thousands of agents making millions of steps per second, Postgres may become a bottleneck. In that case, consider a distributed log (Kafka) with a consumer that writes to Postgres asynchronously. But then you lose the synchronous durability guarantee.
- Very large payloads: If your prompts are gigantic (e.g., including entire codebases), the JSONB payload can become large. We keep it under 1 MB per step; beyond that, we store references to S3.
- Multi-region: We run in a single region. For multi-region, you'd need a distributed database (CockroachDB, Yugabyte) or a different approach.
Conclusion
Serializing every agent action to Postgres before the next step is a simple, boring pattern that solves the hardest problem in autonomous agents: state durability. It gives us exact replay, crash recovery, idempotency, and a natural audit log. It's not glamorous, but it works.
If you're building agent swarms, don't rely on in-memory state. Write everything down. Your future self—debugging a production incident at 2 AM—will thank you.