Перейти к содержимому
27 июл. 2024 г.·7 мин чтения

Повторные запросы к LLM: backoff, circuit breaker, очереди

Повторные запросы к LLM требуют правил: без них таймауты, 429 и скачки задержки быстро ломают сервис. Разберем backoff, circuit breaker и очереди.

Повторные запросы к LLM: backoff, circuit breaker, очереди

Почему простые ретраи быстро выходят из-под контроля

На тестовом стенде простой retry выглядит безобидно: запрос упал, клиент подождал секунду и отправил его еще раз. В проде так почти не бывает. Один и тот же сбой сразу видят сотни клиентов, воркеров или вкладок браузера. Если модель или провайдер начинают отвечать медленнее, все повторяют вызов почти одновременно.

Нагрузка растет в самый неудачный момент. Допустим, чат поддержки отправляет 100 запросов в секунду, а таймаут у клиента стоит 15 секунд. Провайдер внезапно начинает отвечать за 25 секунд. Клиент считает запрос неудачным и запускает повтор через 2 секунды. Но первый запрос часто не исчезает: модель все еще считает ответ. В итоге у вас уже не 100 активных вызовов, а 200, потом 300. Так повторные запросы к LLM сами разгоняют аварию.

Здесь важно различать таймаут, ошибку 429 и просто длинный ответ. Таймаут значит только одно: клиент перестал ждать. Сервер в этот момент может продолжать работу. Ошибка 429 говорит, что лимит уже уперся в потолок, и немедленный повтор обычно делает хуже. А длинный ответ без ошибки вообще не просит нового вызова. Он просит другой порог ожидания или очередь.

Если вслепую повторять один и тот же вызов, продукт быстро теряет устойчивость. Пользователь видит крутилку дольше обычного и жмет кнопку еще раз. Бэкенд отправляет тот же промпт повторно. В истории чата появляются дубли. Оператор поддержки получает два похожих ответа. Если LLM еще и вызывает инструменты, можно дважды создать тикет, повторно записать комментарий или лишний раз дернуть CRM.

Даже когда побочных действий нет, слепой retry портит метрики. Средняя задержка растет, хвост задержек растет еще сильнее, очередь забивается, а число 429 увеличивается. Команда видит странную картину: часть запросов успешна, но только после двух-трех попыток, а корень проблемы прячется под слоем автоповторов.

Через общий LLM-шлюз это заметно еще сильнее. Один медленный провайдер легко тянет за собой сразу несколько сервисов, если все они используют одинаковые правила ретраев. Поэтому правило "повторим до трех раз" само по себе почти не помогает. Без пауз, ограничений и контроля такой цикл быстро превращает локальный сбой в общий.

Как backoff гасит всплеск ошибок

Backoff нужен, когда ошибка временная, а не постоянная. Его задача проста: не бить в API второй, третий и четвертый раз подряд, если сервис уже показал, что ему плохо. Для повторных запросов к LLM это часто лучше, чем мгновенный ретрай, который только раздувает очередь и число ошибок.

Есть три базовых подхода. Fixed backoff держит одну и ту же паузу, например 500 мс. Настроить его легко, но при большом потоке клиенты начинают повторять запросы почти в один момент. Exponential backoff растит паузу на каждой попытке: 500 мс, 1 с, 2 с, 4 с. Давление на провайдера падает заметно быстрее. Самый практичный вариант - backoff с jitter, когда к растущей паузе добавляют случайный разброс. Так повторные попытки расходятся по времени и не создают эффект толпы.

При ошибке 429 backoff особенно полезен. Представьте 100 воркеров, которые одновременно получили rate limit. Если все повторят запрос через ровно секунду, новый пик придет через ту же секунду. Если каждый подождет случайное время в диапазоне, скажем, от 800 до 1600 мс, нагрузка растянется, и часть запросов пройдет без нового удара по лимиту.

Для плавающей задержки backoff тоже полезен. Провайдер может отвечать не за 2 секунды, а за 8-10 секунд в момент перегруза. Мгновенные повторы в такой ситуации только забирают еще больше соединений, потоков и денег. Короткая пауза часто дешевле, чем новый запрос прямо сейчас.

Но backoff не лечит зависший провайдер. Если endpoint уходит в таймаут на 30-60 секунд или стабильно ломает все запросы, ожидание между попытками не решает проблему. Вы просто дольше держите пользователя и копите хвост из зависших задач. Тут нужен не еще один backoff, а остановка трафика на этот маршрут и переключение на другой.

Для интерактивных сценариев обычно хватает 2-3 повторных попыток для 429 и коротких сетевых сбоев. Общий бюджет времени на все попытки лучше держать в пределах 8-15 секунд. Для фоновых задач лимит можно делать длиннее. Jitter почти всегда обязателен: именно он не дает всем клиентам ретраить синхронно.

Если вы ходите в модели через общий шлюз, логика не меняется. Backoff должен снижать давление после временных сбоев, а не маскировать плохой маршрут. Правило простое: если ошибка похожа на перегрузку, подождите; если провайдер завис, перестаньте стучаться в ту же дверь.

Где circuit breaker спасает сервис

Когда внешний LLM API начинает отвечать по 20-30 секунд или сыпать таймаутами, проблема быстро уходит внутрь вашего сервиса. Воркеры висят в ожидании, новые запросы копятся, пул соединений забивается, а обычные ретраи только добавляют нагрузку.

Circuit breaker обрывает этот цикл раньше. Он следит за серией сбоев и, если видит, что внешний сервис явно не в форме, временно перестает слать туда новые вызовы. Вместо долгого ожидания приложение сразу дает быстрый и понятный результат: отказ, запасной маршрут или отправку задачи в очередь.

У breaker три состояния. В режиме closed все работает как обычно, запросы идут наружу. В режиме open цепь разомкнута, новые вызовы сразу отклоняются. В режиме half-open после короткой паузы проходят несколько пробных запросов. Если внешний API снова отвечает стабильно, breaker закрывается. Если нет, он снова открывается.

Представьте чат поддержки в час пик. За минуту пришло 400 запросов, а провайдер LLM внезапно зависает на 25 секунд. Без breaker каждый воркер ждет до упора, потом часть запросов еще и повторяется. Через пару минут тормозит уже не только интеграция с моделью, но и все рядом.

С breaker после 5-10 неудачных вызовов поток режется раньше. Следующие запросы получают отказ за миллисекунды, а не после длинного таймаута. Для части трафика это неприятно, но намного лучше, чем положить весь сервис.

Быстрое отклонение бережет воркеры, треды, память и HTTP-соединения. Оно еще и оставляет ресурсы для того, что должно работать даже в момент сбоя: авторизация, запись событий, маршрутизация на другой провайдер, ответ клиенту без зависания.

Breaker не заменяет backoff. Backoff нужен, когда сбой короткий и повтор через 1-2 секунды еще имеет смысл. Breaker нужен, когда уже видно, что серия ошибок не случайна и новые попытки только душат систему. Обычно их ставят вместе: backoff для редких сбоев, circuit breaker для плохой полосы, а очередь забирает остаток нагрузки.

Когда очередь лучше немедленного повтора

Очередь полезна, когда задержка у модели скачет: сейчас ответ приходит за 2 секунды, через минуту - за 20. В такой момент повторные запросы к LLM часто вредят сильнее, чем помогают. Первый запрос еще висит, второй уже ушел, затем приходит третий, и сервис сам создает себе перегрузку.

Очередь упрощает поведение системы. Она ставит задачи в буфер и отдает их воркерам с фиксированным лимитом, поэтому всплеск не превращается в шторм соединений. Если провайдер или модель временно тормозят, система замедляется предсказуемо, а не срывается в хаос.

Это особенно заметно при плавающей задержке. Допустим, у вас 20 воркеров. Значит, одновременно в LLM API уйдет максимум 20 запросов, а не 500 из-за паники в ретраях. Да, часть задач подождет в очереди, но нагрузка останется под контролем.

В очередь лучше отправлять то, чему не нужен ответ в ту же секунду: суммаризацию длинных диалогов после завершения сессии, извлечение полей из документов, генерацию эмбеддингов и тегов, массовую оценку ответов модели, повторную обработку batch-задач. Сразу отвечать лучше там, где человек ждет живой отклик: чат, голосовой бот, автодополнение, подсказка оператору во время разговора. Для таких сценариев обычно ставят жесткий дедлайн, например 2-3 секунды. Если модель не уложилась, сервис отдает короткий fallback-ответ, а полную обработку уже отправляет в очередь.

У очереди есть три настройки, без которых схема быстро ломается. Первая - лимит параллельности. Его выбирают не по мощности вашего сервиса, а по тому, сколько одновременных запросов реально выдерживает провайдер без 429 и резкого роста задержки. Вторая - срок жизни задачи. Если ответ нужен в течение минуты, нет смысла держать задачу 40 минут. Просроченный запрос лучше отменить и пометить как неактуальный. Третья - повтор из очереди. После таймаута или 429 воркер не должен сразу бить в API еще раз. Он возвращает задачу в очередь с паузой и счетчиком попыток.

Цена у решения тоже есть. Пользователь иногда ждет дольше. Отлаживать цепочку труднее, потому что путь растягивается: API, очередь, воркер, запись результата, проверка статуса. Идемпотентность нужна обязательно. Если воркер получил ответ от модели, но упал до сохранения результата, та же задача может выполниться снова. Без стабильного task_id это кончится дублями, лишними списаниями и путаницей в логах.

Очередь хороша там, где можно обменять мгновенный ответ на ровную нагрузку и понятное поведение системы.

Как собрать схему по шагам

Держите запасной маршрут
Подключите несколько провайдеров через один эндпоинт, чтобы не зависеть от одного сбоя.

Собирать схему лучше не с ретраев, а с карты ошибок. Разделите ответы и сбои хотя бы на четыре группы: 429, таймауты, 5xx и сетевые обрывы. У каждой группы своя причина, и одинаковая реакция только добавляет шум.

Дальше задайте бюджет времени для всего запроса. Если чат должен ответить за 8 секунд, нельзя тратить 7 из них на три бездумных повтора. Полезно заранее решить, сколько попыток вы даете каждому типу ошибки, а когда прекращаете ждать и возвращаете запасной вариант.

Для 429 и коротких сетевых сбоев обычно хватает backoff с jitter. Это снижает риск, что все воркеры ударят в провайдера снова в одну и ту же миллисекунду. Для 429 пауза должна расти, а для случайного обрыва сети часто хватает одного быстрого повтора.

С таймаутами и волной 5xx лучше быть строже. Если модель уже несколько раз подряд не уложилась в лимит или провайдер начал сыпать ошибками, откройте circuit breaker на короткий срок. Так сервис перестанет жечь время и сокеты на заведомо плохой маршрут и либо переключится на другой, либо честно скажет, что ответ задержится.

Практический шаблон простой. Для 429 дайте 2 повтора с экспоненциальной паузой и случайным разбросом. Для сетевого сбоя оставьте 1 быстрый повтор, но только если запрос идемпотентен. Для 5xx часто достаточно 1 повтора, а потом смены маршрута или отказа. Длинные таймауты лучше отдавать на breaker после серии неудач, например 3-5 подряд. Если ответ тянется дольше нескольких секунд, а пользователь может подождать, отправляйте задачу в очередь.

Очередь нужна не везде. Для живого чата она раздражает, если туда уходит каждый второй запрос. Но для суммаризации диалогов, классификации обращений или генерации длинного отчета очередь часто лучше немедленного повтора. Пользователь получает статус, система не паникует, а нагрузка выравнивается.

Если у вас один OpenAI-совместимый endpoint для разных моделей и провайдеров, такие правила удобно держать в одном месте. Например, через RU LLM можно централизованно применять маршрутизацию и правила отказоустойчивости, не переписывая клиентский код. Тогда повторные запросы к LLM перестают быть набором случайных костылей: короткие сбои лечит backoff, долгие отказы режет breaker, а терпеливые задачи уходят в очередь.

Пример для чата поддержки в час пик

В 18:40 в чат поддержки интернет-магазина приходит в 3 раза больше обращений, чем обычно. Оператор открывает диалог, а LLM за 1-2 секунды пишет черновик ответа: где заказ, как оформить возврат, что сказать клиенту без лишней сухости. Пока провайдер держит нормальную задержку, схема работает почти идеально.

Проблема начинается в пик. В 18:47 задержка у провайдера растет до 8-10 секунд. Часть запросов ловит таймауты LLM API, часть получает ошибку 429. Оператор жмет кнопку еще раз, бэкенд тоже делает ретрай, и один черновик превращается уже в 3-4 вызова. Если в смене 70 операторов, нагрузка раздувается очень быстро. Именно так повторные запросы к LLM сами усиливают сбой.

Возьмем один поток. Оператор Анна отвечает клиенту по возврату товара, запрос уходит через общий OpenAI-совместимый шлюз. Дальше все зависит от схемы. Backoff ждет 300 мс, потом 1 секунду, потом 3 секунды. При коротком всплеске это работает неплохо: провайдер успевает выдохнуть, и Анна все же получает черновик. Но если задержка держится несколько минут, backoff только растягивает ожидание. Circuit breaker после нескольких таймаутов закрывает проблемный маршрут на 30-60 секунд. Сервис не тратит соединения на бесполезные попытки и может сразу отдать запасной сценарий: более простую модель или сообщение "черновик временно недоступен". Очередь запросов не спорит с провайдером на пике. Она просто ограничивает параллельность, например до 20 вызовов, и ставит остальные задачи в линию. Анна ждет чуть дольше, зато система не захлебывается.

По отдельности у каждого подхода есть слабое место. Backoff не спасает от долгой деградации, breaker не любит одиночные случайные сбои, а очередь сама по себе не лечит плохой маршрут.

Поэтому команды обычно комбинируют все три механизма. Очередь сглаживает пик, backoff помогает пережить короткие 429, а breaker быстро останавливает бессмысленные повторы. Если трафик идет через RU LLM, можно открыть breaker только для одного провайдера и увести запросы на другой маршрут без смены SDK, кода и промптов. Для чата поддержки это часто лучший компромисс: операторы получают черновики чуть позже, но сервис не падает в самый неудобный момент.

Какие ошибки чаще всего делают команды

Соберите LLM потоки вместе
Держите модели, провайдеров и биллинг в одном месте.

Самая дорогая ошибка проста: команда включает повторные запросы к LLM и считает, что этого уже достаточно. На практике без паузы с jitter все инстансы начинают ретраить почти одновременно. Один всплеск 429 или короткий сбой у провайдера быстро превращается в новую волну запросов.

Похожая история с таймаутами. Часто для всех операций ставят одно число, например 30 секунд, и на этом успокаиваются. Но короткая проверка классификации и длинная генерация ответа живут по разным правилам. Если таймаут слишком короткий, команда получает лишние повторы и пустую трату токенов. Если слишком длинный, воркеры висят, очередь растет, а пользователи ждут без причины.

Еще одна частая ошибка - открытая очередь без потолка по длине и сроку жизни задач. Сначала это выглядит безопасно: пусть запросы лучше подождут, чем упадут. Потом наступает час пик, в очереди копятся тысячи задач, часть из них уже никому не нужна, но система все равно честно пытается их догнать. Для чата поддержки это особенно плохо: ответ через 4 минуты часто уже бесполезен.

С идемпотентностью команды тоже промахиваются регулярно. Если повторный запрос может заново отправить письмо, создать тикет или списать бонусы, простой ретрай становится источником дублей. Модель при этом может отработать нормально, а вот бизнес-логика сломается. Поэтому у каждого запроса должен быть понятный idempotency key или другой способ понять, что действие уже выполнилось.

Отдельно мешает слепая работа без метрик. Если никто не смотрит на долю 429, реальную задержку по перцентилям и уровень отказов, настройки ретраев делают почти наугад. Команда видит только среднее время ответа и не замечает, что хвост задержки уже уехал вдвое.

Если запросы идут через общий OpenAI-совместимый шлюз, эти ошибки никуда не исчезают. Меняется только точка входа, а не физика системы.

Что проверить перед запуском

Без смены клиентского кода
Замените только base_url и продолжайте работать с привычными SDK.

Повторные запросы к LLM дают пользу только тогда, когда вы заранее решили, на какие сбои сервис отвечает повтором, а на какие нет. Если включить один общий ретрай на все подряд, 429 станет чаще, очередь вырастет, а пользователи будут ждать дольше.

Сначала разделите ошибки по типам. Для 429 нужен свой сценарий: уважать Retry-After, если он есть, и ставить более длинный backoff с jitter. Для 5xx обычно хватает малого числа попыток с короткой паузой. Для сетевых ошибок и обрывов соединения часто уместен один быстрый повтор, но только если запрос безопасно повторять. Таймаут тоже не всегда означает сбой у провайдера: иногда модель еще отвечает, просто ваш лимит слишком жесткий.

Смотрите не только на число попыток, но и на общий бюджет времени. Пользователь не чувствует, сколько раз вы сходили в API. Он чувствует, сколько секунд ждал. Для чата обычно разумно задать жесткий дедлайн на весь запрос, а не на каждую попытку отдельно. Если у вас осталось 400 мс до конца бюджета, новый ретрай почти всегда хуже, чем быстрый отказ или постановка задачи в очередь.

Перед запуском у команды должны быть на одной панели длина очереди, доля таймаутов по провайдеру и по модели, частота открытия circuit breaker, число ответов, которые удалось спасти повтором, и количество дублей побочных действий. Без этого настройка превращается в гадание.

Отдельно проверьте идемпотентность. Повтор не должен списать оплату второй раз, создать две записи в CRM или отправить одно и то же письмо. Для этого обычно хватает idempotency key, явного request_id и простого правила дедупликации на стороне приложения. Если запрос меняет состояние, сначала решите вопрос с дублями, потом включайте ретраи.

План отката нужен до релиза, а не после первой аварии. Оставьте старый режим под флагом, включайте новый по доле трафика и заранее договоритесь, что считать ухудшением: рост очереди, всплеск 429, больше пустых ответов или просадку по времени. Если новый режим ломает качество, откат должен занимать минуты.

Какие следующие шаги выбрать в проде

Стабилизировать повторные запросы к LLM лучше на одном живом потоке, а не на всей системе сразу. Возьмите самую болезненную цепочку, например генерацию ответа в чате поддержки или разбор входящих писем. На ней за 7 дней соберите базу: долю 429, частоту таймаутов, p95 и p99 задержки, среднее число ретраев на запрос и процент сессий, где пользователь ушел, не дождавшись ответа.

Без этой базы настройка быстро превращается в гадание. Команда видит отдельные сбои, но не понимает, что на самом деле бьет по сервису сильнее всего: короткие всплески 429, длинные зависания у провайдера или слишком агрессивные повторы на своей стороне.

Потом разложите ошибки по месту, где они возникают. Если провайдер иногда отвечает чуть позже обычного, часто хватает локального retry с коротким backoff и jitter. Если внешний API начинает сыпать 429 или виснет сериями, ставьте circuit breaker, чтобы не тратить соединения впустую и не забивать свою же систему. Если задача не требует ответа прямо сейчас, отправьте ее в очередь вместо трех быстрых повторов подряд.

Еще один полезный шаг - собрать в одном месте маршрутизацию по моделям и провайдерам, таймауты для каждого типа запроса, правила retry, breaker и очереди, а также единые логи с причиной отказа и числом попыток. Так команда быстрее видит, где ломается цепочка. Спор о том, виновата сеть, провайдер или ваша логика повторов, заканчивается по логам, а не по ощущениям.

Если у вас уже есть код под OpenAI-совместимый API и нужен единый endpoint в РФ, не обязательно менять весь стек. В RU LLM можно просто заменить base_url на api.rullm.com и оставить SDK, код и промпты без изменений. Это удобный вариант, когда вы хотите держать маршрутизацию, биллинг и логи в одном месте.

Для первого продового варианта хватит простого контракта на каждый класс запросов. Для чата поддержки это может выглядеть так: один локальный retry, breaker на серию 429, очередь для несрочной постобработки и жесткий предел по общему времени ответа. Через месяц смотрите не только на число ошибок, но и на цену одной успешной операции. Этот показатель быстро показывает, стала схема лучше или просто сложнее.

Часто задаваемые вопросы

Почему мгновенный retry часто делает только хуже?

Потому что первый запрос часто еще выполняется, даже если клиент уже сдался по таймауту. Если сразу отправить тот же промпт снова, вы сами поднимете нагрузку в момент сбоя и быстрее упретесь в 429, длинные очереди и дубли ответов.

Таймаут означает, что запрос точно не выполнился?

Нет. Таймаут значит только одно: ваш клиент перестал ждать ответ. Провайдер в этот момент может все еще считать результат, поэтому новый вызов легко создает дубль и лишний расход токенов.

В каких случаях backoff действительно нужен?

Используйте backoff, когда сбой похож на временный: 429, короткий сетевой обрыв, редкий 5xx. Пауза между попытками дает провайдеру время прийти в норму и не раздувает очередь так сильно, как мгновенный повтор.

Зачем добавлять jitter к backoff?

Случайный разброс разводит повторы по времени. Без jitter сотни клиентов часто повторяют запрос почти в одну секунду и сами создают новый пик нагрузки.

Сколько повторов обычно ставят для 429, 5xx и сетевых ошибок?

Для чата обычно хватает 2 повторов при 429 и 1 быстрого повтора при коротком сетевом сбое, если запрос безопасно повторять. Для 5xx чаще всего тоже достаточно 1 попытки, а общий бюджет на все ожидание лучше держать примерно в пределах 8–15 секунд.

Когда circuit breaker полезнее обычных ретраев?

Открывайте breaker, когда провайдер сериями уходит в таймаут, долго висит или стабильно сыпет ошибками. В такой момент новый retry редко спасает запрос, зато быстро съедает воркеры, соединения и время пользователя.

Когда очередь лучше немедленного повтора?

Отправляйте задачу в очередь, если ответ не нужен в ту же секунду. Суммаризация, эмбеддинги, разбор документов и batch-обработка обычно лучше живут в буфере с лимитом параллельности, чем в цепочке быстрых повторов.

Как защититься от дублей при повторах?

Держите idempotency key или request_id на каждый запрос, который меняет состояние. Тогда повтор не создаст второй тикет, не отправит письмо еще раз и не запишет один и тот же результат дважды.

Какие метрики стоит смотреть после запуска?

Смотрите не только на среднюю задержку. Нужны p95 и p99, доля 429, частота таймаутов по провайдеру и модели, длина очереди, число открытий breaker и процент запросов, которые вытащил retry.

Можно внедрить такую схему без переписывания клиентов?

Да, если у вас уже есть код под OpenAI-совместимый API. В RU LLM можно заменить base_url на api.rullm.com и дальше держать маршрутизацию, правила retry и отказоустойчивость в одном месте без переделки SDK, кода и промптов.