Ответ уже приходит от модели, это видно по логам шлюза: токены идут один за другим. Но в браузере поле чата молчит секунд десять, а потом абзац появляется целиком, как будто никакого потока и не было. Иногда вариант хуже: текст успевает показаться наполовину, и соединение обрывается без внятной ошибки. Два симптома похожи, а причины у них разные. В первом случае где-то копятся байты, во втором где-то истекает таймер. Чтобы не гадать, надо пройти по цепочке и поставить на каждом звене отметку времени.

Куда делись байты: четыре точки на пути одного токена

Путь потокового ответа обычно длиннее, чем кажется. Модель отдаёт фрагменты шлюзу, шлюз упаковывает их в события SSE, Nginx пересылает их дальше, возможно, через ещё один внешний прокси или CDN, браузер получает данные, а интерфейс решает, когда перерисовать текст. Любое из этих звеньев способно держать данные у себя. Поэтому вопрос «где ломается» удобнее заменить вопросом «в какой момент появился первый фрагмент на каждом участке».

Для этого достаточно журнала из четырёх временных отметок для одного и того же запроса:

Журнал одного запроса: четыре отметки
ОтметкаГде снимаетсяЧто показывает
T1Лог или вывод шлюза: момент отправки первого событияКогда ответ реально начал выходить из шлюза
T2Лог Nginx: время до получения первых данных от апстримаКогда Nginx увидел начало ответа
T3Клиент: время до первого полученного байта (DevTools или curl)Когда начало ответа дошло до клиента
T4Интерфейс: момент, когда первый фрагмент текста нарисованКогда пользователь реально увидел слово

Разности между соседними отметками и есть ответ. Если T1 и T2 близки, а T3 сильно позже, задерживает Nginx или то, что стоит перед клиентом. Если T3 близка к T1, но T4 далеко, поток доехал, а интерфейс не показывает его. Если T2 сильно позже T1, текст копится между шлюзом и Nginx, то есть искать нужно в самом шлюзе или в его приложении. Каждая отметка отсекает половину пространства поиска.

Для T2 в журнале Nginx пригодятся стандартные переменные времени: $request_time и группа $upstream_*_time. Заведите отдельный формат лога для проверяемого маршрута и пишите в него идентификатор запроса, который приходит из клиента или генерируется на входе. Без общего идентификатора разные журналы потом приходится сопоставлять по минутам, а это плохая работа.

Разберём на условном примере, как читать такие отметки. Пусть для одного запроса T1 равна 0,4 с от начала запроса, T2 тоже около 0,4 с, T3 равна 9,8 с, а T4 равна 9,9 с. Шлюз начал отдавать ответ почти сразу, Nginx его увидел, но до клиента первый байт добрался почти через десять секунд. Значит, данные удерживались после Nginx или внутри него. Интерфейс тут ни при чём: между T3 и T4 всего 0,1 с. Теперь другой набор: T1 равна 0,4 с, T2 равна 7,5 с. Сам Nginx ничего не держал, он просто долго не получал данных, и искать нужно выше по цепочке: в шлюзе, его очереди или в приложении, которое собирает ответ перед отправкой.

Отметки сравнимы только при одном начале отсчёта. Если часы на серверах расходятся, разность T1 и T2 окажется выдуманной. Надёжнее считать время от начала запроса на каждом участке, а не сопоставлять абсолютные часы разных машин. Для клиентской стороны так и получается: curl и DevTools сами отсчитывают время от отправки запроса.

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

Сначала напрямую, потом через Nginx

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

curl -N -sS -o /dev/null -w 'first byte: %{time_starttransfer}s, total: %{time_total}s\n' \
  -H 'Content-Type: application/json' \
  -d '{...ваш потоковый запрос...}' \
  http://127.0.0.1:ПОРТ_ШЛЮЗА/ВАШ_ПУТЬ

Здесь важны два числа. Время до первого байта показывает, как быстро начался ответ. Полное время показывает, как долго он шёл. Для потока они должны сильно различаться: первое мало, второе велико. Если оба числа почти равны, поток не потоковый уже на этом участке, и Nginx пока ни при чём. Тогда проверьте, что в запросе действительно включён потоковый режим и что приложение за шлюзом не собирает ответ целиком перед отправкой.

Затем повторите тот же запрос через публичный адрес, то есть через Nginx и всё, что стоит перед ним. Разница во времени до первого байта между двумя запусками даёт вам размер задержки, внесённой промежуточными звеньями. Если прямой запрос отдаёт первый байт за долю секунды, а через Nginx приходит через десять секунд вместе со всем текстом, диагноз почти поставлен: где-то по дороге включено накопление.

Одно замечание про сравнение. Сравнивайте сопоставимые запросы: тот же промпт, та же модель, тот же размер ответа. Генерация у моделей вариативна, и разница между двумя разными ответами может быть вызвана просто разной длиной. Лучше сделать три-пять пар запусков и смотреть на устойчивую картину, а не на один замер.

Как буферизация превращает поток в один кусок

Принцип работы прокси по умолчанию рассчитан на обычные страницы. Nginx читает ответ апстрима в буферы быстро, не завися от скорости клиента, а затем отдаёт его клиенту. Для HTML-страницы это разумно: апстрим освобождается раньше. Для потока токенов то же поведение ломает смысл, потому что фрагменты оседают в буферах, а клиент получает их порциями, когда буфер заполнится или ответ закончится. Короткий ответ целиком помещается в буфер, поэтому пользователь видит его одним куском. Сам по себе поток при этом не пропал: он просто удерживался на пути.

В документации модуля proxy сказано, что без буферизации ответ передаётся по мере получения. Для SSE это нужное поведение, и управляется оно директивой proxy_buffering в нужном контексте. Но сначала проверьте на тестовом маршруте, что именно она влияет на ваш случай, а не считайте её универсальным лекарством. Буферизация может скрываться и в других местах: во втором прокси перед Nginx, в CDN, в самом приложении или во встроенном сжатии ответа. Одной директивой можно починить только своё звено.

Поэтому правильная последовательность такая. Выделите отдельный маршрут для потокового эндпоинта, чтобы не менять поведение остального сайта. Включите на нём отправку без буферизации. Повторите замер T3. Если он приблизился к T1, значит, виновник был здесь. Если не приблизился, ищите следующее звено выше по цепочке запроса. Менять настройки сразу в нескольких местах не стоит: потом не поймёте, какое изменение дало эффект.

Чтобы замер T3 был честным, проверяйте его на потоке с известным ритмом, например на тестовом апстриме с паузой в одну секунду из следующих разделов. У него предсказуемая картина: шесть событий с интервалом около секунды. Если через Nginx они приходят пачкой в конце, накопление подтверждено, и после правки любого звена оно либо исчезнет, либо нет. Реальная модель для такой проверки неудобна: у неё интервалы между токенами плавают, и по одному запуску трудно отличить буферизацию от медленной генерации.

Помните и про критерий успеха. Если поток после правки стал приходить по частям, это не значит, что работа окончена. Прогоните короткий ответ в один-два предложения: именно он при буферизации помещается в буфер целиком и выглядит как «ничего не изменилось». Длинный ответ может оказаться потоковым и при включённом накоплении просто потому, что буфер успевает заполняться и отправляться. Поэтому проверка на коротком ответе строже и быстрее выявляет проблему.

Если после отключения буферизации на маршруте картина не изменилась, вернитесь к таблице отметок: T2 покажет, получил ли Nginx данные вовремя. Если да, а T3 по-прежнему поздняя, подозрения переходят на внешний прокси, CDN или сжатие ответа перед клиентом. Это отдельные звенья со своими настройками, и каждое проверяется отдельным прямым запросом в обход следующего за ним.

Есть и обратная сторона. Отключение буферизации на маршруте означает, что Nginx передаёт данные клиенту с тем темпом, с каким тот их принимает. Медленный клиент теперь дольше удерживает соединение с апстримом. Для небольшого числа пользователей чата это не проблема, но для публичного сервиса с большим числом одновременных потоков стоит заранее посчитать, сколько открытых соединений вы готовы держать, и проверить это нагрузочным тестом на своём железе, а не принимать на веру чужие цифры.

Данные дошли, а текст не появился

Бывает, что T3 уже совпадает с T1, а пользователь всё равно видит абзац разом. Тогда проблема на стороне клиента. Откройте вкладку «Сеть» в инструментах разработчика и найдите запрос потока. Для SSE там обычно есть представление событий: если события приходят с растущим временем, транспорт здоров. Дальше смотрите на код интерфейса.

Типичные причины в интерфейсе скучны. Фрагменты могут складываться в буфер и выводиться раз в несколько сотен миллисекунд или после завершения. Разбор потока может ждать конца строки или пустой строки между событиями, а шлюз отправляет событие без нужного разделителя. Текст может накапливаться в состоянии и перерисовываться только после окончания запроса. Библиотека, которая оборачивает запрос, может сначала собрать тело ответа, а потом отдать его коду. Всё это легко проверить: добавьте вывод времени в консоль в самом обработчике события и сравните с T3. Разница между ними и есть T4 минус T3.

Такое разделение экономит часы. Пока вы не знаете, дошли ли байты до браузера, спор о «глючном Nginx» и «кривом фронте» остаётся спором о мнениях. Когда есть отметка T3, один из двух спорщиков сразу оказывается прав.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Длинный ответ и пауза: что на самом деле измеряет таймаут

Теперь второй симптом: поток идёт хорошо, но на длинных ответах обрывается. Здесь удобно держать в голове простое различие. Один таймер может отсчитывать общую длительность ответа, другой отсчитывает паузу между чтениями. У Nginx директива proxy_read_timeout устроена вторым способом: в документации указано, что она измеряет паузу между двумя операциями чтения, а не общую продолжительность ответа.

Из этого следуют два вывода, которые часто путают. Во-первых, ответ длиной в несколько минут сам по себе не нарушает этот таймаут, если данные приходят достаточно часто. Модель, которая выдаёт токены каждые сотни миллисекунд, может говорить сколь угодно долго. Во-вторых, короткий ответ может быть оборван, если в нём случилась долгая тишина. Типичный пример: модель «думает» перед первым токеном, и пауза до начала ответа оказывается больше установленного значения. Или шлюз при ожидании ответа от резервного провайдера ничего не отправляет клиенту.

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

Покажем расчёт на условных числах. Пусть в вашем сценарии самая долгая пауза до первого токена, замеренная на тяжёлых запросах, равна 20 секундам, самая долгая пауза посреди ответа равна 6 секундам, а на нестабильную сеть вы закладываете запас вдвое. Тогда таймаут чтения на этом маршруте должен превышать 40 секунд, иначе первая пауза в плохой момент оборвёт запрос. Эти числа выдуманы для иллюстрации, ваши будут другими. Важен порядок: сначала измеряется максимальная тишина, и только потом из неё выводится значение, а не наоборот.

Есть и вторая сторона. Слишком большой таймаут прячет зависшие запросы: апстрим перестал отвечать, а соединение висит и занимает ресурсы. Поэтому выбор значения всегда компромисс между терпимостью к долгому молчанию и скоростью обнаружения настоящего зависания. Если в шлюзе есть собственный таймаут и переключение на резерв, он должен срабатывать раньше, чем таймаут Nginx. В противном случае Nginx оборвёт соединение, не дождавшись решения шлюза, и пользователь получит ошибку там, где мог получить ответ от резервной модели. Повторы и тайм-ауты на уровне Router в LiteLLM настраиваются отдельно, поэтому значения двух слоёв нужно сверять между собой.

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

Какая пауза чему соответствует
СценарийГде возникает тишинаЧто решить
Обычный чат, быстрая модельКороткая пауза до первого токенаУмеренный таймаут чтения, замерить реальный максимум
Длинный контекст, тяжёлая обработкаДолгое ожидание первого токенаЛибо увеличить таймаут на этом маршруте, либо слать служебные события, пока идёт подготовка
Резервный провайдер при сбое основногоТишина на время переключенияСогласовать таймаут шлюза и таймаут Nginx так, чтобы Nginx не обрывал раньше

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

Короткий поток с паузой: тест, который всё проясняет

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

import time, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PAUSE = float(sys.argv[1]) if len(sys.argv) > 1 else 1.0

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/event-stream')
        self.send_header('Cache-Control', 'no-cache')
        self.end_headers()
        for i in range(6):
            line = 'data: ' + str(i) + ' ' + format(time.time(), '.3f') + '\n\n'
            self.wfile.write(line.encode())
            self.wfile.flush()
            time.sleep(PAUSE)

ThreadingHTTPServer(('127.0.0.1', 9000), H).serve_forever()

Запускайте со значением паузы 1 секунда и смотрите через curl с флагом -N: события должны приходить по одному, а метка времени в каждой строке сравнивается с моментом получения. Если все шесть событий пришли разом в конце, где-то включена буферизация, и отметки T2 и T3 покажут, в каком месте. Затем увеличьте паузу выше вашего значения proxy_read_timeout. Соединение должно оборваться посреди потока. Так вы увидите, как выглядит обрыв по таймауту чтения на практике: частичный ответ, закрытое соединение и запись в журнале ошибок Nginx. После этого уменьшите паузу так, чтобы она не превышала таймаут, но общая длительность превышала его в несколько раз. Поток должен дойти до конца. Этот последний опыт и показывает наглядно, что таймаут отсчитывает паузу, а не весь ответ.

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

Разрыв после частичного ответа и тихий повтор

Самый неприятный случай начинается, когда ответ уже частично показан, а потом соединение рвётся. Что произойдёт дальше, зависит от того, кто и как повторяет запрос, и здесь легко получить дубли, которые никто не заметит.

Браузерный EventSource по умолчанию сам пытается переподключиться после обрыва. Если сервер не умеет продолжать поток с места разрыва, новое соединение запустит новый ответ с начала. Пользователь увидит повторяющееся начало: два одинаковых приветствия или абзац, продублированный в середине. Если интерфейс дописывает текст в тот же блок, дубль не бросается в глаза, но искажает смысл. Если же запрос идёт обычным fetch с чтением тела, автоповтора нет, и пользователь просто получает оборванный текст.

Ещё один слой повторов может лежать на шлюзе. Router в LiteLLM поддерживает cooldown, fallback, timeout и повторы с фиксированной или экспоненциальной задержкой, а состояние cooldown и учёта лимитов может храниться в Redis. Эти механизмы полезны, когда провайдер временно недоступен. Но они решают задачу «как получить ответ», а не «как не показать пользователю один и тот же текст дважды». Если ошибка случилась до первого токена, повтор или переход на резервную модель обычно безопасен: клиент ещё ничего не видел. После первых токенов картина другая: новый запуск может дать другой текст, и склеить два разных начала в один ответ нельзя.

Как с этим жить, зависит от задачи. Несколько разумных вариантов:

  • Разрешать автоматический повтор только до отправки первого фрагмента клиенту, а после него показывать честную ошибку и предлагать повторить вручную.
  • Если интерфейс всё же перезапрашивает ответ, перед новым ответом очищать уже показанную часть, чтобы текст не склеивался из двух генераций.
  • Если у вас есть побочные действия, например запись в базу или вызов внешней системы после ответа, не завязывать их на сам факт завершения потока без отдельной проверки. Новый идентификатор попытки сам по себе не делает повтор безопасным: безопасность повтора нужно обеспечивать отдельным механизмом.
  • Записывать в журнал, сколько попыток было у запроса и на какой из них клиент получил первый фрагмент. Без этого тихие повторы видны только пользователю.

Здесь пригодятся те же четыре отметки. Если у одного запроса в журнале две отметки T1, значит, генерация запускалась дважды, и стоит выяснить, кто её повторил: браузер, Nginx, шлюз или клиентская библиотека. Пока не видно, кто повторяет, исправлять придётся наугад.

Разберём типичный случай. Пользователь видит, что ответ дошёл до половины и пропал, затем текст начался заново. В журнале у одного идентификатора две отметки T1, разделённые несколькими секундами, а клиентская отметка T3 есть у обеих попыток. Это переподключение на стороне клиента: первая попытка успела отдать часть данных, вторая стартовала с нуля. Если же вторая T1 появилась, а T3 у первой попытки отсутствует, вероятнее, что повтор сделал шлюз до отправки первого фрагмента, и пользователь ничего дублированного не видел.

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

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

Где всё это удобно проверять

Для описанных опытов нужен сервер, на котором можно править конфигурацию Nginx, ставить тестовый апстрим и читать журналы целиком. Подойдёт обычный VPS с root-доступом: в публичном предложении MAATRIX указаны NVMe-диски, процессоры AMD EPYC и root-доступ, а тарифы начинаются с конфигурации 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe (Ba) и доходят до 16 vCPU, 32 ГБ и 320 ГБ (Djed). Для проверки буферизации и таймаутов хватит и небольшой конфигурации: тестовый поток почти ничего не потребляет. Сколько одновременных потоков выдержит ваш шлюз вместе с Nginx, покажет только замер на вашей нагрузке, а не размер тарифа. Есть смысл поднять отдельную копию стенда, прогнать на ней тест с паузой и лишь затем переносить изменения на рабочий маршрут.

Что должно получиться в конце

Рабочая картина выглядит так. Пользователь отправляет вопрос и через долю секунды видит первые слова, а дальше текст растёт по мере появления. Администратор открывает журнал и видит четыре отметки одного запроса: когда шлюз отдал первый фрагмент, когда его увидел Nginx, когда он дошёл до браузера и когда был нарисован. Если однажды задержка вернётся, вопрос «где её искать» займёт минуту, потому что разность двух соседних отметок указывает на звено. А если поток оборвётся, по журналу будет понятно, был ли это таймаут паузы, закрытие соединения, повтор на шлюзе или переподключение в браузере.

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