Zum Inhalt springen
Alle Artikel

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.

text
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"}
Streaming auf Static Web Apps: v4-Function mit HTTP-Streams, genau text/event-stream, Fallback nur vor dem ersten Token.
Architektur für gestreamte KI-Antworten auf Azure Static Web Apps: Das Browser-Widget sendet mit stream true, die Managed Function im Azure-Functions-v4-Modell mit HTTP-Streams reicht die streamGenerateContent-Events von Gemini als Server-Sent Events mit Content-Type genau text/event-stream weiter, und ein Fallback-Modell wird nur vor dem ersten Token versucht.

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.

javascript
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.

javascript
// 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.

javascript
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.

bash
# 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.

Weitere Artikel

    Kontakt aufnehmen

    Erzählen Sie mir von Ihrem Projekt, ich antworte innerhalb von 1–2 Werktagen.

    Dominik Heinrich
    Unternehmen
    MADO Consulting

    Weltweit verfügbar, remote. Vor Ort auf Anfrage, je nach Land.

    Ich antworte innerhalb von 1–2 Werktagen. Freie Termine: siehe Kalender

    Öffnet hier den Buchungskalender von Calendly. Beim Laden werden Daten an Calendly übertragen (siehe Impressum).