Thread-Gedächtnis für einen LangGraph-Support-Agenten mit SQLite-Checkpoints
Wie ein Support-Agent auf LangGraph mit SqliteSaver und Thread-IDs den Kontext über mehrere Runden hält und warum sich kurzfristiges Thread-Gedächtnis von Langzeitgedächtnis unterscheidet.
Ein Support-Agent, der die LEPA-Dokumentation durchsuchen kann, ist nützlich, scheitert aber trotzdem an der zweiten Nachricht in einem Chat. Stellen Sie sich vor, ein Nutzer tippt ein einzelnes Wort, und der Agent fragt nach, welchen Bereich er meint.
Turn 1: "help"
Agent: Which area — login, grades, reports…?
Der Nutzer antwortet mit dem eigentlichen Problem, und wenn nichts den ersten Austausch gespeichert hat, fängt der Agent bei null an.
Turn 2: "Why aren't grades showing?"
Agent: …starts from zero unless something remembers Turn 1
Support ist ein Dialog, also braucht der Agent eine Möglichkeit, sich daran zu erinnern. Im Folgenden geht es um das Gedächtnis, das für den LEPA-Support-Agenten tatsächlich gebaut wurde, und um die Art, die bewusst weggelassen wurde.
Warum ein zustandsloser Endpoint Rückfragen kaputt macht
Jede HTTP-Anfrage kommt ohne Vorgeschichte an. Für den Server sieht jeder Aufruf aus wie ein neuer Besucher.
User → Agent → answer → forget
User → Agent → answer → forget
User → Agent → answer → forget
Das ruiniert jeden Ablauf, in dem der Agent zuerst eine Frage stellt. Eine Antwort auf „Welcher LEPA-Bereich?“ ergibt nur Sinn, wenn das System noch weiß, dass beide Nachrichten zu einem Gespräch gehören.
Zwei Arten von Gedächtnis, nur eine gebaut
Früh im Projekt wurde die Trennung so gezogen:
- Kurzzeitgedächtnis umfasst ein Gespräch, also einen Thread.
- Langzeitgedächtnis umfasst Fakten, die eine Sitzung überdauern.
Version eins des Agenten hat die erste Art. Die zweite gibt es noch nicht. Der Unterschied ist so wichtig, dass er unten überall wiederholt wird, wo Verwirrung entstehen könnte.
Zustand während eines Laufs und über Läufe hinweg
Der LangGraph-Zustand wirkt bereits als kleines Gedächtnis, solange ein Lauf andauert. Das Support-Zustandsobjekt trägt vier Dinge.
messages
user_role
issue_category
clarification_needed
retrieved_documents
final_answer
…
Das Feld messages ist mit dem Reducer add_messages verbunden, sodass jede neue Runde angehängt und nichts überschrieben wird. Innerhalb eines invoke-Aufrufs geben sich die Knoten über diesen gemeinsamen Zustand den Kontext weiter.
Ein invoke-Aufruf endet allerdings irgendwann. Sobald die nächste HTTP-Anfrage eintrifft, ist alles aus der letzten verloren, sofern der Zustand nicht irgendwo gespeichert wurde. Genau dieses Speichern nennt man Checkpointing.
Wie ein Checkpointer funktioniert
Vereinfacht gesagt speichert ein Checkpointer den Graphzustand nach getaner Arbeit und legt ihn unter einer Gesprächskennung ab. Man kann es sich vorstellen wie das Schließen eines Ticket-Tabs im Helpdesk, das man später wieder öffnet: gleiche Ticketnummer, gleicher Verlauf.
Das Projekt verwendet SqliteSaver aus dem Paket langgraph-checkpoint-sqlite. Der Ablauf beginnt mit einer Kennung, die in der Laufkonfiguration übergeben wird.
thread_id = "abc-123"
↓
invoke graph with config { configurable: { thread_id } }
↓
state is saved to SQLite
↓
next invoke with same thread_id
↓
prior messages / fields load again
Auf der API-Seite nimmt der Handler die Thread-ID aus dem Request-Body oder erzeugt eine UUID, wenn keine mitgeschickt wurde, und verpackt sie im Konfigurations-Dictionary.
thread_id = body.thread_id or str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}
Diese Konfiguration wird zusammen mit der neuen Nutzernachricht an den Graphen übergeben.
result = graph.invoke(
{"messages": [HumanMessage(content=body.message)]},
config=config,
)
Eine Thread-ID bedeutet eine Spur im Gedächtnis.
Was gespeichert wird
Für einen bestimmten Thread sichert der Checkpointer den gesamten Graphzustand. Dazu gehören die wachsende Nachrichtenliste und Felder wie die Rolle des Nutzers und die Problemkategorie, wie sie in früheren Schritten gesetzt wurden.
In der Praxis sieht die erste Runde so aus: Der Nutzer schreibt etwas Vages, der Agent stellt eine Rückfrage, und der Zustand wird gespeichert.
Turn 1: "help"
→ clarification question
→ state saved
Die zweite Runde läuft dann mit den früheren Nachrichten weiter. Der Klassifikator liest den neuesten Nutzertext also im Kontext, und die Wissens- und Support-Schritte können vernünftig antworten.
Turn 2: "Why aren't grades showing?"
→ prior messages still there
→ classify sees the latest user text
→ knowledge + support can answer
Tests bestätigen das Verhalten. Sie schicken eine vage Eröffnungsnachricht, danach die Frage zu den Noten unter derselben Thread-ID, und prüfen, dass das zweite Ergebnis einen längeren Nachrichtenverlauf hat als das erste.
Was „Kurzzeitgedächtnis“ hier bedeutet
Wenn es heißt, der Agent habe ein Kurzzeitgedächtnis, beschränkt sich die Aussage auf diesen Stack.
SQLite checkpoint
+ thread_id
+ SupportState.messages (append)
Es ist das Gedächtnis eines einzelnen Support-Threads. Es ermöglicht:
- eine Rückfrage, auf die eine Antwort folgt
- das Erinnern früherer Runden innerhalb desselben Chats
- einen Chat-Endpoint, der sich wie eine Sitzung verhält statt wie ein Strom zusammenhangloser Fragen
Automatisch liefert es nicht:
- ein dauerhaftes Profil jeder Lehrkraft
- schulweites Wissen über die Markdown-Dokumente hinaus
- eine Aussage wie „der Agent weiß, dass diese Person in jeder App den Dunkelmodus bevorzugt“
Das gehört zu Entwürfen für ein Langzeitgedächtnis.
Wie ein Langzeitgedächtnis aussähe
Langzeitgedächtnis sind Informationen, die bewusst über einen Thread hinaus aufbewahrt und später abgerufen werden. Für LEPA kämen etwa infrage: Ein Nutzer ist immer Lehrkraft, eine Schule nennt ihre Halbjahre „Semester A“, oder ein Problem beim Veröffentlichen von Noten wurde letzte Woche gelöst.
Ein grober Vergleich hilft, und es ist nur ein Vergleich.
Short-term ≈ RAM for this conversation
Long-term ≈ saved notes you can look up next month
Ein gespeichertes Protokoll des Threads abc-123 bleibt Thread-Gedächtnis. Gesprächsverlauf ist nicht automatisch Langzeitgedächtnis; dafür bräuchte es einen eigenen Speicher und eine Abrufstrategie. Das Zustandsobjekt hat sogar ein Feld für eine Gesprächszusammenfassung für später reserviert. Kein aktueller Knoten füllt es, es ist also eine Markierung für künftige Arbeit und keine Funktion.
Die Chat-Antwort und der Thread-ID-Vertrag
Der Chat-Endpoint liefert einen JSON-Body zurück, der unter anderem die Antwort, die Kategorie und ein Gedächtnis-Flag enthält.
{
"answer": "...",
"category": "grades",
"memory_updated": true,
"thread_id": "...",
"clarification_needed": false
}
Von Clients wird erwartet, dass sie die Thread-ID in der nächsten Runde zurückschicken. Lassen sie sie weg, erzeugt die API eine neue UUID, und ein neues Gespräch beginnt. Diese eine Regel macht das Gedächtnis explizit statt magisch.
Ein Vorbehalt zur Antwort: Das Gedächtnis-Flag ist in der aktuellen API immer true. Es zeigt nicht an, dass eine Langzeitzusammenfassung geschrieben wurde. Es sagt nur, dass die Anfrage durch den Graphen mit Checkpoints gelaufen ist.
Eine kleine Falle im Betrieb
SqliteSaver.from_conn_string ist ein Kontextmanager und schließt die Datenbank daher, sobald die Ausführung den with-Block verlässt. Für ein Skript funktioniert das, für einen langlebigen FastAPI-Prozess nicht. Deshalb öffnet eine Hilfsfunktion namens get_sqlite_checkpointer eine Verbindung, ruft setup auf und hält die Verbindung für die gesamte Lebensdauer der App offen.
Ein winziges Detail, das man leicht übersieht, das aber wichtig wird, sobald das Gedächtnis außerhalb eines Notebooks leben muss.
Der Gedächtnis-Stack in einer Zeile
Das Gedächtnis des Agenten besteht aus dem Zustand während des Laufs plus SQLite-Checkpoints zwischen den Läufen.
State during a run
+
SQLite checkpoints across runs
+
thread_id to find the right conversation
Für Support-Gespräche über mehrere Runden reicht das. Für ein echtes Langzeitgedächtnis reicht es nicht, und das offen zu sagen, gehört zur Lektion. Der nächste Schritt blickt über ein einzelnes Werkzeug samt Gedächtnis hinaus und klärt, warum der Workflow in die Phasen Aufnahme, Wissen und Support gegliedert ist.