Token-für-Token-Streaming von LLM-Antworten ist das beste User Experience: Der Nutzer sieht sofort, dass etwas passiert, die Latenz wird nicht gefühlt, und der Chatbot-Look ist natürlich.
Das Problem: Streaming ist knifflig. Django braucht Server-Sent Events (SSE), React braucht EventSource, und dazwischen können viele Fehler passieren (Netzwerk-Drops, reconnects, Timeouts).
In diesem Guide bauen wir ein production-ready Streaming-Setup: Django-Backend mit StreamingHttpResponse, React-Frontend mit reconnect-Logic, Error-Handling und Cancellation.
Server-Sent Events (SSE) vs. WebSockets: Warum SSE?
Kurz: SSE ist einfacher, WebSockets sind mehr Overhead.
SSE (Server-Sent Events): - HTTP-Standard (nicht eigenes Protokoll) - Built-in Reconnect (Browser managed) - Einfach mit Django (StreamingHttpResponse) - Unidrektional (Server → Client) - CORS-freundlich
WebSockets: - Bidirektional (besser für Chat mit simultanen Input/Output) - Komplexer Setup (braucht ASGI, z.B. Daphne) - Bessere Performance für hohen Durchsatz - Schwieriger Browser-Reconnect
Für LLM-Streaming (User schreibt Query, Server streamt Response): SSE ist perfekt. Du brauchst Bidirektional nur wenn der User während die LLM antwortet, neue Messages schreiben kann.
Unser Setup: SSE mit Django Sync-Views (keine ASGI nötig).
Django Backend: StreamingHttpResponse Setup
Das Kernprinzip: Django gibt eine Generator-Function zurück, die Tokens nacheinander yieldet.
Code (views.py): ```python import json from django.http import StreamingHttpResponse from django.views.decorators.http import require_http_methods from anthropic import Anthropic
client = Anthropic(api_key="...")
def stream_generator(user_message, system_prompt=None): """Generator, der Tokens vom LLM yieldet""" try: with client.messages.stream( model="claude-3-5-sonnet-20241022", max_tokens=2000, system=system_prompt or "You are a helpful assistant.", messages=[ {"role": "user", "content": user_message} ] ) as stream: for text in stream.text_stream: # SSE-Format: 'data: <json>\n\n' event_data = json.dumps({ "type": "token", "content": text }) yield f"data: {event_data}\n\n" # Stream finished yield "data: {\"type\": \"done\"}\n\n" except Exception as e: error_data = json.dumps({ "type": "error", "message": str(e) }) yield f"data: {error_data}\n\n"
@require_http_methods(["POST"]) def chat_stream(request): """HTTP endpoint, der streaming response gibt""" import json body = json.loads(request.body) user_message = body.get("message", "") system_prompt = body.get("system_prompt", None) # CORS headers für lokales Entwickeln (in production: ProxyPass oder nginx) response = StreamingHttpResponse( stream_generator(user_message, system_prompt), content_type="text/event-stream" ) response["Cache-Control"] = "no-cache" response["X-Accel-Buffering"] = "no" # Wichtig: Nginx nicht buffern lassen response["Access-Control-Allow-Origin"] = "*" # CORS return response ```
Important Details: 1. `Content-Type: text/event-stream` — Browser erkennt SSE 2. `X-Accel-Buffering: no` — Wichtig falls du Nginx/Gunicorn benutzt (verhindert dass chunks gepuffert werden) 3. `Cache-Control: no-cache` — Stream darf nicht gecacht werden 4. SSE-Format: `data: <content>\n\n` (zwei Newlines!)
URLs (urls.py): ```python from django.urls import path from . import views
urlpatterns = [ path("api/chat/stream/", views.chat_stream, name="chat_stream"), ] ```
Wichtig für Production: StreamingHttpResponse mit Django Sync-Views funktioniert out-of-the-box, aber Gunicorn braucht richtige Konfiguration:
``` gunicorn myapp.wsgi:application \ --workers 4 \ --worker-class sync \ --timeout 300 ```
Ein Worker pro Stream ist OK (Threads nutzen kann auch funktionieren, aber sync ist einfacher für SSE).
React Frontend: EventSource + Reconnect Logic
Client-Seite ist 80% der Komplexität. Browser EventSource hat built-in Reconnect, aber du musst es richtig handhaben.
Custom Hook (useStreamingLLM.ts): ```typescript import { useCallback, useRef, useState } from "react";
interface StreamEvent { type: "token" | "done" | "error"; content?: string; message?: string; }
export function useStreamingLLM() { const [response, setResponse] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const stream = useCallback( (message: string, systemPrompt?: string) => { setResponse(""); setError(null); setLoading(true);
// Alte Connection schließen wenn noch offen if (eventSourceRef.current) { eventSourceRef.current.close(); }
try { // EventSource zum Backend verbinden const eventSource = new EventSource( `/api/chat/stream/?message=${encodeURIComponent(message)}&system_prompt=${encodeURIComponent(systemPrompt || "")}` );
eventSource.onmessage = (event) => { try { const data: StreamEvent = JSON.parse(event.data);
switch (data.type) { case "token": setResponse((prev) => prev + (data.content || "")); break; case "done": setLoading(false); eventSource.close(); break; case "error": setError(data.message || "Unknown error"); setLoading(false); eventSource.close(); break; } } catch (e) { console.error("Failed to parse event", e); setError("Failed to parse response"); } };
eventSource.onerror = () => { // Browser handelt automatisch Reconnect nach 1s, 2s, 4s, etc. // Nach 3 failed attempts (default), gibt Browser auf console.error("EventSource error, browser will auto-reconnect"); // Optional: Manual reconnect logic if (eventSource.readyState === EventSource.CLOSED) { setError("Connection lost. Reconnecting..."); } };
eventSourceRef.current = eventSource; } catch (e) { setError(`Failed to start streaming: ${e}`); setLoading(false); } }, [] );
const cancel = useCallback(() => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } setLoading(false); }, []);
return { response, loading, error, stream, cancel }; } ```
Aber Stop! EventSource hat ein Problem: du kannst der URL keine POST-Body senden. Die obige Lösung hackst mit Query Params. Das ist OK für kleine Messages, aber für große Requests (>2000 chars) brauchst du was anderes.
Better: Manual EventSource mit POST ```typescript export function useStreamingLLM() { const [response, setResponse] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const stream = useCallback( async (message: string, systemPrompt?: string) => { setResponse(""); setError(null); setLoading(true);
// Cancel alten Request wenn noch laufen if (abortControllerRef.current) { abortControllerRef.current.abort(); }
const abortController = new AbortController(); abortControllerRef.current = abortController;
try { const response = await fetch("/api/chat/stream/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, system_prompt: systemPrompt }), signal: abortController.signal, });
if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
if (!response.body) { throw new Error("No response body"); }
const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
// ReadableStream line-by-line parsen while (true) { const { done, value } = await reader.read(); if (done) break;
buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n\n");
// Alle komplette Events verarbeiten for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].replace(/^data: /, "").trim(); if (!line) continue;
try { const data: StreamEvent = JSON.parse(line); if (data.type === "token") { setResponse((prev) => prev + (data.content || "")); } else if (data.type === "done") { setLoading(false); } else if (data.type === "error") { setError(data.message || "Unknown error"); setLoading(false); } } catch (e) { console.error("Failed to parse event", line, e); } }
// Unvollständige letzte Zeile für nächste Iteration speichern buffer = lines[lines.length - 1]; }
setLoading(false); } catch (e) { if ((e as Error).name === "AbortError") { setError("Stream cancelled"); } else { setError(`Failed to stream: ${e}`); } setLoading(false); } }, [] );
const cancel = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } setLoading(false); }, []);
return { response, loading, error, stream, cancel }; } ```
Dieser Ansatz benutzt ReadableStream statt EventSource. Mehr Control, kein POST-Limitation.
Component Usage: ```typescript function ChatUI() { const { response, loading, error, stream, cancel } = useStreamingLLM(); const [message, setMessage] = useState("");
return ( <div> <textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Your question..." /> <button onClick={() => stream(message)} disabled={loading} > {loading ? "Streaming..." : "Send"} </button> {loading && <button onClick={cancel}>Cancel</button>} {error && <p style={{ color: "red" }}>{error}</p>} <div style={{ whiteSpace: "pre-wrap" }}> {response} </div> </div> ); } ```
Production-Ready: Error Handling und Timeouts
Oben gezeigte Code ist gut, aber Production braucht mehr:
1. Request Timeout (Django Backend): ```python import signal import time
def timeout_handler(signum, frame): raise TimeoutError("LLM response took too long")
def stream_generator(user_message, timeout_secs=30): signal.signal(signal.SIGALRM, timeout_handler) signal.alarm(timeout_secs) # 30 sec timeout try: with client.messages.stream(...) as stream: for text in stream.text_stream: signal.alarm(timeout_secs) # Reset timer on each token yield f"data: {{\"type\": \"token\", \"content\\": \"{json.dumps(text)}\"}\n\n" finally: signal.alarm(0) # Cancel alarm ```
2. React Retry Logic: ```typescript const stream = useCallback( async (message: string) => { let retries = 0; const maxRetries = 3; const attemptStream = async () => { try { // ... stream code ... } catch (e) { if (retries < maxRetries && isNetworkError(e)) { retries++; const delay = Math.pow(2, retries) * 1000; // exponential backoff console.log(`Retry ${retries}/${maxRetries} after ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); return attemptStream(); } throw e; } }; return attemptStream(); }, [] ); ```
3. Nginx Konfiguration (falls proxy-setup): ```nginx location /api/chat/stream/ { proxy_pass http://django_backend; proxy_buffering off; # Critical! proxy_cache off; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Connection ""; proxy_http_version 1.1; } ```
Debugging: Häufige Probleme und Lösungen
Problem: Chunks kommen nicht oder verzögert an - Check Django: `X-Accel-Buffering: no`? ✓ - Check Nginx: `proxy_buffering off;`? ✓ - Check Gunicorn: Syncronous Worker? ✓ - Browser DevTools → Network → Header des Requests checken
Problem: "ERR_INCOMPLETE_CHUNKED_ENCODING" - Django crashed mid-stream (Exception in generator) - Fix: Error-Handling in stream_generator (wie oben) - Django logs checken für ExceptionDetails
Problem: React zeigt nichts oder nur ersten Token - Check: Buffer-Parsing in ReadableStream (line-by-line mit \n\n) - Check: JSON-Escaping in Django (content enthält Quotes?) - Browser DevTools Console für JS errors
Problem: Hohe Latenz vor ersten Token - Das ist LLM-Latenz, nicht Streaming-Problem - First-token-latency ist típically 1-3 Sekunden für Claude/GPT - Nichts an SSE zu optimieren, nur LLM-Inference
Performance Tipp: Batch Tokens Wenn Streaming zu viele einzelne Renders triggert, batche Tokens: ```typescript let tokenBatch = ""; const flushBatch = () => { setResponse(prev => prev + tokenBatch); tokenBatch = ""; };
// In stream event handler tokenBatch += data.content; if (tokenBatch.length > 50) flushBatch(); // Render alle 50 tokens ```
Sicherheit: CORS und Abuse Prevention
Streaming Endpoints sind anfällig für Missbrauch:
1. CORS richtig setzen (nicht `*`): ```python from django_cors_headers.decorators import ensure_csrf_cookie from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # SSE braucht kein CSRF Token @require_http_methods(["POST"]) def chat_stream(request): # CORS Header selektiv origin = request.META.get("HTTP_ORIGIN") if origin in ["https://example.com", "https://app.example.com"]: # ... setze response headers ... ```
2. Rate Limiting: ```python from django.views.decorators.cache import cache_page from django_ratelimit.decorators import ratelimit
@ratelimit(key="user", rate="10/h", method="POST") def chat_stream(request): # User darf max 10 streaming requests pro Stunde pass ```
3. Input Validation: ```python if len(user_message) > 10000: return StreamingHttpResponse( (f"data: {{\"type\": \"error\", \"message\": \"Message too long\"}}\n\n"), content_type="text/event-stream" ) ```
Fazit
Streaming ist komplexer als Standard request/response, aber das Resultat (token-by-token typing) ist es wert. Die Architektur ist standardisiert (SSE/ReadableStream), und wir haben dir alles gezeigt, was du brauchst.
Zusammengefasst: Django gibt Streaming Response, React liest sie mit ReadableStream, error-handling und cancellation built-in.
Wenn du diese Implementation in dein Projekt integrist und Fragen hast oder etwas optimiert werden soll: Unser KI-Readiness-Check schaut sich dein bestehender Setup an und zeigt dir, wo die Hebel sind für noch bessere User Experience.