Chuyển đến nội dung
14/30Chương 14 trên 30

Lần gọi LLM production đầu tiên: streaming, retry, timeout

Xây một provider biết “nói dối”: 429, socket treo, stream đứt giữa chừng — rồi đo client phản ứng. Full jitter: 2,2 giây so với 226.

Trên trang này

Chapter 13 kết thúc với một chiếc đồng hồ bấm giờ đặt lên một model bạn có thể chạm vào. Weights nằm trong bộ nhớ của bạn, KV cache là thứ bạn tự bật hoặc tắt, và con số xuất hiện — time to first token — là thuộc tính của phần cứng bạn sở hữu.

Giờ hãy đặt model đó sau một port, như mọi sản phẩm đều làm, rồi đọc lại cùng con số ấy. Nó vẫn là time to first token, nhưng không còn là thuộc tính của bất cứ thứ gì bạn kiểm soát. Giờ nó bao gồm một TLS handshake, một hàng đợi ở provider, một rate limiter, và cả khả năng không có token nào đến cả.

Mệnh đề cuối cùng chính là chương này. Đoạn code bạn sắp viết không tính toán gì. Nó mở một kết nối, chờ, parse thứ nhận được, quyết định phải làm gì khi không có gì đến, quyết định lại khi thứ đến là lỗi, và tự hủy khi người dùng đổi ý. Mỗi việc đó là một quyết định về trạng thái theo thời gian, và mỗi việc đều có một câu trả lời sai có thể được ship và làm tốn tiền.

Đây là hình dạng của vấn đề, đã được đo, toàn bộ nằm trong chương này:

chuyện đã xảy raclient cẩu thả làm gìcái giá phải trả
server chấp nhận socket rồi không bao giờ trả lờichờ300,8 s trước khi Node tự bỏ cuộc
key sai (401)retry năm lầntrễ 6.325 ms, rồi vẫn cùng lỗi 401
một trăm client cùng chạm rate limittất cả retry theo cùng lịch226 s để xả hết, so với 2,2 s
request bị timeout và được gửi lạigửi lại nóprovider sinh — và tính tiền — câu trả lời hai lần
kết nối rớt giữa câu trả lờihiển thị phần text dang dởkhông thể phân biệt với một câu trả lời ngắn đúng

Không có thứ nào là vấn đề modeling. Tất cả đều nằm trong một trăm dòng đầu tiên của mọi sản phẩm LLM từng được viết.

Đọc lại bảng đó và tự hỏi nó mô tả loại chương trình nào. Nó giữ một kết nối mở trong bốn mươi giây. Nó phải có thể bị hủy từ một nút bấm. Nó tích lũy một câu trả lời từng phần, hợp lệ để hiển thị nhưng không hợp lệ để lưu. Và nó chạy trong một server process hoặc tại một edge worker, cạnh thứ render câu trả lời, trong khi giữ một socket.

Đó không phải notebook. Không phải Python không làm được — nó làm được, và nhiều người vẫn làm — mà là mọi thứ mười ba chương trước đã xây dựng thuộc một loại khác. Chương 1 đến 13 cầm weights, gradients, logits và tokenizer bytes. Từ đây, code cầm một kết nối, một retry, một hủy bỏ, trạng thái tích lũy và, sau này, một prompt xin quyền. Khóa học đổi ngôn ngữ đúng tại đường ráp nơi đối tượng thay đổi.

Vì vậy quy tắc, viết một lần:

Nếu code đang cầm weights, gradients, logits hoặc tokenizer bytes, đó là Python. Nếu nó giữ kết nối, retry, hủy, tích lũy trạng thái và hỏi quyền, đó là TypeScript.

Đường ráp chỉ có một và nó nằm ở đây, giữa Chapter 13 và Chapter 14. Ba tiêu chí độc lập đặt nó ở đây.

Một: ecosystem, được đếm. Mọi thứ nửa bên trái của khóa học này trích dẫn đều là Python, và trong mười hai khóa học được kiểm tra cho syllabus này không có một tiền lệ nào dạy backpropagation bằng ngôn ngữ khác: micrograd (17,4K sao), nanoGPT (62,8K), nanochat (57,8K), minbpe (10,7K), PyTorch (102,8K), transformers (164,9K). Viết Chapter 5 bằng TypeScript sẽ cắt đứt liên kết với các nguồn đó, mà các liên kết ấy là một nửa giá trị của một chương tồn tại để được tham chiếu hơn là để xếp hạng. Ở phía này, phép tính đảo chiều: package ai của Vercel có 89,4M lượt tải mỗi tháng và ship chính thứ đó — một vòng lặp tool-calling agent, export dưới dạng ToolLoopAgent — nên khái niệm mà khóa học này đi tới trong Chapter 23 có reference implementation bằng TypeScript, dù như chương đó đo được, chưa ai thống nhất tên gọi cho nó; Mastra có 27,7K sao; và các SDK của Anthropic, được sinh từ một specification, khai báo 202 endpoint trong TypeScript so với 201 trong Python — ngang hàng, không phải một bản port chiếu lệ.

Hai: nguồn chuẩn tắc của MCP. Schema của specification Model Context Protocol là một file schema.ts. Dạy protocol của Chapter 26 bằng một ngôn ngữ khác nghĩa là dạy một bản dịch của tài liệu khai sinh ra nó.

Ba: nhu cầu tìm kiếm, với một chỉnh sửa cho phỏng đoán hiển nhiên. machine learning python là cụm từ bão hòa nhất trên internet; ai agent typescript cũng có một cái đuôi nhu cầu khỏe mạnh riêng. Nhưng “ecosystem MCP chủ yếu là TypeScript” chỉ đúng tùy bạn đếm thế nào: registry chính thức liệt kê 8.275 server trên npm so với 3.603 trên PyPI, trong khi về lượt tải Python thắng — 287M mỗi tháng cho mcp cộng 72M cho fastmcp so với 195M cho @modelcontextprotocol/sdk. MCP là lãnh thổ thật sự song ngữ duy nhất ở đây, đó là lý do Chapter 27 viết cùng một server hai lần thay vì giả vờ.

Hiện chi tiết

Năm ngoại lệ được tuyên bố, để quy tắc là quy tắc chứ không phải khẩu hiệu.

Chapters 17, 20 và 29 mang một panel thứ hai bằng Python: triển khai top-p sampling cần probability vector trong tay bạn và HTTP API thì không bao giờ đưa nó cho bạn; định giá một fine-tune cho trung thực nghĩa là chạy một fine-tune, và một LoRA adapter chỉ là khoảng mười hai dòng nn.Module; còn lm-eval-harness, HELM, SWE-bench và τ-bench là Python, nên một evaluation harness bằng TypeScript sẽ là ảnh phản chiếu của sai lầm backpropagation. Chapter 27 là song ngữ, vì lý do đã đo ở trên. Chapter 28Markdown, vì một agent skill chính là một file SKILL.md và gán cho nó một ngôn ngữ lập trình sẽ có nghĩa là chưa hiểu định dạng.

Mười ba chương Python không bị vứt bỏ. Thứ ở phía bên kia port chính là thứ chúng đã xây, và phần cuối ở đây nối một client với nó.

Bạn không thể học bất kỳ thứ gì trong số này với một provider thật. Bạn không thể yêu cầu nó trả 429 đúng khoảnh khắc bạn chọn, hay một socket chấp nhận kết nối rồi không bao giờ trả lời, hay một stream dừng giữa một từ — và bạn sẽ phải trả tiền cho từng thí nghiệm, trong khi các thí nghiệm thú vị là những thí nghiệm bạn chạy một trăm lần.

Vì vậy chương trình đầu tiên trong nửa này của khóa học không phải một client. Nó là một server thù địch: bốn mươi dòng Node thuần nói cùng wire protocol như một endpoint chat completions và cố tình hành xử sai theo yêu cầu. Mọi con số trong chương này đều đi ra từ nó.

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);

Bốn hành vi thù địch, mỗi hành vi một dòng: /hang chấp nhận socket rồi không bao giờ ghi vào nó; /401 từ chối key; kiểm tra capacity tạo ra một 429 thật với header Retry-After thật khi đã có ba request đang in flight; và ?cut=N bỏ dở câu trả lời giữa chừng, hoặc bằng cách reset socket hoặc — với &how=close — bằng cách đóng nó một cách trật tự, điều hóa ra rất quan trọng. Phần còn lại là một stream Server-Sent Events thật: một JSON object trên mỗi dòng data:, một dòng trống giữa các event, chuỗi [DONE] ở cuối.1

Chạy nó, và phần còn lại của chương là phép đo.

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]

Một chat request là một danh sách messages, mỗi message có một role. Danh sách đó là toàn bộ trạng thái của model: không có bộ nhớ giữa các lần gọi, và bất cứ thứ gì bạn muốn model biết đều phải nằm trong array bạn gửi lần này. Chapter 15 nói về việc đặt gì vào đó và Chapter 16 nói về chi phí của nó, nên ở đây chỉ là hình dạng.

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,
};

Những role đó không phải đồ trang trí. Chúng được render vào chat template của Chapter 11 trước khi model thấy dù chỉ một token, vì vậy gửi sai role sẽ âm thầm làm câu trả lời kém đi thay vì báo lỗi.

Một quy tắc không có ngoại lệ: API key không bao giờ đi tới client. Không nằm trong environment variable có tiền tố cho browser, không nằm trong hằng số build-time, không “tạm thời”. Một key trong bundle sẽ trở thành key trên hóa đơn của người khác chỉ trong vài ngày. Browser nói chuyện với server của bạn, server của bạn giữ key và nói chuyện với provider — và vì server của bạn nằm ở giữa, nó cũng là nơi duy nhất có thể đo mỗi người dùng tiêu bao nhiêu, tức là nơi phần kế toán của Chapter 16 phải sống.

Giờ là thí nghiệm mà chương này được xây quanh. Một câu hỏi, một mock provider tạo ra mười ba token với 60 ms mỗi token, ba cách hỏi.

Đầu tiên, không streaming. Client gửi request và chờ toàn bộ JSON body.

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

Hai con số giống nhau, và đó là toàn bộ vấn đề. Trong 791 ms, người dùng có một spinner, và không một từ nào sẵn sàng sớm hơn — server đã có câu trả lời, từng byte một, nhưng chọn không nói gì.

Thứ hai, với streaming. Cùng server, cùng câu trả lời, cùng tổng lượng việc. Khác biệt là một 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);
      }
    }
  }
}

Ba chi tiết ở đó là chịu lực và hầu hết lần thử đầu tiên bỏ qua cả ba. buffer tồn tại vì một network chunk không có quan hệ gì với một event: một read() có thể trả về nửa event, hoặc hai event rưỡi. Flag { stream: true } tồn tại vì một ký tự UTF-8 nhiều byte có thể bị tách qua hai chunk, và nếu không có nó một chữ có dấu sẽ ngẫu nhiên biến thành ký tự thay thế. Và các event được tách bằng một dòng trống, không phải một newline, nên vòng lặp tìm \n\n.

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

Nhanh hơn mười hai lần để thấy từ đầu tiên, và chậm hơn hai mili giây để tới từ cuối cùng. Streaming không làm gì nhanh hơn. Nó thay đổi việc người dùng đang làm trong cùng 790 ms: đọc thay vì chờ. Đó là toàn bộ lợi ích, nó rất lớn, và đó là lý do mọi sản phẩm chat đều stream.

Thứ ba, với hai mươi client cùng lúc. Mock provider phục vụ ba request một lúc. Bắn hai mươi request:

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

Hai mươi câu trả lời, bảy mươi tư request, năm mươi tư lần bị từ chối. Không ai mất gì, mọi client nhận cùng text, và chi phí nhìn thấy duy nhất là thời gian. Đó là một retry policy đang hoạt động. Phần còn lại của chương này nói về ba cách nó có thể hỏng thay vào đó.

finish_reason, và hai kết thúc trông giống nhau

Liên kết đến mục: finish_reason, và hai kết thúc trông giống nhau

Trước các lỗi, hãy nhìn vào field mà gần như ai cũng bỏ qua ở lượt đầu. Mọi stream kết thúc bằng một event mang finish_reason. stop nghĩa là model quyết định nó đã xong. length nghĩa là nó chạm trần token, nên câu trả lời bị cắt cụt giữa câu và đó không phải lỗi của model. Các chương sau thêm tool_calls (Chapter 18) và content filters.

Giờ xem hai kết thúc mà một client ngây thơ không thể phân biệt. Cùng server, cùng delay, một cái bị cắt bởi max_tokens và một cái nơi kết nối được đóng sạch sau năm token:

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"

Đọc kỹ hai hàng đầu. Text giống hệt. Số chunk giống hệt. Không có exception trong cả hai trường hợp. Vòng lặp for await kết thúc bình thường cả hai lần, vì từ góc nhìn của reader thì body đã hết và đó là tất cả những gì một body có thể làm. Khác biệt duy nhất trong toàn bộ quan sát là một cái mang finish_reason: "length" và cái kia không mang gì cả.

Vì vậy quy tắc không phải “catch errors khi streaming”. Mà là:

Một stream kết thúc mà không có finish_reason thì chưa kết thúc. Nó đã dừng.

Luôn coi thiếu finish_reason là thất bại, và đừng bao giờ persist text đó như một câu trả lời đã hoàn tất. Hàng thứ ba cho thấy trường hợp dễ hơn — một socket bị phá hủy có throw, và nó cũng làm mất chunk đang in flight, nên text ngắn hơn một từ so với hai hàng trên.

Thói quen tốn kém nhất của một sản phẩm mới là dùng một block catch cho mọi thứ provider trả về. Các code này không phải biến thể của “nó thất bại”. Chúng là năm chỉ thị, và bốn trong số đó mâu thuẫn nhau.

statusnó nghĩa là gìcần làm gìchờ?
400request của bạn sai định dạng — JSON lỗi, field không biết, context quá dàisửa codekhông bao giờ
401key sai, thiếu hoặc bị thu hồisửa deploymentkhông bao giờ
429rate limit: quá nhiều request, hoặc quá nhiều token, mỗi phútretryRetry-After, rồi backoff
500provider bị hỏngretrybackoff
503provider quá tải — nó đang chạy, nhưng đã đầyretrybackoff, và shed load

Đường quan trọng nằm giữa 4xx và phần còn lại. Một 400 hoặc 401 trả đúng cùng câu trả lời nếu bạn gửi nó một nghìn lần, vì không có gì ở hai đầu thay đổi giữa các lần thử. Retry nó không phải cẩn trọng, mà là một độ trễ với thêm vài bước. Đã đo: một client thực hiện sáu attempt — năm retry với exponential backoff — và một client đọc code trước.

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

Sáu giây spinner để tới một câu trả lời vốn đã sẵn sàng trong bốn mili giây. Và đó là phiên bản nhẹ: retry trong sản phẩm thường bị lồng nhau — một HTTP client có retry bên trong một job runner có retry bên trong một queue có cơ chế redelivery riêng — nên sáu giây biến thành sáu phút của một deployment hỏng vĩnh viễn nhưng trông như chỉ đang chậm.

Triage gồm chín dòng và nên nằm ở một chỗ:

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
}

Thêm hai mục cho danh sách của bạn: 402, một số provider dùng cho “bạn đã hết credit” và cần một màn hình có link mua thêm thay vì retry, và 529 hoặc các tương đương vendor-specific của nó, hoạt động như 503.

Backoff, và jitter thực sự mua cho bạn điều gì

Liên kết đến mục: Backoff, và jitter thực sự mua cho bạn điều gì

Retry thì dễ. Retry khi nào mới là phần có câu trả lời đúng đo được.

Exponential backoff là chuẩn: chờ một base delay, nhân đôi sau mỗi lần lỗi, dừng ở một trần. Nó tồn tại vì một server quá tải sẽ tệ hơn nếu các client vừa fail quay lại ngay.

Vấn đề là mọi người đều nhân đôi từ cùng một điểm bắt đầu. Nếu một trăm client cùng chạm limit ở cùng thời điểm — và chuyện đó sẽ xảy ra, vì đó là bản chất của một traffic spike — thì cả trăm cùng chờ 200 ms, cả trăm cùng retry, cả trăm cùng fail, và cả trăm cùng chờ 400 ms. Lịch retry đã đồng bộ hóa họ. Đó là một thundering herd, và randomness là cách sửa.2

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

Chỉ một thay đổi đó — chọn đều trong khoảng thay vì lấy cận trên của nó — được gọi là full jitter. Nó là một lời gọi tới Math.random(), và đáng để đo thay vì tin:

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);      

Một trăm client, một server phục vụ ba request một lúc, mọi thứ khác giống hệt, mỗi cấu hình chạy ba lần:

HTTP requestsrejectionsclient tệ nhấtcửa sổ 50 ms bận nhấtwall clock
không jitter, run 149139110 lần thử46 lượt đến65,6 s
không jitter, run 278068019 lần thử72 lượt đến245,7 s
không jitter, run 377067018 lần thử97 lượt đến225,6 s
full jitter, run 13242245 lần thử32 lượt đến2,2 s
full jitter, run 23132136 lần thử31 lượt đến2,3 s
full jitter, run 33182186 lần thử25 lượt đến1,8 s

Có hai điều trong bảng đó, và điều thứ hai mới quan trọng.

Điều thứ nhất là median: 226 giây so với 2,2, khoảng một trăm lần, với chưa tới một nửa số request. Cửa sổ retry bận nhất giải thích vì sao. Không có jitter, tới 97 trong số một trăm client đến trong cùng slot 50 mili giây; server có ba chỗ, nên 94 bị từ chối và cùng đi ngủ, vẫn đồng bộ, để làm lại lần nữa với thời gian chờ dài hơn. Với jitter, cùng một trăm client trải ra qua các cửa sổ đó thành các nhóm khoảng ba mươi và xả gần như ngay lập tức.

Điều thứ hai là variance. Không có jitter: 65,6 s, 245,7 s, 225,6 s. Có jitter: 2,2, 2,3, 1,8. Một hệ thống không jitter không chỉ hoạt động tệ, nó hoạt động khó đoán, vì kết quả được quyết định bởi các tai nạn scheduling vi mô chọn ra ba trong một trăm client đồng bộ đến trước. Đó là dấu hiệu của bug này trong production: một endpoint ổn, ổn, ổn, rồi đột nhiên mất bốn phút, và không có thay đổi nào của bạn giải thích được.

Và retry rẻ nhất là retry không bao giờ xảy ra. Đặt một concurrency gate trước provider — một counter không bao giờ cho phép hơn N request đang in flight — và cùng hai mươi client từng cần 74 request và 7,1 giây sẽ hành xử như sau:

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

Hai mươi request cho hai mươi câu trả lời, không có rejection, nhanh hơn tám lần. Retry là lời xin lỗi; gate là khỏi cần xin lỗi.

Khi một provider trả 429, nó thường nói bạn phải chờ bao lâu, trong header Retry-After.3 Con số đó không phải lời khuyên: provider là bên duy nhất trong trao đổi biết khi nào window của nó reset.

Vì vậy thời gian chờ là giá trị lớn hơn trong hai thứ: không bao giờ nhỏ hơn Retry-After, và cũng không bao giờ nhỏ hơn backoff của chính bạn, vì header cho bạn biết khi nào limiter tha cho bạn chứ không phải khi nào server có chỗ.

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));  

Trace của client kém may mắn nhất trong lượt chạy hai mươi client cho thấy header đang làm đúng việc. Bốn lần draw backoff đầu tiên của nó đều dưới một giây, và cả bốn đều bị override:

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

Hai ghi chú thực tế. Retry-After có thể là HTTP date thay vì số giây, nên hãy parse cả hai. Và provider rate-limit trên hai trục cùng lúc — requests per minute và tokens per minute — đó là lý do prompt dài bị từ chối thấp hơn rất nhiều so với request limit được tài liệu hóa. Header trông giống nhau trong cả hai trường hợp; cách sửa thì không.

Hỏi mock provider cho /hang. Nó chấp nhận kết nối, rồi không làm gì cả: không headers, không body, không close. Điều này không hề kỳ lạ — đó là điều load balancer làm khi process phía sau nó đã chết mà không đóng socket.

Hai client, một khác biệt:

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

Ba trăm giây. Năm phút giữ một socket mở, chiếm một request slot và để người dùng nhìn spinner, kết thúc bằng một TypeError chung chung không nói gì về chuyện đã xảy ra. Con số đó không phải bug: đó là headers timeout mặc định của Node, hợp lý cho một HTTP client tổng quát và thảm họa cho request đối diện người dùng. Mọi runtime đều có một mặc định như vậy, hầu hết mọi người không bao giờ tra cứu, và cách duy nhất để tìm ra mặc định của bạn là cố tình treo một socket như chúng ta vừa làm.

Vậy: mọi outgoing request đều có một deadline tường minh, do bạn chọn.

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),   
});

Với một streaming call, một deadline là không đủ, vì có hai lỗi khác nhau. Lỗi thứ nhất là stream không bao giờ mở: không event nào đến cả, và mười đến ba mươi giây là hợp lý. Lỗi thứ hai là stream mở rồi stall: token đã chảy rồi dừng, mãi mãi, trong khi socket vẫn khỏe. Timeout tổng thời lượng không thể phân biệt một stream bị stall với một câu trả lời đúng nhưng dài, nên thứ bạn muốn là một idle timeout — một timer được reset bởi mỗi event, chỉ fire khi không có gì đến trong, chẳng hạn, mười lăm giây.

Cancellation là cùng cơ chế đó nhưng hướng vào một con người. AbortSignal.timeout và người dùng bấm Stop đều đến dưới dạng một AbortError, nên hãy kết hợp chúng và ghi lại cái nào đã fire:

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

Abort quan trọng vì một lý do vượt ra ngoài sự gọn gàng: token vẫn đang được sinh và tính tiền trong lúc bạn không nghe. Chapter 16 đặt giá cho chuyện đó.

Giờ là lỗi làm tốn tiền thay vì thời gian. Một request timeout ở client, và động tác hiển nhiên là gửi lại — nhưng timeout không nói gì về việc server đã nhận nó hay chưa. Rất thường là đã nhận, và vẫn đang làm việc.

Đã đo. Mock provider cần 780 ms cho câu trả lời. Client bỏ cuộc ở 300 ms và retry. Server đếm số câu trả lời nó thật sự đã sinh, tức thứ nó sẽ tính tiền:

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

Không có key: hai lần generation đầy đủ, trả tiền hai lần, và client không nhận được cái nào. Có key: server nhận ra request thứ hai là cùng một request và trả lời tức thì bằng câu trả lời nó đã tạo, nên retry vừa tránh khoản phí kép vừa là attempt cuối cùng thành công.

Một idempotency key là một chuỗi unique bạn sinh cho mỗi logical operation — không phải mỗi attempt — và gửi nguyên vẹn trong mọi retry của operation đó. Server lưu outcome theo key và replay nó. Đó là cơ chế payment API dùng, vì cùng lý do.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");
}

Hai giới hạn trung thực. Không phải provider nào cũng hỗ trợ idempotency keys trên completions, và nơi endpoint không idempotent, số retry đúng cho một POST có thể đã chạy là zero. Và một stream fail giữa chừng nói chung không replay được: bạn hoặc restart nó và trả tiền lại, hoặc giữ partial text và đánh dấu incomplete. Sản phẩm của bạn chọn cách nào là quyết định sản phẩm, không phải quyết định networking, và đáng được đưa ra có chủ ý.

Client viết trong chương này không biết phía sau port là gì. Trỏ base URL của nó vào một provider thương mại và nó stream token từ một model nghìn tỷ parameters. Trỏ nó vào một server xây trên số học của Chapter 13 — phục vụ model bạn đã pretrained trong Chapter 10, với KV cache và quantized weights của nó — và cùng đoạn code đó, không đổi, stream token từ một model bạn đã xây.

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

Một dòng duy nhất đó là đường ráp của khóa học này. Một bên của nó là thứ mười ba chương đầu đã xây; bên kia là thứ mười sáu chương tiếp theo xây. Ranh giới sạch vì contract là HTTP và SSE, và không bên nào biết gì thêm về bên kia.

Đáng chú ý là bạn đã mất gì khi bước qua. Phía sau một commercial endpoint, bạn không kiểm soát weights, cũng không kiểm soát sampling implementation, cũng không kiểm soát version bạn đang nói chuyện, cũng không biết liệu nó có đổi sáng nay hay không. Thứ bạn kiểm soát là contract: messages bạn gửi, deadline bạn đặt, codes bạn phân biệt, và bạn làm gì khi không có gì quay lại. Đó là một bề mặt nhỏ hơn so với Chapter 5, và mọi chương còn lại là về cách dùng nó cho tốt.

Giờ bạn có một client stream, bỏ cuộc đúng lúc, retry đúng thứ và không bao giờ retry sai thứ. Thứ nó gửi vẫn chỉ là bất cứ gì bạn gõ.

Chapter 15 nói về nội dung đó, và đi kèm một kỷ luật. Internet đầy lời khuyên prompting — hứa tip cho model, đe dọa nó, bảo nó hít thở sâu — và hầu như không lời khuyên nào đi kèm phép đo. Một số kỹ thuật làm output dịch chuyển rất nhiều, một số không làm nó dịch chuyển chút nào, và ít nhất một kỹ thuật làm một classification task tệ hơn trong khi tốn nhiều token hơn. Cái nào là cái nào không hiển nhiên khi đọc chúng, và không thể giải quyết bằng tranh luận.

Vì vậy chương tiếp theo xây một bench: sáu mươi case có đáp án đã biết, bốn biến thể của cùng prompt, chạy song song qua đúng client bạn vừa viết, lập bảng với confidence intervals từ Chapter 4 — vì bốn biến thể trên hai mươi case chẳng phân biệt được gì. Một câu chi phối toàn chương: prompt được đo, không được tranh cãi.


Mọi con số ở trên đến từ mock provider, trên Node 22 qua loopback interface, nên latency sạch hơn bất kỳ network thật nào có thể cho bạn. Điều đó là cố ý: không failure nào được đo ở đây do network gây ra, và một hostile server bạn có thể restart dạy tốt hơn một server thật mà bạn phải trả tiền và không thể phá.

  1. Server-Sent Events, WHATWG HTML Living Standard, mục 9.2. Wire format — các field data:, event tách bằng dòng trống, id:retry: — được định nghĩa ở đó, cùng với interface EventSource. EventSource không thể gửi request body hoặc custom headers, đó là lý do mọi LLM client parse format thủ công trên fetch thay vì dùng nó.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). Nguồn của công thức “full jitter” dùng ở trên, với các mô phỏng cho thấy vì sao phiên bản ngây thơ đồng bộ hóa client. Lập luận đi kèm cho việc shed load thay vì queue nó là chương Handling Overload của Beyer, Jones, Petoff và Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016).

  3. Fielding, R., Nottingham, M. và Reschke, J. (eds.), HTTP Semantics, RFC 9110, mục 15, định nghĩa các lớp status code; Nottingham, M. và Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), mục 4, định nghĩa 429 Too Many Requests. Retry-After là RFC 9110 mục 10.2.3, và chấp nhận hoặc số giây hoặc một HTTP date.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, đọc ngày 7 tháng 9 năm 2026 — phát biểu rõ nhất về contract: một key cho mỗi logical operation, kết quả đã lưu được replay, trả về conflict khi attempt đầu tiên vẫn đang in flight — và pattern này độc lập với provider. Các tham chiếu chuẩn tắc cho request và event shapes dùng ở đây là developers.openai.com/api/reference/resources/chat cho streaming, error codes và rate limits, và platform.claude.com/docs/en/api/messages cho Messages API; ai-sdk.dev/docs là ví dụ được triển khai kỹ nhất về cùng các mối quan tâm đó được bọc trong một library. Tất cả được đọc cùng ngày.

Sẵn sàng để LIA chọn giúp bạn chưa?

Xây dựng cùng mọi mô hình AI ở một nơi — bắt đầu miễn phí ngay hôm nay.