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 ครั้งแรก

  1. ตารางเวลารอ (base 1 วินาที เพดาน 8): [1, 2, 4, 8, 8]

    Python

cache และ hit rate

สร้างกุญแจ cache และวัดว่าประหยัดกี่คำขอ

  1. กุญแจ cache เป็น sha256 ของ JSON ที่เรียง key — dict ลำดับต่างกันได้กุญแจเดียวกัน

    Python

เติมช่องว่าง

แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ

ยังไม่ได้ทำ

เติมสูตรเวลารอแบบ 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

1. ข้อผิดพลาดแบบใดที่ควร retry
2. jitter ใน backoff มีไว้ทำไม
3. ทำไมกุญแจ cache ควรใช้ json.dumps(sort_keys=True) แล้ว hash
4. circuit breaker ช่วยอะไร

สรุป

  • 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) รวมถึงข้อมูลส่วนบุคคลที่ไม่ควรเก็บ