첫 Production LLM 호출: Streaming, 재시도, 타임아웃
429, 멈춘 socket, 중간에 끊긴 stream까지 거짓말하는 provider를 만들고 client가 어떻게 버티는지 측정합니다. full jitter는 226초 대신 2.2초.
이 페이지에서
13장은 직접 만질 수 있는 model 위에 스톱워치를 올려두고 끝났습니다. 가중치는 메모리 안에 있었고, KV cache를 켜거나 끄는 것도 여러분의 몫이었으며, 나온 숫자 — 첫 token까지의 시간 — 는 여러분 하드웨어의 속성이었습니다.
이제 그 model을 port 뒤에 둡니다. 모든 product가 그렇게 합니다. 그리고 같은 숫자를 다시 읽습니다. 여전히 첫 token까지의 시간이지만, 이제 여러분이 통제하는 어떤 것의 속성도 아닙니다. 이제 그 안에는 TLS handshake, provider의 queue, rate limiter, 그리고 token이 아예 도착하지 않을 가능성이 들어 있습니다.
마지막 절이 이 장의 전부입니다. 이제 작성할 code는 아무것도 계산하지 않습니다. connection을 열고, 기다리고, 도착한 것을 parse하고, 아무것도 도착하지 않을 때 무엇을 할지 결정하고, 도착한 것이 error일 때 다시 결정하며, 사용자가 마음을 바꾸면 스스로 cancel합니다. 이 각각은 시간에 따른 state에 대한 결정이고, 각각에는 출시되어 비용을 발생시키는 잘못된 답이 있습니다.
문제의 형태는 이렇습니다. 이 장에서 모두 측정합니다.
| 일어난 일 | 부주의한 client가 하는 일 | 비용 |
|---|---|---|
| server가 socket을 수락하고 응답하지 않음 | 기다림 | Node가 스스로 포기하기까지 300.8초 |
| key가 잘못됨(401) | 다섯 번 재시도 | 6,325ms 지연 후 같은 401 |
| client 100개가 rate limit에 동시에 걸림 | 모두 같은 schedule로 재시도 | 비우는 데 226초, 반면 2.2초 |
| request가 timeout되고 다시 전송됨 | 다시 보냄 | provider가 답을 두 번 생성하고 과금함 |
| connection이 답변 중간에 끊김 | partial text를 보여줌 | 올바른 짧은 답과 구분 불가 |
이 중 어느 것도 modelling 문제가 아닙니다. 모두 지금까지 작성된 모든 LLM product의 첫 100줄 안에 있습니다.
이 장에서 언어가 바뀌는 이유
섹션 링크: 이 장에서 언어가 바뀌는 이유저 표를 다시 읽고, 어떤 종류의 program을 설명하는지 물어보세요. 40초 동안 connection을 열어 둡니다. 버튼으로 cancel할 수 있어야 합니다. 표시하기에는 유효하지만 저장하기에는 유효하지 않은 partial answer를 누적합니다. 그리고 server process나 edge worker에서, 답을 render하는 것 옆에서, socket을 붙잡고 실행됩니다.
그것은 notebook이 아닙니다. Python이 못 한다는 뜻은 아닙니다. 할 수 있고, 사람들도 합니다. 다만 앞선 13개 장이 쌓아 온 모든 것은 다른 종류였습니다. 1장부터 13장까지는 가중치, gradient, logits, tokenizer byte를 다뤘습니다. 여기서부터 code는 connection, retry, cancellation, 누적 state, 그리고 나중에는 permission prompt를 다룹니다. course는 object가 바뀌는 바로 그 이음새에서 언어를 바꿉니다.
그래서 규칙을 한 번 적습니다.
code가 손에 가중치, gradient, logits 또는 tokenizer byte를 들고 있으면 Python입니다. connection을 들고, retry하고, cancel하고, state를 누적하고, permission을 요청하면 TypeScript입니다.
이음새는 하나이고, 여기 13장과 14장 사이에 놓입니다. 세 가지 독립적인 기준이 이곳을 가리킵니다.
하나: ecosystem, 숫자로 보기. 이 course의 왼쪽 절반이 인용하는 모든 것은 Python이고, 이 syllabus를 위해 감사한 12개 course 전체에서 backpropagation을 다른 언어로 가르친 선례는 하나도 없습니다. micrograd(17.4K stars), nanoGPT(62.8K), nanochat(57.8K), minbpe(10.7K), PyTorch(102.8K), transformers(164.9K). 5장을 TypeScript로 쓰면 그 source들과의 연결이 끊어집니다. 그리고 그 연결은 rank가 아니라 참조되기 위해 존재하는 장에서 가치의 절반입니다. 이쪽에서는 산술이 뒤집힙니다. Vercel의 ai package는 월 89.4M downloads이고, 그 자체로 물건을 제공합니다 — tool-calling agent loop를 ToolLoopAgent로 export합니다. 그래서 이 course가 23장에서 도달하는 개념은, 그 장이 측정하듯 아직 아무도 이름에 합의하지 않았음에도, TypeScript에 reference implementation이 있습니다. Mastra는 27.7K stars입니다. 그리고 Anthropic의 SDK들은 하나의 specification에서 생성되며, TypeScript에서 202개 endpoint, Python에서 201개 endpoint를 선언합니다. 배려 차원의 port가 아니라 동등성입니다.
둘: MCP의 규범적 source. Model Context Protocol specification의 schema는 schema.ts file입니다. 26장의 protocol을 다른 언어로 가르친다는 것은 그 창립 문서의 번역을 가르친다는 뜻입니다.
셋: 검색 수요, 그리고 뻔한 추측에 대한 수정. machine learning python는 internet에서 가장 포화된 phrase이고, ai agent typescript도 건강한 long tail을 갖고 있습니다. 하지만 "MCP ecosystem은 대부분 TypeScript"라는 말은 어떻게 세느냐에 따라야만 참입니다. official registry는 npm에 8,275개 server, PyPI에 3,603개 server를 나열하지만, downloads 기준으로는 Python이 이깁니다 — mcp 월 287M plus fastmcp 72M, @modelcontextprotocol/sdk 195M 대비. MCP는 여기서 유일하게 진짜 bilingual territory입니다. 그래서 27장은 아닌 척하지 않고 같은 server를 두 번 씁니다.
세부 정보 보기
선언된 다섯 가지 예외: 규칙이 slogan이 아니라 규칙이 되도록.
17장, 20장, 29장은 Python으로 된 두 번째 panel을 포함합니다. top-p sampling을 구현하려면 probability vector를 손에 쥐고 있어야 하고 HTTP API는 그것을 절대 주지 않습니다. fine-tune의 가격을 정직하게 매기려면 실제로 하나를 실행해야 하며, LoRA adapter는 nn.Module 열몇 줄입니다. 그리고 lm-eval-harness, HELM, SWE-bench, τ-bench는 Python입니다. 따라서 TypeScript로 된 evaluation harness는 backpropagation 실수의 거울상입니다. 27장은 위에서 측정한 이유 때문에 bilingual입니다. 28장은 Markdown입니다. agent skill은 곧 SKILL.md file이고, 그것에 programming language를 부여한다는 것은 format을 이해하지 못했다는 뜻이기 때문입니다.
13개의 Python 장은 버려지지 않습니다. port 반대편에 있는 것이 바로 그것들이 만든 것이고, 여기 마지막 section은 client를 그것에 연결합니다.
망가뜨릴 수 있는 provider
섹션 링크: 망가뜨릴 수 있는 provider실제 provider를 상대로는 이것을 배울 수 없습니다. 원하는 순간에 429를 달라고 할 수도 없고, connection을 수락하고 절대 응답하지 않는 socket을 달라고 할 수도 없고, 단어 중간에서 멈추는 stream을 달라고 할 수도 없습니다. 그리고 모든 experiment마다 비용을 지불해야 합니다. 흥미로운 experiment는 100번씩 돌리는 것인데도 말입니다.
그래서 course의 이 절반에서 첫 program은 client가 아닙니다. 적대적 server입니다. chat completions endpoint와 같은 wire protocol을 말하고, 요청하면 일부러 잘못 행동하는 plain Node 40줄입니다. 이 장의 모든 숫자는 거기서 나왔습니다.
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);적대적 behavior 네 가지, 각각 한 줄입니다. /hang는 socket을 수락하고 아무것도 쓰지 않습니다. /401는 key를 거부합니다. capacity check는 이미 3개 request가 진행 중이면 진짜 Retry-After header가 있는 진짜 429를 만듭니다. 그리고 ?cut=N는 socket을 reset하거나 — &how=close와 함께 — 질서 있게 닫아 답을 중간에 포기합니다. 이것이 매우 중요하다는 점이 드러납니다. 나머지는 실제 Server-Sent Events stream입니다. data: line마다 JSON object 하나, event 사이 blank line 하나, 끝에는 string [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 body, 그리고 server를 절대 떠나지 않는 key
섹션 링크: request body, 그리고 server를 절대 떠나지 않는 keychat request는 message의 list이고, 각 message에는 role이 있습니다. 그 list가 model의 전체 state입니다. 호출 사이에 memory는 없고, model이 알기를 원하는 것은 무엇이든 이번에 보내는 array 안에 있어야 합니다. 15장은 무엇을 넣을지에 관한 장이고 16장은 그 비용에 관한 장이므로, 여기서는 shape만 봅니다.
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들은 장식이 아닙니다. model이 token 하나를 보기 전에 11장의 chat template으로 render됩니다. 그래서 잘못된 role을 보내면 error를 일으키는 대신 조용히 답의 품질이 떨어집니다.
예외 없는 규칙 하나: API key는 절대 client로 이동하지 않습니다. browser용 prefix가 붙은 environment variable에도, build-time constant에도, "임시로"도 아닙니다. bundle 안의 key는 며칠 안에 다른 사람의 청구서 위 key가 됩니다. browser는 여러분의 server와 대화하고, 여러분의 server는 key를 보유한 채 provider와 대화합니다. 그리고 여러분의 server가 가운데 있기 때문에, 각 user가 얼마를 쓰는지 meter할 수 있는 유일한 장소이기도 합니다. 16장의 accounting은 바로 거기에 있어야 합니다.
같은 질문, 세 번
섹션 링크: 같은 질문, 세 번이제 이 장이 기반으로 삼는 experiment입니다. 질문 하나, 60ms마다 13개 token을 생성하는 mock provider 하나, 질문하는 방법 세 가지.
첫째, streaming 없이. client는 request를 보내고 전체 JSON body를 기다립니다.
blocking first visible = 791 ms complete = 791 ms finish_reason = stop두 숫자는 같습니다. 그리고 그것이 문제의 전부입니다. 791ms 동안 user에게는 spinner만 있고, 한 단어도 더 일찍 사용할 수 없었습니다. server는 byte 단위로 답을 갖고 있었지만 아무 말도 하지 않기로 선택했습니다.
둘째, streaming으로. 같은 server, 같은 답, 같은 전체 work입니다. 차이는 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가 event와 아무 관계가 없기 때문입니다. read() 하나가 event 절반을 반환할 수도 있고, 두 개 반을 반환할 수도 있습니다. { stream: true } flag가 있는 이유는 multi-byte UTF-8 character가 두 chunk에 걸쳐 나뉠 수 있기 때문입니다. 이것이 없으면 accented letter가 무작위로 replacement character가 됩니다. 그리고 event는 newline이 아니라 blank line으로 구분됩니다. 그래서 loop가 \n\n를 찾습니다.
streaming first visible = 65 ms complete = 793 ms finish_reason = stop첫 단어까지는 12배 빠르고, 마지막 단어까지는 2ms 느립니다. Streaming은 아무것도 더 빠르게 만들지 않습니다. 같은 790ms 동안 user가 무엇을 하는지를 바꿉니다. 기다리는 대신 읽습니다. 그것이 전체 benefit이고, 그것은 거대하며, 모든 chat product가 stream하는 이유입니다.
셋째, client 20개를 동시에. mock provider는 한 번에 request 3개를 처리합니다. 20개를 쏩니다.
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답 20개, request 74개, rejection 54개. 아무도 아무것도 잃지 않았고, 모든 client가 같은 text를 받았으며, 눈에 보이는 유일한 비용은 시간이었습니다. 그것이 작동하는 retry policy입니다. 이 장의 나머지는 그것이 실패할 수 있는 세 가지 방식에 관한 것입니다.
finish_reason, 그리고 같아 보이는 두 가지 끝
섹션 링크: finish_reason, 그리고 같아 보이는 두 가지 끝실패로 가기 전에, 거의 모두가 첫 pass에서 무시하는 field입니다. 모든 stream은 finish_reason를 담은 event로 끝납니다. stop는 model이 끝났다고 판단했다는 뜻입니다. length는 token ceiling에 닿았다는 뜻이므로, 답은 문장 중간에서 잘렸고 model의 잘못이 아닙니다. 나중 장들은 tool_calls(18장)과 content filter를 추가합니다.
이제 naive client가 구분할 수 없는 두 끝을 봅니다. 같은 server, 같은 delay, 하나는 max_tokens로 잘렸고 하나는 5개 token 뒤 connection이 깨끗하게 닫혔습니다.
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"첫 두 row를 주의 깊게 읽으세요. text가 동일합니다. chunk count도 동일합니다. 어느 쪽도 exception이 없습니다. for await loop는 두 경우 모두 정상 종료되었습니다. reader의 관점에서는 body가 끝났고, body가 할 수 있는 일은 그것뿐이기 때문입니다. 전체 관찰에서 유일한 차이는 하나에는 finish_reason: "length"가 있고 다른 하나에는 아무것도 없다는 점입니다.
따라서 규칙은 "streaming 중 error를 catch하라"가 아닙니다. 규칙은 이것입니다.
finish_reason없이 끝난 stream은 끝난 것이 아닙니다. 멈춘 것입니다.
누락된 finish_reason는 항상 failure로 취급하고, 그 text를 completed answer로 절대 persist하지 마세요. 세 번째 row는 더 쉬운 경우를 보여줍니다. destroyed socket은 throw하고, 진행 중이던 chunk도 잃습니다. 그래서 text가 위의 두 경우보다 한 단어 짧습니다.
다섯 status code는 다섯 가지 다른 문제
섹션 링크: 다섯 status code는 다섯 가지 다른 문제새 product가 가진 가장 비싼 습관은 provider가 반환하는 모든 것에 대해 catch block 하나를 쓰는 것입니다. 이 code들은 "실패했다"의 변형이 아닙니다. 다섯 가지 지시이고, 그중 네 가지는 서로 모순됩니다.
| status | 의미 | 해야 할 일 | 기다릴까? |
|---|---|---|---|
| 400 | request가 malformed — bad JSON, unknown field, context가 너무 김 | code를 고침 | 절대 아님 |
| 401 | key가 틀렸거나, 없거나, revoke됨 | deployment를 고침 | 절대 아님 |
| 429 | rate limit: minute당 request 또는 token이 너무 많음 | retry | Retry-After, 그다음 backoff |
| 500 | provider가 망가짐 | retry | backoff |
| 503 | provider가 overloaded — 살아 있지만 가득 참 | retry | backoff, 그리고 load를 덜어냄 |
중요한 선은 4xx와 나머지 사이에 그어집니다. 400이나 401은 천 번 보내도 정확히 같은 답을 반환합니다. attempt 사이에 양쪽 끝 어디에서도 아무것도 바뀌지 않기 때문입니다. 그것을 retry하는 것은 신중함이 아니라 절차가 더 붙은 delay입니다. 측정해 봅시다. 하나는 여섯 번 attempt합니다 — exponential backoff로 다섯 번 retry —, 다른 하나는 code를 먼저 읽습니다.
retry everything -> 6 requests, gave up after 6,325 ms, still HTTP 401
triage first -> 1 request, gave up after 4 ms, still HTTP 4014ms 만에 알 수 있었던 답에 도달하기 위해 6초 동안 spinner가 돕니다. 그리고 이것은 순한 버전입니다. product의 retry는 보통 중첩되어 있습니다. retry하는 HTTP client 안에 retry하는 job runner가 있고, 그 안에 자체 redelivery가 있는 queue가 있습니다. 그래서 6초는 영구적으로 망가진 deployment가 느린 deployment처럼 보이는 6분이 됩니다.
triage는 9줄이고 한곳에 있어야 합니다.
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
}list에 두 가지를 더합니다. 402는 일부 provider가 "credit이 부족함"에 사용하며, retry가 아니라 더 구매할 link가 있는 screen이 필요합니다. 그리고 529 또는 vendor-specific equivalent는 503처럼 행동합니다.
Backoff, 그리고 jitter가 실제로 사 주는 것
섹션 링크: Backoff, 그리고 jitter가 실제로 사 주는 것Retry하는 것은 쉽습니다. 언제 retry할지가 측정 가능한 정답이 있는 부분입니다.
Exponential backoff가 표준입니다. base delay만큼 기다리고, 실패할 때마다 두 배로 늘리며, ceiling에서 멈춥니다. 방금 실패한 client들이 바로 돌아오면 overloaded server가 더 나빠지기 때문에 존재합니다.
문제는 모두가 같은 시작점에서 두 배로 늘린다는 것입니다. client 100개가 같은 순간 limit에 걸리면 — traffic spike가 바로 그런 것이므로 실제로 그럴 것입니다 — 100개 모두 200ms를 기다리고, 100개 모두 함께 retry하고, 100개 모두 함께 실패하며, 100개 모두 400ms를 기다립니다. retry schedule이 그들을 동기화했습니다. 이것이 thundering herd이고, randomness가 해결책입니다.2
그 하나의 변화 — 상한을 그대로 쓰는 대신 interval에서 uniformly 고르는 것 — 를 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); client 100개, 한 번에 3개를 처리하는 server 하나, 나머지는 모두 동일, 각각 3회 실행:
| HTTP requests | rejections | worst client | 가장 바쁜 50ms window | wall clock | |
|---|---|---|---|---|---|
| no jitter, run 1 | 491 | 391 | 10 tries | 46 arrivals | 65.6초 |
| no jitter, run 2 | 780 | 680 | 19 tries | 72 arrivals | 245.7초 |
| no jitter, run 3 | 770 | 670 | 18 tries | 97 arrivals | 225.6초 |
| full jitter, run 1 | 324 | 224 | 5 tries | 32 arrivals | 2.2초 |
| full jitter, run 2 | 313 | 213 | 6 tries | 31 arrivals | 2.3초 |
| full jitter, run 3 | 318 | 218 | 6 tries | 25 arrivals | 1.8초 |
저 표에는 두 가지가 있고, 두 번째가 중요합니다.
첫 번째는 median입니다. 226초 대 2.2초, 대략 100배 차이이고 request는 절반도 안 됩니다. 가장 바쁜 retry window가 이유를 말해 줍니다. jitter가 없으면 client 100개 중 최대 97개가 같은 50ms slot 안에 도착했습니다. server에는 3개 자리가 있었으므로 94개는 거부되고 함께 잠들었으며, 여전히 동기화된 채 더 긴 wait로 다시 같은 일을 했습니다. jitter가 있으면 같은 100개가 같은 window들에 약 30개씩 퍼지고 거의 즉시 비워졌습니다.
두 번째는 variance입니다. jitter 없음: 65.6초, 245.7초, 225.6초. 있음: 2.2, 2.3, 1.8. jitter가 없는 system은 단지 성능이 나쁜 것이 아니라 예측 불가능하게 성능이 나쁩니다. 결과가 동기화된 client 100개 중 어느 3개가 먼저 도착하는지를 고르는 microscopic scheduling accident에 의해 결정되기 때문입니다. production에서 이 bug의 signature는 이렇습니다. endpoint가 괜찮고, 괜찮고, 괜찮다가, 갑자기 4분이 걸립니다. 그리고 여러분의 변경 중 그 이유를 설명하는 것은 없습니다.
그리고 가장 싼 retry는 일어나지 않는 retry입니다. provider 앞에 concurrency gate를 둡니다. 진행 중인 request가 N개를 절대 넘지 않게 하는 counter입니다. 그러면 request 74개와 7.1초가 필요했던 같은 client 20개가 이렇게 행동합니다.
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms답 20개를 위한 request 20개, rejection 0개, 8배 빠름. retry는 사과입니다. gate는 사과할 필요가 없게 하는 것입니다.
Retry-After는 제안이 아니라 하한입니다
섹션 링크: Retry-After는 제안이 아니라 하한입니다provider가 429를 반환할 때는 보통 Retry-After header로 얼마나 기다릴지 알려 줍니다.3 그 숫자는 조언이 아닙니다. 교환에 참여한 당사자 중 window가 언제 reset되는지 아는 유일한 쪽은 provider입니다.
그래서 wait는 둘 중 더 큰 값입니다. Retry-After보다 절대 작지 않고, 여러분의 backoff보다도 절대 작지 않습니다. header는 limiter가 여러분을 언제 용서하는지를 알려 주는 것이지 server에 언제 room이 생기는지를 알려 주는 것이 아니기 때문입니다.
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0; // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt)); 20-client run에서 가장 운 나쁜 client의 trace는 header가 제 역할을 하는 모습을 보여줍니다. 그 client의 첫 네 backoff draw는 모두 1초 미만이었고, 네 번 모두 override되었습니다.
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실용적인 note 두 가지. Retry-After는 초 단위 숫자가 아니라 HTTP date일 수 있으므로 둘 다 parse하세요. 그리고 provider는 동시에 두 axis에서 rate-limit합니다 — minute당 requests와 minute당 tokens. 그래서 긴 prompts는 문서화된 request limit보다 훨씬 아래에서 거부됩니다. header는 두 경우 모두 같아 보입니다. fix는 같지 않습니다.
아무도 선택하지 않은 timeout
섹션 링크: 아무도 선택하지 않은 timeoutmock provider에 /hang를 요청해 보세요. connection을 수락한 다음 아무것도 하지 않습니다. headers도, body도, close도 없습니다. 이것은 이국적인 일이 아닙니다. 뒤쪽 process가 socket을 닫지 않은 채 죽었을 때 load balancer가 하는 일입니다.
client 둘, 차이는 하나:
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_TIMEOUT300초. socket이 열린 채 5분, request slot이 점유된 채 5분, user가 spinner를 바라보는 5분, 그리고 무엇이 일어났는지 아무 말도 하지 않는 generic TypeError로 끝납니다. 이 숫자는 bug가 아닙니다. Node의 default headers timeout입니다. generic HTTP client에는 합리적이고, user-facing request에는 재앙적입니다. 모든 runtime에는 이런 default가 있고, 대부분의 사람은 찾아보지 않습니다. 여러분의 값을 알아내는 유일한 방법은 우리가 방금 한 것처럼 일부러 socket을 걸어 두는 것입니다.
따라서: 모든 outgoing request에는 여러분이 선택한 explicit 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 call에는 deadline 하나로 충분하지 않습니다. 두 가지 다른 failure가 있기 때문입니다. 첫 번째는 stream이 절대 열리지 않는 것입니다. event가 전혀 도착하지 않으며, 10초에서 30초가 적절합니다. 두 번째는 stream이 열렸다가 stall되는 것입니다. tokens가 흐르다가 멈추고, socket은 여전히 healthy한 채 영원히 그대로입니다. total-duration timeout은 stalled stream과 긴 correct answer를 구분할 수 없습니다. 그래서 원하는 것은 idle timeout입니다. 모든 event마다 reset되고, 예를 들어 15초 동안 아무것도 도착하지 않을 때만 fire되는 timer입니다.
Cancellation은 같은 machinery가 사람을 향한 것입니다. AbortSignal.timeout와 user가 Stop을 누르는 것은 모두 AbortError로 도착하므로, 둘을 combine하고 어느 쪽이 fire되었는지 record하세요.
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();Abort가 중요한 이유는 깔끔함을 넘어섭니다. 여러분이 듣고 있지 않는 동안에도 tokens는 생성되고 과금되고 있습니다. 16장은 거기에 가격을 붙입니다.
retry해도 안전한 것
섹션 링크: retry해도 안전한 것이제 시간보다 돈이 드는 failure입니다. client에서 request가 timeout되고, obvious move는 다시 보내는 것입니다. 하지만 timeout은 server가 그것을 받았는지에 대해 아무것도 말해 주지 않습니다. 아주 자주 server는 받았고, 아직 작업 중입니다.
측정합니다. mock provider는 답에 780ms가 필요합니다. client는 300ms에 포기하고 retry합니다. server는 실제로 생성한 답의 수를 셉니다. 그것이 과금했을 값입니다.
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): 1key가 없으면: full generation 두 번, 두 번 과금, 그리고 client는 그중 어느 것도 받지 못했습니다. key가 있으면: server는 두 번째 request가 같은 request임을 인식하고 이미 생성한 답으로 즉시 응답했습니다. 그래서 retry는 double charge를 피했고, 마침내 성공한 attempt가 되었습니다.
idempotency key는 logical operation마다 — attempt마다가 아니라 — 생성하고, 그 operation의 모든 retry에 변경 없이 보내는 unique string입니다. server는 key에 outcome을 저장하고 replay합니다. payment API들이 같은 이유로 사용하는 mechanism입니다.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가 completions에서 idempotency keys를 지원하는 것은 아닙니다. 그리고 endpoint가 idempotent하지 않은 곳에서는, 이미 실행되었을 수 있는 POST에 대한 올바른 retry 횟수는 0입니다. 또한 중간에 실패한 stream은 일반적으로 replay할 수 없습니다. 다시 시작하고 다시 지불하거나, partial text를 유지하고 incomplete로 표시해야 합니다. product가 어느 쪽을 택할지는 networking decision이 아니라 product decision이며, 의도적으로 결정할 가치가 있습니다.
이음새 닫기
섹션 링크: 이음새 닫기이 장에서 작성한 client는 port 뒤에 무엇이 있는지 전혀 모릅니다. base URL을 commercial provider로 향하게 하면 trillion parameters model에서 tokens를 stream합니다. 10장에서 pretrained한 model을, KV cache와 quantized weights와 함께, 13장의 arithmetic 위에 구축한 server가 serve하게 하고 거기로 향하게 하면, 같은 code가 unchanged로 여러분이 만든 model에서 tokens를 stream합니다.
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1"; 그 한 줄이 이 course의 이음새입니다. 한쪽에는 처음 13개 장이 만든 것이 있고, 다른 한쪽에는 다음 16개 장이 만들 것이 있습니다. contract가 HTTP와 SSE이기 때문에 boundary는 깨끗합니다. 어느 쪽도 상대에 대해 그 외의 것을 알지 못합니다.
건너오면서 무엇을 잃었는지 알아차릴 가치가 있습니다. commercial endpoint 뒤에서는 가중치도, sampling implementation도, 대화 중인 version도, 그것이 오늘 아침 바뀌었는지도 통제할 수 없습니다. 여러분이 통제하는 것은 contract입니다. 보내는 message, 설정하는 deadline, 구분하는 code, 그리고 아무것도 돌아오지 않을 때 하는 일입니다. 그것은 5장에서 가졌던 것보다 더 작은 surface이고, 남은 모든 장은 그것을 잘 쓰는 법에 관한 것입니다.
다음은 어디로 가는가
섹션 링크: 다음은 어디로 가는가이제 여러분에게는 stream하고, 제때 포기하고, 올바른 것만 retry하며, 잘못된 것은 절대 retry하지 않는 client가 있습니다. 그것이 보내는 것은 여전히 여러분이 typed한 무엇이든입니다.
15장은 그 content에 관한 장이고, discipline을 동반합니다. internet에는 prompting advice가 가득합니다. model에게 tip을 제안하라, 협박하라, deep breath를 하라고 말하라. 그리고 그중 거의 어느 것도 측정과 함께 오지 않습니다. 어떤 technique은 output을 크게 움직이고, 어떤 것은 전혀 움직이지 않으며, 적어도 하나는 더 많은 tokens를 쓰면서 classification task를 더 나쁘게 만듭니다. 어느 것이 어느 것인지는 읽어서는 obvious하지 않고, 논쟁으로 settled되지도 않습니다.
그래서 다음 장은 bench를 만듭니다. known answer가 있는 case 60개, 같은 prompt의 variant 4개, 방금 작성한 바로 그 client를 통해 parallel로 실행하고, 4장의 confidence interval과 함께 tabulate합니다. case 20개에서 variant 4개는 아무것도 구분하지 못하기 때문입니다. 장 전체를 지배하는 한 문장은 이것입니다. prompt는 논쟁하는 것이 아니라 측정하는 것입니다.
Sources and method
섹션 링크: Sources and method위의 모든 숫자는 loopback interface 위 Node 22에서 mock provider로 얻었습니다. 그래서 latency는 어떤 실제 network보다 깨끗합니다. 그것은 의도적입니다. 측정하는 failure 중 network가 원인인 것은 없으며, restart할 수 있는 hostile server는 비용을 내야 하고 망가뜨릴 수 없는 실제 server보다 더 잘 가르칩니다.
-
Server-Sent Events, WHATWG HTML Living Standard, section 9.2. wire format —
data:fields, blank-line-separated events,id:andretry:— 은 거기서 정의되며,EventSourceinterface도 함께 정의됩니다.EventSource는 request body나 custom header를 보낼 수 없습니다. 그래서 모든 LLM client는 그것을 사용하는 대신fetch위에서 format을 직접 parse합니다. ↩ -
Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). 위에서 사용한 "full jitter" formulation의 source이며, naive version이 왜 client를 동기화하는지 보여 주는 simulation을 포함합니다. queueing보다 load shedding을 해야 한다는 companion argument는 Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016)의 Handling Overload chapter입니다. ↩
-
Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, section 15는 status code class를 정의합니다. Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4는 429 Too Many Requests를 정의합니다.
Retry-After는 RFC 9110 section 10.2.3이며, 초 단위 숫자 또는 HTTP date를 받습니다. ↩ -
Stripe, Idempotent requests,
docs.stripe.com/api/idempotent_requests, 2026년 9월 7일 열람 — contract의 가장 명확한 statement입니다. logical operation마다 key 하나, 저장된 result replay, 첫 attempt가 아직 진행 중이면 conflict 반환 — 그리고 이 pattern은 provider-independent입니다. 여기서 사용한 request와 event shape에 대한 normative reference는 streaming, error code, rate limit에 대한developers.openai.com/api/reference/resources/chat, Messages API에 대한platform.claude.com/docs/en/api/messages입니다.ai-sdk.dev/docs은 같은 concern을 library로 감싼 best worked example입니다. 모두 같은 날 열람했습니다. ↩