Почему стриминг токенов (SSE) ломает привычный бэкенд и как с этим жить

12.06.2026 09:00

Вы подключаете LLM к своему проекту. Находите библиотеку, пишете код для работы с потоковым ответом (stream: true), отправляете запрос из фронтенда и ждете. И ждете. А через 15 секунд весь сгенерированный текст вываливается на экран одним куском.

Где стриминг? Почему токены не появляются по одному, как в ChatGPT?

Добро пожаловать в мир Server-Sent Events (SSE) на классическом стеке. Если вы используете связку Nginx + PHP-FPM (или Python WSGI), ваш сервер годами настраивался на максимальную пропускную способность коротких запросов, а не на долгие открытые соединения. По умолчанию веб-сервер собирает ответ приложения в буфер, чтобы отдать его клиенту максимально быстро и эффективно. Для классического веба это благо. Для SSE — приговор.

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

Уровень 1: Буферизация Nginx

Nginx — первый подозреваемый, когда речь заходит о задержках ответов. Его задача — защитить бэкенд от медленных клиентов. По умолчанию Nginx включает proxy_buffering и fastcgi_buffering. Он ждет, пока буфер не заполнится (обычно 4-8 КБ), и только потом отправляет кусок данных в сеть. Токены от LLM весят байты. Чтобы заполнить буфер в 4 КБ, модели придется сгенерировать сотни слов. Вот почему вы видите ответ огромными кусками или целиком в самом конце.

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

location /api/chat/stream {
 # Отключаем буферизацию FastCGI (для PHP-FPM)
 fastcgi_buffering off;
 
 # Отключаем буферизацию прокси (для NodeJS/Python)
 proxy_buffering off;
 
 # Убираем таймауты на чтение, так как генерация может идти долго
 fastcgi_read_timeout 300s;
 proxy_read_timeout 300s;
 
 # Отключаем кэширование
 fastcgi_cache off;
 proxy_cache off;
 
 fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
 include fastcgi_params;
}

Но есть способ изящнее. Вы можете приказать Nginx отключить буферизацию прямо из кода приложения, отправив заголовок X-Accel-Buffering: no. Nginx перехватит его и пустит трафик прозрачно.

Уровень 2: Внутренние буферы языка (на примере PHP)

Вы настроили Nginx, но токены все еще приходят с задержкой? Следующий барьер — сам язык программирования.

В PHP работает собственная система контроля вывода (Output Control). Функции echo или print не пишут данные напрямую в сокет. Они складывают их во внутренний буфер PHP.

Чтобы пробить эту стену, нужно задать правильные HTTP-заголовки и принудительно сбрасывать буфер после каждого отправленного чанка:

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // Магия для Nginx

// Отключаем неявную буферизацию PHP, закрывая все уровни ob_
while (ob_get_level > 0) {
 ob_end_flush;
}

$tokens = ['Hell', 'o,', ' ho', 'w a', 're ', 'you?'];

foreach ($tokens as $token) {
 // Формат SSE требует префикса 'data: ' и двух переносов строк
 echo "data: " . json_encode(['text' => $token]) . "\n\n";
 
 // Принудительно выталкиваем данные из PHP в веб-сервер
 flush;
 
 usleep(100000); // Имитация задержки генерации
}

В Python ситуация похожа: в Django нужны объекты StreamingHttpResponse, во Flask — Response с генератором и mimetype='text/event-stream'.

Уровень 3: Скрытые ловушки инфраструктуры

Иногда Nginx отдает данные напрямую, скрипт делает flush, но поток все равно зависает или ломает приложение. Вот четыре неочевидные проблемы:

  1. Блокировка сессий (Session Locking). Если в PHP вы вызвали session_start, файл сессии блокируется. Пока идет стриминг (а он может идти 30 секунд), любой другой AJAX-запрос от этого же пользователя зависнет в ожидании разблокировки сессии. Решение: вызывайте session_write_close перед началом цикла стриминга.
  2. Исчерпание воркеров (Worker Exhaustion). FPM-воркеры потребляют много памяти. Если у вас пул на 50 процессов, то всего 50 одновременных пользователей, читающих стрим, полностью парализуют бэкенд. Новые запросы начнут отваливаться с 502 Bad Gateway.
  3. Обрыв соединения клиентом. Пользователь закрыл вкладку, а ваш код продолжает в цикле принимать токены от OpenAI, расходуя деньги. В PHP необходимо регулярно проверять статус connection_aborted и прерывать цикл, если клиент ушел.
  4. Сжатие Gzip/Brotli. Алгоритмы сжатия работают блоками. Они физически не могут сжать и отправить 5 байт токена. Сжатие необходимо отключать для стриминговых эндпоинтов директивой gzip off;.

Как упростить жизнь: Интеграция с RouterAPI

Поддержка чистого стриминга на старом стеке отнимает массу времени: нужно следить за обрывами, корректно парсить чужие SSE-потоки, бороться с лимитами процессов. Для высоконагруженного продакшена лучше выносить долгие соединения в асинхронные микросервисы (Go, Swoole, RoadRunner).

Например, в экосистеме RouterAPI от нейросети проблему удержания тысяч соединений решает специализированный шлюз RouterAPI (gateway). Он изначально спроектирован для работы с долгоживущими SSE-соединениями при минимальном потреблении памяти.

Если архитектура требует проксировать запросы через ваш PHP-бэкенд (например, для контроля биллинга или прав доступа), RouterAPI выступает в роли идеального стабильного апстрима. Он всегда отдает эталонный text/event-stream. Вам остается лишь читать его построчно через Guzzle и сразу отправлять клиенту:

$client = new \GuzzleHttp\Client;
$response = $client->request('POST', 'https://api.RouterAPI.host/v1/chat/completions', [
 'headers' => [
 'Authorization' => 'Bearer YOUR_ROUTERAPI_KEY',
 'Content-Type' => 'application/json',
 ],
 'json' => [
 'model' => 'gpt-4o',
 'messages' => [['role' => 'user', 'content' => 'Расскажи о черных дырах']],
 'stream' => true,
 ],
 'stream' => true, // Критически важно! Запрещаем Guzzle качать весь ответ в память
]);

$body = $response->getBody;

// Освобождаем сессию до начала долгого цикла
session_write_close;

while (!$body->eof && !connection_aborted) {
 $line = $body->readLine; // Читаем один SSE-чанк от RouterAPI
 
 if ($line !== false) {
 echo $line;
 flush; // Немедленно проталкиваем токен клиенту
 }
}

В этом сценарии RouterAPI нивелирует хаос внешних провайдеров. Независимо от того, куда ушел запрос — в OpenAI, Anthropic или локальную Llama — нормализатор RouterAPI приведет любой ответ к единому стандарту. Вы не столкнетесь с тем, что какой-то провайдер забыл отправить [DONE] в конце потока или сломал JSON внутри чанка. Ваши flush отработают без непредвиденных исключений.

Резюме

Стриминг ломает привычную парадигму «получил запрос — собрал данные — отдал ответ». Он требует прямого контроля над тем, что раньше мы слепо делегировали веб-серверу и фреймворку.

Чек-лист для настоящего SSE:

  1. Отключите буферизацию Nginx (X-Accel-Buffering: no).
  2. Отключите Gzip для стримов.
  3. Пробивайте внутренние буферы языка (с помощью flush и ob_end_flush).
  4. Освобождайте блокировки сессий (session_write_close).
  5. Следите за отвалившимися клиентами (connection_aborted).

А если не хотите бороться с зоопарком SSE-реализаций десятков разных провайдеров — используйте RouterAPI как единую точку входа, которая гарантирует предсказуемый и чистый стрим в любых условиях.

Теги

Ещё по теме