Eine sich drehende Ladeanzeige ist kein Fortschritt – sie gaukelt nur Aktivität vor, ohne Klarheit zu schaffen. Genau das passiert, wenn ein Sprachmodell wie bei spectr-ai einen Sicherheitsbericht generiert: Nach 15 bis 40 Sekunden liegt das Ergebnis plötzlich vor, doch der Nutzer starrt in der Zwischenzeit in eine leere Seite. Der Unterschied liegt im Streaming: Wird jeder Token sofort an den Browser gesendet, entsteht der Eindruck eines fließenden Textes, als würde jemand live tippen. Dieselbe Wartezeit, aber ein völlig anderes Erlebnis.
Warum Server-Sent Events (SSE) die bessere Wahl sind
Die naheliegende Lösung wäre der Einsatz von EventSource – die Browser-API für Server-Sent Events ist einfach zu nutzen und übernimmt sogar die automatische Wiederverbindung bei Abbrüchen. Doch hier liegt der Haken: EventSource unterstützt ausschließlich GET-Anfragen. Da spectr-ai jedoch eine POST-Anfrage mit Vertragsdaten und Modellauswahl im Body benötigt, scheidet diese Option aus. Stattdessen setzt die Lösung auf fetch mit manueller Stream-Verarbeitung über response.body.getReader().
Der entscheidende Vorteil: Diese Methode ermöglicht den Einsatz von AbortController, um Anfragen gezielt abzubrechen – eine Funktion, die EventSource nicht direkt anbietet. Für einmalige LLM-Anfragen ist eine automatische Wiederverbindung ohnehin kontraproduktiv: Ein erneuter Verbindungsaufbau würde die Generierung neu starten und doppelte Kosten verursachen.
| Anforderung | EventSource | fetch + Reader | |-------------|------------|----------------| | GET-Anfrage | Ja | Nein | | POST-Anfrage mit Body | Nein | Ja | | Benutzerdefinierte Header (z. B. Auth) | Nein | Ja | | Manuelle Abbruchsteuerung | Umständlich | Einfach (AbortController) | | Automatische Wiederverbindung | Ja | Muss selbst implementiert werden |
Der Kern: Sprachmodell-Stream in Textfragmente umwandeln
Sprachmodelle wie Ollama oder Claude unterstützen Streaming – doch ihre Ausgabeformate unterscheiden sich. Ollama bietet ein OpenAI-kompatibles Endpunkt unter /v1/chat/completions, das mit stream: true Server-Sent Events im SSE-Format zurückgibt: Jede Zeile beginnt mit data: {json}\n\n, und das Ende wird mit data: [DONE] markiert. Die Serverkomponente übernimmt hier eine Doppelrolle: Sie agiert sowohl als SSE-Client (liest die Modellausgabe) als auch als SSE-Server (sendet die Tokens an den Browser).
Die folgende Funktion wandelt den HTTP-Stream des Modells in ein asynchrones Iterator-Objekt um, das einzelne Textfragmente liefert:
// lib/stream-model.ts
interface ChatChunk {
choices: { delta: { content?: string } }[];
}
export async function* streamModel(
prompt: string,
contract: string,
signal: AbortSignal,
): AsyncGenerator<string> {
const response = await fetch(
"
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
model: "qwen2.5-coder:7b",
stream: true,
messages: [
{ role: "system", content: prompt },
{ role: "user", content: contract },
],
}),
},
);
if (!response.ok || !response.body) {
throw new Error(`Modellanfrage fehlgeschlagen: ${response.status}`);
}
const reader = response.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() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data: ")) continue;
const payload = trimmed.slice(6);
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload) as ChatChunk;
const token = chunk.choices[0]?.delta?.content;
if (token) yield token;
}
}
}Der entscheidende Trick liegt in der Pufferverwaltung: Netzwerkpakete kommen nicht immer als vollständige Zeilen an. Ein einzelner read()-Aufruf könnte eine unvollständige data:-Zeile zurückgeben. Durch Aufteilung nach Zeilenumbrüchen und das Behalten des letzten Fragments bis zum nächsten Lesevorgang wird verhindert, dass versucht wird, ein abgeschnittenes JSON-Objekt zu parsen.
Für Claude gestaltet sich die Integration ähnlich, allerdings nutzt die Anthropic-SDK ein eigenes Typensystem mit content_block_delta-Events. Aus Sicht des Servers bleibt die Schnittstelle jedoch identisch: ein asynchroner Generator, der Textfragmente liefert. Ein Austausch des Backend-Codes für Claude würde den Rest der Implementierung unverändert lassen.
Der Server: Route-Handler für dynamische Token-Streaming
Im nächsten Schritt wird der Generator in einen ReadableStream eingebettet und über einen Next.js-Route-Handler bereitgestellt. Jeder Token wird als separates SSE-Event formatiert, sodass der Client ihn sofort rendern kann.
// app/api/report/route.ts
import { NextRequest } from "next/server";
import { streamModel } from "@/lib/stream-model";
export const runtime = "nodejs";
export const maxDuration = 60;
interface TokenEvent {
type: "token";
text: string;
}
interface DoneEvent {
type: "done";
}
interface ErrorEvent {
type: "error";
message: string;
}
type ReportEvent = TokenEvent | DoneEvent | ErrorEvent;
export async function POST(request: NextRequest) {
const { contract } = (await request.json()) as { contract: string };
if (!contract?.trim()) {
return Response.json({ error: "Kein Vertrag angegeben" }, { status: 400 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
function send(event: ReportEvent) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(event)}\n\n`),
);
}
try {
for await (const token of streamModel(
SYSTEM_PROMPT,
contract,
request.signal,
)) {
send({ type: "token", text: token });
}
send({ type: "done" });
} catch (err) {
if (request.signal.aborted) return; // Nutzer hat die Seite verlassen
const message = err instanceof Error ? err.message : "Generierung fehlgeschlagen";
send({ type: "error", message });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}Drei Aspekte dieser Implementierung sind besonders wertvoll:
- Durchgängige Signal-Weiterleitung: Das Abbruch-Signal des Browsers wird über
request.signalan Next.js übergeben, fließt weiter instreamModelund schließlich zur Modell-Anfrage. So lässt sich die Generierung zu jedem Zeitpunkt unterbrechen – ohne manuelle Nacharbeit.
- Robuste Fehlerbehandlung: Tritt ein Fehler auf, wird ein
error-Event gesendet. Der Client kann darauf reagieren, etwa durch eine Benachrichtigung oder einen Neustart.
- Effiziente Pufferung: Die Header-Einstellungen
Cache-Control,ConnectionundX-Accel-Bufferingstellen sicher, dass der Stream ohne Verzögerungen oder Zwischenspeicherung übertragen wird.
Fazit: Streaming als Standard für Echtzeit-Erlebnisse
Die Integration von Server-Sent Events in eine Next.js-Anwendung mag auf den ersten Blick komplex wirken, doch die Vorteile überwiegen deutlich. Nutzer erhalten sofortiges Feedback, während die Generierung läuft – ein Erlebnis, das an Tools wie ChatGPT erinnert. Die hier vorgestellte Architektur ist modular: Der Kern-Code für das Streaming bleibt unverändert, selbst wenn das Backend-Modell gewechselt wird. Für Entwickler, die Echtzeit-Textgenerierung in Webanwendungen integrieren möchten, bietet diese Lösung eine solide Grundlage – skalierbar, wartbar und ohne externe Abhängigkeiten.
Die Zukunft gehört Anwendungen, die Nutzer nicht im Ungewissen lassen. Mit SSE wird Wartezeit nicht nur verkürzt, sondern in ein interaktives Erlebnis verwandelt.
KI-Zusammenfassung
Learn how to stream LLM tokens to browsers using Next.js 15 and SSE. Avoid blank screens with real-time AI report generation and efficient error handling.