Panggilan LLM Produksi Pertamamu: Streaming, Retry, Timeout
Bangun provider yang berbohong: 429, socket macet, stream terpotong. Ukur klienmu. Full jitter: 2,2 detik vs 226.
Di halaman ini
Bab 13 berakhir dengan stopwatch pada model yang bisa kamu sentuh. Weights ada di memorimu, KV cache milikmu untuk diaktifkan atau dinonaktifkan, dan angka yang keluar — time to first token — adalah properti hardware-mu.
Sekarang letakkan model itu di balik port, seperti yang dilakukan setiap produk, lalu baca angka yang sama lagi. Itu masih time to first token, tetapi bukan lagi properti dari apa pun yang kamu kendalikan. Kini angka itu mencakup TLS handshake, antrean di provider, rate limiter, dan kemungkinan bahwa tidak ada token yang pernah tiba sama sekali.
Klausa terakhir itulah bab ini. Kode yang akan kamu tulis tidak menghitung apa pun. Ia membuka koneksi, menunggu, mem-parse apa yang tiba, memutuskan apa yang harus dilakukan ketika tidak ada yang tiba, memutuskan lagi ketika yang tiba adalah error, dan membatalkan dirinya sendiri ketika pengguna berubah pikiran. Masing-masing adalah keputusan tentang state over time, dan masing-masing punya jawaban salah yang bisa terkirim ke produksi dan menghabiskan uang.
Berikut bentuk masalahnya, terukur, semuanya dalam bab ini:
| apa yang terjadi | apa yang dilakukan klien ceroboh | biayanya |
|---|---|---|
| server menerima socket dan tidak pernah membalas | menunggu | 300,8 dtk sebelum Node menyerah sendiri |
| key salah (401) | mencoba ulang lima kali | delay 6.325 md, lalu 401 yang sama |
| seratus klien terkena rate limit bersama-sama | semua retry dengan jadwal yang sama | 226 dtk untuk menguras, dibanding 2,2 dtk |
| request timeout dan dikirim ulang | mengirim ulang | provider menghasilkan — dan menagih — jawaban dua kali |
| koneksi terputus di tengah jawaban | menampilkan teks parsial | tidak bisa dibedakan dari jawaban pendek yang benar |
Tak satu pun dari ini adalah masalah modelling. Semuanya ada dalam seratus baris pertama setiap produk LLM yang pernah ditulis.
Mengapa bab ini berganti bahasa
Tautan ke bagian: Mengapa bab ini berganti bahasaBaca tabel itu lagi dan tanyakan program seperti apa yang dideskripsikannya. Ia menahan koneksi tetap terbuka selama empat puluh detik. Ia harus bisa dibatalkan dari tombol. Ia mengakumulasi jawaban parsial yang valid untuk ditampilkan dan tidak valid untuk disimpan. Dan ia berjalan di proses server atau di edge worker, di sebelah hal yang merender jawaban, sambil memegang socket.
Itu bukan notebook. Bukan berarti Python tidak bisa melakukannya — bisa, dan orang-orang melakukannya — melainkan bahwa semua yang dibangun tiga belas bab sebelumnya berjenis lain. Bab 1 sampai 13 memegang weights, gradients, logits dan byte tokenizer. Mulai dari sini kode memegang koneksi, retry, pembatalan, state terakumulasi dan, nanti, prompt izin. Kursus ini berganti bahasa tepat di sambungan tempat objeknya berubah.
Jadi aturannya, ditulis sekali:
Jika kode memegang weights, gradients, logits atau byte tokenizer, itu Python. Jika ia memegang koneksi, retries, membatalkan, mengakumulasi state dan meminta izin, itu TypeScript.
Sambungannya tunggal dan jatuh di sini, antara Bab 13 dan Bab 14. Tiga kriteria independen menaruhnya di sini.
Satu: ekosistem, dihitung. Semua yang dikutip paruh kiri kursus ini adalah Python, dan dari dua belas kursus yang diaudit untuk silabus ini tidak ada satu pun preseden backpropagation diajarkan dalam bahasa lain: micrograd (17,4K bintang), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Menulis Bab 5 dalam TypeScript akan memutus tautan dengan sumber-sumber itu, dan tautan adalah setengah nilai dari bab yang hadir untuk dirujuk, bukan untuk ranking. Di sisi ini aritmetikanya berbalik: paket ai milik Vercel berada di 89,4 juta unduhan per bulan dan mengirimkan benda itu sendiri — loop agent tool-calling, diekspor sebagai ToolLoopAgent — jadi konsep yang dicapai kursus ini di Bab 23 memiliki implementasi referensi dalam TypeScript, meskipun, seperti diukur bab itu, belum ada yang sepakat soal namanya; Mastra berada di 27,7K bintang; dan SDK Anthropic, yang digenerasi dari satu spesifikasi, mendeklarasikan 202 endpoint di TypeScript dibanding 201 di Python — parity, bukan port basa-basi.
Dua: sumber normatif MCP. Schema spesifikasi Model Context Protocol adalah file schema.ts. Mengajarkan protokol Bab 26 dalam bahasa lain berarti mengajarkan terjemahan dari dokumen pendirinya.
Tiga: permintaan pencarian, dengan koreksi atas tebakan yang jelas. machine learning python adalah frasa paling jenuh di internet; ai agent typescript punya ekor pencarian sehatnya sendiri. Tetapi “ekosistem MCP sebagian besar TypeScript” hanya benar tergantung cara kamu menghitung: registry resmi mencantumkan 8.275 server di npm dibanding 3.603 di PyPI, sementara berdasarkan unduhan Python menang — 287 juta per bulan untuk mcp plus 72 juta untuk fastmcp dibanding 195 juta untuk @modelcontextprotocol/sdk. MCP adalah satu wilayah yang benar-benar bilingual di sini, itulah sebabnya Bab 27 menulis server yang sama dua kali alih-alih berpura-pura.
Tampilkan detail
Lima pengecualian yang dinyatakan, agar aturan ini aturan dan bukan slogan.
Bab 17, 20, dan 29 membawa panel kedua dalam Python: mengimplementasikan sampling top-p membutuhkan vector probabilitas di tanganmu dan HTTP API tidak pernah memberikannya; memberi harga fine-tune secara jujur berarti menjalankannya, dan adapter LoRA adalah belasan baris nn.Module; dan lm-eval-harness, HELM, SWE-bench, dan τ-bench adalah Python, jadi evaluation harness dalam TypeScript akan menjadi bayangan cermin dari kesalahan backpropagation. Bab 27 bilingual, karena alasan terukur di atas. Bab 28 adalah Markdown, karena agent skill adalah file SKILL.md dan memberinya bahasa pemrograman berarti tidak memahami formatnya.
Tiga belas bab Python tidak dibuang. Yang ada di sisi lain port adalah apa yang mereka bangun, dan bagian terakhir di sini menghubungkan klien ke sana.
Provider yang bisa kamu rusak
Tautan ke bagian: Provider yang bisa kamu rusakKamu tidak bisa mempelajari semua ini dengan provider nyata. Kamu tidak bisa meminta 429 pada momen yang kamu pilih, atau socket yang menerima koneksimu lalu tidak pernah menjawab, atau stream yang berhenti di tengah kata — dan kamu akan membayar setiap eksperimen, padahal eksperimen yang menarik adalah yang kamu jalankan seratus kali.
Jadi program pertama di paruh kursus ini bukan klien. Ia adalah server bermusuhan: empat puluh baris Node polos yang berbicara dengan wire protocol yang sama seperti endpoint chat completions dan sengaja berperilaku buruk sesuai permintaan. Setiap angka dalam bab ini berasal darinya.
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);Empat perilaku bermusuhan, masing-masing satu baris: /hang menerima socket dan tidak pernah menulis ke sana; /401 menolak key; pemeriksaan kapasitas menghasilkan 429 asli dengan header Retry-After asli begitu tiga request sudah berjalan; dan ?cut=N meninggalkan jawaban di tengah jalan, baik dengan mereset socket atau — dengan &how=close — menutupnya secara tertib, yang ternyata sangat penting. Sisanya adalah stream Server-Sent Events sungguhan: satu objek JSON per baris data:, satu baris kosong di antara event, string [DONE] di akhir.1
Jalankan, dan sisa bab ini adalah pengukuran.
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]Request body, dan key yang tidak pernah meninggalkan server
Tautan ke bagian: Request body, dan key yang tidak pernah meninggalkan serverRequest chat adalah daftar pesan, masing-masing dengan role. Daftar itu adalah seluruh state model: tidak ada memori antar-panggilan, dan apa pun yang kamu ingin model ketahui harus ada di dalam array yang kamu kirim kali ini. Bab 15 membahas apa yang harus dimasukkan ke dalamnya dan Bab 16 membahas biayanya, jadi di sini kita hanya melihat bentuknya.
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,
};Role-role itu bukan dekorasi. Mereka dirender ke chat template dari Bab 11 sebelum model melihat satu token pun, itulah sebabnya mengirim role yang salah menurunkan kualitas jawaban secara diam-diam alih-alih memunculkan error.
Satu aturan tanpa pengecualian: API key tidak pernah berjalan ke klien. Tidak dalam environment variable berprefiks untuk browser, tidak dalam konstanta build-time, tidak “sementara”. Key di bundle adalah key di tagihan orang lain dalam hitungan hari. Browser berbicara ke servermu, servermu memegang key dan berbicara ke provider — dan karena servermu berada di tengah, server juga satu-satunya tempat yang bisa mengukur pengeluaran tiap pengguna, tempat accounting Bab 16 harus hidup.
Pertanyaan yang sama, tiga kali
Tautan ke bagian: Pertanyaan yang sama, tiga kaliSekarang eksperimen yang menjadi dasar bab ini. Satu pertanyaan, satu mock provider yang menghasilkan tiga belas token pada 60 md masing-masing, tiga cara bertanya.
Pertama, tanpa streaming. Klien mengirim request dan menunggu seluruh body JSON.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopDua angkanya sama, dan itulah seluruh masalahnya. Selama 791 md pengguna melihat spinner, dan tidak satu kata pun tersedia lebih awal — server punya jawabannya, byte demi byte, dan memilih untuk tidak mengatakan apa-apa.
Kedua, dengan streaming. Server yang sama, jawaban yang sama, kerja total yang sama. Bedanya adalah 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);
}
}
}
}Tiga detail di sana adalah penyangga utama dan kebanyakan percobaan pertama melewatkan ketiganya. buffer ada karena network chunk tidak punya hubungan dengan event: satu read() bisa mengembalikan setengah event, atau dua setengah. Flag { stream: true } ada karena karakter UTF-8 multi-byte bisa terbelah di dua chunk, dan tanpanya huruf beraksen bisa menjadi karakter pengganti secara acak. Dan event dipisahkan oleh baris kosong, bukan newline, itulah sebabnya loop mencari \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopDua belas kali lebih cepat ke kata pertama, dan dua milidetik lebih lambat ke kata terakhir. Streaming tidak membuat apa pun lebih cepat. Ia mengubah apa yang dilakukan pengguna selama 790 md yang sama: membaca alih-alih menunggu. Itulah seluruh manfaatnya, manfaatnya besar, dan itulah alasan setiap produk chat melakukan streaming.
Ketiga, dengan dua puluh klien sekaligus. Mock provider melayani tiga request pada satu waktu. Tembakkan dua puluh:
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: trueDua puluh jawaban, tujuh puluh empat request, lima puluh empat penolakan. Tidak ada yang kehilangan apa pun, setiap klien mendapat teks yang sama, dan satu-satunya biaya yang terlihat adalah waktu. Itulah retry policy yang bekerja. Sisa bab ini membahas tiga cara ia bisa gagal.
finish_reason, dan dua akhir yang terlihat sama
Tautan ke bagian: finish_reason, dan dua akhir yang terlihat samaSebelum kegagalan, field yang hampir semua orang abaikan pada percobaan pertama. Setiap stream berakhir dengan event yang membawa finish_reason. stop berarti model memutuskan ia selesai. length berarti ia mencapai batas token, jadi jawaban terpotong di tengah kalimat dan itu bukan kesalahan model. Bab-bab berikutnya menambahkan tool_calls (Bab 18) dan content filter.
Sekarang lihat dua akhir yang tidak bisa dibedakan klien naif. Server yang sama, delay yang sama, satu dipotong oleh max_tokens dan satu lagi koneksinya ditutup bersih setelah lima token:
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"Baca dua baris pertama dengan saksama. Teks identik. Jumlah chunk identik. Tidak ada exception di keduanya. Loop for await selesai normal pada keduanya, karena dari sudut pandang reader, body berakhir dan hanya itu yang bisa dilakukan body. Satu-satunya perbedaan dalam seluruh observasi adalah satu membawa finish_reason: "length" dan yang lain tidak membawa apa pun.
Jadi aturannya bukan “tangkap error saat streaming”. Aturannya adalah:
Stream yang berakhir tanpa
finish_reasontidak berakhir. Ia berhenti.
Perlakukan finish_reason yang hilang sebagai kegagalan, selalu, dan jangan pernah persist teks itu sebagai jawaban selesai. Baris ketiga menunjukkan kasus yang lebih mudah — socket yang dihancurkan memang throw, dan juga kehilangan chunk yang sedang berjalan, itulah sebabnya teksnya satu kata lebih pendek daripada dua di atas.
Lima status code yang merupakan lima masalah berbeda
Tautan ke bagian: Lima status code yang merupakan lima masalah berbedaKebiasaan paling mahal produk baru adalah satu blok catch untuk semua yang dikembalikan provider. Kode-kode ini bukan variasi dari “gagal”. Mereka adalah lima instruksi, dan empat di antaranya saling bertentangan.
| status | artinya | apa yang dilakukan | tunggu? |
|---|---|---|---|
| 400 | request-mu malformed — JSON buruk, field tidak dikenal, context terlalu panjang | perbaiki kode | tidak pernah |
| 401 | key salah, hilang, atau dicabut | perbaiki deployment | tidak pernah |
| 429 | rate limit: terlalu banyak request, atau terlalu banyak token, per menit | retry | Retry-After, lalu backoff |
| 500 | provider rusak | retry | backoff |
| 503 | provider overload — ia hidup, ia penuh | retry | backoff, dan shed load |
Garis yang penting berada di antara 4xx dan sisanya. 400 atau 401 mengembalikan jawaban yang persis sama jika kamu mengirimnya seribu kali, karena tidak ada yang berubah di kedua ujung antara percobaan. Mencoba ulang bukan kehati-hatian, melainkan delay dengan langkah ekstra. Terukur: satu klien yang melakukan enam attempt — lima retry dengan exponential backoff — dan satu yang membaca kodenya dulu.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 401Enam detik spinner untuk mencapai jawaban yang tersedia dalam empat milidetik. Dan itu versi ringan: retries dalam produk biasanya bertumpuk — HTTP client yang retry di dalam job runner yang retry di dalam queue dengan redelivery-nya sendiri — jadi enam detik menjadi enam menit deployment yang rusak permanen terlihat seperti deployment lambat.
Triage-nya sembilan baris dan tempatnya satu saja:
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
}Dua lagi untuk daftarmu: 402, yang dipakai sebagian provider untuk “kreditmu habis” dan membutuhkan layar dengan tautan untuk membeli lebih banyak, bukan retry, dan 529 atau padanan spesifik vendornya, yang berperilaku seperti 503.
Backoff, dan apa yang sebenarnya dibeli jitter
Tautan ke bagian: Backoff, dan apa yang sebenarnya dibeli jitterRetry itu mudah. Retry kapan adalah bagian yang punya jawaban benar terukur.
Exponential backoff adalah standar: tunggu delay dasar, gandakan setelah setiap kegagalan, berhenti di plafon. Ia ada karena server yang overload makin buruk jika klien yang baru gagal langsung kembali.
Masalahnya semua orang menggandakan dari titik awal yang sama. Jika seratus klien terkena limit pada momen yang sama — dan mereka akan begitu, karena itulah traffic spike — maka semua seratus menunggu 200 md, semua seratus retry bersama, semua seratus gagal bersama, dan semua seratus menunggu 400 md. Jadwal retry telah menyinkronkan mereka. Itu adalah thundering herd, dan randomness adalah perbaikannya.2
Satu perubahan itu — memilih secara uniform dari interval alih-alih mengambil ujung atasnya — disebut full jitter. Itu satu panggilan ke Math.random(), dan layak diukur alih-alih dipercaya begitu saja:
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); Seratus klien, satu server yang melayani tiga pada satu waktu, semua hal lain identik, tiga run masing-masing:
| HTTP requests | penolakan | klien terburuk | window 50 md tersibuk | wall clock | |
|---|---|---|---|---|---|
| tanpa jitter, run 1 | 491 | 391 | 10 tries | 46 kedatangan | 65,6 dtk |
| tanpa jitter, run 2 | 780 | 680 | 19 tries | 72 kedatangan | 245,7 dtk |
| tanpa jitter, run 3 | 770 | 670 | 18 tries | 97 kedatangan | 225,6 dtk |
| full jitter, run 1 | 324 | 224 | 5 tries | 32 kedatangan | 2,2 dtk |
| full jitter, run 2 | 313 | 213 | 6 tries | 31 kedatangan | 2,3 dtk |
| full jitter, run 3 | 318 | 218 | 6 tries | 25 kedatangan | 1,8 dtk |
Ada dua hal di tabel itu, dan yang kedua yang penting.
Yang pertama adalah median: 226 detik melawan 2,2, faktor sekitar seratus, dengan kurang dari setengah request. Window retry tersibuk menjelaskan alasannya. Tanpa jitter, hingga 97 dari seratus klien tiba di slot 50 milidetik yang sama; server punya tiga, jadi 94 ditolak dan tidur bersama, masih tersinkronisasi, untuk melakukannya lagi dengan tunggu yang lebih lama. Dengan jitter, seratus yang sama tersebar di window yang sama dalam kelompok sekitar tiga puluh dan terkuras hampir seketika.
Yang kedua adalah variance. Tanpa jitter: 65,6 dtk, 245,7 dtk, 225,6 dtk. Dengan itu: 2,2, 2,3, 1,8. Sistem tanpa jitter tidak hanya berkinerja buruk, ia berkinerja tak terduga, karena hasilnya ditentukan oleh kecelakaan scheduling mikroskopis yang memilih tiga mana dari seratus klien tersinkronisasi yang tiba lebih dulu. Itulah tanda bug ini di produksi: endpoint yang baik-baik saja, baik-baik saja, baik-baik saja, lalu butuh empat menit, dan tidak ada perubahan darimu yang menjelaskannya.
Dan retry termurah adalah retry yang tidak pernah terjadi. Letakkan concurrency gate di depan provider — counter yang tidak pernah membiarkan lebih dari N request berjalan — dan dua puluh klien yang tadi membutuhkan 74 request dan 7,1 detik berperilaku seperti ini:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msDua puluh request untuk dua puluh jawaban, nol penolakan, delapan kali lebih cepat. Retry adalah permintaan maaf; gate adalah tidak perlu meminta maaf.
Retry-After adalah lantai, bukan saran
Tautan ke bagian: Retry-After adalah lantai, bukan saranKetika provider mengembalikan 429, biasanya ia memberitahumu berapa lama harus menunggu, di header Retry-After.3 Angka itu bukan nasihat: provider adalah satu-satunya pihak dalam pertukaran yang tahu kapan window-nya reset.
Jadi waktu tunggu adalah yang lebih besar dari keduanya: jangan pernah kurang dari Retry-After, dan jangan pernah kurang dari backoff-mu sendiri juga, karena header memberitahumu kapan limiter memaafkanmu, bukan kapan server punya ruang.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); Trace dari klien paling tidak beruntung dalam run dua puluh klien menunjukkan header melakukan tugasnya. Empat backoff draw pertamanya semuanya di bawah satu detik, dan keempatnya dioverride:
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 msDua catatan praktis. Retry-After bisa berupa tanggal HTTP, bukan jumlah detik, jadi parse keduanya. Dan provider melakukan rate-limit pada dua axis sekaligus — requests per minute dan tokens per minute — itulah sebabnya prompt panjang ditolak jauh di bawah batas request yang terdokumentasi. Headernya terlihat sama di kedua kasus; perbaikannya tidak.
Timeout yang tidak dipilih siapa pun
Tautan ke bagian: Timeout yang tidak dipilih siapa punMinta mock provider untuk /hang. Ia menerima koneksi, lalu tidak melakukan apa pun sama sekali: tanpa header, tanpa body, tanpa close. Ini bukan hal eksotis — begitulah load balancer berperilaku ketika proses di belakangnya mati tanpa menutup socket.
Dua klien, satu perbedaan:
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_TIMEOUTTiga ratus detik. Lima menit socket tetap terbuka, slot request terisi, dan pengguna menatap spinner, berakhir dengan TypeError generik yang tidak mengatakan apa pun tentang apa yang terjadi. Angka itu bukan bug: itu default headers timeout Node, masuk akal untuk HTTP client generik dan katastrofik untuk request yang menghadap pengguna. Setiap runtime punya default semacam itu, kebanyakan orang tidak pernah mencarinya, dan satu-satunya cara menemukan milikmu adalah sengaja menggantung socket seperti yang baru saja kita lakukan.
Jadi: setiap outgoing request mendapat deadline eksplisit, dipilih olehmu.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});Untuk panggilan streaming, satu deadline tidak cukup, karena ada dua kegagalan berbeda. Yang pertama adalah stream tidak pernah terbuka: tidak ada event yang tiba sama sekali, dan sepuluh sampai tiga puluh detik tepat. Yang kedua adalah stream terbuka lalu stall: token mengalir lalu berhenti, selamanya, sementara socket tetap sehat. Timeout durasi total tidak bisa membedakan stream macet dari jawaban benar yang panjang, jadi yang kamu inginkan adalah idle timeout — timer yang di-reset oleh setiap event, menyala hanya ketika tidak ada apa pun yang tiba selama, misalnya, lima belas detik.
Cancellation adalah mesin yang sama diarahkan ke manusia. AbortSignal.timeout dan pengguna menekan Stop sama-sama tiba sebagai AbortError, jadi gabungkan keduanya dan catat mana yang menyala:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort penting bukan sekadar demi kerapian: token sedang dihasilkan dan ditagih saat kamu tidak mendengarkan. Bab 16 memberi harga pada itu.
Apa yang aman untuk retry
Tautan ke bagian: Apa yang aman untuk retrySekarang kegagalan yang menghabiskan uang, bukan waktu. Sebuah request timeout di klien, dan langkah yang tampak jelas adalah mengirimnya lagi — tetapi timeout tidak memberi tahu apa pun tentang apakah server menerimanya. Sangat sering server menerimanya, dan masih bekerja.
Terukur. Mock provider membutuhkan 780 md untuk jawaban. Klien menyerah pada 300 md dan retry. Server menghitung berapa banyak jawaban yang benar-benar ia hasilkan, yaitu apa yang akan ia tagih:
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): 1Tanpa key: dua generation penuh, dibayar dua kali, dan klien menerima tidak satu pun. Dengan key: server mengenali request kedua sebagai request yang sama dan langsung membalas dengan jawaban yang sudah ia hasilkan, sehingga retry menghindari tagihan ganda sekaligus menjadi attempt yang akhirnya berhasil.
Idempotency key adalah string unik yang kamu buat per operasi logis — bukan per attempt — dan kamu kirim tanpa perubahan pada setiap retry operasi itu. Server menyimpan outcome terhadap key dan memutar ulangnya. Itulah mekanisme yang dipakai payment API, karena alasan yang sama.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");
}Dua batas jujur. Tidak semua provider mendukung idempotency key pada completions, dan ketika endpoint tidak idempotent, jumlah retry yang benar untuk POST yang mungkin sudah berjalan adalah nol. Dan stream yang gagal di tengah jalan tidak bisa di-replay dalam kasus umum: kamu restart dan membayar lagi, atau menyimpan teks parsial dan menandainya incomplete. Mana yang dilakukan produkmu adalah keputusan produk, bukan keputusan networking, dan layak dibuat dengan sengaja.
Menutup sambungan
Tautan ke bagian: Menutup sambunganKlien yang ditulis dalam bab ini tidak tahu apa yang ada di balik port. Arahkan base URL-nya ke provider komersial dan ia men-stream token dari model dengan triliunan parameter. Arahkan ke server yang dibangun di atas aritmetika Bab 13 — melayani model yang kamu pretrain di Bab 10, dengan KV cache dan weights terkuantisasinya — dan kode yang sama, tanpa perubahan, men-stream token dari model yang kamu bangun.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; Satu baris itu adalah sambungan kursus ini. Di satu sisinya ada apa yang dibangun tiga belas bab pertama; di sisi lain, apa yang dibangun enam belas bab berikutnya. Batasnya bersih karena kontraknya HTTP dan SSE, dan tidak ada sisi yang tahu hal lain tentang sisi lainnya.
Perlu diperhatikan apa yang hilang ketika kamu menyeberang. Di balik endpoint komersial, kamu tidak mengendalikan weights, maupun implementasi sampling, maupun versi yang kamu ajak bicara, maupun apakah versi itu berubah pagi ini. Yang kamu kendalikan adalah kontrak: pesan yang kamu kirim, deadline yang kamu tetapkan, kode yang kamu bedakan, dan apa yang kamu lakukan ketika tidak ada yang kembali. Itu permukaan yang lebih kecil daripada yang kamu miliki di Bab 5, dan setiap bab tersisa membahas cara menggunakannya dengan baik.
Ke mana selanjutnya
Tautan ke bagian: Ke mana selanjutnyaKamu kini punya klien yang streaming, menyerah tepat waktu, retry hal yang benar, dan tidak pernah retry hal yang salah. Yang dikirimkannya masih apa pun yang kamu ketik.
Bab 15 membahas konten itu, dan datang dengan disiplin. Internet penuh dengan saran prompting — tawari model tip, ancam dia, suruh dia menarik napas panjang — dan hampir tidak ada yang datang dengan pengukuran. Sebagian teknik itu menggeser output sangat besar, sebagian tidak menggeser sama sekali, dan setidaknya satu membuat tugas klasifikasi lebih buruk sambil menghabiskan lebih banyak token. Mana yang mana tidak jelas dari membacanya, dan tidak selesai lewat debat.
Jadi bab berikutnya membangun bench: enam puluh kasus dengan jawaban yang diketahui, empat varian prompt yang sama, dijalankan paralel melalui klien yang baru saja kamu tulis, ditabulasikan dengan confidence interval dari Bab 4 — karena empat varian atas dua puluh kasus tidak membedakan apa pun sama sekali. Satu kalimat mengatur seluruh bab: prompt diukur, bukan diperdebatkan.
Sumber dan metode
Tautan ke bagian: Sumber dan metodeSetiap angka di atas berasal dari mock provider, pada Node 22 melalui loopback interface, jadi latensinya lebih bersih daripada jaringan nyata mana pun. Itu disengaja: tidak satu pun kegagalan yang diukur disebabkan oleh jaringan, dan server bermusuhan yang bisa kamu restart mengajar lebih baik daripada server nyata yang harus kamu bayar dan tidak bisa kamu rusak.
Referensi
Tautan ke bagian: Referensi-
Server-Sent Events, WHATWG HTML Living Standard, bagian 9.2. Wire format — field
data:, event yang dipisahkan baris kosong,id:danretry:— didefinisikan di sana, bersama interfaceEventSource.EventSourcetidak bisa mengirim request body atau header kustom, itulah sebabnya setiap klien LLM mem-parse formatnya secara manual di atasfetchalih-alih menggunakannya. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Sumber formulasi “full jitter” yang digunakan di atas, dengan simulasi yang menunjukkan mengapa versi naif menyinkronkan klien. Argumen pendamping untuk shed load alih-alih mengantrekannya adalah bab Handling Overload dari Beyer, Jones, Petoff dan Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. dan Reschke, J. (eds.), HTTP Semantics, RFC 9110, bagian 15, mendefinisikan kelas status code; Nottingham, M. dan Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), bagian 4, mendefinisikan 429 Too Many Requests.
Retry-Afteradalah RFC 9110 bagian 10.2.3, dan menerima baik jumlah detik maupun tanggal HTTP. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, dibaca 7 September 2026 — pernyataan kontrak paling jelas: satu key per operasi logis, result tersimpan diputar ulang, conflict dikembalikan saat attempt pertama masih berjalan — dan polanya independen dari provider. Referensi normatif untuk bentuk request dan event yang digunakan di sini adalahdevelopers.openai.com/api/reference/resources/chatuntuk streaming, error code dan rate limit, sertaplatform.claude.com/docs/en/api/messagesuntuk Messages API;ai-sdk.dev/docsadalah contoh kerja terbaik dari concern yang sama dibungkus dalam library. Semua dibaca pada hari yang sama. ↩