ข้ามไปยังเนื้อหา
14/30บทที่ 14 จาก 30

LLM call แรกใน Production: Streaming, Retries, Timeouts

สร้าง provider ที่หลอกคุณได้—429, socket ค้าง, stream ขาดครึ่ง—แล้ววัด client ของคุณ Full jitter: 2.2 วินาที เทียบกับ 226

ในหน้านี้

บทที่ 13 จบลงด้วยนาฬิกาจับเวลาบนโมเดลที่คุณสัมผัสได้ weights อยู่ในหน่วยความจำของคุณ KV cache เป็นของคุณที่จะเปิดหรือปิด และตัวเลขที่ออกมา — time to first token — เป็นคุณสมบัติของฮาร์ดแวร์ของคุณ

ตอนนี้เอาโมเดลนั้นไปไว้หลัง port ซึ่งเป็นสิ่งที่ทุก product ทำ แล้วอ่านตัวเลขเดิมอีกครั้ง มันยังคงเป็น time to first token แต่ไม่ใช่คุณสมบัติของสิ่งใดที่คุณควบคุมได้อีกต่อไป ตอนนี้มันรวม TLS handshake, คิวที่ provider, rate limiter และความเป็นไปได้ที่อาจไม่มี token มาถึงเลย

วลีสุดท้ายนั่นคือทั้งบทนี้ code ที่คุณกำลังจะเขียนไม่ได้คำนวณอะไรเลย มันเปิด connection, รอ, parse สิ่งที่มาถึง, ตัดสินใจว่าจะทำอย่างไรเมื่อไม่มีอะไรมา, ตัดสินใจอีกครั้งเมื่อสิ่งที่มาถึงเป็น error และยกเลิกตัวเองเมื่อผู้ใช้เปลี่ยนใจ แต่ละอย่างคือการตัดสินใจเกี่ยวกับ state over time และแต่ละอย่างมีคำตอบผิดที่ถูกปล่อยขึ้น production แล้วทำให้เสียเงิน

นี่คือรูปทรงของปัญหา วัดแล้ว ทั้งหมดอยู่ในบทนี้:

เกิดอะไรขึ้นclient ที่ประมาททำอะไรต้นทุนคืออะไร
server รับ socket แล้วไม่ตอบกลับเลยรอ300.8 s ก่อนที่ Node จะยอมแพ้เอง
key ผิด (401)retry ห้าครั้งdelay 6,325 ms แล้วได้ 401 เดิม
client หนึ่งร้อยตัวชน rate limit พร้อมกันทั้งหมด retry ตามตารางเวลาเดียวกัน226 s กว่าจะระบายหมด เทียบกับ 2.2 s
request timeout แล้วถูกส่งซ้ำส่งซ้ำprovider generate — และคิดเงิน — คำตอบ สองครั้ง
connection หลุดกลางคำตอบแสดงข้อความบางส่วนแยกไม่ออกจากคำตอบสั้นที่ถูกต้อง

ไม่มีข้อใดเป็นปัญหาเรื่อง modelling ทั้งหมดอยู่ในร้อยบรรทัดแรกของ LLM product ทุกตัวที่เคยเขียนมา

อ่านตารางนั้นอีกครั้ง แล้วถามตัวเองว่ามันอธิบายโปรแกรมแบบไหน มันถือ connection เปิดไว้สี่สิบวินาที ต้องยกเลิกได้จากปุ่ม มันสะสมคำตอบบางส่วนที่แสดงได้ แต่บันทึกไม่ได้ และมันรันอยู่ใน server process หรือ edge worker ข้าง ๆ สิ่งที่ render คำตอบ พร้อมถือ socket ไว้

นั่นไม่ใช่ notebook ไม่ใช่ว่า Python ทำไม่ได้ — ทำได้ และผู้คนก็ทำ — แต่ทุกอย่างที่สิบสามบทก่อนหน้าสร้างขึ้นเป็นคนละชนิดกัน บทที่ 1 ถึง 13 ถือ weights, gradients, logits และ tokenizer bytes จากนี้ไป code ถือ connection, retry, cancellation, accumulated state และต่อไปจะถือ permission prompt course เปลี่ยนภาษาตรง seam ที่ object เปลี่ยนพอดี

ดังนั้นกฎ เขียนไว้ครั้งเดียว:

ถ้า code มี weights, gradients, logits หรือ tokenizer bytes อยู่ในมือ มันคือ Python ถ้ามันถือ connection, retries, cancels, accumulates state และขอ permission มันคือ TypeScript

seam นี้มีจุดเดียว และอยู่ตรงนี้ ระหว่างบทที่ 13 กับบทที่ 14 เกณฑ์อิสระสามข้อวางมันไว้ตรงนี้

หนึ่ง: ecosystem เมื่อนับจริง ทุกอย่างที่ครึ่งซ้ายของ course นี้อ้างถึงคือ Python และในสิบสอง course ที่ audit เพื่อ syllabus นี้ ไม่มี precedent แม้แต่หนึ่งเดียวที่สอน backpropagation ด้วยภาษาอื่น: micrograd (17.4K stars), nanoGPT (62.8K), nanochat (57.8K), minbpe (10.7K), PyTorch (102.8K), transformers (164.9K) การเขียน บทที่ 5 ด้วย TypeScript จะตัดความเชื่อมโยงกับแหล่งเหล่านั้น และลิงก์เหล่านั้นคือครึ่งหนึ่งของคุณค่าของบทที่มีไว้ให้ถูกอ้างอิงมากกว่าจะ rank ฝั่งนี้เลขคณิตกลับด้าน: package ai ของ Vercel อยู่ที่ 89.4M downloads ต่อเดือน และ ship สิ่งนั้นเอง — tool-calling agent loop ที่ export เป็น ToolLoopAgent — ดังนั้น concept ที่ course นี้ไปถึงใน บทที่ 23 จึงมี reference implementation ใน TypeScript แม้ว่าอย่างที่บทนั้นวัดไว้ จะยังไม่มีใครตกลงชื่อเรียกมันได้; Mastra อยู่ที่ 27.7K stars; และ SDKs ของ Anthropic ที่ generate จาก specification เดียวกัน ประกาศ 202 endpoints ใน TypeScript เทียบกับ 201 ใน Python — parity ไม่ใช่ port ตามมารยาท

สอง: แหล่ง normative ของ MCP schema ของ Model Context Protocol specification เป็นไฟล์ schema.ts การสอน protocol ของ บทที่ 26 ด้วยภาษาอื่นหมายถึงการสอนคำแปลของเอกสารก่อตั้งมัน

สาม: search demand พร้อมการแก้เดาที่ดูชัดเจน machine learning python คือวลีที่อิ่มตัวที่สุดบนอินเทอร์เน็ต; ai agent typescript ก็มี tail ที่แข็งแรงของตัวเอง แต่ “MCP ecosystem ส่วนใหญ่เป็น TypeScript” จะจริงหรือไม่ขึ้นกับวิธีนับ: registry ทางการแสดง server 8,275 ตัวบน npm เทียบกับ 3,603 บน PyPI ขณะที่เมื่อดู downloads Python ชนะ — 287M ต่อเดือนสำหรับ mcp บวก 72M สำหรับ fastmcp เทียบกับ 195M สำหรับ @modelcontextprotocol/sdk MCP คือพื้นที่ bilingual อย่างแท้จริงหนึ่งเดียวตรงนี้ ซึ่งเป็นเหตุผลที่ บทที่ 27 เขียน server เดียวกันสองครั้งแทนที่จะเสแสร้ง

แสดงรายละเอียด

ข้อยกเว้นที่ประกาศไว้ห้าข้อ เพื่อให้กฎเป็นกฎ ไม่ใช่ slogan

บทที่ 17, 20 และ 29 มี panel ที่สองใน Python: การ implement top-p sampling ต้องมี probability vector อยู่ในมือ และ HTTP API ไม่เคยให้สิ่งนั้นกับคุณ; การตั้งราคา fine-tune อย่างซื่อสัตย์หมายถึงต้อง run มันจริง และ LoRA adapter คือ nn.Module แค่สิบกว่าบรรทัด; และ lm-eval-harness, HELM, SWE-bench และ τ-bench เป็น Python ดังนั้น evaluation harness ใน TypeScript จะเป็นภาพสะท้อนของความผิดพลาดแบบ backpropagation บทที่ 27 เป็น bilingual ด้วยเหตุผลที่วัดไว้ข้างต้น บทที่ 28 เป็น Markdown เพราะ agent skill คือ ไฟล์ SKILL.md และการกำหนด programming language ให้มันจะหมายความว่ายังไม่เข้าใจ format

สิบสามบท Python ไม่ได้ถูกทิ้ง สิ่งที่อยู่หลัง port คือสิ่งที่มันสร้าง และ section สุดท้ายตรงนี้จะเชื่อม client เข้ากับมัน

คุณเรียนรู้เรื่องนี้กับ provider จริงไม่ได้ คุณขอ 429 ณ ช่วงเวลาที่เลือกเองไม่ได้ ขอ socket ที่รับ connection แล้วไม่ตอบไม่ได้ หรือขอ stream ที่หยุดกลางคำไม่ได้ — และคุณจะต้องจ่ายเงินสำหรับทุกการทดลอง ทั้งที่การทดลองที่น่าสนใจคือการทดลองที่ต้อง run เป็นร้อยครั้ง

ดังนั้นโปรแกรมแรกในครึ่งนี้ของ course จึงไม่ใช่ client แต่เป็น hostile server: Node ธรรมดาสี่สิบบรรทัดที่พูด wire protocol เดียวกับ chat completions endpoint และทำตัวผิดปกติได้ตามสั่ง ทุกตัวเลขในบทนี้ออกมาจากมัน

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

พฤติกรรม hostile สี่แบบ แบบละหนึ่งบรรทัด: /hang รับ socket แล้วไม่เขียนอะไรลงไปเลย; /401 ปฏิเสธ key; capacity check สร้าง 429 จริงพร้อม header Retry-After จริงเมื่อมี request อยู่ระหว่างทำงานสามรายการแล้ว; และ ?cut=N ทิ้งคำตอบกลางทาง ไม่ว่าจะ reset socket หรือ — ด้วย &how=close — ปิดแบบเป็นระเบียบ ซึ่งปรากฏว่าสำคัญมาก ส่วนที่เหลือคือ Server-Sent Events stream จริง: JSON object หนึ่งตัวต่อบรรทัด data:, blank line คั่นระหว่าง events, string [DONE] ตอนจบ1

Run มัน แล้วส่วนที่เหลือของบทคือการวัด

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]

chat request คือรายการ messages แต่ละรายการมี role รายการนั้นคือ state ทั้งหมด ของโมเดล: ไม่มี memory ระหว่าง calls และอะไรก็ตามที่คุณอยากให้โมเดลรู้ ต้องอยู่ใน array ที่คุณส่งครั้งนี้ บทที่ 15 ว่าด้วยสิ่งที่จะใส่ลงไป และ บทที่ 16 ว่าด้วยต้นทุนของมัน ดังนั้นตรงนี้มีแค่ shape

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

role เหล่านั้นไม่ใช่ decoration มันถูก render เข้า chat template ของ บทที่ 11 ก่อนที่โมเดลจะเห็น token แรก ซึ่งเป็นเหตุผลว่าทำไมการส่ง role ผิดจึงทำให้คำตอบแย่ลงเงียบ ๆ แทนที่จะ raise error

กฎหนึ่งข้อไม่มีข้อยกเว้น: API key ไม่เคยเดินทางไปยัง client ไม่อยู่ใน environment variable ที่ prefix สำหรับ browser ไม่อยู่ใน build-time constant ไม่ใช่ “ชั่วคราว” key ใน bundle คือ key บนบิลของคนอื่นภายในไม่กี่วัน browser คุยกับ server ของคุณ server ของคุณถือ key และคุยกับ provider — และเพราะ server ของคุณอยู่ตรงกลาง มันจึงเป็นที่เดียวที่ meter ได้ว่าผู้ใช้แต่ละคนใช้จ่ายเท่าไร ซึ่งเป็นที่ที่ accounting ของบทที่ 16 ต้องอยู่

ตอนนี้คือการทดลองที่บทนี้สร้างอยู่บนมัน คำถามเดียว mock provider หนึ่งตัวที่สร้าง token สิบสามตัว ตัวละ 60 ms และวิธีถามสามแบบ

แรก แบบไม่ streaming client ส่ง request แล้วรอ JSON body ทั้งก้อน

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

ตัวเลขสองตัวเท่ากัน และนั่นคือปัญหาทั้งหมด เป็นเวลา 791 ms ผู้ใช้มี spinner และไม่มีแม้แต่คำเดียวที่พร้อมก่อนหน้านั้น — server มีคำตอบแล้ว byte ต่อ byte แต่เลือกจะไม่พูดอะไร

สอง แบบ streaming server เดิม คำตอบเดิม งานรวมเท่าเดิม ความต่างคือ 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);
      }
    }
  }
}

รายละเอียดสามอย่างตรงนั้นเป็น load-bearing และความพยายามครั้งแรกส่วนใหญ่ข้ามทั้งสามอย่าง buffer มีอยู่เพราะ network chunk ไม่มีความสัมพันธ์กับ event: read() หนึ่งครั้งอาจคืน event ครึ่งอัน หรือสองอันครึ่งก็ได้ flag { stream: true } มีอยู่เพราะอักขระ UTF-8 แบบหลาย byte อาจถูกตัดข้ามสอง chunks และถ้าไม่มีมัน ตัวอักษรมี accent จะกลายเป็น replacement character แบบสุ่ม และ events ถูกคั่นด้วย blank line ไม่ใช่ newline ซึ่งเป็นเหตุผลที่ loop มองหา \n\n

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

เร็วขึ้นสิบสองเท่าถึงคำแรก และ ช้าลงสอง milliseconds ถึงคำสุดท้าย Streaming ไม่ได้ทำให้อะไรเร็วขึ้น มันเปลี่ยนสิ่งที่ผู้ใช้ทำในช่วง 790 ms เดียวกัน: อ่าน แทนที่จะรอ นั่นคือประโยชน์ทั้งหมด มันมหาศาล และเป็นเหตุผลที่ chat product ทุกตัว stream

สาม กับ client ยี่สิบตัวพร้อมกัน mock provider ให้บริการ 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

คำตอบยี่สิบรายการ request เจ็ดสิบสี่รายการ rejection ห้าสิบสี่ครั้ง ไม่มีใครเสียอะไร client ทุกตัวได้ข้อความเดียวกัน และต้นทุนที่มองเห็นได้มีแค่เวลา นั่นคือ retry policy ที่ทำงาน ส่วนที่เหลือของบทนี้ว่าด้วยสามวิธีที่มันอาจ fail แทน

finish_reason และตอนจบสองแบบที่หน้าตาเหมือนกัน

ลิงก์ไปยังส่วน: finish_reason และตอนจบสองแบบที่หน้าตาเหมือนกัน

ก่อน failure มาดู field ที่แทบทุกคนมองข้ามในรอบแรก stream ทุกอันจบด้วย event ที่ถือ finish_reason stop หมายถึงโมเดลตัดสินใจว่าเสร็จแล้ว length หมายถึงมันชนเพดาน token ดังนั้นคำตอบถูกตัดกลางประโยคและไม่ใช่ความผิดของโมเดล บทต่อ ๆ ไปเพิ่ม tool_calls (บทที่ 18) และ content filters

ตอนนี้ดูตอนจบสองแบบที่ client แบบ naive แยกไม่ออก server เดิม delay เดิม อันหนึ่งถูก truncate โดย max_tokens และอีกอันที่ connection ถูกปิดอย่างเรียบร้อยหลังห้า 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"

อ่านสองแถวแรกให้ดี ข้อความเหมือนกัน chunk count เหมือนกัน ไม่มี exception ทั้งสองกรณี loop for await จบตามปกติทั้งสองครั้ง เพราะจากมุมมองของ reader body จบลง และนั่นคือทั้งหมดที่ body ทำได้ ความต่างเดียวในการสังเกตทั้งหมดคืออันหนึ่งมี finish_reason: "length" และอีกอันไม่มีอะไรเลย

ดังนั้นกฎไม่ใช่ “catch errors ขณะ streaming” แต่คือ:

Stream ที่จบโดยไม่มี finish_reason ไม่ได้จบ มันหยุด

ถือว่า finish_reason ที่หายไปเป็น failure เสมอ และอย่า persist ข้อความนั้นเป็นคำตอบที่เสร็จสมบูรณ์ แถวที่สามแสดงกรณีที่ง่ายกว่า — socket ที่ถูกทำลาย throw จริง และมันยังทำ chunk ที่กำลังเดินทางหายไปด้วย ซึ่งเป็นเหตุผลที่ข้อความสั้นกว่าสองแถวข้างบนหนึ่งคำ

Status code ห้าตัวที่เป็นปัญหาห้าคนละแบบ

ลิงก์ไปยังส่วน: Status code ห้าตัวที่เป็นปัญหาห้าคนละแบบ

นิสัยที่แพงที่สุดของ product ใหม่คือมี block catch เดียวสำหรับทุกอย่างที่ provider คืนมา code เหล่านี้ไม่ใช่ variation ของ “มัน fail” มันคือ instruction ห้าแบบ และสี่แบบในนั้นขัดแย้งกันเอง

statusหมายความว่าอะไรควรทำอะไรรอไหม
400request ของคุณ malformed — JSON แย่, field ไม่รู้จัก, context ยาวเกินแก้ codeไม่ต้องเลย
401key ผิด หายไป หรือถูก revokeแก้ deploymentไม่ต้องเลย
429rate limit: request มากเกินไป หรือ token มากเกินไป ต่อหนึ่งนาทีretryRetry-After แล้วค่อย backoff
500provider พังretrybackoff
503provider overloaded — มัน up อยู่ แต่มันเต็มretrybackoff และ shed load

เส้นที่สำคัญอยู่ระหว่าง 4xx กับที่เหลือ 400 หรือ 401 จะคืนคำตอบเดิมทุกครั้งถ้าคุณส่งมันพันครั้ง เพราะไม่มีอะไรที่ปลายทางใดเปลี่ยนระหว่าง attempts การ retry มันไม่ใช่ความระมัดระวัง แต่เป็น delay ที่มีขั้นตอนเพิ่ม วัดแล้ว: client หนึ่งตัวที่พยายามหกครั้ง — retry ห้าครั้งด้วย exponential backoff — และอีกตัวที่อ่าน code ก่อน

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

spinner หกวินาทีเพื่อไปถึงคำตอบที่พร้อมตั้งแต่สี่ milliseconds และนี่คือเวอร์ชันเบา: retries ใน product มักซ้อนกัน — HTTP client ที่ retry อยู่ใน job runner ที่ retry อยู่ใน queue ที่มี redelivery ของตัวเอง — ดังนั้นหกวินาทีจึงกลายเป็นหกนาทีของ deployment ที่พังถาวรแต่ดูเหมือนช้า

triage มีเก้าบรรทัดและควรอยู่ที่เดียว:

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
}

เพิ่มในรายการอีกสองตัว: 402 ซึ่ง provider บางรายใช้สำหรับ “เครดิตหมด” และต้องการหน้าจอพร้อมลิงก์ให้ซื้อเพิ่ม ไม่ใช่ retry และ 529 หรือ equivalent เฉพาะ vendor ซึ่ง behave เหมือน 503

Retry ง่าย ส่วน retry เมื่อไร คือส่วนที่มีคำตอบถูกที่วัดได้

Exponential backoff คือมาตรฐาน: รอ base delay, เพิ่มเป็นสองเท่าหลัง fail แต่ละครั้ง, หยุดที่ ceiling มันมีอยู่เพราะ server ที่ overloaded จะแย่ลงถ้า client ที่เพิ่ง fail กลับมาทันที

ปัญหาคือทุกคนเพิ่มเป็นสองเท่าจากจุดเริ่มเดียวกัน ถ้า client หนึ่งร้อยตัวชน limit ในจังหวะเดียวกัน — และมันจะเกิด เพราะนั่นคือ traffic spike — ทั้งร้อยรอ 200 ms, ทั้งร้อย retry พร้อมกัน, ทั้งร้อย fail พร้อมกัน, แล้วทั้งร้อยรอ 400 ms ตาราง retry ทำให้มัน synchronized นั่นคือ thundering herd และ randomness คือวิธีแก้2

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

การเปลี่ยนแปลงเดียวนี้ — เลือกแบบ uniform จาก interval แทนการเอาปลายบน — เรียกว่า full jitter มันคือการเรียก Math.random() หนึ่งครั้ง และควรวัดแทนที่จะเชื่อ:

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

client หนึ่งร้อยตัว server หนึ่งตัวที่ให้บริการได้ครั้งละสามรายการ อย่างอื่นเหมือนกัน run อย่างละสามครั้ง:

HTTP requestsrejectionsclient ที่แย่ที่สุดwindow 50 ms ที่แน่นที่สุดwall clock
no jitter, run 149139110 tries46 arrivals65.6 s
no jitter, run 278068019 tries72 arrivals245.7 s
no jitter, run 377067018 tries97 arrivals225.6 s
full jitter, run 13242245 tries32 arrivals2.2 s
full jitter, run 23132136 tries31 arrivals2.3 s
full jitter, run 33182186 tries25 arrivals1.8 s

มีสองสิ่งในตารางนั้น และสิ่งที่สองสำคัญกว่า

สิ่งแรกคือ median: 226 วินาที เทียบกับ 2.2 ต่างกันราวร้อยเท่า ด้วย request น้อยกว่าครึ่ง window retry ที่แน่นที่สุดบอกเหตุผล ถ้าไม่มี jitter client สูงสุด 97 จากหนึ่งร้อยตัวมาถึงภายใน slot 50 milliseconds เดียวกัน server มีที่สามรายการ ดังนั้น 94 รายการถูกปฏิเสธและเข้านอนพร้อมกัน ยัง synchronized อยู่ เพื่อทำซ้ำอีกครั้งด้วยเวลารอนานขึ้น เมื่อมี jitter หนึ่งร้อยตัวเดิมกระจายตัวใน window เดียวกันเป็นกลุ่มราวสามสิบ และระบายหมดแทบจะทันที

สิ่งที่สองคือ variance ไม่มี jitter: 65.6 s, 245.7 s, 225.6 s มี jitter: 2.2, 2.3, 1.8 ระบบที่ไม่มี jitter ไม่ได้แค่ perform แย่ แต่มัน perform แบบคาดเดาไม่ได้ เพราะผลลัพธ์ถูกตัดสินโดยอุบัติเหตุ scheduling ระดับ microscopic ที่เลือกว่าสามตัวไหนจาก client หนึ่งร้อยตัวที่ synchronized จะมาถึงก่อน นี่คือลายเซ็นของ bug นี้ใน production: endpoint ที่ปกติ ปกติ ปกติ แล้วจู่ ๆ ใช้เวลาสี่นาที โดยไม่มีการเปลี่ยนแปลงของคุณที่อธิบายได้

และ retry ที่ถูกที่สุดคือ retry ที่ไม่เกิด ใส่ concurrency gate ไว้หน้า provider — counter ที่ไม่ยอมให้มี request in flight มากกว่า N — แล้ว client ยี่สิบตัวเดิมที่เคยต้องใช้ 74 request และ 7.1 วินาที จะ behave แบบนี้:

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

ยี่สิบ request สำหรับยี่สิบคำตอบ rejection ศูนย์ เร็วขึ้นแปดเท่า retry คือคำขอโทษ gate คือการไม่ต้องขอโทษ

เมื่อ provider คืน 429 มักจะบอกคุณว่าต้องรอนานแค่ไหนใน header Retry-After3 ตัวเลขนั้นไม่ใช่คำแนะนำ: provider คือฝ่ายเดียวในการแลกเปลี่ยนที่รู้ว่า window ของมัน reset เมื่อไร

ดังนั้นเวลารอคือค่าที่ มากกว่า ของสองค่า: ไม่ต่ำกว่า Retry-After และไม่ต่ำกว่า backoff ของคุณเองด้วย เพราะ header บอกคุณว่า limiter ให้อภัยเมื่อไร ไม่ใช่ว่า server มีที่ว่างเมื่อไร

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 ของ client ที่โชคร้ายที่สุดใน run ยี่สิบ client แสดงให้เห็นว่า header ทำหน้าที่ของมัน backoff draws สี่ครั้งแรกของมันต่ำกว่าหนึ่งวินาทีทั้งหมด และทั้งสี่ถูก 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

หมายเหตุเชิงปฏิบัติสองข้อ Retry-After อาจเป็น HTTP date แทนจำนวนวินาที ดังนั้น parse ทั้งสองแบบ และ provider rate-limit บนแกน สอง แกนพร้อมกัน — requests per minute และ tokens per minute — ซึ่งเป็นเหตุผลที่ prompt ยาวถูก reject ต่ำกว่า request limit ที่ document ไว้มาก header หน้าตาเหมือนกันทั้งสองกรณี แต่วิธีแก้ไม่เหมือนกัน

ขอ mock provider สำหรับ /hang มันรับ connection แล้วไม่ทำอะไรเลย: ไม่มี headers, ไม่มี body, ไม่ close นี่ไม่ใช่เรื่องแปลก — มันคือสิ่งที่ load balancer ทำเมื่อ process ข้างหลังมันตายโดยไม่ปิด sockets

client สองตัว ต่างกันหนึ่งอย่าง:

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

สามร้อยวินาที ห้านาทีของ socket ที่ถูกถือเปิดไว้ request slot ถูกกิน และผู้ใช้จ้อง spinner จบลงด้วย TypeError ทั่วไปที่ไม่บอกอะไรเกี่ยวกับสิ่งที่เกิดขึ้น ตัวเลขนั้นไม่ใช่ bug: มันคือ headers timeout เริ่มต้นของ Node ซึ่งสมเหตุสมผลสำหรับ HTTP client ทั่วไป และหายนะสำหรับ request ที่ผู้ใช้กำลังรอ runtime ทุกตัวมี default แบบนี้ คนส่วนใหญ่ไม่เคยไปดู และวิธีเดียวที่จะหา default ของคุณคือ hang socket โดยตั้งใจแบบที่เราเพิ่งทำ

ดังนั้น: outgoing request ทุกอันต้องมี deadline ชัดเจน ที่คุณเลือกเอง

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

สำหรับ streaming call deadline เดียวไม่พอ เพราะมี failure สองแบบที่ต่างกัน แบบแรกคือ stream ไม่เคยเปิด: ไม่มี event มาถึงเลย และสิบถึงสามสิบวินาทีกำลังเหมาะ แบบที่สองคือ stream เปิดแล้ว stall: token ไหลแล้วหยุดไปตลอดกาล โดย socket ยัง healthy timeout แบบ total-duration แยก stream ที่ stall ออกจากคำตอบยาวที่ถูกต้องไม่ได้ ดังนั้นสิ่งที่คุณต้องการคือ idle timeout — timer ที่ reset โดยทุก event และ fire เฉพาะเมื่อไม่มีอะไรมา เช่น สิบห้าวินาที

Cancellation คือเครื่องจักรเดียวกันที่ชี้ไปที่คน AbortSignal.timeout และผู้ใช้กด Stop ต่างก็มาถึงเป็น AbortError ดังนั้นรวมมันเข้าด้วยกันและบันทึกว่าอันไหน fire:

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

การ abort สำคัญด้วยเหตุผลที่เกินกว่าความเรียบร้อย: token กำลังถูก generate และคิดเงิน ขณะที่คุณไม่ได้ฟัง บทที่ 16 ใส่ราคาให้เรื่องนั้น

ตอนนี้คือ failure ที่ทำให้เสียเงินแทนเวลา request timeout ที่ client และการเคลื่อนไหวที่ดูชัดเจนคือส่งซ้ำ — แต่ timeout ไม่ได้บอกอะไรคุณเลยว่า server ได้รับมันหรือไม่ บ่อยครั้งมันได้รับแล้ว และยังทำงานอยู่

วัดแล้ว mock provider ต้องใช้ 780 ms สำหรับคำตอบ client ยอมแพ้ที่ 300 ms แล้ว retry server นับว่ามัน generate คำตอบจริงกี่ครั้ง ซึ่งคือสิ่งที่จะถูกคิดเงิน:

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: generations เต็มสองครั้ง จ่ายสองครั้ง และ client ไม่ได้รับ สักครั้ง มี key: server จำ request ที่สองได้ว่าเป็น request เดียวกันและตอบทันทีด้วยคำตอบที่มันผลิตไปแล้ว ดังนั้น retry ทั้งหลีกเลี่ยงการคิดเงินซ้ำและเป็น attempt ที่สำเร็จในที่สุด

Idempotency key คือ string unique ที่คุณ generate ต่อ logical operation — ไม่ใช่ต่อ attempt — และส่งเหมือนเดิมทุกครั้งที่ retry operation นั้น server เก็บ outcome ไว้กับ key แล้ว replay มัน นี่คือกลไกที่ payment APIs ใช้ ด้วยเหตุผลเดียวกัน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");
}

ข้อจำกัดที่ซื่อสัตย์สองข้อ ไม่ใช่ provider ทุกเจ้ารองรับ idempotency keys บน completions และเมื่อ endpoint ไม่ idempotent จำนวน retry ที่ถูกต้องสำหรับ POST ที่อาจ run ไปแล้วคือศูนย์ และ stream ที่ fail ครึ่งทางโดยทั่วไป replay ไม่ได้: คุณต้อง restart มันแล้วจ่ายอีกครั้ง หรือเก็บ partial text ไว้และ mark ว่า incomplete product ของคุณจะทำแบบไหนเป็นการตัดสินใจของ product ไม่ใช่เรื่อง networking และควรตั้งใจตัดสินใจ

client ที่เขียนในบทนี้ไม่รู้เลยว่าอะไรอยู่หลัง port ชี้ base URL ของมันไปที่ commercial provider แล้วมัน stream tokens จากโมเดลที่มีพารามิเตอร์หนึ่งล้านล้านตัว ชี้ไปที่ server ที่สร้างบนเลขคณิตของบทที่ 13 — serve โมเดลที่คุณ pretrain ใน บทที่ 10 พร้อม KV cache และ quantized weights — แล้ว code เดิม ไม่ต้องเปลี่ยน ก็ stream tokens จากโมเดลที่คุณสร้าง

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

บรรทัดเดียวนั้นคือ seam ของ course นี้ ด้านหนึ่งของมันคือสิ่งที่สิบสามบทแรกสร้าง อีกด้านคือสิ่งที่สิบหกบทถัดไปสร้าง boundary สะอาดเพราะ contract คือ HTTP และ SSE และทั้งสองฝั่งไม่รู้อะไรอย่างอื่นเกี่ยวกับกันและกัน

ควรสังเกตว่าคุณเสียอะไรไปเมื่อข้ามมา หลัง commercial endpoint คุณไม่ได้ควบคุมทั้ง weights, sampling implementation, version ที่คุณกำลังคุยด้วย หรือว่ามันเปลี่ยนไปเมื่อเช้านี้หรือไม่ สิ่งที่คุณควบคุมคือ contract: messages ที่คุณส่ง, deadline ที่คุณตั้ง, codes ที่คุณแยกแยะ และสิ่งที่คุณทำเมื่อไม่มีอะไรกลับมา นั่นคือ surface ที่เล็กกว่าที่คุณมีในบทที่ 5 และทุกบทที่เหลือว่าด้วยการใช้มันให้ดี

ตอนนี้คุณมี client ที่ stream, ยอมแพ้ตรงเวลา, retry สิ่งที่ถูกต้อง และไม่เคย retry สิ่งที่ผิด สิ่งที่มันส่งยังเป็นอะไรก็ตามที่คุณพิมพ์

บทที่ 15 ว่าด้วย content นั้น และมาพร้อม discipline อินเทอร์เน็ตเต็มไปด้วยคำแนะนำเรื่อง prompting — เสนอ tip ให้โมเดล, ข่มขู่มัน, บอกให้มันหายใจลึก ๆ — และแทบไม่มีข้อใดมาพร้อมการวัด บางเทคนิคขยับ output มาก บางเทคนิคไม่ขยับเลย และอย่างน้อยหนึ่งเทคนิคทำให้ classification task แย่ลง พร้อมใช้ token มากขึ้น ข้อไหนเป็นข้อไหนไม่ชัดจากการอ่าน และไม่ได้ตัดสินด้วยการเถียง

ดังนั้นบทถัดไปสร้าง bench: หกสิบ case ที่มีคำตอบรู้แล้ว, prompt เดียวกันสี่ variants, run ขนานผ่าน client ที่คุณเพิ่งเขียน, tabulate พร้อม confidence intervals จาก บทที่ 4 — เพราะสี่ variants บนยี่สิบ case แยกอะไรไม่ได้เลย ประโยคเดียวกำกับทั้งบท: prompt ต้องถูกวัด ไม่ใช่ถกเถียง


ตัวเลขทั้งหมดข้างต้นมาจาก mock provider บน Node 22 ผ่าน loopback interface ดังนั้น latencies จึงสะอาดกว่า network จริงใด ๆ นั่นเป็นความตั้งใจ: ไม่มี failure ที่วัดอยู่นี้เกิดจาก network และ hostile server ที่คุณ restart ได้ สอนได้ดีกว่า server จริงที่คุณต้องจ่ายเงินและทำให้พังไม่ได้

  1. Server-Sent Events, WHATWG HTML Living Standard, section 9.2 wire format — fields data:, events ที่คั่นด้วย blank line, id: และ retry: — ถูก define ไว้ที่นั่น พร้อม interface EventSource EventSource ส่ง request body หรือ custom headers ไม่ได้ ซึ่งเป็นเหตุผลที่ LLM client ทุกตัว parse format นี้เองด้วยมือบน fetch แทนที่จะใช้มัน

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). แหล่งที่มาของ formulation “full jitter” ที่ใช้ข้างต้น พร้อม simulations ที่แสดงว่าทำไมเวอร์ชัน naive ทำให้ clients synchronized argument คู่กันเรื่อง shedding load แทน queueing คือบท Handling Overload ของ Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O’Reilly, 2016)

  3. Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, section 15, define status code classes; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4, define 429 Too Many Requests Retry-After คือ RFC 9110 section 10.2.3 และรับได้ทั้งจำนวนวินาทีหรือ HTTP date

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, อ่านเมื่อ 7 กันยายน 2026 — statement ที่ชัดที่สุดของ contract: หนึ่ง key ต่อ logical operation, stored results ถูก replay, conflict ถูกคืนขณะที่ attempt แรกยัง in flight — และ pattern นี้ independent จาก provider normative references สำหรับ request และ event shapes ที่ใช้ที่นี่คือ 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 คือ worked example ที่ดีที่สุดของ concern เดียวกันที่ห่ออยู่ใน library ทั้งหมดอ่านวันเดียวกัน


สร้างโดย

David Vicente Campos

ผู้ก่อตั้ง NeuraLIA Labs และผู้ร่วมก่อตั้ง MyRealFood

ผมเป็นวิศวกรคอมพิวเตอร์ที่จบจากมหาวิทยาลัยเลออน ผมร่วมก่อตั้ง MyRealFood ที่ที่ผมในฐานะ CTO ได้สร้างแอปซึ่งผู้คนหลายล้านคนใช้เพื่อกินให้ดีขึ้น และผมก่อตั้ง NeuraLIA Labs ที่ที่ผมสร้างผลิตภัณฑ์ AI ที่นี่ผมเขียนถึงสิ่งที่ผมต้องทำความเข้าใจระหว่างทาง ในแบบที่ผมเคยหวังว่าจะมีใครสักคนอธิบายให้ผมฟัง

เพิ่มเติมเกี่ยวกับผู้เขียน

เผยแพร่โดย NeuraLIA Labs

รับโพสต์ใหม่ในกล่องจดหมาย

ข่าว AI คู่มือ และอัปเดตผลิตภัณฑ์ — อีเมลสั้น ๆ เมื่อเรามีสิ่งที่คุ้มเวลาของคุณ

ชอบแบบข้อความมากกว่าไหม รับเนื้อหาเดียวกันได้ที่นี่:คอมมูนิตี้ WhatsApp (เปิดในแท็บใหม่)ช่อง Telegram (เปิดในแท็บใหม่)

ดัชนีคอร์ส

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jevอ่าน 5 นาที

โมเดล AI Jev สร้างมาเพื่อการตัดสินใจ ไม่ใช่การเขียนความเรียง

Jev ของ TypeSafe AI กำลังได้รับความสนใจ เพราะมองความฉลาดของซอฟต์แวร์เป็นปัญหาความน่าจะเป็น: เลือกกิ่งที่ถูกต้อง แนบความมั่นใจ และหลีกเลี่ยงการจ่ายเงินให้ LLM เขียนข้อความเมื่อโค้ดต้องการการตัดสินใจ

Abstract legal research workspace with documents, search nodes and governance controls.
openaiอ่าน 4 นาที

Astra for Law ของ OpenAI คือระบบ AI ด้านกฎหมาย ไม่ใช่โมเดลใหม่

การเปิดตัวด้านกฎหมายของ OpenAI ไม่ได้เน้นโมเดลฐานรากใหม่เท่ากับระบบที่ล้อมรอบโมเดลนั้น: การค้นคืนเฉพาะโดเมน เครื่องมือที่เชื่อถือได้ สิทธิ์ เบนช์มาร์ก และเส้นทางการตรวจทาน

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineeringอ่าน 4 นาที

วิศวกรรมบริบทสำหรับเอเจนต์ AI ที่ทำงานระยะยาว

เอเจนต์ที่ทำงานต่อเนื่องไม่ได้ล้มเหลวเพียงเพราะหน้าต่างบริบทเล็กเกินไป แต่ล้มเหลวเมื่อไฟล์ ผลลัพธ์จากเครื่องมือ และประวัติที่ค้างเก่าบดบังงานที่เอเจนต์ควรทำให้เสร็จ

พร้อมให้ LIA เลือกโมเดลให้แล้วหรือยัง?

สร้างงานด้วยโมเดล AI ทุกตัวในที่เดียว เริ่มฟรีวันนี้