Стриминговые ответы: ReadableStream, SSE для LLM-интерфейсов и прокси, которые их буферизуют
Route handlers стримят через ReadableStream и SSE — транспорт LLM-интерфейсов. Backpressure работает по pull: продюсер пишет, когда потребитель читает. Suspense стримит HTML; хендлеры — данные. Оба молча ломаются за буферизующими прокси и сжатием — шлите no-transform.
Команда выкатывает AI-чат. Локально он прекрасен: токены текут в UI как печать на машинке, время до первого токена около 300 мс. Они деплоятся за корпоративный nginx-реверс-прокси — и демо руководству показывает спиннер на девять секунд, после чего весь ответ влетает разом. Ошибок ноль. Хендлер стримил идеально; curl -N прямо в под это доказал. Виновник был двумя слоями выше: nginx-овский proxy_buffering (включён по умолчанию) собирал весь ответ, прежде чем переслать хоть байт, а gzip-модуль для верности делал то же самое — сжатию нужны куски, чтобы их сжимать, и оно их ждёт. Один заголовок из хендлера — X-Accel-Buffering: no плюс Cache-Control: no-cache, no-transform — вернул поток. Урок обобщается: стрим — это контракт между каждым хопом от вашего кода до экрана пользователя, и любой один буферизующий хоп — прокси, middleware сжатия, serverless-платформа, собирающая ответы целиком, — молча превращает ваш стриминговый UX обратно в батч.
Возврат стрима из route handler
Route handler возвращает web Response, а телом Response может быть ReadableStream — в момент возврата Next немедленно отправляет заголовки и сбрасывает чанки по мере того, как ваш код их enqueue-ит. Никто не ждёт «завершения» функции:
// app/api/chat/route.ts — проксируем LLM, переотправляем токены как SSE
export async function POST(request: Request) {
const { prompt } = await request.json();
const upstream = await llm.stream(prompt); // async iterable of tokens
const encoder = new TextEncoder();
const stream = new ReadableStream({
async pull(controller) {
const { value, done } = await upstream.next();
if (done) {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
return;
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
},
cancel() {
upstream.return?.(); // client disconnected — stop paying the LLM
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache, no-transform",
"x-accel-buffering": "no", // nginx: do not buffer
},
});
}Три детали в этом коде несут продакшен-вес. Колбэк pull — крючок backpressure, о нём ниже. Колбэк cancel срабатывает при отключении клиента; без него пользователь, закрывший вкладку на середине ответа, оставляет хендлер радостно потреблять (и оплачивать) остаток LLM-генерации. А заголовки — броня против девятисекундного спиннера из Hook.
SSE: скучный, правильный протокол для потоков токенов
Для LLM-интерфейсов нужна конвенция фрейминга — у сырых чанков нет границ сообщений; токен может приехать разрезанным между двумя сетевыми чтениями. Server-Sent Events — устоявшийся ответ: content type text/event-stream, сообщения в рамке data: <payload>\n\n, в браузере читается EventSource (авто-реконнект, докачка через Last-Event-ID) или, как делают все AI SDK для POST-тел, чтением response.body с парсером. SSE выигрывает у WebSockets здесь потому, что поток токенов однонаправленный: без upgrade-рукопожатия, чистая HTTP-семантика (работает сквозь стандартные прокси и мультиплексирование HTTP/2), тривиально возобновляем и доступен через обычный fetch. WebSockets оправдывают свою сложность, только когда клиент должен часто слать сообщения по тому же соединению.
Сценарий отказа, уникальный для SSE, — порча фрейминга: если вы выдадите payload с сырым переводом строки, не разрезав его по data:-строкам, клиентский парсер молча обрежет сообщения — JSON-кодируйте каждый payload (как выше), и проблема исчезнет.
Хендлер LLM-чата стримит ответы, а облачный счёт показывает генерации полной длины даже для пользователей, закрывших вкладку через две секунды. Чего не хватает?
Backpressure: производить в темпе потребителя
У стрима есть продюсер (ваш LLM-прокси, курсор базы, читатель файла) и потребитель (соединение пользователя — часто медленный телефон на слабом Wi-Fi). Когда продюсер быстрее, разница накапливается в памяти вашего сервера. Web streams решают это pull-моделью: рантайм вызывает ваш pull(controller), когда во внутренней очереди есть место (отслеживается controller.desiredSize, освобождается по мере того, как потребитель вычитывает байты), — корректно написанный источник генерирует только по запросу. Антипаттерн — обход модели: колбэк start() с циклом, который enqueue-ит целый скан таблицы, или прокачка быстрого upstream в медленного клиента без ожидания writer-а:
// НЕВЕРНО: backpressure игнорируется — весь курсор оказывается в памяти
start(controller) {
for await (const row of hugeCursor) controller.enqueue(encode(row));
}
// ВЕРНО: pull() читает по одному элементу по запросу со стороны потребителя
async pull(controller) {
const { value, done } = await hugeCursor.next();
done ? controller.close() : controller.enqueue(encode(value));
}Для потоков токенов ставки низки — токены крошечные, а люди читают медленно. Для эндпоинтов экспорта данных (CSV-дампы, хвосты логов) backpressure — это разница между ровным профилем памяти в 30 МБ и OOM-убитым подом, когда один клиент с медленным соединением запросил экспорт на 2 ГБ. Если вы компонуете трансформации, pipeThrough/pipeTo пропагируют backpressure за вас; утекает он в самописных циклах.
Два стриминга, одно слово — и строй буферизаторов
Next.js стримит в двух разных смыслах, и их смешение путает отладку. Suspense-стриминг страниц — это App Router, прогрессивно рендерящий HTML: оболочка уходит сразу, рендерятся фолбэки loading.tsx/<Suspense>, а опоздавшие серверные компоненты приезжают чанками вне порядка, которые inline-скрипты вставляют на место. Его единица — UI; протоколом владеет фреймворк. Хендлерный стриминг данных — этот урок — это вы возвращаете байты, которые потребляет клиентский код: SSE-события, NDJSON-строки, CSV-ряды. Его единица — данные; фрейминг, заголовки и отмена — ваши. Они делят транспорт (chunked HTTP) и потому делят врага: всё, что буферизует.
Строй, хоп за хопом. nginx: proxy_buffering on по умолчанию — отключается per-response заголовком X-Accel-Buffering: no. Сжатие: middleware gzip/brotli накапливает вход для окон сжатия; Cache-Control: no-transform велит благовоспитанным посредникам не перекодировать, а собственный слой сжатия обязан исключить text/event-stream. CDN: некоторые буферизуют или даже кешируют стриминговые ответы, если не пометить их no-cache/private. Serverless-платформы: классическая AWS Lambda за API Gateway буферизовала весь ответ by design — стримингу нужна явно стриминго-способная инфраструктура (Lambda response streaming или edge/Node-серверы, которые flush-ат). Диагностика, разрезающая всё это: curl -N прямо в приложение (стримит? ваш код в порядке), затем в каждый слой наружу, пока стрим не перестанет стримить, — этот хоп и есть виновник.
Хендлер CSV-экспорта стримит результат на 2 ГБ. С быстрыми клиентами память ровная. Когда экспорт запрашивает клиент на медленном соединении, память пода растёт до OOM. Какой механизм отказал?
- 01Каковы три продакшен-критичные части стримингового SSE-хендлера и что ломается без каждой?
- 02Разграничьте Suspense-стриминг и хендлерный стриминг и приведите диагностику строя буферизаторов.
Route handler стримит, возвращая Response с телом-ReadableStream: заголовки уходят немедленно, чанки сбрасываются по мере enqueue. Для LLM-интерфейсов конвенция фрейминга — SSE: text/event-stream, строки data:, разделённые пустыми строками, JSON-кодированные payload-ы, чтобы вложенные переводы строк не портили парсер, — выбранная вместо WebSockets потому, что поток токенов однонаправлен, а чистая HTTP-семантика переживает прокси, мультиплексируется по HTTP/2 и возобновляется через Last-Event-ID. Три части хендлера несут продакшен-вес: производство по pull, где рантайм запрашивает данные по мере вычитывания (desiredSize), и медленный телефон не заставит под накопить двухгигабайтный экспорт в памяти — разница между ровным профилем и OOM-kill, причём pipeThrough/pipeTo пропагируют backpressure автоматически, а самописные enqueue-циклы его теряют; cancel(), который срабатывает при отключении клиента и обязан прервать upstream-вызов, иначе закрытые вкладки дожигают LLM-бюджет до конца каждой генерации; и заголовочная броня — Cache-Control: no-cache, no-transform плюс X-Accel-Buffering: no. Эта броня существует потому, что стрим — контракт с каждым хопом между кодом и экраном: nginx буферизует по умолчанию, middleware сжатия копит окна перед выдачей, CDN могут собирать или кешировать стримы, а классические serverless-гейтвеи буферизовали ответы целиком — любой из них молча превращает стриминг в батч, поэтому демо с девятисекундным спиннером провалилось, хотя curl -N в под стримил отлично. Держите два стриминга раздельно: Suspense-стриминг — фреймворк прогрессивно доставляет HTML страницы чанками вне порядка; хендлерный стриминг — ваш протокол данных; но транспорт, враги и похоповая curl-диагностика у них общие. Теперь, когда встретишь стриминговую фичу, идеально работающую в dev, но в проде выдающую спиннер с последующим выбросом всего сразу, — первый вопрос: какой хоп между хендлером и браузером буферизует, и ровно какой заголовок это отключает.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.