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 descuidado | qué cuesta |
|---|---|---|
| el servidor aceptó el socket y nunca respondió | espera | 300,8 s antes de que Node se rinda por sí solo |
| la clave era incorrecta (401) | reintenta cinco veces | 6.325 ms de retraso, y luego el mismo 401 |
| cien clientes alcanzan el rate limit a la vez | todos reintentan con el mismo calendario | 226 s para vaciarse, frente a 2,2 s |
| la petición agotó el tiempo y se volvió a enviar | la reenvía | el provider genera —y factura— la respuesta dos veces |
| la conexión se cortó a mitad de respuesta | muestra el texto parcial | indistinguible 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.
Por qué este capítulo cambia de lenguaje
Enlace a la sección: Por qué este capítulo cambia de lenguajeVuelve 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.
Un provider que puedes romper
Enlace a la sección: Un provider que puedes romperNo 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.
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.
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"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 servidorUna 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.
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.
La misma pregunta, tres veces
Enlace a la sección: La misma pregunta, tres vecesAhora 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.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopLos 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.
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.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDoce 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:
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: trueVeinte 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 igualesAntes 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:
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_reasonno 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 distintosEl 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í.
| status | qué significa | qué hacer | ¿esperar? |
|---|---|---|---|
| 400 | tu petición está mal formada: JSON incorrecto, campo desconocido, context demasiado largo | arregla el código | nunca |
| 401 | la clave es incorrecta, falta o ha sido revocada | arregla el despliegue | nunca |
| 429 | rate limit: demasiadas peticiones, o demasiados tokens, por minuto | reintenta | Retry-After, luego backoff |
| 500 | el provider se ha roto | reintenta | backoff |
| 503 | el provider está sobrecargado: está activo, está lleno | reintenta | backoff, 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.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Seis 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:
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.
Backoff, y qué compra realmente el jitter
Enlace a la sección: Backoff, y qué compra realmente el jitterReintentar 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
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:
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 HTTP | rechazos | peor cliente | ventana de 50 ms más ocupada | wall clock | |
|---|---|---|---|---|---|
| sin jitter, ejecución 1 | 491 | 391 | 10 intentos | 46 llegadas | 65,6 s |
| sin jitter, ejecución 2 | 780 | 680 | 19 intentos | 72 llegadas | 245,7 s |
| sin jitter, ejecución 3 | 770 | 670 | 18 intentos | 97 llegadas | 225,6 s |
| full jitter, ejecución 1 | 324 | 224 | 5 intentos | 32 llegadas | 2,2 s |
| full jitter, ejecución 2 | 313 | 213 | 6 intentos | 31 llegadas | 2,3 s |
| full jitter, ejecución 3 | 318 | 218 | 6 intentos | 25 llegadas | 1,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í:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msVeinte peticiones para veinte respuestas, cero rechazos, ocho veces más rápido. Un reintento es la disculpa; la puerta es no necesitar una.
Retry-After es un suelo, no una sugerencia
Enlace a la sección: Retry-After es un suelo, no una sugerenciaCuando 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.
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:
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 msDos 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.
El timeout que nadie eligió
Enlace a la sección: El timeout que nadie eligió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:
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_TIMEOUTTrescientos 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.
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ó:
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.
Qué es seguro reintentar
Enlace a la sección: Qué es seguro reintentarAhora 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:
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): 1Sin 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
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.
Cerrar la costura
Enlace a la sección: Cerrar la costuraEl 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ú.
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.
Hacia dónde va esto ahora
Enlace a la sección: Hacia dónde va esto ahoraAhora 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.
Fuentes y método
Enlace a la sección: Fuentes y métodoTodos 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.
Referencias
Enlace a la sección: Referencias-
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:yretry:— se define allí, junto con la interfazEventSource.EventSourceno puede enviar un cuerpo de petición ni cabeceras personalizadas, por eso todo cliente LLM parsea el formato a mano sobrefetchen lugar de usarla. ↩ -
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). ↩
-
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-Afteres RFC 9110 sección 10.2.3, y acepta tanto un número de segundos como una fecha HTTP. ↩ -
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í sondevelopers.openai.com/api/reference/resources/chatpara streaming, códigos de error y rate limits, yplatform.claude.com/docs/en/api/messagespara la Messages API;ai-sdk.dev/docses el mejor ejemplo desarrollado de las mismas preocupaciones envueltas en una librería. Todo leído el mismo día. ↩