KI-Antworten auf Azure Static Web Apps streamen: der eine Header, der alles zurückhielt
Der KI-Guide auf dieser Website zeigte seine Antwort früher erst, wenn sie fertig war: zweieinhalb bis drei Sekunden hüpfende Punkte, dann eine Textwand. Streaming löst das, weil der Text erscheint, während das Modell noch schreibt. Manche Framework-Dokumentation behauptet, Azure Static Web Apps puffere jede Function-Antwort, Streaming sei dort also unmöglich. Es geht. Aber ein kleines Detail in einem einzigen Antwort-Header hielt die ganze Antwort zurück, und erst ein kontrolliertes Experiment hat es gefunden. Hier sind das Setup, das funktioniert, das Experiment und der Code.
10 Min. Lesezeit
1. Warum Streaming für einen Sales-Chat zählt
Ein Besucher, der auf Ihrer Website eine Frage stellt, entscheidet in ein, zwei Sekunden, ob sich das Warten lohnt. Vor dem Streaming zeigte der Guide auf dieser Website nur eine Tipp-Anzeige, bis das Modell fertig war, und dann nach etwa 2,5 bis 3 Sekunden den ganzen Text auf einmal. Mit Streaming erscheinen die ersten Worte, gemessen auf der Live-Seite, nach 1,2 bis 1,5 Sekunden, und der Rest folgt, während der Besucher schon liest.
Streaming macht das Modell nicht schneller und ändert die Token-Rechnung nicht. Es ändert, wann der Besucher den ersten brauchbaren Satz sieht, und bei einem Chat, der verkaufen soll, ist das die Zahl, die zählt.
2. Server-Sent Events über einen normalen POST
Für eine Chat-Antwort braucht es keine WebSockets. Die Daten fliessen in eine Richtung, vom Server zum Browser, und Server-Sent Events (SSE) tun genau das über normales HTTP, durch jeden Proxy und jedes CDN, das das Protokoll respektiert. EventSource im Browser unterstützt nur GET, eine Chat-Anfrage trägt das Gespräch aber im Body, also sendet das Widget mit fetch einen POST und liest den Antwort-Body als Stream.
Das Event-Format klein und eindeutig halten: ein Event delta mit einem Stück Text, done, wenn die Antwort fertig ist, und error, wenn etwas schiefgeht. Jedes Event ist eine JSON-Zeile nach data:, gefolgt von einer Leerzeile. Das ist das ganze Protokoll, und es lässt sich in curl, in den DevTools und in Tests leicht lesen.
event: delta
data: {"text":"Yes. Start with a cost baseline "}
event: delta
data: {"text":"per resource, then right-size on p95."}
event: done
data: {"model":"gemini-3.5-flash"}3. Die Function: v4-Modell und HTTP-Streams
Static Web Apps betreibt Ihre API als Managed Azure Functions. Das ältere v3-Programmiermodell mit einer function.json pro Endpunkt setzt den Antwort-Body einmal, kann also nur am Stück antworten. Das v4-Programmiermodell für Node.js hat HTTP-Streams: einmal mit app.setup({ enableHttpStream: true }) einschalten und einen ReadableStream als Body zurückgeben. Alles, was Sie hineinschreiben, geht sofort raus.
Die API dieser Website von v3 auf v4 umzustellen hiess: den Endpunkt mit app.http registrieren, main in api/package.json auf die Datei zeigen lassen und function.json löschen. Getestet habe ich das auf einer separaten Preview-Umgebung derselben Static Web App, bevor Produktion berührt wurde: eine Test-Function, die sechs Events im Abstand von einer halben Sekunde schickte, und sie kamen im Abstand von einer halben Sekunde an.
const { app } = require("@azure/functions");
app.setup({ enableHttpStream: true });
app.http("chat", {
methods: ["POST"],
authLevel: "anonymous",
handler: async (request) => {
const body = await request.json();
const enc = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event, data) =>
controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
try {
for await (const text of askModel(body)) send("delta", { text });
send("done", {});
} catch {
send("error", { error: "upstream" });
} finally {
controller.close();
}
},
});
// Exactly text/event-stream: no charset suffix (see step 4)
return { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" }, body: stream };
},
});4. Der Header, der jede Antwort zurückhielt
Mit dem echten Chat verhielt sich die Testumgebung seltsam: Die Antwort kam als fünfzehn einzelne Events an, aber alle fünfzehn in derselben Millisekunde, ganz am Ende. Das Modell streamte, die Function streamte, und trotzdem bekam der Browser einen Block. Also habe ich einen Test-Build ausgerollt, der die Sendezeit des Servers in jedes Event schrieb: Der Server sendete über 1,3 Sekunden verteilt, der Client bekam alles auf einmal. Irgendetwas dazwischen pufferte.
Um herauszufinden was, habe ich acht Varianten desselben 300-Millisekunden-Tickers nebeneinander ausgerollt und jeweils nur eine Sache verändert: GET oder POST, der rohe Body oder einer in einem Response-Objekt, Cache-Control no-store und no-transform, X-Content-Type-Options nosniff, X-Accel-Buffering no, und der Content-Type mit oder ohne "; charset=utf-8". Nur eine Variable spielte eine Rolle. Jede Variante mit dem Charset-Zusatz kam in einem Schwall am Ende an, jede ohne pünktlich im Abstand von 300 Millisekunden.
Der Zusatz bringt ohnehin nichts: Der HTML-Standard sagt, dass Event-Streams immer als UTF-8 dekodiert werden und dass es keinen Weg gibt, eine andere Kodierung zu wählen. Die Korrektur war also eine Zeile: genau text/event-stream senden.
Tipp aus der Praxis
Wenn eine Plattform etwas angeblich nicht kann, die kleinstmögliche Probe bauen und immer nur eine Sache verändern. Hier waren es zwei Deployments in eine Preview-Umgebung und etwa zwanzig Minuten, und das hat den Umzug der API weg von der Plattform erspart.
5. Die Modellseite: streamen, ausweichen, abbrechen
Gemini streamt über den Endpunkt streamGenerateContent; mit alt=sse antwortet er als Server-Sent Events, ein JSON-Stück pro Event. Die Function liest diese Stücke, überspringt Teile, die als internes Nachdenken des Modells markiert sind, und reicht den Text als delta-Events weiter. Claude und OpenAI-kompatible APIs streamen genauso, mit eigener Event-Form, das Weiterreichen bleibt also gleich.
Für Fallbacks braucht es eine neue Regel. Ohne Streaming versucht man bei einem Fehler einfach das nächste Modell. Mit Streaming ist vielleicht schon eine halbe Antwort zu sehen, und eine zweite Antwort darüber würde den Besucher verwirren. Das nächste Modell wird also nur versucht, solange noch nichts gesendet wurde. Ein Timeout bis zum ersten Token verhindert, dass ein langsames Modell den Chat blockiert, und eine längere Gesamtgrenze beendet einen Stream, der auf halber Strecke hängen bleibt.
// Try providers in order; switch only while nothing has been sent
for (const provider of providers) {
let started = false;
const ctrl = new AbortController();
const firstToken = setTimeout(() => ctrl.abort(), provider.timeout ?? 15000);
try {
const out = await provider.stream(request, ctrl.signal, (text) => {
if (!started) (started = true), clearTimeout(firstToken);
send("delta", { text });
});
if (out.text) return send("done", { model: provider.model });
} catch {
/* fall through */
} finally {
clearTimeout(firstToken);
}
if (started) break; // half an answer is on screen: don't start another
}
send("error", { error: "upstream" });6. Der Browser: anzeigen, was ankommt, sauber abschliessen
Im Browser den Antwort-Body mit einem Reader lesen, die Bytes dekodieren, an Leerzeilen trennen und jedes Event verarbeiten. Im nächsten Animation-Frame rendern statt bei jedem Stück, damit lange Antworten nicht ruckeln. Die Antworten dieses Guides enden mit Markern für Navigations-Buttons und Folgefragen; während der Text streamt, würde ein unfertiger Marker kurz aufblitzen, also bleibt alles ab einer offenen doppelten Klammer verborgen, bis es vollständig ist, und die Buttons erscheinen mit dem done-Event.
Zwei Details machen es robust. Die wachsende Nachricht mit aria-busy markieren, damit Screenreader die fertige Antwort einmal vorlesen statt jedes Bruchstück. Und den alten Weg behalten: Antwortet der Server mit JSON statt mit einem Stream, weil er eine ältere Version ist oder ein Host, der nicht streamen kann, zeigt das Widget diese Antwort wie bisher.
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream, application/json" },
body: JSON.stringify({ messages, stream: true }),
});
if (!res.headers.get("content-type")?.includes("text/event-stream")) return showJson(await res.json());
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
const event = /^event: (.*)$/m.exec(block)?.[1];
const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] ?? "{}");
if (event === "delta") appendText(data.text); // painted on the next animation frame
if (event === "done") showButtons();
}
}7. Beweisen, bevor man vertraut
Ein Stream, der irgendwo gepuffert wird, sieht im Browser genau aus wie ein langsamer Stream, also mit Zeitstempeln testen. curl -N schaltet das Puffern von curl selbst ab; die Ankunftszeit jeder Event-Zeile ausgeben und die Streuung ansehen. Haben alle Events denselben Zeitstempel, puffert etwas. Zuerst auf einer Preview-Umgebung, dann in Produktion, und noch einmal mit der Content Security Policy der Produktion.
Den Rest decken die Prüfungen ab, die bei jedem Build dieser Website laufen: Die Tests des Pakets streamen durch jeden Adapter, kappen die Verbindung nach einem Teil der Antwort und prüfen, dass keine zweite Antwort startet, und eine Prüfung in der API der Website bestätigt, dass das Hauptmodell streamt und die Events beim Client ankommen. Der JSON-Weg ohne Streaming bleibt ebenfalls getestet, weil ältere Clients ihn noch nutzen.
# Arrival time of every SSE event; all identical = something buffers
curl -sN -X POST https://<your-site>/api/chat \
-H 'Content-Type: application/json' \
--data '{"messages":[{"role":"user","text":"How do you start?"}],"stream":true}' \
| perl -MTime::HiRes=time -ne 'BEGIN{$|=1; $t0=time} printf "%6.2fs %s", time-$t0, $_ if /^event:/'8. Einmal paketieren, überall wiederverwenden
All das steckt in einem wiederverwendbaren Paket, der Engine, die ich für diesen Guide gebaut habe und jetzt auf anderen Websites nutze: das Widget, ein Handler mit Adaptern für Azure Functions, Node, Vercel, Cloudflare Workers und andere Web-Standard-Hosts, und der Provider-Code für Gemini, Claude und OpenAI-kompatible APIs. Streaming wurde damit zu einem Versions-Update für jede Website, die es nutzt, und die Charset-Korrektur zu einem weiteren Patch-Release. Helptempo ist die gehostete Version derselben Engine, für Teams, die den Chat wollen, ohne ihre eigene Umgebung anzufassen.
Wartet Ihr Chat heute auf die ganze Antwort, ist die Änderung kleiner, als sie aussieht: eine Einstellung in der Function, ein Header, ein Reader im Browser und eine Fallback-Regel. Vorher und nachher messen; auf dieser Website hat es die Zeit bis zum ersten sichtbaren Wort etwa halbiert.
Fazit
Static Web Apps streamt mit dem v4-Modell problemlos. Genau text/event-stream senden, Modelle nur vor dem ersten Token wechseln und jede Behauptung mit Zeitstempeln beweisen.
Echte Projekte dazu
Selbst ausprobieren
Quellen und offizielle Leitfäden
- Azure Functions: Node.js developer guide (v4 model, HTTP streams)
- Azure Static Web Apps: API support with Azure Functions
- HTML Living Standard: Server-sent events (event streams are always UTF-8)
- MDN: Using server-sent events
- MDN: ReadableStream
- Gemini API reference: generateContent and streamGenerateContent
Weitere Artikel
- 3'025 Pull Requests in zehn Monaten: ein KI-gestützter Delivery-Prozess von innen
- Der EU AI Act für den Mittelstand: was für Sie gilt, und ab wann
- FinOps für KI-Kosten: Tokens, Caching, Routing und Budgets, die halten
- Ein Restaurant-Netzwerk in KI-Antworten sichtbar machen: ein GEO-Projekt Schritt für Schritt
- Wie diese Website gebaut ist: statisch, wo es geht, streamend, wo es muss
- Kubernetes-Kosten auf EKS und GKE: dasselbe Playbook, andere Hebel
- Eine Landing Zone für den Mittelstand, in Terraform oder Bicep: die Teile, die zählen
- Das Playbook für Legacy-Migrationen: Wellen, Parallelbetrieb und identische Zeilenzahlen und Prüfsummen
- MCP-Sicherheit im Detail: KI-Assistenten ins Produkt lassen, ohne die Türen zu öffnen
- Monitoring auf Geschäftsergebnisse: grüne Dashboards, leere Kasse
- KI-Agenten am Telefon: die Architektur, und die Stolperfallen, die keine Demo zeigt
- Restore-Tests: Ein Backup, das nie wiederhergestellt wurde, ist nur eine Hoffnung
- Einen KI-Assistenten für WhatsApp bauen, von Anfang bis Ende: was es über die Demo hinaus braucht
- Air-gapped-Systeme: kritische Software ganz ohne Internet betreiben
- Produktive KI auf AWS: Amazon Bedrock und AgentCore in einem privaten Setup
- Produktive KI auf Microsoft Foundry: ein privates Referenz-Setup für Agenten
- /goal, /loop oder Routines: Hören Sie auf, Ihren Coding-Agenten zu babysitten
- Claudes neue Pläne: Max, Ultra und was sie für Teams bedeuten
- OpenAI Codex Ende 2026: Goal Mode, Dots und wie man es absichert
- GitHub Copilot, Claude Code oder Codex? So würde ich für ein Unternehmen entscheiden
- GEO-Grundlagen: So erscheinen Sie in KI-Antworten
- Produktive KI auf Google Cloud: von Vertex AI zur Gemini Enterprise Agent Platform
- Lokale LLMs im Air-Gap-Netz: KI für Ihre sensibelsten Daten
- Ich will ein lokales LLM betreiben: Welchen Server soll ich kaufen?
- MiniMax M3: ein offenes Spitzenmodell zum Selbstbetreiben?
- Prompt-Research im grossen Stil mit einem offenen Modell
- SEO und GEO zusammen: ein praktisches Audit in 6 Schritten
- Kubernetes richtig dimensionieren: das Playbook, mit dem ein Cluster von 7 auf 4 Nodes kam
- Private KI für sensible Daten: ein lokales LLM-Setup, das durch die Sicherheitsprüfung kommt
- Vom KI-Prototyp zur Produktion: die acht Schichten, die ich ergänze, in dieser Reihenfolge
