GuidesChatbot with memory

Build a chatbot with memory

The API keeps no state between requests: every call must carry the conversation it should know about. “Memory” is therefore something your application builds. This guide adds three layers of memory to a chat assistant for a fictional language-learning app, Lingotrail:

  1. Conversation history: the recent turns of the current chat.
  2. Running summary: a compact record of older turns once the chat gets long.
  3. Long-term facts: things worth remembering across chats (“learning Portuguese for a move to Porto in March”, “prefers short answers”).

You will need: an API key in SYNTIC_API_KEY, Python 3.9 or later, and pip install openai.

Layer 1: conversation history

Store each turn and send them back on the next request. This alone gives a coherent conversation:

import os
 
from openai import OpenAI
 
client = OpenAI(base_url="https://app.syntic.ai/v1", api_key=os.environ["SYNTIC_API_KEY"])
 
BASE_SYSTEM = """
You are Lingotrail's conversation tutor. Chat with the learner in their target language
at their level, correct one mistake per reply (gently, after your answer), and keep replies short.
""".strip()
 
 
def chat_turn(chat: dict, user_text: str) -> str:
    chat["turns"].append({"role": "user", "content": user_text})
    reply = client.chat.completions.create(
        model="Amara S1.2",
        stream=False,
        max_tokens=400,
        messages=[{"role": "system", "content": build_system(chat)}] + chat["turns"],
    )
    text = reply.choices[0].message.content or ""
    chat["turns"].append({"role": "assistant", "content": text})
    chat["last_prompt_tokens"] = reply.usage.prompt_tokens
    return text

build_system is defined below; for now it can return BASE_SYSTEM. Store chat in your database, keyed by user and conversation.

Layer 2: summarise old turns

Every request re-sends the history, so a long chat gets slower and more expensive with each turn, and eventually reaches the request limits (500 messages, about 2 MB). When the history passes a threshold, fold the oldest turns into a summary:

KEEP_RECENT = 12          # turns sent verbatim
SUMMARISE_AT = 30         # start folding when the chat has this many turns
 
 
def compact(chat: dict) -> None:
    if len(chat["turns"]) < SUMMARISE_AT:
        return
    old, recent = chat["turns"][:-KEEP_RECENT], chat["turns"][-KEEP_RECENT:]
    transcript = "\n".join(f"{t['role']}: {t['content']}" for t in old)
    r = client.chat.completions.create(
        model="Amara Quick",
        stream=False,
        max_tokens=300,
        messages=[
            {"role": "system", "content": (
                "Update the running summary of a tutoring chat. Keep: topics practised, "
                "recurring mistakes, words the learner struggled with, anything they said about themselves. "
                "Drop greetings and small talk. Under 150 words."
            )},
            {"role": "user", "content": f"Current summary:\n{chat.get('summary') or '(none)'}\n\nNew turns:\n{transcript}"},
        ],
    )
    chat["summary"] = r.choices[0].message.content
    chat["turns"] = recent

Call compact(chat) before each chat_turn. Two details matter:

  • The summary is updated, not rewritten from scratch. Passing the current summary in keeps facts from earlier folds.
  • Cut on a user turn. The recent slice should start with a user message. If your tutor uses tools, never split an assistant message with tool_calls from its tool results; move the cut point forward until the pair is whole.

Layer 3: long-term facts

Some things should survive the end of a chat. After a chat ends (or every few turns), ask a cheap model to pull out durable facts and merge them with what you already know:

import json
 
FACT_RULES = """
You maintain a short profile of a language learner for their tutor.
Given the existing facts and a new conversation, return the updated list as JSON:
{"facts": ["...", "..."]}
Keep only facts useful in future lessons: goals, level, native language, interests, preferences,
persistent mistakes. Remove facts the learner has corrected. At most 15 facts, each under 20 words.
Never store health details, passwords, payment data or other people's personal information.
""".strip()
 
 
def update_facts(user: dict, transcript: str) -> None:
    r = client.chat.completions.create(
        model="Amara Quick",
        stream=False,
        max_tokens=500,
        messages=[
            {"role": "system", "content": FACT_RULES},
            {"role": "user", "content": json.dumps({"existing_facts": user.get("facts", []), "conversation": transcript})},
        ],
    )
    text = r.choices[0].message.content or ""
    try:
        facts = json.loads(text[text.index("{"): text.rindex("}") + 1])["facts"]
    except (ValueError, KeyError):
        return                      # keep the old facts rather than store something broken
    user["facts"] = [f for f in facts if isinstance(f, str)][:15]

Put the layers together

def build_system(chat: dict) -> str:
    parts = [BASE_SYSTEM]
    if chat["user"].get("facts"):
        parts.append("What you know about this learner:\n- " + "\n- ".join(chat["user"]["facts"]))
    if chat.get("summary"):
        parts.append("Earlier in this conversation:\n" + chat["summary"])
    return "\n\n".join(parts)

The order is deliberate. The fixed tutor instructions come first and do not change, so they can be served from the prompt cache. Facts change rarely; the summary changes most often, so it comes last.

Prompt caching works on matching prompt beginnings. Anything that changes on every request (a timestamp, a random greeting) placed at the top of the system message stops caching for everything after it. See Pricing.

Let the user see and delete memory

Long-term memory is personal data. Treat it that way:

  • Show the stored facts in the app’s settings, in plain words, with a delete button per fact and a “forget everything” button.
  • Do not store facts from chats the user marked as private.
  • Delete facts and summaries when the account is deleted, and include them in data exports.
  • The FACT_RULES exclusions are a first filter; do not rely on them alone for sensitive data.

Measure whether memory helps

Memory can make answers worse when stale facts override what the user says now. Test with scripted conversations:

ScenarioExpected
Learner said “I’m moving to Porto” last week; today asks for travel phrasesUses European Portuguese phrasing.
Fact says “beginner”; learner now writes fluent paragraphsFollows today’s level, and the fact is updated after the chat.
Learner says “forget that I mentioned my job”The fact is gone after update_facts.
60-turn chatReply still refers correctly to something from turn 5 via the summary.

Cost

With 12 recent turns, a summary and a fact list, a typical request stays around 2,000 to 3,000 input tokens however long the chat runs, instead of growing without limit. The summary and fact updates run on Amara Quick and only every few turns. Log usage.prompt_tokens per turn to confirm the curve stays flat.

Was this page helpful?