Перейти к содержимому
5 мин чтения

Возможна ли миграция активного запроса между GPU-репликами?

Миграция активного запроса при сбое GPU: когда повторить prefill, почему нельзя продолжать decode и как задать контракт streaming-ошибки.

Возможна ли миграция активного запроса между GPU-репликами?

Активную генерацию нельзя честно перенести на другую GPU-реплику только потому, что у шлюза есть тот же prompt и та же модель. После начала decode у исходной реплики появляется частное состояние запроса: KV cache по уже обработанному контексту, позиция в последовательности, состояние сэмплера и, в некоторых конфигурациях, состояние speculative decoding. Если эта реплика исчезла, новая реплика не знает, какой следующий токен должен был появиться.

Миграция активного запроса поэтому должна означать не один механизм, а два разных режима: повторить вычисление до отправки первого токена или корректно оборвать уже начатый поток. Попытка скрыть эту границу автоматическим retry обычно создает худший сбой: пользователь получает повторенный фрагмент, обрывок JSON или два разных ответа на одно действие.

Decode нельзя продолжить без состояния конкретного запроса

После prefill движок кладет для каждого слоя attention ключи и значения в KV cache. Decode использует этот cache, чтобы вычислять следующий токен, не прогоняя весь контекст заново. Это не общий снимок модели, который можно восстановить по request_id. Это изменяемое состояние конкретной последовательности в памяти конкретного воркера.

Для настоящего продолжения новая реплика должна получить согласованный снимок как минимум следующих данных:

  • KV cache всех уже принятых и сгенерированных токенов;
  • точную позицию и маски attention;
  • параметры сэмплера и состояние генератора случайных чисел;
  • идентификаторы весов, токенизатора, шаблона чата и подключенных адаптеров;
  • состояние draft-модели, если работает speculative decoding.

Потеря хотя бы одной части превращает «продолжить» в новый запуск. Иногда новый запуск внешне выглядит похожим, особенно при temperature=0 и коротком ответе. Это не гарантия. Параллельное выполнение, разные ядра, квантование, разные ревизии движка и маршрутизация MoE способны изменить выбор токена даже там, где команда ожидает детерминизм.

Документация vLLM описывает automatic prefix caching как повторное использование KV cache для новых запросов с совпадающим префиксом. Это полезно для повторного prefill, но не равно переносу живой последовательности между воркерами. В документации и RFC SGLang отдельно рассматриваются удаленное и иерархическое хранение KV cache, потому что локальный cache одного воркера сам по себе недоступен остальному пулу.

Не называйте повторную генерацию «продолжением» в API, логах и пользовательском интерфейсе. Это размывает контракт именно там, где потом придется разбирать спорный платеж, вызов инструмента или неполный структурированный ответ.

Повторный prefill безопасен только до первой отправки клиенту

Шлюз может повторить prefill на другой реплике, если он знает две вещи: исходный воркер больше не выполнит запрос и клиент еще не получил ни одного фрагмента результата. В этот момент повтор меняет задержку, но не меняет наблюдаемую семантику запроса.

Граница должна проходить по факту доставки, а не по внутреннему факту генерации. Воркер мог уже вычислить десять токенов, но шлюз еще не записал первый SSE event в сокет клиента. Тогда повтор допустим. И наоборот, один отправленный delta уже делает автоматический retry опасным, даже если воркер упал сразу после него.

Полезно хранить фазу отдельно от HTTP-статуса:

{
  "request_id": "req_01J...",
  "phase": "prefill",
  "attempt": 1,
  "first_delta_sent": false,
  "model_revision": "model-x@sha256:...",
  "retry_budget": 1
}

После отправки первого фрагмента шлюз фиксирует переход:

{
  "request_id": "req_01J...",
  "phase": "decode",
  "attempt": 1,
  "first_delta_sent": true,
  "delivered_output_tokens": 37
}

Этот журнал не нужен для красивой observability-панели. Он решает конкретный вопрос во время аварии: имеет ли роутер право отправить тот же вход на другую реплику. Если first_delta_sent=false, право есть. Если true, шлюз должен закончить поток ошибкой, если только у него нет проверенного механизма передачи полного состояния запроса.

Нельзя подменять этот признак status=200. В streaming HTTP сервер может отправить заголовки 200, а затем упасть до первого содержательного chunk. Он может также записать первый chunk в буфер прокси, но клиент его не увидит из-за разрыва дальше по цепочке. Для строгой гарантии «клиент точно ничего не получил» одной телеметрии роутера недостаточно. В большинстве систем достаточно более консервативного правила: после первой записи в downstream-соединение retry запрещен.

Причину сбоя нужно отличать от медленного ответа

Не каждый пропавший heartbeat означает, что GPU-реплика потеряна. Автоматический failover по короткому таймауту часто создает две активные попытки: старая реплика выходит из паузы и продолжает работу, а новая уже повторяет запрос. При неидемпотентных tool calls это уже не проблема текста, а двойное бизнес-действие.

Роутер должен различать минимум четыре состояния.

  1. Реплика явно умерла. Процесс завершился, оркестратор сообщил о потере pod, соединение с upstream закрыто. До первого токена запрос можно повторить.
  2. Сеть между шлюзом и репликой оборвалась. Реплика может продолжать decode. Повторять запрос можно только после отмены с подтверждением или после истечения жесткого lease, когда исходная попытка уже не имеет права публиковать результат.
  3. Реплика перегружена. Длинный prefill, очередь или сборка больших batch не равны отказу. Перенос по мягкому latency-таймауту повышает нагрузку и добивает пул.
  4. Оборвался downstream к клиенту. Результат больше не нужен, но upstream может занимать GPU. Шлюз должен отправить cancel исходному воркеру и освободить слот, а не запускать запасную попытку.

Внутренний lease делает эту схему управляемой. Роутер выдает попытке идентификатор и срок публикации. Воркер обязан перед каждым отправленным chunk проверять, что lease еще действителен. При переключении роутер отзывает lease у старой попытки, затем создает новую. Это не возвращает уже потерянные токены, зато уменьшает шанс, что две реплики одновременно пишут в один пользовательский поток.

Не путайте отмену с доказательством отмены. HTTP-запрос на cancel, отправленный в перегруженный воркер, может не дойти или дойти слишком поздно. Пока система не получила подтверждение либо не истекло время lease, старый запрос надо считать потенциально живым.

Prefix cache сокращает повтор, но не переносит сессию

Повторный prefill не обязан быть дорогим, если новая реплика может найти KV cache общего префикса. Системный prompt, описание инструментов, политика безопасности и первая часть длинной истории часто совпадают между попытками. Префиксный cache позволяет вычислить только отсутствующий хвост.

Но здесь часто делают неверный вывод: «есть distributed KV cache, значит можно мигрировать decode». Общий cache обычно индексирует законченные или пригодные к повторному использованию блоки входной последовательности. Активный decode меняет последовательность после каждого токена. Его cache может быть закреплен за запросом, находиться в GPU-памяти, иметь формат конкретного attention backend и зависеть от разбиения модели по устройствам.

SGLang прямо развивает prefill/decode disaggregation и передачу KV cache между этими ролями. Его roadmap описывает передачу delta KV для общих префиксов в агентных сценариях, а релизы отдельно отмечают decode-side prefix cache. Это ускорение архитектуры исполнения, не обещание бесшовно пережить смерть decode-воркера в середине ответа.

Проверяйте совместимость cache до того, как считать его переносимым. Совпадения имени модели недостаточно. Нужны одинаковые:

  • ревизия весов и формат квантования;
  • tokenizer и правила применения chat template;
  • архитектура attention и dtype KV cache;
  • tensor parallelism, pipeline parallelism и layout блоков;
  • набор LoRA-адаптеров и их порядок.

Если хотя бы один параметр расходится, cache нельзя подмешивать «на удачу». Ошибка в cache хуже медленного prefill: она может вернуть правдоподобный, но неверный ответ. В публичных отчетах SGLang есть пример повреждения KV cache на границе блока, где одинаковый prompt при temperature=0 выдавал разные последовательности. Это хороший повод считать корректность cache частью тестов отказоустойчивости, а не только оптимизацией latency.

У потокового протокола должен быть контракт на частичный ответ

Сохраняйте след попытки
Аудит-логи AI Router оставляют технический след, нужный для разбора прерванных и повторных запросов.

Клиенту нельзя оставлять догадку: закончился ли ответ, можно ли повторить действие, что делать с уже показанным текстом. OpenAI-совместимый SSE формат удобен для интеграции, но сам по себе не дает надежного механизма возобновления генерации с токена N.

После сбоя в decode шлюз должен закрыть поток с явной причиной. Если формат позволяет финальное служебное событие, в нем нужны request_id, partial_output=true, retryable=false и код причины. Если соединение уже физически разорвано, клиент увидит сетевую ошибку. Тогда тот же набор данных должен быть доступен через журнал запроса или включаться в повторный запрос клиента.

Контракт можно выразить так:

{
  "error": {
    "code": "upstream_decode_interrupted",
    "message": "Генерация прервана после отправки части ответа",
    "request_id": "req_01J...",
    "partial_output": true,
    "delivered_output_tokens": 37,
    "retryable": false
  }
}

retryable=false здесь не означает, что пользователь обязан сдаться. Он означает, что шлюз не имеет права незаметно повторить исходный запрос. Пользовательский клиент может предложить «Продолжить», сформировав новый запрос с уже показанным текстом. Для чата это разумный путь. Для JSON-ответа клиент чаще должен отбросить частичный объект и запросить генерацию заново с тем же входом, но новым логическим действием.

Не склеивайте новый текст со старым на стороне роутера. Даже если повтор начинается с того же фрагмента, различия в пунктуации, tool call или закрывающей скобке сделают результат недостоверным. Особенно плохо это проявляется при structured output: первые 90 процентов JSON могут выглядеть нормальными, а один повторенный ключ превращает объект в невалидную строку.

Идемпотентность запроса и идемпотентность действия не совпадают

Команда часто добавляет Idempotency-Key и считает задачу закрытой. Этот заголовок помогает сопоставить повторные попытки клиента с одним логическим запросом. Он не делает саму генерацию безопасной для повтора после частичной выдачи.

Надо различать три объекта:

  • логический запрос пользователя, например «составь платежное поручение»;
  • попытка инференса, привязанная к конкретной реплике и lease;
  • внешнее действие, например вызов функции, отправка письма или списание денег.

Для чистого text completion шлюз может повторить попытку до первого токена. Для агентного запроса с инструментами правила жестче. Если воркер успел вызвать инструмент, а затем умер до финального текста, повторять весь запрос нельзя без дедупликации на стороне инструмента. Иначе модель может второй раз создать заявку, отправить уведомление или провести операцию.

Инструмент должен принимать собственный idempotency key, производный от логического действия, а не от GPU-попытки. Например:

logical_request_id = req_01J...
tool_call_id = call_07
idempotency_key = req_01J...:call_07

После failover новый воркер может узнать, что call_07 уже выполнен, и получить прежний результат вместо второго вызова. Без этого даже идеальная миграция KV cache не спасает систему от дубликатов.

Перенос состояния оправдан только при заранее спроектированной совместимости

Добавьте метки AI-контента
AI Router поддерживает метки AI-контента для требований законодательства Казахстана.

Иногда продолжение decode все же возможно. Для этого нужен не общий роутер, который внезапно решил быть умнее движка, а протокол самого inference runtime. Он должен передавать или реплицировать KV blocks, метаданные последовательности и sampler state между заранее совместимыми узлами, фиксировать границу снимка и уметь восстановиться без гонки с продолжающимся decode.

Это дорого. Передача KV state для длинного контекста требует сети, памяти и контроля согласованности. Синхронная репликация каждого нового токена добавляет работу в самый чувствительный участок latency. Асинхронная репликация оставляет окно потери: primary успел отправить токены клиенту, standby еще не получил соответствующий снимок.

Поэтому не ставьте «продолжение при падении GPU» в SLA, пока не ответили на четыре вопроса:

  1. Какие именно данные передаются между репликами и когда снимок считается консистентным?
  2. Совпадают ли версии модели, cache layout, адаптеры и параметры decode?
  3. Как роутер запрещает старой попытке публиковать токены после failover?
  4. Как тест доказывает отсутствие дубликатов и пропусков при падении в каждом месте токенного цикла?

Если ответ на любой из них звучит как «обычно должно сработать», продолжения нет. Есть best-effort повтор, который нельзя маскировать под надежность.

Политика роутера должна быть короткой и строгой

Сократите путь до модели
Собственная GPU-инфраструктура AI Router подходит командам, которым важны data residency и низкая задержка.

Хорошая политика failover не пытается выжать retry из каждого сбоя. Она запрещает опасные действия и оставляет несколько разрешенных переходов.

on_upstream_failure:
  before_first_delta:
    require: [source_attempt_fenced, retry_budget_available]
    action: retry_prefill_on_healthy_replica
    max_attempts: 2

  after_first_delta:
    action: terminate_stream
    client_error: upstream_decode_interrupted
    automatic_retry: false

  uncertain_source_liveness:
    action: fence_source_attempt
    wait_for: cancel_ack_or_lease_expiry
    then: retry_only_if_no_delta_was_sent

max_attempts: 2 не является универсальным числом. Оно показывает форму политики: у retry должен быть бюджет. Без него массовое падение GPU превращается в лавину повторных prefill на оставшихся репликах. Роутер начнет тратить дефицитную вычислительную емкость на дублирование уже обреченных запросов.

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

Тестировать надо падение между токенами, а не только restart pod

Проверка «перезапустили воркер, сервис снова отвечает» ничего не говорит о живых потоках. Нужен fault-injection тест, который убивает процесс в точках, где решение роутера меняется.

Минимальный набор сценариев такой:

  1. Убить реплику во время prefill до записи первого downstream byte. Ожидаемый результат: одна полная выдача после повторной попытки.
  2. Убить реплику после вычисления токена, но до отправки SSE-события. Ожидаемый результат: повтор допустим, клиент не видит обрывка.
  3. Убить реплику сразу после первого отправленного delta. Ожидаемый результат: поток завершается ошибкой, вторая генерация не стартует.
  4. Разорвать сеть между шлюзом и репликой, сохранив процесс живым. Ожидаемый результат: старая попытка получает fence или дожидается истечения lease, дубликатов нет.
  5. Убить реплику после вызова инструмента. Ожидаемый результат: новый запуск не выполняет действие второй раз.

Проверяйте не только HTTP-код. Тест должен сверять список chunk по request_id, число попыток, факт отмены upstream, логи инструментов и отсутствие одновременной публикации двумя attempt_id. Сохраняйте вход, seed, ревизию модели и трассу событий, иначе редкий сбой не получится воспроизвести.

AI Router может сохранять OpenAI-совместимый контракт для клиентов, но внутренний роутинг должен хранить больше, чем base_url и имя модели: фазу потока, идентификатор попытки, lease и границу первой доставки. Без этих данных failover остается догадкой.

Не обещайте пользователю бесшовное продолжение, если инфраструктура умеет только повторить запрос. Гораздо надежнее быстро повторить prefill до первого токена и четко сообщить об обрыве после него. Это правило переживает смену моделей, GPU и inference runtime, потому что оно основано на том, что клиент уже успел увидеть.

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

Можно ли продолжить streaming-ответ на другой GPU после сбоя?

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

Когда можно безопасно повторить LLM-запрос после падения реплики?

Да, пока шлюз не отправил клиенту ни одного смыслового фрагмента ответа. Новая реплика повторно выполняет prefill по тому же нормализованному запросу и начинает генерацию заново. Клиент увидит задержку, но не получит склеенный ответ из двух разных запусков.

Достаточно ли HTTP 200, чтобы считать LLM-запрос выполненным?

Статус 200 означает только то, что сервер начал успешный HTTP-ответ. Если поток уже пошел, клиент мог получить один или несколько SSE-событий, хотя запись в access log еще не закрыта. Для решения о retry нужен отдельный признак first_byte_sent или first_delta_sent.

Помогает ли prefix caching мигрировать активный запрос?

Обычно нет. Кэш префикса хранит вычисления для совпадающих входных токенов и помогает повторно выполнить prefill, но не делает активный decode-сеанс переносимым. Для миграции нужен перенос именно частного состояния текущего запроса, а не только общий cache hit.

Почему повтор запроса может вернуть другой ответ?

Потому что новый запуск часто даст другой текст, даже при тех же параметрах. На результат влияют версия весов, токенизатор, LoRA-адаптер, схема сэмплирования, speculative decoding и детали исполнения. Нулевая температура уменьшает разброс, но не превращает повтор в криптографически идентичное продолжение.

Нужен ли idempotency key для streaming-запросов?

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

Что должен вернуть шлюз, если GPU умер во время ответа?

Шлюз должен вернуть ошибку потока и метаданные, достаточные для осмысленного решения на стороне клиента. Минимум: request_id, код причины, число отправленных токенов или признак partial_output, а также retryable. Если пользователю уже показали текст, интерфейс не должен незаметно заменить его другим ответом.

Какие метрики нужны для отказоустойчивого LLM-шлюза?

Ретраи измеряют отдельно до первого токена и после него. Полезны доля повторных prefill, число частичных потоков, время от потери heartbeat до прекращения выдачи, ошибки передачи KV cache и расхождение версий между репликами. Один общий счетчик 5xx скрывает именно тот отказ, который ломает пользовательский опыт.

С чего начать настройку failover для LLM inference?

Сначала отключите автоматический retry после первого отправленного delta-события. Затем добавьте журнал фаз запроса, стабильный request_id и тест, который убивает воркер в prefill и в decode. После этого можно вводить ограниченный retry до первого токена и проверять его на реальных длинных контекстах.

Решает ли disaggregated prefill и decode проблему failover?

Разделение prefill и decode помогает масштабировать разные части нагрузки, но само по себе не дает аварийного продолжения ответа. Оно может упростить повтор prefill или передачу KV cache между заранее совместимыми компонентами. При потере decode-реплики правило о границе первого отправленного токена все равно остается.