İçeriğe geç
14/3030 bölümden 14. bölüm

İlk production LLM çağrın: Streaming, retry ve timeout

429, takılan socket ve yarıda kesilen stream üreten sahte provider kur; client davranışını ölç. Full jitter: 226’ya karşı 2,2 sn.

Bu sayfada

Chapter 13, dokunabildiğin bir modelin üzerinde çalışan bir kronometreyle bitmişti. Ağırlıklar belleğindeydi, KV cache’i açıp kapatmak senin elindeydi ve ortaya çıkan sayı — time to first token — donanımının bir özelliğiydi.

Şimdi o modeli bir portun arkasına koy; her ürünün yaptığı şey bu. Aynı sayıyı yeniden oku. Hâlâ time to first token, ama artık kontrol ettiğin herhangi bir şeyin özelliği değil. Artık bir TLS handshake’i, provider tarafındaki bir kuyruğu, bir rate limiter’ı ve hiçbir token’ın hiç gelmeme ihtimalini içeriyor.

Bu son cümlecik bölümün kendisi. Yazmak üzere olduğun kod hiçbir şey hesaplamıyor. Bir bağlantı açıyor, bekliyor, geleni parse ediyor, hiçbir şey gelmediğinde ne yapacağına karar veriyor, gelen şey bir hata olduğunda yeniden karar veriyor ve kullanıcı fikrini değiştirdiğinde kendini iptal ediyor. Bunların her biri zaman içinde state hakkında bir karar ve her birinin ship edilip para kaybettiren yanlış bir cevabı var.

Problemin şekli burada; ölçülmüş hâliyle, tamamı bu bölümde:

ne oldudikkatsiz bir client ne yaparmaliyeti
server socket’i kabul etti ve hiç cevap vermedibeklerNode kendi kendine vazgeçene kadar 300,8 sn
key yanlıştı (401)beş kez retry yapar6.325 ms gecikme, sonra aynı 401
yüz client aynı anda rate limit’e çarptıhepsi aynı programla retry yaparboşalması 226 sn, 2,2 sn’ye karşı
request timeout’a düştü ve yeniden gönderildiyeniden gönderirprovider cevabı iki kez üretir — ve faturalandırır
bağlantı cevap ortasında koptukısmi metni gösterirdoğru ama kısa bir cevaptan ayırt edilemez

Bunların hiçbiri bir modelleme problemi değil. Hepsi, bugüne kadar yazılmış her LLM ürününün ilk yüz satırında.

O tabloyu yeniden oku ve nasıl bir programı tarif ettiğini sor. Kırk saniye boyunca bir bağlantıyı açık tutuyor. Bir düğmeden iptal edilebilir olmalı. Gösterilmesi geçerli, kaydedilmesi geçersiz olan kısmi bir cevabı biriktiriyor. Ve cevabı render eden şeyin yanında, bir socket tutarak bir server process’te ya da edge worker’da çalışıyor.

Bu bir notebook değil. Python bunu yapamaz demek değil — yapabilir ve insanlar yapıyor — ama önceki on üç bölümün inşa ettiği her şey başka türdendi. Chapter 1’den 13’e kadar kod ağırlıklar, gradient’ler, logit’ler ve tokenizer byte’ları tutuyordu. Buradan itibaren kod bir bağlantı, bir retry, bir cancellation, birikmiş state ve daha sonra bir izin prompt’u tutuyor. Kurs, nesnenin değiştiği tam dikiş noktasında dil değiştiriyor.

Kuralı bir kez yazalım:

Kodun elinde ağırlıklar, gradient’ler, logit’ler veya tokenizer byte’ları varsa Python’dır. Bir bağlantı tutuyor, retry yapıyor, iptal ediyor, state biriktiriyor ve izin istiyorsa TypeScript’tir.

Dikiş noktası tek ve burada, Chapter 13 ile Chapter 14 arasında. Üç bağımsız ölçüt onu buraya koyuyor.

Bir: ecosystem, sayılarla. Bu kursun sol yarısında atıf yapılan her şey Python ve bu müfredat için incelenen on iki kursun hiçbirinde backpropagation’ın başka bir dilde öğretildiğine dair tek bir emsal yok: micrograd (17,4K yıldız), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Chapter 5’i TypeScript’te yazmak bu kaynaklarla bağı koparırdı; o bağlar, sıralanmak için değil referans verilmek için var olan bir bölümün değerinin yarısı. Bu tarafta aritmetik tersine dönüyor: Vercel’in ai paketi ayda 89,4M indirmede ve şeyin kendisini ship ediyor — ToolLoopAgent olarak export edilen bir tool-calling agent döngüsü — yani bu kursun Chapter 23’te ulaştığı kavramın referans implementasyonu TypeScript’te; üstelik o bölümün ölçtüğü gibi, kimse adına karar vermiş olmasa da. Mastra 27,7K yıldızda; Anthropic’in tek bir spesifikasyondan üretilen SDK’ları ise Python’daki 201’e karşı TypeScript’te 202 endpoint bildiriyor — nezaketen yapılmış bir port değil, parite.

İki: MCP’nin normatif kaynağı. Model Context Protocol spesifikasyonunun şeması bir schema.ts dosyası. Chapter 26’nın protokolünü başka bir dilde öğretmek, kurucu belgesinin çevirisini öğretmek demek.

Üç: search talebi, bariz tahmine düzeltmeyle. machine learning python internetteki en doygun ifade; ai agent typescript de kendi sağlıklı kuyruğuna sahip. Ama “MCP ecosystem çoğunlukla TypeScript” ancak nasıl saydığına bağlı olarak doğru: resmî registry, npm’de 8.275 server listeliyor; PyPI’da 3.603. İndirmelerde ise Python kazanıyor — mcp için ayda 287M artı fastmcp için 72M; @modelcontextprotocol/sdk için 195M’ye karşı. MCP burada gerçekten iki dilli tek bölge; bu yüzden Chapter 27 aynı server’ı rol yapmadan iki kez yazıyor.

Ayrıntıları göster

Kuralın slogan değil kural olması için ilan edilen beş istisna.

Chapter 17, 20 ve 29 bir Python’da ikinci panel taşıyor: top-p sampling’i implement etmek probability vector’ı elinde tutmayı gerektirir ve bir HTTP API bunu asla vermez; bir fine-tune’un fiyatını dürüstçe çıkarmak onu çalıştırmak demektir ve bir LoRA adapter nn.Module içinde bir düzine satırdır; ayrıca lm-eval-harness, HELM, SWE-bench ve τ-bench Python’dır, bu yüzden TypeScript’te bir evaluation harness, backpropagation hatasının aynadaki yansıması olurdu. Chapter 27, yukarıdaki ölçülmüş nedenle iki dillidir. Chapter 28 Markdown’dır, çünkü bir agent skill zaten bir SKILL.md dosyasıdır ve ona bir programlama dili vermek formatı anlamamış olmak olurdu.

On üç Python bölümü çöpe atılmıyor. Portun diğer tarafında olan şey onların inşa ettiği şey ve buradaki son bölüm bir client’ı ona bağlıyor.

Bunların hiçbirini gerçek bir provider’a karşı öğrenemezsin. Ondan seçtiğin bir anda 429 vermesini, bağlantını kabul edip asla cevaplamayan bir socket’i ya da bir kelimenin ortasında duran bir stream’i isteyemezsin — ayrıca her deney için para ödersin; oysa ilginç deneyler yüz kez çalıştırdıklarındır.

Bu yüzden kursun bu yarısındaki ilk program bir client değil. Bir düşmanca server: chat completions endpoint’iyle aynı wire protocol’ü konuşan ve istendiğinde kötü davranan kırk satır sade Node. Bu bölümdeki her sayı ondan çıktı.

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

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

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

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

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

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

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

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

Dört düşmanca davranış, her biri bir satır: /hang socket’i kabul eder ve ona hiç yazmaz; /401 key’i reddeder; capacity check, hâlihazırda üç request uçuşta olduğunda gerçek bir Retry-After header’ıyla gerçek bir 429 üretir; ?cut=N ise ya socket’i reset ederek ya da — &how=close ile — düzenli biçimde kapatarak cevabı yarıda bırakır; bunun çok önemli olduğu ortaya çıkıyor. Geri kalanı gerçek bir Server-Sent Events stream’i: her data: satırında bir JSON object, event’ler arasında boş satır, sonda [DONE] string’i.1

Çalıştır; bölümün kalanı ölçüm.

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

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

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

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

data: [DONE]

Request body ve server’dan asla ayrılmayan key

Bölüme bağlantı: Request body ve server’dan asla ayrılmayan key

Bir chat request, her biri role taşıyan bir messages listesidir. Bu liste modelin tüm state’idir: çağrılar arasında memory yoktur ve modelin bilmesini istediğin her şey bu sefer gönderdiğin array’in içinde olmak zorundadır. Chapter 15 içine ne koyacağını, Chapter 16 bunun maliyetini anlatıyor; burada sadece şekil var.

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

Bu role’ler süs değildir. Model tek bir token görmeden önce Chapter 11’deki chat template’e render edilirler; yanlış role göndermenin hata fırlatmak yerine cevabı sessizce bozmasının nedeni bu.

İstisnasız tek kural: API key asla client’a gitmez. Browser için prefix’lenmiş bir environment variable’da değil, build-time constant’ta değil, “geçici olarak” hiç değil. Bundle içindeki bir key, birkaç gün içinde başkasının faturasında çalışan bir key’dir. Browser senin server’ınla konuşur; server’ın key’i tutar ve provider’la konuşur — server’ın ortada olduğu için, her kullanıcının ne harcadığını ölçebilen tek yer de orasıdır; Chapter 16’daki muhasebe orada yaşamak zorunda.

Şimdi bölümün üzerine kurulduğu deney. Tek soru, 60 ms’de bir on üç token üreten tek mock provider, sormanın üç yolu.

İlki, streaming olmadan. Client request’i gönderir ve tüm JSON body’yi bekler.

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

İki sayı aynı ve bütün problem bu. 791 ms boyunca kullanıcıda bir spinner var ve tek bir kelime bile daha önce kullanılabilir değildi — server cevaba byte byte sahipti ve hiçbir şey söylememeyi seçti.

İkincisi, streaming ile. Aynı server, aynı cevap, aynı toplam iş. Fark bir parser.

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

Oradaki üç ayrıntı yük taşıyor ve ilk denemelerin çoğu üçünü de atlıyor. buffer var çünkü bir network chunk’ın bir event ile ilişkisi yoktur: bir read() yarım event ya da iki buçuk event döndürebilir. { stream: true } flag’i var çünkü çok byte’lı bir UTF-8 karakter iki chunk’a bölünebilir; o olmadan aksanlı bir harf rastgele replacement character’a dönüşür. Ve event’ler newline ile değil boş satırla ayrılır; bu yüzden döngü \n\n arar.

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

İlk kelimeye on iki kat daha hızlı, son kelimeye iki milisaniye daha yavaş. Streaming hiçbir şeyi hızlandırmaz. Aynı 790 ms boyunca kullanıcının ne yaptığını değiştirir: beklemek yerine okumak. Bütün fayda budur, muazzamdır ve her chat ürününün stream etmesinin nedeni budur.

Üçüncüsü, aynı anda yirmi client ile. Mock provider aynı anda üç request’e hizmet verir. Yirmisini ateşle:

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

Yirmi cevap, yetmiş dört request, elli dört ret. Kimse bir şey kaybetmedi, her client aynı metni aldı ve görünen tek maliyet zamandı. Bu, çalışan bir retry policy. Bölümün kalanı bunun başarısız olabileceği üç yolla ilgili.

Hatalardan önce, hemen herkesin ilk geçişte görmezden geldiği field. Her stream finish_reason taşıyan bir event ile biter. stop modelin bittiğine karar verdiği anlamına gelir. length token tavanına çarptığı anlamına gelir; yani cevap cümlenin ortasında kesilmiştir ve bu modelin suçu değildir. Sonraki bölümler tool_calls (Chapter 18) ve content filter’lar ekler.

Şimdi naive bir client’ın ayırt edemediği iki sona bak. Aynı server, aynı gecikme; biri max_tokens ile truncation, diğeri bağlantının beş token’dan sonra temizce kapatılması:

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

İlk iki satırı dikkatle oku. Aynı metin. Aynı chunk sayısı. İkisinde de exception yok. for await döngüsü iki durumda da normal bitti, çünkü reader’ın bakış açısından body bitti ve bir body’nin yapabileceği tek şey bu. Tüm gözlemdeki tek fark, birinin finish_reason: "length" taşıması ve diğerinin hiçbir şey taşımaması.

Yani kural “streaming sırasında hataları yakala” değil. Şu:

finish_reason olmadan biten bir stream bitmemiştir. Durmuştur.

Eksik bir finish_reason’i her zaman failure kabul et ve o metni tamamlanmış bir cevap olarak asla persist etme. Üçüncü satır daha kolay durumu gösteriyor — destroy edilmiş socket gerçekten throw eder ve uçuşta olan chunk’ı da kaybeder; bu yüzden metin yukarıdaki ikisinden bir kelime daha kısa.

Yeni bir ürünün en pahalı alışkanlığı, provider’ın döndürdüğü her şey için tek bir catch bloğuna sahip olmaktır. Bu code’lar “başarısız oldu”nun varyasyonları değildir. Beş talimattır ve dördü birbirine ters düşer.

statusanlamıne yapmalıbekle?
400request’in hatalı biçimlenmiş — bozuk JSON, bilinmeyen field, context çok uzunkodu düzeltasla
401key yanlış, eksik ya da revoke edilmişdeployment’ı düzeltasla
429rate limit: dakikada çok fazla request ya da çok fazla tokenretry yapRetry-After, sonra backoff
500provider bozulduretry yapbackoff
503provider overload altında — ayakta ama doluretry yapbackoff ve load shed et

Önemli çizgi 4xx ile geri kalanın arasından geçer. 400 ya da 401’i bin kez gönderirsen tam olarak aynı cevabı döndürür, çünkü denemeler arasında iki uçta da hiçbir şey değişmez. Onu retry etmek tedbir değil, ekstra adımlı gecikmedir. Ölçüm: exponential backoff ile beş retry olmak üzere altı attempt yapan bir client ve önce code’u okuyan bir client.

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

Dört milisaniyede mevcut olan cevaba ulaşmak için altı saniye spinner. Ve bu hafif versiyon: ürünlerde retry genellikle iç içedir — retry yapan bir HTTP client, retry yapan bir job runner’ın içinde, kendi redelivery’si olan bir queue’nun içinde — böylece altı saniye, kalıcı olarak bozuk bir deployment’ın yavaş görünerek altı dakikaya uzamasına dönüşür.

Triage dokuz satırdır ve tek bir yerde olmalıdır:

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

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

Listene iki tane daha: bazı provider’ların “credit’in bitti” için kullandığı 402; retry yerine daha fazla satın alma linki olan bir ekran ister. Bir de 529 veya vendor-specific eşdeğerleri; bunlar 503 gibi davranır.

Backoff ve jitter’ın gerçekte ne kazandırdığı

Bölüme bağlantı: Backoff ve jitter’ın gerçekte ne kazandırdığı

Retry etmek kolay. Ne zaman retry edileceği, ölçülebilir doğru cevabı olan kısımdır.

Exponential backoff standarttır: bir base delay bekle, her failure’dan sonra ikiye katla, bir tavanda dur. Var olma nedeni şu: overload altındaki bir server, az önce failed olan client’lar hemen geri gelirse daha kötüleşir.

Problem, herkesin aynı başlangıç noktasından ikiye katlamasıdır. Yüz client aynı anda bir limite çarparsa — ve çarparlar, çünkü traffic spike budur — yüzünün tamamı 200 ms bekler, yüzünün tamamı birlikte retry eder, yüzünün tamamı birlikte fail olur ve yüzünün tamamı 400 ms bekler. Retry schedule onları senkronize etmiştir. Bu bir thundering herd ve çözüm randomness’tır.2

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

Bu tek değişiklik — aralığın üst ucunu almak yerine aralıktan uniform seçmek — full jitter diye adlandırılır. Math.random()’a tek bir çağrıdır ve inanmak yerine ölçmeye değer:

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

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

Yüz client, aynı anda üçüne hizmet veren bir server, diğer her şey aynı, her biri üç run:

HTTP requestsrejectionsen kötü clienten yoğun 50 ms penceresiwall clock
jitter yok, run 149139110 deneme46 geliş65,6 sn
jitter yok, run 278068019 deneme72 geliş245,7 sn
jitter yok, run 377067018 deneme97 geliş225,6 sn
full jitter, run 13242245 deneme32 geliş2,2 sn
full jitter, run 23132136 deneme31 geliş2,3 sn
full jitter, run 33182186 deneme25 geliş1,8 sn

O tabloda iki şey var ve önemlisi ikincisi.

İlki median: 226 saniyeye karşı 2,2, yaklaşık yüz kat, request sayısının yarısından azıyla. En yoğun retry penceresi nedenini söylüyor. Jitter olmadan yüz client’ın 97’sine kadarı aynı 50 milisaniyelik slot içine geldi; server’da üç yer vardı, yani 94’ü reddedildi ve hâlâ senkronize şekilde, daha uzun bir beklemeyle aynı şeyi tekrar yapmak üzere uyudu. Jitter ile aynı yüz client aynı pencerelere yaklaşık otuzluk gruplar hâlinde yayıldı ve neredeyse hemen boşaldı.

İkincisi variance. Jitter olmadan: 65,6 sn, 245,7 sn, 225,6 sn. Onunla: 2,2, 2,3, 1,8. Jitter’sız bir sistem yalnızca kötü performans göstermez; öngörülemez performans gösterir, çünkü sonucu, senkronize olmuş yüz client’tan hangi üçünün önce geldiğini seçen mikroskobik scheduling kazaları belirler. Production’da bu bug’ın imzası budur: iyi, iyi, iyi olan ve sonra dört dakika süren bir endpoint; senin yaptığın hiçbir değişiklik bunu açıklamaz.

En ucuz retry ise hiç gerçekleşmeyendir. Provider’ın önüne bir concurrency gate koy — uçuşta N’den fazla request’e asla izin vermeyen bir counter — ve 74 request ile 7,1 saniyeye ihtiyaç duyan aynı yirmi client şöyle davranır:

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

Yirmi cevap için yirmi request, sıfır ret, sekiz kat daha hızlı. Retry özürdür; gate özre ihtiyaç duymamaktır.

Bir provider 429 döndürdüğünde genellikle Retry-After header’ında ne kadar beklemen gerektiğini söyler.3 Bu sayı tavsiye değildir: exchange içinde penceresinin ne zaman resetleneceğini bilen tek taraf provider’dır.

Bu yüzden bekleme ikisinin büyüğüdür: asla Retry-After’den az değil ve kendi backoff’undan da az değil; çünkü header limiter’ın seni ne zaman affedeceğini söyler, server’da ne zaman yer olacağını değil.

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

Yirmi client’lık run’daki en şanssız client’ın trace’i header’ın işini yaptığını gösteriyor. İlk dört backoff çekilişinin hepsi bir saniyenin altındaydı ve dördü de override edildi:

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

İki pratik not. Retry-After saniye sayısı yerine bir HTTP date olabilir; ikisini de parse et. Ayrıca provider’lar aynı anda iki eksende rate-limit uygular — dakikadaki request ve dakikadaki token — bu yüzden uzun prompt’lar dokümante edilmiş request limitinin çok altında reddedilir. Header iki durumda da aynı görünür; çözüm aynı değildir.

Mock provider’dan /hang iste. Bağlantıyı kabul eder ve sonra hiçbir şey yapmaz: header yok, body yok, close yok. Bu egzotik değil — arkasındaki process socket’lerini kapatmadan öldüğünde bir load balancer’ın yaptığı şey budur.

İki client, tek fark:

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

Üç yüz saniye. Açık tutulan bir socket, işgal edilmiş bir request slot’u ve spinner’a bakan bir kullanıcıyla beş dakika; ne olduğunu anlatmayan generic bir TypeError ile bitiyor. Bu sayı bug değil: Node’un default headers timeout’u; generic bir HTTP client için makul, user-facing bir request için felaket. Her runtime’ın böyle bir default’u vardır, çoğu insan hiç bakmaz ve kendininkini bulmanın tek yolu az önce yaptığımız gibi bir socket’i bilerek asılı bırakmaktır.

Yani: her outgoing request, senin seçtiğin açık bir deadline alır.

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

Bir streaming call için tek deadline yetmez, çünkü iki farklı failure vardır. İlki stream hiç açılmaz: hiçbir event gelmez ve on ile otuz saniye arası doğrudur. İkincisi stream açılır ve sonra stall olur: token’lar akar ve sonra socket hâlâ sağlıklı hâlde sonsuza dek durur. Total-duration timeout, durmuş bir stream’i uzun ama doğru bir cevaptan ayıramaz; bu yüzden istediğin şey bir idle timeout’tur — her event ile resetlenen, yalnızca örneğin on beş saniye boyunca hiçbir şey gelmediğinde tetiklenen bir timer.

Cancellation, aynı mekanizmanın bir insana çevrilmiş hâlidir. AbortSignal.timeout ve Stop’a basan bir kullanıcı ikisi de AbortError olarak gelir; bu yüzden onları birleştir ve hangisinin tetiklendiğini kaydet:

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

Abort etmek düzenlilikten daha fazlası için önemlidir: sen dinlemiyorken token’lar üretiliyor ve faturalandırılıyor. Chapter 16 buna fiyat koyuyor.

Şimdi zaman yerine para kaybettiren failure. Bir request client tarafında timeout’a düşer ve bariz hamle onu yeniden göndermektir — ama timeout, server’ın onu alıp almadığı hakkında hiçbir şey söylemez. Çok sık almıştır ve hâlâ çalışıyordur.

Ölçüldü. Mock provider cevap için 780 ms’ye ihtiyaç duyuyor. Client 300 ms’de vazgeçiyor ve retry yapıyor. Server gerçekte kaç cevap ürettiğini sayıyor; faturalandıracağı şey budur:

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

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

Key olmadan: iki tam generation, iki kez ücret, client ise hiçbirini almadı. Key ile: server ikinci request’i aynı request olarak tanıdı ve zaten üretmiş olduğu cevapla anında döndü; böylece retry hem çift ücretten kaçındı hem de sonunda başarılı olan attempt oldu.

Bir idempotency key, logical operation başına — attempt başına değil — ürettiğin ve onun her retry’ında değiştirmeden gönderdiğin unique string’dir. Server sonucu key’e karşı saklar ve replay eder. Payment API’lerinin aynı nedenle kullandığı mekanizma budur.4

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

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

İki dürüst sınır. Her provider completions üzerinde idempotency key desteklemez ve endpoint idempotent değilse, zaten çalışmış olabilecek bir POST için doğru retry sayısı sıfırdır. Ayrıca yarıda failed olan bir stream genel durumda replay edilemez: ya yeniden başlatır ve tekrar ödersin ya da kısmi metni tutar ve incomplete olarak işaretlersin. Ürününün bunlardan hangisini yaptığı bir networking kararı değil, ürün kararıdır ve bilerek vermeye değer.

Bu bölümde yazılan client portun arkasında ne olduğuna dair hiçbir fikre sahip değil. Base URL’ini commercial bir provider’a yönelt; trilyon parameter’lı bir modelden token stream eder. Chapter 13’ün aritmetiği üzerine kurulu bir server’a yönelt — Chapter 10’da pretrained ettiğin modeli, KV cache’i ve quantized ağırlıklarıyla serving eden bir server — aynı kod, değişmeden, senin inşa ettiğin bir modelden token stream eder.

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

Bu tek satır kursun dikiş noktasıdır. Bir tarafında ilk on üç bölümün inşa ettiği şey; diğer tarafında sonraki on altı bölümün inşa edeceği şey var. Sınır temiz çünkü contract HTTP ve SSE; iki taraf da diğeri hakkında başka hiçbir şey bilmiyor.

Geçerken ne kaybettiğini fark etmeye değer. Commercial bir endpoint’in arkasında ne ağırlıkları, ne sampling implementasyonunu, ne konuştuğun version’ı, ne de bu sabah değişip değişmediğini kontrol edersin. Kontrol ettiğin şey contract’tır: gönderdiğin messages, set ettiğin deadline, ayırt ettiğin code’lar ve hiçbir şey geri gelmediğinde yaptığın şey. Bu, Chapter 5’te sahip olduğundan daha küçük bir yüzey ve kalan her bölüm onu iyi kullanmakla ilgili.

Artık stream eden, zamanında vazgeçen, doğru şeyleri retry eden ve yanlış olanları asla retry etmeyen bir client’ın var. Gönderdiği şey hâlâ senin yazdığın neyse o.

Chapter 15 bu içerikle ilgili ve bir disiplinle geliyor. İnternet prompting tavsiyeleriyle dolu — modele bahşiş teklif et, tehdit et, derin nefes almasını söyle — ve neredeyse hiçbiri ölçümle gelmiyor. Bu tekniklerin bazıları output’u çok oynatıyor, bazıları hiç oynatmıyor ve en az biri bir classification task’ı daha fazla token’a mal ederken daha kötü yapıyor. Hangisinin hangisi olduğu okuyarak belli olmaz ve tartışmayla çözülmez.

Bu yüzden sonraki bölüm bir bench kuruyor: bilinen cevaplara sahip altmış case, aynı prompt’un dört variant’ı, az önce yazdığın client üzerinden paralel çalıştırılmış, Chapter 4’ten gelen confidence interval’larıyla tabloya dökülmüş — çünkü yirmi case üzerindeki dört variant hiçbir şeyi ayırt etmez. Tüm bölümü yöneten tek cümle: prompt tartışılmaz, ölçülür.


Yukarıdaki her sayı mock provider’dan, Node 22 üzerinde loopback interface ile geldi; bu yüzden latency’ler herhangi bir gerçek network’ün vereceğinden daha temiz. Bu bilinçli: ölçülen failure’ların hiçbiri network’ten kaynaklanmıyor ve yeniden başlatabildiğin düşmanca bir server, para ödemek zorunda olduğun ve kıramadığın gerçek bir server’dan daha iyi öğretir.

  1. Server-Sent Events, WHATWG HTML Living Standard, bölüm 9.2. Wire format — data: field’ları, boş satırla ayrılmış event’ler, id: ve retry: — orada, EventSource interface’iyle birlikte tanımlanır. EventSource request body veya custom header gönderemez; bu yüzden her LLM client formatı onu kullanmak yerine fetch üzerinden elle parse eder.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Yukarıda kullanılan “full jitter” formülasyonunun kaynağı; naive versiyonun client’ları neden senkronize ettiğini gösteren simulation’larla birlikte. Load’u queue’ya almak yerine shed etme yönündeki eşlik eden argüman, Beyer, Jones, Petoff ve Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016) içindeki Handling Overload bölümüdür.

  3. Fielding, R., Nottingham, M. ve Reschke, J. (eds.), HTTP Semantics, RFC 9110, bölüm 15, status code sınıflarını tanımlar; Nottingham, M. ve Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), bölüm 4, 429 Too Many Requests’i tanımlar. Retry-After RFC 9110 bölüm 10.2.3’tür ve saniye sayısı ya da HTTP date kabul eder.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, 7 Eylül 2026’da okundu — contract’ın en açık ifadesi: logical operation başına bir key, stored result’ların replay edilmesi, ilk attempt hâlâ uçuşta iken conflict döndürülmesi — ve pattern provider-independent’tır. Burada kullanılan request ve event shape’leri için normatif referanslar streaming, error code’lar ve rate limit’ler için developers.openai.com/api/reference/resources/chat, Messages API için platform.claude.com/docs/en/api/messages; ai-sdk.dev/docs aynı kaygıların bir library içinde sarılmış en iyi worked example’ıdır. Hepsi aynı gün okundu.

Seçimi LIA'ya bırakmaya hazır mısın?

Tüm yapay zeka modelleriyle tek yerde üret — bugün ücretsiz başla.