Ir al contenido principal
Tu stream de IA se corta en Vercel. Aquí está el motivo.

Tu stream de IA se corta en Vercel. Aquí está el motivo.

AI Integration
8 min readPor Daily Miranda Pardo

El agente funciona perfecto en localhost. El stream arranca, los tokens llegan uno a uno, la respuesta completa aparece en pantalla. Haces el deploy a Vercel. El primer usuario lo prueba y a los diez segundos la respuesta se congela a mitad de frase. Sin error visible. Sin excepción en los logs. Solo silencio.

Este bug tiene nombre: FUNCTION_INVOCATION_TIMEOUT. Y la razón por la que no aparece en los logs es que Vercel mata la función sin lanzar ninguna excepción — simplemente cierra la conexión. El LLM sigue generando tokens en el servidor de Anthropic. Tu cliente no los recibe nunca.

Por qué el streaming rompe en serverless

Los servidores tradicionales mantienen una conexión abierta mientras dura el stream. Una función serverless tiene una duración máxima. Cuando esa duración se agota, la función se termina sin importar en qué punto estaba el stream.

Los límites por defecto en Vercel:

PlanRuntimeTimeout por defectoMáximo ampliable
HobbyNode.js10s10s (no ampliable)
ProNode.js15s900s
ProEdge30s30s (no ampliable)
EnterpriseNode.js15s900s

El error más común: usar Edge Runtime porque "es más rápido para AI" sin saber que tiene techo duro a 30 segundos. Si tu LLM genera respuestas de más de 30 segundos — algo habitual cuando el contexto es largo o el modelo razona en múltiples pasos — Edge Runtime no es una opción válida para producción.

El segundo error: no enviar el sentinel [DONE]

Cuando la función muere por timeout, el cliente no recibe señal de cierre. El EventSource o el ReadableStream del frontend simplemente deja de recibir datos. Dependiendo de cómo esté implementado el cliente, puede:

  • Quedarse esperando indefinidamente (spinner eterno)
  • Mostrar la respuesta truncada como si fuera la final
  • Intentar reconectar en bucle hasta que el usuario recarga

Ninguna de las tres opciones es aceptable en producción.

El setup correcto para streaming LLM en Next.js

1. Define el runtime y el maxDuration explícitamente

// app/api/chat/route.ts
export const runtime = 'nodejs'; // NUNCA edge para llamadas > 30s
export const maxDuration = 300;  // Pro: hasta 900s. Hobby: ignorado (máx 10s)

import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic();

export async function POST(req: Request) {
  const { messages } = await req.json();

  const stream = anthropic.messages.stream({
    model: 'claude-opus-4-8',
    max_tokens: 4096,
    messages,
  });

  const readable = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      try {
        for await (const chunk of stream) {
          if (
            chunk.type === 'content_block_delta' &&
            chunk.delta.type === 'text_delta'
          ) {
            controller.enqueue(
              encoder.encode(
                `data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`
              )
            );
          }
        }
        // El sentinel SIEMPRE debe enviarse antes de cerrar
        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      } catch (err) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ error: 'stream_error' })}\n\n`)
        );
      } finally {
        controller.close();
      }
    },
  });

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no', // Crítico para Nginx en self-hosted
    },
  });
}

El try/catch/finally garantiza que el controlador siempre se cierra, incluso si el stream del LLM lanza una excepción. Sin el finally, un error en el loop deja el ReadableStream en estado indefinido.

2. El cliente que no sabe que el stream murió

La implementación habitual con fetch y ReadableStream en el cliente tiene un problema: no distingue entre "el stream acabó limpiamente" y "la conexión se cayó". Necesitas detectar si llegó el sentinel [DONE] antes de marcar la respuesta como completa.

// hooks/useStreamingResponse.ts
import { useRef, useState, useCallback } from 'react';

export function useStreamingResponse() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState<'idle' | 'streaming' | 'done' | 'error'>('idle');
  const abortRef = useRef<AbortController | null>(null);

  const send = useCallback(async (messages: unknown[]) => {
    abortRef.current?.abort();
    abortRef.current = new AbortController();

    setText('');
    setStatus('streaming');
    let receivedDone = false;

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        body: JSON.stringify({ messages }),
        headers: { 'Content-Type': 'application/json' },
        signal: abortRef.current.signal,
      });

      const reader = res.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const lines = decoder.decode(value).split('\n');
        for (const line of lines) {
          if (!line.startsWith('data: ')) continue;
          const payload = line.slice(6);

          if (payload === '[DONE]') {
            receivedDone = true;
            break;
          }

          try {
            const { text: chunk, error } = JSON.parse(payload);
            if (error) throw new Error(error);
            if (chunk) setText(prev => prev + chunk);
          } catch {
            // chunk malformado — ignorar y continuar
          }
        }
        if (receivedDone) break;
      }

      // Si el stream terminó sin [DONE] → timeout o error del servidor
      setStatus(receivedDone ? 'done' : 'error');

    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setStatus('error');
      }
    }
  }, []);

  const cancel = useCallback(() => {
    abortRef.current?.abort();
    setStatus('idle');
  }, []);

  return { text, status, send, cancel };
}

El campo receivedDone es el punto crítico. Si el stream se cierra sin que llegue [DONE], el hook pone el estado en 'error' en lugar de 'done'. Eso permite al componente mostrar un mensaje claro al usuario ("La respuesta se interrumpió — vuelve a intentarlo") en lugar de mostrar texto truncado como si fuera la respuesta completa.

El caso del proxy que bufferiza todo

Si tu Next.js corre detrás de Nginx en un servidor propio (no en Vercel), hay un segundo problema: Nginx bufferiza las respuestas del upstream por defecto. El usuario no ve ningún token hasta que la respuesta completa llega al proxy, momento en el que todo aparece de golpe.

El fix es una sola cabecera:

# nginx.conf
location /api/chat {
  proxy_pass http://localhost:3000;
  proxy_set_header X-Accel-Buffering "no";
  proxy_buffering off;
  proxy_read_timeout 300s;  # ajustar a tu maxDuration
  chunked_transfer_encoding on;
}

En Vercel esto no aplica porque la plataforma gestiona el buffer automáticamente. Pero en cualquier setup self-hosted con Nginx o Apache es el primer sitio a revisar cuando el streaming "no funciona".

El AbortController que nadie implementa

Cuando el usuario navega a otra página mientras el stream está activo, el stream del servidor sigue ejecutándose y consumiendo tokens aunque nadie los reciba. En producción con varios usuarios concurrentes, eso es coste de API directo.

El AbortController en el hook del ejemplo anterior cancela el fetch cuando el componente se desmonta. Falta la parte del servidor: detectar que el cliente cerró la conexión.

// Dentro de la route — detección de abort del cliente
const abortController = new AbortController();
req.signal.addEventListener('abort', () => {
  abortController.abort();
});

const stream = anthropic.messages.stream(
  { model: 'claude-opus-4-8', max_tokens: 4096, messages },
  { signal: abortController.signal }
);

Con esto, cuando el cliente cancela, la llamada al LLM se interrumpe también. En implementaciones sin esto, cada navegación del usuario deja un stream huérfano corriendo hasta que se agota o hasta que el maxDuration lo corta.

Checklist de streaming en producción

Antes de desplegar cualquier endpoint de streaming LLM, verifica punto por punto:

  • runtime = 'nodejs' — nunca Edge para respuestas de más de 30s
  • maxDuration definido explícitamente (no dejar el default de 10s)
  • El sentinel [DONE] se envía en el bloque finally
  • El cliente detecta si llegó [DONE] antes de marcar como done
  • X-Accel-Buffering: no en la cabecera si hay Nginx por delante
  • AbortController conectado tanto en el cliente como en la llamada al LLM
  • Plan B en el cliente: qué muestra el usuario si el stream se corta

El servicio de Integración IA en Next.js incluye la revisión y corrección de todos estos puntos antes de cualquier despliegue en producción. Si tienes un agente en producción que falla de forma intermitente, el streaming mal configurado es la primera causa a descartar.

Por qué no aparece en los logs de Vercel

El timeout de Vercel no genera una excepción que puedas capturar. La función simplemente termina. En el dashboard de Vercel aparece como "Duration: 10000ms" con estado 200 — porque la conexión HTTP se estableció correctamente y la respuesta empezó a enviarse antes del timeout.

Para detectar estos casos, necesitas instrumentar el número de tokens recibidos en el cliente y compararlo con el total esperado. Si el cliente recibe menos del 90% de los tokens sin haber recibido [DONE], es un timeout enmascarado.

El servicio de observabilidad y tracing de agentes IA incluye este tipo de instrumentación: métricas de completitud de stream por endpoint, duración de llamadas y tasa de timeouts por modelo y longitud de contexto.

Lo que cambia cuando está bien configurado

Un streaming mal configurado genera entre un 3% y un 8% de peticiones fallidas silenciosas en producción — usuarios que ven respuestas truncadas sin entender por qué. Con el setup correcto, ese número cae a cero para respuestas dentro del maxDuration.

Para respuestas genuinamente largas (modelos razonando en varios pasos, herramientas que tardan), la solución no es ampliar el timeout indefinidamente: es diseñar el pipeline para enviar resultados parciales mientras el proceso continúa. Pero eso es arquitectura de agentes, no configuración de streaming — y es una conversación distinta.

Si tienes un endpoint de streaming en producción y ves respuestas truncadas intermitentes, escríbeme. En 30 minutos diagnosticamos si es un problema de configuración, de runtime o de arquitectura.

Revisar el streaming de mi agente en producción →

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.