Hedged requests для LLM API: как снизить p99, не удвоив счёт

Hedged request — это запасной запрос к тому же LLM-провайдеру, который стартует с небольшой задержкой и выигрывает гонку только тогда, когда основной запрос застрял в длинном хвосте latency. Техника может заметно снизить p99, но без ограничений легко превращает проблему задержек в проблему двойного счёта.

Hedged requests для LLM API: как снизить p99, не удвоив счёт

Почему один медленный запрос портит весь сервис

Средняя latency у LLM API часто выглядит нормально, пока вы не смотрите на p95 и p99. Небольшая доля запросов попадает в очередь провайдера, на перегруженный инстанс или в медленный сетевой маршрут. Для пользователя это не «один процент статистики», а зависший чат, сорванный SLA или агент, который не успел завершить цепочку.

Обычный retry здесь помогает плохо: он стартует только после timeout. Если timeout равен 20 секундам, пользователь уже потерял 20 секунд. Hedged request запускает запасную попытку раньше — например, после 1,8 секунды — и оставляет результат той попытки, которая первой завершилась успешно.

Когда hedge действительно полезен

Техника подходит для независимых операций чтения: генерации текста, классификации, embeddings и других запросов, которые можно безопасно повторить. Особенно хорошо она работает, когда распределение latency имеет длинный хвост, а большая часть запросов отвечает быстро.

Не стоит автоматически хеджировать:

  • tool calls с записью в CRM, оплатой или отправкой сообщений;
  • запросы, где повтор может вызвать побочный эффект;
  • очень дорогие промпты без жёсткого лимита дополнительного бюджета;
  • потоковые ответы после того, как пользователю уже показаны первые токены.

Для операций с записью сначала нужна идемпотентность: единый idempotency key, дедупликация на сервере и понятное поведение при отмене клиента.

Базовая реализация на Python

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

import asyncio from collections.abc import Awaitable, Callable async def first_success(tasks, timeout: float): pending = set(tasks) errors = [] loop = asyncio.get_running_loop() deadline = loop.time() + timeout while pending: remaining = deadline - loop.time() if remaining <= 0: break done, pending = await asyncio.wait( pending, timeout=remaining, return_when=asyncio.FIRST_COMPLETED, ) for task in done: try: result = task.result() for loser in pending: loser.cancel() return result except Exception as exc: errors.append(exc) for task in pending: task.cancel() if errors: raise errors[-1] raise TimeoutError("LLM request deadline exceeded") async def hedged_call( send: Callable[[], Awaitable[str]], hedge_after: float = 1.8, total_deadline: float = 8.0, ) -> str: loop = asyncio.get_running_loop() started_at = loop.time() primary = asyncio.create_task(send()) try: return await asyncio.wait_for( asyncio.shield(primary), timeout=hedge_after, ) except asyncio.TimeoutError: pass hedge = asyncio.create_task(send()) remaining = total_deadline - (loop.time() - started_at) return await first_success( [primary, hedge], timeout=max(0.0, remaining), )

В production к этому коду нужно добавить request_id, трассировку, корректное закрытие HTTP-ответов и учёт того, что отмена клиентской coroutine не всегда останавливает вычисления и биллинг у провайдера.

Как выбрать задержку, а не угадать её

Фиксированные «две секунды» быстро устаревают. Лучше считать hedge_after по свежей истории конкретного маршрута: модель + регион + размер промпта + режим streaming. Практичная стартовая точка — p90 или p95 latency за скользящее окно.

Слишком ранний hedge уменьшит задержку, но почти удвоит число запросов. Слишком поздний будет похож на обычный retry и почти не повлияет на p99. Я бы начал с консервативного порога, включил технику на 1–5% трафика и менял задержку только после накопления данных.

Как не удвоить счёт

Главное ограничение — не более одного hedge на пользовательский запрос. Затем нужен отдельный бюджет:

  • глобальный лимит hedge-запросов в минуту;
  • лимит по пользователю или tenant;
  • запрет для промптов выше заданного числа токенов;
  • автоматическое отключение, если доля hedges или стоимость выходят за порог;
  • отдельный semaphore, чтобы запасные запросы не вытеснили основной трафик.

Полезный сигнал — hedge win rate. Если запасная попытка побеждает в 0,2% случаев, а запускается для 15% запросов, вы платите слишком много. Если она регулярно выигрывает и заметно срезает p99, порог выбран разумно — либо у провайдера есть проблема, которую уже стоит обсуждать отдельно.

Какие метрики собирать

  • p50, p95 и p99 до и после включения;
  • hedge_started_rate и hedge_won_rate;
  • дополнительные input/output tokens;
  • время отмены проигравшего запроса;
  • доля запросов, где обе попытки завершились ошибкой;
  • стоимость одного «спасённого» медленного запроса.

Важно сравнивать не только latency. Если p99 снизился на 30%, а расходы выросли на 40%, решение может быть хуже исходной проблемы. Для разных сценариев ценность секунды разная: интерактивный чат и ночная batch-обработка не должны иметь одну политику.

Безопасный план внедрения

  1. Сначала измерить распределение latency по модели и размеру запроса.
  2. Разрешить hedge только для идемпотентных операций.
  3. Запустить на 1% трафика с одним дополнительным запросом.
  4. Поставить cost cap, semaphore и kill switch.
  5. Сравнить p99, ошибки и фактическую стоимость.
  6. Расширять rollout только при устойчивом выигрыше.

Hedged requests — не замена timeout, retry и circuit breaker. Это ещё один уровень защиты именно от длинного хвоста задержек. Он полезен, когда запускается редко, измеряется отдельно и имеет собственный бюджет.

Используете hedged requests в production? Интересно, какой порог запуска оказался у вас рабочим: фиксированный, p95 по истории или динамический по модели и размеру промпта?