קריאת ה-LLM הראשונה שלך בייצור: streaming, ניסיונות חוזרים ו-timeouts
בנו provider שמשקר לכם — 429, sockets תקועים ו-streams שנחתכים באמצע — ומדדו מה ה-client עושה. full jitter: 2.2 שניות מול 226.
בעמוד הזה
פרק 13 הסתיים עם סטופר על מודל שאפשר היה לגעת בו. ה-weights היו בזיכרון שלכם, ה-KV cache היה שלכם להפעלה או כיבוי, והמספר שיצא — time to first token — היה מאפיין של החומרה שלכם.
עכשיו שימו את המודל הזה מאחורי port, שזה מה שכל מוצר עושה, וקראו שוב את אותו מספר. זה עדיין time to first token, אבל זה כבר לא מאפיין של שום דבר שאתם שולטים בו. הוא כולל עכשיו TLS handshake, תור אצל ה-provider, rate limiter, ואת האפשרות ששום token לא יגיע בכלל.
הסעיף האחרון הוא הפרק. הקוד שאתם עומדים לכתוב לא מחשב שום דבר. הוא פותח חיבור, ממתין, מפענח את מה שמגיע, מחליט מה לעשות כששום דבר לא מגיע, מחליט שוב כשמה שמגיע הוא שגיאה, ומבטל את עצמו כשהמשתמש משנה את דעתו. כל אחת מאלה היא החלטה על מצב לאורך זמן, ולכל אחת יש תשובה שגויה שנשלחת לייצור ועולה כסף.
כך נראית הבעיה, במדידה, כולה בפרק הזה:
| מה קרה | מה client רשלני עושה | מה זה עולה |
|---|---|---|
| השרת קיבל את ה-socket ומעולם לא השיב | ממתין | 300.8 שנ׳ לפני ש-Node מוותר בעצמו |
| המפתח היה שגוי (401) | מנסה שוב חמש פעמים | 6,325 ms של עיכוב, ואז אותו 401 |
| מאה clients פוגעים יחד ב-rate limit | כולם מנסים שוב באותו לוח זמנים | 226 שנ׳ לניקוז, מול 2.2 שנ׳ |
| ה-request הגיע ל-timeout ונשלח מחדש | שולח אותו מחדש | ה-provider מייצר — ומחייב — את התשובה פעמיים |
| החיבור נפל באמצע התשובה | מציג את הטקסט החלקי | לא ניתן להבחנה מתשובה קצרה תקינה |
אף אחת מאלה אינה בעיית modelling. כולן נמצאות במאה השורות הראשונות של כל מוצר LLM שנכתב אי פעם.
למה הפרק הזה משנה שפה
קישור למקטע: למה הפרק הזה משנה שפהקראו שוב את הטבלה ושאלו איזה סוג תוכנית היא מתארת. היא מחזיקה חיבור פתוח במשך ארבעים שניות. חייב להיות אפשר לבטל אותה מכפתור. היא צוברת תשובה חלקית שתקפה להצגה ולא תקפה לשמירה. והיא רצה בתהליך שרת או ב-edge worker, ליד הדבר שמרנדר את התשובה, ומחזיקה socket.
זה לא notebook. זה לא ש-Python לא יכולה לעשות את זה — היא יכולה, ואנשים עושים זאת — אלא שכל מה ששלושה-עשר הפרקים הקודמים בנו היה מסוג אחר. פרקים 1 עד 13 החזיקו weights, gradients, logits ו-tokenizer bytes. מכאן הקוד מחזיק חיבור, retry, ביטול, מצב נצבר ובהמשך permission prompt. הקורס משנה שפה בדיוק בתפר שבו האובייקט משתנה.
אז הכלל, כתוב פעם אחת:
אם לקוד יש בידיים weights, gradients, logits או tokenizer bytes, הוא Python. אם הוא מחזיק חיבור, retries, מבטל, צובר מצב ומבקש רשות, הוא TypeScript.
התפר יחיד והוא נופל כאן, בין פרק 13 לפרק 14. שלושה קריטריונים עצמאיים מציבים אותו כאן.
אחד: ה-ecosystem, במספרים. כל מה שהחצי השמאלי של הקורס הזה מצטט הוא Python, ובין שנים-עשר הקורסים שנבדקו עבור הסילבוס הזה אין תקדים אחד של backpropagation שנלמדת בשפה אחרת: micrograd (17.4K כוכבים), nanoGPT (62.8K), nanochat (57.8K), minbpe (10.7K), PyTorch (102.8K), transformers (164.9K). כתיבת פרק 5 ב-TypeScript הייתה שוברת את הקשר למקורות האלה, והקשרים הם חצי מהערך של פרק שקיים כדי שאפשר יהיה להפנות אליו יותר מאשר כדי לדרג. בצד הזה האריתמטיקה מתהפכת: חבילת ai של Vercel עומדת על 89.4M הורדות בחודש ושולחת את הדבר עצמו — לולאת tool-calling agent, מיוצאת כ-ToolLoopAgent — כך שלמושג שהקורס מגיע אליו ב-פרק 23 יש reference implementation ב-TypeScript, אף על פי שכפי שהפרק ההוא מודד, אף אחד לא הסכים על שם עבורו; Mastra עומדת על 27.7K כוכבים; וה-SDKs של Anthropic, שנוצרו ממפרט אחד, מצהירים על 202 endpoints ב-TypeScript מול 201 ב-Python — parity, לא port מנומס.
שתיים: המקור הנורמטיבי של MCP. הסכמה של מפרט Model Context Protocol היא קובץ schema.ts. ללמד את הפרוטוקול של פרק 26 בשפה אחרת פירושו ללמד תרגום של המסמך המכונן שלו.
שלוש: ביקוש חיפוש, עם תיקון לניחוש המתבקש. machine learning python הוא הביטוי הרווי ביותר באינטרנט; ל-ai agent typescript יש זנב בריא משלו. אבל ״ה-ecosystem של MCP הוא בעיקר TypeScript״ נכון רק תלוי איך סופרים: ה-registry הרשמי מונה 8,275 שרתים ב-npm מול 3,603 ב-PyPI, בעוד לפי הורדות Python מנצחת — 287M בחודש עבור mcp ועוד 72M עבור fastmcp מול 195M עבור @modelcontextprotocol/sdk. MCP הוא הטריטוריה הדו-לשונית האמיתית היחידה כאן, ולכן פרק 27 כותב את אותו שרת פעמיים במקום להעמיד פנים.
הצגת פרטים
חמשת החריגים המוצהרים, כדי שהכלל יהיה כלל ולא סיסמה.
פרקים 17, 20 ו-29 נושאים פאנל שני ב-Python: מימוש top-p sampling דורש את וקטור ההסתברויות ביד ו-HTTP API אף פעם לא נותן לכם אחד; לתמחר fine-tune בכנות פירושו להריץ אחד, ו-LoRA adapter הוא תריסר שורות של nn.Module; ו-lm-eval-harness, HELM, SWE-bench ו-τ-bench הם Python, כך ש-evaluation harness ב-TypeScript יהיה תמונת המראה של טעות ה-backpropagation. פרק 27 הוא דו-לשוני, מהסיבה המדודה למעלה. פרק 28 הוא Markdown, כי agent skill הוא קובץ SKILL.md, ולתת לו שפת תכנות פירושו לא להבין את הפורמט.
שלושה-עשר פרקי ה-Python לא נזרקים. מה שנמצא בצד השני של ה-port הוא מה שהם בנו, והסעיף האחרון כאן מחבר אליו client.
Provider שאפשר לשבור
קישור למקטע: Provider שאפשר לשבוראי אפשר ללמוד שום דבר מזה מול provider אמיתי. אי אפשר לבקש ממנו 429 ברגע שתבחרו, או socket שמקבל את החיבור שלכם ולעולם לא עונה, או stream שנעצר באמצע מילה — והייתם משלמים על כל ניסוי, כשהניסויים המעניינים הם אלה שמריצים מאה פעמים.
לכן התוכנית הראשונה בחצי הזה של הקורס אינה client. היא שרת עוין: ארבעים שורות של Node פשוט שמדברות את אותו wire protocol כמו endpoint של chat completions ומתנהגות רע לפי דרישה. כל מספר בפרק הזה יצא ממנו.
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);ארבע התנהגויות עוינות, שורה לכל אחת: /hang מקבל את ה-socket ולעולם לא כותב אליו; /401 דוחה את המפתח; בדיקת הקיבולת מייצרת 429 אמיתי עם header אמיתי של Retry-After ברגע ששלוש בקשות כבר בטיפול; ו-?cut=N נוטש את התשובה באמצע, או באיפוס ה-socket או — עם &how=close — בסגירה מסודרת, דבר שמתברר כחשוב מאוד. השאר הוא stream אמיתי של Server-Sent Events: אובייקט JSON אחד בכל שורת data:, שורה ריקה בין אירועים, המחרוזת [DONE] בסוף.1
הריצו אותו, ושאר הפרק הוא מדידה.
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, והמפתח שלעולם לא עוזב את השרת
קישור למקטע: גוף ה-request, והמפתח שלעולם לא עוזב את השרתבקשת chat היא רשימת הודעות, לכל אחת role. הרשימה הזו היא כל המצב של המודל: אין זיכרון בין קריאות, וכל מה שאתם רוצים שהמודל ידע חייב להיות בתוך ה-array שאתם שולחים הפעם. פרק 15 עוסק במה לשים בו ו-פרק 16 עוסק במה זה עולה, אז כאן זו רק הצורה.
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,
};ה-roles האלה אינם קישוט. הם מרונדרים לתוך תבנית ה-chat של פרק 11 לפני שהמודל רואה אפילו token אחד, ולכן שליחת role שגוי מדרדרת את התשובה בשקט במקום להעלות שגיאה.
כלל אחד ללא חריגים: ה-API key לעולם לא נוסע אל ה-client. לא במשתנה סביבה עם prefix לדפדפן, לא בקבוע בזמן build, לא ״זמנית״. מפתח בתוך bundle הוא מפתח על חשבון של מישהו אחר בתוך ימים. הדפדפן מדבר עם השרת שלכם, השרת שלכם מחזיק את המפתח ומדבר עם ה-provider — ובגלל שהשרת שלכם באמצע, הוא גם המקום היחיד שיכול למדוד כמה כל משתמש מוציא, ושם חייבת לחיות החשבונאות של פרק 16.
אותה שאלה, שלוש פעמים
קישור למקטע: אותה שאלה, שלוש פעמיםעכשיו הניסוי שעליו הפרק בנוי. שאלה אחת, mock provider אחד שמייצר שלושה-עשר tokens בקצב של 60 ms כל אחד, שלוש דרכים לשאול.
ראשית, בלי streaming. ה-client שולח את ה-request וממתין לכל גוף ה-JSON.
blocking first visible = 791 ms complete = 791 ms finish_reason = stopשני המספרים זהים, וזו כל הבעיה. במשך 791 ms למשתמש יש spinner, ואף מילה לא הייתה זמינה קודם — לשרת הייתה התשובה, byte אחרי byte, והוא בחר לא לומר כלום.
שנית, עם streaming. אותו שרת, אותה תשובה, אותה עבודה כוללת. ההבדל הוא 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);
}
}
}
}שלושה פרטים שם נושאים עומס, ורוב הניסיונות הראשונים מדלגים על שלושתם. ה-buffer קיים כי ל-network chunk אין קשר לאירוע: read() אחד יכול להחזיר חצי אירוע, או שניים וחצי. דגל { stream: true } קיים כי תו UTF-8 מרובה bytes יכול להתחלק בין שני chunks, ובלעדיו אות עם סימן דיאקריטי הופכת באקראי לתו החלפה. ואירועים מופרדים על ידי שורה ריקה, לא newline, ולכן הלולאה מחפשת \n\n.
streaming first visible = 65 ms complete = 793 ms finish_reason = stopפי שנים-עשר מהר יותר למילה הראשונה, ושתי מילישניות לאט יותר לאחרונה. Streaming לא עושה שום דבר מהיר יותר. הוא משנה את מה שהמשתמש עושה במשך אותם 790 ms: קורא במקום להמתין. זה כל היתרון, הוא עצום, וזו הסיבה שכל מוצר chat עושה streaming.
שלישית, עם עשרים clients בבת אחת. ה-mock provider משרת שלוש בקשות בכל פעם. ירי של עשרים:
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עשרים תשובות, שבעים וארבע בקשות, חמישים וארבע דחיות. אף אחד לא איבד דבר, כל client קיבל את אותו טקסט, והעלות הנראית היחידה הייתה זמן. זו מדיניות retry שעובדת. שאר הפרק עוסק בשלוש הדרכים שבהן היא יכולה להיכשל במקום.
finish_reason, ושני סופים שנראים אותו דבר
קישור למקטע: finish_reason, ושני סופים שנראים אותו דברלפני הכשלים, השדה שכמעט כולם מתעלמים ממנו במעבר הראשון. כל stream מסתיים באירוע שנושא finish_reason. stop פירושו שהמודל החליט שהוא סיים. length פירושו שהוא פגע בתקרת ה-token, ולכן התשובה נחתכה באמצע משפט וזה לא באשמת המודל. פרקים מאוחרים יותר מוסיפים tool_calls (פרק 18) ומסנני תוכן.
עכשיו צפו בשני סופים ש-client נאיבי לא יכול להבחין ביניהם. אותו שרת, אותו עיכוב, אחד נחתך על ידי max_tokens ואחד שבו החיבור נסגר נקי אחרי חמישה 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"קראו בזהירות את שתי השורות הראשונות. טקסט זהה. מספר chunks זהה. אין exception באף אחד מהמקרים. לולאת for await הסתיימה כרגיל בשניהם, כי מנקודת המבט של הקורא הגוף נגמר וזה כל מה שגוף יכול לעשות. ההבדל היחיד בכל התצפית הוא שאחד נושא finish_reason: "length" והשני לא נושא דבר.
לכן הכלל אינו ״לתפוס שגיאות בזמן streaming״. הוא:
Stream שמסתיים בלי
finish_reasonלא הסתיים. הוא נעצר.
התייחסו ל-finish_reason חסר ככשל, תמיד, ולעולם אל תשמרו את הטקסט הזה כתשובה שהושלמה. השורה השלישית מראה את המקרה הקל יותר — socket שהושמד כן זורק, והוא גם מאבד את ה-chunk שהיה בדרך, ולכן הטקסט קצר במילה אחת משני הקודמים.
חמישה status codes שהם חמש בעיות שונות
קישור למקטע: חמישה status codes שהם חמש בעיות שונותההרגל היקר ביותר של מוצר חדש הוא בלוק catch אחד לכל מה שה-provider מחזיר. הקודים האלה אינם וריאציות של ״זה נכשל״. הם חמש הוראות, וארבע מהן סותרות זו את זו.
| status | מה המשמעות | מה לעשות | לחכות? |
|---|---|---|---|
| 400 | ה-request שלכם פגום — JSON שגוי, שדה לא מוכר, context ארוך מדי | לתקן את הקוד | אף פעם |
| 401 | המפתח שגוי, חסר או בוטל | לתקן את ה-deployment | אף פעם |
| 429 | rate limit: יותר מדי בקשות, או יותר מדי tokens, לדקה | לנסות שוב | Retry-After, ואז backoff |
| 500 | ה-provider נשבר | לנסות שוב | backoff |
| 503 | ה-provider עמוס — הוא למעלה, הוא מלא | לנסות שוב | backoff, ולהשיל עומס |
הקו החשוב עובר בין 4xx לשאר. 400 או 401 מחזירים בדיוק את אותה תשובה אם תשלחו אותם אלף פעמים, כי שום דבר באף אחד מהצדדים לא משתנה בין ניסיונות. retry שלהם אינו זהירות, הוא עיכוב עם שלבים נוספים. במדידה: client אחד שמבצע שישה ניסיונות — חמישה retries עם exponential backoff — ואחד שקורא קודם את הקוד.
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שש שניות של spinner כדי להגיע לתשובה שהייתה זמינה בארבע מילישניות. וזו הגרסה המתונה: retries במוצר בדרך כלל מקוננים — HTTP client שעושה retry בתוך job runner שעושה retry בתוך queue עם redelivery משלה — כך ששש שניות הופכות לשש דקות של deployment שבור לצמיתות שנראה כמו איטי.
הטריאז׳ הוא תשע שורות ושייך למקום אחד:
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
}שניים נוספים לרשימה: 402, שחלק מה-providers משתמשים בו עבור ״נגמר לכם הקרדיט״ ודורש מסך עם קישור לקנייה נוספת במקום retry, ו-529 או המקבילות הספציפיות לספק, שמתנהגים כמו 503.
Backoff, ומה jitter באמת קונה
קישור למקטע: Backoff, ומה jitter באמת קונהלעשות retry זה קל. לעשות retry מתי הוא החלק שיש לו תשובה נכונה מדידה.
Exponential backoff הוא הסטנדרט: מחכים השהיית בסיס, מכפילים אותה אחרי כל כשל, עוצרים בתקרה. הוא קיים כי שרת עמוס מחמיר אם ה-clients שזה עתה נכשלו חוזרים מיד.
הבעיה היא שכולם מכפילים מאותה נקודת התחלה. אם מאה clients פוגעים במגבלה באותו רגע — והם יפגעו, כי זה מה שקורה בקפיצת תנועה — אז כל המאה ממתינים 200 ms, כל המאה מנסים שוב יחד, כל המאה נכשלים יחד, וכל המאה ממתינים 400 ms. לוח הזמנים של ה-retry סנכרן אותם. זו thundering herd, ואקראיות היא התיקון.2
השינוי היחיד הזה — בחירה אחידה מתוך המרווח במקום לקחת את הקצה העליון שלו — נקרא full jitter. זו קריאה אחת ל-Math.random(), ושווה למדוד אותה במקום להאמין:
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); מאה clients, שרת אחד שמשרת שלושה בכל פעם, כל השאר זהה, שלוש הרצות לכל מצב:
| HTTP requests | דחיות | ה-client הגרוע ביותר | חלון 50 ms העמוס ביותר | wall clock | |
|---|---|---|---|---|---|
| בלי jitter, הרצה 1 | 491 | 391 | 10 ניסיונות | 46 כניסות | 65.6 שנ׳ |
| בלי jitter, הרצה 2 | 780 | 680 | 19 ניסיונות | 72 כניסות | 245.7 שנ׳ |
| בלי jitter, הרצה 3 | 770 | 670 | 18 ניסיונות | 97 כניסות | 225.6 שנ׳ |
| full jitter, הרצה 1 | 324 | 224 | 5 ניסיונות | 32 כניסות | 2.2 שנ׳ |
| full jitter, הרצה 2 | 313 | 213 | 6 ניסיונות | 31 כניסות | 2.3 שנ׳ |
| full jitter, הרצה 3 | 318 | 218 | 6 ניסיונות | 25 כניסות | 1.8 שנ׳ |
שני דברים בטבלה הזו, והשני הוא החשוב.
הראשון הוא החציון: 226 שניות מול 2.2, פקטור של בערך מאה, עם פחות מחצי מהבקשות. חלון ה-retry העמוס ביותר מסביר למה. בלי jitter, עד 97 מתוך מאה ה-clients הגיעו בתוך אותו משבצת של 50 מילישניות; לשרת היו שלושה, לכן 94 נדחו והלכו לישון יחד, עדיין מסונכרנים, כדי לעשות זאת שוב עם המתנה ארוכה יותר. עם jitter, אותם מאה התפזרו על אותם חלונות בקבוצות של כשלושים והתרוקנו כמעט מיד.
השני הוא ה-variance. בלי jitter: 65.6 שנ׳, 245.7 שנ׳, 225.6 שנ׳. איתו: 2.2, 2.3, 1.8. מערכת בלי jitter לא רק מתפקדת רע, היא מתפקדת באופן בלתי צפוי, כי התוצאה נקבעת על ידי תאונות תזמון מיקרוסקופיות שבוחרות אילו שלושה מתוך מאה clients מסונכרנים יגיעו ראשונים. זו החתימה של הבאג הזה בייצור: endpoint שהוא בסדר, בסדר, בסדר, ואז לוקח ארבע דקות, ושום שינוי שלכם לא מסביר את זה.
וה-retry הזול ביותר הוא זה שלא קורה. שימו concurrency gate לפני ה-provider — מונה שלעולם לא מאפשר ליותר מ-N בקשות להיות in flight — ואותם עשרים clients שהיו צריכים 74 בקשות ו-7.1 שניות מתנהגים כך:
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 msעשרים בקשות לעשרים תשובות, אפס דחיות, פי שמונה מהר יותר. retry הוא ההתנצלות; ה-gate הוא לא להזדקק לה.
Retry-After הוא רצפה, לא המלצה
קישור למקטע: Retry-After הוא רצפה, לא המלצהכש-provider מחזיר 429 הוא בדרך כלל אומר לכם כמה זמן לחכות, ב-header Retry-After.3 המספר הזה אינו עצה: ה-provider הוא הצד היחיד בחילוף שיודע מתי החלון שלו מתאפס.
לכן ההמתנה היא הגדולה מבין השתיים: אף פעם לא פחות מ-Retry-After, ואף פעם לא פחות מה-backoff שלכם, כי ה-header אומר לכם מתי ה-limiter סולח לכם ולא מתי לשרת יש מקום.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); ה-trace של ה-client הכי חסר מזל בהרצת עשרים ה-clients מראה שה-header עושה את העבודה. ארבע משיכות ה-backoff הראשונות שלו היו כולן מתחת לשנייה אחת, וארבעתן נדרסו:
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שתי הערות מעשיות. Retry-After עשוי להיות תאריך HTTP ולא מספר שניות, אז פענחו את שניהם. ו-providers עושים rate-limit על שני צירים בבת אחת — בקשות לדקה ו-tokens לדקה — ולכן prompts ארוכים נדחים הרבה מתחת למגבלת הבקשות המתועדת. ה-header נראה אותו דבר בשני המקרים; התיקון לא.
ה-timeout שאף אחד לא בחר
קישור למקטע: ה-timeout שאף אחד לא בחרבקשו מה-mock provider את /hang. הוא מקבל את החיבור, ואז לא עושה שום דבר בכלל: אין headers, אין body, אין close. זה לא אקזוטי — זה מה ש-load balancer עושה כשהתהליך מאחוריו מת בלי לסגור את ה-sockets שלו.
שני clients, הבדל אחד:
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שלוש מאות שניות. חמש דקות של socket שמוחזק פתוח, slot של בקשה תפוס ומשתמש שבוהה ב-spinner, שמסתיימות ב-TypeError גנרי שלא אומר דבר על מה שקרה. המספר הזה אינו bug: זהו headers timeout ברירת המחדל של Node, סביר עבור HTTP client כללי ואסון עבור בקשה מול משתמש. לכל runtime יש ברירת מחדל כזו, רוב האנשים לעולם לא בודקים אותה, והדרך היחידה למצוא את שלכם היא לתלות socket בכוונה כפי שעשינו עכשיו.
לכן: כל request יוצא מקבל deadline מפורש, שאתם בוחרים.
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});עבור קריאת streaming, deadline אחד אינו מספיק, כי יש שני כשלים שונים. הראשון הוא ה-stream לעולם לא נפתח: שום אירוע לא מגיע כלל, ועשר עד שלושים שניות הוא זמן נכון. השני הוא ה-stream נפתח ואז נתקע: tokens זרמו ואז נעצרו, לנצח, כשה-socket עדיין תקין. timeout של משך כולל אינו יכול להבדיל בין stream תקוע לתשובה נכונה ארוכה, ולכן מה שאתם רוצים הוא idle timeout — טיימר שמתאפס בכל אירוע, ויורה רק כששום דבר לא הגיע במשך, נניח, חמש-עשרה שניות.
ביטול הוא אותה מכונה שמכוונת לאדם. AbortSignal.timeout ומשתמש שלוחץ Stop מגיעים שניהם כ-AbortError, לכן שלבו אותם ורשמו מי מהם הופעל:
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort חשוב מסיבה שמעבר לסדר וניקיון: ה-tokens מיוצרים ומחויבים בזמן שאתם כבר לא מאזינים. פרק 16 שם על זה מחיר.
מה בטוח לנסות שוב
קישור למקטע: מה בטוח לנסות שובעכשיו הכשל שעולה כסף במקום זמן. request מגיע ל-timeout ב-client, והמהלך המתבקש הוא לשלוח אותו שוב — אבל timeout לא אומר לכם כלום על השאלה אם השרת קיבל אותו. לעיתים קרובות הוא קיבל, ועדיין עובד.
נמדד. ה-mock provider צריך 780 ms לתשובה. ה-client מוותר אחרי 300 ms ומנסה שוב. השרת סופר כמה תשובות הוא באמת ייצר, שזה מה שהיה מחייב עליו:
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בלי מפתח: שתי generations מלאות, בתשלום כפול, וה-client לא קיבל אף אחת מהן. עם מפתח: השרת זיהה שה-request השני הוא אותו request והשיב מיד עם התשובה שכבר ייצר, כך שה-retry גם מנע חיוב כפול וגם היה הניסיון שהצליח לבסוף.
Idempotency key הוא מחרוזת ייחודית שאתם מייצרים לכל פעולה לוגית — לא לכל ניסיון — ושולחים ללא שינוי בכל retry שלה. השרת שומר את התוצאה מול המפתח ומשמיע אותה מחדש. זה המנגנון שבו payment APIs משתמשים, מאותה סיבה.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");
}שני גבולות כנים. לא כל provider תומך ב-idempotency keys עבור completions, ובמקום שבו ה-endpoint אינו idempotent, מספר ה-retries הנכון עבור POST שאולי כבר רץ הוא אפס. ו-stream שנכשל באמצע אינו ניתן להשמעה מחדש במקרה הכללי: או שאתם מפעילים אותו מחדש ומשלמים שוב, או שומרים את הטקסט החלקי ומסמנים אותו כלא שלם. איזו מהאפשרויות המוצר שלכם עושה היא החלטת מוצר, לא החלטת רשת, וכדאי לקבל אותה בכוונה.
סגירת התפר
קישור למקטע: סגירת התפרל-client שנכתב בפרק הזה אין מושג מה נמצא מאחורי ה-port. כוונו את ה-base URL שלו ל-provider מסחרי והוא מזרים tokens ממודל של טריליון parameters. כוונו אותו לשרת שנבנה על האריתמטיקה של פרק 13 — שמשרת את המודל שאימנתם מראש ב-פרק 10, עם ה-KV cache שלו וה-weights המקוונטטים שלו — ואותו קוד, ללא שינוי, מזרים tokens ממודל שבניתם.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; השורה היחידה הזו היא התפר של הקורס הזה. בצד אחד שלה נמצא מה ששלושה-עשר הפרקים הראשונים בנו; בצד השני, מה ששישה-עשר הבאים בונים. הגבול נקי כי החוזה הוא HTTP ו-SSE, ואף צד לא יודע שום דבר נוסף על הצד האחר.
שווה לשים לב מה איבדתם כשחציתם. מאחורי endpoint מסחרי אינכם שולטים ב-weights, לא במימוש ה-sampling, לא בגרסה שאתם מדברים איתה, ולא בשאלה אם היא השתנתה הבוקר. מה שאתם שולטים בו הוא החוזה: ההודעות שאתם שולחים, ה-deadline שאתם קובעים, הקודים שאתם מבחינים ביניהם, ומה שאתם עושים כששום דבר לא חוזר. זה משטח קטן יותר מזה שהיה לכם בפרק 5, וכל פרק שנותר עוסק בשימוש טוב בו.
לאן זה ממשיך
קישור למקטע: לאן זה ממשיךעכשיו יש לכם client שעושה streaming, מוותר בזמן, מנסה שוב את הדברים הנכונים ולעולם לא מנסה שוב את הלא נכונים. מה שהוא שולח הוא עדיין כל מה שהקלדתם.
פרק 15 עוסק בתוכן הזה, והוא מגיע עם משמעת. האינטרנט מלא בעצות prompting — להציע למודל טיפ, לאיים עליו, לומר לו לנשום עמוק — וכמעט אף אחת מהן לא מגיעה עם מדידה. חלק מהטכניקות האלה מזיזות את הפלט הרבה, חלק לא מזיזות אותו כלל, ולפחות אחת הופכת משימת סיווג לגרועה יותר בזמן שהיא עולה יותר tokens. מי היא מי לא ברור מקריאה שלהן, וזה לא מוכרע בוויכוח.
לכן הפרק הבא בונה bench: שישים מקרים עם תשובות ידועות, ארבע וריאציות של אותו prompt, רצות במקביל דרך בדיוק ה-client שכתבתם עכשיו, מטובלות בטבלה עם רווחי הסמך מ-פרק 4 — כי ארבע וריאציות על פני עשרים מקרים לא מבחינות בשום דבר בכלל. משפט אחד מנהל את כל הפרק: prompt נמדד, לא מתווכחים עליו.
מקורות ושיטה
קישור למקטע: מקורות ושיטהכל מספר למעלה הגיע מה-mock provider, על Node 22 מעל loopback interface, ולכן ה-latencies נקיים יותר מכל מה שרשת אמיתית תיתן לכם. זה מכוון: אף אחד מהכשלים שנמדדים אינו נגרם מהרשת, ושרת עוין שאפשר להפעיל מחדש מלמד טוב יותר משרת אמיתי שצריך לשלם עליו ואי אפשר לשבור.
הפניות
קישור למקטע: הפניות-
Server-Sent Events, WHATWG HTML Living Standard, סעיף 9.2. פורמט ה-wire — שדות
data:, אירועים שמופרדים בשורות ריקות,id:ו-retry:— מוגדר שם, יחד עם ממשקEventSource.EventSourceלא יכול לשלוח request body או headers מותאמים, ולכן כל LLM client מפענח את הפורמט ידנית מעלfetchבמקום להשתמש בו. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). המקור לניסוח ״full jitter״ שבו נעשה שימוש למעלה, עם הסימולציות שמראות למה הגרסה הנאיבית מסנכרנת clients. הטיעון המשלים להשלת עומס במקום לתורים הוא פרק Handling Overload אצל Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016). ↩
-
Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, סעיף 15, מגדיר את מחלקות ה-status code; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), סעיף 4, מגדיר 429 Too Many Requests.
Retry-Afterהוא RFC 9110 סעיף 10.2.3, ומקבל או מספר שניות או תאריך HTTP. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, נקרא ב-7 בספטמבר 2026 — ההצהרה הברורה ביותר של החוזה: מפתח אחד לכל פעולה לוגית, תוצאות שמורות מושמעות מחדש, conflict מוחזר בזמן שהניסיון הראשון עדיין in flight — והדפוס אינו תלוי provider. ההפניות הנורמטיביות לצורות ה-request וה-event שבהן נעשה שימוש כאן הןdevelopers.openai.com/api/reference/resources/chatעבור streaming, error codes ו-rate limits, ו-platform.claude.com/docs/en/api/messagesעבור Messages API;ai-sdk.dev/docsהיא הדוגמה המפותחת הטובה ביותר לאותם שיקולים עטופים בספרייה. כולן נקראו באותו יום. ↩