Ограничения частоты запросов и повторные попытки в API LLM
Во вторник в 09:12 наш конвейер суммаризации выглядел исправным: 96 рабочих процессов работали, медианная задержка модели составляла около 2.8 секунд, доля ошибок класса 5xx не была повышена. Затем клиент загрузил накопившиеся 38,000 обращений в поддержку.
За четыре минуты конвейер «упал», хотя ни один сервер не вышел из строя. Мы одновременно упёрлись в два разных ограничения:
requests_per_minute: слишком много вызовов APItokens_per_minute: слишком большой суммарный объём промптов и ответов
Наивная логика повторных попыток усугубила ситуацию. Каждый рабочий процесс получал 429, ждал одну секунду, и все они повторяли запросы одновременно. Это создавало волну повторных попыток, которая приводила к новым 429, а те вызывали следующую волну. Система случайно организовала синхронную атаку типа «отказ в обслуживании» на собственную квоту.
Обработка ограничений частоты запросов к LLM не сводится к тому, чтобы «добавить экспоненциальное увеличение задержки». В продакшене нужно рассматривать токены, очереди, параллелизм, повторные попытки, идемпотентность, резервных провайдеров и особенности тарификации как единую систему.
Два ключевых ограничения: RPM и TPM
Большинство API LLM применяют ту или иную комбинацию следующих ограничений:
- RPM: запросов в минуту
- TPM: токенов в минуту
- RPD / TPD: запросов или токенов в день
- Ограничения на параллельные запросы: максимальное количество одновременно выполняемых вызовов
- Ограничения для отдельных моделей: более высокие лимиты для небольших моделей и более низкие — для передовых
Ключевой момент: Ограничения RPM и TPM достигаются по-разному.
Если вы отправляете много коротких промптов, первым достигается лимит RPM. Если вы отправляете меньше промптов, но большого размера, первым достигается лимит TPM.
Пример:
Limit:
- 600 requests/minute
- 300,000 tokens/minute
Workload A:
- 1,000 short classification calls/minute
- 200 tokens each
- 200,000 tokens/minute total
Result:
- TPM is fine
- RPM fails
Workload B:
- 100 long document analysis calls/minute
- 5,000 tokens each
- 500,000 tokens/minute total
Result:
- RPM is fine
- TPM fails
Распространённая ловушка: разработчики ограничивают частоту запросов, но не расход токенов. Это работает в тестовой среде, где промпты короткие. Затем пользователи в продакшене вставляют PDF, истории чатов, журналы или юридические договоры, и бюджет токенов мгновенно исчерпывается.
На практике я считаю, что каждый вызов LLM резервирует два ресурса:
cost = {
requests: 1,
tokens: estimated_prompt_tokens + max_output_tokens
}
Даже если модель возвращает меньше выходных токенов, чем max_output_tokens, резервирование верхней границы предотвращает выделение ресурсов сверх доступного объёма.
Что на самом деле происходит, если игнорировать TPM
Предположим, у вас есть:
TPM limit: 120,000
Average request: 4,000 input tokens + 1,000 max output tokens
Reserved cost: 5,000 tokens
Ваша реальная максимальная пропускная способность — не «как можно больше рабочих процессов». Она составляет:
120,000 / 5,000 = 24 requests per minute
Это в среднем один запрос каждые 2.5 секунд.
Если вы запускаете 100 рабочих процессов, вся первая партия запросов может успешно стартовать, если провайдер допускает кратковременные всплески нагрузки. Затем аккаунт упирается в жёсткий лимит. Каждый последующий запрос получает 429, и повторные попытки накапливаются.
Решение не сводится к уменьшению числа рабочих процессов. Нужен планировщик, который учитывает резервирование токенов.
Минимальный ограничитель частоты запросов с учётом токенов
Для одного процесса обычно достаточно алгоритма token bucket. Доступная ёмкость пополняется со временем, а каждый запрос должен получить и слот запроса, и квоту токенов.
Вот упрощённый пример на Python:
import asyncio
import time
from dataclasses import dataclass
@dataclass
class Bucket:
capacity: float
refill_per_second: float
tokens: float
updated_at: float
def refill(self):
now = time.monotonic()
elapsed = now - self.updated_at
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_second)
self.updated_at = now
async def acquire(self, amount: float):
while True:
self.refill()
if self.tokens >= amount:
self.tokens -= amount
return
missing = amount - self.tokens
sleep_for = missing / self.refill_per_second
await asyncio.sleep(min(sleep_for, 1.0))
class LLMRateLimiter:
def __init__(self, rpm: int, tpm: int):
now = time.monotonic()
self.request_bucket = Bucket(
capacity=rpm,
refill_per_second=rpm / 60,
tokens=rpm,
updated_at=now,
)
self.token_bucket = Bucket(
capacity=tpm,
refill_per_second=tpm / 60,
tokens=tpm,
updated_at=now,
)
self.lock = asyncio.Lock()
async def acquire(self, estimated_tokens: int):
async with self.lock:
await self.request_bucket.acquire(1)
await self.token_bucket.acquire(estimated_tokens)
Использование:
estimated_tokens = prompt_tokens + max_output_tokens
await limiter.acquire(estimated_tokens)
response = await client.chat.completions.create(
model="gpt-5.5",
messages=messages,
max_tokens=max_output_tokens,
)
Этот пример намеренно прост. В распределённой системе используйте Redis, рекомендательные блокировки Postgres или очередь с централизованной диспетчеризацией. Ключевой принцип тот же: ограничивайте и число запросов, и расход токенов до вызова API.
Повторные попытки: увеличение задержки необходимо, но недостаточно
Повторные попытки полезны при:
- Временных
429ответах - Сетевых тайм-аутах
- Временных
500,502,503или504ошибках - Перегрузке на стороне провайдера
- Сбросах соединения
Повторные попытки опасны при:
- Ошибках валидации
- Ошибках длины контекста
- Сбоях аутентификации
- Отклонениях по политике безопасности
- Промптах, которые стабильно приводят к ошибке
- Неидемпотентных вызовах инструментов
Политика повторных попыток должна сначала классифицировать ошибки.
| Тип ошибки | Повторять? | Типичное действие |
|---|---|---|
429 rate_limit_exceeded | Да | Увеличивать задержку, учитывать заголовки повторных попыток, снижать параллелизм |
500/502/503/504 | Да | Увеличивать задержку со случайным разбросом, ограничивать число попыток |
| Тайм-аут до получения тела ответа | Возможно | Повторить с ключом идемпотентности, если он поддерживается |
| Превышена длина контекста | Нет | Сжать, разбить на части или направить запрос модели с более длинным контекстом |
| Недействительный ключ API | Нет | Отключить ключ, уведомить оператора |
| Отклонение из-за содержимого или требований безопасности | Нет | Вернуть контролируемую ошибку или переформулировать запрос |
| Побочный эффект вызова инструмента уже произошёл | Нет / Возможно | Требуется продумать идемпотентность |
Базовая схема увеличения задержки:
import random
def retry_delay(attempt: int, base=0.5, cap=30.0):
exponential = min(cap, base * (2 ** attempt))
return random.uniform(0, exponential)
Это random.uniform(0, exponential) — случайный разброс задержек. Не пропускайте его.
Без случайного разброса задержек:
100 workers fail
100 workers sleep 1s
100 workers retry
100 workers fail
100 workers sleep 2s
100 workers retry
Со случайным разбросом задержек:
100 workers fail
workers retry across a spread of 0-1s
then 0-2s
then 0-4s
Второй подход даёт провайдеру и вашей собственной очереди возможность восстановиться.
Учитывайте заголовки повторных попыток, если они есть
Многие API возвращают подсказку, например:
Retry-After: 12
или специфичные для провайдера заголовки сброса лимитов. Если API указывает конкретную задержку, используйте её как нижнюю границу:
delay = max(header_retry_after_seconds, retry_delay(attempt))
await asyncio.sleep(delay)
На практике я также ограничиваю общее время повторных попыток. Запрос в пользовательском чате не должен незаметно повторяться две минуты, пока пользователь смотрит на индикатор загрузки. Фоновое пакетное задание может ждать гораздо дольше.
Пример политики:
{
"interactive_chat": {
"max_attempts": 3,
"max_total_retry_seconds": 8
},
"batch_summarization": {
"max_attempts": 8,
"max_total_retry_seconds": 300
}
}Очередь лучше массового наплыва воркеров
Если объём работы превышает квоту, не стоит позволять всем воркерам напрямую обращаться к API LLM. Поставьте перед ними очередь.
Практическая архитектура:
App/API
-> Job queue
-> Rate-aware dispatcher
-> Provider client
-> Result store
Диспетчер отвечает за:
- учёт RPM
- учёт TPM
- выбор модели
- политику повторных попыток
- переключение на резервного провайдера
- ограничения параллелизма
- приоритеты в очереди
Это делает перегрузку заметной. Вместо тысяч заданий, завершающихся с ошибкой 429, вы видите рост длины очереди и расчётного времени ожидания.
В системах на базе Redis я предпочитаю разделять задания, «готовые к выполнению», и задания с «отложенной повторной попыткой»:
llm:ready
llm:delayed
llm:dead_letter
Задание, завершившееся с ошибкой, не возвращается сразу в ready. Ему назначается время повторной попытки. Небольшой планировщик возвращает задания в очередь готовых к выполнению, когда наступает назначенное время.
{
"job_id": "sum_7f13",
"model": "claude-sonnet-4.6",
"estimated_tokens": 6200,
"attempt": 2,
"not_before": "2026-02-03T09:14:22Z"
}
Это not_before — это поле предотвращает лавину повторных попыток.
Управление параллелизмом и ограничение частоты запросов — разные вещи
Ограничения параллелизма задают предел объёма одновременно выполняемой работы. Ограничения частоты задают предел объёма работы за определённый интервал времени.
Нужны оба механизма.
Представьте, что каждый вызов занимает 20 секунд, а ваш лимит RPM равен 60. Ограничитель, учитывающий только RPM, допускает один запрос в секунду. Через 20 секунд у вас будет примерно 20 одновременно выполняющихся вызовов. Если задержка вырастет до 90 секунд, тот же RPM приведёт к 90 одновременным вызовам. Это может превысить ограничения провайдера или доступных локальных ресурсов.
Используйте семафор для управления параллелизмом:
provider_concurrency = asyncio.Semaphore(20)
async def call_model(job):
async with provider_concurrency:
await limiter.acquire(job.estimated_tokens)
return await send_request(job)
Типичная ловушка: если захватить семафор слишком рано, воркеры будут простаивать, занимая дефицитные слоты параллельного выполнения в ожидании токенов ограничителя частоты. Обычно сначала следует получить разрешение ограничителя частоты, а затем захватить семафор непосредственно перед сетевым вызовом. Если ожидание разрешения может быть долгим, не занимайте на это время ресурсы воркеров.
Плавное снижение качества: направляйте ресурсы туда, где качество важнее
Когда ограничения частоты начинают мешать, у вас есть и другие варианты, кроме отказа.
Например, ассистент службы поддержки может снижать качество обслуживания следующим образом:
| Уровень нагрузки | Стратегия | Влияние на пользователя |
|---|---|---|
| Нормальный | Использовать самую мощную из настроенных моделей | Наилучшее качество |
| Небольшая нагрузка на лимит TPM | Уменьшить max_tokens, сократить извлечённый контекст | Немного более короткие ответы |
| Умеренная нагрузка | Направлять простые задачи на менее крупную модель | Минимальное, если классификация и маршрутизация несложны |
| Высокая нагрузка | Ставить несрочные задания в очередь | Задержка фоновой работы |
| Критическая нагрузка | Возвращать ответ из кеша или по шаблону | Более низкое качество при сохранении доступности |
Конкретные примеры:
- Направляйте задачи по присвоению тегов обращениям на Haiku 4.5, Qwen или небольшой вариант Llama вместо использования передовой модели.
- Используйте Sonnet 4.6 для обычных задач рассуждения, а Opus 4.8 или GPT-5.5 оставьте для сценариев эскалации.
- Переносите анализ длинного контекста на Fable 5 или Gemini 3 только тогда, когда запрос действительно требует такого контекстного окна.
- Для пакетных заданий приостанавливайте работу низкоприоритетных арендаторов, прежде чем нагрузка начнёт влиять на интерактивный трафик.
Цена такого подхода — сложность. Каждый резервный сценарий требует тестов, наблюдаемости и согласования с продуктовой командой. В некоторых областях менее качественный ответ может нанести больше вреда, чем задержанный.
Резервирование с использованием нескольких ключей и провайдеров
Маршрутизация с использованием нескольких ключей и провайдеров может повысить устойчивость, но её легко реализовать неправильно.
Есть три отдельных подхода:
| Подход | Какую задачу решает | Риск |
|---|---|---|
| Несколько ключей одного провайдера и аккаунта | Операционная изоляция | Может нарушать условия провайдера, если используется для обхода лимитов |
| Несколько аккаунтов или регионов | Изоляция сбоев | Усложнение биллинга и управления |
| Несколько провайдеров или моделей | Доступность и гибкость затрат | Различия в результатах, несовместимость промптов |
Не используйте несколько ключей для скрытого обхода лимитов. Используйте их для обоснованного разделения: арендаторов, окружений, продуктов или контуров биллинга.
Чёткая политика маршрутизации может выглядеть так:
{
"task": "support_ticket_summary",
"primary": {
"provider": "anthropic",
"model": "claude-sonnet-4.6"
},
"fallbacks": [
{
"provider": "openai",
"model": "gpt-5.5",
"when": ["provider_5xx", "rate_limit_after_retry_budget"]
},
{
"provider": "google",
"model": "gemini-3",
"when": ["context_length_needed", "primary_unavailable"]
}
],
"degrade_to": {
"provider": "open_model_cluster",
"model": "qwen",
"when": ["batch_low_priority"]
}
}
Если вы используете агрегатор или платформу вроде AI Prime Tech для доступа к нескольким моделям, те же инженерные правила по-прежнему действуют: явно отслеживайте идемпотентность, бюджеты, условия переключения на резервный вариант и правила окончательного начисления оплаты.
Как избежать двойного начисления оплаты при переключении на резервный вариант
Главная сложность переключения на резервный вариант — понять, завершился ли первый запрос.
Рассмотрим такую последовательность:
Client sends request to Provider A
Provider A processes it successfully
Network connection drops before client receives response
Client retries with Provider B
Both providers bill
Two completions may exist
Полностью решить эту проблему на стороне клиента нельзя, если провайдер не поддерживает ключи идемпотентности или проверку состояния запроса. Но можно уменьшить ущерб.
Используйте ключи идемпотентности, если они доступны
Создавайте стабильный ключ для каждой логической операции:
import hashlib
import json
def idempotency_key(task_name, tenant_id, input_payload):
canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode()).hexdigest()
return f"{tenant_id}:{task_name}:{digest}"
Сохраняйте операцию перед отправкой:
{
"operation_id": "acme:summarize:9d2a...",
"status": "in_flight",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"created_at": "2026-02-03T09:12:01Z"
}
Затем обновляйте её после завершения:
{
"operation_id": "acme:summarize:9d2a...",
"status": "completed",
"provider_request_id": "req_abc123",
"usage": {
"input_tokens": 4180,
"output_tokens": 612
}
}
Прежде чем повторять попытку у другого провайдера, проверьте таблицу операций. Если другой воркер уже завершил операцию, верните сохранённый результат.
Различайте типы тайм-аутов
Не все тайм-ауты одинаковы:
- Тайм-аут подключения: запрос, скорее всего, не дошёл до провайдера
- Тайм-аут чтения до получения заголовков: результат неизвестен
- Тайм-аут во время потоковой передачи токенов: провайдер почти наверняка обработал запрос
- Отмена на стороне клиента: зависит от того, была ли отмена передана дальше
В случаях с неопределённым результатом я предпочитаю осторожную политику повторных попыток:
interactive:
- retry connect timeout
- do not cross-provider retry after partial stream
- show recoverable error if status unknown
batch:
- retry after reconciliation delay
- prefer same provider with idempotency key
- fallback only after retry budget expires
Используйте потоковую передачу осторожно
Потоковая передача уменьшает воспринимаемую задержку, но усложняет повторные попытки. Если вы уже отправили пользователю часть ответа, повторная попытка с другой моделью может дать иное продолжение.
В потоковом чате я обычно считаю получение первого токена моментом, после которого незаметная повторная попытка невозможна. С этого момента сбои становятся видимыми пользователю:
"The response was interrupted. Retry?"
Это менее изящно, чем незаметное восстановление, но позволяет избежать склеивания несовместимых результатов разных моделей.
Наблюдаемость: метрики, которые действительно помогают
Одной доли успешных запросов недостаточно.
Отслеживайте как минимум:
llm_requests_total{provider,model,status}
llm_tokens_reserved_total{provider,model}
llm_tokens_used_total{provider,model}
llm_retries_total{provider,model,error_type}
llm_queue_depth{priority,task_type}
llm_queue_wait_seconds{priority,task_type}
llm_rate_limit_hits_total{provider,model,limit_type}
llm_fallbacks_total{from_provider,to_provider,reason}
llm_duplicate_suppressed_total{task_type}
Во время реальных инцидентов самой полезной панелью мониторинга часто оказывается не задержка. Это:
queue_wait_seconds p50 / p95
rate_limit_hits by provider and model
tokens_reserved vs tokens_used
fallback rate by reason
Если tokens_reserved стабильно значительно выше, чем tokens_used, вы резервируете токены с чрезмерным запасом. Если резерв слишком близок к фактическому расходу или ниже него, вы выделяете больше ресурсов, чем доступно, и неожиданно столкнётесь с лимитом TPM.
Практическая обёртка для повторных попыток
Вот компактный шаблон повторных попыток только для тех сбоев, при которых повтор допустим:
import asyncio
import random
import time
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class LLMError(Exception):
def __init__(self, status, message="", retry_after=None):
self.status = status
self.retry_after = retry_after
super().__init__(message)
def jittered_backoff(attempt, base=0.5, cap=20):
return random.uniform(0, min(cap, base * (2 ** attempt)))
async def call_with_retries(fn, max_attempts=4, max_elapsed=20):
started = time.monotonic()
for attempt in range(max_attempts):
try:
return await fn()
except LLMError as error:
elapsed = time.monotonic() - started
if error.status not in RETRYABLE_STATUS:
raise
if attempt == max_attempts - 1 or elapsed >= max_elapsed:
raise
delay = jittered_backoff(attempt)
if error.retry_after is not None:
delay = max(delay, error.retry_after)
remaining = max_elapsed - elapsed
await asyncio.sleep(min(delay, remaining))
Эта обёртка намеренно проста. Сложная логика должна быть сосредоточена в классификации, очередях, идемпотентности и маршрутизации, а не в волшебном цикле повторных попыток, который работает бесконечно.
Практические рекомендации
- Учитывайте оба вида ограничений частоты запросов: RPM и TPM. Регулирование нагрузки с учётом токенов позволяет избежать неожиданностей из-за больших промптов.
- Используйте экспоненциальную задержку со случайным разбросоми учитывайте заголовки повторных попыток, если провайдер их предоставляет.
- Разместите очередь и диспетчер между приложением и провайдерами LLM для пакетных задач или задач с большим объёмом запросов.
- Мера контроля число параллельных запросов отдельно от ограничений частоты запросов; иначе скачки задержки могут привести к слишком большому числу запросов в обработке.
- Дизайн плавное снижение функциональности осознанно: модели меньшего размера, более короткий контекст, фоновая работа через очередь или кешированные ответы.
- Рассматривайте резервное переключение между провайдерами как средство повышения надёжности, а не разрешение рассылать дублирующие запросы.
- Используйте ключи идемпотентности и записи об операциях для снижения риска двойного списания и предотвращения дублирующей генерации.
- Честно сообщайте пользователям, если потоковый ответ прервался после того, как часть токенов уже была отправлена.
- Стройте панели мониторинга на основе следующих показателей: ожидание в очереди, резервирование токенов, срабатывания ограничений частоты запросов, повторные попытки и причины резервного переключения.
- Сохраняйте логику повторных попыток простой; основная работа в промышленной эксплуатации — это распределение бюджетов, планирование и понимание того, когда повторять запрос не следует.
Один ключ API для Claude Opus 5.5, Sonnet 5, Haiku 4.5 и Fable 5.1, а также GPT-6 моделей. Оплата по мере использования, без подписки.
Получить ключ API →