Rate Limits и стратегии повторных запросов (Exponential Backoff)

35 минут Урок 28

Урок 6.2: Rate Limits и стратегии повторных запросов (Exponential Backoff)

Приветствую, коллеги! Мы продолжаем погружаться в LLMOps. В прошлом уроке мы говорили о мониторинге, а сегодня коснемся темы, которая отличает «домашний» прототип от надежного продакшн-сервиса — управление лимитами (Rate Limits) и стратегии ретраев (Retries).

Представьте ситуацию: вы запускаете новую фичу на базе Gemini 3, которая анализирует документы пользователей. На тестах всё летало. Вы выкатываете обновление, приходят первые 1000 пользователей, и... сервис падает. В логах сплошная краснота: 429 Too Many Requests. Хуже того, ваши попытки быстро повторить запросы только усугубляют ситуацию, и API блокирует вас на более длительный срок.

Сегодня мы разберем, как подружиться с квотами Google Cloud, почему простой цикл while — это зло, и как математически изящно решать проблему перегрузки с помощью Exponential Backoff с добавлением Jitter.

Анатомия ограничений: RPM, TPM и RPD

Прежде чем писать код, давайте поймем правила игры. Google (как и любой поставщик LLM) вводит ограничения не из жадности, а для защиты инфраструктуры и обеспечения справедливости (fair usage).

При работе с Gemini 3 API вы столкнетесь с тремя основными метриками:

  • RPM (Requests Per Minute): Количество HTTP-запросов в минуту. Это «светофор» для частоты обращений. Даже если запросы маленькие, нельзя делать их слишком часто.
  • TPM (Tokens Per Minute): Количество обрабатываемых токенов (входных + выходных) в минуту. Это «весы» для объема данных. Один тяжелый промпт с контекстом в 1 миллион токенов может мгновенно съесть всю квоту, даже если RPM будет равен 1.
  • RPD (Requests Per Day): Общий лимит запросов в сутки. Обычно актуален для бесплатных тарифов (Free Tier).

Важный нюанс Gemini 3: Поскольку модель мультимодальная, картинки и видео также конвертируются в токены при расчете квот. Секунда видео может «весить» как сотни текстовых токенов.

Когда вы превышаете лимит, сервер возвращает статус HTTP 429 (Too Many Requests) или, в терминах gRPC/Python клиента, исключение ResourceExhausted.

python
import google.generativeai as genai
from google.api_core import exceptions
import time

# Типичная ошибка новичка: "долбёжка" сервера
def naive_retry_strategy(prompt):
    model = genai.GenerativeModel('gemini-1.5-pro')
    while True:
        try:
            response = model.generate_content(prompt)
            return response.text
        except exceptions.ResourceExhausted:
            print("Лимит исчерпан! Пробую снова немедленно...")
            # ОШИБКА: Мгновенный повтор без паузы
            # Это приведет к еще более быстрому исчерпанию квоты
            continue 
        except Exception as e:
            print(f"Другая ошибка: {e}")
            break

Проблема «Стада» (Thundering Herd)

Код выше иллюстрирует то, что инженеры называют «наивным ретраем». Если 100 ваших микросервисов одновременно получат ошибку 429 и одновременно попытаются повторить запрос через 0.1 секунды, они создадут эффект Thundering Herd (набег стада). API, который только начал «дышать», снова захлебнется, но уже от ваших повторных запросов.

Решение: Exponential Backoff (Экспоненциальная отсрочка)

Идея проста: если сервер занят, дайте ему время. Если он всё ещё занят, дайте ему намного больше времени.

Формула задержки выглядит так:

Delay = Base * 2Attempt

  • Base: Базовая задержка (например, 1 секунда).
  • Attempt: Номер попытки (0, 1, 2...).

Последовательность ожидания будет: 1с, 2с, 4с, 8с, 16с... Это позволяет быстро погасить нагрузку.

Секретный ингредиент: Jitter (Дрожание)

Чистая экспонента имеет недостаток: если сервис упал глобально, все клиенты начнут ретраить синхронно (через 1с, потом все вместе через 2с). Это создает волны нагрузки.

Чтобы «размазать» запросы во времени, мы добавляем случайность — Jitter.

Delay = (Base * 2Attempt) + Random(0, 1)

Теперь один клиент подождет 1.1с, а другой — 1.9с. Синхронизация разрушена, сервер спасен.

python
import time
import random
from google.api_core import exceptions

# Правильная реализация вручную для понимания алгоритма
def generate_with_backoff(model, prompt, max_retries=5, base_delay=1.0):
    retries = 0
    
    while retries < max_retries:
        try:
            response = model.generate_content(prompt)
            return response.text
            
        except exceptions.ResourceExhausted as e:
            # Вычисляем время ожидания
            # 1. Экспоненциальная часть: 2 в степени retries
            # 2. Джиттер: случайное число добавит непредсказуемости
            sleep_time = (base_delay * (2 ** retries)) + random.uniform(0, 1)
            
            print(f"Квота превышена (429). Попытка {retries + 1}/{max_retries}. "
                  f"Ждем {sleep_time:.2f} сек.")
            
            time.sleep(sleep_time)
            retries += 1
            
    raise Exception("Не удалось получить ответ после всех попыток ретрая")

Профессиональный подход: библиотека Tenacity

Писать циклы while и высчитывать формулы вручную — полезно для учебы, но в продакшене лучше использовать проверенные инструменты. В экосистеме Python стандартом де-факто является библиотека Tenacity.

Она позволяет вынести логику повторов в декораторы, оставляя бизнес-логику чистой. Tenacity умеет:

  • Повторять только при определенных исключениях.
  • Использовать разные стратегии ожидания (фиксированные, экспоненциальные).
  • Прекращать попытки по времени или количеству раз.
  • Добавлять Jitter одной строчкой конфигурации.

Давайте напишем production-ready обертку для Gemini 3 API.

python
import google.generativeai as genai
from google.api_core import exceptions
from tenacity import (
    retry,
    stop_after_attempt,
    wait_random_exponential,
    retry_if_exception_type,
    before_sleep_log
)
import logging
import sys

# Настройка логирования
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger(__name__)

# Конфигурация Gemini
genai.configure(api_key="YOUR_API_KEY")
model = genai.GenerativeModel('gemini-1.5-flash')

# Декоратор Tenacity
# 1. wait_random_exponential: реализует экспоненту + jitter (от 1с до 60с макс)
# 2. stop_after_attempt: сдаемся после 6 попыток
# 3. retry_if_exception_type: ретраим ТОЛЬКО при ошибках квоты или сервера (5xx)
#    Не ретраим при ошибках валидации (400)!

@retry(
    wait=wait_random_exponential(multiplier=1, max=60),
    stop=stop_after_attempt(6),
    retry=retry_if_exception_type((
        exceptions.ResourceExhausted, 
        exceptions.ServiceUnavailable,
        exceptions.GatewayTimeout
    )),
    before_sleep=before_sleep_log(logger, logging.INFO)
)
def secure_generate(prompt):
    """
    Безопасная функция генерации с автоматическим управлением Rate Limits.
    """
    logger.info(f"Отправка запроса: {prompt[:20]}...")
    response = model.generate_content(prompt)
    return response.text

# Пример использования
if __name__ == "__main__":
    try:
        # Эмулируем нагрузку
        result = secure_generate("Расскажи про теорию струн в двух абзацах.")
        print("\nУспех:", result[:100], "...")
    except Exception as e:
        print(f"\nФинальный сбой: {e}")
Упражнение

Рассчитайте стратегию ретраев. Представьте, что у вас есть скрипт пакетной обработки (batch processing), который обрабатывает 1000 файлов. Базовая задержка (Base) = 2 секунды. Максимальное время, которое мы готовы ждать одного ответа — 35 секунд. Сколько попыток (retries) максимально может сделать система, прежде чем превысит этот лимит ожидания для одного запроса, используя чистый Exponential Backoff (без учета времени выполнения самого запроса)?

Стратегия работы с «тяжелыми» запросами (TPM Limit)

Exponential Backoff отлично работает для ошибок RPM (слишком часто). Но что, если вы упираетесь в TPM (слишком много токенов)?

Например, вы отправляете в Gemini книгу целиком. Даже один запрос может вызвать ResourceExhausted, если он превышает мгновенную пропускную способность, или если вы шлете такие запросы параллельно. В этом случае просто ждать может быть недостаточно — нужно снижать конкурентность (concurrency).

Паттерн «Token Bucket» на стороне клиента:
В высоконагруженных системах (например, когда вы используете Gemini для обработки датасетов из BigQuery) ретраев мало. Нужно предотвращать отправку.

  1. Оцените количество токенов в промпте перед отправкой (используя model.count_tokens()).
  2. Используйте локальный ограничитель (Semaphore или RateLimiter), который знает ваш лимит TPM.
  3. Если «бюджет токенов» на текущую минуту исчерпан, скрипт засыпает до отправки запроса в API.

Это переводит подход от «Бьемся в стену и отступаем» к «Смотрим на светофор перед выездом».

Вопрос

Почему использование случайного разброса (Jitter) критически важно при реализации стратегии Exponential Backoff в распределенной системе?

Заключение и лучшие практики

Внедрение LLM в продакшн — это не только про качество промптов, но и про устойчивость кода. Rate Limits — это нормальная часть работы любой облачной системы.

Чек-лист для вашего продакшена:

  • Никаких бесконечных циклов: Всегда ограничивайте max_retries или stop_after_delay.
  • Идемпотентность: Убедитесь, что повтор запроса не приведет к дублированию данных в вашей БД (например, если API ответил, но сеть отвалилась на получении ответа).
  • Мониторинг 429: Настройте алерты на всплески ошибок 429. Если их слишком много, возможно, пора запросить повышение квоты у Google Cloud или оптимизировать промпты.
  • Graceful Degradation: Если API недоступен после всех ретраев, покажите пользователю понятное сообщение («ИИ сейчас перегружен, попробуйте через минуту»), а не 500 Server Error.

В следующем уроке мы разберем кэширование ответов (Caching), которое поможет вам не только сэкономить квоты, но и значительно ускорить работу приложения.