Back to Blog
July 28, 2026

Level Up Your AI Agents: From Volatile Loops to Scalable AI Memory Systems

Your agent does not need a bigger chat log; it needs a memory boundary that survives restarts, rejects stale facts, and explains every recall.

A volatile AI agent loop evolving into a durable multi-tier memory system

Your first agent probably had a loop, a messages array, and enough personality to feel alive. Then the process restarted. The agent forgot the customer's constraints, repeated a tool call, and confidently revived a decision that had been reversed yesterday. You did not build a forgetful agent. You discovered the boundary between a demo and a system.

This is a normal level-up moment. The beginner tool is prompt history: fast, visible, and perfect for proving an idea. The professional alternative is a memory service with explicit write, retrieve, update, expire, and observe paths. It does not replace the model's context window. It decides what deserves to enter that scarce window.

The evidence for making this boundary explicit is stronger than "long prompts feel messy." The LongMemEval paper built 500 questions across five long-term memory abilities and reported a 30% accuracy drop for commercial assistants and long-context models over sustained interactions. Google Research also reported that its positional-attention calibration improved retrieval-augmented generation by up to 15 percentage points. A large context can hold more tokens; it does not guarantee that the right fact will be used.

We will migrate a realistic support agent from an in-memory array to Postgres with pgvector, hybrid retrieval, optimistic updates, and consolidation. The same architecture works for coding agents, research assistants, and operations bots. If your loop also executes tools, pair this guide with our agent state-machine guide so memory and control flow do not become one giant callback.

What makes an AI memory system scalable?

A scalable agent memory system persists typed facts outside the prompt, retrieves relevant evidence, and invalidates stale state safely.

Memory is a data product, not a transcript. A useful record says who owns a fact, where it came from, when it became valid, whether it supersedes another fact, and how confident the system should be. An embedding is only one index over that record. It is not the record itself.

Think in three tiers. Working memory is the small, ordered context for the current run. Episodic memory records events such as "refund requested" or "deployment failed." Semantic memory contains consolidated facts such as "the customer prefers invoices in EUR." The tiers have different write rates and invalidation rules, which is why one ever-growing JSON blob eventually hurts.

StageCode patternWhat breaksProfessional replacement
Volatile loopmessages.push(event)Restart loses state; retries duplicate actionsDurable event log with idempotency keys
Transcript stuffingmessages.slice(-100)Old facts crowd out relevant evidenceBudgeted, provenance-aware retrieval
Vector-only RAGnearest(queryEmbedding)Exact IDs and negations rank poorlyHybrid lexical and semantic ranking
Memory servicewrite → retrieve → consolidateRequires lifecycle and observability designTyped records, traces, evals, and expiry

Step 1: Give memory a durable contract

Start with Postgres because it gives you transactions, constraints, full-text search, row-level security options, and vector indexing in one operational unit. That is an excellent trade for an indie developer: fewer moving parts while the shape of the product is still changing.

The schema below separates identity from content. external_id makes retried writes idempotent. revision enables optimistic concurrency. valid_from, valid_until, and superseded_by make time explicit. The content hash avoids paying to re-embed identical text.

// scripts/migrate-memory.ts
// Run: npm i postgres && DATABASE_URL=... npx tsx scripts/migrate-memory.ts
import postgres from 'postgres'

const url = process.env.DATABASE_URL
if (!url) throw new Error('DATABASE_URL is required')

const sql = postgres(url, { max: 1, connect_timeout: 10 })

async function migrate() {
  try {
    await sql.begin(async (tx) => {
      // Edge case: this fails clearly when the DB role cannot install extensions.
      await tx`CREATE EXTENSION IF NOT EXISTS vector`
      await tx`CREATE EXTENSION IF NOT EXISTS pgcrypto`
      await tx`
        CREATE TABLE IF NOT EXISTS agent_memories (
          id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
          tenant_id uuid NOT NULL,
          agent_id text NOT NULL,
          external_id text NOT NULL,
          kind text NOT NULL CHECK (kind IN ('episode', 'fact', 'decision')),
          content text NOT NULL CHECK (length(trim(content)) BETWEEN 1 AND 12000),
          content_hash text NOT NULL,
          embedding vector(1536),
          source_uri text,
          metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
          revision integer NOT NULL DEFAULT 1 CHECK (revision > 0),
          confidence real NOT NULL DEFAULT 1 CHECK (confidence BETWEEN 0 AND 1),
          valid_from timestamptz NOT NULL DEFAULT now(),
          valid_until timestamptz,
          superseded_by uuid REFERENCES agent_memories(id),
          created_at timestamptz NOT NULL DEFAULT now(),
          UNIQUE (tenant_id, agent_id, external_id),
          CHECK (valid_until IS NULL OR valid_until > valid_from)
        )
      `
      await tx`CREATE INDEX IF NOT EXISTS memory_tenant_active_idx
        ON agent_memories (tenant_id, agent_id, valid_from DESC)
        WHERE superseded_by IS NULL`
      await tx`CREATE INDEX IF NOT EXISTS memory_text_idx
        ON agent_memories USING gin (to_tsvector('english', content))`
      // HNSW speeds reads but costs memory and slower writes: measure before tuning.
      await tx`CREATE INDEX IF NOT EXISTS memory_embedding_hnsw_idx
        ON agent_memories USING hnsw (embedding vector_cosine_ops)
        WHERE embedding IS NOT NULL`
    })
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    console.error('Memory migration rolled back:', message)
    process.exitCode = 1
  } finally {
    await sql.end({ timeout: 5 })
  }
}

void migrate()

Do not add every imaginable memory type on day one. Schemas are cheapest to change before agents depend on their semantics. Start with events, facts, and decisions. Store raw screenshots, audio, or tool logs in object storage and keep a signed or access-controlled reference here. Large binary payloads make retrieval and backups worse.

Design the admission policy before the search box

A retriever cannot repair a memory that should never have been written. Put an admission policy between the agent's observation stream and durable storage. The policy should answer four questions: Is this information useful after the current run? Is it supported by a source? Is the agent allowed to retain it? Does it update an existing fact or describe a new event?

Transient chain-of-thought, repeated tool output, secrets, access tokens, and speculative guesses should fail admission. A decision such as "deploy after the migration completes" may be durable, while the ten intermediate messages used to reach it are not. A customer preference may be useful, but privacy rules can require consent, a retention limit, or a deletion path. Memory quality begins by writing less.

Separate extraction from acceptance. An LLM can propose a typed candidate with content, source, confidence, and a fact key. Deterministic code then validates the candidate against size limits, allowed kinds, tenant policy, and source availability. High-impact facts such as payment instructions, medical details, or permission changes should require a trusted system event or human confirmation. Fluency is not authorization.

A useful admission metric is the ratio of retrieved memories that actually support an answer. More stored rows can reduce that ratio by adding distractors. Optimize for evidence density, not database growth.

Step 2: Make the write path idempotent and conflict-aware

Most memory corruption arrives through retries, not exotic model behavior. A queue redelivers after a timeout. Two workers summarize the same conversation. A user corrects an address while an older run is still finishing. If writes are "append whatever the model says," all three become permanent contradictions.

Treat extraction as untrusted input. Validate size and kind, generate embeddings behind a timeout, and require an expected revision for updates. The example uses a stable external event ID. Replaying the same event returns the existing row; sending different content with the same ID raises a conflict instead of silently rewriting history.

// examples/write-memory.ts
// Run: npm i postgres && DATABASE_URL=... EMBEDDING_URL=... npx tsx examples/write-memory.ts
import { createHash } from 'node:crypto'
import postgres from 'postgres'

type Input = {
  tenantId: string
  agentId: string
  externalId: string
  kind: 'episode' | 'fact' | 'decision'
  content: string
  sourceUri?: string
}

const dbUrl = process.env.DATABASE_URL
const embeddingUrl = process.env.EMBEDDING_URL
if (!dbUrl || !embeddingUrl) throw new Error('DATABASE_URL and EMBEDDING_URL are required')
const tenantId = process.env.TENANT_ID
if (!tenantId) throw new Error('TENANT_ID is required')
const sql = postgres(dbUrl)

async function embed(text: string): Promise<number[]> {
  const response = await fetch(embeddingUrl!, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ input: text }),
    signal: AbortSignal.timeout(8_000),
  })
  if (!response.ok) throw new Error(`Embedding service returned ${response.status}`)
  const body = (await response.json()) as { embedding?: number[] }
  if (!body.embedding || body.embedding.length !== 1536) {
    throw new Error('Embedding has the wrong dimension; expected 1536')
  }
  return body.embedding
}

async function remember(input: Input) {
  const content = input.content.trim().replace(/\s+/g, ' ')
  // Edge cases: empty model output and oversized tool dumps never reach storage.
  if (!content || content.length > 12_000) throw new Error('Content must be 1..12000 chars')
  if (!input.externalId.trim()) throw new Error('externalId is required for idempotency')

  const hash = createHash('sha256').update(content).digest('hex')
  const vector = `[${(await embed(content)).join(',')}]`

  return sql.begin(async (tx) => {
    const existing = await tx<{ id: string; content_hash: string }[]>`
      SELECT id, content_hash FROM agent_memories
      WHERE tenant_id = ${input.tenantId}
        AND agent_id = ${input.agentId}
        AND external_id = ${input.externalId}
      FOR UPDATE
    `
    if (existing[0]) {
      if (existing[0].content_hash !== hash) {
        throw new Error('Idempotency conflict: externalId already has different content')
      }
      return { id: existing[0].id, replayed: true }
    }

    const rows = await tx<{ id: string }[]>`
      INSERT INTO agent_memories (
        tenant_id, agent_id, external_id, kind, content,
        content_hash, embedding, source_uri
      ) VALUES (
        ${input.tenantId}, ${input.agentId}, ${input.externalId}, ${input.kind},
        ${content}, ${hash}, ${vector}::vector, ${input.sourceUri ?? null}
      ) RETURNING id
    `
    return { id: rows[0].id, replayed: false }
  })
}

async function main() {
  try {
    const result = await remember({
      tenantId,
      agentId: 'support-agent',
      externalId: 'ticket-1842:customer-correction:1',
      kind: 'fact',
      content: 'Customer requires invoices in EUR, not USD.',
      sourceUri: 'ticket://1842/message/31',
    })
    console.log(result)
  } catch (error) {
    console.error('Memory write rejected:', error instanceof Error ? error.message : error)
    process.exitCode = 1
  } finally {
    await sql.end({ timeout: 5 })
  }
}

void main()

Gotcha: idempotency is scoped to a producer event, not the text. Two customers can say the same sentence and still need separate records. Conversely, the same queue event must not create two memories merely because punctuation changed during extraction.

When should you replace prompt history with durable memory?

Move beyond arrays when users expect cross-session continuity, retries can duplicate events, or one agent process needs to become many.

You do not need infrastructure theater for a weekend prototype. Keep the array while one process owns one short-lived conversation and losing it is harmless. Migrate when state becomes a product promise: a support agent must remember preferences next week, a coding agent must resume after CI, or multiple workers must share a consistent view.

The key signal is not record count. It is coordination. The moment two concurrent runs can write or read the same user state, you need ownership, isolation, and conflict rules. The moment a user can ask "why do you remember that?", you need provenance and deletion.

Step 3: Retrieve with filters before similarity

Vector search is good at paraphrases and bad at pretending everything is current. Exact invoice numbers, error codes, negation, and dates often benefit from lexical search. A production retriever therefore narrows by authorization and validity first, finds semantic and lexical candidates second, and reranks the union last.

Reciprocal rank fusion is a practical default because it combines ranks without assuming that cosine distance and text relevance share a meaningful numeric scale. The retriever below degrades to lexical search if the embedding provider is unavailable. That is better than failing the entire agent or retrieving across tenants.

// examples/retrieve-memory.ts
// Run: npm i postgres && DATABASE_URL=... EMBEDDING_URL=... npx tsx examples/retrieve-memory.ts
import postgres from 'postgres'

const dbUrl = process.env.DATABASE_URL
const embeddingUrl = process.env.EMBEDDING_URL
const tenantId = process.env.TENANT_ID
if (!dbUrl || !embeddingUrl || !tenantId) {
  throw new Error('DATABASE_URL, EMBEDDING_URL, and TENANT_ID are required')
}
const sql = postgres(dbUrl, { connect_timeout: 5 })

async function queryEmbedding(query: string): Promise<number[] | null> {
  try {
    const response = await fetch(embeddingUrl, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ input: query }),
      signal: AbortSignal.timeout(3_000),
    })
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    const data = (await response.json()) as { embedding?: number[] }
    if (data.embedding?.length !== 1536) throw new Error('Unexpected embedding dimension')
    return data.embedding
  } catch (error) {
    console.warn('Semantic retrieval disabled:', error instanceof Error ? error.message : error)
    return null // Edge case: lexical fallback keeps the agent useful during provider outages.
  }
}

async function retrieve(tenantId: string, agentId: string, rawQuery: string) {
  const query = rawQuery.trim()
  if (query.length < 3 || query.length > 500) throw new Error('Query must be 3..500 chars')
  const embedding = await queryEmbedding(query)
  const vector = embedding ? `[${embedding.join(',')}]` : null

  const rows = await sql<{
    id: string
    content: string
    source_uri: string | null
    score: number
  }[]>`
    WITH active AS (
      SELECT * FROM agent_memories
      WHERE tenant_id = ${tenantId}
        AND agent_id = ${agentId}
        AND superseded_by IS NULL
        AND valid_from <= now()
        AND (valid_until IS NULL OR valid_until > now())
    ),
    semantic AS (
      SELECT id, row_number() OVER (ORDER BY embedding <=> ${vector}::vector) AS rank
      FROM active
      WHERE ${vector}::text IS NOT NULL AND embedding IS NOT NULL
      ORDER BY embedding <=> ${vector}::vector LIMIT 30
    ),
    lexical AS (
      SELECT id, row_number() OVER (
        ORDER BY ts_rank_cd(to_tsvector('english', content), websearch_to_tsquery('english', ${query})) DESC
      ) AS rank
      FROM active
      WHERE to_tsvector('english', content) @@ websearch_to_tsquery('english', ${query})
      LIMIT 30
    ),
    fused AS (
      SELECT id, sum(score) AS score FROM (
        SELECT id, 1.0 / (60 + rank) AS score FROM semantic
        UNION ALL
        SELECT id, 1.0 / (60 + rank) AS score FROM lexical
      ) ranked GROUP BY id
    )
    SELECT a.id, a.content, a.source_uri, f.score
    FROM fused f JOIN active a USING (id)
    ORDER BY f.score DESC, a.valid_from DESC
    LIMIT 8
  `
  return rows
}

async function main() {
  try {
    const rows = await retrieve(tenantId, 'support-agent', 'invoice currency')
    if (rows.length === 0) console.log('No supported memory found; agent should abstain')
    else console.table(rows)
  } catch (error) {
    console.error('Retrieval failed:', error instanceof Error ? error.message : error)
    process.exitCode = 1
  } finally {
    await sql.end({ timeout: 5 })
  }
}

void main()

Retrieval is incomplete until you build the prompt packet. Set both an item limit and a token budget. Preserve source URIs. Put untrusted memory inside delimiters and instruct the model that retrieved text is evidence, never policy. A malicious ticket can contain "ignore previous instructions"; if your agent treats stored text as system instructions, persistence turns a one-message injection into a long-term one.

Apply backpressure before traffic applies it for you. Embedding calls should have bounded concurrency, timeouts, and a retry queue; request handlers should not wait indefinitely for enrichment. It is acceptable to write an event without an embedding and enrich it later because lexical retrieval can cover the gap. It is not acceptable to acknowledge a durable memory before the database transaction commits.

Watch the shape of the workload. HNSW favors reads but consumes memory and adds write work. Exact search may be enough for a small tenant. Partitioning may help a large installation but makes global indexes and migrations harder. Record query latency, candidate counts, fallback use, and empty-result rates by tenant before changing index parameters. Professional scaling is measurement followed by a targeted change, not an architecture diagram copied from a hyperscaler.

Step 4: Consolidate without erasing history

Episodic rows grow quickly and repeat themselves. Consolidation turns repeated events into durable facts, but deletion is the wrong first move. Keep evidence, create the new fact, and mark older facts as superseded. That makes corrections explainable and reversible.

The worker below uses a transaction-scoped advisory lock so two jobs cannot consolidate the same agent simultaneously. It performs a conservative, deterministic update rather than asking a model to choose truth. In a richer system, an extractor can propose facts, but database rules or a reviewer should arbitrate high-impact conflicts.

// examples/supersede-fact.ts
// Run: npm i postgres && DATABASE_URL=... npx tsx examples/supersede-fact.ts
import { createHash } from 'node:crypto'
import postgres from 'postgres'

const dbUrl = process.env.DATABASE_URL
const tenantId = process.env.TENANT_ID
if (!dbUrl || !tenantId) throw new Error('DATABASE_URL and TENANT_ID are required')
const sql = postgres(dbUrl, { max: 4 })

async function supersedeCurrencyFact(
  tenantId: string,
  agentId: string,
  sourceExternalId: string,
  currency: string,
) {
  if (!/^[A-Z]{3}$/.test(currency)) throw new Error('Currency must be an ISO-style 3-letter code')
  const content = `Customer invoice currency is ${currency}.`
  const hash = createHash('sha256').update(content).digest('hex')

  return sql.begin(async (tx) => {
    const locked = await tx<{ locked: boolean }[]>`
      SELECT pg_try_advisory_xact_lock(hashtext(${tenantId + ':' + agentId})) AS locked
    `
    // Edge case: overlapping workers retry later instead of producing competing "latest" facts.
    if (!locked[0]?.locked) throw new Error('Consolidation already running for this agent')

    const existing = await tx<{ id: string; content: string }[]>`
      SELECT id, content FROM agent_memories
      WHERE tenant_id = ${tenantId}
        AND agent_id = ${agentId}
        AND kind = 'fact'
        AND metadata->>'fact_key' = 'invoice_currency'
        AND superseded_by IS NULL
      FOR UPDATE
    `
    if (existing[0]?.content === content) {
      return { id: existing[0].id, changed: false } // Idempotent replay.
    }

    const inserted = await tx<{ id: string }[]>`
      INSERT INTO agent_memories (
        tenant_id, agent_id, external_id, kind, content,
        content_hash, metadata, confidence, source_uri
      ) VALUES (
        ${tenantId}, ${agentId}, ${sourceExternalId}, 'fact', ${content},
        ${hash}, ${sql.json({ fact_key: 'invoice_currency' })},
        1, ${'event://' + sourceExternalId}
      ) RETURNING id
    `
    if (existing[0]) {
      await tx`
        UPDATE agent_memories
        SET superseded_by = ${inserted[0].id}, valid_until = now()
        WHERE id = ${existing[0].id}
      `
    }
    return { id: inserted[0].id, changed: true }
  })
}

async function main() {
  try {
    console.log(await supersedeCurrencyFact(
      tenantId,
      'support-agent',
      'ticket-1842:currency-confirmed:2',
      process.argv[2] ?? 'EUR',
    ))
  } catch (error) {
    console.error('Consolidation aborted:', error instanceof Error ? error.message : error)
    process.exitCode = 1
  } finally {
    await sql.end({ timeout: 5 })
  }
}

void main()

Debugging: when the agent remembers the wrong thing

Debug the pipeline in stages. "Bad memory" can mean the fact was never written, the wrong tenant was queried, the retriever missed it, the prompt truncated it, or the model ignored good evidence. One end-to-end score hides those distinctions.

  • Duplicate facts: inspect external IDs and queue delivery attempts. Fix producer idempotency before adding a deduplication prompt.
  • Stale recall: query superseded_by, validity timestamps, and fact keys. Confirm that active filters run before vector ranking.
  • Exact IDs never appear: log lexical and semantic candidate lists separately. Product codes often need full-text or trigram search.
  • Relevant row, wrong answer: capture the final prompt packet. Check ordering, token truncation, source labels, and explicit abstention instructions.
  • Latency spikes: use EXPLAIN (ANALYZE, BUFFERS), check HNSW index use, and measure embedding-provider time separately from database time.
  • Cross-user leakage: stop traffic, preserve audit logs, and treat it as a security incident. Tenant filters belong in every query and ideally database policy.

Add one trace ID across extraction, embedding, database write, retrieval, prompt assembly, and final answer. Log record IDs and scores, not private content. You should be able to replay why a memory entered the prompt without leaking the memory into observability tools.

Build two evals. A retrieval eval asks whether the correct record appeared in the candidate set. An answer eval asks whether the model used evidence correctly and abstained when evidence was missing. Include corrections, same-name users, expired facts, embedding outages, empty queries, and prompt-injection text. This is also where the practices in our AI agent testing guide become part of your release gate.

Edge cases that arrive earlier than you expect

  • Deletion requests: embeddings can reveal semantic information. Delete vectors, derived summaries, caches, and replicas according to one retention contract.
  • Model migrations: embeddings from different models may have different dimensions and geometry. Version the model and rebuild into a new column or table.
  • Clock skew: prefer database timestamps for validity. Client clocks can make a correction expire before it becomes active.
  • Multilingual recall: PostgreSQL's English text configuration will not tokenize every language well. Choose per-locale configuration or rely more on multilingual embeddings.
  • Low-frequency truth: repeated statements are not automatically more correct. Confidence must represent evidence quality, not mention count.
  • Hot tenants: one large customer can dominate an HNSW index and write queue. Monitor by tenant and introduce partitioning only when measurements justify it.

Your practical migration plan

Keep your current loop and replace one boundary at a time. First, mirror durable events into Postgres while the array remains the read source. Second, compare retrieval results in shadow mode without changing answers. Third, enable memory for a small cohort and record every retrieved ID. Fourth, add consolidation only after you have correction and deletion semantics.

Resist the urge to start with a graph, three databases, and a background agent that rewrites its own beliefs. A single Postgres service can take you surprisingly far. Scale is not the number of technologies in the diagram. It is the system's ability to preserve meaning as data, workers, users, and failure modes multiply.

The mindset shift is the real level-up: prompts are programs, but memories are shared state. Shared state needs contracts, transactions, authorization, lifecycle rules, and tests. Once those pieces are explicit, your agent stops merely sounding consistent. It becomes recoverable, inspectable, and safe to trust with the next session.

Ready to level up your dev toolkit?

Desplega.ai helps developers transition to professional tools smoothly with dependable agent infrastructure, practical guardrails, and production-ready workflows.

Get Started

Frequently Asked Questions

Is a large context window enough for agent memory?

No. Context is temporary working space, not durable state. A memory system adds persistence, tenant isolation, retrieval, provenance, expiry, and conflict handling across runs.

Do I need a dedicated vector database?

Not initially. Postgres with pgvector keeps facts, metadata, full-text search, and embeddings transactional. Split services only after measured scale or availability needs justify it.

What should an AI agent remember?

Persist durable user facts, decisions, outcomes, and evidence. Keep transient reasoning and raw tool noise out; store references to large artifacts instead of copying them.

How do I test whether retrieval is improving?

Create a versioned recall set with expected facts, temporal conflicts, irrelevant distractors, and abstention cases. Track retrieval and answer quality separately on every change.