Jedes Team, das ein LLM-gestütztes Feature baut, stößt auf denselben Entscheidungspunkt: Das Modell streamt Tokens, während sie erzeugt werden, und das Frontend muss sie einzeln nacheinander anzeigen, statt auf die komplette Antwort zu warten. Die reflexartige Antwort lautet meist „WebSockets, wegen Streaming“ — und drei Wochen später debuggt jemand, warum die Chat-UI auf localhost perfekt funktioniert, in Produktion aber in einem einzigen riesigen Block ankommt, weil ein nginx-Proxy oder ein unternehmensinterner Load Balancer die gesamte Antwort gepuffert hat, bevor auch nur ein einziges Byte weitergeleitet wurde.
Die eigentliche Entscheidung ist nicht „Streaming vs. kein Streaming“ — es geht darum, den richtigen Transport für einen bestimmten Datenfluss zu wählen, und WebSockets ist häufig die falsche Antwort für etwas, das unter all der Begeisterung für Echtzeit-UIs in Wirklichkeit ein unidirektionaler Textstrom vom Server zum Client ist. Server-Sent Events (SSE) existieren genau für diese Problemform, laufen über normales HTTP und umgehen eine ganze Kategorie an Infrastrukturkomplexität, die WebSockets mit sich bringen — aber SSE hat eigene Fallstricke beim Proxy-Buffering, die Teams oft erst bei der ersten Bereitstellung hinter etwas Komplexerem als einem nackten Uvicorn-Prozess überraschen. Dieser Beitrag zeigt, wann jeder der drei Transporte tatsächlich richtig ist, was Streaming in Produktionsinfrastruktur kaputtmacht und enthält funktionierenden FastAPI-Code sowohl für SSE- als auch für WebSocket-Token-Streaming.
Sie lernen:
- Warum Polling für einen relevanten Teil „quasi-echtzeitiger“ Features immer noch die richtige Wahl ist — und ab wann es nicht mehr ausreicht
- Wie Server-Sent Events über normales HTTP funktionieren und warum sie besser zu LLM-Token-Streaming passen, als die meisten Teams zunächst annehmen
- Wann Sie WebSockets wirklich brauchen — die konkreten bidirektionalen Low-Latency-Fälle, die SSE nicht abdecken kann
- Welche nginx-Buffering-, gzip- und Proxy-Konfiguration Streaming-Antworten in Produktion stillschweigend kaputtmacht
- Funktionierenden FastAPI-Code für SSE auf Basis von
StreamingResponseund für einen WebSocket-Endpunkt - Ein Entscheidungsframework, das Sie direkt anwenden können, statt standardmäßig den gerade trendigsten Transport zu wählen
Inhaltsverzeichnis
- Die Grundlagen
- Polling: Manchmal immer noch richtig
- Server-Sent Events für Token-für-Token-Ausgabe
- WebSockets: Wenn Sie tatsächlich Bidirektionalität brauchen
- Umgang mit Verbindungsabbrüchen und Fehlern während des Streams
- Strukturierung von Streaming-Endpunkten in einer größeren App
- Das Entscheidungsframework
- Buffering- und Proxy-Fallstricke, die Streaming kaputtmachen
- Codebeispiele
- Häufige Fallstricke
- Best Practices für Produktion
Die Grundlagen
Drei Transporte, drei unterschiedliche Problemformen
Polling bedeutet, dass der Client in festen Abständen wiederholt fragt: „Gibt es etwas Neues?“ Server-Sent Events bedeutet, dass der Server eine HTTP-Verbindung offen hält und Textereignisse genau dann darüber nach unten schiebt, wenn sie eintreten — nur in eine Richtung, vom Server zum Client. WebSockets ist eine Vollduplex-Verbindung, bei der beide Seiten jederzeit Nachrichten senden können, völlig unabhängig von einem klassischen Request/Response-Zyklus.
Der Fehler, den die meisten Teams machen, ist, das Ganze als Schwierigkeitsleiter zu betrachten — Polling ist einfach, SSE ist besser, WebSockets sind am besten — obwohl es in Wirklichkeit eine Entscheidung über die Form des Datenflusses ist. Wenn Ihre Daten immer nur in eine Richtung fließen (LLM-Tokens, die an eine Chat-UI gestreamt werden, ein Fortschrittsbalken, ein Live-Log-Tail), lösen WebSockets ein Problem, das Sie gar nicht haben, und fügen gleichzeitig Infrastruktur hinzu, die Sie tatsächlich betreiben müssen: Verbindungszustand, Reconnect-Logik und ein Protokoll, das sich nicht so sauber hinter Standard-HTTP-Tooling einfügt.
Warum diese Entscheidung gerade bei LLM-Features besonders wichtig ist
Token-für-Token-LLM-Ausgabe ist ihrem Wesen nach unidirektional und textbasiert — exakt die Form, für die SSE entwickelt wurde. Außerdem ist sie in der Regel nicht latenzkritisch im unter-50-ms-Sinn, für den WebSockets existieren (ein Token, das 100 ms später als theoretisch möglich ankommt, ist über die mehrsekündige Dauer einer vollständigen Generierung hinweg nicht wahrnehmbar); entscheidend sind vielmehr Volumen und Zuverlässigkeit — viele Tokens, in der richtigen Reihenfolge, über eine Verbindung, die einen kurzen Netzwerkaussetzer übersteht, ohne dass der gesamte Austausch scheitert.
Polling: Manchmal immer noch richtig
Polling wird zu schnell abgetan. Für Statuswerte, die sich selten ändern — den Abschlusszustand eines Hintergrundjobs, die Phase einer Dokumentverarbeitungspipeline — ist ein Client, der alle paar Sekunden pollt, einfacher zu bauen, einfacher zu debuggen und funktioniert durch jeden Proxy, jede Firewall und jedes CDN ohne besondere Konfiguration, weil es einfach wiederholte normale HTTP-Requests sind.
@router.get("/jobs/{job_id}/status")
async def get_job_status(job_id: str):
job = await fetch_job(job_id)
return {"status": job.status, "progress": job.progress}
const interval = setInterval(async () => {
const res = await fetch(`/jobs/${jobId}/status`);
const data = await res.json();
updateProgressBar(data.progress);
if (data.status === "complete") clearInterval(interval);
}, 2000);
Polling reicht nicht mehr aus, sobald die benötigte Aktualisierungsfrequenz höher ist, als vernünftigerweise per Polling abgefragt werden kann (unter einer Sekunde), oder sobald das Volumen pollender Clients die verschwendeten „nichts hat sich geändert“-Anfragen zu echten Kosten macht — beides ist genau die Situation bei Token-für-Token-LLM-Ausgabe, bei der Sie pro aktivem Nutzer mehrmals pro Sekunde pollen müssten, nur um näherungsweise das zu erreichen, was ein Stream kostenlos liefert.
Server-Sent Events für Token-für-Token-Ausgabe
SSE ist normales HTTP mit einem speziellen Response-Content-Type (text/event-stream) und einem einfachen Textformat, das die native EventSource-API des Browsers (oder eine fetch- plus ReadableStream-Leseschleife) inkrementell parsen kann, sobald Bytes eintreffen, statt auf das Schließen der Verbindung zu warten:
data: {"token": "The"}
data: {"token": " quick"}
data: {"token": " brown"}
Jede data:-Zeile (beendet durch eine Leerzeile) ist ein Ereignis. Weil es einfach HTTP ist, funktioniert es mit der richtigen Buffering-Konfiguration durch Standard-Load-Balancer und CDNs, benötigt kein Protocol Upgrade wie WebSockets, und die eingebaute EventSource-API des Browsers übernimmt die automatische Wiederverbindung mitsamt last-event-id für Sie — ein Feature, das Sie bei WebSockets sonst selbst bauen müssten.
FastAPI liefert SSE über StreamingResponse, das einen asynchronen Generator umschließt — jedes yield sendet sofort einen Chunk über die offene Verbindung:
from fastapi.responses import StreamingResponse
import json
async def token_stream(prompt: str):
async for chunk in llm_client.stream(prompt):
yield f"data: {json.dumps({'token': chunk})}\n\n"
yield "data: [DONE]\n\n"
@router.get("/chat/stream")
async def chat_stream(prompt: str):
return StreamingResponse(
token_stream(prompt),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
Das async for des Generators arbeitet natürlich mit dem Event Loop zusammen — jedes yield ist ein natürlicher Aussetzungspunkt, sodass ein langsamer LLM-Provider andere Requests nicht blockiert, wie es ein synchroner blockierender Aufruf tun würde (dieselbe Zusammenarbeit mit dem Event Loop wird in Warum Ihr FastAPI-Endpunkt den Event Loop blockiert behandelt). Der Header X-Accel-Buffering: no ist ein Vorgeschmack auf den nächsten Abschnitt — ohne ihn kann dieser gesamte Endpunkt lokal scheinbar funktionieren und seine Ausgabe in Produktion dann doch in einem einzigen riesigen Block liefern.
WebSockets: Wenn Sie tatsächlich Bidirektionalität brauchen
WebSockets rechtfertigen ihre Komplexität dann, wenn der Client über dieselbe offene Verbindung zu beliebigen Zeitpunkten Daten zurücksenden muss, ohne den Overhead eines neuen HTTP-Requests pro Nachricht — ein kollaborativer Editor, der Tastenanschläge überträgt, Positionsupdates in einem Multiplayer-Spiel oder ein Chat-Feature, bei dem der Client die Generierung unterbrechen oder während des Streams Folge-Nachrichten senden kann und der Server sofort reagieren muss.
from fastapi import WebSocket, WebSocketDisconnect
@router.websocket("/ws/chat")
async def chat_ws(websocket: WebSocket):
await websocket.accept()
try:
while True:
prompt = await websocket.receive_text()
async for chunk in llm_client.stream(prompt):
await websocket.send_json({"token": chunk})
await websocket.send_json({"done": True})
except WebSocketDisconnect:
pass
Das sieht dem SSE-Beispiel ähnlich, aber die Verbindung unterstützt jetzt tatsächlich, dass der Client mitten in der Sitzung einen neuen Prompt sendet, ohne einen neuen HTTP-Request zu öffnen — das ist die tatsächliche Fähigkeit, die hier eingekauft wird. Der Preis dafür ist, dass Sie Ihre eigene Reconnect-Logik brauchen (Browser verbinden WebSockets nicht automatisch neu, wie EventSource es tut), Ihr eigenes Message Framing, falls Sie mehr Struktur als rohen Text/JSON benötigen, und Infrastruktur, die den WebSocket-Upgrade-Handshake korrekt proxyt, was nicht jede Load-Balancer- oder CDN-Konfiguration out of the box unterstützt.
Für die meisten Features der Art „eine LLM-Antwort in eine Chat-UI streamen“ muss der Client während des Streams in Wirklichkeit gar nichts senden — die nächste Benutzernachricht ist ein neuer Request, keine Nachricht über dieselbe offene Verbindung — und genau in diesem Fall erfüllt SSE die Anforderung mit deutlich geringerem Infrastruktur-Risiko.
Umgang mit Verbindungsabbrüchen und Fehlern während des Streams
Eine Streaming-Antwort hat eine größere Fehleroberfläche als ein normaler Request/Response-Ablauf — der Client kann mitten im Stream weg navigieren, der LLM-Provider kann nach einer Teilantwort einen Fehler werfen, und beides muss explizit behandelt werden, sonst endet man mit verwaister Generierungsarbeit oder einem Stream, der hängt, statt sauber fehlzuschlagen.
Erkennen von Client-Verbindungsabbrüchen. Das Request-Objekt von FastAPI stellt is_disconnected() bereit. Diese Methode sollte innerhalb eines lang laufenden Generators geprüft werden, damit Sie aufhören, Arbeit zu leisten (und dafür zu bezahlen), die ohnehin niemand mehr empfangen wird:
async def token_stream(request: Request, prompt: str):
async for chunk in llm_client.stream(prompt):
if await request.is_disconnected():
break # client navigated away — stop generating
yield f"data: {json.dumps({'token': chunk})}\n\n"
Ohne diese Prüfung stoppt ein verlassener Browser-Tab den zugrunde liegenden LLM-Aufruf nicht — Sie zahlen weiter für Tokens und erzeugen weiter Tokens, die niemand jemals sehen wird, und das summiert sich bei jedem realen Traffic-Volumen schnell.
Fehler während des Streams an den Client übermitteln. Sobald das erste Token gesendet wurde, können Sie den HTTP-Statuscode nicht mehr ändern — die Antwort hat bereits begonnen. Fehler, die mitten in der Generierung auftreten, müssen als Datenereignis gesendet werden, das das Frontend explizit prüft, statt sich auf einen HTTP-Fehlerstatus zu verlassen:
async def token_stream(prompt: str):
try:
async for chunk in llm_client.stream(prompt):
yield f"data: {json.dumps({'token': chunk})}\n\n"
except LLMProviderError as exc:
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
finally:
yield "data: [DONE]\n\n"
Das Frontend muss dann jedes eingehende Ereignis auf einen error-Key prüfen, statt anzunehmen, dass jedes Ereignis ein Token ist — ein Detail, das in einer ersten Implementierung leicht ausgelassen wird und oft erst dann sichtbar wird, wenn der LLM-Provider in Produktion tatsächlich mitten in der Antwort ausfällt.
Strukturierung von Streaming-Endpunkten in einer größeren App
Streaming-Routen sammeln typischerweise eigene Belange an — providerspezifische Retry-Logik, Prompt-Konstruktion, Disconnect-Handling —, die nicht in einen Router gehören, der nebenbei gewöhnliche CRUD-Endpunkte enthält. In einem domänengetriebenen Layout wie in FastAPI-Projektstruktur, die Wachstum überlebt hält eine chat- oder completions-Domäne mit eigenem router.py, service.py und einem dedizierten streaming.py für die Generatorfunktionen diese Logik isoliert und unabhängig vom Transport testbar — derselbe token_stream-Generator kann von einer SSE-Route, einem WebSocket-Handler oder einem Test verwendet werden, der ihn einfach direkt iteriert, ohne die LLM-Aufruflogik dreimal über verschiedene Einstiegspunkte zu duplizieren.
Das Entscheidungsframework
| Signal | Polling | SSE | WebSockets |
|---|---|---|---|
| Datenrichtung | Request/Response | Nur Server → Client | Bidirektional |
| Aktualisierungsfrequenz | Sekunden+ | Unter einer Sekunde, hohes Volumen | Unter einer Sekunde, hohes Volumen |
| Sendet der Client mitten im Stream? | N/A | Nein | Ja |
| Funktioniert durch Standard-HTTP-Proxys | Immer | Meistens, mit Konfiguration | Benötigt Upgrade-Support |
| Eingebaute Wiederverbindung | N/A (zustandslos) | Ja (EventSource) | Nein — selbst bauen |
| Infrastrukturkomplexität | Am geringsten | Niedrig bis mittel | Höher |
| Passt zu LLM-Token-Streaming | Schlecht (zu grob) | Gut | Overkill außer bei Bidirektionalität |
Wenn der Client während der Verbindung nie Daten senden muss, beginnen Sie mit SSE — das ist die Form, die LLM-Streaming tatsächlich hat, und die Betriebskosten sind geringer. Greifen Sie gezielt zu WebSockets, wenn die Interaktion wirklich in Echtzeit in beide Richtungen läuft, nicht weil es für ein eigentlich unidirektionales Feature anspruchsvoller klingt.
Buffering- und Proxy-Fallstricke, die Streaming kaputtmachen
Hier gehen SSE-Deployments tatsächlich schief, und fast nie im FastAPI-Code — sondern in allem, was zwischen Uvicorn und dem Client sitzt.
nginx-Response-Buffering. Standardmäßig puffert nginx weitergeleitete Antworten, bevor sie an den Client gesendet werden, was Streaming komplett zunichtemacht — der Client erhält die gesamte Antwort auf einmal, wenn der LLM-Aufruf fertig ist, nicht inkrementell. Die Lösung ist, das Buffering speziell für die Streaming-Route zu deaktivieren:
location /chat/stream {
proxy_pass http://fastapi_upstream;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
Der im FastAPI-Beispiel oben gezeigte Response-Header X-Accel-Buffering: no ist nginx-spezifisch und erzielt denselben Effekt, ohne die nginx-Konfiguration direkt anzufassen — nützlich, wenn Sie die Proxy-Konfiguration nicht kontrollieren, aber die Response-Header der App schon.
gzip-/Kompressions-Middleware. Response-Kompression muss die vollständige Ausgabe puffern, um effektiv komprimieren zu können, was im direkten Widerspruch zur inkrementellen Auslieferung steht. Schließen Sie Streaming-Routen aus jeder GZipMiddleware oder Reverse-Proxy-Kompressionskonfiguration aus — einzelne kleine SSE-Chunks zu komprimieren erzeugt zudem Overhead bei geringem Nutzen, da jedes Token typischerweise sehr klein ist.
Idle Timeouts von Load Balancern. Ein gemanagter Load Balancer (etwa ein AWS ALB) hat einen Standard-Idle-Timeout — oft 60 Sekunden — und schließt eine Verbindung, wenn so lange keine neuen Bytes gesendet werden. Eine langsame LLM-Antwort oder eine Pause zwischen Tokens, die länger als dieses Timeout dauert, beendet den Stream stillschweigend. Entweder senden Sie periodische SSE-Kommentarzeilen (: keep-alive\n\n), um die Verbindung aktiv zu halten, oder Sie erhöhen den Idle-Timeout des Load Balancers speziell für Streaming-Routen.
Uvicorn-Worker-Anzahl und lang offen gehaltene Verbindungen. Jede offene SSE- oder WebSocket-Verbindung beansprucht über ihre gesamte Dauer die Aufmerksamkeit eines Workers. Wenn Uvicorn mit zu wenigen Workern im Verhältnis zu gleichzeitigen Streaming-Verbindungen läuft, stauen sich neue, eigentlich unabhängige Requests hinter langlebigen Streams — dimensionieren Sie Ihre Worker-Anzahl (oder wechseln Sie zu einem Process Manager wie Gunicorn mit der Uvicorn-Worker-Klasse) mit Blick auf die erwartete Zahl gleichzeitiger Streams, nicht nur auf Ihre durchschnittliche Request-Rate.
Client-seitiges Buffering. Manche Browser und HTTP-Clients puffern kleine Response-Chunks, bevor sie sie an JavaScript weitergeben. Eine Mindest-Chunk-Größe zu senden (einige Implementierungen polstern frühe Ereignisse auf) oder nach jedem yield explizit zu flushen verhindert, dass die ersten paar Tokens scheinbar „festkleben“, bevor der Stream sichtbar beginnt.
Codebeispiele
Ein vollständiger SSE-Endpunkt mit Keep-Alive-Pings, um Idle Timeouts von Load Balancern zu überleben, und ein passender Frontend-Consumer:
import asyncio
import json
async def token_stream(prompt: str):
last_sent = asyncio.get_event_loop().time()
async for chunk in llm_client.stream(prompt):
yield f"data: {json.dumps({'token': chunk})}\n\n"
last_sent = asyncio.get_event_loop().time()
yield "data: [DONE]\n\n"
@router.get("/chat/stream")
async def chat_stream(prompt: str):
return StreamingResponse(
token_stream(prompt),
media_type="text/event-stream",
headers={
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
const evtSource = new EventSource(`/chat/stream?prompt=${encodeURIComponent(prompt)}`);
evtSource.onmessage = (event) => {
if (event.data === "[DONE]") { evtSource.close(); return; }
const { token } = JSON.parse(event.data);
appendToken(token);
};
Für einen wirklich bidirektionalen Bedarf — etwa wenn der Client die Generierung mitten im Stream unterbrechen soll — lässt sich der zuvor gezeigte WebSocket-Handler natürlich erweitern, indem parallel zum Generierungs-Loop auf eine eingehende „stop“-Nachricht geprüft wird, etwa mit asyncio.wait auf sowohl die Receive-Coroutine als auch die Generierungs-Coroutine, sodass beide zuerst fertig werden können.
Häufige Fallstricke
Fehler: standardmäßig zu WebSockets für LLM-Streaming greifen. Die meisten Chat-artigen Features sind pro Zug unidirektional und brauchen die zusätzliche Infrastruktur nicht. Lösung: Standardmäßig SSE verwenden, es sei denn, der Client muss während desselben offenen Verbindungsaufbaus tatsächlich mitten im Stream Daten senden.
Fehler: Proxy-Buffering nicht deaktivieren. Das ist die mit Abstand häufigste Ursache für „Streaming funktioniert lokal, kommt in Produktion aber als ein Block an“. Lösung: Buffering (proxy_buffering off, X-Accel-Buffering: no) für jede Streaming-Route explizit deaktivieren und dies in einer Staging-Umgebung mit echtem Proxy verifizieren, nicht nur gegen einen nackten Uvicorn-Prozess.
Fehler: Keep-Alives gegen Idle Timeouts des Load Balancers vergessen. Ein Stream, der länger pausiert als der Idle Timeout des LB, wird mitten in der Antwort stillschweigend beendet. Lösung: Regelmäßig Kommentarzeilen-Pings senden und den Idle-Timeout-Wert Ihres Load Balancers explizit kennen, statt anzunehmen, dass er großzügig gesetzt ist.
Fehler: eine Streaming-Antwort komprimieren. Kompressions-Middleware puffert, um effektiv arbeiten zu können, und verhindert damit die inkrementelle Auslieferung. Lösung: Streaming-Routen vollständig von gzip-/Brotli-Middleware ausschließen.
Fehler: zu wenige Worker für lang gehaltene Verbindungen bereitstellen. Jeder offene Stream belegt einen Worker für seine gesamte Dauer; zu wenige Worker bedeuten, dass neue Requests hinter aktiven Streams in die Warteschlange geraten. Lösung: Die Worker-Anzahl auf Basis der erwarteten gleichzeitigen Streaming-Verbindungen dimensionieren, nicht nach dem durchschnittlichen Request-Durchsatz.
Best Practices für Produktion
- Standardmäßig SSE für unidirektionales Streaming verwenden und WebSockets für wirklich bidirektionale Low-Latency-Interaktionen reservieren.
- Streaming in Staging hinter Ihrem echten Produktions-Proxy-Stack testen, nicht nur gegen einen nackten Uvicorn-Prozess — Buffering-Bugs bleiben unsichtbar, bis ein Proxy im Pfad ist.
- Buffering und Kompression auf Streaming-Routen explizit deaktivieren, sowohl in der Reverse-Proxy-Konfiguration als auch über Response-Header.
- Keep-Alive-Pings bei langen Streams senden, um Idle Timeouts von Load Balancern und zwischengeschalteten Proxys zu überstehen.
- Die Uvicorn-/Gunicorn-Worker-Anzahl auf gleichzeitige offene Streams auslegen, nicht nur auf Requests pro Sekunde — eine lang gehaltene Verbindung hat andere Kapazitätskosten als ein schneller Roundtrip.
Fazit
Der richtige Streaming-Transport wird von der tatsächlichen Form Ihres Datenflusses bestimmt, nicht davon, welcher in einem Architektur-Dokument am beeindruckendsten klingt — und für die überwältigende Mehrheit der LLM-Token-Streaming-Features ist diese Form unidirektional, genau das also, wofür SSE gebaut wurde, zu einem Bruchteil der Infrastrukturkosten von WebSockets. Der Code, der Tokens korrekt streamt, ist meist der einfache Teil; die Proxy- und Load-Balancer-Konfiguration zwischen Ihrer FastAPI-App und dem Browser ist der Ort, an dem Streaming in Produktion tatsächlich kaputtgeht. Testen Sie es deshalb dort, bevor Sie ihm vertrauen.
Wurde Ihr Streaming-Endpunkt tatsächlich hinter Ihrem Produktions-Proxy getestet — oder nur gegen einen nackten Uvicorn-Dev-Server?
