Source: https://aiprimetech.io/ru/blog/rate-limits-and-retries/ 23 июня, 2026 · 7 мин · API # Ограничения частоты запросов и повторные попытки в API LLM A Автор: [AI Prime Tech](https://aiprimetech.io/ru/) · При участии ИИ Во вторник в 09:12 наш конвейер суммаризации выглядел исправным: 96 рабочих процессов работали, медианная задержка модели составляла около 2.8 секунд, доля ошибок класса 5xx не была повышена. Затем клиент загрузил накопившиеся 38,000 обращений в поддержку. За четыре минуты конвейер «упал», хотя ни один сервер не вышел из строя. Мы одновременно упёрлись в два разных ограничения: - requests_per_minute: слишком много вызовов API - tokens_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](/ru/blog/tag/api/) A [AI Prime Tech](https://aiprimetech.io/ru/) Этот блог публикует AI Prime Tech. Статьи готовятся с помощью ИИ; прежде чем полагаться на сведения, зависящие от версии, такие как идентификаторы моделей, цены и лимиты, сверяйте их с официальной документацией. Получите более дешёвый доступ к API Claude Один ключ API для Claude Opus 5.5, Sonnet 5, Haiku 4.5 и Fable 5.1, а также GPT-6 моделей. Оплата по мере использования, без подписки. [Получить ключ API →](/register) AI Prime Tech — независимый сторонний API-шлюз. Claude™ и Anthropic® — товарные знаки Anthropic, PBC. Связь с этой компанией или одобрение с её стороны не подразумеваются.