Saltar al contenido
14/30Capítulo 14 de 30

Tu primera llamada LLM en producción: streaming, reintentos y timeouts

Crea un provider que te mienta: 429, sockets colgados y streams a medias. Mide tu cliente. Full jitter: 2,2 s frente a 226.

En esta página

El capítulo 13 terminó con un cronómetro sobre un modelo que podías tocar. Los pesos estaban en tu memoria, el KV cache era tuyo para activarlo o desactivarlo, y el número que salía —tiempo hasta el primer token— era una propiedad de tu hardware.

Ahora pon ese modelo detrás de un puerto, que es lo que hace cualquier producto, y vuelve a leer el mismo número. Sigue siendo tiempo hasta el primer token, pero ya no es propiedad de nada que controles. Ahora incluye un handshake TLS, una cola en el provider, un limitador de tasa y la posibilidad de que no llegue ningún token.

Esa última cláusula es el capítulo. El código que estás a punto de escribir no computa nada. Abre una conexión, espera, parsea lo que llega, decide qué hacer cuando no llega nada, vuelve a decidir cuando lo que llega es un error y se cancela cuando el usuario cambia de idea. Cada una de esas cosas es una decisión sobre estado a lo largo del tiempo, y cada una tiene una respuesta incorrecta que llega a producción y cuesta dinero.

Esta es la forma del problema, medido, todo ello en este capítulo:

qué ocurrióqué hace un cliente descuidadoqué cuesta
el servidor aceptó el socket y nunca respondióespera300,8 s antes de que Node se rinda por sí solo
la clave era incorrecta (401)reintenta cinco veces6.325 ms de retraso, y luego el mismo 401
cien clientes alcanzan el rate limit a la veztodos reintentan con el mismo calendario226 s para vaciarse, frente a 2,2 s
la petición agotó el tiempo y se volvió a enviarla reenvíael provider genera —y factura— la respuesta dos veces
la conexión se cortó a mitad de respuestamuestra el texto parcialindistinguible de una respuesta corta correcta

Nada de esto es un problema de modelado. Todo está en las primeras cien líneas de cualquier producto LLM jamás escrito.

Vuelve a leer esa tabla y pregúntate qué tipo de programa describe. Mantiene una conexión abierta durante cuarenta segundos. Debe poder cancelarse desde un botón. Acumula una respuesta parcial que es válida para mostrar e inválida para guardar. Y se ejecuta en un proceso de servidor o en un edge worker, junto a lo que renderiza la respuesta, manteniendo un socket.

Eso no es un notebook. No es que Python no pueda hacerlo —puede, y mucha gente lo hace—, sino que todo lo que construyeron los trece capítulos anteriores era de otra clase. Los capítulos 1 a 13 tenían en sus manos pesos, gradientes, logits y bytes del tokenizador. A partir de aquí, el código sostiene una conexión, un reintento, una cancelación, estado acumulado y, más adelante, un prompt de permiso. El curso cambia de lenguaje exactamente en la costura donde cambia el objeto.

Así que la regla, escrita una vez:

Si el código tiene pesos, gradientes, logits o bytes del tokenizador en las manos, es Python. Si sostiene una conexión, reintenta, cancela, acumula estado y pide permiso, es TypeScript.

La costura es una sola y cae aquí, entre el capítulo 13 y el capítulo 14. Tres criterios independientes la colocan aquí.

Uno: el ecosistema, contado. Todo lo que cita la mitad izquierda de este curso es Python, y entre los doce cursos auditados para este temario no hay ni un precedente de backpropagation enseñado en otro lenguaje: micrograd (17,4K estrellas), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Escribir el capítulo 5 en TypeScript rompería el vínculo con esas fuentes, y los vínculos son la mitad del valor de un capítulo que existe para ser referenciado más que para posicionar. En este lado la aritmética se invierte: el paquete ai de Vercel está en 89,4M descargas al mes y distribuye la cosa en sí —un bucle de agent con tool calling, exportado como ToolLoopAgent—, así que el concepto al que llega este curso en el capítulo 23 tiene su implementación de referencia en TypeScript, aunque, como mide ese capítulo, nadie se haya puesto de acuerdo en cómo llamarlo; Mastra está en 27,7K estrellas; y los SDK de Anthropic, generados a partir de una sola especificación, declaran 202 endpoints en TypeScript frente a 201 en Python: paridad, no un port de cortesía.

Dos: la fuente normativa de MCP. El esquema de la especificación del Model Context Protocol es un archivo schema.ts. Enseñar el protocolo de el capítulo 26 en otro lenguaje significa enseñar una traducción de su documento fundacional.

Tres: demanda de búsqueda, con una corrección a la suposición obvia. machine learning python es la frase más saturada de internet; ai agent typescript tiene su propia cola saludable. Pero «el ecosistema MCP es sobre todo TypeScript» solo es cierto según cómo cuentes: el registro oficial lista 8.275 servidores en npm frente a 3.603 en PyPI, mientras que por descargas gana Python: 287M al mes para mcp más 72M para fastmcp frente a 195M para @modelcontextprotocol/sdk. MCP es el único territorio genuinamente bilingüe aquí, por eso el capítulo 27 escribe el mismo servidor dos veces en lugar de fingir.

Mostrar detalles

Las cinco excepciones declaradas, para que la regla sea una regla y no un eslogan.

Los capítulos 17, 20 y 29 llevan un segundo panel en Python: implementar muestreo top-p exige tener el vector de probabilidad en la mano y una API HTTP nunca te da uno; poner precio a un fine-tune con honestidad significa ejecutar uno, y un adaptador LoRA son una docena de líneas de nn.Module; y lm-eval-harness, HELM, SWE-bench y τ-bench son Python, así que un harness de evaluación en TypeScript sería la imagen especular del error de backpropagation. El capítulo 27 es bilingüe, por el motivo medido más arriba. El capítulo 28 es Markdown, porque una agent skill es un archivo SKILL.md y darle un lenguaje de programación significaría no haber entendido el formato.

Los trece capítulos en Python no se descartan. Lo que está al otro lado del puerto es lo que construyeron, y la última sección de aquí conecta un cliente con ello.

No puedes aprender nada de esto contra un provider real. No puedes pedirle un 429 en un momento elegido, ni un socket que acepte tu conexión y nunca responda, ni un stream que se detenga en mitad de una palabra; y pagarías por cada experimento, cuando los experimentos interesantes son los que ejecutas cien veces.

Así que el primer programa de esta mitad del curso no es un cliente. Es un servidor hostil: cuarenta líneas de Node puro que hablan el mismo protocolo de cable que un endpoint de chat completions y se portan mal bajo demanda. Todos los números de este capítulo salieron de él.

mock-provider.mjsJS
import { createServer } from "node:http";

const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3;                      // how many requests it will serve at once
let inflight = 0;

const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);

createServer(async (req, res) => {
  const url = new URL(req.url, "http://x");

  if (url.pathname === "/hang") return;                        
  if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }

  if (inflight >= CAPACITY) {                                  
    res.writeHead(429, { "retry-after": "1" });                
    return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
  }
  inflight++;

  const cut = Number(url.searchParams.get("cut") ?? -1);       // abandon after N chunks
  const how = url.searchParams.get("how");                     // "close" = orderly, else reset
  const max = Number(url.searchParams.get("max_tokens") ?? 999);
  const delay = Number(url.searchParams.get("delay") ?? 60);   // ms per token

  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  for (let i = 0; i < Math.min(WORDS.length, max); i++) {
    if (i === cut) {                                           
      how === "close" ? res.end() : res.destroy();             
      inflight--; return;                                      
    }
    await new Promise((r) => setTimeout(r, delay));
    sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
  }
  sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
  res.write("data: [DONE]\n\n");
  inflight--;
  res.end();
}).listen(8787);

Cuatro comportamientos hostiles, una línea cada uno: /hang acepta el socket y nunca escribe en él; /401 rechaza la clave; la comprobación de capacidad produce un 429 genuino con una cabecera Retry-After genuina cuando ya hay tres peticiones en curso; y ?cut=N abandona la respuesta a medias, ya sea reseteando el socket o —con &how=close— cerrándolo de forma ordenada, lo que resulta importar muchísimo. El resto es un stream real de Server-Sent Events: un objeto JSON por línea data:, una línea en blanco entre eventos, la cadena [DONE] al final.1

Ejecútalo, y el resto del capítulo es medición.

terminalBASH
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"
TEXT
data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"length"}]}

data: [DONE]

El cuerpo de la petición, y la clave que nunca sale del servidor

Enlace a la sección: El cuerpo de la petición, y la clave que nunca sale del servidor

Una petición de chat es una lista de mensajes, cada uno con un rol. Esa lista es el estado entero del modelo: no hay memoria entre llamadas, y todo lo que quieras que el modelo sepa tiene que estar dentro del array que envías esta vez. El capítulo 15 trata sobre qué poner ahí y el capítulo 16 sobre lo que cuesta, así que aquí es solo la forma.

call.tsTS
const body = {
  model: "gpt-4.1-mini",
  messages: [
    { role: "system", content: "You explain instruments in one sentence." },
    { role: "user", content: "What is a tide gauge?" },
  ],
  stream: true,
  max_tokens: 200,
};

Esos roles no son decoración. Se renderizan en la plantilla de chat de el capítulo 11 antes de que el modelo vea un solo token, por eso enviar el rol equivocado degrada silenciosamente la respuesta en lugar de lanzar un error.

Una regla sin excepciones: la clave de la API nunca viaja al cliente. Ni en una variable de entorno prefijada para el navegador, ni en una constante de build-time, ni «temporalmente». Una clave en un bundle es una clave en la factura de otra persona en cuestión de días. El navegador habla con tu servidor, tu servidor guarda la clave y habla con el provider; y como tu servidor está en medio, también es el único lugar que puede medir lo que gasta cada usuario, que es donde debe vivir la contabilidad del capítulo 16.

Ahora el experimento sobre el que se construye el capítulo. Una pregunta, un provider simulado que produce trece tokens a 60 ms cada uno, tres formas de preguntar.

Primero, sin streaming. El cliente envía la petición y espera el cuerpo JSON completo.

TEXT
blocking   first visible =  791 ms   complete =  791 ms   finish_reason = stop

Los dos números son iguales, y ese es todo el problema. Durante 791 ms el usuario tiene un spinner, y ni una sola palabra estuvo disponible antes: el servidor tenía la respuesta, byte a byte, y eligió no decir nada.

Segundo, con streaming. Mismo servidor, misma respuesta, mismo trabajo total. La diferencia es un parser.

sse.tsTS
export async function* readSSE(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });   
    let sep: number;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {       
      const event = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      for (const line of event.split("\n")) {
        if (!line.startsWith("data:")) continue;
        const payload = line.slice(5).trim();
        if (payload === "[DONE]") return;
        yield JSON.parse(payload);
      }
    }
  }
}

Hay tres detalles ahí que son estructurales y la mayoría de primeros intentos se saltan los tres. buffer existe porque un chunk de red no tiene relación con un evento: un read() puede devolver medio evento, o dos y medio. La bandera { stream: true } existe porque un carácter UTF-8 multibyte puede partirse entre dos chunks, y sin ella una letra acentuada se convierte al azar en un carácter de sustitución. Y los eventos se separan por una línea en blanco, no por un salto de línea, por eso el bucle busca \n\n.

TEXT
streaming  first visible =   65 ms   complete =  793 ms   finish_reason = stop

Doce veces más rápido hasta la primera palabra, y dos milisegundos más lento hasta la última. El streaming no hace nada más rápido. Cambia lo que hace el usuario durante los mismos 790 ms: leer en lugar de esperar. Ese es todo el beneficio, es enorme, y es la razón por la que todo producto de chat hace streaming.

Tercero, con veinte clientes a la vez. El provider simulado atiende tres peticiones cada vez. Lanza veinte:

TEXT
jitter=true  clients=20  server capacity=3
HTTP requests made: 74   429s received: 54   200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: true

Veinte respuestas, setenta y cuatro peticiones, cincuenta y cuatro rechazos. Nadie perdió nada, todos los clientes recibieron el mismo texto y el único coste visible fue tiempo. Eso es una política de reintentos funcionando. El resto de este capítulo trata sobre las tres formas en que puede fallar.

finish_reason, y dos finales que parecen iguales

Enlace a la sección: finish_reason, y dos finales que parecen iguales

Antes de los fallos, el campo que casi todo el mundo ignora en la primera pasada. Todo stream termina con un evento que lleva finish_reason. stop significa que el modelo decidió que había terminado. length significa que alcanzó el techo de tokens, así que la respuesta está truncada a mitad de frase y no es culpa del modelo. Capítulos posteriores añaden tool_calls (capítulo 18) y filtros de contenido.

Ahora observa dos finales que un cliente ingenuo no puede distinguir. Mismo servidor, mismo retraso, uno truncado por max_tokens y otro donde la conexión se cierra limpiamente después de cinco tokens:

TEXT
max_tokens=5           loop ended NORMALLY   chunks=5  finish_reason=length  text="A tide gauge is a"
socket closed cleanly  loop ended NORMALLY   chunks=5  finish_reason=null    text="A tide gauge is a"
socket destroyed       threw TypeError: terminated (UND_ERR_SOCKET)
                                             chunks=4  finish_reason=null    text="A tide gauge is"

Lee las dos primeras filas con atención. Texto idéntico. Recuento de chunks idéntico. Sin excepción en ninguno de los dos casos. El bucle for await terminó con normalidad ambas veces, porque desde el punto de vista del lector el cuerpo terminó y eso es todo lo que puede hacer un cuerpo. La única diferencia en toda la observación es que uno lleva finish_reason: "length" y el otro no lleva nada en absoluto.

Así que la regla no es «captura errores mientras haces streaming». Es:

Un stream que termina sin un finish_reason no terminó. Se paró.

Trata siempre un finish_reason ausente como un fallo, y nunca persistas ese texto como una respuesta completada. La tercera fila muestra el caso más fácil: un socket destruido sí lanza, y también pierde el chunk que estaba en vuelo, por eso el texto es una palabra más corto que los dos anteriores.

Cinco códigos de estado que son cinco problemas distintos

Enlace a la sección: Cinco códigos de estado que son cinco problemas distintos

El hábito más caro que tiene un producto nuevo es un bloque catch para todo lo que devuelve el provider. Estos códigos no son variaciones de «ha fallado». Son cinco instrucciones, y cuatro se contradicen entre sí.

statusqué significaqué hacer¿esperar?
400tu petición está mal formada: JSON incorrecto, campo desconocido, context demasiado largoarregla el códigonunca
401la clave es incorrecta, falta o ha sido revocadaarregla el desplieguenunca
429rate limit: demasiadas peticiones, o demasiados tokens, por minutoreintentaRetry-After, luego backoff
500el provider se ha rotoreintentabackoff
503el provider está sobrecargado: está activo, está llenoreintentabackoff, y reduce carga

La línea que importa pasa entre los 4xx y el resto. Un 400 o un 401 devuelve exactamente la misma respuesta si lo envías mil veces, porque nada cambia en ninguno de los extremos entre intentos. Reintentarlo no es cautela, es un retraso con pasos extra. Medido: un cliente que hace seis intentos —cinco reintentos con backoff exponencial— y otro que lee primero el código.

TEXT
retry everything  ->  6 requests, gave up after 6,325 ms, still HTTP 401
triage first      ->  1 request,  gave up after     4 ms, still HTTP 401

Seis segundos de spinner para llegar a una respuesta que estaba disponible en cuatro milisegundos. Y esa es la versión suave: los reintentos en un producto suelen estar anidados —un cliente HTTP con reintentos dentro de un job runner con reintentos dentro de una cola con su propia reentrega—, así que seis segundos se convierten en seis minutos de un despliegue permanentemente roto que parece lento.

El triaje son nueve líneas y pertenece a un solo sitio:

classify.tsTS
export type Verdict = "retry" | "retry-after" | "fatal";

export function classify(status: number): Verdict {
  if (status === 429) return "retry-after";        
  if (status === 408 || status >= 500) return "retry";
  return "fatal";  // 400, 401, 403, 404, 422 — nothing changes by waiting
}

Dos más para tu lista: 402, que algunos providers usan para «te has quedado sin crédito» y que necesita una pantalla con un enlace para comprar más en vez de un reintento, y 529 o sus equivalentes específicos de proveedor, que se comportan como 503.

Reintentar es fácil. Reintentar cuándo es la parte con una respuesta correcta medible.

El backoff exponencial es el estándar: espera un retraso base, duplícalo tras cada fallo, detente en un techo. Existe porque un servidor sobrecargado empeora si los clientes que acaban de fallar vuelven directamente.

El problema es que todo el mundo duplica desde el mismo punto de partida. Si cien clientes alcanzan un límite en el mismo momento —y lo harán, porque eso es un pico de tráfico—, entonces los cien esperan 200 ms, los cien reintentan juntos, los cien fallan juntos y los cien esperan 400 ms. El calendario de reintentos los ha sincronizado. Eso es un thundering herd, y la aleatoriedad es la solución.2

sleep=random(0, min(cap, base2n))\text{sleep} = \mathrm{random}\big(0,\ \min(\text{cap},\ \text{base} \cdot 2^{\,n})\big)

Ese único cambio —elegir uniformemente dentro del intervalo en lugar de tomar su extremo superior— se llama full jitter. Es una llamada a Math.random(), y merece la pena medirlo en lugar de creerlo:

backoff.tsTS
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
  Math.min(cap, base * 2 ** n);

export const backoffFull = (n: number, base = 200, cap = 20_000) =>
  Math.random() * Math.min(cap, base * 2 ** n);      

Cien clientes, un servidor que atiende tres cada vez, todo lo demás idéntico, tres ejecuciones cada uno:

peticiones HTTPrechazospeor clienteventana de 50 ms más ocupadawall clock
sin jitter, ejecución 149139110 intentos46 llegadas65,6 s
sin jitter, ejecución 278068019 intentos72 llegadas245,7 s
sin jitter, ejecución 377067018 intentos97 llegadas225,6 s
full jitter, ejecución 13242245 intentos32 llegadas2,2 s
full jitter, ejecución 23132136 intentos31 llegadas2,3 s
full jitter, ejecución 33182186 intentos25 llegadas1,8 s

Dos cosas en esa tabla, y la segunda es la importante.

La primera es la mediana: 226 segundos frente a 2,2, un factor de alrededor de cien, con menos de la mitad de peticiones. La ventana de reintento más ocupada explica por qué. Sin jitter, hasta 97 de los cien clientes llegaron dentro de la misma ranura de 50 milisegundos; el servidor tenía tres, así que 94 fueron rechazados y se fueron a dormir juntos, todavía sincronizados, para volver a hacerlo con una espera más larga. Con jitter, los mismos cien se repartieron por las mismas ventanas en grupos de alrededor de treinta y se vaciaron casi de inmediato.

La segunda es la varianza. Sin jitter: 65,6 s, 245,7 s, 225,6 s. Con él: 2,2, 2,3, 1,8. Un sistema sin jitter no solo rinde mal, rinde de forma impredecible, porque el resultado lo deciden accidentes microscópicos de planificación que eligen cuáles de tres de cien clientes sincronizados llegan primero. Esa es la firma de este bug en producción: un endpoint que va bien, bien, bien, y de pronto tarda cuatro minutos, sin que ningún cambio tuyo lo explique.

Y el reintento más barato es el que nunca ocurre. Pon una puerta de concurrencia delante del provider —un contador que nunca deja que haya más de N peticiones en vuelo— y los mismos veinte clientes que necesitaron 74 peticiones y 7,1 segundos se comportan así:

TEXT
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms

Veinte peticiones para veinte respuestas, cero rechazos, ocho veces más rápido. Un reintento es la disculpa; la puerta es no necesitar una.

Cuando un provider devuelve 429, normalmente te dice cuánto esperar en la cabecera Retry-After.3 Ese número no es un consejo: el provider es la única parte del intercambio que sabe cuándo se reinicia su ventana.

Así que la espera es el mayor de los dos: nunca menos que Retry-After, y nunca menos que tu propio backoff tampoco, porque la cabecera te dice cuándo te perdona el limitador, no cuándo tiene sitio el servidor.

wait.tsTS
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0;   // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt));  

La traza del cliente con peor suerte en la ejecución de veinte clientes muestra que la cabecera hace su trabajo. Sus cuatro primeros sorteos de backoff estuvieron todos por debajo de un segundo, y los cuatro fueron sustituidos:

TEXT
t+   26ms  attempt 0  HTTP 429  -> sleep 1000 ms
t+ 1032ms  attempt 1  HTTP 429  -> sleep 1000 ms
t+ 2034ms  attempt 2  HTTP 429  -> sleep 1000 ms
t+ 3046ms  attempt 3  HTTP 429  -> sleep 1000 ms
t+ 4047ms  attempt 4  HTTP 429  -> sleep 2782 ms
t+ 6852ms  attempt 5  HTTP 200  -> sleep 0 ms

Dos notas prácticas. Retry-After puede ser una fecha HTTP en lugar de un número de segundos, así que parsea ambos. Y los providers aplican rate limit en dos ejes a la vez —peticiones por minuto y tokens por minuto—, por eso los prompts largos se rechazan muy por debajo del límite de peticiones documentado. La cabecera tiene el mismo aspecto en ambos casos; la solución no.

Pide al provider simulado /hang. Acepta la conexión y luego no hace absolutamente nada: sin cabeceras, sin cuerpo, sin cierre. No es exótico: es lo que hace un balanceador de carga cuando el proceso que tiene detrás ha muerto sin cerrar sus sockets.

Dos clientes, una diferencia:

TEXT
AbortSignal.timeout(5s)   gave up after   5.0 s  (TimeoutError: The operation was aborted due to timeout)
no timeout                gave up after 300.8 s  (TypeError: fetch failed)
                          cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT

Trescientos segundos. Cinco minutos de un socket mantenido abierto, una ranura de petición ocupada y un usuario mirando un spinner, para terminar en un TypeError genérico que no dice nada sobre lo ocurrido. Ese número no es un bug: es el timeout de cabeceras por defecto de Node, razonable para un cliente HTTP genérico y catastrófico para una petición de cara al usuario. Todo runtime tiene un valor por defecto así, la mayoría de la gente nunca lo consulta, y la única forma de encontrar el tuyo es colgar un socket a propósito como acabamos de hacer.

Así que: toda petición saliente recibe un deadline explícito, elegido por ti.

deadline.tsTS
const res = await fetch(url, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
  body: JSON.stringify(payload),
  signal: AbortSignal.timeout(20_000),   
});

Para una llamada con streaming, un deadline no basta, porque hay dos fallos distintos. El primero es el stream nunca se abre: no llega ningún evento, y entre diez y treinta segundos está bien. El segundo es el stream se abre y luego se queda parado: los tokens fluían y luego se detuvieron, para siempre, con el socket todavía sano. Un timeout de duración total no puede distinguir un stream parado de una respuesta larga correcta, así que lo que quieres es un idle timeout: un temporizador que se reinicia con cada evento y solo dispara cuando no ha llegado nada durante, por ejemplo, quince segundos.

La cancelación es la misma maquinaria apuntada a una persona. AbortSignal.timeout y un usuario pulsando Detener llegan ambos como un AbortError, así que combínalos y registra cuál disparó:

cancel.tsTS
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();

Abortar importa por una razón que va más allá de la limpieza: los tokens se están generando y facturando mientras tú no estás escuchando. El capítulo 16 pone precio a eso.

Ahora el fallo que cuesta dinero en lugar de tiempo. Una petición agota el tiempo en el cliente, y el movimiento obvio es enviarla de nuevo; pero un timeout no te dice nada sobre si el servidor la recibió. Muy a menudo sí, y sigue trabajando.

Medido. El provider simulado necesita 780 ms para la respuesta. El cliente se rinde a los 300 ms y reintenta. El servidor cuenta cuántas respuestas generó realmente, que es lo que facturaría:

TEXT
idempotency-key: no    attempt 0: TimeoutError after 300 ms  |  attempt 1: TimeoutError after 300 ms
                       answers generated (and billed): 2

idempotency-key: yes   attempt 0: TimeoutError after 300 ms  |  attempt 1: HTTP 200 (replay) id=cmpl_1
                       answers generated (and billed): 1

Sin clave: dos generaciones completas, pagadas dos veces, y el cliente no recibió ninguna. Con clave: el servidor reconoció la segunda petición como la misma petición y respondió al instante con la respuesta que ya había producido, así que el reintento evitó el doble cargo y además fue el intento que por fin tuvo éxito.

Una clave de idempotencia es una cadena única que generas por operación lógica —no por intento— y envías sin cambios en cada reintento de ella. El servidor almacena el resultado contra la clave y lo reproduce. Es el mecanismo que usan las API de pagos, por la misma razón.4

idempotent.tsTS
async function send(url: string, payload: unknown) {
  const key = crypto.randomUUID();      // once per turn, not per attempt

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: { "content-type": "application/json", "idempotency-key": key },  
      signal: AbortSignal.timeout(20_000),
    });
    if (res.ok) return res;
    if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
    await sleep(backoffFull(attempt));
  }
  throw new Error("out of attempts");
}

Dos límites honestos. No todos los providers soportan claves de idempotencia en completions, y cuando el endpoint no es idempotente, el número correcto de reintentos para un POST que puede haberse ejecutado ya es cero. Y un stream que falló a medias no es reproducible en el caso general: o lo reinicias y pagas de nuevo, o conservas el texto parcial y lo marcas como incompleto. Cuál de las dos cosas hace tu producto es una decisión de producto, no de red, y merece la pena tomarla a propósito.

El cliente escrito en este capítulo no tiene ni idea de qué hay detrás del puerto. Apunta su URL base a un provider comercial y hace streaming de tokens desde un modelo de un billón de parámetros. Apúntala a un servidor construido sobre la aritmética del capítulo 13 —sirviendo el modelo que preentrenaste en el capítulo 10, con su KV cache y sus pesos cuantizados— y el mismo código, sin cambios, hace streaming de tokens desde un modelo que construiste tú.

switch.tsTS
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1";  

Esa única línea es la costura de este curso. A un lado está lo que construyeron los primeros trece capítulos; al otro, lo que construyen los siguientes dieciséis. La frontera es limpia porque el contrato es HTTP y SSE, y ninguno de los lados sabe nada más sobre el otro.

Merece la pena fijarse en lo que perdiste al cruzar. Detrás de un endpoint comercial no controlas ni los pesos, ni la implementación de sampling, ni la versión con la que estás hablando, ni si cambió esta mañana. Lo que controlas es el contrato: los mensajes que envías, el deadline que fijas, los códigos que distingues y qué haces cuando no vuelve nada. Es una superficie más pequeña que la que tenías en el capítulo 5, y todos los capítulos restantes tratan sobre usarla bien.

Ahora tienes un cliente que hace streaming, se rinde a tiempo, reintenta las cosas correctas y nunca reintenta las equivocadas. Lo que envía sigue siendo lo que hayas escrito.

El capítulo 15 trata sobre ese contenido, y viene con una disciplina. Internet está lleno de consejos de prompting —ofrécele una propina al modelo, amenázalo, dile que respire hondo— y casi ninguno llega con una medición. Algunas de esas técnicas mueven mucho la salida, otras no la mueven nada, y al menos una empeora una tarea de clasificación mientras cuesta más tokens. Cuál es cuál no es obvio al leerlas, y no se resuelve discutiendo.

Así que el siguiente capítulo construye un banco de pruebas: sesenta casos con respuestas conocidas, cuatro variantes del mismo prompt, ejecutadas en paralelo a través exactamente del cliente que acabas de escribir, tabuladas con los intervalos de confianza de el capítulo 4, porque cuatro variantes sobre veinte casos no distinguen nada en absoluto. Una frase gobierna todo el capítulo: un prompt se mide, no se debate.


Todos los números anteriores salieron del provider simulado, en Node 22 sobre una interfaz loopback, así que las latencias son más limpias de lo que te dará cualquier red real. Es deliberado: ninguno de los fallos que se miden lo causa la red, y un servidor hostil que puedes reiniciar enseña mejor que uno real por el que debes pagar y que no puedes romper.

  1. Server-Sent Events, WHATWG HTML Living Standard, sección 9.2. El formato de cable —campos data:, eventos separados por líneas en blanco, id: y retry:— se define allí, junto con la interfaz EventSource. EventSource no puede enviar un cuerpo de petición ni cabeceras personalizadas, por eso todo cliente LLM parsea el formato a mano sobre fetch en lugar de usarla.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). La fuente de la formulación de «full jitter» usada arriba, con las simulaciones que muestran por qué la versión ingenua sincroniza clientes. El argumento complementario para reducir carga en lugar de encolarla es el capítulo Handling Overload de Beyer, Jones, Petoff y Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016).

  3. Fielding, R., Nottingham, M. y Reschke, J. (eds.), HTTP Semantics, RFC 9110, sección 15, define las clases de códigos de estado; Nottingham, M. y Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), sección 4, define 429 Too Many Requests. Retry-After es RFC 9110 sección 10.2.3, y acepta tanto un número de segundos como una fecha HTTP.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, leído el 7 de septiembre de 2026: la declaración más clara del contrato: una clave por operación lógica, resultados almacenados que se reproducen, un conflicto devuelto mientras el primer intento sigue en vuelo; y el patrón es independiente del provider. Las referencias normativas para las formas de petición y evento usadas aquí son developers.openai.com/api/reference/resources/chat para streaming, códigos de error y rate limits, y platform.claude.com/docs/en/api/messages para la Messages API; ai-sdk.dev/docs es el mejor ejemplo desarrollado de las mismas preocupaciones envueltas en una librería. Todo leído el mismo día.

¿Listo para dejar que elija LIA?

Crea con todos los modelos de IA en un mismo sitio. Empieza gratis hoy.