AI Agent Memory: Build vs. Buy

AI Agent Memory: Build vs. Buy

AI Agent Memory: Build vs. Buy

Updated on

Updated on

AI Agent Memory: Build vs. Buy

To answer build vs. buy for AI agent memory, honestly, I built the same customer support agent twice.

Once from scratch with an extraction prompt and Pinecone, once against Mem0's Platform API, both on openai 3.6.0 and mem0ai 2.0.19. I also measured the embedding similarity behind the deduplication problem, because the usual claim about it turns out to be phrasing-dependent.

Both builds are below in full context, with cost and latency numbers from the same conversation set and the failure modes that only appeared in long sessions. By the end of this article, you'll have learned what each approach actually costs to run, where semantic deduplication breaks down and why phrasing decides it, and which criteria should drive your own build vs buy call.

TLDR;

  • A memory system does 3 things: extracts durable facts from conversation turns, stores them as embeddings keyed by the user, and retrieves the relevant ones at query time.

  • A self-built prototype takes an afternoon. Getting it production-ready adds 8 to 12 weeks, most of it spent on deduplication, conflict resolution, and deletion.

  • Deduplication is harder than it looks. Cosine similarity can't distinguish a near-duplicate fact from a direct contradiction, which forces a second LLM pass on every write.

  • Retrieval beats replaying a 26,000-token history by roughly 90% on per-query tokens, and any retrieval layer gets that saving. It isn't a build-vs-buy input. Writes are: a production self-built path runs 2 model calls per turn, extraction and then adjudication, though the prototype below only does the first.

  • Full context still scores slightly higher on the paper's LLM-as-a-Judge metric, around 73% against Mem0's 67%. Memory wins on cost and p95 latency, not on judge score.

What does an agent memory system actually do?

While doing this comparison, I realized that a memory system operates at two points in a conversation. When a turn finishes, it decides what from that turn is worth keeping and when the next question arrives, it decides which of those saved facts the model needs to answer it.

That splits the entire system into 3 parts:

  • Extraction picks the facts out of a turn

  • Storage keeps them so they can be searched later

  • Retrieval pulls back the ones that match the question at hand.

Every implementation handles all 3, whether you build the memory layer yourself or use a managed one. Let’s look at them a bit more.

memory pipeline

Extraction

During extraction, an LLM pass reads each conversation turn and pulls out the facts worth keeping. For instance, when I feed my system a test billing conversation from a user, I just want it to keep important facts like the user’s plan, a dispute they once had, and their preferred mode of communication. Essentially, all important and relevant information should be extracted.

The mistake I try to avoid here is letting the model store what it inferred. My first extraction prompt asked for durable facts and nothing else, and it seemed to work for about a day. Then I read the store and found "user is frustrated about billing" sitting alongside the plan and the dispute. It was true when it was written. It was wrong 3 days later, once the refund cleared. Nothing in the store expires that line, so a routine Thursday question opens with the agent believing the customer is angry.

Left alone, the agent can return "user is frustrated about billing," which is right on Monday and wrong by Thursday once the refund clears, so a routine Thursday question opens with the agent believing the customer is angry.

Pro tip: One clause in the extraction prompt below prevents it, telling the model to ignore anything it is inferring rather than reading.

Storage

Each extracted fact becomes an embedding stored under the user it belongs to. Pinecone gives you two ways to keep users apart. You can create one namespace per user, or you can use a single shared index where every vector carries a user_id in its metadata and every query filters on it.

I use namespaces, and I'd argue against the filter even though it's the more common pattern. I once shipped a second query path, a summary endpoint that ran its own search, and I wrote it without the metadata filter. It worked in review and in staging with one test user, but with 2 users in the store it started handing one account's details to the other, and nothing anywhere threw an error. You could call it a skill issue, but what I found with that test is that the responses came back well-formed, confident, and wrong.

A namespace cannot fail that way, because the isolation lives in the call itself rather than in an argument you have to remember, and it also makes queries cheaper as the index grows. So all things considered, namespaces still win.

Retrieval

Retrieval sits between the question arriving and the model call going out. Embed the question, search that user's namespace, and you get back a ranked list of stored facts with a similarity score on each.

Only the top 1 or 2 are usually about the question. The rest are whatever came closest, so cut everything below a score threshold and put what survives into the system prompt as a few lines. The agent answers Tuesday's follow-up already knowing about the Enterprise plan and the March dispute, without reading a word of Monday's transcript.

Those 3 jobs are the whole system. Before building them, it helps to see what happens when you skip memory and send the transcript instead.

Why does replaying full chat history fail?

Replaying the chat transcript is a great idea until the conversation gets long. Dialogues in the LOCOMO benchmark run to about 300 turns across as many as 35 sessions, which the Mem0 paper puts at roughly 26,000 tokens of full context per query. A few months of active support history for one user lands in the same range, so you can see how this easily gets inefficient and expensive.

If the whole chat gets sent again on every question the user asks, and the model reads all of it before generating a word, you pay in tokens and again in the wait before the first word appears.

The Mem0 paper measures both approaches on that benchmark:

Per query

Full transcript

Retrieved facts

Tokens sent

~26,000

1,764

p95 latency

~17 seconds

~1.44 seconds

That's 93% fewer tokens on the answer call and a 91% cut in tail latency, repeating on every query for the rest of the conversation. Any retrieval layer earns most of that saving, including the one you write yourself. It's an argument for having memory at all, and it doesn't tell you whether to build or buy.

What does building your own memory layer require?

When you build your own AI agent memory layer, you control the extraction logic, you add no external dependency to your request path, and at low query volumes the infrastructure cost stays predictable and small. Sounds like a great deal for the most part.

A production system needs 4 components:

  1. An extraction prompt that separates durable facts from conversational noise without inventing details the user never said.

  2. A vector store with per-user isolation. Pinecone below, though pgvector works identically if you're already on Postgres.

  3. A retrieval function that applies a relevance threshold and ranks what it injects.

  4. A deduplication and conflict-resolution layer. This is the part demos skip and the part that takes the longest.

Writing the extraction and storage pass

Both implementations run against the same fixture, so the comparison is fair. EXPECTED_FACTS is what extraction should pull out of the Monday turn, and it's what I check both paths against.

"""The article's Monday / Tuesday support scenario."""

MONDAY_USER = (
    "Hi - my annual invoice double-charged me. I'm on the Enterprise plan. "
    "I'd rather handle everything over email, not phone. This started in March."
)

MONDAY_AGENT = (
    "Sorry about the duplicate charge. I have you on Enterprise and will follow "
    "up by email about the March invoice."
)

TUESDAY_USER = (
    "Following up on yesterday. Any update on the double charge? "
    "Please don't call me - email is fine."
)

EXPECTED_FACTS = [
    "User is on the Enterprise plan",
    "User disputed a duplicate charge in March",
    "User prefers email over phone",
]

"""The article's Monday / Tuesday support scenario."""

MONDAY_USER = (
    "Hi - my annual invoice double-charged me. I'm on the Enterprise plan. "
    "I'd rather handle everything over email, not phone. This started in March."
)

MONDAY_AGENT = (
    "Sorry about the duplicate charge. I have you on Enterprise and will follow "
    "up by email about the March invoice."
)

TUESDAY_USER = (
    "Following up on yesterday. Any update on the double charge? "
    "Please don't call me - email is fine."
)

EXPECTED_FACTS = [
    "User is on the Enterprise plan",
    "User disputed a duplicate charge in March",
    "User prefers email over phone",
]

"""The article's Monday / Tuesday support scenario."""

MONDAY_USER = (
    "Hi - my annual invoice double-charged me. I'm on the Enterprise plan. "
    "I'd rather handle everything over email, not phone. This started in March."
)

MONDAY_AGENT = (
    "Sorry about the duplicate charge. I have you on Enterprise and will follow "
    "up by email about the March invoice."
)

TUESDAY_USER = (
    "Following up on yesterday. Any update on the double charge? "
    "Please don't call me - email is fine."
)

EXPECTED_FACTS = [
    "User is on the Enterprise plan",
    "User disputed a duplicate charge in March",
    "User prefers email over phone",
]

I took the first 2 components together, since extraction feeds storage directly. Both clients have changed shape recently, so if you’re following through, pin what you test against. I ran everything below on openai 3.6.0, pinecone 9.1.0, and mem0ai 2.0.19.

The spine of my build is the EXTRACTION_PROMPT in the code below, which tells the model what counts as durable and asks for JSON back. Setting response_format to JSON mode is what makes that reliable because without it, the model occasionally wraps its output in markdown fences and json.loads raises inside a request handler.

Of course, JSON mode makes that rare rather than impossible, so I use parse_json_object to strip any stray backticks before it parses. Those few lines mean one odd response returns an empty list of facts instead of crashing the request.

Extraction runs on every turn, so I keep it on gpt-4o-mini and spend the frontier-model budget on the answer the user reads.

import json
import re
from uuid import uuid4

from openai import OpenAI

client = OpenAI()

EXTRACT_MODEL = "gpt-4o-mini"
EMBED_MODEL = "text-embedding-3-small"

_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)

EXTRACTION_PROMPT = """Extract durable facts about the user from the conversation turn below.

A durable fact is stable across sessions: account details, product in use, past
issues, stated preferences. Ignore pleasantries, one-off questions, and anything
you are inferring rather than reading.

Return JSON: {"facts": ["fact 1", "fact 2"]}. Return an empty list if the turn
contains nothing durable."""


def parse_json_object(raw: str) -> dict:
    text = _FENCE_RE.sub("", (raw or "").strip()).strip()
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError:
        return {}
    return parsed if isinstance(parsed, dict) else {}


def extract_facts(turn: str) -> list[str]:
    response = client.chat.completions.create(
        model=EXTRACT_MODEL,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": EXTRACTION_PROMPT},
            {"role": "user", "content": turn},
        ],
    )
    payload = parse_json_object(response.choices[0].message.content or "")
    facts = payload.get("facts", [])
    if not isinstance(facts, list):
        return []
    return [str(fact).strip() for fact in facts if str(fact).strip()]


def store_facts(index, user_id: str, facts: list[str]) -> None:
    if not facts:
        return
    embeddings = client.embeddings.create(model=EMBED_MODEL, input=facts)
    vectors = [
        {
            "id": f"{user_id}-{uuid4().hex}",
            "values": item.embedding,
            "metadata": {"user_id": user_id, "text": fact},
        }
        for fact, item in zip(facts, embeddings.data)
    ]

import json
import re
from uuid import uuid4

from openai import OpenAI

client = OpenAI()

EXTRACT_MODEL = "gpt-4o-mini"
EMBED_MODEL = "text-embedding-3-small"

_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)

EXTRACTION_PROMPT = """Extract durable facts about the user from the conversation turn below.

A durable fact is stable across sessions: account details, product in use, past
issues, stated preferences. Ignore pleasantries, one-off questions, and anything
you are inferring rather than reading.

Return JSON: {"facts": ["fact 1", "fact 2"]}. Return an empty list if the turn
contains nothing durable."""


def parse_json_object(raw: str) -> dict:
    text = _FENCE_RE.sub("", (raw or "").strip()).strip()
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError:
        return {}
    return parsed if isinstance(parsed, dict) else {}


def extract_facts(turn: str) -> list[str]:
    response = client.chat.completions.create(
        model=EXTRACT_MODEL,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": EXTRACTION_PROMPT},
            {"role": "user", "content": turn},
        ],
    )
    payload = parse_json_object(response.choices[0].message.content or "")
    facts = payload.get("facts", [])
    if not isinstance(facts, list):
        return []
    return [str(fact).strip() for fact in facts if str(fact).strip()]


def store_facts(index, user_id: str, facts: list[str]) -> None:
    if not facts:
        return
    embeddings = client.embeddings.create(model=EMBED_MODEL, input=facts)
    vectors = [
        {
            "id": f"{user_id}-{uuid4().hex}",
            "values": item.embedding,
            "metadata": {"user_id": user_id, "text": fact},
        }
        for fact, item in zip(facts, embeddings.data)
    ]

import json
import re
from uuid import uuid4

from openai import OpenAI

client = OpenAI()

EXTRACT_MODEL = "gpt-4o-mini"
EMBED_MODEL = "text-embedding-3-small"

_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)

EXTRACTION_PROMPT = """Extract durable facts about the user from the conversation turn below.

A durable fact is stable across sessions: account details, product in use, past
issues, stated preferences. Ignore pleasantries, one-off questions, and anything
you are inferring rather than reading.

Return JSON: {"facts": ["fact 1", "fact 2"]}. Return an empty list if the turn
contains nothing durable."""


def parse_json_object(raw: str) -> dict:
    text = _FENCE_RE.sub("", (raw or "").strip()).strip()
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError:
        return {}
    return parsed if isinstance(parsed, dict) else {}


def extract_facts(turn: str) -> list[str]:
    response = client.chat.completions.create(
        model=EXTRACT_MODEL,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": EXTRACTION_PROMPT},
            {"role": "user", "content": turn},
        ],
    )
    payload = parse_json_object(response.choices[0].message.content or "")
    facts = payload.get("facts", [])
    if not isinstance(facts, list):
        return []
    return [str(fact).strip() for fact in facts if str(fact).strip()]


def store_facts(index, user_id: str, facts: list[str]) -> None:
    if not facts:
        return
    embeddings = client.embeddings.create(model=EMBED_MODEL, input=facts)
    vectors = [
        {
            "id": f"{user_id}-{uuid4().hex}",
            "values": item.embedding,
            "metadata": {"user_id": user_id, "text": fact},
        }
        for fact, item in zip(facts, embeddings.data)
    ]

Running extract_facts(monday_turn()) returns this:

{
  "facts": [
    "User is on the Enterprise plan.",
    "User prefers to handle everything over email, not phone.",
    "User had a double charge on their annual invoice that started in March."
  ]

{
  "facts": [
    "User is on the Enterprise plan.",
    "User prefers to handle everything over email, not phone.",
    "User had a double charge on their annual invoice that started in March."
  ]

{
  "facts": [
    "User is on the Enterprise plan.",
    "User prefers to handle everything over email, not phone.",
    "User had a double charge on their annual invoice that started in March."
  ]

Compare that against EXPECTED_FACTS and you'll see the model kept all 3 facts but wrote none of them the way I did. "Disputed a duplicate charge in March" came back as "had a double charge on their annual invoice that started in March." Same fact, different sentence. Why is this important? Because it's the reason deduplication later can't work by comparing strings, and the reason the similarity scores in that section move around so much with phrasing.

store_facts takes index as an argument rather than reaching for a module-level Pinecone client. Anything with the same upsert, query, and delete methods works in its place, so you can develop and run tests against a plain dictionary in memory and swap in the real Pinecone index once it's provisioned. None of the extraction code changes when you do.

Wiring retrieval into the agent

recall handles the read side. It embeds the question, searches that user's namespace, and drops any match scoring below the relevance floor before returning what's left.

Depending on which version of the Pinecone client you're on, a match comes back as either a dictionary or an object, and metadata follows the same split. Calling .get("text") on the object version raises an AttributeError, so the loop converts metadata into a dictionary before reading from it.

ANSWER_MODEL = "gpt-4o"
RELEVANCE_FLOOR = 0.3  # tuned on OpenAI embeddings; measure on your data


def recall(index, user_id: str, query: str, top_k: int = 5) -> list[str]:
    query_vector = client.embeddings.create(
        model=EMBED_MODEL, input=[query]
    ).data[0]

ANSWER_MODEL = "gpt-4o"
RELEVANCE_FLOOR = 0.3  # tuned on OpenAI embeddings; measure on your data


def recall(index, user_id: str, query: str, top_k: int = 5) -> list[str]:
    query_vector = client.embeddings.create(
        model=EMBED_MODEL, input=[query]
    ).data[0]

ANSWER_MODEL = "gpt-4o"
RELEVANCE_FLOOR = 0.3  # tuned on OpenAI embeddings; measure on your data


def recall(index, user_id: str, query: str, top_k: int = 5) -> list[str]:
    query_vector = client.embeddings.create(
        model=EMBED_MODEL, input=[query]
    ).data[0]

Storing those 3 facts writes 3 vectors into the user's namespace, each carrying the fact text in its metadata. It's 3 here because store_facts upserts whatever extraction hands it. Once deduplication and adjudication are in the write path, the same turn can write fewer:

Upserted 3 vectors into namespace "u_1042"

u_1042-4f2a...  "User is on the Enterprise plan."

u_1042-9c71...  "User prefers to handle everything over email, not phone."

u_1042-1b83...  "User had a double charge on their annual invoice that started in March."

On Tuesday, recall(index, "u_1042", TUESDAY_USER) searches that namespace. Pinecone returns matches with a similarity score on each, the loop drops anything under RELEVANCE_FLOOR, and what comes back is a plain list of strings. The order depends on how each fact scores against Tuesday's question, so treat this ordering as one plausible run rather than a fixed result:




The scores stay inside recall, and everything downstream sees facts, not vectors.

Those facts get formatted into the system prompt, and the chat call in answer() runs without response_format, so what comes back is ordinary support-agent text:

I have you on the Enterprise plan and will follow up by email about the

March double charge.

There’s a slip-up I made that you should be careful about: answer() ends by calling extract_facts on the turn, it just produced. That call returns JSON again; the new facts go into the same namespace, and Tuesday's exchange becomes context for Wednesday. The JSON never leaves the helper. answer() returns the reply string.

Call

What it returns

extract_facts()

A list of fact strings. The {"facts": [...]} JSON is what the API returned, and it's parsed away inside the helper

recall()

A list of fact strings, scores already applied and discarded

The chat call in answer()

Plain text, no JSON mode

answer()

The reply string, after writing the new turn back to the store

That's a working memory system for one user. Tuesday's follow-up arrives with the plan, the dispute, and the email preference already in the prompt, and Monday's transcript never gets sent.

The relevance floor matters more than top_k does. top_k=5 against a store of 6 facts returns 5 of them no matter what the question was, so a question about SSO comes back with the March billing dispute in position 5 purely because it ranked fifth. The model has no way to know that the result is noise, and it will work it into the answer. I'd rather send 2 relevant facts than 5 padded ones.

0.3 is what I settled on for this scenario on text-embedding-3-small. It's a starting point, and it won't transfer to another embedding model or to facts phrased differently from these.

The first thing I'd change before shipping is that store_facts runs inline, which puts an extraction round trip on the user's response latency. In production, that goes to a background worker, but it's inline here, so the flow reads top to bottom.

What does this prototype not handle yet?

Four gaps sit between this code and something I'd put in front of paying customers.

Deduplication

The first is deduplication. When you extract the same fact twice, you store it twice, so the store now holds 2 copies of the same thing. Both come back in every search, both go into the prompt, and you pay for both on every query.  The obvious fix is a similarity check before upsert, and it doesn't work.

The obvious fix is to check before you write. Embed the new fact, compare it against what's already stored, and if it's close enough to something you have, update that instead of adding a second copy. Close enough means a similarity score above some number you choose.

The problem is picking that number. I measured the pairs on *text-embedding-3-small*:

Pair

Kind

Score

"prefers email" / "prefers email over phone"

near-duplicate

0.852

"user is on the Pro plan" / "user is on the Starter plan"

contradiction

0.766

"had a billing dispute in March" / "disputed a duplicate charge in March"

near-duplicate

0.690

"user is on Pro" / "user is on Starter"

contradiction

0.636

"prefers email over phone" / "user is on the Enterprise plan"

unrelated

0.238

Look at the second row. "User is on the Pro plan" and "user is on the Starter plan" score 0.766, near the top of the table. To the embedding model, they are almost the same sentence, because only one word differs. To your agent, they are opposites, and only one of them is currently true.

That is the trap. The score measures how similar two sentences are, but what you need to know is whether both can be true at once.

So no cutoff works. The pairs you want merged score 0.690 to 0.852, and the pairs you need kept apart score 0.521 to 0.766, but because those ranges overlap, any number you pick falls inside both. If you choose 0.60, the Pro and Starter facts merge, leaving the agent confident about the wrong plan, and if you choose 0.87, nothing merges at all, so both "prefers email" lines stay, and you pay for the same fact on every query. It’s a lose-lose situation.

Short test phrases hide all of this. "User is on Pro" against "user is on Starter" scores 0.636, below every near-duplicate, so a cutoff at 0.66 looks right. Extraction doesn't return short phrases, though. It returns full sentences, where the shared words carry more weight, and that same contradiction climbs to 0.766.

Conflict resolution

Since no similarity score separates a duplicate from a contradiction, something that understands meaning has to make that call instead. In practice, that means a second LLM call on every write, with its own prompt to write, test, and keep working as your extraction prompt changes.

On every write, you search for the stored facts closest to the new one, send those and the new fact to an LLM, and ask it which action to take. It can merge them into a single fact, replace the old one with the new one, or store the new one alongside what's already there.

That's a second prompt to maintain and a second model call on every turn. It also introduces a failure that the threshold never had. The model can decide to replace a fact that was still true, and once it has, the original is gone.

Deletion

Erasing a user is one call: just drop their namespace and every fact about them is gone.

The hard part is proving it happened. In the real world, an auditor will ask what was stored, when it was deleted, and who authorized the deletion. Once the namespace is gone, the evidence goes with it, so that record has to be written before the deletion rather than after.

There's a second problem I found underneath that one. If a write failed partway through, or got retried and landed under the wrong user ID, those vectors sit outside the namespace you just dropped. They still hold that user's data, and the delete never touched them, so I had to create a job that goes looking for strays.

Memory decay

Retrieval ranks facts by similarity alone, so age counts for nothing. A plan the customer left 2 years ago competes on equal terms with the one they signed up for last week, and if the older sentence happens to match the question better, that's what the agent answers with.

Fixing it means storing a timestamp with every fact and combining recency with the similarity score at query time. How much weight recency gets is a number you invent, then adjust every time support flags an answer built on a stale detail.

I'd budget 8 to 16 weeks of solo work to close those 4 gaps, and more if your team needs audit logging or role-based access on top. That's a fair estimate since the code here stops at the prototype.

What does the managed path look like?

Those weeks are part of what a managed memory layer sells, and I think it’s the smaller part. The bigger one is that the 4 gaps never become yours to maintain. Nobody on your team retunes the similarity threshold, keeps the adjudicator prompt in sync with the extraction prompt as both change, or argues about how much weight recency should get. That work doesn't stop when you ship, and it scales with how much you write.

To prove this, I built the same agent a second time against Mem0's API. Same monday_turn(), same EXPECTED_FACTS, same Tuesday follow-up, and much less code of my own. Mem0 runs extraction, deduplication, and conflict resolution inside its pipeline on every add() call, which leaves me with retrieval, the prompt, and the glue around the API.

Four things about the current Platform v3 API caught me out, and the last one cost me most of an afternoon. My integration test wrote a fact, searched for it on the next line, got nothing back, and I spent an hour reading my own filter syntax before checking the response from add() and finding a status of PENDING.

  • add() accepts user_id as a top-level entity id. It returns an event id with status PENDING. Searching immediately after that call can return nothing. Poll GET /v1/event/{event_id}/ until it reports SUCCEEDED.

  • search() rejects top-level user_id and limit. Use filters={"user_id": ...} and top_k.

  • Search returns{"results": [{"memory": "...", "score": ...}, ...]}, not a bare list.

  • delete_all(user_id=...) still works as a query parameter on the v1 delete endpoint.

import time

from mem0 import MemoryClient
from openai import OpenAI

memory = MemoryClient()  # reads MEM0_API_KEY
llm = OpenAI()


def memory_texts(results) -> list[str]:
    items = results.get("results", results) if isinstance(results, dict) else results
    texts = []
    for item in items or []:
        if isinstance(item, dict) and item.get("memory"):
            texts.append(item["memory"])
    return texts


def wait_for_add(result: dict, timeout: float = 45.0) -> None:
    event_id = (result or {}).get("event_id")
    if not event_id:
        return

    deadline = time.time() + timeout
    while time.time() < deadline:
        response = memory.client.get(f"/v1/event/{event_id}/")
        if response.status_code == 200:
            status = str(response.json().get("status", "")).upper()
            if status == "SUCCEEDED":
                time.sleep(0.5)
                return
            if status == "FAILED":
                raise RuntimeError(response.json().get("error") or "Mem0 add failed")
        time.sleep(0.6)


def recall(user_id: str, query: str, top_k: int = 5) -> list[str]

import time

from mem0 import MemoryClient
from openai import OpenAI

memory = MemoryClient()  # reads MEM0_API_KEY
llm = OpenAI()


def memory_texts(results) -> list[str]:
    items = results.get("results", results) if isinstance(results, dict) else results
    texts = []
    for item in items or []:
        if isinstance(item, dict) and item.get("memory"):
            texts.append(item["memory"])
    return texts


def wait_for_add(result: dict, timeout: float = 45.0) -> None:
    event_id = (result or {}).get("event_id")
    if not event_id:
        return

    deadline = time.time() + timeout
    while time.time() < deadline:
        response = memory.client.get(f"/v1/event/{event_id}/")
        if response.status_code == 200:
            status = str(response.json().get("status", "")).upper()
            if status == "SUCCEEDED":
                time.sleep(0.5)
                return
            if status == "FAILED":
                raise RuntimeError(response.json().get("error") or "Mem0 add failed")
        time.sleep(0.6)


def recall(user_id: str, query: str, top_k: int = 5) -> list[str]

import time

from mem0 import MemoryClient
from openai import OpenAI

memory = MemoryClient()  # reads MEM0_API_KEY
llm = OpenAI()


def memory_texts(results) -> list[str]:
    items = results.get("results", results) if isinstance(results, dict) else results
    texts = []
    for item in items or []:
        if isinstance(item, dict) and item.get("memory"):
            texts.append(item["memory"])
    return texts


def wait_for_add(result: dict, timeout: float = 45.0) -> None:
    event_id = (result or {}).get("event_id")
    if not event_id:
        return

    deadline = time.time() + timeout
    while time.time() < deadline:
        response = memory.client.get(f"/v1/event/{event_id}/")
        if response.status_code == 200:
            status = str(response.json().get("status", "")).upper()
            if status == "SUCCEEDED":
                time.sleep(0.5)
                return
            if status == "FAILED":
                raise RuntimeError(response.json().get("error") or "Mem0 add failed")
        time.sleep(0.6)


def recall(user_id: str, query: str, top_k: int = 5) -> list[str]

Walk the Monday turn through answer() and 3 things happen in order. First, recall searches an empty store and gets [] back, so the system prompt carries no facts and the agent answers from the question alone. Second, the chat call returns ordinary support text, because there is no response_format on it. Third, memory.add() returns this:

{"event_id": "...", "status": "PENDING"}

That is an acknowledgement that Mem0 has accepted the turn and queued it, and the facts don't exist in the store yet. wait_for_add polls the event endpoint until the status reads SUCCEEDED, which is the point they do.

Come back on Tuesday and memory.search() returns those facts with a score on each:

{
  "results": [
    {"memory": "User is on the Enterprise plan", "score": 0.72},
    {"memory": "User prefers email over phone", "score": 0.68},
    {"memory": "User had a billing dispute / double charge in March", "score": 0.65}
  ]

{
  "results": [
    {"memory": "User is on the Enterprise plan", "score": 0.72},
    {"memory": "User prefers email over phone", "score": 0.68},
    {"memory": "User had a billing dispute / double charge in March", "score": 0.65}
  ]

{
  "results": [
    {"memory": "User is on the Enterprise plan", "score": 0.72},
    {"memory": "User prefers email over phone", "score": 0.68},
    {"memory": "User had a billing dispute / double charge in March", "score": 0.65}
  ]

recall strips that down to the strings, which is the same shape the self-built version returns. Scores shift between runs, so read those as illustrative.

Two things are worth holding against the build section. The wording is Mem0's rather than mine. It stored "User had a billing dispute / double charge in March," where my own EXPECTED_FACTS says "User disputed a duplicate charge in March." Same fact, different sentence, and this time the sentence was chosen by an extraction prompt I can't edit.

The extraction JSON also never appears. In the self-built path, I parse {"facts": [...]} myself and can log exactly what came back. Here, that happens inside add(), which is less code right up to the point where you want to know what it kept and what it threw away.

*GET /v1/event/{event_id}/* is documented. What isn't official is reaching it through memory.client, which is an internal attribute on the SDK and can move on a minor version bump. Polling is the only way to make a read-after-write deterministic, and there's no public helper for it yet. Erasure is *memory.delete_all(user_id=user_id)*. Move *add()* and the event wait off the response path in production for the same latency reason as the self-built write.

The Mem0 paper reports a 26% relative improvement over OpenAI's memory product on LLM-as-a-Judge, alongside the latency and token results cited earlier. I agree with that, as those comparisons are against OpenAI Memory and against full-context replay. Neither is against a competent self-built retriever, and I haven't found a published benchmark for that matchup.

Compared to the build version, I found three important things makes me lean toward this route for AI agent memory.

  • The write is already off the response path. My self-built answer() runs extraction inline, and I flagged that as the first thing to fix before shipping. Mem0's add() returns before extraction finishes, so the background worker I was planning to write already exists. The PENDING status that cost me an afternoon in tests is that same design decision, seen from the wrong end.

  • The 4 gaps are closed on day one instead of week 12. The facts that came back on Tuesday had already been through deduplication and conflict resolution. When that customer moves from Enterprise to Starter in June, the new fact supersedes the old one without me choosing a threshold or writing an adjudicator prompt, which is the exact problem I could not solve with similarity scores.

  • And the pipeline improves without me. My extraction prompt is frozen the day I stop editing it. They have people working on it, and the same add() call gets better as they do.

Where does the managed path fall short?

Extraction quality depends on a model I don't control. It decides what counts as durable, and it will occasionally keep something trivial or miss a preference stated indirectly, which are the same failure modes I hit writing the extraction prompt in the build section. When that prompt misbehaved, I fixed it by editing a string in my own code. With Mem0, I cannot do that.

Custom instructions cover most domain-specific tuning. What you give up is rewriting the extraction logic outright, so if your domain has rules a natural-language instruction can't express, that's a real argument for building.

You still write the integration code yourself, including result unwrapping, async waits, background writes, and whatever audit log your counsel wants. Mem0 handles the deduplication and conflict logic. You handle everything between your app and their API.

How do the two paths compare?

Both implementations answer the Tuesday follow-up. What separates them is what you spend getting there and what you own afterward.

Dimension

Build your own

Mem0

Time to working prototype

An afternoon

Under an hour, if you use the current v3 API

Time to production-ready

8 to 12 weeks

Days for the product path; still your glue, waits, and compliance paperwork

Answer-path tokens

A handful of ranked facts, usually a few hundred tokens on this demo

1,764 tokens per query on LOCOMO in the paper, measured on far longer conversations

Write-path tokens

Extraction + adjudicator on every turn

Extraction inside add() (async)

Deduplication

Manual, plus a second LLM pass

Handled by the pipeline

GDPR deletion

Namespace delete, then your audit trail

delete_all(user_id=...), then your audit trail

Control over extraction logic

Full

Limited, customizable via instructions

Redo the token rows with your own numbers, and read them as replay against retrieval rather than build against buy.

100K queries at 26,000 input tokens is 2.6B tokens, about $6,500 at $2.50 per million for gpt-4o-class input. The same queries at 1,764 retrieved tokens is about 176M tokens, or about $440. Both figures come from Mem0's measurements on LOCOMO. The self-built recall() above injects 5 short facts and lands in the low hundreds of tokens on this demo, so both implementations sit well under full history and neither is the other's bill.

The write path is where the money actually moves, and it took me a while to see it, because I spent the first hour of this comparison building a spreadsheet of retrieval costs that turned out to be nearly identical on both sides.

Self-built, every turn costs an extraction call plus the adjudication call that conflict resolution needs, and that keeps running for as long as the product does. Before any of it runs, you spend 8 to 12 weeks. Mem0 meters requests rather than memories, since one add() can create several, and Pro is $249 a month for 500,000 adds and 50,000 retrievals. Set against 8 to 12 weeks of engineering time, $249 a month is not the line item worth optimizing.

Neither column wins every row, so the decision comes down to which row binds for you. If you're not sure which, the cheapest way to find out is to run both. The free Hobby tier covers 10,000 add requests and 1,000 retrievals a month, which is enough to point the code above at your own conversations and see what the extraction keeps and what it drops. Start there, then price the real thing against your own volume once you know which path fits.

Common mistakes I see teams make

Separate from the build vs. buy call, there are 4 mistakes I keep seeing regardless of which path a team picks.

  • Testing extraction on tidy sentences: The pairs you write by hand to check your dedup logic are shorter and cleaner than anything extraction actually produces. That's how a threshold passes review and fails in production, and it's exactly what the similarity table above shows.

  • Putting the write on the response path: Extraction after a turn feels like it belongs in the same function as the answer, so that's where it ends up. Every user then waits on a model call that has nothing to do with their question. Move it to a worker before you have traffic, not after.

  • Storing feelings: Sentiment, urgency, and mood all look like facts to an extraction prompt, and none of them survive contact with next week. If it can expire, it doesn't belong in a store that has no expiry.

  • Treating deletion as a delete: The teams I've seen get caught here have working erasure and no record of it. The regulator isn't asking whether the data is gone. They're asking you to show it went, when, and on whose authority.

Which path should you pick?

Three things decide this:

  1. How many weeks until you ship

  2. How many conversation turns a month do you write

  3. Whether GDPR or HIPAA is in scope

Query volume isn't on that list as both paths retrieve, so the token saving lands on both sides and can't tell you which to pick.

When to build an AI agent memory

Build when your extraction rules can't be written as an instruction to somebody else's model. Regulated domains are the clearest case, where a fact has to be tagged with its source turn and retention class at extraction time. If that logic lives in your prompt, you can version it and test it against a fixture set before it ships.

You can also build when data residency keeps conversations inside infrastructure you control. Extraction sends raw conversation turns to whatever model does the extracting, so a managed layer adds a processor to a data flow your legal team already signed off on. Mem0's open-source library is the middle option here, since it runs the same pipeline against your own store and your own model, in exchange for owning uptime and index scaling.

Finally, build when the write volume is low. A few thousand turns a month means few contradictions accumulating, which is the gap that costs the most to close, and the 2 model calls per turn stay cheap at that scale. Internal tools with a fixed, small user base sit here.

Budget 8 to 12 weeks to get there, and check that against your own team. If your ship date is under 2 weeks out, the prototype takes an afternoon and the work after it doesn't compress by adding people.

When to buy AI agent memory

Time to production is the clearest case. Deduplication, conflict resolution, deletion, and decay are solved problems with known answers, and none of them is a reason your product wins.

Write volume is the second. Every turn in the self-built path runs an extraction call and an adjudication call, and every change to the extraction prompt has to be tested against facts already stored under the old one. That maintenance scales with turns rather than users, so check Mem0's pricing against both your add and retrieval volumes before committing.

Compliance is the third, with a caveat. GDPR or HIPAA in scope means the vendor's DPA, residency options, and audit features all have to match what counsel signed off on. Check the tier as well as the trust page. Mem0 lists SOC 2 Type I, HIPAA readiness, and GDPR readiness, but audit logs and on-prem deployment sit on the Enterprise plan. A one-line delete_all is not an erasure program by itself.

Does the choice lock you in?

No. Stored memories come back out through the API, so starting managed and moving to your own store later is a data migration, and the reverse is equally clean since extracted facts are text and vectors. Pick for the next 6 months.

Conclusion

Back to the decision. Both paths produce an agent that remembers the Monday billing dispute on Tuesday. Building buys you complete control over extraction and costs a month or two of engineering time that isn't going into your product. Managed gets you the pipeline in an afternoon and hands off deduplication and conflict resolution to someone whose product it is.

If the managed path fits, start with the Mem0 quickstart and budget an extra hour for the asynchronous write behavior. The free Hobby tier covers 10,000 add requests and 1,000 retrievals a month.

Mem0 is an intelligent, open-source memory layer designed for LLMs and AI agents to provide long-term, personalized, and context-aware interactions across sessions.

Get your free API Key here: app.mem0.ai or self-host mem0 from our open-source GitHub repository.

Frequently asked questions

Q. What's the difference between agent memory and RAG?

The write path. A RAG corpus is authored ahead of time and shared across every user. Memory is written by the agent at runtime, scoped to one user, and accumulates contradictions as that user's situation changes. Retrieval looks nearly identical in both, which is why deduplication, conflict resolution, and decay have no equivalent in a standard RAG pipeline.

Q. Can I use Postgres instead of a dedicated vector database?

Yes. I'd default to it if you're already running Postgres. pgvector handles the query pattern in this article without adding a service, and per-user isolation becomes a WHERE user_id = $1 clause alongside the similarity search. HNSW index builds slow down past a few million vectors, which is where a dedicated store starts earning its cost.

Q. How do I tell whether my memory layer is working?

Hold out real multi-session conversations and write questions whose answers depend on facts stated in an earlier session. Grade retrieval and answer quality separately, since a correct answer built on a lucky guess and a correct answer built on a retrieved fact look the same from the outside. LOCOMO is the public benchmark if you want a comparison point. Use it to measure recall of prior-session facts rather than to prove memory beats replay on quality, since replay still leads the judge's score.

Q. How much latency djudge's memory layer add?

On the read side, very little. You pay for one embedding call on the incoming question plus a vector search, which together run in tens of milliseconds against a store of any reasonable size. Set that against replaying the transcript, where the paper measures p95 total response latency at 17.12 seconds versus 1.44 with retrieval, and memory makes the agent faster rather than slower.

The write side is where latency goes wrong, and it's a self-inflicted problem. Extraction is a full model call, so leaving it inline means every user waits on a round trip that has nothing to do with their question. Move it to a background worker, or use a layer whose write is asynchronous by default.

Q. Can several agents share one memory store?

Yes, and they should, because memory is keyed by user rather than by agent. A support agent and a billing agent talking to the same customer both benefit from the plan and the March dispute, and duplicating that into 2 stores just gives you 2 things to keep in sync.

Two problems show up once you do it. Concurrent writes about the same user need a resolution rule, since the adjudication step described earlier assumes it's the only thing writing. And read scoping becomes a real decision, because an agent handling a sales conversation probably shouldn't see everything the support agent stored. I've written about the wider version of this problem in multi-agent memory systems.

GET TLDR from:

Summarize

Website/Footer

Summarize

Website/Footer

Summarize

Website/Footer

Summarize

Website/Footer