Перейти к содержанию
Innovialabs

Как сделать потоковый вывод ответов LLM в веб-приложении

Практика4 мин

Коротко

Запросите у API модели поток, передавайте каждый фрагмент в браузер как Server-Sent Events, читайте его через fetch и reader, дописывайте текст в интерфейс по мере поступления и убедитесь, что прокси не буферизует ответ.

Короткий ответ

Потоковый вывод — это когда пользователь видит ответ слово за словом, а не ждёт его целиком. Цепочка из трёх звеньев:

  1. Бэкенд → API модели: включаете режим стриминга в SDK провайдера и получаете фрагменты по мере генерации.
  2. Бэкенд → браузер: пересылаете фрагменты в потоковом HTTP-ответе, обычно в формате Server-Sent Events (SSE).
  3. Браузер → интерфейс: читаете поток и дописываете текст в сообщение на экране.

Не вызывайте API модели напрямую из браузера: ключ окажется открытым. Бэкенд всегда посередине.

SSE или чистый fetch-поток

ВариантПлюсыМинусы
EventSource (нативный SSE)Простой API, автопереподключениеТолько GET, без тела запроса и своих заголовков
fetch + формат SSEPOST с JSON, заголовки авторизации, AbortControllerПоток разбираете сами
fetch + простой текстСамый простой форматСложно передавать метаданные и ошибки
WebSocketДвусторонняя связьЛишняя инфраструктура для одностороннего потока

Для чата практичнее всего fetch с POST-запросом и ответом в формате SSE: историю сообщений можно передать в теле, а структура событий остаётся понятной.

Бэкенд: пересылка потока

Пример использует стандартные Response и ReadableStream из Fetch API — они работают в современных Node.js-средах и route handlers. llm.stream — условный потоковый вызов вашего SDK.

export async function POST(req: Request) {
  const { messages } = await req.json();
  const encoder = new TextEncoder();
  const send = (obj: unknown) => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`);

  const body = new ReadableStream({
    async start(controller) {
      try {
        for await (const text of llm.stream(messages, { signal: req.signal })) {
          controller.enqueue(send({ text }));
        }
        controller.enqueue(send({ done: true }));
      } catch {
        controller.enqueue(send({ error: "generation_failed" }));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  });
}

Главное: передайте сигнал отмены запроса в вызов модели, чтобы генерация останавливалась, когда пользователь ушёл. Ошибки отправляйте обычным событием — к моменту сбоя посреди потока HTTP-статус уже 200.

Фронтенд: чтение и отмена

const controller = new AbortController();

async function ask(messages: Message[], onText: (t: string) => void) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages }),
    signal: controller.signal,
  });
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    const events = buffer.split("\n\n");
    buffer = events.pop() ?? "";
    for (const event of events) {
      if (!event.startsWith("data: ")) continue;
      const msg = JSON.parse(event.slice(6));
      if (msg.error) throw new Error(msg.error);
      if (msg.text) onText(msg.text);
    }
  }
}
// Кнопка «Стоп»: controller.abort();

Сетевой фрагмент не равен одному событию: в нём может быть половина события или несколько сразу. Поэтому код держит буфер и разбирает только завершённые события, разделённые пустой строкой.

Markdown на лету

  • Перерисовывайте весь накопленный текст, а не каждый токен отдельно: разметка зависит от контекста (списки, блоки кода).
  • Ограничьте частоту обновлений, например раз в кадр анимации, чтобы не перерисовывать интерфейс на каждый мелкий фрагмент.
  • Незакрытый блок кода или таблица посреди потока — это нормально: хороший markdown-рендерер с этим справляется, а финальный рендер всё выравнивает.
  • Очищайте HTML. Вывод модели — недоверенные данные, не вставляйте сырой HTML в страницу.
  • Автопрокрутку держите только пока пользователь внизу чата.

Ошибки и буферизация на прокси

Самая частая жалоба: «локально стриминг работает, а на проде ответ появляется целиком». Причина почти всегда в буферизации:

  • Nginx по умолчанию буферизует проксируемые ответы. Отключите это через proxy_buffering off; для нужного маршрута или отправляйте заголовок X-Accel-Buffering: no.
  • Сжатие (gzip/brotli) на прокси или CDN может накапливать данные перед отправкой. Исключите text/event-stream.
  • CDN и балансировщики могут обрывать соединение по таймауту простоя. При долгих паузах отправляйте строку-комментарий вроде : ping.
  • Serverless-платформы могут ограничивать длительность ответа — проверьте поддержку стриминга у своей платформы.

Также обработайте: нажатие «Стоп» (сохраните частичный ответ), обрыв сети (покажите кнопку повтора) и ошибки лимитов провайдера (понятное сообщение вместо пустого пузыря).

Частые вопросы

Нет, общее время генерации то же. Улучшается воспринимаемая скорость: первые слова появляются почти сразу, и пользователь не смотрит на индикатор загрузки.

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.