Вы подключаете 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, но поток все равно зависает или ломает приложение. Вот четыре неочевидные проблемы:
- Блокировка сессий (Session Locking). Если в PHP вы вызвали
session_start, файл сессии блокируется. Пока идет стриминг (а он может идти 30 секунд), любой другой AJAX-запрос от этого же пользователя зависнет в ожидании разблокировки сессии. Решение: вызывайтеsession_write_closeперед началом цикла стриминга. - Исчерпание воркеров (Worker Exhaustion). FPM-воркеры потребляют много памяти. Если у вас пул на 50 процессов, то всего 50 одновременных пользователей, читающих стрим, полностью парализуют бэкенд. Новые запросы начнут отваливаться с 502 Bad Gateway.
- Обрыв соединения клиентом. Пользователь закрыл вкладку, а ваш код продолжает в цикле принимать токены от OpenAI, расходуя деньги. В PHP необходимо регулярно проверять статус
connection_abortedи прерывать цикл, если клиент ушел. - Сжатие 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:
- Отключите буферизацию Nginx (
X-Accel-Buffering: no). - Отключите Gzip для стримов.
- Пробивайте внутренние буферы языка (с помощью
flushиob_end_flush). - Освобождайте блокировки сессий (
session_write_close). - Следите за отвалившимися клиентами (
connection_aborted).
А если не хотите бороться с зоопарком SSE-реализаций десятков разных провайдеров — используйте RouterAPI как единую точку входа, которая гарантирует предсказуемый и чистый стрим в любых условиях.