Teil 3 · Zwei-Phasen-Streaming: Zeigen, wie das Modell denkt, bevor es handelt

Apr. 30, 2026·
Ndimofor Aretas
Ndimofor Aretas
· 8 Min Lesezeit
blog AI Engineering

Teil einer Serie über den Aufbau von Gemma CogniVault. Zuvor: Hybrid-Retrieval mit FAISS + BM25 + RRF. Alle Abkürzungen werden vollständig im Anhang am Ende der Seite erklärt.

Als ich Gemma 4 zum ersten Mal mit Strands Agents in CogniVault verkabelt habe, fühlte sich der Chat langsam an. Nicht laggy — langsam auf eine Art, die schlimmer ist als laggy. Der User tippt eine Frage ein. Der Cursor sitzt da und macht nichts. Dann, irgendwann, fällt eine Antwort aus dem Nichts.

Das Modell war nicht untätig. Es hat nachgedacht. Gemma 4 hat einen Chain-of-Thought-Modus, der einen (manchmal langen) Gedankengang produziert, bevor die finale Antwort kommt. Bei einem einphasigen Agenten-Stream passiert dieses ganze Nachdenken innerhalb der Agenten-Loop — still und heimlich — bevor irgendwelche Tool-Aufrufe laufen oder irgendwelche Tokens an die UI gesendet werden.

Also habe ich den Aufruf in zwei Phasen unterteilt.

Die Struktur

POST /rag
  ├── Phase 1 — Direkter Ollama-Aufruf, Thinking aktiviert
  │     stream: {"type":"thinking","data":"..."}    (Reasoning-Tokens)
  └── Phase 2 — Strands Agent (Thinking deaktiviert)
        stream: {"type":"metadata","data":{...}}    (Quellenangaben, sobald die Suche läuft)
        stream: {"type":"text","data":"..."}        (Antwort-Tokens)
        stream: {"type":"memory","data":{...}}      (End-of-Stream: Speicherverbrauch der Session)

Der Endpoint streamt Newline-Delimited JSON (NDJSON): Jede Zeile im Response-Body ist ein eigenständiger JSON-Umschlag mit einem type und einem data. Das Frontend entscheidet anhand des type und rendert entsprechend: ein ausklappbares Reasoning-Panel für die Thinking-Tokens, die Hauptnachrichten-Blase für die Text-Tokens und eine Sidebar-Card pro Quelle.

Der User sieht das Modell sofort anfangen zu denken. Die Latenz bis zum ersten Byte sinkt von “lang genug, um sich zu fragen, ob es abgestürzt ist” zu “sofort”. Die Gesamtzeit bis zur finalen Antwort ändert sich nicht. Aber die gefühlte Geschwindigkeit schon.

Phase 1 — Nur Nachdenken

Phase 1 ist ein einzelner direkter Aufruf an Ollama mit aktiviertem Thinking. Er bekommt exakt das, was auch Phase 2 sehen wird — denselben System-Prompt, die aktuelle Frage und alle angehängten Bilder —, sodass die Argumentation die Realität widerspiegelt. Nur die Reasoning-Tokens werden konsumiert; was auch immer an Antworttext Phase 1 zu produzieren beginnt, wird verworfen, weil wir nicht wollen, dass eine halbfertige Antwort mit der echten konkurriert.

# Simplified from backend/services/rag_agent.py
client = ollama.AsyncClient(host=settings.ollama_host)
stream = await client.chat(
    model=settings.llm_model,
    messages=[
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": query, "images": images},
    ],
    options={"thinking": True},
    stream=True,
)
async for chunk in stream:
    if chunk.message.thinking:
        yield envelope("thinking", chunk.message.thinking)

Phase 1 ist absichtlich Best-Effort: Jeder Fehler hier wird einfach geschluckt und geloggt, und der Stream geht direkt über zu Phase 2. Ein kaputtes Reasoning-Panel sollte den User niemals seine Antwort kosten.

Phase 2 — Agent mit Tools

Phase 2 baut einen frischen Strands Agent pro Request auf — kein geteilter veränderlicher Zustand zwischen gleichzeitigen Chats —, stellt die Konversationshistorie der Session darin wieder her und führt die Tool-Loop mit sechs registrierten Tools aus:

ToolZweck
search_knowledge_base(query)Hybride FAISS + BM25 Suche, Top-7, RRF Fusion. Scope-Filter-aware.
list_documents()Bestandsaufnahme jeder indizierten Datei mit Typ und Chunk-Anzahl.
analyze_document(filename)Innerer Gemma-Aufruf → strukturierte Zusammenfassung (Themen, Entitäten, Fakten).
compare_documents(doc_a, doc_b, question)Innerer Gemma-Aufruf, der dokumentübergreifend antwortet.
calculator(expression)Sicherer AST-Evaluator — kein eval(), kein beliebiger Code.
current_time()Zeitstempel für zeitbewusste Fragen.

Der Agent entscheidet, welche Tools er in welcher Reihenfolge aufruft. Es gibt keinen hart codierten Router; der System-Prompt erklärt, was verfügbar ist, und Strands kümmert sich um die Schleife. Für die meisten Dokumentenfragen ist der Weg: search_knowledge_base → Antwort. Für Vergleiche: compare_documents → Antwort. Für “Welche Dateien habe ich?”: list_documents → Antwort. Für Begrüßungen und einfache Mathematik sagt der System-Prompt dem Agenten, dass er die Suche komplett überspringen darf. Das Modell wählt selbst.

Zwei Details, deren Debugging Zeit gekostet hat, um sie richtig hinzubekommen:

  • Phase 2 läuft mit explizit deaktiviertem Thinking. Ohne dieses Flag kann Gemmas Standardverhalten <think>…</think>-Tags in die sichtbare Antwort durchsickern lassen, und alles vor dem schließenden Tag wird vom Markdown-Renderer verschluckt. Eine Modelloption — options={"thinking": False} — behob einen Bug mit “abgeschnittenen Antworten”, der viel unheimlicher aussah, als er tatsächlich war.
  • Zitate werden vor dem ersten Antwort-Token rausgeschrieben. Tools laufen, bevor die Text-Deltas ankommen. Bis das erste sichtbare Token gestreamt wird, ist also jede Quelle, die die Suche gefunden hat, bereits in der Sidebar. Der Accumulator ist ein Request-lokaler ContextVar, an den das Such-Tool anhängt.
# Simplified — the real loop reads Strands' raw event dicts
async for event in agent.stream_async(user_input):
    delta = event["event"].get("contentBlockDelta", {}).get("delta", {}).get("text")
    if delta:
        for doc in new_citations():           # drain the ContextVar accumulator
            yield envelope("metadata", doc)
        yield envelope("text", delta)

Warum das wichtiger ist, als es klingt

Du könntest ähnliches Verhalten mit einem einzigen Agenten-Aufruf implementieren, der thinking-Events mit text-Events verschränkt. Die Gründe, warum ich es trotzdem aufgeteilt habe:

  1. Das Thinking-Modell und das Tool-Modell können unterschiedlich sein. Aktuell sind beide gemma4:e4b, aber die Architektur erlaubt es mir, ein kleineres, schnelleres Modell für das Reasoning in Phase 1 auszutauschen und das große für die Tool-Nutzung in Phase 2 zu behalten. Das mache ich noch nicht — aber ich will die Option haben.

  2. Phase 1 streamt immer sofort. Eine reine Agenten-Loop fängt erst an, Tokens zu produzieren, nachdem das Modell entschieden hat, was es sagen will. Das Zwei-Phasen-Modell garantiert, dass der User fast sofort nach Drücken der Enter-Taste eine Aktivität sieht, unabhängig davon, wie komplex die Tool-Arbeit in Phase 2 wird.

  3. Fehler sind isoliert. Wenn Phase 2 umfällt (Ollama Timeout, Tool Error), ist die Argumentation aus Phase 1 immer noch sichtbar — der User kann sehen, was das Modell tun wollte, was den Fehler deutlich weniger frustrierend macht als ein blankes “irgendwas ist schiefgelaufen”.

ContextVar-Isolation, noch einmal

Der gleiche ContextVar-Trick, der im letzten Post das Retrieval eingegrenzt hat, greift auch hier. Zu Beginn jedes /rag-Streams setzt der Handler zwei Request-lokale Variablen: den Dokument-Scope-Filter und den Zitier-Accumulator. Die Tools des Agenten lesen und schreiben diese implizit. Die Konversationshistorie selbst lebt in einem Per-Session-Store, der durch Per-Session asyncio-Locks geschützt ist. Zwei gleichzeitige Requests im selben Chat können sich also auch nicht gegenseitig korrumpieren.

Getestet mit zwei offenen Browser-Tabs im selben Backend, mit Scope auf verschiedene Dokumentenkategorien, in denen gleichzeitig überlappende Queries gesendet wurden. Null Kreuzkontamination. Die Test-Suite deckt dies explizit in test_thinking.py und test_doc_scope_filter.py ab — schau dir den Testing-Post für die ganze Geschichte an.

Die Frontend-Seite des Vertrags

Ein Detail, das mich ins Straucheln gebracht hat: Das ist ein POST-Endpoint, also scheidet die EventSource-API des Browsers (die nur GET macht) aus. Das Frontend nutzt fetch und liest den Response-Body inkrementell aus, splittet bei Newlines und parst jede Zeile als JSON:

// Simplified from useRagStream.ts
const res = await fetch("/rag", {
  method: "POST",
  body: JSON.stringify(payload),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop()!; // keep the trailing partial line
  for (const line of lines) {
    if (!line.trim()) continue;
    const { type, data } = JSON.parse(line);
    switch (type) {
      case "thinking":
        appendThinking(data);
        break;
      case "text":
        appendText(data);
        break;
      case "metadata":
        addCitation(data);
        break;
      case "memory":
        updateMemoryMeter(data);
        break;
    }
  }
}

Das Reasoning-Panel startet zusammengeklappt, mit einem kleinen pulsierenden Indikator, solange die Thinking-Tokens noch streamen — genug, um zu signalisieren “das Modell arbeitet”, ohne dem User gleich eine Wand aus Chain-of-Thought ins Gesicht zu drücken. Ein Klick klappt den vollen Text aus, während oder nach dem Stream.

Was ich mir noch mal ansehen würde

  • Phase 1 denkt auf eine volle Antwort hin, und wir werfen den Antwortteil weg. Ein eigener “Plane dein Vorgehen, aber antworte noch nicht”-Prompt für Phase 1 würde den Argumentationsstrang kompakter und billiger machen. Heute teilt er sich den Haupt-System-Prompt — das ist simpler, aber die Argumentation kann abschweifen.
  • Noch kein Interrupt. Sobald Phase 1 startet, läuft sie bis zum Ende durch. Wenn der User mitten im Stream eine Nachfrage tippt, lassen wir sie zu Ende laufen. Ein echter Cancel-Button würde bedeuten, ein Abort-Signal durch Ollamas HTTP-Client zu fädeln — machbar, aber noch nicht gemacht.
  • Phase 1 denkt manchmal zu viel nach. Begrüßungen und triviale Fragen produzieren immer noch einen Absatz an Begründung. Ein “Sollte ich nachdenken?"-Gate (wahrscheinlich ein winziger Classifier oder einfach eine Heuristik basierend auf der Query-Länge) würde Phase 1 in diesen Fällen komplett überspringen.

Takeaway

Streaming ist nicht einfach nur eine Optimierung. Es ist ein UX-Primitiv. Zwei-Phasen-Streaming kauft dir eine Eigenschaft gratis ein: Der sichtbare Teil der Interaktion startet, bevor der langsame Teil beginnt. Der User darf dem Modell beim Denken zusehen, was — ehrlich gesagt — interessanter ist, als einem Spinner zuzuschauen.

Wenn sich deine Agenten-App langsam anfühlt, obwohl die Antworten schnell kommen, schau dir an, wann die Tokens anfangen zu fließen. Der Fix ist oft nicht ein schnelleres Modell.


Anhang: Abkürzungen in diesem Post

AbkürzungVolle FormBedeutung
NDJSONNewline-Delimited JSONEin Stream, in dem jede Zeile ihr eigenes komplettes JSON-Objekt ist — das, was /rag ausgibt
JSONJavaScript Object NotationDas universelle Textformat für strukturierte Daten
UXUser ExperienceWie sich das Produkt in der Nutzung anfühlt — der eigentliche Profiteur vom Zwei-Phasen-Streaming
UIUser InterfaceDie sichtbare Oberfläche, in die der Stream rendert
FAISSFacebook AI Similarity SearchDie dichte (dense) Hälfte des hybriden Retrievals (vorheriger Post)
BM25Best Match 25Die Keyword-Hälfte des hybriden Retrievals (vorheriger Post)
RRFReciprocal Rank FusionDie Rank-only-Formel, die die beiden Ergebnislisten zusammenführt
ASTAbstract Syntax TreeDie geparste Form eines Ausdrucks — wie der Taschenrechner Mathe ohne eval() berechnet
HTTPHyperText Transfer ProtocolDas Protokoll, das den Stream transportiert
SSEServer-Sent EventsDas eingebaute GET-only Streaming-Format des Browsers — hier nicht nutzbar, weil /rag ein POST ist
APIApplication Programming InterfaceDie Grenze, die das Frontend aufruft

Als Nächstes: Crash-resistente Ingestion mit DBOS — wie CogniVault bearbeitete PDFs neu einliest, ohne alles neu zu embedden, und ein kill -9 mitten in der Pipeline überlebt.