Стриминг serverless-ответов через Server-Sent Events (SSE)

Стриминг — самый мощный рычаг влияния на воспринимаемую задержку. Как стримить ответы из serverless-функций через SSE: async-генераторы, стриминг токенов LLM, обработка отключений и heartbeat — и когда стримить не стоит.

Стриминг serverless-ответов через Server-Sent Events (SSE)

Если вы пользовались современным AI-продуктом, вы чувствовали разницу, которую даёт стриминг. Ответ не появляется целиком после неловкой паузы — он печатается по токену, как только модель начинает его выдавать. Это не косметика. Это самый мощный рычаг влияния на воспринимаемую задержку, и на serverless его исторически было сложнее всего сделать правильно.

Эта статья — про стриминг ответов из serverless-функций через Server-Sent Events (SSE): что такое SSE, почему для этой задачи он подходит лучше WebSockets, как стримить из функции с помощью async-генератора, как переживать отключения клиента и какие паттерны важны для стриминга токенов LLM и прогресса долгих задач.

Почему стриминг важнее чистой скорости

Запрос, который отдаёт ответ на 2000 токенов за четыре секунды, ощущается медленным. Тот же запрос в стриминге ощущается быстрым — потому что пользователь видит первые слова через несколько сотен миллисекунд и читает по мере поступления остального. Объём работы не изменился; изменился опыт.

Стриминг окупается всегда, когда ответ формируется постепенно:

  • Стриминг токенов LLM — классический «печатающийся» ответ в стиле ChatGPT. Модель выдаёт токены по мере декодирования; вы форвардите каждый в тот момент, когда он появляется.
  • Прогресс AI-агента — «ищу…», «вызываю инструмент цен…», «формирую черновик…». Многошаговые агенты могут молчать секундами; стриминг превращает это молчание в видимый прогресс.
  • Долгие задачи с промежуточным выводом — генератор отчётов, экспорт данных, миграция, которая выдаёт строку на каждую обработанную запись.
  • Поиск и извлечение — первые результаты на экране, пока длинный хвост ещё ранжируется.

Во всех случаях цель одна: сократить время до первого байта, а не общее время. Стриминг — это способ сделать это, не обманывая пользователя фейковым прогресс-баром.

Server-Sent Events против WebSockets

Есть два очевидных способа доставлять данные в браузер: WebSockets и Server-Sent Events. Для стриминга ответа — когда говорит один сервер, а клиент слушает — SSE почти всегда подходит лучше, и именно его serverless-шлюз может отдавать чисто.

Server-Sent Events — предельно простой стандарт: сервер отвечает с Content-Type: text/event-stream и пишет текстовые фреймы вида data: {"delta":"Прив"}\n\n. Браузер читает их встроенным EventSource (или fetch + reader потока). Это односторонний канал поверх обычного HTTP, с автоматическим переподключением, без рукопожатия и специального протокола.

WebSockets дают полнодуплексный сокет. Это мощно, когда клиент и сервер непрерывно обмениваются данными — мультиплеер, совместное редактирование, живые курсоры. Но это постоянное соединение с состоянием — ровно то, что serverless-функции держать не предназначены. Для «сгенерировать ответ и отдать его потоком» WebSocket — тяжеловесный ответ на односторонний вопрос.

Правило: если данные текут в одну сторону (сервер → клиент), берите SSE. Его проще собрать, проще эксплуатировать, и он естественно ложится на request/response-шлюз.

Стриминг из serverless-функции: возвращайте async-генератор

На Inquir функция стримит, возвращая async-генератор. Вместо того чтобы вычислить весь результат и вернуть его, вы yield-ите куски по мере готовности, а шлюз форвардит каждый клиенту как SSE-событие в реальном времени.

export async function* handler(event, context) {
  const { prompt } = JSON.parse(event.body);

  // Стримим токены прямо из модели по мере декодирования.
  const completion = await llm.stream({ prompt });
  for await (const token of completion) {
    yield { delta: token };            // -> data: {"delta":"..."}\n\n на проводе
  }

  yield { done: true, usage: completion.usage };
}

Здесь происходят две вещи. Во-первых, функция — это async function*, то есть async-генератор, поэтому рантайм понимает, что её надо стримить, а не ждать единого возвращаемого значения. Во-вторых, каждый yield немедленно сбрасывается клиенту как фрейм data:. Возвращённый объект сериализуется в JSON и отправляется как data: {...}\n\n; возвращённая строка пишется на провод как есть — так можно эмитить свои именованные SSE-события (event: tool_start\ndata: {...}\n\n), когда нужно.

Клиентская сторона столь же компактна:

const res = await fetch('/api/agent', { method: 'POST', body: JSON.stringify({ prompt }) });
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  render(decoder.decode(value));       // добавляем дельту в UI
}

Никакого сокета, библиотеки или согласования протокола. Просто POST, который отвечает потоком.

Модель событий для AI-агентов

Голых токен-дельт достаточно для обычного ответа, но агенту, который вызывает инструменты, полезен небольшой словарь именованных событий, чтобы UI показывал, что агент делает, а не только что он говорит. Полезная конвенция:

  • start — запуск начался; отдаём запрос и доступные инструменты.
  • delta — токен контента; тот самый «печатающийся» текст в реальном времени.
  • tool_start — агент вызвал инструмент (показать плашку «идёт поиск…»).
  • tool_result — инструмент вернул результат (свернуть плашку, показать цитату).
  • done — финальный ответ плюс сводка/usage.
  • error — что-то упало посреди потока; клиент показывает частичный ответ и повтор.

Поскольку вы сами решаете, что yield-ить, вы эмитите именно эти события в нужные моменты. UI становится маленькой машиной состояний, управляемой потоком, а состояния «думаю…» перестают быть фейковыми спиннерами и становятся настоящими.

Как пережить отключения — то, о чём все забывают

Вот режим отказа, который бьёт команды в проде: пользователь закрывает вкладку на середине 30-секундного ответа, но ваша функция продолжает работать — всё ещё вызывает модель, всё ещё жжёт токены, всё ещё держит воркер занятым ради запроса, который никто не слушает. Умножьте на реальный трафик — и вы платите за кучу работы, которая никому не доходит.

Inquir передаёт вашему стриминг-хендлеру сигнал отмены в контексте, чтобы вы могли отреагировать на отключение:

export async function* handler(event, context) {
  const completion = await llm.stream({ prompt, signal: context.signal });
  for await (const token of completion) {
    if (context.signal.aborted) return;   // клиент ушёл — перестаём тянуть токены
    yield { delta: token };
  }
}

context.signal — это стандартный сигнал AbortController. Когда клиент отключается, рантайм отменяет его и перестаёт вычитывать ваш генератор, отрабатывая блоки finally, чтобы вы могли отменить вызов модели и освободить ресурсы. Если передать context.signal прямо в SDK модели (большинство поддерживают AbortSignal), закрытая вкладка действительно отменяет дорогую работу, а не оставляет её сиротой.

Heartbeat, буферизация и прокси

Две операционные детали отличают демо от того, что выживает в реальных сетях:

  • Heartbeat. Поток может законно молчать много секунд — агент думает, инструмент отвечает медленно. Простаивающие соединения обрывают балансировщики и прокси. Шлюз шлёт периодический SSE-комментарий (: ping), чтобы держать соединение живым сквозь эти прокладки, и задумчивая пауза не читается как мёртвый сокет.
  • Сбрасывайте, не буферизуйте. SSE ощущается реалтаймом, только если каждый фрейм сбрасывается, а не копится до конца ответа. Шлюз сразу флашит заголовки, переводя клиента в режим стриминга, и уступает event loop между чанками, чтобы сетевой слой выталкивал байты по мере производства. Если вы когда-нибудь видели «стриминговый» эндпоинт, который вываливает весь вывод одним залпом в конце, — это была буферизация, и она убивает весь смысл.

Ничего из этого настраивать не нужно — это поведение стриминг-пути по умолчанию. Но знание, что оно есть, объясняет, почему молчаливый агент остаётся на связи и почему ваши токены действительно приходят по одному.

Стриминг в Node.js, Python и Go

Стриминг — первоклассная возможность во всех трёх рантаймах, с одной разницей в форме, которую стоит знать:

  • Node.js 22 и Python 3.12 стримят так, как вы ожидаете — async-генератор с yield (Python: async def / yield).
  • Go 1.22 стримит, возвращая канал, который рантайм сливает в SSE-ответ — это лучше ложится на модель конкурентности Go, чем генератор.

Один нюанс, который надо учесть: стриминг в Go требует тёплого/горячего пути исполнения; холодный путь стриминг отклоняет. На практике для латентно-чувствительных стриминг-эндпоинтов вы и так держите тёплый пул, так что это редко мешает — но это ровно тот межрантаймовый нюанс, который лучше знать до выбора языка для стриминг-сервиса, а не обнаруживать в проде.

Когда не стоит стримить

Стриминг — не бесплатная сложность, и для многих эндпоинтов это неверный инструмент:

  • Маленькие мгновенные ответы. Если весь payload — 200 байт и считается за 5 мс, стриминг лишь добавляет механику. Верните JSON.
  • Работа, которая переживает запрос. Стриминг держит соединение открытым, пока идёт работа; он не делает работу устойчивой. Для 20-минутного экспорта не стримьте 20 минут — примите запрос, быстро ответьте, запустите фоновой задачей, а клиент пусть опрашивает или получит вебхук. Стриминг — для вывода, производимого во время запроса, которого пользователь активно ждёт, а не для выноса долгой работы.
  • Machine-to-machine вызовы, которым нужен один JSON. Если вызывающий — другой сервис, которому нужен только финальный результат, обычный ответ проще для всех.

Подбирайте транспорт под форму работы: мгновенно → JSON, производится-постепенно-пока-ждут → SSE-поток, переживает-запрос → фоновая задача.

Итог

Стриминг — самый дешёвый крупный выигрыш в AI-UX, и на serverless-бэкенде он сводится к небольшому набору движений: вернуть async-генератор, yield-ить события по мере их появления, уважать сигнал отмены, чтобы закрытая вкладка отменяла реальную работу, и дать платформе взять на себя heartbeat и флаш. Сделайте это — и четырёхсекундный ответ будет ощущаться мгновенным: первый токен за несколько сотен миллисекунд, остальное дочитывается само, пока воркер тихо стримит, а вызов модели отменяется в тот момент, когда никто больше не слушает.

Если вы строите чат, агентов или что угодно, где ответ приходит по кусочкам, — стримьте. Пользователи прочитают разницу раньше, чем смогут её измерить.