От стримов к вебсокетам: как я боролся с буферизацией и наконец победил

от автора

Перефразируя Эдгара Дега:
«Искусство — это не то, что ты делаешь, а то, что ты заставляешь машину сделать».

Привет. Меня зовут Николай Пискунов, я руководитель направления Big Data и эксперт курса Cloud DevSecOps по безопасной разработке от Академии вАЙТИ Beeline Cloud. Продолжаю цикл статей о клиенте для облачного сервиса Ollama. В первой части я рассказал о начале работы над клиентом и оставил пасхалку про сломанный стриминг. Во второй мы переехали с PostgreSQL на MongoDB, добавили анализ проектов с уважением к .gitignore и оптимизировали фронтенд.

Я обещал рассказать о поддержке системных промтов и сравнении ответов разных моделей. Но, как это часто бывает в разработке, планы пришлось скорректировать: проблема стриминга, о которой я упоминал ещё в первой статье, вконец меня доконала. Сегодня я расскажу, почему Server-Sent Events (SSE) оказались неподходящим выбором, как вебсокеты исправили ситуацию и как я поигрался с форматированием ответов от ассистента.

Та самая пасхалка: напоминание о проблеме

В первой статье я упомянул, что «стриминг в этом проекте работает с ошибкой». Проблема была в кастомном хуке useEventStream, который использовал fetch вместо нативного EventSource. Причина была уважительной: EventSource поддерживает только GET-запросы, а нам нужен был POST для передачи больших промтов. В результате последний чанк мог теряться, соединение закрывалось преждевременно, а иногда я получал обрывки ответа.

Почему стримы не работают

Давайте честно посмотрим на доступные варианты организации стриминга от ИИ-модели.

Server-Sent Events (SSE) — то, с чего мы начинали. Это простой и элегантный протокол, когда он подходит, но в нашем случае оказался не самым удачным.

Плюсы SSE:

  • простота реализации на сервере;

  • работает поверх обычного HTTP;

  • автоматическое переподключение в браузере.

Минусы, которые стали критическими:

  • Только однонаправленная связь (сервер → клиент). Для передачи промта нам пришлось делать отдельный POST-запрос.

  • Nginx и другие прокси любят буферизировать ответы. Мы выкручивались заголовками X-Accel-Buffering: no, но это работало не везде.

  • Проблемы с корпоративными прокси и фаерволами, которые могут резать долгие соединения.

  • Ограничение на количество одновременных соединений в браузере (обычно шесть на домен).

Reactive Spring (WebFlux). Я рассматривал возможность переписать бэкенд на реактивный стек. Звучало заманчиво: неблокирующий I/O, backpressure, нативная поддержка SSE через Flux.

Плюсы WebFlux:

  • отличная масштабируемость;

  • реактивные драйверы MongoDB;

  • контроль нагрузки через backpressure.

Но минусы перевесили:

  • Проект уже написан на Spring MVC. Переход на WebFlux потребовал бы практически полной переработки бэкенда.

  • Смешивать MVC и WebFlux в одном приложении — плохая практика.

  • Команда (читай: я) гораздо лучше знакома с классическим стеком.

  • Крутая кривая обучения и сложность отладки реактивного кода.

WebSocket — компромиссный вариант. Когда я начал изучать WebSocket, понял: это именно то, что нужно. Да, протокол сложнее, но он решает все наши проблемы.

Плюсы WebSocket, которые меня убедили:

  • Полнодуплексная связь. Клиент может отправлять сообщения в любое время, не делая отдельные HTTP-запросы.

  • Меньше проблем с прокси. WebSocket спроектирован с учётом инфраструктурных особенностей.

  • Единый канал. Всё общение идёт через одно соединение: и отправка промта, и получение стрима, и ошибки, и служебные сообщения.

  • Лучше для реального времени. Никаких костылей с буферизацией и тайм-аутами.

  • Можно использовать STOMP. Этот подпротокол даёт структурированные сообщения с маршрутизацией, делая код похожим на обычные REST-контроллеры.

Минусы, с которыми пришлось смириться:

  • более сложный протокол, нужно управлять соединениями;

  • требуется обрабатывать переподключения и потерю связи;

  • немного выше накладные расходы на поддержание соединения.

Но для нашего сценария плюсы очевидно перевешивают.

Переходим на вебсокеты

Выбрав WebSocket, я решил не изобретать велосипед и использовать STOMP — проверенный протокол поверх вебсокетов, который отлично интегрируется со Spring.

Убираем старый код в бэкенде

Первым делом я удалил старый эндпоинт /api/chat/stream из ChatController. Теперь он отвечает только за очистку истории. Весь стриминг переехал в новый ChatWebSocketController:

java@Controller@RequiredArgsConstructor@Slf4jpublic class ChatWebSocketController {     private final ChatService chatService;     @MessageMapping("/chat.send")    public void sendMessage(@Valid @Payload ChatMessagePayload payload) {        new Thread(() -> {            try {                chatService.processAndStreamChat(payload);            } catch (Exception e) {                log.error("Error processing chat message", e);            }        }).start();    }     @MessageExceptionHandler    @SendToUser("/topic/errors")    public String handleException(Exception exception) {        return "Ошибка на стороне сервера: " + exception.getMessage();    }}

 

Обратите внимание: стартует новый поток для обработки сообщения, чтобы не блокировать обработчик STOMP. В продакшне я планирую заменить это пулом потоков, но пока работает и так.

Конфигурация вебсокетов оказалась простой:

java@Configuration@EnableWebSocketMessageBrokerpublic class WebSocketConfig implements WebSocketMessageBrokerConfigurer {     @Override    public void registerStompEndpoints(StompEndpointRegistry registry) {        registry.addEndpoint("/ws")                .setAllowedOriginPatterns("http://localhost:5173")                .withSockJS();    }     @Override    public void configureMessageBroker(MessageBrokerRegistry registry) {        registry.enableSimpleBroker("/topic", "/queue", "/user");        registry.setApplicationDestinationPrefixes("/app");        registry.setUserDestinationPrefix("/user");    }}

Клиенты подключаются к /ws через SockJS (фолбэк для старых браузеров), отправляют сообщения в /app/chat.send, а получают ответы в топике /topic/chat/{sessionId}.

Адаптируем сервис под вебсокеты

Сервисный слой изменился минимально. Вместо SseEmitter теперь

используется SimpMessagingTemplate для отправки сообщений:

javapublic void processAndStreamChat(ChatMessagePayload payload) {    String destination = "/topic/chat/" + payload.getSessionId();        try {        // ... подготовка запроса к Ollama Cloud ...               restClient.post()                .uri("/v1/chat/completions")                .body(requestBody)                .exchange((clientRequest, clientResponse) -> {                    // ... чтение стрима ...                                        while ((line = reader.readLine()) != null) {                        if (line.startsWith("data: ") && !line.equals("data: [DONE]")) {                            String text = parseChunk(line);                            if (!text.isEmpty()) {                                messagingTemplate.convertAndSend(destination,                                        new ChatResponsePayload(text, false));                            }                        }                    }                                        messagingTemplate.convertAndSend(destination,                            new ChatResponsePayload("", true));                    return null;                });    } catch (Exception e) {        messagingTemplate.convertAndSend(destination,                new ChatResponsePayload("Ошибка сервера: " + e.getMessage(), true));    }}

Что здесь важно:

  • автоматическое переподключение с экспоненциальной задержкой;

  • heartbeat для обнаружения мёртвых соединений;

  • отдельный канал для ошибок (/user/topic/errors);

  • обработка всех типов ошибок: STOMP-протокол, WebSocket, тайм-ауты.

Что изменилось для пользователя

В интерфейсе появился индикатор состояния подключения:

tsx<div className={`connection-status ${isConnected ? 'connected' : 'disconnected'}`}>    {isConnected ? '🟢 Онлайн' : (isConnecting ? '🟡 Подключение...' : '🔴 Офлайн')}</div>

Теперь пользователь всегда видит, работает ли соединение. Если связь потерялась, хук автоматически переподключается, и чат продолжается с того же места.

Добавляем форматирование ответов

Параллельно с переходом на вебсокеты я занялся давно назревшей проблемой: ответы от нейросети приходили как простой текст, хотя модели часто возвращают размеченный Markdown с блоками кода.

Одно дело, когда ассистент отвечает текстом, и совсем другое — когда выдаёт многострочные примеры кода. Без подсветки синтаксиса и нормального форматирования это выглядело ужасно. Решить проблему помог переход от разметки Markdown на HTML с подсветкой.

Я добавил в проект несколько зависимостей:

json"dependencies": {    "marked": "^17.0.3",    "marked-highlight": "^2.2.3",    "highlight.js": "^11.11.1",    "dompurify": "^3.3.1"}

И написал утилиту markdownToHtml:

typescriptimport { marked } from 'marked';import { markedHighlight } from 'marked-highlight';import DOMPurify from 'dompurify';import hljs from 'highlight.js';import 'highlight.js/styles/vs.css'; marked.use(    markedHighlight({        langPrefix: 'hljs language-',        highlight(code, lang) {            const language = hljs.getLanguage(lang) ? lang : 'plaintext';            return hljs.highlight(code, { language }).value;        }    })); marked.setOptions({ breaks: true, gfm: true }); export const markdownToHtml = (markdown: string): string => {    if (!markdown) return '';        try {        const rawHtml = marked.parse(markdown) as string;        return DOMPurify.sanitize(rawHtml, {            USE_PROFILES: { html: true },            ALLOWED_ATTR: ['class', 'style']        });    } catch (error) {        console.error('Error parsing Markdown:', error);        return DOMPurify.sanitize(markdown);    }};

Теперь каждый ответ ассистента проходит через этот конвейер. Блоки кода получают подсветку синтаксиса с нумерацией строк.

Доработка компонента MessageList

В компоненте MessageList я добавил постобработку HTML: для каждого блока pre определяем язык по классу и добавляем атрибут data-language:

typescriptconst processHtmlContent = useCallback((html: string): string => {    const tempDiv = document.createElement('div');    tempDiv.innerHTML = html;     const preElements = tempDiv.querySelectorAll('pre');    preElements.forEach(pre => {        const codeElement = pre.querySelector('code');        if (codeElement) {            const classList = codeElement.className.split(' ');            const languageClass = classList.find(c => c.startsWith('language-'));            const language = languageClass ? languageClass.replace('language-', '') : 'plaintext';                        pre.setAttribute('data-language', language);                        const code = codeElement.textContent || '';            const lines = code.split('n');            codeElement.innerHTML = lines                .map(line => `<span class="line">${line || ' '}</span>`)                .join('n');        }    });     return tempDiv.innerHTML;}, []);

В CSS добавил стили для красивого отображения:

css.formatted-content pre {    position: relative;    background-color: #0d1117;    border-radius: 8px;    padding: 1em 0 1em 1em;    margin: 1.5em 0;    overflow-x: auto;    border: 1px solid #30363d;    font-family: 'SF Mono', monospace;    font-size: 14px;} .formatted-content pre::before {    content: attr(data-language);    position: absolute;    top: 0.5em;    right: 1em;    color: #8b949e;    font-size: 12px;    background-color: #1f2937;    padding: 0.2em 0.8em;    border-radius: 20px;    border: 1px solid #30363d;} .formatted-content pre .copy-code-btn {    position: absolute;    top: 0.5em;    right: 5em;    background-color: #1f2937;    color: #e6edf3;    border: 1px solid #30363d;    border-radius: 6px;    padding: 0.2em 0.8em;    font-size: 12px;    cursor: pointer;    opacity: 0;    transition: opacity 0.2s ease;} .formatted-content pre:hover .copy-code-btn {    opacity: 1;}

Модальное окно для длинных сообщений

Для очень длинных ответов оставил возможность открыть сообщение в модальном окне с полным форматированием. Это полезно, когда ассистент выдаёт несколько сотен строк кода.

Что дальше?

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

В ближайших планах:

  • Системные промты. Возможность задать контекст для ассистента, который будет учитываться во всех ответах в рамках сессии.

  • Сравнение моделей. Отправлять один запрос сразу нескольким моделям и видеть их ответы рядом. Это поможет выбирать лучшую модель под конкретную задачу.

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

Выводы

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

  • стриминг работает стабильно, без потери чанков;

  • единый канал для всего общения упростил архитектуру;

  • появилась возможность легко добавлять новые типы сообщений (уведомления, статусы, команды);

  • интерфейс стал информативнее с индикатором состояния.

А форматирование ответов с подсветкой кода сделало использование чата по-настоящему приятным. Теперь код от ассистента выглядит так, как и должен выглядеть — с подсветкой, нумерацией строк и возможностью скопировать одним кликом.

Проект живёт и развивается на GitVerse в ветке ws-chat. Заходите, ставьте звёздочки, создавайте issue и пул-реквесты. Вместе мы сделаем его ещё лучше!

P. S. Если ваш стриминг всё ещё не работает, попробуйте вебсокеты. И не забудьте о heartbeat.

Beeline Cloud — безопасный облачный провайдер. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

ссылка на оригинал статьи https://habr.com/ru/articles/1057902/