Реализация Streaming AI-ответов (Server-Sent Events) на сайте

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация Streaming AI-ответов (Server-Sent Events) на сайте
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация Streaming AI-ответов (Server-Sent Events) на сайте

Без стриминга пользователь смотрит на пустой экран 3–10 секунд, пока LLM генерирует ответ. С SSE-стримингом текст появляется токен за токеном — воспринимается как мгновенный ответ, хотя общее время не изменилось.

Как работает стриминг LLM

LLM генерирует токены последовательно. API провайдеров поддерживает stream=True — в этом режиме сервер отправляет каждый токен сразу после генерации, не дожидаясь завершения.

Протокол SSE (Server-Sent Events) — это HTTP-соединение, которое остаётся открытым. Сервер отправляет текстовые события в формате data: {...}\n\n. Браузер читает их через EventSource API.

Серверная часть (Python/FastAPI)

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
import json

app = FastAPI()
client = AsyncOpenAI()

async def stream_openai_response(messages: list[dict], model: str):
    async with client.chat.completions.stream(
        model=model,
        messages=messages,
        temperature=0.7
    ) as stream:
        async for chunk in stream:
            delta = chunk.choices[0].delta
            if delta.content:
                yield f"data: {json.dumps({'content': delta.content})}\n\n"

        # Финальное событие
        yield "data: [DONE]\n\n"

@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
    messages = build_messages(request.history, request.message)

    return StreamingResponse(
        stream_openai_response(messages, "gpt-4o-mini"),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # отключает буферизацию Nginx
            "Connection": "keep-alive"
        }
    )

Заголовок X-Accel-Buffering: no критичен при работе за Nginx — без него прокси буферизирует ответ и стриминг не работает.

Серверная часть (Node.js/Express)

import express from "express";
import OpenAI from "openai";

const app = express();
const openai = new OpenAI();

app.post("/api/chat/stream", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");

  const { messages } = req.body;

  try {
    const stream = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages,
      stream: true,
    });

    for await (const chunk of stream) {
      const content = chunk.choices[0]?.delta?.content;
      if (content) {
        res.write(`data: ${JSON.stringify({ content })}\n\n`);
      }
    }

    res.write("data: [DONE]\n\n");
    res.end();
  } catch (error) {
    res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
    res.end();
  }
});

Клиентская часть (React)

import { useState, useCallback, useRef } from "react";

function useChatStream() {
  const [content, setContent] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const sendMessage = useCallback(async (messages: Message[]) => {
    abortRef.current = new AbortController();
    setContent("");
    setIsStreaming(true);

    try {
      const response = await fetch("/api/chat/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages }),
        signal: abortRef.current.signal,
      });

      const reader = response.body!.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        const lines = chunk.split("\n");

        for (const line of lines) {
          if (line.startsWith("data: ")) {
            const data = line.slice(6);
            if (data === "[DONE]") {
              setIsStreaming(false);
              return;
            }
            try {
              const parsed = JSON.parse(data);
              if (parsed.content) {
                setContent(prev => prev + parsed.content);
              }
            } catch {}
          }
        }
      }
    } catch (error) {
      if (error.name !== "AbortError") {
        console.error("Stream error:", error);
      }
    } finally {
      setIsStreaming(false);
    }
  }, []);

  const stop = useCallback(() => {
    abortRef.current?.abort();
    setIsStreaming(false);
  }, []);

  return { content, isStreaming, sendMessage, stop };
}

Отображение markdown в реальном времени

Стримящийся текст часто содержит markdown. Рендерить через react-markdown каждый токен дорого — перерисовка всего дерева. Лучше дебаунсить:

import ReactMarkdown from "react-markdown";
import { useDebounce } from "@/hooks/useDebounce";

function StreamingMessage({ content, isStreaming }: Props) {
  const debouncedContent = useDebounce(content, isStreaming ? 50 : 0);

  return (
    <div className="prose prose-sm max-w-none">
      <ReactMarkdown>{debouncedContent}</ReactMarkdown>
      {isStreaming && <span className="animate-pulse">▊</span>}
    </div>
  );
}

Nginx-конфигурация

location /api/chat/stream {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header X-Accel-Buffering no;
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;
    chunked_transfer_encoding on;
}

proxy_buffering off — отключает буферизацию. proxy_read_timeout 120s — продлевает таймаут для длинных ответов.

Обработка ошибок и reconnect

Если соединение прервалось, браузерный EventSource автоматически переподключается. Для fetch-подхода нужно реализовать reconnect вручную:

async function streamWithRetry(messages: Message[], maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      await sendMessage(messages);
      return;
    } catch (error) {
      if (i === maxRetries - 1 || error.name === "AbortError") throw error;
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    }
  }
}

Сроки

Базовый стриминговый эндпоинт + React-хук — 2–3 дня. Полноценный чат с историей, markdown-рендерингом и кнопкой остановки — 4–5 дней.