Как мы научили FastAPI и GigaChat Pro жить дружно под нагрузкой

Утро понедельника, у дилерского центра — пакетная рассылка коммерческих предложений. За ночь накопились сотни заявок, менеджер жмёт «Сгенерировать для всех» и уходит за кофе. Пока чайник закипает, наш сервис успевает вернуть уже несколько 429-х подряд, очередь падает в ретраи, а фронтенд крутит спиннер той степени тяжести, при которой пользователь начинает моргать медленнее. Дальше — предсказуемо: сообщение в Telegram «опять не работает».
Так выглядел первый честный продакшен нашего ИИ-агента для генерации КП под нагрузкой GigaChat Pro. Ниже — история о том, как мы перестали биться лбом о квоту LLM и разложили параллельность внутри FastAPI на четыре аккуратных слоя. Если вы разрабатываете новых ИИ-агентов и уже сталкивались с тем, что «оно работает на demo и валится на первой партии» — это про вас.
Откуда взялся шторм: наивный asyncio.gather
Когда задача звучит как «сгенерируй КП для трёхсот с лишним заявок», в голове разработчика первая мысль обычно одна: asyncio.gather. Async же — пусть бегут параллельно, поток не блокируем, всё прекрасно.
async def generate_for_batch(requests: list[Request]) -> list[Proposal]:
return await asyncio.gather(*[
llm.generate(req) for req in requests
])
Эта строчка прожила у нас в проде пару недель. Пока заявок было по десять-пятнадцать в час — летало. Как только клиент включил автозагрузку с маркетплейса, мы получили сотни одновременных HTTP-запросов к GigaChat Pro за считаные секунды.
LLM-провайдер на такое реагирует предсказуемо: рейт-лимит по RPS, потом 429, потом временная блокировка ключа. Внутри gather все запросы одновременно ловят одно и то же исключение, дефолтная ретрай-логика проглатывает его и отдаёт пустой результат. Менеджер видит красноречивое «сгенерировано 0».
Первый вечер я честно потратил на то, чтобы обвинить провайдера. Второй — на то, чтобы признаться самому себе: провайдер тут ни при чём.
Семафор: один регулятор на весь процесс
Первое, что мы сделали, — не «выкатили retry», а ограничили параллельность. Здравая дисциплина: даже если квота формально позволяет много RPS, нам не за чем швырять в неё всю партию одновременно. Сервер всё равно поставит запросы в очередь, только уже за своей стеной.
LLM_SEMAPHORE = asyncio.Semaphore(8)
async def generate_one(req: Request) -> Proposal:
async with LLM_SEMAPHORE:
return await llm.generate(req)
async def generate_for_batch(requests: list[Request]) -> list[Proposal]:
return await asyncio.gather(
*[generate_one(r) for r in requests],
return_exceptions=True,
)
Восемь — не магическое число. Мы подобрали его эмпирически, глядя на p95-латентность одного вызова и на то, при какой параллельности она перестаёт расти как на дрожжах. У другого клиента и на другом тарифе это может быть три или двенадцать — важен сам факт, что регулятор есть.
По ощущениям батч из трёхсот заявок из «иногда час, иногда отвал» превратился в предсказуемые несколько минут. Менеджер перестал обновлять страницу.
Важный момент — return_exceptions=True. Без него одна упавшая заявка убивает весь батч, и мы снова видим «0 из всех». С ним получаем массив, где часть элементов — Proposal, часть — исключения. Отдельным проходом превращаем их в статус «требует ручной обработки» и подсвечиваем в интерфейсе. Такой честный ответ — уже часть решения для ИИ-агентов: пользователь должен видеть, где машина сдалась.
Retry с jitter: почему синхронный backoff делает только хуже
Семафор не отменяет того, что отдельный запрос может упасть с 429 или таймаутом. Очевидный рефлекс — добавить retry. Менее очевидно, что наивный retry на параллельной нагрузке создаёт «громоподобное стадо»: восемь запросов одновременно получают 429, одновременно ждут две секунды, одновременно бьют сервер снова. Эффект прямо противоположный задуманному.
Лечится это jitter — случайной добавкой к задержке:
import random
from tenacity import (
retry, stop_after_attempt, wait_exponential_jitter,
retry_if_exception_type
)
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=1, max=30, jitter=2),
retry=retry_if_exception_type((RateLimitError, TimeoutError)),
reraise=True,
)
async def call_llm(prompt: str) -> str:
return await llm_client.complete(prompt, timeout=45)
Пять попыток с экспоненциальной задержкой и случайным джиттером — стандартная схема, но дьявол в деталях. Ретраим мы только RateLimitError и TimeoutError. Ошибки валидации, неверный токен, 400-е — не ретраим: они от повтора не починятся, а квоту сожрут.
После связки «семафор плюс tenacity с jitter» доля заявок, требующих ручной обработки, из заметной боли превратилась в фоновый шум. Стало не стыдно показывать систему заказчику.
Circuit breaker: знай, когда остановиться
Семафор и retry хорошо работают, пока деградация временная. Но раз в квартал случается ситуация, когда у провайдера лежит целый регион: ретраи бессмысленны, мы только сжигаем квоту и время менеджера. Здесь нужен circuit breaker — третий слой защиты.
Идея простая: считаем подряд идущие фейлы. Если за последнюю минуту слишком большая доля запросов упала с 5xx или таймаутом — «размыкаем» цепь и на ближайшую минуту возвращаем заглушку без попытки вызова. Потом пробуем один тестовый запрос; если он прошёл — цепь снова замкнута.
class CircuitBreaker:
def __init__(self, failure_threshold=0.3, window=60, cooldown=60):
self.failures = collections.deque()
self.successes = collections.deque()
self.opened_at: float | None = None
self.failure_threshold = failure_threshold
self.window = window
self.cooldown = cooldown
async def call(self, fn, *args, **kwargs):
now = time.monotonic()
if self.opened_at and now - self.opened_at < self.cooldown:
raise CircuitOpenError("LLM провайдер недоступен, попробуйте позже")
self._evict_old(now)
try:
result = await fn(*args, **kwargs)
self.successes.append(now)
self.opened_at = None
return result
except (RateLimitError, TimeoutError, ProviderError):
self.failures.append(now)
if self._failure_rate() > self.failure_threshold:
self.opened_at = now
raise
За полгода эта штука спасала нам вечер уже дважды. Провайдер выкатывал кривой релиз — мы за полторы минуты автоматически переключали трафик на резервную модель и не тратили часы на разбор «почему всё медленно». Заказчик даже не успевал написать.
Если у вас в проде уже крутится сеть ИИ-агентов, а не один сервис, — breaker становится тем более обязательным. Один упавший провайдер без него потянет за собой соседние агентские цепочки, потому что все они на одном ключе.
Очередь как четвёртый слой
Семафор живёт внутри одного процесса. Если за nginx у нас два FastAPI-воркера — суммарная параллельность уже шестнадцать, а не восемь. Регулятор из процесса переезжает наружу: в пул задач в PostgreSQL.
Менеджер нажал «Сгенерировать для всех» — мы не запускаем сотни корутин в HTTP-обработчике. Мы кладём задачи в таблицу llm_jobs со статусом pending и сразу отвечаем «принято». Фоновый воркер выбирает их через SELECT ... FOR UPDATE SKIP LOCKED, обрабатывает с теми же семафорами и retry, складывает результат обратно в БД. Фронт подписан на серверные события и обновляет прогресс-бар.
SELECT id, payload
FROM llm_jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 8;
Эта схема даёт два бонуса. Первый — она переживает рестарт сервиса: незавершённые задачи остаются pending и подхватываются следующим воркером. Второй — легко выставить per-tenant лимит: один клиент — максимум столько-то одновременных задач, другой — больше, и при шторме одного заказчика не страдают остальные.
Заодно этот же механизм — базовый строительный блок, если поверх пары LLM-эндпоинтов вы собираетесь собирать сеть ИИ-агентов: планировщик, исполнители, инструменты. Всё это в конечном счёте — задачи с состоянием, а не «дёрнули HTTP и забыли».
Сколько это стоит по времени и деньгам
Часто спрашивают: сколько стоит ИИ-агент такого рода — не в смысле подписки на модель, а в смысле, сколько инженерного времени займёт «сделать по-нормальному». Честно: четыре слоя, о которых шла речь, — это пара вечеров работы одного бэкенд-разработчика, если он уже знаком с asyncio и tenacity. Первый вечер уходит на семафор и retry, второй — на breaker и перенос батча в очередь.
Дальше — эксплуатация. Расходы на сам GigaChat Pro после внедрения семафора и breaker'а обычно заметно проседают: перестают гореть впустую ретраи в шторм, исчезают бесполезные вызовы в момент, когда провайдер и так лежит. Точных процентов не назову — у каждого клиента свой профиль нагрузки, — но счёт за модель после наведения порядка становится ощутимо ниже и, что важнее, предсказуемым.
Разговоры с заказчиком про «опять не работает» стоят несопоставимо дороже, чем эти два вечера. Это, пожалуй, главный аргумент в пользу того, чтобы браться за регуляторы нагрузки до того, как первый крупный клиент включит массовую загрузку.
Что в итоге
Резюмировать хочется без табличек с процентами, потому что честнее так.
- Батчи, которые раньше «то отрабатывали, то нет», стали заканчиваться за предсказуемое время. Менеджер снова успевает попить кофе, но уже не нервно.
- 429-е от провайдера из ежедневной сводки инцидентов превратились в редкие всплески, которые корректно ретраятся сами.
- Простой при инцидентах у провайдера сократился с «разбираемся часами» до «переключились автоматически, потом посмотрели логи».
- Ручного разбора «почему опять ноль сгенерировано» практически не осталось.
Главный вывод, ради которого вся эта история. Когда команда говорит «у нас LLM тормозит» — в большинстве случаев проблема не в модели и не в провайдере. Проблема в том, что между FastAPI и LLM-эндпоинтом нет ни одного регулятора нагрузки: ни семафора, ни умного retry, ни breaker'а, ни очереди. Запросы летят как из шланга, провайдер бьёт по рукам, разработчик разводит руками.
Четыре слоя — семафор, retry с jitter, circuit breaker, очередь — это не архитектурная революция, а базовая гигиена. Если вы прямо сейчас проектируете новых ИИ-агентов или уже эксплуатируете зрелое решение на ИИ-агентах в проде, посмотрите, что регулирует параллельность в ваших LLM-вызовах. Если ответ — «честный asyncio.gather», знайте: ближайший шторм на квоте уже едет к вам, просто он ещё не в курсе вашего расписания.
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




