open atlas
↑ К треку
Next.js с нуля до senior NEXT · 03 · 04

Стриминговые ответы: ReadableStream, SSE для LLM-интерфейсов и прокси, которые их буферизуют

Route handlers стримят через ReadableStream и SSE — транспорт LLM-интерфейсов. Backpressure работает по pull: продюсер пишет, когда потребитель читает. Suspense стримит HTML; хендлеры — данные. Оба молча ломаются за буферизующими прокси и сжатием — шлите no-transform.

NEXT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Команда выкатывает 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. Какой механизм отказал?

Вспомните перед уходом
  1. 01
    Каковы три продакшен-критичные части стримингового SSE-хендлера и что ломается без каждой?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.