Impressionist landscape painting
← Back to blog

Engineering

How to Build Agent Memory Without Letting It Drift or Corrupt Itself

A practical engineering guide for preventing hallucinations, contradiction, and self-reinforcing errors in agent memory systems.

Paulina XuMay 7, 202614 min
EngineeringMemoryAgents

Introduction: Memory Is the Easiest Way for an Agent to Break Itself

Agent memory sounds simple: store important information so the model doesn’t forget.

In practice, it’s one of the largest sources of long-term failure in any agentic system. A single-shot prompt can only be wrong once. A persistent memory store can be wrong forever — and worse, it can teach the agent to be wrong about everything downstream of that one bad entry.

Why does this happen so reliably?

Because, left to their own devices, LLMs will happily:

  • save hallucinations as though they were facts
  • rewrite prior memory with contradictions
  • “snowball” small errors into systemic misalignment
  • accumulate irrelevant clutter until context windows explode

This is memory drift: the gradual deviation between what is true and what the agent believes. Drift is insidious because it compounds silently. Each retrieval surfaces the corrupted entry, the model conditions on it, and the next write inherits the error. By the time you notice the agent confidently asserting something false, the bad memory has usually been reinforced dozens of times.

The root cause is almost always architectural, not the model. Teams treat memory as a free-form dumping ground that the model can both read from and write to without constraints. That turns the agent into a self-modifying system with no guardrails — and self-modifying systems without guardrails do not stay aligned.

This post teaches you how to build memory systems that resist drift and corruption — with clear architectures, code examples, and actionable patterns. The throughline is a single principle: writes are dangerous, so make them earn their place. Reads can be cheap and frequent; writes must be deliberate, validated, and reversible.

Separate Transient vs. Durable Memory

Most agent failures happen because teams put everything into a single blob of “memory.” When scratchpad reasoning, half-finished tool output, and verified user facts all live in the same store, the agent cannot tell a guess from a ground truth — and neither can your retrieval layer.

You must instead separate memory into layers, each with its own lifecycle and write rules:

Transient Memory

Short-term, task-scoped, and automatically discarded when the task ends. It is allowed to be messy because nothing reads it after the current run.

Examples:

  • current step summary
  • scratchpad output
  • tool results
  • ephemeral chain-of-thought

Durable Memory

Long-term, persistent, and only updated after validation. Every entry here is something you are willing to let the agent believe weeks from now.

Examples:

  • user preferences
  • verified facts
  • personal profile data
  • agent configuration

The asymmetry is the whole point. Transient memory is write-cheap and read-once; durable memory is write-expensive and read-many. Conflating the two is what lets a throwaway hypothesis from step 3 of a task quietly become a permanent “fact” the agent cites in every future conversation.

Implementation Architecture

class Memory:
    def __init__(self):
        self.transient = {}  # auto-reset per task
        self.durable = []    # validated, persistent memories

memory = Memory()

# Reset transient memory every run
def reset_transient(memory):
    memory.transient = {}

# Load/store durable memory explicitly
def add_durable_memory(memory, item):
    memory.durable.append(item)

def get_durable_memory(memory):
    return memory.durable

Note what is not in this interface: there is no method that lets the agent loop write straight into durable. The only public path into durable storage runs through a validator you control, which we build next. Keeping add_durable_memory private-by-convention (or behind an access layer the model cannot call) is the cheapest guardrail you will ever add.

Rule: An agent must never write directly to durable memory. Only the validator can.

Implement Memory Validation (“Does This Contradict Stored Facts?”)

Every candidate memory must go through a validation pipeline before becoming durable. Validation answers two questions: is this consistent with what we already believe, and is the model confident enough that we should trust the answer? A candidate that fails either test gets rejected and never touches durable storage.

Step 1 — Have the model describe its confidence

VALIDATION_PROMPT = """
Given the proposed memory: "{candidate}"

Does this contradict any of the following known facts?

Known facts:
{facts}

Respond with JSON only:
{{
  "contradiction": true|false,
  "reason": "...",
  "confidence": 0-1
}}
"""

Step 2 — Call the validator

Two details matter here. First, pass response_format={"type": "json_object"} so the model is constrained to emit valid JSON — without it, json.loads will eventually choke on prose or a stray code fence. Second, wrap the parse defensively: a malformed response should fail closed (reject the write), never crash the agent.

import json
from openai import OpenAI

client = OpenAI()

def validate_memory(candidate, durable_facts):
    prompt = VALIDATION_PROMPT.format(
        candidate=candidate,
        facts="\n".join(durable_facts)
    )
    res = client.chat.completions.create(
        model="gpt-4.1",
        response_format={"type": "json_object"},
        messages=[{"role": "user", "content": prompt}]
    )
    try:
        return json.loads(res.choices[0].message.content)
    except (json.JSONDecodeError, TypeError):
        # Fail closed: treat unparseable verdicts as a contradiction
        return {"contradiction": True, "reason": "invalid verdict", "confidence": 0.0}

Step 3 — Accept only if confidence is high & contradiction is false

def safe_write(memory, candidate):
    verdict = validate_memory(candidate, memory.durable)
    if verdict["contradiction"] or verdict["confidence"] < 0.7:
        return False  # reject
    add_durable_memory(memory, candidate)
    return True

The 0.7 threshold is a starting point, not a law. Tune it against your own data: raise it for high-stakes domains where a wrong durable memory is costly, lower it where stale or missing memories hurt more than occasional bad ones. This single gate already prevents the most common failure — hallucinations being stored as facts — but it has a blind spot. A confident model can still contradict an old memory if that old memory wasn’t included in the facts list, which is exactly why retrieval and embedding checks come next.

Use Embeddings to Detect Duplicate or Conflicting Memories

An LLM validator is good at semantics but expensive and slow to run on every pair of memories. Embeddings give you a cheap first pass. Agents routinely save:

  • the same memory multiple times
  • slightly different versions of the same memory
  • conflicting statements (“Alice lives in NY” vs. “Alice moved to SF”)

Use embedding similarity to:

  • dedupe near-identical entries before they bloat the store
  • merge slight variants into a single canonical memory
  • surface candidate conflicts for the validator to adjudicate

Embedding Pipeline

A small open model like all-MiniLM-L6-v2 is fast, cheap, and good enough for dedupe. Encode to tensors so the similarity helper can operate on them directly.

from sentence_transformers import SentenceTransformer, util

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

def embed(text):
    return model.encode(text, convert_to_tensor=True)

Detect duplicates

In production you would cache embeddings on each MemoryItem rather than re-encoding stored entries on every check — the version below re-embeds for clarity, but the hot path should never recompute a vector it already has.

def is_duplicate(candidate, memory, threshold=0.85):
    c_embed = embed(candidate)
    for m in memory.durable:
        if util.cos_sim(c_embed, embed(m)) > threshold:
            return True
    return False

Detect contradictions (basic version)

A crude lexical heuristic catches the most obvious negations. It is brittle, but it is also instant and dependency-free, so it makes a fine pre-filter before you spend tokens on a model.

NEGATION_PAIRS = [
    ("is", "is not"),
    ("lives in", "does not live in"),
]

def contradiction_heuristic(a, b):
    for pos, neg in NEGATION_PAIRS:
        if (pos in a and neg in b) or (neg in a and pos in b):
            return True
    return False

Detect contradictions (robust version)

High semantic similarity does not mean agreement — “Alice lives in NY” and “Alice lives in SF” are nearly identical to a similarity model yet directly contradict each other. The right tool is a natural-language inference (NLI) cross-encoder, which scores a sentence pair across three labels: contradiction, entailment, and neutral. Use embedding similarity to find the few stored memories most likely to be about the same thing, then run the NLI model only on those pairs.

from sentence_transformers import CrossEncoder

# Outputs scores for [contradiction, entailment, neutral]
nli = CrossEncoder("cross-encoder/nli-deberta-v3-base")
LABELS = ["contradiction", "entailment", "neutral"]

def nli_contradicts(candidate, stored, threshold=0.5):
    scores = nli.predict([(stored, candidate)])[0]
    label = LABELS[scores.argmax()]
    return label == "contradiction" and scores.max() > threshold

Combining the cheap heuristic, embedding similarity, and a targeted NLI check gives you a tiered detector: lexical rules and vectors do the bulk filtering, and the more expensive models only run on the handful of candidates that actually warrant a second look.

Enforce Memory Decay, Pruning, and Summarization

Long-running agents accumulate garbage. Even with perfect validation on the way in, a store that only ever grows will eventually drown the signal you care about in stale, low-value entries.

The failure chain is predictable: memory size goes up, retrieval precision goes down, the agent conditions on weaker context, and drift accelerates. The fix is to treat forgetting as a first-class feature, not an afterthought.

Implement three complementary mechanisms — decay, pruning, and summarization.

1. Time-Based Decay

Attach a timestamp to every durable item so age becomes a query-able property.

import time

class MemoryItem:
    def __init__(self, text):
        self.text = text
        self.timestamp = time.time()

Remove stale entries on a schedule:

def decay(memory, max_age=604800):  # 1 week, in seconds
    now = time.time()
    memory.durable = [
        m for m in memory.durable if now - m.timestamp < max_age
    ]

Uniform expiry is the simplest policy but rarely the right one. A user’s stated allergy should outlive their preferred meeting time. In practice you’ll want per-category lifetimes — long horizons for stable profile facts, short ones for situational state — rather than a single global max_age.

2. Relevance-Based Pruning

When the store grows past a budget, keep only the entries most relevant to what the agent is actually doing, scored by embedding similarity to the current query:

def prune_irrelevant(memory, query, keep=20):
    q = embed(query)
    scored = [(m, util.cos_sim(q, embed(m.text))) for m in memory.durable]
    scored.sort(key=lambda pair: pair[1], reverse=True)
    memory.durable = [m for (m, score) in scored[:keep]]

Pruning by relevance-to-a-single-query is aggressive — anything off-topic for the current task gets dropped. Reserve it for stores you can rebuild, or combine it with a “pinned” flag for memories that must never be pruned regardless of momentary relevance.

3. Memory Summarization (Periodic Compression)

After pruning, compress what remains into a compact, factual summary. The prompt’s most important line is the instruction not to infer — summarization is the stage where a careless model is most tempted to invent connective tissue that was never in the source.

SUMMARY_PROMPT = """
Summarize the following agent memory into concise, factual bullet points.
Do NOT infer or hallucinate missing information.
Preserve every distinct fact; merge only exact duplicates.

Memory items:
{items}
"""

def summarize_memory(memory):
    text = "\n".join(m.text for m in memory.durable)
    res = client.chat.completions.create(
        model="gpt-4.1",
        messages=[{"role": "user", "content": SUMMARY_PROMPT.format(items=text)}]
    )
    summary = res.choices[0].message.content
    memory.durable = [MemoryItem(s.strip()) for s in summary.split("\n") if s.strip()]

Together these three mechanisms keep the store bounded, current, and dense. Run decay and pruning frequently and cheaply; run summarization sparingly, since each compression pass is itself a write and therefore another opportunity for drift to creep in.

Prevent Agents From Writing Hallucinations Into Memory

Validation and consistency checks catch contradictions, but they don’t catch a confident, internally consistent fabrication. Even with everything above in place, agents will try to:

  • record opinions as facts
  • save misinterpretations of tool outputs
  • store hallucinated URLs, people, or dates
  • invent user traits (“The user likes aggressive tones”)

Mitigate this at the input stage, before validation, by filtering on what kind of statement the candidate is — not just whether it conflicts with something.

Technique A — Classify the Memory Type

Only allow factual memories. Reject:

  • speculation
  • guesses
  • hypothetical stories
  • chain-of-thought
CLASSIFIER_PROMPT = """
Classify the following memory candidate:

"{candidate}"

Is it:
A) factual
B) hypothetical/speculative
C) opinion/emotion
D) chain-of-thought/internal reasoning

Respond with a single letter: A, B, C, or D.
"""

def classify_memory(candidate):
    res = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": CLASSIFIER_PROMPT.format(candidate=candidate)}]
    )
    return res.choices[0].message.content.strip()

Allow only category A. A small, cheap model is fine here — classification is an easy task, and routing it to a lightweight model keeps the per-write cost negligible even when the agent proposes memories constantly.

Technique B — Require Evidence Links for Any Stored Fact

Force the model to point at the specific tool output or document that supports the memory. A fact that can’t cite its own source is a fact you shouldn’t persist.

VALIDATE_FACT_PROMPT = """
Given memory candidate:

"{candidate}"

And the available context:

{context}

Provide supporting evidence quoted verbatim from the context,
or the literal string "NO_EVIDENCE" if none exists.

Respond with JSON only:
{{"evidence": "..."}}
"""

Reject anything that comes back with NO_EVIDENCE or with a quote you can’t find in the supplied context. Grounding the write in verifiable text is what separates a remembered fact from a remembered guess.

Technique C — Block Self-Referential or Anthropomorphic Memories

Never store the agent’s claims about its own internal state. These are the seeds of identity drift — the agent reading its own invented “feelings” back into context and escalating them over time. Block claims like:

  • “I feel…”
  • “I am becoming…”
  • “I want…”

A simple lexical filter handles the obvious cases cheaply:

BLOCKLIST = ["i feel", "i believe", "i want", "as an ai", "my emotions", "i am becoming"]

def is_self_referential(text):
    lowered = text.lower()
    return any(phrase in lowered for phrase in BLOCKLIST)

A blocklist is a floor, not a ceiling — it won’t catch paraphrases. Pair it with the type classifier from Technique A, which rejects opinion and internal-reasoning categories more robustly than string matching ever can.

End-to-End Safe Memory Pipeline (Drop-In Code)

Each technique above is a layer of defense, and the value comes from ordering them by cost. Run the cheap, deterministic filters first and bail early; only spend tokens on the LLM validator once a candidate has survived everything quicker. The pipeline below does exactly that.

def safe_memory_update(memory, candidate):
    # 1. Cheapest filters first: lexical blocklist, then type classifier
    if is_self_referential(candidate):
        return False
    if classify_memory(candidate) != "A":
        return False

    # 2. Embedding dedupe — skip if we already know this
    if is_duplicate(candidate, memory):
        return False

    # 3. Most expensive check last: validate consistency with stored facts
    verdict = validate_memory(candidate, [m.text for m in memory.durable])
    if verdict["contradiction"] or verdict["confidence"] < 0.7:
        return False

    # 4. Earned its place — write it
    add_durable_memory(memory, MemoryItem(candidate))
    return True

This is the minimum viable safe memory architecture. It is intentionally conservative: when in doubt, it rejects. For most agents that bias is correct — a missed memory costs you one re-derivation, while a corrupt memory costs you every future decision that touches it.

Make Memory Writes Observable and Reversible

Everything so far reduces the rate of bad writes. Nothing yet helps you when a bad write slips through anyway — and at scale, some will. The two properties that turn a silent corruption into a recoverable incident are observability (you can see what was written and why) and reversibility (you can undo it).

The cheapest way to get both is to make durable memory append-only and to log every accepted and rejected candidate alongside the verdict that produced it. Don’t mutate or delete entries in place; record events and reconstruct the current state from them. That way a corrupt memory is a single revocable event, not an irreversible overwrite of something that used to be true.

import time, logging

logger = logging.getLogger("agent.memory")

def safe_memory_update(memory, candidate):
    decision = {"candidate": candidate, "accepted": False, "reason": ""}

    if is_self_referential(candidate):
        decision["reason"] = "self_referential"
    elif classify_memory(candidate) != "A":
        decision["reason"] = "non_factual"
    elif is_duplicate(candidate, memory):
        decision["reason"] = "duplicate"
    else:
        verdict = validate_memory(candidate, [m.text for m in memory.durable])
        if verdict["contradiction"] or verdict["confidence"] < 0.7:
            decision["reason"] = "failed_validation"
            decision["verdict"] = verdict
        else:
            item = MemoryItem(candidate)
            item.source = "agent"          # provenance: who proposed it
            item.created_at = time.time()
            add_durable_memory(memory, item)
            decision["accepted"] = True
            decision["reason"] = "accepted"

    logger.info("memory_write_decision", extra=decision)
    return decision["accepted"]

With provenance on every item and a log line for every decision, two things become possible that were impossible before. First, you can audit drift: when an agent asserts something false, you can trace the exact write that introduced it and the verdict that let it through. Second, you can revoke surgically — delete the offending entry and anything derived from it — instead of nuking the whole store and starting over.

These decision logs are also your best signal for tuning. A spike in failed_validation rejections might mean your confidence threshold is too aggressive; a flood of duplicate rejections suggests your dedupe threshold or summarization cadence needs adjusting. Memory safety is not a one-time configuration — it’s a system you watch and tune like any other part of production.

Conclusion

LLM memory is powerful — but dangerously easy to corrupt.

If you let the model write its own memories freely, your agent becomes a self-modifying system with no guardrails — and the corruption compounds quietly until it’s expensive to unwind.

By combining:

  • transient vs. durable memory separation
  • validation against stored facts with a confidence gate
  • contradiction detection, from lexical heuristics to NLI cross-encoders
  • embedding-based dedupe & conflict checks
  • decay + pruning + summarization to keep the store bounded
  • strict gating against hallucinated or unsupported memories
  • observable, reversible, append-only writes with full provenance

…you can build stable, safe, long-horizon agents that don’t rewrite their own identity or accumulate fiction.