Streaming von LLM-Antworten: Warum es für UX und Kosten wichtig ist
Die 900 ms, durch die sich unsere Chat-Oberfläche „kaputt“ anfühlte
Vor einigen Monaten untersuchte ich Fehler in einem Kundensupport-Assistenten, dessen Ende-zu-Ende-Latenz auf dem Papier völlig akzeptabel war: Die meisten Antworten waren nach 4–7 Sekunden vollständig. Das Modell wählte Tools aus, rief Kontokontext ab und generierte eine hilfreiche Antwort.
Aber die Nutzer hassten ihn.
Der Grund war einfach: Die Oberfläche blieb in den ersten 3.8 Sekunden leer.
Kein Text beim Ladeindikator, keine Teilantwort, kein Hinweis darauf, dass das Modell die Frage verstanden hatte. Nur eine leere Nachrichtenblase des Assistenten. Als wir denselben Endpunkt auf Token-Streaming über Server-Sent Events umstellten, änderte sich die durchschnittliche Zeit bis zum vollständigen Abschluss kaum. Doch die Zeit bis zur ersten sichtbaren Ausgabe sank bei vielen Anfragen auf etwa 700–900 ms, und das Produkt fühlte sich sofort schneller an.
Das ist der eigentliche Grund, warum Streaming wichtig ist.
Das Streaming von LLM-Antworten ist keine Magie. Es lässt das Modell nicht schneller denken. Es reduziert nicht die Gesamtzahl der Tokens. Es senkt nicht automatisch Ihre Rechnung. Aber es verändert den Interaktionsablauf auf eine Weise, die Nutzer spüren, und gibt Ihrer Anwendung mehr Kontrolle über Abbruch, Darstellung, Moderation, Nutzungserfassung und Backpressure.
Dieser Artikel erläutert, wie SSE-Streaming tatsächlich funktioniert, was Sie messen sollten, wo die Abrechnung knifflig werden kann und welche clientseitigen Muster ich in Produktivsystemen verwende.
Was ist der wichtigste Vorteil von Streaming-Antworten?
Der wichtigste Vorteil von Streaming-Antworten ist die wahrgenommene Latenz: Der Nutzer sieht die ersten Tokens innerhalb weniger Hundert Millisekunden, statt während der gesamten Generierungszeit auf einen Ladeindikator zu starren. Die gesamte Generierungszeit ändert sich kaum – was sich ändert, ist die Zeit bis zum ersten Token, und genau diese Zeit spüren Nutzer tatsächlich. Eine Antwort, die nach 9 Sekunden vollständig ist, aber schon nach 400 ms zu erscheinen beginnt, wirkt schnell; dieselbe Antwort, die nach 9 Sekunden auf einmal erscheint, wirkt wie eine Fehlfunktion.
Es gibt auch weitere Vorteile: Mit Streaming können Sie Teilausgaben schrittweise darstellen, Generierungen frühzeitig abbrechen, wenn der Nutzer genug gesehen hat (und so Ausgabe-Tokens sparen), und bei langen Antworten Lese-Timeouts von Proxys vermeiden. Aber wenn Sie sich nur eines merken: Streaming macht das Modell nicht schneller – es lässt die Wartezeit aus der Nutzererfahrung verschwinden.
Was Streaming tatsächlich bedeutet
Wenn Sie eine LLM-Anfrage ohne Streaming stellen, wartet der Server normalerweise, bis das Modell die Antwort vollständig generiert hat, und gibt dann eine einzelne JSON-Nutzlast zurück:
{
"id": "resp_123",
"model": "some-model",
"output_text": "Sure — here is the answer...",
"usage": {
"input_tokens": 812,
"output_tokens": 436
}
}
Beim Streaming sendet der Server schrittweise Ereignisse, sobald Ausgaben verfügbar sind. Das gängigste Übertragungsverfahren ist Server-Sent Events, üblicherweise abgekürzt als SSE.
SSE ist lediglich eine HTTP-Antwort mit einer langlebigen Verbindung und einem speziellen Inhaltstyp:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Anstelle eines einzelnen JSON-Objekts sendet der Server eine Folge kleiner Ereignisse:
event: message.delta
data: {"delta":"Sure"}
event: message.delta
data: {"delta":" — here"}
event: message.delta
data: {"delta":" is"}
event: message.delta
data: {"delta":" the answer..."}
event: message.done
data: {"finish_reason":"stop","usage":{"input_tokens":812,"output_tokens":436}}
Verschiedene APIs verwenden unterschiedliche Ereignisnamen und Schemata, aber der Ablauf ist meist derselbe:
- Eine HTTP-Verbindung öffnen.
- Anfragemetadaten und Prompt senden.
- Schrittweise Datenblöcke empfangen.
- Datenblöcke an die aktuelle Nachricht des Assistenten anhängen.
- Ein abschließendes Ereignis mit dem Beendigungsgrund und häufig auch Nutzungsmetadaten empfangen.
- Den Stream schließen.
In der Praxis bedeutet LLM-„Streaming“ meist nicht ein Token pro Ereignis. Anbieter können Tokens aus Effizienzgründen zu Datenblöcken bündeln. Sie erhalten möglicherweise ein einzelnes Zeichen, ein Wortfragment, mehrere Wörter oder ein strukturiertes JSON-Delta. Entwickeln Sie niemals einen Client, der davon ausgeht, dass jeder Datenblock ein vollständiges Wort oder einen vollständigen Satz enthält.
Latenz: Gesamtzeit im Vergleich zur Zeit bis zum ersten Token
Die wichtigste Unterscheidung besteht zwischen Gesamtlatenz und Zeit bis zum ersten Token.
| Metrik | Was sie misst | Warum sie wichtig ist |
|---|---|---|
| Zeit bis zum ersten Byte, TTFB | Erstes vom Server empfangenes Byte | Umfasst Routing, Authentifizierung, Warteschlangen und Modell-Scheduling |
| Zeit bis zum ersten Token, TTFT | Erstes generiertes Token bzw. erster Datenblock, der für den Nutzer sichtbar ist | Wichtigster Faktor für die wahrgenommene Reaktionsfähigkeit |
| Tokens pro Sekunde | Geschwindigkeit der Ausgabegenerierung nach dem ersten Token | Bestimmt, wie schnell lange Antworten vollständig sind |
| Gesamtzeit bis zum Abschluss | Vollständige Antwort empfangen | Relevant für Automatisierung, Tool-Aufrufe und Batch-Aufgaben |
| Zeit bis zu einer nutzbaren Antwort | Der Nutzer hat genügend Informationen, um zu handeln | Oft früher als der vollständige Abschluss |
Für die Chat-UX ist TTFT meist wichtiger als die Gesamtzeit. Eine Antwort, die nach 800 ms beginnt und nach 8 Sekunden vollständig ist, fühlt sich oft besser an als eine, die nach 4 Sekunden auf einmal erscheint.
Für Maschine-zu-Maschine-Workflows ist Streaming oft weniger wichtig. Wenn Ihr nächster Schritt erst ausgeführt werden kann, sobald das vollständige JSON-Objekt vorliegt, erhöht Streaming möglicherweise nur die Implementierungskomplexität.
TTFT in Python messen
Hier ist ein einfaches Muster, um die Latenz bis zum ersten Datenblock und die Gesamtdauer des Streams zu messen. Die API-Struktur ist bewusst generisch gehalten: Passen Sie URL und Nutzlast an Ihren Anbieter an.
import json
import time
import requests
url = "https://api.example.com/v1/chat/completions"
payload = {
"model": "sonnet-4.6",
"stream": True,
"messages": [
{"role": "user", "content": "Explain SSE streaming in 5 bullet points."}
]
}
headers = {
"Authorization": "Bearer $API_KEY",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
start = time.perf_counter()
first_chunk_at = None
chars = 0
with requests.post(url, headers=headers, json=payload, stream=True) as r:
r.raise_for_status()
for raw_line in r.iter_lines(decode_unicode=True):
if not raw_line:
continue
if not raw_line.startswith("data: "):
continue
data = raw_line[len("data: "):]
if data == "[DONE]":
break
event = json.loads(data)
# Provider-specific: this may be event["delta"], event["choices"][0]["delta"], etc.
delta = event.get("delta", "")
if delta and first_chunk_at is None:
first_chunk_at = time.perf_counter()
chars += len(delta)
print(delta, end="", flush=True)
end = time.perf_counter()
print("\n\n--- metrics ---")
print(f"ttft_ms={((first_chunk_at or end) - start) * 1000:.0f}")
print(f"total_ms={(end - start) * 1000:.0f}")
print(f"chars={chars}")
Eine häufige Falle: Wenn Sie Streaming mit einem lokalen Proxy, einer Serverless-Funktion oder einem Reverse-Proxy testen, der Antworten puffert, könnten Sie glauben, dass das Modell nicht streamt. Das Modell streamt möglicherweise korrekt, aber Ihre Infrastruktur hält die Datenblöcke zurück, bis die Antwort vollständig ist.
Ich habe das in folgenden Fällen beobachtet:
- Aktivierte Pufferung in Nginx.
- Cloud-Funktionen, die keine Streaming-Antworten unterstützen.
- Komprimierungs-Middleware, die auf größere Puffer wartet.
- Web-Frameworks, die die gesamte Antwort erstellen, bevor sie sie zurückgeben.
- Unternehmens-Proxys, die kleine Datenblöcke zusammenfassen.
Bei Nginx ist diese Einstellung oft entscheidend:
location /api/llm/stream {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
add_header X-Accel-Buffering no;
}
SSE im Vergleich zu WebSockets
SSE ist nicht die einzige Möglichkeit für Streaming. WebSockets sind ebenfalls verbreitet, insbesondere bei bidirektionalen Anwendungen. Für die meisten LLM-Chat-Oberflächen ist SSE jedoch die einfachere Standardwahl.
| Funktion | SSE | WebSocket |
|---|---|---|
| Richtung | Vom Server zum Client | Bidirektional |
| Protokoll | Einfache HTTP-Antwort | WebSocket-Upgrade |
| Browserunterstützung | Natives EventSource; funktioniert auch mit fetch Streams | Natives WebSocket |
| Verhalten beim Wiederverbinden | Integriertes Konzept, Anwendungslogik aber weiterhin erforderlich | Implementieren Sie selbst |
| Kompatibilität mit Load Balancern | Meist einfacher | Manchmal zusätzliche Konfiguration erforderlich |
| Am besten geeignet für | Token-Streaming, Fortschrittsereignisse, einfacher Chat | Zusammenarbeit in Echtzeit, Audio, Live-Sitzungen mit mehreren Gesprächsrunden |
| Unterstützung für Anfrage-Bodys | EventSource unterstützt nur GET; verwenden Sie fetch für POST-Streaming | Vollständig bidirektionale Nachrichten |
Das EventSource Die Browser-API ist praktisch, unterstützt aber nur GET-Anfragen. Die meisten LLM-Aufrufe benötigen POST-Anfragekörper und Authentifizierungsheader, daher verwende ich für das Streaming meist fetch() und ReadableStream.
Muster für Browser-Clients: Fetch + ReadableStream
Eine minimale Browser-Implementierung sieht so aus:
async function streamChat({ messages, signal, onDelta, onDone }) {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "gpt-5.5",
stream: true,
messages
}),
signal
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = 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 data = trimmed.slice(5).trim();
if (data === "[DONE]") {
onDone?.();
return;
}
const event = JSON.parse(data);
if (event.delta) {
onDelta(event.delta);
}
}
}
onDone?.();
}
Und in einer Benutzeroberfläche:
const controller = new AbortController();
let assistantText = "";
streamChat({
messages,
signal: controller.signal,
onDelta(delta) {
assistantText += delta;
renderAssistantMessage(assistantText);
},
onDone() {
markMessageComplete();
}
});
// Cancel button
document.querySelector("#stop").onclick = () => {
controller.abort();
};
Nicht jeden Chunk unbedacht rendern
Wenn Sie den React-State bei jedem winzigen Chunk aktualisieren, können Sie versehentlich ein clientseitiges Performanceproblem verursachen. Manche Streams liefern Dutzende Chunks pro Sekunde. Einen großen Chat-Komponentenbaum jedes Mal neu zu rendern, verschwendet Ressourcen.
In der Praxis bündele ich UI-Aktualisierungen meist pro Animationsframe:
let pending = "";
let scheduled = false;
function onDelta(delta) {
pending += delta;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
assistantText += pending;
pending = "";
scheduled = false;
setAssistantText(assistantText);
});
}
}
So bleibt die Oberfläche flüssig, während die Ausgabe weiterhin als live wahrgenommen wird.
Teilausgaben korrekt verarbeiten
Streaming bedeutet, dass Ihre Anwendung unvollständigen Text empfängt. Das klingt selbstverständlich, aber viele Fehler entstehen dadurch, dass Teilausgaben wie endgültige Ausgaben behandelt werden.
Markdown ist vorübergehend ungültig
Während des Streamings kann Markdown fehlerhaft sein:
Here is the query:
```sql
SELECT *
FROM users
WHERE
Die Begrenzung eines Codeblocks ist möglicherweise noch nicht geschlossen. Eine Tabelle enthält vielleicht nur die Kopfzeile. Ein Link ist womöglich erst zur Hälfte ausgegeben. Ihr Renderer muss damit umgehen können.
Für Chat-Oberflächen bevorzuge ich Folgendes:
- Markdown schrittweise, aber mit robuster Fehlerbehandlung rendern.
- Gefährliches HTML deaktivieren.
- Aufwendige Syntaxhervorhebung vermeiden, bis die Nachricht vollständig ist.
- Einen dezenten Status „Wird generiert“ anzeigen.
- Nach dem Ende des Streams die abschließende Formatierung erneut ausführen.
Streaming von JSON ist schwieriger
Wenn Sie das Modell um JSON bitten und die Ausgabe streamen, ist der Zwischenstand meist ungültiges JSON. Das ist ein Problem für Anwendungen, die strukturierte Benutzeroberflächen in Echtzeit aktualisieren möchten.
Zum Beispiel:
{
"title": "Incident Summary",
"severity": "high",
"steps": [
"Restart
Das lässt sich noch nicht parsen.
Es gibt drei gängige Ansätze:
- Bis zum Abschluss warten, dann einmal parsen.
- Zeilenweise abgegrenztes JSON verwenden, wobei jede vollständige Zeile unabhängig geparst werden kann.
- Semantische Ereignisse streamen, statt rohem JSON.
Für strukturiertes Streaming bevorzuge ich Ausgaben in Ereignisform:
event: section
data: {"title":"Summary"}
event: bullet
data: {"text":"Database failover started at 09:42 UTC."}
event: bullet
data: {"text":"Read latency remained elevated for 6 minutes."}
Dieses Muster erfordert mehr Orchestrierung, lässt sich aber wesentlich zuverlässiger rendern.
Was tatsächlich passiert, wenn Nutzer auf „Stopp“ klicken
Der Abbruch ist einer der oft unterschätzten Vorteile von Streaming.
Bei einer Anfrage ohne Streaming kann der Nutzer die Seite verlassen, während das Modell auf dem Server weitergeneriert. Je nach Anbieter und Backend kann diese Generierung weiterhin Ausgabetokens verbrauchen.
Beim Streaming kann der Client die HTTP-Anfrage abbrechen:
const controller = new AbortController();
fetch("/api/chat/stream", {
method: "POST",
body: JSON.stringify(payload),
signal: controller.signal
});
// Later
controller.abort();
Der Abbruch erfolgt jedoch nicht immer sofort über die gesamte Verarbeitungskette hinweg.
Es gibt mehrere Ebenen:
- Der Browser bricht den Fetch-Aufruf ab.
- Ihr Backend erkennt, dass die Verbindung geschlossen wird.
- Ihr Backend bricht die Anfrage an den vorgeschalteten Anbieter ab.
- Der Anbieter stoppt die Generierung.
- Der Verbrauch wird anhand der bereits verarbeiteten oder generierten Tokens berechnet.
Ein häufiger Fallstrick: Wenn Ihr Backend den Stream weiterleitet, den Abbruch aber nicht an den Anbieter weitergibt, trennt sich der Browser des Nutzers, während Ihr Server die Modellausgabe bis zum Abschluss weiterliest.
In Pseudocode im Node-Stil:
app.post("/api/chat/stream", async (req, res) => {
const upstreamController = new AbortController();
req.on("close", () => {
upstreamController.abort();
});
const upstream = await fetch(PROVIDER_URL, {
method: "POST",
headers: providerHeaders,
body: JSON.stringify({
...req.body,
stream: true
}),
signal: upstreamController.signal
});
res.setHeader("Content-Type", "text/event-stream");
for await (const chunk of upstream.body) {
res.write(chunk);
}
res.end();
});
Das ist der Unterschied zwischen „Die UI hat gestoppt“ und „Das Modell hat gestoppt“.
Streaming und Kosten: Was sich ändert und was nicht
Streaming verändert, wann Sie den Verbrauch erfassen. Die grundlegende Abrechnungseinheit ändert sich dadurch meist nicht.
Die meisten LLM-APIs rechnen anhand von Faktoren wie diesen ab:
- Eingabetokens.
- Ausgabetoken.
- Zwischengespeicherte Eingabetokens, sofern Prompt-Caching greift.
- Tool-Aufrufe oder spezielle Modalitäten, je nach API.
- Mitunter Reasoning-Tokens oder intern budgetierte Rechenleistung für bestimmte Modellfamilien.
Streaming selbst ist in der Regel kein Preisnachlass. Wenn das Modell 700 Ausgabetokens generiert, sollten Sie damit rechnen, 700 Ausgabetokens zu berücksichtigen, unabhängig davon, ob sie schrittweise oder als eine abschließende Nutzlast eintreffen.
Streaming wirkt sich im Betrieb auf die Kosten aus:
| Szenario | Auswirkung auf die Kosten |
|---|---|
| Der Nutzer bricht nach 80 Tokens ab | Die Generierung der verbleibenden Ausgabe lässt sich möglicherweise vermeiden, wenn der Abbruch weitergegeben wird |
| Die UI erkennt frühzeitig, dass die Antwort ausreicht | Die Anwendung kann die Generierung gezielt stoppen |
| Das Backend bricht die Anfrage an den Anbieter nicht ab | Sie zahlen möglicherweise trotz getrennter Clientverbindung für die vollständige Generierung |
| Verbrauchsdaten treffen erst im letzten Ereignis ein | Bei abgebrochenen Streams sind möglicherweise anbieterseitige Protokolle oder Schätzungen erforderlich |
| Lange Antworten werden standardmäßig gestreamt | Nutzer verbrauchen möglicherweise mehr, weil sich die Nutzung flüssiger anfühlt |
| Tool-Aufrufe vor dem Streamen von Text | TTFT kann steigen, während die Kosten für Ausgabetokens unverändert bleiben können |
Fallstrick bei der Verbrauchserfassung
Viele Streaming-APIs senden Metadaten zum Verbrauch am Ende des Streams:
{
"type": "message.done",
"usage": {
"input_tokens": 1240,
"output_tokens": 389
},
"finish_reason": "stop"
}
Wenn der Stream vor dem letzten Ereignis unterbrochen wird, erhält Ihre Anwendung möglicherweise keine Verbrauchsdaten. Das bedeutet nicht zwangsläufig, dass der Verbrauch null ist.
Für die interne Erfassung speichere ich meist drei Felder:
{
"request_id": "chat_01j...",
"status": "client_aborted",
"usage": null,
"estimated_output_chars": 1842,
"final_usage_received": false
}
Anschließend gleiche ich die Daten später ab, sofern der Anbieter Anfrageprotokolle oder Verbrauchsereignisse bereitstellt. Falls nicht, können Sie zumindest grobe Schätzungen für die Produktanalyse vornehmen, ohne bei der Abrechnung eine falsche Genauigkeit vorzutäuschen.
Rechnen Sie gegenüber Endnutzern nicht anhand einer Schätzung auf Zeichenbasis ab, sofern Ihre Bedingungen dies nicht ausdrücklich vorsehen. Die Tokenisierung variiert je nach Modell und Sprache.
Architekturmuster für das Backend
Ein Streaming-Ablauf im Produktivbetrieb sieht typischerweise so aus:
Browser
|
| POST /api/chat/stream
v
App Backend
|
| provider streaming request
v
LLM API
Ich gebe Anbieterschlüssel nur selten direkt an Browser weiter. Das Backend bietet Ihnen:
- Authentifizierung und Autorisierung.
- Ratenbegrenzung pro Nutzer oder Arbeitsbereich.
- Prompt-Zusammenstellung und Informationsabruf.
- Tool-Ausführung.
- Steuerung von Schwärzung und Protokollierung.
- Failover auf einen anderen Anbieter.
- Weitergabe von Abbrüchen.
- Verbrauchserfassung.
Eine minimale Streaming-Antwort aus einem Python-FastAPI-Backend:
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
PROVIDER_URL = "https://api.example.com/v1/chat/completions"
@app.post("/api/chat/stream")
async def chat_stream(request: Request):
body = await request.json()
async def event_generator():
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
PROVIDER_URL,
headers={
"Authorization": "Bearer YOUR_PROVIDER_KEY",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json={
**body,
"stream": True,
},
) as upstream:
async for line in upstream.aiter_lines():
if await request.is_disconnected():
break
if not line:
continue
# Optionally transform provider-specific events here.
yield line + "\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
In der Praxis normalisiere ich Anbieterereignisse meist auf ein einheitliches internes Schema:
{
"type": "delta",
"text": "partial text here"
}
und dann:
{
"type": "done",
"finish_reason": "stop",
"usage": {
"input_tokens": 812,
"output_tokens": 436
}
}
Das ist relevant, wenn Sie mehrere Modelle wie Claude, GPT, Gemini, Llama, Qwen oder Kimi über eine gemeinsame Schnittstelle unterstützen. Die Streaming-Semantik unterscheidet sich bei jedem Anbieter leicht. Eine frühe Normalisierung hält Ihr Frontend unspektakulär, und genau das wollen Sie.
TTFT hängt nicht nur vom Modell ab
Wenn TTFT schlecht ist, geben viele zunächst dem Modell die Schuld. Manchmal zu Recht: Größere Modelle, längere Kontexte und aufwendigere Reasoning-Modi können die Latenz bis zum ersten Token erhöhen. Doch bevor das erste Token ankommt, sind viele technische Abläufe nötig.
Zu den Einflussfaktoren gehören:
- Aufbau der TLS-Verbindung.
- Kaltstarts in Ihrem Backend.
- Prompt-Erstellung und Informationsabruf.
- Latenz der Vektordatenbank.
- Tool-Aufrufe vor der Generierung.
- Wartezeiten beim Anbieter.
- Verarbeitung eines langen Eingabekontexts.
- Sicherheitsprüfungen oder Richtlinienebenen.
- Proxy-Pufferung.
- Verzögerung beim Rendern im Client.
Ein praxisnaher Trace sollte diese Aspekte getrennt erfassen:
{
"trace_id": "req_abc",
"timings_ms": {
"auth": 12,
"retrieval": 84,
"prompt_build": 9,
"provider_connect": 141,
"ttfb": 612,
"first_delta": 804,
"stream_complete": 5820
}
}
Wenn der Abruf 600 ms dauert und das erste Delta des Modells 250 ms später eintrifft, löst ein Modellwechsel Ihr UX-Problem möglicherweise nicht. Wenn der Verbindungsaufbau zum Anbieter den größten Zeitanteil ausmacht, kann die Wiederverwendung von Verbindungen helfen. Wenn das Frontend mit dem Rendern auf einen vollständigen Absatz wartet, funktioniert Ihr Stream, aber Ihre UI-Regeln verbergen ihn.
Wann Streaming nicht sinnvoll ist
Streaming lohnt sich nicht immer.
Ich verzichte auf Streaming, wenn:
- Die Antwort sehr kurz ist, etwa bei Klassifikation oder Routing.
- Der Aufrufer ein anderer Dienst ist, der vollständiges JSON benötigt.
- Die Ausgabe vor der Anzeige vollständig moderiert werden muss.
- Die Anfrage in einer Job-Warteschlange oder Batch-Pipeline ausgeführt wird.
- Das Framework oder die Hosting-Schicht Streaming unzuverlässig macht.
- Das Produkt Antworten als Ganzes statt schrittweise angezeigten Text benötigt.
Es gibt auch einen UX-Nachteil: Streaming kann sichtbar machen, dass das Modell mitten in der Antwort die Richtung ändert. Im Chat tolerieren Menschen das bis zu einem gewissen Grad, aber in Workflows mit weitreichenden Konsequenzen kann es unausgereift wirken. Bei Benutzeroberflächen für rechtliche, medizinische, finanzielle oder stark durch Compliance geprägte Anwendungen können Sie sich dafür entscheiden, erst nach Sicherheitsprüfungen zu streamen oder zunächst in einen internen Puffer zu streamen, bevor sichere Abschnitte angezeigt werden.
Praktische Muster auf der Clientseite
Diese Muster empfehle ich für die meisten Chat-Produkte:
1. Status anzeigen, bevor Text eintrifft
Lassen Sie die Chatblase des Assistenten nicht leer. Verwenden Sie eindeutige Zustände:
- „Kontext wird gelesen …“
- „Dokumente werden durchsucht …“
- „Antwort wird erstellt …“
- „Noch in Bearbeitung …“
Das ist besonders hilfreich, wenn Tool-Aufrufe vor der Textgenerierung stattfinden.
2. Eine tatsächlich funktionierende Stopp-Schaltfläche anbieten
Eine Stopp-Schaltfläche ist nicht nur UX-Dekoration. Bei korrekter Implementierung kann sie Ausgabetokens einsparen.
Stellen Sie sicher, dass:
- Der Browser die Anfrage abbricht.
- Ihr Backend Verbindungsabbrüche erkennt.
- Ihr Backend die Anfrage an den Anbieter abbricht.
- Sie die Anfrage als abgebrochen erfassen.
3. Schrittweise rendern, abschließend formatieren
Während des Streamings:
- Text anhängen.
- Markdown mit geringem Aufwand rendern.
- Aufwendige Syntaxhervorhebung vermeiden.
- Das Scrollverhalten vorhersehbar halten.
Nach Abschluss:
- Markdown erneut parsen.
- Code hervorheben.
- Kopierschaltflächen aktivieren.
- Quellenangaben oder Referenzen hinzufügen, sofern Ihr System sie unterstützt.
4. Teilnachrichten sorgfältig speichern
Wenn ein Stream auf halbem Weg fehlschlägt, legen Sie fest, wie dies im Gesprächsverlauf dargestellt wird.
Optionen:
- Die unvollständige Assistentennachricht mit der Markierung „unterbrochen“ beibehalten.
- Sie vollständig entfernen.
- Dem Nutzer erlauben, es mit demselben Prompt erneut zu versuchen.
- Das Modell auffordern, anhand des unvollständigen Kontexts fortzufahren.
Ich behalte die unvollständige Nachricht normalerweise mit einer sichtbaren Markierung bei. Stilles Löschen verwirrt Nutzer, und so zu tun, als sei sie vollständig, ist noch schlechter.
5. Ereignisse vereinheitlichen
Ihr Frontend sollte nicht fünf anbieterspezifische Streaming-Formate kennen müssen. Vereinheitlichen Sie diese im Backend:
{"type":"delta","text":"Hello"}
{"type":"tool_start","name":"search_docs"}
{"type":"tool_result","name":"search_docs","status":"ok"}
{"type":"done","finish_reason":"stop","usage":{"input_tokens":100,"output_tokens":50}}
{"type":"error","message":"upstream timeout"}
Dadurch wird es auch einfacher, zwischen Modellfamilien zu wechseln oder Anfragen dynamisch weiterzuleiten. Wenn Sie eine API-Schicht für mehrere Modelle verwenden, ist diese Vereinheitlichung möglicherweise bereits Teil Ihres Gateways; andernfalls lohnt es sich, sie selbst zu implementieren.
Praktische Empfehlungen
- Streaming verbessert die wahrgenommene Latenz, indem es die Zeit bis zur ersten sichtbaren Ausgabe verkürzt, nicht indem es die gesamte Generierung kostenlos oder augenblicklich macht.
- Messen Sie TTFT, die Gesamtzeit bis zum Abschluss und die Tokens pro Sekunde getrennt.
- SSE ist meist der einfachste Transportmechanismus für LLM-Textstreaming; verwenden Sie
fetch()Streams, wenn Sie POST-Bodies benötigen. - Achten Sie auf Pufferung durch Proxys und Frameworks. Viele „Streaming-Fehler“ sind Fehler durch Pufferung in der Infrastruktur.
- Behandeln Sie unvollständige Ausgaben als ungültig: Markdown kann unvollständig sein, JSON lässt sich möglicherweise nicht parsen, und Codeblöcke können noch offen sein.
- Ein Abbruch spart nur dann Kosten, wenn er vom Browser über das Backend bis zum Anbieter weitergegeben wird.
- Nutzungsmetadaten treffen oft erst am Ende des Streams ein; unterbrochene Streams erfordern eine sorgfältige Erfassung.
- Rendern Sie nicht bei jedem winzigen Abschnitt Ihre gesamte UI neu. Bündeln Sie Aktualisierungen mithilfe von Animationsframes.
- Vereinheitlichen Sie anbieterspezifische Stream-Ereignisse in Ihrem Backend.
- Verzichten Sie bei kurzen Klassifikationen, Batch-Jobs, strikt strukturierten Ausgaben oder Workflows, die vor der Anzeige eine vollständige Moderation erfordern, auf Streaming.
Ein API-Schlüssel für Claude Opus 5.5, Sonnet 5, Haiku 4.5 und Fable 5.1 sowie GPT-6 Modelle. Nutzungsbasierte Abrechnung, kein Abonnement.
API-Schlüssel erhalten →