Saltar al contenido
Todos los artículos

Respuestas de IA en streaming en Azure Static Web Apps: la cabecera que lo retenía todo

La guía de IA de esta web antes mostraba su respuesta solo cuando estaba completa: entre dos segundos y medio y tres de puntitos saltando y luego un muro de texto. El streaming lo soluciona, porque el texto aparece mientras el modelo aún escribe. Alguna documentación de frameworks dice que Azure Static Web Apps retiene todas las respuestas de las funciones, así que ahí no se podría hacer streaming. Sí se puede. Pero un pequeño detalle en una sola cabecera de respuesta retenía la respuesta entera, y hizo falta un experimento controlado para encontrarlo. Este es el montaje que funciona, el experimento y el código.

10 min de lectura

1. Por qué el streaming importa en un chat de ventas

Un visitante que pregunta algo en tu web decide en uno o dos segundos si merece la pena esperar la respuesta. Antes del streaming, la guía de esta web solo mostraba un indicador de escritura hasta que el modelo terminaba, y luego el texto entero de golpe, tras unos 2,5 a 3 segundos. Con streaming, medido en la web en producción, las primeras palabras aparecen tras 1,2 a 1,5 segundos y el resto llega mientras el visitante ya está leyendo.

El streaming no hace más rápido al modelo ni cambia la factura de tokens. Cambia el momento en que el visitante ve la primera frase útil, y en un chat que vende, ese es el número que cuenta.

2. Server-sent events sobre un POST normal

Para la respuesta de un chat no necesitas WebSockets. Los datos van en una dirección, del servidor al navegador, y los server-sent events (SSE) hacen justo eso sobre HTTP normal, a través de cualquier proxy o CDN que respete el protocolo. El EventSource del navegador solo admite GET, pero una petición de chat lleva la conversación en el cuerpo, así que el widget usa fetch con un POST y lee el cuerpo de la respuesta como un stream.

Mantén el formato de eventos pequeño y explícito: un evento delta con un trozo de texto, done cuando la respuesta está completa y error si algo falla. Cada evento es una línea JSON tras data:, seguida de una línea vacía. Ese es todo el protocolo, y se lee fácil en curl, en las DevTools y en los tests.

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 en Static Web Apps: función v4 con HTTP streams, exactamente text/event-stream, reserva solo antes del primer token.
Arquitectura de respuestas de IA en streaming en Azure Static Web Apps: el widget del navegador envía con stream true, la función gestionada con el modelo v4 de Azure Functions y HTTP streams reenvía los eventos de streamGenerateContent de Gemini como server-sent events con Content-Type exactamente text/event-stream, y un modelo de reserva solo se prueba antes del primer token.

3. La función: modelo v4 y HTTP streams

Static Web Apps ejecuta tu API como Azure Functions gestionadas. El modelo de programación v3, con un function.json por endpoint, fija el cuerpo de la respuesta una sola vez, así que solo puede responder de una pieza. El modelo v4 para Node.js tiene HTTP streams: los activas una vez con app.setup({ enableHttpStream: true }) y devuelves un ReadableStream como cuerpo. Todo lo que encolas sale en cuanto lo escribes.

Pasar la API de esta web de v3 a v4 consistió en registrar el endpoint con app.http, apuntar main de api/package.json a ese archivo y borrar function.json. Lo probé en un entorno de preview aparte de la misma Static Web App antes de tocar producción: una función de prueba que enviaba seis eventos con medio segundo de separación, y llegaron con medio segundo de separación.

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. La cabecera que retenía cada respuesta

Con el chat real desplegado, el entorno de prueba se comportaba de forma extraña: la respuesta llegaba como quince eventos separados, pero los quince en el mismo milisegundo, justo al final. El modelo hacía streaming, la función hacía streaming, y aun así el navegador recibía un bloque. Así que desplegué una versión de prueba que escribía la hora de envío del servidor en cada evento: el servidor enviaba a lo largo de 1,3 segundos y el cliente lo recibía todo de golpe. Algo en medio estaba reteniendo.

Para averiguar qué, desplegué ocho variantes del mismo emisor de un evento cada 300 milisegundos, una junto a otra, cambiando una sola cosa cada vez: GET o POST, el cuerpo directo o envuelto en un objeto Response, Cache-Control no-store y no-transform, X-Content-Type-Options nosniff, X-Accel-Buffering no, y el Content-Type con o sin "; charset=utf-8". Solo una variable importaba. Todas las variantes con el sufijo del charset llegaban de golpe al final; todas las que no lo tenían llegaban a tiempo, cada 300 milisegundos.

El sufijo, además, no aporta nada: el estándar HTML dice que los event streams se decodifican siempre como UTF-8 y que no hay forma de elegir otra codificación. La corrección fue una línea: enviar exactamente text/event-stream.

Consejo práctico

Cuando una plataforma supuestamente no admite algo, construye la prueba más pequeña posible y cambia una sola cosa cada vez. Aquí fueron dos despliegues en un entorno de preview y unos veinte minutos, y evitó sacar la API de la plataforma.

5. El lado del modelo: streaming, reserva y timeouts

Gemini hace streaming a través del endpoint streamGenerateContent; con alt=sse responde como server-sent events, un trozo JSON por evento. La función lee esos trozos, salta las partes marcadas como razonamiento interno del modelo y reenvía el texto como eventos delta. Claude y las APIs compatibles con OpenAI hacen streaming igual, con su propio formato de eventos, así que el reenvío no cambia.

Las reservas necesitan una regla nueva. Sin streaming, si el primer modelo falla, simplemente pruebas el siguiente. Con streaming quizá ya se ve media respuesta, y empezar otra encima confundiría al visitante. Así que el siguiente modelo solo se prueba mientras no se haya enviado nada. Un timeout hasta el primer token evita que un modelo lento bloquee el chat, y un límite total más largo corta un stream que se queda colgado a medias.

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. El navegador: mostrar lo que llega y cerrar bien

En el navegador, lee el cuerpo de la respuesta con un reader, decodifica los bytes, separa por líneas vacías y procesa cada evento. Renderiza en el siguiente frame de animación en vez de en cada trozo, para que las respuestas largas no vayan a tirones. Las respuestas de esta guía terminan con marcadores para botones de navegación y preguntas de seguimiento; mientras el texto llega, un marcador a medias aparecería un instante, así que todo lo que va desde un doble corchete abierto queda oculto hasta que está completo, y los botones aparecen con el evento done.

Dos detalles lo hacen robusto. Marca el mensaje que crece con aria-busy, para que los lectores de pantalla lean la respuesta terminada una vez y no cada fragmento. Y conserva el camino antiguo: si el servidor responde con JSON en lugar de un stream, porque es una versión anterior o un host que no puede hacer streaming, el widget muestra esa respuesta como antes.

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. Demostrarlo antes de fiarse

Un stream retenido en algún punto parece en el navegador exactamente un stream lento, así que prueba con marcas de tiempo. curl -N desactiva el buffer del propio curl; imprime la hora de llegada de cada línea de evento y mira la dispersión. Si todos los eventos comparten la misma marca de tiempo, algo está reteniendo. Hazlo primero en un entorno de preview, luego en producción y otra vez con la Content Security Policy de producción aplicada.

El resto lo cubren las comprobaciones que corren en cada build de esta web: los tests del paquete hacen streaming por cada adaptador, cortan la conexión tras parte de una respuesta y comprueban que no empiece una segunda, y una comprobación en la API de la web confirma que el modelo principal hace streaming y que los eventos llegan al cliente. El camino JSON sin streaming también sigue probado, porque los clientes antiguos aún lo usan.

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. Empaquetarlo una vez y reutilizarlo en todas partes

Todo esto vive en un paquete reutilizable, el motor que construí para esta guía y que ahora uso en otras webs: el widget, un handler con adaptadores para Azure Functions, Node, Vercel, Cloudflare Workers y otros hosts con estándares web, y el código de proveedores para Gemini, Claude y APIs compatibles con OpenAI. El streaming se convirtió en una actualización de versión para cada web que lo usa, y la corrección del charset en otra versión de parche. Helptempo es la versión alojada del mismo motor, para equipos que quieren el chat sin tocar su propia infraestructura.

Si tu chat espera hoy a la respuesta completa, el cambio es más pequeño de lo que parece: un ajuste en la función, una cabecera, un reader en el navegador y una regla de reserva. Mide antes y después; en esta web redujo aproximadamente a la mitad el tiempo hasta la primera palabra visible.

Conclusión

Static Web Apps hace streaming sin problemas con el modelo v4. Envía exactamente text/event-stream, cambia de modelo solo antes del primer token y demuestra cada afirmación con marcas de tiempo.

Más artículos

    Hablemos

    Cuéntame qué estás construyendo; te respondo en 1 o 2 días laborables.

    Dominik Heinrich
    Empresa
    MADO Consulting

    Disponible en todo el mundo en remoto. Presencial bajo petición, según el país.

    Te respondo en 1 o 2 días laborables. Huecos libres: mira el calendario

    Abre aquí el calendario de reservas de Calendly. Al cargarlo se envían datos a Calendly (ver aviso legal).