AI Engineer › พื้นฐาน LLM และ API
เรียก API ให้ทนทาน: retry, cache, rate limit
ประมาณ 35 นาที
เป้าหมายของบท
- retry เฉพาะข้อผิดพลาดชั่วคราวด้วย exponential backoff (พร้อมเพดานและ jitter)
- ทำ cache ที่มีกุญแจ deterministic และวัด hit rate / ต้นทุนที่ประหยัดได้
- อธิบายและจำลอง sliding-window rate limiter และ circuit breaker
API จะล้มเหลวเป็นเรื่องปกติ
เมื่อระบบเรียก LLM API หลายพันครั้ง สิ่งที่หนีไม่พ้นคือ: rate limit (HTTP 429), เซิร์ฟเวอร์ล่ม/โอเวอร์โหลด (5xx), timeout, เครือข่ายหลุด
ส่วนที่ทำให้แอปดูเป็นมืออาชีพคือการรับมือกับความล้มเหลวเหล่านี้ ไม่ใช่แค่เขียน prompt ให้เก่ง (ตัว SDK ส่วนใหญ่มี retry อัตโนมัติและตั้ง max_retries / timeout ได้ แต่คุณต้องเข้าใจหลักการ)
บทนี้จำลองบริการที่ล้มเหลวด้วยฟังก์ชันของเรา (ไม่ได้เรียก API จริง) เพื่อให้ฝึกและตรวจผลได้ซ้ำทุกครั้ง
1) Retry ด้วย exponential backoff
ลองใหม่เฉพาะข้อผิดพลาดที่ “ชั่วคราว” (429, 5xx, timeout) — ส่วน 400/401 (คำขอผิด/ไม่มีสิทธิ์) ลองกี่ครั้งก็ผลเดิม อย่า retry รอนานขึ้นเป็นเท่าตัวทุกครั้ง (1, 2, 4, 8, …) จนถึงเพดาน และใส่ jitter (สุ่มลดเวลารอ) เพื่อไม่ให้ลูกค้าจำนวนมากกลับมาพร้อมกัน:
def backoff_delays(attempts, base=1, cap=8):
return [min(cap, base * 2 ** k) for k in range(attempts)] # [1, 2, 4, 8, 8]
rng = random.Random(0)
jittered = [rng.uniform(0, d) for d in backoff_delays(5)] # full jitter
ระวังการ retry คำขอที่ “มีผลข้างเคียง” (เช่น สั่งจ่ายเงิน) — ต้องมี idempotency key
2) Cache ผลที่ซ้ำ
คำถามเดียวกันด้วยพารามิเตอร์เดียวกันไม่ควรเสียเงินซ้ำ สร้างกุญแจจากคำขอทั้งก้อนแบบ deterministic:
key = hashlib.sha256(json.dumps(request, sort_keys=True).encode()).hexdigest()
sort_keys=True ทำให้ dict ที่ลำดับ key ต่างกันได้กุญแจเดียวกัน · วัด hit rate = (คำขอทั้งหมด − คำขอที่ต้องเรียกจริง) ÷ คำขอทั้งหมด
ข้อควรระวัง: cache เหมาะกับงานที่ต้องการคำตอบเดิม (FAQ, สกัดข้อมูล) แต่ไม่เหมาะกับงานที่ต้องการความหลากหลาย และต้องมีอายุ (TTL) เมื่อฐานความรู้เปลี่ยน
3) Rate limiter และ circuit breaker
- Sliding window: อนุญาตได้ไม่เกิน N คำขอในช่วง W วินาทีที่ผ่านมา — ที่เหลือรอหรือปฏิเสธ
- Circuit breaker: หลังล้มเหลวติดกัน 3 ครั้ง “เปิดวงจร” หยุดยิงคำขอชั่วคราว ป้องกันการถล่มบริการที่ล่มอยู่และประหยัดเวลา/เงินของเรา แล้วค่อยลองใหม่ภายหลัง
หลักคิดรวม: ล้มเหลวเร็ว, ลองใหม่อย่างมีเงื่อนไข, ไม่จ่ายซ้ำโดยไม่จำเป็น, และบันทึก log ทุกครั้งที่ retry
ตัวอย่างแก้โจทย์
กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้
retry ด้วย backoff
ลองเรียกบริการที่ล้มเหลว 2 ครั้งแรก
ตารางเวลารอ (base 1 วินาที เพดาน 8): [1, 2, 4, 8, 8]
cache และ hit rate
สร้างกุญแจ cache และวัดว่าประหยัดกี่คำขอ
กุญแจ cache เป็น sha256 ของ JSON ที่เรียง key — dict ลำดับต่างกันได้กุญแจเดียวกัน
เติมช่องว่าง
แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ
เติมสูตรเวลารอแบบ exponential: attempt ที่ 3 (นับจาก 0) ของ base = 1 วินาที เพดาน 8 วินาที เก็บไว้ใน delay เติม ___ ให้ครบ
แบบฝึกท้ายบท
บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม
🟢 พื้นฐาน
ตารางเวลารอสำหรับ 5 ครั้งแรก ด้วยสูตร min(cap, base × 2^k) เมื่อ k = 0..4, base = 1, cap = 8 เก็บเป็น list ไว้ในตัวแปร delays
ไม่ใช้เพดาน: เวลารอรวมของ 5 ครั้งแรกเมื่อ base = 0.5 วินาที (0.5 × 2^k, k = 0..4) เก็บไว้ในตัวแปร total_wait
กุญแจ cache ของคำขอ {"model": "demo", "prompt": "hello"}: sha256 ของ json.dumps(req, sort_keys=True).encode() เอา 8 ตัวอักษรแรกของ hex เก็บไว้ในตัวแปร key8
จาก HTTP status [400, 401, 429, 500, 503] มีกี่ตัวที่ควร retry (429 และ 5xx) เก็บไว้ในตัวแปร n_retryable
🟡 ประยุกต์
เขียนฟังก์ชัน retry ที่เรียก fn(prompt) สูงสุด 4 ครั้ง ถ้าเกิด RateLimitError ให้บันทึกเวลารอ min(8, 2^attempt) ลง list (ไม่ต้อง sleep จริง) แล้วลองใหม่ — ใช้กับ make_flaky(2) และ prompt "hello": เก็บข้อความที่ได้ไว้ใน result_text จำนวนครั้งที่เรียกไว้ใน attempts_used และเวลารอรวมไว้ใน total_delay
ลำดับ prompts ที่ให้ไว้: ใช้ dict เป็น cache นับจำนวนครั้งที่ต้องเรียกจริง (api_calls) และ hit rate = (ทั้งหมด − เรียกจริง) ÷ ทั้งหมด (hit_rate)
sliding window: อนุญาตคำขอที่เวลา t ได้ก็ต่อเมื่อจำนวนคำขอที่ "ถูกอนุญาตแล้ว" ในช่วง (t − 5, t] ยังน้อยกว่า 3 — นับจำนวนที่อนุญาต (accepted) และถูกปฏิเสธ (rejected) จาก timestamps ที่ให้ไว้
🔴 ท้าทาย
circuit breaker: outcomes ที่ให้ไว้ ("ok"/"fail") — ตัวนับความล้มเหลวติดกันรีเซ็ตเมื่อเจอ ok เมื่อล้มเหลวติดกันครบ 3 ครั้งให้ "เปิดวงจร" ทันที เก็บ index ของคำขอที่ทำให้วงจรเปิดไว้ใน opened_at (นับจาก 0) และจำนวนคำขอที่เหลือหลังจากนั้น (ถูกข้าม) ไว้ใน skipped
สายคำขอ stream = 10 คำถามแรกของ qs ซ้ำ 3 รอบ ตามด้วยคำถามที่ 11–30 อีกครั้งละหนึ่งรอบ (รวม 50 คำขอ) — ถ้าใช้ cache ตามข้อความคำถาม ต้องเรียก API จริงกี่ครั้ง (unique_calls) ประหยัดได้กี่ครั้ง (calls_saved) และคิดเป็นร้อยละของคำขอทั้งหมด (saving_pct ปัด 1 ตำแหน่ง)
Quiz
สรุป
- retry เฉพาะ 429/5xx/timeout ด้วย backoff 1, 2, 4, 8… (มีเพดานและ jitter) และห้าม retry คำขอผิด (400/401) หรือคำขอที่มีผลข้างเคียงโดยไม่มี idempotency key
- cache ด้วยกุญแจ sha256(json.dumps(req, sort_keys=True)); ตัวอย่างลำดับ 8 คำขอมี 4 ข้อความไม่ซ้ำ hit rate 0.5
- sliding window จำกัดอัตราคำขอ; circuit breaker เปิดหลังล้มเหลวติดกัน 3 ครั้งเพื่อหยุดถล่มบริการ (บทนี้จำลองบริการด้วยฟังก์ชัน ไม่ได้เรียก API จริง)
คำถามสัมภาษณ์
คุณจะทำให้แอปที่เรียก LLM API ทนต่อ rate limit และ downtime ได้อย่างไร
retry เฉพาะข้อผิดพลาดชั่วคราวด้วย exponential backoff + jitter และจำกัดจำนวนครั้ง ตั้ง timeout, ใช้ cache สำหรับคำขอซ้ำ, ใส่ rate limiter ฝั่งเราเพื่อไม่ชนเพดาน, circuit breaker/fallback เมื่อบริการล่มนาน, ทำ idempotency สำหรับคำขอที่มีผลข้างเคียง และบันทึก log/metrics ของทุก retry เพื่อดูแนวโน้ม
เมื่อไรที่การ cache คำตอบของ LLM ไม่เหมาะสม
เมื่อต้องการความหลากหลายของคำตอบ เมื่อคำตอบขึ้นกับข้อมูลที่เปลี่ยนตลอดเวลาหรือบริบทเฉพาะผู้ใช้ (ต้องรวมสิ่งเหล่านั้นในกุญแจหรือไม่ cache) และเมื่อเนื้อหาต้นทางเปลี่ยน (ต้องมี TTL/การล้าง cache) รวมถึงข้อมูลส่วนบุคคลที่ไม่ควรเก็บ