🎁 Neu Kostenlos registrieren, 10 Aufrufe gratis. Bis zu 1 $, ohne Karte.
LLM-Chatbot bauen: Streaming, Kontextkompression, Memory

LLM-Chatbot bauen: Streaming, Kontextkompression, Memory

Chatbot Schritt für Schritt: Modellauswahl, echter Stop beim Provider, gemessenes Kontextbudget, Kompression ins Memory und Websuche per Gateway.

Unsere Empfehlungen

Live-Preise
Modell Fazit Preis
Gemini 3.6 Flash Schnell, ein echtes 1M-Kontextfenster (Treffer bei 972K abgerufen), und die auf minimal gesetzte Reasoning-Stufe senkte die gemessenen Kosten pro Aufruf bei einstufigen Chat-Aufgaben um 91-97%. Beste Standardwahl ab $1.5/Mio.
DeepSeek V4 Flash Die günstigste chatfähige Wahl auf dieser Seite, mit dem höchsten Rabatt für Cache-Lesezugriffe in der Tabelle, sodass das erneute Lesen eines langen Verlaufs fast nichts kostet. Außerdem der Standard-Zusammenfasser des MVP. Budgetwahl ab $0.138/Mio.
Claude Sonnet 5 Die stärkste Konsistenz bei Persona und Schreibstil unter den vier Modellen. Budgethinweis: Identischer Text wird in 41% mehr Tokens als bei Sonnet 4.6 tokenisiert; daher Tokenanzahlen statt Listenpreise vergleichen. Qualitätswahl ab $2/Mio.
GLM-5.2 Ein Tool-Aufruf mit warmem Cache kostete gemessen $0.0009 gegenüber $0.0051 bei claude-opus-4-8. Die mediane Latenz eines Aufrufs mit warmem Cache lag bei 6.6s; daher nur für Abläufe verwenden, bei denen der Nutzer mit einer Wartezeit rechnet. Günstige Tool-Aufrufe ab $1.4/Mio.
Inhalt
  1. Was muss ein MVP-Chatbot können?
  2. Wie ist der Chatbot aufgebaut?
  3. Welche Modelle gehören in die Auswahl?
  4. Was sieht das Modell in jedem Turn?
  5. Wie funktioniert Prompt Caching und welche Modelle unterstützen es?
  6. Woran erkennt man, dass das Kontextlimit näher rückt?
  7. Was passiert am Limit?
  8. Wie bleibt Memory über Sessions hinweg erhalten?
  9. Was passiert bei einem Klick auf Stop?
  10. Was passiert, wenn ein Turn fehlschlägt?
  11. Wie funktioniert Websuche ohne Tool-Loop im eigenen Code?
  12. Wie lässt sich Markdown streamen, ohne dass das Rendering zerbricht?
  13. Was kostet ein Gespräch?
  14. Wo befinden sich die Einstellungen?
  15. Was fehlt und wo würde es angebunden?
  16. Weitere Artikel

Dieser Leitfaden baut einen vollständigen Chatbot, der lokal in etwa zwei Minuten läuft und dessen Code sich an einem Nachmittag lesen lässt: ein FastAPI-Server, eine statische Seite, keine Datenbank, kein Build-Schritt. Wir gehen das Design der einzelnen Subsysteme durch, zeigen den Ablauf jedes Turns und beschreiben die Fehlerbilder, auf die wir beim Bau gestoßen sind. Den vollständigen Quellcode findest du unter github.com/synthorai-io/use-cases im Verzeichnis chatbot/. Alle Requests laufen über einen OpenAI-kompatiblen Endpoint. Die Modelle erscheinen daher einfach in einem Dropdown und brauchen keine getrennten Integrationen.

git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env    # put your API key in it
cd chatbot
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn server:app --reload

Die Demo mitten in einem Gespräch: Modellauswahl und Websuche-Schalter in der oberen Leiste, Session-Summen und segmentierte Kontextanzeige über dem Chat, eingeklappter Aktivitätsverlauf, Markdown-Antwort mit Quellen-Chips und Gesprächsleiste

Was muss ein MVP-Chatbot können?

Acht Dinge. Jedes davon verhindert ein konkretes Fehlerbild:

FunktionVerhindertes ProblemImplementierung
Dauerhaft gespeicherte Gespräche (auflisten, durchsuchen, umbenennen)ein Chat, der nach dem Neuladen alles vergessen hat, ist eine Demo und kein Werkzeugstorage.py
Modellauswahl mit Wechsel mitten im Gesprächein Modell, dessen Preis auf die schwierigste Frage ausgelegt ist, macht jede einfache Frage unnötig teuerconfig.py
Editierbare Persona mit Presetsder System-Prompt ist das Produkt; Änderungen dürfen kein Deployment erfordernpresets/
Streaming mit einem Stop, der den Provider erreichtStille vor dem ersten Token wirkt wie ein Defekt; ein vorgetäuschter Stop verursacht weiter Kostenserver.py
Kontextbudget mit Kompression am Limitjedes Modell hat ein Kontextfenster; unbemerktes Überschreiten lässt Bots scheinbar „dümmer“ werdencontext.py
Langzeit-Memorysich in jeder Session neu vorstellen zu müssen, ist eine der spürbarsten Schwächen eines Chatbotscontext.py + .data/memory.md
Websuche und Abruf von Webseitendas Modellwissen endet beim Training; Chatfragen beziehen sich oft auf aktuelle Informationentools.py
Kostentransparenz pro Turn und Sessiondie Kosten wachsen mit dem Verlauf; ohne Messwerte lässt sich nichts optimierenserver.py

Websuche und Seitenabruf brauchen eine ausführlichere Begründung, weil sie in MVP-Featurelisten oft als Erstes gestrichen werden. Das Wissen eines Chatmodells endet am Trainingsstichtag, also Monate vor dem aktuellen Datum. Chatfragen drehen sich jedoch häufig um Gegenwärtiges: Preise, Versionen, Releases oder „Unterstützt X inzwischen Y?“. Ein Chatbot, der solche Fragen aus Trainingsdaten beantwortet, liefert nicht nur schlechtere Ergebnisse. Er liegt selbstbewusst falsch, ohne dass Nutzer erkennen können, welche Antwort veraltet ist. Das Problem geht über fehlende Fakten hinaus: Beim Bau stellten wir fest, dass ein Modell ohne Datumsanker seinen Trainingsstichtag als „heute“ interpretiert. Dadurch sucht es falsch und setzt veraltete Jahreszahlen in die eigenen Suchanfragen. Retrieval ist für einen Chatbot kein optionales Extra. Es entscheidet, ob Nutzer mit einem statischen Wissensstand oder mit einem Assistenten arbeiten.

Warum zwei Tools statt einem? Suche und Abruf beantworten unterschiedliche Fragen. Eine Suche liefert einige hundert Zeichen lange Snippets, die sich regelmäßig widersprechen. Sie beantwortet: „Was gibt es dazu?“ Der Abruf lädt eine einzelne Seite vollständig und beantwortet: „Was steht auf genau dieser Seite?“ Nur so lässt sich ein exakter Wert verifizieren. Das eine Tool findet Quellen, das andere prüft sie. Das Modell entscheidet in jedem Turn, ob es eines der Tools braucht. Ohne Tool-Nutzung entstehen keine Zusatzkosten, und Limits begrenzen die Kosten bei Nutzung.

Auch die übrigen erwartbaren Funktionen sind enthalten: Regenerate, Markdown-Rendering ohne Brüche während des Streamings und klar benannte Wiederherstellungswege für Fehler. Die folgenden Abschnitte gehen die Liste grob von oben nach unten durch, entsprechend dem Weg eines Requests durch den Code.

Wie ist der Chatbot aufgebaut?

Aus drei Teilen: einer statischen Seite, sechs kleinen Python-Dateien und dem Gateway. Die Seite sendet eine Nachricht. Der Server baut den Kontext zusammen, komprimiert ihn bei Bedarf, streamt die Completion per Server-Sent Events zurück und speichert die gemessene Nutzung. Gespräche liegen als JSON-Dateien vor und lassen sich direkt mit cat lesen.

static/index.html   the chat UI (vanilla JS: picker, budget bar, markdown, activity trail)
server.py           routes; the per-turn pipeline: project → compress → stream → record
context.py          token budgeting, compression call, memory file, message assembly
tools.py            the /v1/messages transport used by tool-enabled turns
storage.py          one JSON file per conversation under .data/ (settings included)
config.py           env-driven settings: model lineup, budgets, prompts
presets/            system-prompt presets, one .txt each

Der wichtigste Teil ist die Pipeline pro Turn in server.py. Jede Nutzernachricht durchläuft dieselben fünf Schritte. Jeder Schritt entspricht einem der folgenden Abschnitte:

user message


[1] project the next request's size     last measured prompt_tokens
    │                                   + estimate(new message, pessimistic)
    │                                   + completion reserve

[2] over budget? ──yes──▶ compress      old turns ──▶ rolling summary
    │                                       └───────▶ durable facts ──▶ memory.md

[3] assemble and send                   [persona][memory][summary][date][history]
    │                                   cache mark after the system blocks

[4] stream SSE back to the page         delta / reasoning / search / fetch /
    │                                   compression notice / warning / error

[5] record measured usage               usage.prompt_tokens becomes step [1]'s
                                        input on the next turn

Der Ablauf bildet einen geschlossenen Regelkreis: Der in Schritt 5 gemessene Wert ist die Grundlage für Schritt 1 des nächsten Turns. Das Budget stützt sich damit auf die Tokenzahl, die die API tatsächlich gezählt hat, nicht auf eine lokale Schätzung.

Eine wichtige Architekturentscheidung vorab: Normale Turns gehen an /v1/chat/completions, Turns mit aktivierter Websuche oder aktiviertem Seitenabruf an /v1/messages. Diese Trennung ist keine Stilfrage. Der Abschnitt zu Tools erklärt, welches gemessene Verhalten sie erforderlich macht.

Welche Modelle gehören in die Auswahl?

In .env sind sieben Modelle vorkonfiguriert, in der Auswahl nach Klassen gruppiert: schnell und günstig, ausgewogen und Frontier. Über Settings kannst du jede vom Gateway bereitgestellte ID ergänzen; zusätzliche IDs werden in .data/models.json gespeichert. Wichtiger als die gesamte Auswahl ist das Standardmodell: Der MVP startet mit Gemini 3.6 Flash und setzt dessen Reasoning-Regler auf die niedrigste Stufe. Chatantworten sind einstufige Aufgaben. Bei solchen Aufgaben senkte reasoning_effort: "minimal" die gemessenen Kosten pro Call gegenüber der Standardeinstellung um 91-97%, ohne einen für Leser erkennbaren Unterschied in der Ausgabe. Die Einstellung liegt pro Modell in der Konfiguration, weil nicht jedes Modell sie akzeptiert:

# config.py — extra request params per model
MODEL_PARAMS: dict[str, dict] = {
    "gemini-3.6-flash": {"reasoning_effort": "minimal"},
}

DeepSeek V4 Flash hat gleich zwei Aufgaben: Es ist die Budgetoption in der Modellauswahl und der Standard-Summarizer für die Kompression. Auch Zusammenfassungen sind einstufige Aufgaben mit Fehlertoleranz bei der Qualität. Claude Sonnet 5 ist die Wahl, wenn Schreibqualität zum Produkt gehört. Kalkuliere es anhand der Tokenzahlen und nicht des Listenpreises, denn derselbe Text ergibt 41% mehr Tokens als bei Sonnet 4.6. GLM-5.2 eignet sich für Abläufe mit vielen Tool-Aufrufen: Ein warmer Tool-Call-Turn kostete gemessen $0.0009 gegenüber $0.0051 bei Claude Opus 4.8. Die mediane Dauer eines warmen Turns von 6.6s ist im Chatfenster allerdings deutlich spürbar.

Die Fähigkeiten jedes Modells werden gemessen, nicht vorausgesetzt. Der Code berücksichtigt die Unterschiede beim Routing, statt eine einheitliche Unterstützung vorzutäuschen. In dieser Auswahl streamen nur DeepSeek und GLM ihre Herleitung als reasoning_content. Die Claude-Modelle liefern über dieses Gateway keine Thinking-Blöcke, selbst wenn der Thinking-Parameter gesetzt ist. Die UI zeigt deshalb nur dort ein Live-Panel „Thinking“, wo tatsächlich Daten ankommen können.

Ein Modellwechsel mitten im Gespräch verursacht strukturell keinen Aufwand. Der Verlauf besteht aus providerneutralen Nachrichten im Format {"role", "content"}. Ein Gespräch kann günstig beginnen und bei einer schwierigen Frage auf das Qualitätsmodell wechseln. Ein realer Kostenpunkt bleibt unsichtbar: Beim Modellwechsel geht der Prompt Cache des vorherigen Modells verloren. Der erste Turn nach dem Wechsel muss den vollständigen Kontext zum kalten Preis erneut lesen.

Was sieht das Modell in jedem Turn?

Einen mehrschichtigen Prompt, der an einer Stelle und in einer bewusst gewählten Reihenfolge zusammengesetzt wird: die stabilsten Inhalte zuerst.

SchichtQuelleÄnderungshäufigkeit
System-Prompt (Persona)presets/*.txt oder freier Textnie, außer nach Bearbeitung
Langzeit-Memory.data/memory.mdselten (neue Fakten)
Laufende ZusammenfassungKompressionnur bei einer Kompression
DatumsankerServeruhrtäglich
Tool-AnweisungenSettings, nur bei Tool-Turnsselten
Aktuelle TurnsGesprächbei jedem Turn

Die Reihenfolge lautet „stabilste Inhalte zuerst“, weil sie das Caching verbessert. Die Persona bleibt unverändert, das Memory ändert sich selten, die Zusammenfassung nur bei einer Kompression und der Verlauf bei jedem Turn. Daher steht jede Schicht hinter allen Schichten, die sich seltener ändern.

Der Datumsanker ist notwendig, ebenso seine Position. Ohne Datumsanker behandelt ein Modell seinen Trainingsstichtag als „heute“. Es erzeugt Suchanfragen mit veralteten Jahreszahlen und interpretiert Release-Tabellen so, als wäre die neueste Zeile noch aktuell. Der Anker enthält bewusst nur ein Datum und keinen Zeitstempel. Er liegt im Prompt-Präfix; eine feinere Auflösung als ein Tag würde den Cache bei jedem Request ungültig machen. Außerdem folgt er auf Persona, Memory und Zusammenfassung, damit diese Schichten auch über Mitternacht hinweg im Cache bleiben.

Wie funktioniert Prompt Caching und welche Modelle unterstützen es?

Das Prinzip: Provider cachen das byte-identische Präfix eines Requests und liefern es danach mit hohem Rabatt aus. Beim ersten Schreiben fällt ein kleiner Aufschlag an. Chat ist dafür ideal, weil jeder Request aus dem vorherigen Request plus zwei Nachrichten besteht. Der gesamte bisherige Verlauf ist damit konstruktionsbedingt ein stabiles Präfix. Der Schreibaufschlag amortisiert sich bereits im nächsten Turn. Bei Anthropic-Modellen kostet das Schreiben für einen TTL von 5 Minuten 1.25x, das Lesen dagegen ungefähr 0.1x; die Wirtschaftlichkeit der Schreibseite haben wir hier gemessen. cache_control-Markierungen senkten die gemessenen Kosten über drei Claude-Klassen hinweg um 88-89%. Bei einem Gespräch mit echtem Memory und einer Zusammenfassung kostet ein späterer Turn ungefähr ein Zehntel eines kalten ersten Turns.

Allerdings bezeichnet „Caching“ nicht nur einen Mechanismus. Provider fallen in zwei Gruppen, und diese Modellauswahl enthält beide:

ModellCaching-ArtErforderliche AktionAusgewiesene Treffer
Claude-Familieexplizit: cache_control-BreakpointsMarkierung setzencache_read_input_tokens
DeepSeek V4 Flashimplizit: automatisches Präfixnichtsprompt_tokens_details.cached_tokens
GLM-5.2implizit: automatisches Präfixnichtsprompt_tokens_details.cached_tokens
Gemini 3.6 Flashimplizit; Markierung wird akzeptiert und ignoriertkeine wirksame Aktion möglichwird über dieses Gateway nicht ausgewiesen

Beim expliziten Caching nach dem Anthropic-Modell musst du den Breakpoint angeben, erhältst dafür aber genaue Messwerte: getrennte Felder für geschriebene und gelesene Tokens machen den Rabatt pro Turn prüfbar. Implizites Caching im OpenAI-Stil braucht keine Markierungen und greift automatisch bei einem wiederholten Präfix. Dafür bestimmt allein der Provider das Verhalten; als Nachweis bleibt im Nachhinein nur ein Feld cached_tokens. Wie zuverlässig dieses Caching greift, unterscheidet sich stark zwischen Providern. Der MVP bedient beide Gruppen gleichzeitig: Der Prompt wird mit den stabilsten Inhalten zuerst aufgebaut, worauf implizites Caching basiert. Zusätzlich wird immer die explizite Markierung gesendet, die von der impliziten Gruppe ohne Nebenwirkungen ignoriert wird. So erhält jedes Modell mit derselben Request-Struktur das Caching, das es unterstützt.

Die Position der expliziten Markierung ergab sich aus Messungen, nicht aus der Dokumentation: Auf diesem Gateway wird cache_control in einem System-Block berücksichtigt und an jeder anderen Position still ignoriert. Das verbreitete Multi-Turn-Muster, die neueste Nutzernachricht zu markieren und damit den gesamten Verlauf cachebar zu machen, meldet null gelesene Cache-Tokens und berechnet den vollen Preis. Der MVP markiert daher das Ende des Systemabschnitts. Das cachebare Präfix besteht aus Persona, Memory und Zusammenfassung. Daraus folgen zwei Konsequenzen. Caching greift erst, wenn das Präfix das Minimum des Modells überschreitet, ungefähr 1,024 Tokens. Ein neues Gespräch cachet deshalb nichts; ein Gespräch mit gewachsenem Memory und einer Zusammenfassung cachet alles. Außerdem verursacht jede Änderung am Präfix Kosten: Eine Änderung am System-Prompt macht den Cache ab dem ersten Byte kalt, eine Kompression schreibt die Zusammenfassung neu und verursacht einen kalten Lesevorgang, der weiter unten beschrieben wird. Ein Modellwechsel verwirft den Cache des vorherigen Modells vollständig.

Der TTL ist überall eine Konfigurationsentscheidung mit demselben Muster: Der standardmäßige Cache für 5 Minuten deckt ein aktives Gespräch ab. Die 1-Stunden-Stufe deckt Nutzer ab, die zwischenzeitlich weg waren, verdoppelt aber den Schreibaufschlag. CACHE_TTL=1h ist die nötige Ein-Zeilen-Änderung. Ob sie sich lohnt, hängt davon ab, ob Nutzer tatsächlich innerhalb einer Stunde zurückkehren.

Woran erkennt man, dass das Kontextlimit näher rückt?

Messen statt raten: Die einzige Tokenzahl, die für das gerade angesprochene Modell stimmt, ist usage.prompt_tokens aus dem vorherigen API-Request. Lokale Schätzer liefern zwischen Providern irreführende Werte, und die Abweichungen sind erheblich. Derselbe englische Text ergibt selbst bei zwei Modellen desselben Anbieters 41% unterschiedliche Tokenzahlen (unsere Messung); zwischen Anbietern ist die Differenz noch größer. Der MVP zählt lokal nur die Tokens, welche die API noch nicht gesehen hat: die Nachricht, die gerade gesendet wird. Diese Schätzung wird bewusst aufgerundet:

# context.py
def needs_compression(last_prompt_tokens, pending_text, message_count, budget=None):
    if message_count <= config.KEEP_RECENT_MESSAGES:
        return False  # nothing old enough to fold away
    projected = (
        last_prompt_tokens                    # measured, last response
        + estimate_tokens(pending_text)       # estimated, pessimistic
        + config.MAX_COMPLETION_TOKENS        # worst-case reply
    )
    return projected > (budget or config.CONTEXT_BUDGET_TOKENS)

Eine Überschätzung löst die Kompression einen Turn zu früh aus und kostet einen günstigen Zusammenfassungs-Call. Eine Unterschätzung überschreitet das Kontextfenster und verursacht einen fehlgeschlagenen oder unbemerkt gekürzten Request. Diese Asymmetrie bestimmt die Rundungsrichtung.

Auch beim Messen gibt es eine Falle: Provider sind sich uneinig, ob prompt_tokens die gecachten Tokens bereits enthält. Manche melden den vollständigen Prompt, andere nur das ungecachte Delta. Ein Fehler an dieser Stelle ist nicht kosmetisch, denn dieser Wert bildet die Grundlage des Budgets. Wird er um die Größe des Caches zu niedrig angesetzt, löst die Kompression nie aus und das Kontextfenster läuft unbemerkt über. Der Server normalisiert die Werte. Wenn prompt_tokens kleiner als die gemeldete Anzahl gecachter Tokens ist, kann es nicht den gesamten Prompt darstellen; in diesem Fall werden die Teile addiert. Zusätzlich verwendet der Server die eigene Schätzung als Untergrenze für den Messwert. Welche Werte usage je nach Provider tatsächlich liefert, behandeln wir separat in Anatomie der LLM-Token-Nutzung.

Das Standardbudget beträgt 102,400 Tokens, was ein normales Gespräch nicht erreichen wird. Es ist ein Arbeitsbudget und kein Modelllimit. Der richtige Wert liegt dort, wo ein weiterer Turn mehr kostet, als er wert ist. Um den Mechanismus zu beobachten, kannst du in Settings das Fenster für ein einzelnes Gespräch auf einige Tausend Tokens senken und mehrere lange Nachrichten einfügen. Das Kontextdiagramm im Header basiert auf dem Messwert und zeigt segmentiert, was das Fenster belegt: Persona, Memory, Zusammenfassung und Verlauf.

Die Header-Leiste: Session-Summen mit Cache-Anteil und Kosten sowie ein Kontextdiagramm, das System, Memory, Zusammenfassung und Verlauf innerhalb eines Budgets von 16k segmentiert

Was passiert am Limit?

Komprimieren statt abschneiden. Beim Abschneiden vergisst der Bot den Gesprächsanfang, was Nutzer als nachlassende Intelligenz wahrnehmen. Stattdessen werden alle Nachrichten außer den neuesten, standardmäßig den letzten 8, vom günstigen Summarizer-Modell in eine laufende Zusammenfassung überführt. Diese Zusammenfassung wird als System-Block mitgesendet. Das aktuelle Fenster bleibt unverändert erhalten, damit sich die kurzfristige Stimme des Bots nicht ändert. Nur die weiter zurückliegende Vergangenheit wird verlustbehaftet komprimiert. Nichts verschwindet still: Wenn die Kompression ausgeführt wird, erscheint im Verlauf ein Hinweis mit der Anzahl der Nachrichten und der Größe der Zusammenfassung.

Ein vollständiger Kompressionsvorgang:

before   (projected next request 103.1k > 102.4k budget)

  [persona][memory][date][ m1 ..................... m34 │ m35 ....... m42 ]
                            old enough to fold            last 8, kept

one summary call to deepseek-v4-flash:
  in:   prior summary + m1 ... m34
  out:  {"summary": "one dense paragraph: topics, decisions,
                     open questions, promises",
         "facts":   ["prefers Python", "timezone is UTC+8"]}

after

  [persona][memory + new facts][summary][date][ m35 ....... m42 ]
   unchanged  grows rarely      replaced         verbatim

Drei Designentscheidungen im Kompressions-Call bestimmen einen Großteil seiner Zuverlässigkeit:

  • Die neue Zusammenfassung nimmt die vorherige auf. Bei der zweiten Kompression werden die erste Zusammenfassung und die neu hinzugekommenen alten Nachrichten gemeinsam verarbeitet. So bleibt es bei einem laufenden Absatz, statt eine unbegrenzt wachsende Kette aus Zusammenfassungen von Zusammenfassungen zu erzeugen.
  • Ein Fehler verwirft nicht den Turn. Schlägt der Zusammenfassungs-Call fehl, entfernt der Server die ältesten Turns ohne Zusammenfassung, teilt dem Nutzer genau mit, was verloren ging, und beantwortet die Nachricht trotzdem. Unvollständiges Memory ist besser als ein ausgefallener Chatbot. Gerade der Fehlerpfad, der in Demos nie läuft, löst in Produktion einen Pager aus.
  • Der Prompt ist konfigurierbar, mit einer Schutzprüfung. Die Anweisung an den Summarizer bestimmt, woran sich das Gespräch erinnert. Deshalb lässt sich der Kompressions-Prompt pro Gespräch bearbeiten. Der Server lehnt Änderungen ab, wenn der Platzhalter {transcript} entfernt wurde. Ein solcher Prompt würde nichts zusammenfassen, und der Fehler würde erst viel später beim ersten Überlauf sichtbar. Aus demselben Grund hat COMPRESS_MAX_TOKENS mehr Spielraum, als die Prompt-Anweisung verlangt. Eine mitten im Satz abgeschnittene Zusammenfassung wird sonst an jeden späteren Turn weitergegeben.

Für den Cache verursacht eine Kompression genau einen kalten erneuten Lesevorgang: Der Zusammenfassungsblock ändert sich, deshalb ist nach dem Memory-Block für einen Request alles kalt. Danach wird das neue, kleinere Präfix wieder gecacht. Die ehrliche Kostenrechnung lautet: Ein Zusammenfassungs-Call plus ein kalter Lesevorgang sorgt dafür, dass alle folgenden Turns auf einem kleineren, warmen Kontext abgerechnet werden.

Wie bleibt Memory über Sessions hinweg erhalten?

Aus größerer Entfernung betrachtet verwendet der Bot drei Memory-Speicher. Sie unterscheiden sich in Gültigkeitsbereich und Lebensdauer; daraus folgen alle Designentscheidungen dieses Abschnitts:

SpeicherGültigkeitsbereichLebensdauerGröße im Request
Aktuelle Turns, unverändertdieses Gesprächbis sie durch Kompression übernommen werdenvollständiger Text der letzten 8 Nachrichten
Laufende Zusammenfassungdieses Gesprächendet mit dem Gesprächein Absatz mit weniger als 200 Wörtern
Fakten in memory.mdalle Gesprächebis zur manuellen Bereinigungeinige Zeilen

Zusammenfassung und Fakten sehen ähnlich aus, altern aber unterschiedlich und bleiben deshalb getrennt. Die laufende Zusammenfassung beschreibt das Gespräch: Entscheidungen, offene Fragen und Zusagen des Assistenten. Sie endet richtigerweise mit dem Gespräch. Dauerhafte Fakten über den Nutzer wie „bevorzugt Python“ oder „Zeitzone ist UTC+8“ gelten auch in der nächsten Woche. Sie mit dem Gespräch zu löschen, wäre Verschwendung.

Der Kompressions-Prompt fragt beides gleichzeitig ab und liefert JSON zurück: einen String summary und ein Array facts. Die Fakten werden an .data/memory.md angehängt. Jedes Gespräch lädt diese Datei als System-Block. Die Extraktion erfolgt während der Kompression statt in einem eigenen Durchlauf. Der Grund sind die Kosten: Bei der Kompression liest ein Modell die alten Turns ohnehin erneut. Die Faktenextraktion nutzt also Tokens, die bereits bezahlt werden. Ein Call, zwei Ergebnisse.

Die Deduplizierung des MVP vergleicht Zeilen exakt. Die Einschränkung wird schnell sichtbar: Eine Kompression speichert „User prefers Python over Node.js“, eine spätere ergänzt „The user prefers Python over Node.js.“ Semantische Deduplizierung würde bedeuten, jeden Kandidaten einzubetten oder per LLM mit dem Speicher zu vergleichen. Das ist eine echte Funktion mit echten Kosten. Der MVP verwendet daher bewusst die einfache Variante und benennt deren Grenzen. Die Memory-Datei ist gerade deshalb Markdown, damit Menschen sie lesen und bereinigen können. Settings zeigt sie als editierbares Textfeld an. Damit dient es gleichzeitig als Transparenzansicht: Was der Bot über dich weiß, steht in einer Datei, die du öffnen kannst.

Was passiert bei einem Klick auf Stop?

Der Provider beendet die Generierung. Genau darin besteht die Funktion. Viele Chat-UIs tun das nicht: Ein Stop, der nur die Ausgabe ausblendet, während die Completion weiterläuft, berechnet Tokens, die niemand lesen wird.

Die Kette besteht aus drei Gliedern. Während eine Antwort streamt, wird der Send-Button zum Stop-Button, an derselben Position und ohne Bestätigung. Ein Klick bricht den Fetch des Browsers ab. Der Server prüft zwischen Events, ob die Verbindung getrennt wurde, beendet dann seine Streaming-Schleife und schließt dadurch die Upstream-Verbindung. Der Provider hält daraufhin an. Bereits empfangener Text wird gespeichert, als interrupted markiert und im Verlauf mit gestricheltem Rand dargestellt. Derselbe Ablauf greift beim Schließen des Tabs oder bei einem Verbindungsabbruch, weil diese Ereignisse aus Sicht des Servers identisch sind.

Die Persistierung muss hier genau einmal erfolgen, und der Code behandelt das ausdrücklich: Bei einem sauberen Abschluss wird innerhalb der Streaming-Schleife gespeichert, bei einem beschädigten Stream im Exception-Handler und bei einem harten Verbindungsabbruch im finally-Block. In diesem Fall bricht das Framework den Generator ab und Code hinter der Schleife wird nicht mehr ausgeführt. Die Funktion ist idempotent, deshalb gewinnt der Pfad, der zuerst ausgeführt wird.

Stop gehört mit Regenerate zusammen: Unterbrich eine unerwünschte Antwort und erzeuge sie neu, ohne die Frage noch einmal einzugeben. Regenerate entfernt nachgestellte Assistant-Turns, einschließlich eines unterbrochenen Turns. Das Gespräch endet dadurch wieder mit der Nutzernachricht, die anschließend mit dem aktuell gewählten Modell neu beantwortet wird. Da die Modellauswahl für den nächsten Turn gilt, kannst du über Stop und Regenerate dieselbe Frage auch an ein stärkeres Modell stellen.

Was passiert, wenn ein Turn fehlschlägt?

Eines von sechs Dingen. Jedes Fehlerbild hat einen eigenen Wiederherstellungsweg statt einer generischen roten Zeile. classify() in server.py ordnet Upstream-Exceptions einer Fehlerklasse zu; die UI weist diesen Klassen Aktionen zu:

FehlerAnzeige für den NutzerWiederherstellung
Netzwerkfehlerals solcher benanntRetry-Button
Rate LimitWartezeit aus retry-after, falls vorhandenRetry-Button
Ungültiger API-KeyName der zu korrigierenden Umgebungsvariable.env bearbeiten
Content-Filter„Derselbe Text wird bei einem erneuten Versuch wieder abgelehnt“Bearbeiten und erneut senden
Abbruch mitten im StreamTeilantwort bleibt erhalten und wird als unterbrochen markiertRetry
Kompression fehlgeschlagenWarnung mit den verworfenen Inhaltenkeine Aktion nötig; der Turn läuft weiter

Zwei Invarianten leisten dabei den größten Teil der Arbeit. Erstens geht die Nutzernachricht nie verloren. Schlägt der Request vor dem ersten Event fehl, entfernt der Server die Nachricht wieder aus dem Gespräch und setzt den Entwurf in den Composer zurück. Ein Retry kann sie dadurch nicht doppelt senden. Bricht der Stream nach dem Empfang von Text ab, bleibt die Teilantwort erhalten und wird markiert. Zweitens erhält eine abgelehnte Antwort rewind statt Retry: Der Server nimmt die Nutzernachricht aus dem Verlauf und setzt sie zur Bearbeitung in den Composer zurück. Ein identischer Retry gegen einen Content-Filter würde dauerhaft auf dieselbe Weise fehlschlagen.

Hinter beiden Fällen steht eine subtilere Speicherregel: Ein leerer Assistant-Turn wird nie gespeichert. Wird eine leere Assistant-Nachricht erneut upstream gesendet, verletzt sie die von manchen Providern verlangte Abfolge zwischen User und Assistant. Der dadurch ausgelöste Fehler erscheint erst in einem späteren Turn und verweist dann auf die falsche Nachricht, was die Fehlersuche erheblich erschwert. Der MVP entfernt leere Inhalte aus dem Wire-Format und speichert keine Antworten ohne Text. Ausgenommen sind Turns mit Such- oder Reasoning-Aktivitäten, die erhalten bleiben sollen; in diesem Fall wird die Aktivität gespeichert und der leere Text beim Senden entfernt.

Wie funktioniert Websuche ohne Tool-Loop im eigenen Code?

Das Gateway führt die Tools serverseitig aus. Dadurch verlagert sich die Arbeit aus deinem Code. Klassisches Function Calling verwendet eine Schleife, die du selbst implementierst: Das Modell liefert einen tool_calls-Block, dein Code führt das Tool aus, hängt eine tool_result-Nachricht an und sendet das gesamte Gespräch erneut, einmal pro Aufruf. Serverseitige Tools verschieben diese Schleife ins Gateway. Du deklarierst synthorai:web_search oder synthorai:web_fetch im Request. Das Gateway vermittelt zwischen LLM und Tool-Providern, leitet die Tool-Aufrufe des Modells an externe Such- und Abruf-APIs weiter und fügt die Ergebnisse wieder in den Modellkontext ein. An den Tools ist nichts magisch: Jede Suche und jeder Abruf ist selbst ein Aufruf an eine externe API und wird deshalb pro Nutzung berechnet. In dieser Codebasis gibt es keinen Roundtrip mit tool_result, sondern nur Events zur Darstellung:

browser        server (tools.py)     Synthorai gateway       LLM / tool providers
   │ POST /chat    │                     │
   ├──────────────▶│ declare tools +     │
   │               │ budget note         │
   │               ├────────────────────▶│── question + tools ──▶ [LLM]
   │               │                     │◀── tool_use: search ── [LLM]
   │               │                     │── query ─────────────▶ [search API]
   │               │  search results     │◀── results ─────────── [search API]
   │ SSE: search ◀─┤◀────────────────────┤── results ───────────▶ [LLM]
   │               │                     │◀── tool_use: fetch ─── [LLM]
   │               │                     │── URL ───────────────▶ [fetch API]
   │               │  fetch result       │◀── page text ───────── [fetch API]
   │ SSE: fetch ◀──┤◀────────────────────┤── page text ─────────▶ [LLM]
   │ SSE: delta ◀──┤◀────────────────────┤◀── answer tokens ───── [LLM]
   │ SSE: done     │                     │

Die Rollen sind klar getrennt: Das Modell entscheidet, ob es überhaupt sucht, ob ein Snippet die Frage beantwortet oder eine Seite abgerufen werden muss und wann es die Suche beendet und die Antwort schreibt. Das Gateway führt aus, ruft also den Such- oder Abruf-Provider auf und gibt das Ergebnis an das Modell zurück. Dein Server rendert nur die vorbeiströmenden Events. Beide Tools sind standardmäßig aktiviert. Ein Turn, der keines davon benötigt, verursacht keine Zusatzkosten. Reine Rechenfragen bleiben damit kostenlos, während Fragen wie „Was ist aktuell …?“ eine Suche auslösen. Auch die Schleife auf Gateway-Seite ist begrenzt: Wenn die serverseitige Suchschleife ihr eigenes Iterationslimit erreicht und pausiert (pause_turn), sendet der Transport den Turn zum Fortsetzen zurück, höchstens dreimal.

Vier Erkenntnisse aus der Implementierung sind wichtiger als der Happy Path:

  • Der Endpoint bestimmt, was sichtbar ist. Beide Endpoints führen die Suche aus und berechnen sie. Das haben wir am 2026-08-04 sowohl über Anthropic- als auch Gemini-Kanäle gemessen. Nur /v1/messages stellt die Suche selbst bereit: Suchanfrage und Ergebnis-URLs kommen als typisierte Blöcke an und lassen sich rendern und speichern. Bei /v1/chat/completions läuft dieselbe Suche unsichtbar. Der Server liefert HTTP 200 und eine Antwort, die mit „Basierend auf den Suchergebnissen …“ beginnt, aber weder im Streaming- noch im Non-Streaming-Pfad erscheinen Quellenangaben oder Annotationen. Eine bezahlte, aber nicht prüfbare Suche ist eine stille Verschlechterung und damit das schlimmste Fehlerbild: Nichts schlägt fehl, nur die Herkunft der Antwort verschwindet. Genau deshalb existiert tools.py als zweiter Transport, statt die normale Anfrage nur um ein Feld zu ergänzen.
  • Das Modell muss sein Budget ausdrücklich kennen. Die Limits, standardmäßig 3 Suchen und 2 Abrufe pro Turn, werden vom Gateway still durchgesetzt. Kennt das Modell sie nicht, plant es mit unbegrenzten Tools und verbraucht den letzten Aufruf mitten in der Bearbeitung. Der Turn endet dann mit „Lass mich danach suchen …“ und liefert keine Antwort. Der MVP fügt einen Satz mit dem Budget ein. Dessen Formulierung ist ein messbarer Kostenhebel: Ohne Hinweis wurde im Test das gesamte Tool-Budget verbraucht und der Turn endete mitten im Satz. Eine zu strenge Formulierung ließ das Modell aufgeben und den Nutzer zum Lesen der Seite auffordern. Die ausgelieferte Formulierung übersprang die Suche vollständig, rief nur die beiden maßgeblichen Seiten ab und verursachte von den drei Varianten die niedrigsten Kosten. Der Text lässt sich in Settings bearbeiten; ändere ihn und beobachte die Kostenzeile pro Turn.
  • Limits sind die einzige Bremse. Beide Tools werden pro Nutzung berechnet, und das Modell entscheidet über die Anzahl der Durchläufe. Ein unbegrenzter Turn führte im Test drei Suchen und zwei Seitenabrufe aus, bevor auch nur ein einziges Output-Token berechnet wurde.
  • Bei Fehlern degradieren, nicht ausfallen. Zwei Fehler werden gleich behandelt: Tool entfernen, einmal erneut versuchen und den Nutzer informieren. Web Fetch erfordert eine Berechtigung auf dem Key. Fehlt sie, schlägt der gesamte Request mit web_fetch_not_enabled fehl, statt ohne Tool weiterzulaufen. Gemini akzeptiert die Tool-Deklaration, löst aber beim tatsächlichen Aufruf einen Fehler aus (Function call is missing a thought_signature). In beiden Fällen ist es die falsche Entscheidung, den gesamten Turn wegen eines optionalen Tools zu verlieren.

Alle Schritte des Modells auf dem Weg zur Antwort, also Denken, Suchen und Lesen, werden live in einen Aktivitätsverlauf oberhalb der Antwort gestreamt. Eingeklappt erscheint eine unaufdringliche Zeile wie „5s nachgedacht · Web durchsucht · 2 Seiten gelesen“. Ausgeklappt zeigt sie eine Timeline mit Suchanfragen, Ergebnis-Domains und Reasoning-Text. Der Verlauf wird zusammen mit der Nachricht gespeichert. Genau darauf kommt es an: Ein Verlauf, der nach dem Neuladen verschwindet, taugt nicht zur späteren Prüfung einer Antwort. Quellen erscheinen als nummerierte Domain-Chips unter der Antwort.

Ein ausgeklappter Aktivitätsverlauf über einer Antwort: Suchschritt mit Anfrage und zwei Ergebnissen, Abrufschritt mit Seite und Größe sowie Quellen-Chips darunter

Wie lässt sich Markdown streamen, ohne dass das Rendering zerbricht?

Indem der bisher empfangene Text in ein sicher renderbares, stabiles Präfix und einen noch unsicheren Rest zerlegt wird. Das Rendern einer halboffenen Struktur lässt das Layout springen, sobald das nächste Token sie schließt. Deshalb schneidet der Renderer an der letzten vollständigen Zeile ab. Hat ein öffnender Code-Fence noch keinen Abschluss, bleibt ab diesem Fence alles Klartext, bis er geschlossen ist. Die Bubble wird nur dann neu gerendert, wenn das stabile Präfix tatsächlich wächst. Das Streaming baut dadurch nicht für jedes Token das DOM neu auf. Codeblöcke erhalten einen Copy-Button. Der Renderer besteht aus hundert Zeilen Vanilla JS ohne Library. Das beschreibt den Bedarf eines MVP, nicht den Wert von Markdown-Libraries.

Was kostet ein Gespräch?

Genau das, was das Gateway unter usage meldet. Die Aufgabe des MVP besteht darin, diese Werte korrekt auszuwerten. Zwei Normalisierungsprobleme erwiesen sich als entscheidend:

  • Provider verwenden unterschiedliche Feldnamen. Allein für gecachte Tokens melden Anthropic-Modelle cache_read_input_tokens, DeepSeek und GLM nur prompt_tokens_details.cached_tokens und Gemini keines von beiden. Die Statistikzeile liest das jeweils vorhandene Feld, damit „gecacht“ überall dasselbe bedeutet.
  • Fehlende Kosten sind nicht null. Das Gateway meldet bei manchen Turns cost und lässt das Feld bei anderen weg. Das lässt sich beispielsweise bei Turns reproduzieren, in denen ein Tool deklariert, aber nicht verwendet wird. Turns ohne Kostenfeld werden gezählt und als „+N nicht ausgewiesen“ dargestellt, statt mit null in die Summe einzugehen. Eine Summe, die Turns still auslässt, sieht sonst wie die Gesamtrechnung aus, obwohl sie nur eine Untergrenze ist.

Der Header zeigt laufende Session-Summen für Input, Output, Cache-Anteil, Suchen, Abrufe, Kosten und Turns. Jede Antwort hat zusätzlich eine eigene Statistikzeile. Der Cache-Anteil ist der wichtigste Wert: Er macht das oben beschriebene Caching im eigenen Gespräch messbar.

Wo befinden sich die Einstellungen?

In einem einzigen Settings-Panel. Die übertragbare Designentscheidung ist der Gültigkeitsbereich: Fast alle Einstellungen gelten nur für ein einzelnes Gespräch.

Das Settings-Panel: Modellauswahl als entfernbare Chips mit Eingabefeld, Kontextfenster pro Gespräch, Preset-Dropdown über dem editierbaren System-Prompt und darunter der Memory-Bereich

Nur zwei Einstellungen sind global, weil sie den Nutzer statt eines Gesprächs beschreiben: die Modellauswahl, zu der jede vom Gateway bereitgestellte ID hinzugefügt werden kann und die in .data/models.json gespeichert wird, sowie die Langzeit-Memory-Datei, die als editierbares Textfeld sichtbar ist. Alles andere gilt für das geöffnete Gespräch: der System-Prompt mit den Presets aus presets/ als Dropdown, das Kontextbudget, die Schalter für Websuche und Seitenabruf, die Tool-Anweisungen und der Kompressions-Prompt. Zwei Gespräche können dadurch parallel mit unterschiedlichen Personas, Budgets und Tools auf dieselben Modelle zugreifen. So kannst du Behauptungen aus diesem Leitfaden reproduzieren, statt sie ungeprüft zu übernehmen.

Jedes Feld hat eine kleine Info-Markierung. Beim Hovern erscheint eine Vorschau, per Klick bleibt sie geöffnet. Sie erklärt die Kosten einer Änderung, denn bei den meisten Reglern sind diese in der UI sonst nicht sichtbar. Eine Bearbeitung der Persona invalidiert den Cache ab dem ersten Byte und kostet daher einen kalten Turn. Eine neue Formulierung der Tool-Anweisungen verändert die Kosten pro Turn. Ein niedrigeres Budget löst die Kompression früher aus. Nur die Modellauswahl in der oberen Leiste speichert sofort. Ein Modellwechsel mitten im Gespräch ist eine reguläre Aktion und keine Konfigurationsänderung.

Was fehlt und wo würde es angebunden?

Bewusst ausgelassene Funktionen mit ihren jeweiligen Anbindungspunkten:

  • Auth und Multi-User-Unterstützung — eine Session-Schicht vor den Routes. Gespräche haben bereits IDs, deshalb reicht für die Zuordnung zu Nutzern ein Präfix im Dateinamen statt eines Redesigns. Bis dahin ist dies ein localhost-Werkzeug: Jeder bediente Request verbraucht deinen API-Key. Stelle es in dieser Form nicht ins öffentliche Internet.
  • Rate Limiting — an derselben Stelle und aus demselben Grund. Es schützt ein gemeinsam genutztes Deployment, und dieses MVP ist noch keines.
  • RAG — abgerufene Dokumente gehören hinter die Zusammenfassung und vor die aktuellen Nachrichten. Veränderliche Inhalte stehen spät im Präfix, damit sie die gecachten Persona- und Memory-Blöcke nicht laufend ungültig machen. Hier zahlen sich modellseitig die Kontextfenster der 1M-Modelle aus.
  • Clientseitiges Function Calling — die Streaming-Schleife erhält einen tool_calls-Zweig und einen Executor. Der Beitrag zu GLM-5.2 beschreibt die Unterschiede der providerübergreifenden Contracts, die dort berücksichtigt werden müssen. Die oben beschriebenen Gateway-Tools vermeiden diese Schleife bewusst.
  • Semantische Memory-Deduplizierung — jeden neuen Faktenkandidaten einbetten, mit dem Speicher vergleichen und nur neue Fakten behalten. Das Format der Memory-Datei bleibt unverändert.
  • Syntax-Highlighting — der Copy-Button ist die Funktion, die tatsächlich verwendet wird. Highlighting ist eine Library-Entscheidung für ein echtes Frontend.
  • Eine echte Datenbankstorage.py besteht aus wenigen Funktionen. Die Portierung auf SQLite dauert einen Nachmittag. Bis echte Nutzer vorhanden sind, erfüllen die JSON-Dateien ihren Zweck.

Weitere Artikel

← Anwendungsfälle