ข้ามไปยังเนื้อหาหลัก
ProgrammerThailand

AI & Vibe Coding

Rate Limit และ Retry เมื่อเรียก AI API

Rate Limit และ Retry เมื่อเรียก AI API

ใครที่เอา AI API ขึ้น production มาสักพักจะเจออาการคุ้นเคย คือระบบทำงานดีตอนทดสอบ แต่พอมีผู้ใช้พร้อมกันเยอะ ๆ ก็เริ่มได้ HTTP 429 หรือ 5xx กลับมาเป็นระยะ ถ้าโค้ดของเราโยน error ใส่ผู้ใช้ทันที หรือแย่กว่านั้นคือ retry ทันทีแบบวนไม่หยุด ปัญหาจะใหญ่ขึ้นเรื่อย ๆ บทความนี้จะอธิบายว่า error แต่ละแบบหมายถึงอะไร ควร retry อย่างไรด้วย exponential backoff และ jitter ทำไม idempotency สำคัญ และเมื่อไรควรย้ายงานไปเข้าคิว พร้อมตัวอย่างโค้ด PHP ที่นำไปใช้ได้

รู้จัก error ที่ควรและไม่ควร retry

ก่อนเขียน retry ต้องแยกให้ออกก่อนว่า error ไหนเป็นปัญหาชั่วคราวที่ลองใหม่แล้วอาจหาย และ error ไหนลองกี่ครั้งก็ไม่หาย

สถานะความหมายโดยทั่วไปควร retry ไหม
429 Too Many Requestsเรียกถี่หรือใช้ token เกินโควตาต่อช่วงเวลาใช่ รอตาม Retry-After หรือ backoff
500, 502, 503, 504ฝั่งผู้ให้บริการมีปัญหาหรือ overload ชั่วคราวใช่ ด้วย backoff
Timeout / connection errorเครือข่ายหรือ server ตอบช้าเกินใช่ แต่ระวังเรื่องงานซ้ำ
400 Bad Requestrequest ผิดรูปแบบ หรือ input ยาวเกินที่โมเดลรับได้ไม่ ต้องแก้ request
401, 403key ผิด หมดอายุ หรือไม่มีสิทธิ์ไม่ ต้องแจ้งทีม

ผู้ให้บริการบางรายใช้ status code เฉพาะเพิ่มเติม เช่น สถานะที่บอกว่าระบบ overload หรือแนบรายละเอียดใน body ของ error ให้ตรวจเอกสารของผู้ให้บริการที่ใช้อยู่ แล้วปรับรายการ status ที่ retry ได้ให้ตรง

สิ่งสำคัญอีกอย่างคือ header Retry-After ถ้า server ส่งมา แปลว่าเขาบอกเราตรง ๆ แล้วว่าควรรอนานเท่าไร ค่านี้อาจเป็นจำนวนวินาทีหรือเป็นวันที่ตามมาตรฐาน HTTP เราควรเคารพค่านี้ก่อนสูตรของเราเองเสมอ

Exponential backoff และ jitter

วิธีที่ผิดคือ retry ทันทีหลัง error เพราะถ้า server กำลัง overload เราจะซ้ำเติมมันอีก วิธีที่ถูกคือรอนานขึ้นเป็นทวีคูณในแต่ละรอบ เช่น 1, 2, 4, 8 วินาที เรียกว่า exponential backoff และกำหนดเพดานสูงสุดไว้ด้วย

แต่ backoff อย่างเดียวยังไม่พอ ลองนึกภาพ worker ร้อยตัวโดน 429 พร้อมกัน แล้วทุกตัวรอ 1 วินาทีเท่ากันเป๊ะ พอครบเวลาก็ยิงพร้อมกันอีก ปัญหาก็วนกลับมาเหมือนเดิม การเพิ่ม jitter คือการสุ่มเวลารอเพื่อกระจาย request ออกไป วิธีที่นิยมคือ "full jitter" คือสุ่มค่าระหว่าง 0 ถึงค่า backoff ของรอบนั้น

delay = random(0, min(cap, base * 2^attempt))

attempt 0 -> สุ่ม 0 ถึง 1 วินาที
attempt 1 -> สุ่ม 0 ถึง 2 วินาที
attempt 2 -> สุ่ม 0 ถึง 4 วินาที
attempt 3 -> สุ่ม 0 ถึง 8 วินาที (และไม่เกิน cap)

ตัวอย่าง PHP: retry อย่างมีขอบเขต

ตัวอย่างนี้ใช้ cURL ล้วน ๆ endpoint ชื่อโมเดล และ key อ่านจาก environment ส่วนรูปแบบ JSON ของ request และ response รวมถึงชื่อ header สำหรับ key ให้ปรับตามเอกสารของผู้ให้บริการ

<?php

declare(strict_types=1);

final class RetryableHttpException extends RuntimeException
{
    public function __construct(string $message, public readonly ?int $retryAfter = null)
    {
        parent::__construct($message);
    }
}

function parseRetryAfter(?string $value): ?int
{
    if ($value === null || $value === '') {
        return null;
    }
    if (ctype_digit($value)) {
        return (int) $value;
    }
    $ts = strtotime($value);
    return $ts === false ? null : max(0, $ts - time());
}

/** @return array<string, mixed> */
function callLlmOnce(array $payload): array
{
    $url = (string) getenv('LLM_API_URL');
    $key = (string) getenv('LLM_API_KEY');
    $retryAfter = null;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $key,
        ],
        CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
        CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter): int {
            if (stripos($line, 'Retry-After:') === 0) {
                $retryAfter = parseRetryAfter(trim(substr($line, 12)));
            }
            return strlen($line);
        },
    ]);

    $body = curl_exec($ch);
    $errno = curl_errno($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

    if ($errno !== 0) {
        throw new RetryableHttpException('Network error: ' . $errno);
    }
    if ($status === 429 || $status >= 500) {
        throw new RetryableHttpException('HTTP ' . $status, $retryAfter);
    }
    if ($status >= 400) {
        throw new RuntimeException('HTTP ' . $status . ' (ไม่ retry)');
    }
    return json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
}

/** @return array<string, mixed> */
function callLlmWithRetry(array $payload, int $maxAttempts = 5, float $baseSec = 1.0, float $capSec = 30.0): array
{
    for ($attempt = 0; ; $attempt++) {
        try {
            return callLlmOnce($payload);
        } catch (RetryableHttpException $e) {
            if ($attempt + 1 >= $maxAttempts) {
                throw $e;
            }
            $backoff = min($capSec, $baseSec * (2 ** $attempt));
            $delay = $e->retryAfter !== null
                ? min((float) $e->retryAfter, $capSec * 2)
                : (mt_rand() / mt_getrandmax()) * $backoff; // full jitter
            usleep((int) ($delay * 1_000_000));
        }
    }
}

$result = callLlmWithRetry([
    'model' => getenv('LLM_MODEL'),
    'messages' => [['role' => 'user', 'content' => 'สรุปข้อความนี้ให้สั้น']],
    'max_tokens' => 300,
]);

จุดที่ควรสังเกต:

  • จำกัดจำนวนครั้ง ($maxAttempts) และเวลารอสูงสุด ไม่มี retry ไม่รู้จบ
  • ถ้ามี Retry-After ใช้ค่านั้นแทนสูตร แต่ยังมีเพดานกันกรณีค่าแปลก ๆ
  • error กลุ่ม 4xx ที่ไม่ใช่ 429 จะโยนออกไปทันที ไม่เสียเวลาลองซ้ำ
  • ไม่ log key หรือ header ทั้งชุด

ข้อควรระวังใน web request: ถ้า retry หลายรอบภายใน request ของผู้ใช้ เวลารวมอาจนานจนเกิน timeout ของ PHP-FPM หรือ reverse proxy ใน web request ควรตั้ง $maxAttempts ต่ำ ๆ แล้วย้ายงานยาวไปไว้ในคิว

Idempotency: retry แล้วต้องไม่ทำงานซ้ำ

การเรียก LLM เพื่อถามคำถามเฉย ๆ ทำซ้ำได้ไม่เสียหาย นอกจากเสียเงินเพิ่ม แต่ระบบจริงมักมีขั้นตอนต่อจากคำตอบ เช่น บันทึกบทสรุปลงฐานข้อมูล ส่งอีเมล หรือหักเครดิตผู้ใช้ ถ้า timeout เกิดหลังจากที่ server ประมวลผลไปแล้ว เราจะไม่รู้เลยว่ารอบแรกสำเร็จหรือไม่ การ retry อาจทำให้เกิดผลซ้ำ

แนวทางที่ใช้กันคือ:

  • สร้าง idempotency key ต่องาน (เช่น UUID หรือ hash ของ input) แล้วเก็บผลลัพธ์ผูกกับ key นั้น ก่อนทำงานให้เช็กว่ามีผลอยู่แล้วหรือยัง
  • ถ้าผู้ให้บริการรองรับ header สำหรับ idempotency ให้ส่งไปด้วย แต่ไม่ใช่ทุกเจ้ารองรับ ต้องตรวจเอกสาร
  • ใช้ unique constraint ในฐานข้อมูลเป็นด่านสุดท้าย
CREATE TABLE ai_jobs (
    id              BIGSERIAL PRIMARY KEY,
    idempotency_key VARCHAR(64) NOT NULL UNIQUE,
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    attempts        INTEGER NOT NULL DEFAULT 0,
    result          JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- บันทึกงานใหม่ ถ้า key ซ้ำจะไม่สร้างแถวใหม่
INSERT INTO ai_jobs (idempotency_key) VALUES (:key)
ON CONFLICT (idempotency_key) DO NOTHING;

ย้ายงานเข้าคิว

งานที่ไม่ต้องตอบผู้ใช้ทันที เช่น สรุปเอกสารที่อัปโหลด แปลบทความ หรือสร้าง embedding ทั้งชุด ไม่ควรทำใน web request เลย ควรบันทึกงานลงคิวแล้วตอบผู้ใช้ว่า "กำลังประมวลผล" จากนั้นให้ worker ทยอยทำ ข้อดีคือ:

  • คุมความเร็วได้ เช่น จำกัด worker ไว้ไม่กี่ตัวให้ไม่เกิน rate limit ของบัญชี
  • retry ได้นานกว่าเดิมโดยไม่ทำให้หน้าเว็บค้าง
  • เมื่อผู้ให้บริการล่มชั่วคราว งานไม่หาย แค่รอในคิว

ใน Yii2 สามารถใช้ extension yiisoft/yii2-queue ซึ่งรองรับ driver หลายแบบ เช่น database หรือ Redis และมีแนวคิด job ที่ retry ได้ หรือถ้าต้องการเรียบง่าย ใช้ตาราง ai_jobs ข้างบนร่วมกับ console command ที่ดึงงานด้วย SELECT ... FOR UPDATE SKIP LOCKED ของ PostgreSQL ก็ใช้งานได้ดี

-- worker ดึงงานทีละชิ้น ไม่ชนกันแม้รันหลายตัว
UPDATE ai_jobs SET status = 'running', attempts = attempts + 1
WHERE id = (
    SELECT id FROM ai_jobs
    WHERE status = 'pending' AND attempts < 5
    ORDER BY id
    FOR UPDATE SKIP LOCKED
    LIMIT 1
)
RETURNING id, idempotency_key;

Checklist ก่อนขึ้น production

  • แยก error ที่ retry ได้ (429, 5xx, network) ออกจากที่ไม่ควร retry (400, 401, 403)
  • ใช้ exponential backoff พร้อม jitter และมีเพดานทั้งจำนวนครั้งและเวลารอ
  • เคารพ Retry-After เมื่อ server ส่งมา
  • งานที่มีผลข้างเคียงต้องมี idempotency key
  • งานยาวหรือไม่เร่งด่วนย้ายไปไว้ในคิว
  • log จำนวนครั้งที่ retry และสถานะสุดท้าย เพื่อดูแนวโน้มว่าเริ่มชน limit บ่อยขึ้นหรือไม่
  • มีข้อความที่เป็นมิตรกับผู้ใช้เมื่อ retry ครบแล้วยังไม่สำเร็จ

สรุป

  • 429 และ 5xx มักเป็นปัญหาชั่วคราว ควร retry แต่ 4xx อื่น ๆ ต้องแก้ที่ request
  • exponential backoff ช่วยไม่ซ้ำเติม server ส่วน jitter ช่วยกระจาย request ไม่ให้ยิงพร้อมกัน
  • Retry-After คือคำแนะนำโดยตรงจาก server ให้ใช้ก่อนสูตรของเรา
  • idempotency ทำให้ retry ได้อย่างปลอดภัยโดยไม่เกิดผลซ้ำ
  • คิวงานช่วยคุมความเร็ว แยกงานยาวออกจาก web request และทำให้ระบบทนต่อการล่มชั่วคราว

ลองทำ

สร้างไฟล์ PHP เล็ก ๆ ที่จำลอง API ปลายทาง โดยให้ตอบ 429 พร้อม Retry-After: 2 สองครั้งแรก แล้วตอบ 200 ในครั้งที่สาม (รันด้วย php -S localhost:8080 mock.php และเก็บตัวนับไว้ในไฟล์ชั่วคราว) จากนั้นชี้ LLM_API_URL ไปที่ mock นี้ แล้วเรียก callLlmWithRetry() พร้อมพิมพ์เวลาที่รอแต่ละรอบ ตรวจว่าโค้ดรอตาม Retry-After จริง แล้วลองเปลี่ยน mock ให้ตอบ 500 โดยไม่มี header เพื่อดูว่า jitter ทำให้เวลารอแต่ละรอบไม่เท่ากัน

ติดปัญหาตอนทำตาม? ถามในชุมชน

ความคิดเห็น (0)

ยังไม่มีความคิดเห็น — เริ่มคุยเป็นคนแรก ถามสิ่งที่สงสัย หรือแชร์ประสบการณ์ของคุณ