A túa primeira chamada LLM en produción: streaming, reintentos e timeouts
Crea un provedor que che mente — 429, sockets colgados, streams cortados — e mide que fai o teu cliente. Full jitter: 2,2 s fronte a 226.
Nesta páxina
O capítulo 13 rematou cun cronómetro sobre un modelo que podías tocar. Os pesos estaban na túa memoria, o KV cache podíalo activar ou desactivar ti, e o número que saía — tempo ata o primeiro token — era unha propiedade do teu hardware.
Agora pon ese modelo detrás dun porto, que é o que fai calquera produto, e le de novo o mesmo número. Segue sendo tempo ata o primeiro token, pero xa non é unha propiedade de nada que controles. Agora inclúe un handshake TLS, unha cola no provedor, un limitador de taxa e a posibilidade de que non chegue ningún token en absoluto.
Esa última cláusula é o capítulo. O código que estás a piques de escribir non calcula nada. Abre unha conexión, agarda, analiza o que chega, decide que facer cando non chega nada, volve decidir cando o que chega é un erro e cancélase a si mesmo cando o usuario cambia de idea. Cada unha desas cousas é unha decisión sobre estado ao longo do tempo, e cada unha ten unha resposta incorrecta que se envía a produción e custa cartos.
Esta é a forma do problema, medida, toda ela neste capítulo:
| que pasou | que fai un cliente descoidado | que custa |
|---|---|---|
| o servidor aceptou o socket e nunca respondeu | agarda | 300,8 s antes de que Node se renda pola súa conta |
| a chave era incorrecta (401) | reintenta cinco veces | 6.325 ms de atraso, e logo o mesmo 401 |
| cen clientes bateron co límite de taxa á vez | todos reintentan co mesmo horario | 226 s para baleirar, fronte a 2,2 s |
| a solicitude esgotou o tempo e reenviouse | reenvíaa | o provedor xera — e factura — a resposta dúas veces |
| a conexión caeu a media resposta | mostra o texto parcial | indistinguible dunha resposta curta correcta |
Ningunha destas cousas é un problema de modelaxe. Todas están nas primeiras cen liñas de calquera produto LLM que se escribise nunca.
Por que este capítulo cambia de linguaxe
Ligazón á sección: Por que este capítulo cambia de linguaxeLe de novo esa táboa e pregúntate que tipo de programa describe. Mantén unha conexión aberta durante corenta segundos. Debe poder cancelarse desde un botón. Acumula unha resposta parcial que é válida para mostrar e inválida para gardar. E execútase nun proceso de servidor ou nun edge worker, ao lado do que renderiza a resposta, sostendo un socket.
Iso non é un notebook. Non é que Python non poida facelo — pode, e hai xente que o fai —, senón que todo o que construíron os trece capítulos anteriores era doutra clase. Os capítulos 1 a 13 tiñan pesos, gradients, logits e bytes do tokenizer. A partir de aquí, o código ten unha conexión, un reintento, unha cancelación, estado acumulado e, máis adiante, un prompt de permiso. O curso cambia de linguaxe exactamente na costura onde cambia o obxecto.
Así que a regra, escrita unha vez:
Se o código ten pesos, gradients, logits ou bytes do tokenizer nas mans, é Python. Se sostén unha conexión, reintenta, cancela, acumula estado e pide permiso, é TypeScript.
A costura é única e cae aquí, entre o capítulo 13 e o capítulo 14. Tres criterios independentes póñena aquí.
Un: o ecosistema, contado. Todo o que cita a metade esquerda deste curso é Python, e entre os doce cursos auditados para este temario non hai nin un precedente de backpropagation ensinada noutra linguaxe: micrograd (17,4K estrelas), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Escribir o capítulo 5 en TypeScript rompería a ligazón con esas fontes, e as ligazóns son a metade do valor dun capítulo que existe para ser referenciado máis que para posicionar. Neste lado a aritmética invértese: o paquete ai de Vercel está en 89,4M descargas ao mes e entrega a cousa en si — un loop de agent con tool calling, exportado como ToolLoopAgent — así que o concepto ao que chega este curso no capítulo 23 ten a súa implementación de referencia en TypeScript, aínda que, como mide ese capítulo, ninguén se puxo de acordo nun nome para el; Mastra está en 27,7K estrelas; e os SDK de Anthropic, xerados desde unha soa especificación, declaran 202 endpoints en TypeScript fronte a 201 en Python — paridade, non un port de cortesía.
Dous: a fonte normativa de MCP. O esquema da especificación do Model Context Protocol é un ficheiro schema.ts. Ensinar o protocolo do capítulo 26 noutra linguaxe significa ensinar unha tradución do seu documento fundacional.
Tres: demanda de busca, cunha corrección á suposición obvia. machine learning python é a frase máis saturada de internet; ai agent typescript ten a súa propia cola saudable. Pero «o ecosistema MCP é principalmente TypeScript» só é certo dependendo de como contes: o rexistro oficial lista 8.275 servidores en npm fronte a 3.603 en PyPI, mentres que por descargas gaña Python — 287M ao mes para mcp máis 72M para fastmcp fronte a 195M para @modelcontextprotocol/sdk. MCP é o único territorio realmente bilingüe aquí, e por iso o capítulo 27 escribe o mesmo servidor dúas veces en vez de finxir.
Mostrar detalles
As cinco excepcións declaradas, para que a regra sexa unha regra e non un slogan.
Os capítulos 17, 20 e 29 levan un segundo panel en Python: implementar mostraxe top-p necesita que teñas o vector de probabilidades na man e unha API HTTP nunca cho dá; poñer prezo a un fine-tune con honestidade significa executar un, e un adaptador LoRA son unha ducia de liñas de nn.Module; e lm-eval-harness, HELM, SWE-bench e τ-bench son Python, así que un harness de avaliación en TypeScript sería a imaxe especular do erro de backpropagation. O capítulo 27 é bilingüe, polo motivo medido de arriba. O capítulo 28 é Markdown, porque unha skill de agent é un ficheiro SKILL.md e darlle unha linguaxe de programación significaría non ter entendido o formato.
Os trece capítulos en Python non se descartan. O que hai ao outro lado do porto é o que eles construíron, e a última sección de aquí conecta un cliente con iso.
Un provedor que podes romper
Ligazón á sección: Un provedor que podes romperNon podes aprender nada disto contra un provedor real. Non podes pedirlle un 429 nun momento escollido, nin un socket que acepte a túa conexión e nunca responda, nin un stream que pare no medio dunha palabra — e estarías pagando por cada experimento, cando os experimentos interesantes son os que executas cen veces.
Así que o primeiro programa desta metade do curso non é un cliente. É un servidor hostil: corenta liñas de Node simple que falan o mesmo protocolo de rede ca un endpoint de chat completions e se portan mal baixo demanda. Todos os números deste capítulo saíron del.
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);Catro comportamentos hostís, unha liña cada un: /hang acepta o socket e nunca escribe nel; /401 rexeita a chave; a comprobación de capacidade produce un 429 auténtico cun header Retry-After auténtico cando xa hai tres solicitudes en voo; e ?cut=N abandona a resposta pola metade, ben reiniciando o socket ou — con &how=close — pechándoo de forma ordenada, o que resulta importar moitísimo. O resto é un stream Server-Sent Events real: un obxecto JSON por liña data:, unha liña en branco entre eventos, a cadea [DONE] ao final.1
Execútao, e o resto do capítulo é 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]O corpo da solicitude, e a chave que nunca sae do servidor
Ligazón á sección: O corpo da solicitude, e a chave que nunca sae do servidorUnha solicitude de chat é unha lista de mensaxes, cada unha cun rol. Esa lista é o estado enteiro do modelo: non hai memoria entre chamadas, e todo o que queiras que o modelo saiba ten que estar dentro do array que envías esta vez. O capítulo 15 trata sobre que poñer nel e o capítulo 16 sobre canto custa, así que aquí só está a 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,
};Eses roles non son decoración. Renderízanse no template de chat do capítulo 11 antes de que o modelo vexa un só token, e por iso enviar o rol incorrecto degrada a resposta en silencio no canto de lanzar un erro.
Unha regra sen excepcións: a chave da API nunca viaxa ao cliente. Nin nunha variábel de contorno prefixada para o navegador, nin nunha constante de build-time, nin «temporalmente». Unha chave nun bundle é unha chave na factura doutra persoa en cuestión de días. O navegador fala co teu servidor, o teu servidor garda a chave e fala co provedor — e, como o teu servidor está no medio, tamén é o único lugar que pode medir o que gasta cada usuario, que é onde ten que vivir a contabilidade do capítulo 16.
A mesma pregunta, tres veces
Ligazón á sección: A mesma pregunta, tres vecesAgora o experimento sobre o que está construído o capítulo. Unha pregunta, un provedor simulado que produce trece tokens a 60 ms cada un, tres formas de preguntar.
Primeiro, sen streaming. O cliente envía a solicitude e agarda polo corpo JSON completo.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopOs dous números son iguais, e ese é todo o problema. Durante 791 ms o usuario ten un spinner, e ningunha palabra estivo dispoñible antes — o servidor tiña a resposta, byte a byte, e decidiu non dicir nada.
Segundo, con streaming. Mesmo servidor, mesma resposta, mesmo traballo total. A diferenza é 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);
}
}
}
}Hai tres detalles aí que soportan carga e a maioría dos primeiros intentos saltan os tres. O buffer existe porque un chunk de rede non ten relación cun evento: un read() pode devolver medio evento, ou dous e medio. O flag { stream: true } existe porque un carácter UTF-8 multibyte pode partirse entre dous chunks, e sen el unha letra acentuada convértese nun carácter de substitución ao azar. E os eventos sepáranse por unha liña en branco, non por un salto de liña, que é por que o loop busca \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDoce veces máis rápido ata a primeira palabra, e dous milisegundos máis lento ata a última. O streaming non fai nada máis rápido. Cambia o que está a facer o usuario durante os mesmos 790 ms: ler en vez de agardar. Ese é todo o beneficio, é enorme, e é a razón pola que todos os produtos de chat fan streaming.
Terceiro, con vinte clientes á vez. O provedor simulado atende tres solicitudes á vez. Dispara vinte:
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: trueVinte respostas, setenta e catro solicitudes, cincuenta e catro rexeitamentos. Ninguén perdeu nada, cada cliente recibiu o mesmo texto, e o único custo visíbel foi o tempo. Iso é unha política de reintentos funcionando. O resto deste capítulo trata das tres formas en que pode fallar no canto diso.
finish_reason, e dous finais que parecen iguais
Ligazón á sección: finish_reason, e dous finais que parecen iguaisAntes dos fallos, o campo que case todo o mundo ignora no primeiro pase. Todo stream remata cun evento que leva finish_reason. stop significa que o modelo decidiu que xa acabara. length significa que bateu co teito de tokens, así que a resposta está truncada a media frase e non é culpa do modelo. Capítulos posteriores engaden tool_calls (capítulo 18) e filtros de contido.
Agora mira dous finais que un cliente inxenuo non pode distinguir. Mesmo servidor, mesmo atraso, un truncado por max_tokens e outro no que a conexión se pecha limpamente despois 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"Le con coidado as dúas primeiras filas. Texto idéntico. Conta de chunks idéntica. Sen excepción en ningún dos dous casos. O loop for await rematou normalmente as dúas veces, porque desde o punto de vista do reader o corpo rematou e iso é todo o que pode facer un corpo. A única diferenza en toda a observación é que un leva finish_reason: "length" e o outro non leva nada.
Así que a regra non é «captura erros mentres fas streaming». É:
Un stream que remata sen un
finish_reasonnon rematou. Parou.
Trata un finish_reason ausente como un fallo, sempre, e nunca persistas ese texto como unha resposta completada. A terceira fila mostra o caso máis doado: un socket destruído si lanza, e tamén perde o chunk que estaba en voo, por iso o texto é unha palabra máis curto ca os dous de arriba.
Cinco códigos de estado que son cinco problemas distintos
Ligazón á sección: Cinco códigos de estado que son cinco problemas distintosO hábito máis caro que ten un produto novo é un único bloque catch para todo o que devolve o provedor. Estes códigos non son variacións de «fallou». Son cinco instrucións, e catro delas contradínse.
| status | que significa | que facer | agardar? |
|---|---|---|---|
| 400 | a túa solicitude está mal formada — JSON incorrecto, campo descoñecido, context demasiado longo | arranxa o código | nunca |
| 401 | a chave é incorrecta, falta ou foi revogada | arranxa o deployment | nunca |
| 429 | límite de taxa: demasiadas solicitudes, ou demasiados tokens, por minuto | reintenta | Retry-After, logo backoff |
| 500 | o provedor rompeu | reintenta | backoff |
| 503 | o provedor está sobrecargado — está en pé, está cheo | reintenta | backoff, e reduce carga |
A liña que importa vai entre 4xx e o resto. Un 400 ou un 401 devolve exactamente a mesma resposta se o envías mil veces, porque nada cambia en ningún dos extremos entre intentos. Reintentalo non é prudencia, é un atraso con pasos extra. Medido: un cliente que fai seis intentos — cinco reintentos con backoff exponencial —, e outro que le primeiro o 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 chegar a unha resposta que estaba dispoñible en catro milisegundos. E esa é a versión suave: os reintentos nun produto adoitan estar aniñados — un cliente HTTP que reintenta dentro dun job runner que reintenta dentro dunha cola coa súa propia reentrega —, así que seis segundos convértense en seis minutos dun deployment permanentemente roto parecendo un lento.
A triage son nove liñas e pertence a un só lugar:
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
}Dúas máis para a túa lista: 402, que algúns provedores usan para «quedaches sen crédito» e que necesita unha pantalla cunha ligazón para mercar máis en vez dun reintento, e 529 ou os seus equivalentes específicos de provedor, que se comportan como 503.
Backoff, e que compra realmente o jitter
Ligazón á sección: Backoff, e que compra realmente o jitterReintentar é doado. Reintentar cando é a parte cunha resposta correcta medíbel.
O backoff exponencial é o estándar: agardar un atraso base, duplicalo despois de cada fallo, parar nun teito. Existe porque un servidor sobrecargado empeora se os clientes que acaban de fallar volven de inmediato.
O problema é que todo o mundo duplica desde o mesmo punto de partida. Se cen clientes baten cun límite no mesmo momento — e vano facer, porque iso é o que é un pico de tráfico — entón os cen agardan 200 ms, os cen reintentan xuntos, os cen fallan xuntos e os cen agardan 400 ms. O calendario de reintentos sincronizounos. Iso é unha thundering herd, e a aleatoriedade é a solución.2
Ese único cambio — escoller uniformemente dentro do intervalo en vez de tomar o seu extremo superior — chámase full jitter. É unha chamada a Math.random(), e paga a pena medilo en vez de crelo:
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); Cen clientes, un servidor que atende tres á vez, todo o demais idéntico, tres execucións cada un:
| solicitudes HTTP | rexeitamentos | peor cliente | xanela de 50 ms máis ocupada | tempo total | |
|---|---|---|---|---|---|
| sen jitter, execución 1 | 491 | 391 | 10 intentos | 46 chegadas | 65,6 s |
| sen jitter, execución 2 | 780 | 680 | 19 intentos | 72 chegadas | 245,7 s |
| sen jitter, execución 3 | 770 | 670 | 18 intentos | 97 chegadas | 225,6 s |
| full jitter, execución 1 | 324 | 224 | 5 intentos | 32 chegadas | 2,2 s |
| full jitter, execución 2 | 313 | 213 | 6 intentos | 31 chegadas | 2,3 s |
| full jitter, execución 3 | 318 | 218 | 6 intentos | 25 chegadas | 1,8 s |
Hai dúas cousas nesa táboa, e a segunda é a importante.
A primeira é a mediana: 226 segundos fronte a 2,2, un factor duns cen, con menos da metade de solicitudes. A xanela de reintento máis ocupada di por que. Sen jitter, ata 97 dos cen clientes chegaron dentro do mesmo slot de 50 milisegundos; o servidor tiña tres, así que 94 foron rexeitados e durmiron xuntos, aínda sincronizados, para facelo de novo cunha espera máis longa. Con jitter, os mesmos cen espalláronse polas mesmas xanelas en grupos duns trinta e baleiráronse case de inmediato.
A segunda é a varianza. Sen jitter: 65,6 s, 245,7 s, 225,6 s. Con el: 2,2, 2,3, 1,8. Un sistema sen jitter non só rende mal: rende de forma imprevisíbel, porque o resultado decídeno accidentes microscópicos de programación que escollen que tres de cen clientes sincronizados chegan primeiro. Esa é a sinatura deste bug en produción: un endpoint que vai ben, ben, ben, e logo tarda catro minutos, sen que ningún cambio teu o explique.
E o reintento máis barato é o que nunca ocorre. Pon unha porta de concorrencia diante do provedor — un contador que nunca deixa que haxa máis de N solicitudes en voo — e os mesmos vinte clientes que necesitaron 74 solicitudes e 7,1 segundos compórtanse así:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msVinte solicitudes para vinte respostas, cero rexeitamentos, oito veces máis rápido. Un reintento é a desculpa; a porta é non necesitala.
Retry-After é un chan, non unha suxestión
Ligazón á sección: Retry-After é un chan, non unha suxestiónCando un provedor devolve 429 normalmente diche canto agardar, no header Retry-After.3 Ese número non é un consello: o provedor é a única parte do intercambio que sabe cando se reinicia a súa xanela.
Así que a espera é a maior das dúas: nunca menos ca Retry-After, e nunca menos ca o teu propio backoff tampouco, porque o header che di cando o limitador che perdoa e non cando o servidor ten sitio.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); A traza do cliente con peor sorte na execución de vinte clientes mostra o header facendo o seu traballo. As súas catro primeiras tiradas de backoff estiveron todas por debaixo dun segundo, e as catro foron sobrescritas:
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 msDúas notas prácticas. Retry-After pode ser unha data HTTP en vez dun número de segundos, así que analiza as dúas. E os provedores aplican rate-limit en dous eixes á vez — solicitudes por minuto e tokens por minuto —, que é por que os prompts longos son rexeitados moi por debaixo do límite de solicitudes documentado. O header ten o mesmo aspecto nos dous casos; a solución non.
O timeout que ninguén escolleu
Ligazón á sección: O timeout que ninguén escolleuPídelle ao provedor simulado /hang. Acepta a conexión e logo non fai nada en absoluto: sen headers, sen corpo, sen peche. Isto non é exótico: é o que fai un balanceador de carga cando o proceso que hai detrás morreu sen pechar os seus sockets.
Dous clientes, unha diferenza:
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_TIMEOUTTrescentos segundos. Cinco minutos cun socket aberto, un slot de solicitude ocupado e un usuario mirando para un spinner, rematando nun TypeError xenérico que non di nada sobre o que pasou. Ese número non é un bug: é o timeout de headers predeterminado de Node, razoábel para un cliente HTTP xenérico e catastrófico para unha solicitude de cara ao usuario. Cada runtime ten un predeterminado así, a maioría da xente nunca o consulta, e a única forma de atopar o teu é colgar un socket a propósito como acabamos de facer.
Así que: cada solicitude saínte recibe un deadline explícito, escollido 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 unha chamada con streaming un deadline non abonda, porque hai dous fallos distintos. O primeiro é o stream nunca abre: non chega ningún evento, e entre dez e trinta segundos é correcto. O segundo é o stream abre e logo queda parado: fluíron tokens e logo pararon, para sempre, co socket aínda san. Un timeout de duración total non pode distinguir un stream parado dunha resposta longa correcta, así que o que queres é un idle timeout — un temporizador que se reinicia con cada evento e salta só cando non chegou nada durante, digamos, quince segundos.
A cancelación é a mesma maquinaria apuntada a unha persoa. AbortSignal.timeout e un usuario premendo Deter chegan ambos como un AbortError, así que combínaos e rexistra cal foi o que saltou:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abortar importa por unha razón alén da orde: os tokens estanse xerando e facturando mentres ti non estás escoitando. O capítulo 16 ponlle prezo a iso.
Que é seguro reintentar
Ligazón á sección: Que é seguro reintentarAgora o fallo que custa cartos en vez de tempo. Unha solicitude esgota o tempo no cliente, e o movemento obvio é enviala de novo — pero un timeout non che di nada sobre se o servidor a recibiu. Moi a miúdo recibiuna, e segue traballando.
Medido. O provedor simulado necesita 780 ms para a resposta. O cliente rende aos 300 ms e reintenta. O servidor conta cantas respostas xerou realmente, que é o 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): 1Sen chave: dúas xeracións completas, pagadas dúas veces, e o cliente non recibiu ningunha delas. Con chave: o servidor recoñeceu a segunda solicitude como a mesma solicitude e respondeu ao instante coa resposta que xa producira, así que o reintento evitou o dobre cargo e foi o intento que finalmente tivo éxito.
Unha idempotency key é unha cadea única que xeras por operación lóxica — non por intento — e envías sen cambiar en cada reintento dela. O servidor garda o resultado contra a chave e reprodúceo. É o mecanismo que usan as API de pagos, pola mesma 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");
}Dous límites honestos. Non todos os provedores soportan idempotency keys en completions, e onde o endpoint non é idempotente, o número correcto de reintentos para un POST que pode xa terse executado é cero. E un stream que fallou pola metade non é reproducíbel no caso xeral: ou o reinicias e pagas de novo, ou gardas o texto parcial e márcalo como incompleto. Cal desas dúas cousas fai o teu produto é unha decisión de produto, non de rede, e paga a pena tomala a propósito.
Pechar a costura
Ligazón á sección: Pechar a costuraO cliente escrito neste capítulo non ten nin idea do que hai detrás do porto. Apunta o seu URL base a un provedor comercial e fai streaming de tokens desde un modelo dun billón de parámetros. Apúntao a un servidor construído sobre a aritmética do capítulo 13 — servindo o modelo que preadestraches no capítulo 10, co seu KV cache e os seus pesos cuantizados — e o mesmo código, sen cambios, fai streaming de tokens desde un modelo que construíches ti.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Esa única liña é a costura deste curso. Dun lado está o que construíron os primeiros trece capítulos; do outro, o que constrúen os dezaseis seguintes. A fronteira é limpa porque o contrato é HTTP e SSE, e ningún lado sabe nada máis do outro.
Paga a pena reparar no que perdiches ao cruzar. Detrás dun endpoint comercial non controlas nin os pesos, nin a implementación de mostraxe, nin a versión coa que estás a falar, nin se cambiou esta mañá. O que controlas é o contrato: as mensaxes que envías, o deadline que marcas, os códigos que distingues e o que fas cando non volve nada. É unha superficie máis pequena ca a que tiñas no capítulo 5, e todos os capítulos que quedan tratan de usala ben.
A onde vai isto agora
Ligazón á sección: A onde vai isto agoraAgora tes un cliente que fai streaming, rende a tempo, reintenta as cousas correctas e nunca reintenta as incorrectas. O que envía segue sendo o que escribiches.
O capítulo 15 trata sobre ese contido, e vén cunha disciplina. Internet está chea de consellos de prompting — ofrecerlle unha propina ao modelo, ameazalo, dicirlle que respire fondo — e case ningún chega cunha medición. Algunhas desas técnicas moven moito a saída, outras non a moven nada, e polo menos unha empeora unha tarefa de clasificación mentres custa máis tokens. Cal é cal non é obvio ao lelas, e non se resolve discutindo.
Así que o seguinte capítulo constrúe un banco: sesenta casos con respostas coñecidas, catro variantes do mesmo prompt, executadas en paralelo exactamente a través do cliente que acabas de escribir, tabuladas cos intervalos de confianza do capítulo 4 — porque catro variantes sobre vinte casos non distinguen nada en absoluto. Unha frase goberna todo o capítulo: un prompt mídese, non se debate.
Fontes e método
Ligazón á sección: Fontes e métodoTodos os números de arriba saíron do provedor simulado, en Node 22 sobre unha interface loopback, así que as latencias son máis limpas do que che dará calquera rede real. É deliberado: ningún dos fallos medidos é causado pola rede, e un servidor hostil que podes reiniciar ensina mellor ca un real polo que tes que pagar e que non podes romper.
Referencias
Ligazón á sección: Referencias-
Server-Sent Events, WHATWG HTML Living Standard, sección 9.2. O formato de rede — campos
data:, eventos separados por liñas en branco,id:eretry:— defínese alí, xunto coa interfaceEventSource.EventSourcenon pode enviar un corpo de solicitude nin headers personalizados, que é por que todo cliente LLM analiza o formato á man sobrefetchen vez de usalo. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). A fonte da formulación «full jitter» usada arriba, coas simulacións que mostran por que a versión inxenua sincroniza clientes. O argumento compañeiro para reducir carga no canto de poñela en cola é o capítulo Handling Overload de Beyer, Jones, Petoff e Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. e Reschke, J. (eds.), HTTP Semantics, RFC 9110, sección 15, define as clases de códigos de estado; Nottingham, M. e Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), sección 4, define 429 Too Many Requests.
Retry-Afteré RFC 9110 sección 10.2.3, e acepta tanto un número de segundos como unha data HTTP. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, lido o 7 de setembro de 2026 — a declaración máis clara do contrato: unha chave por operación lóxica, resultados gardados que se reproducen, un conflito devolto mentres o primeiro intento segue en voo — e o patrón é independente do provedor. As referencias normativas para as formas de solicitude e evento usadas aquí sondevelopers.openai.com/api/reference/resources/chatpara streaming, códigos de erro e límites de taxa, eplatform.claude.com/docs/en/api/messagespara a Messages API;ai-sdk.dev/docsé o mellor exemplo traballado das mesmas preocupacións envoltas nunha biblioteca. Todas lidas o mesmo día. ↩