Наш мозг физически не переносит пустоту. Когда пользователь нажимает кнопку «Сгенерировать отчет» или «Написать код», у нас есть ровно 400 миллисекунд. Это Порог Доэрти — критическая черта, после которой человек теряет ощущение контроля над интерфейсом. Если экран остается статичным дольше, возникает тревога: зависло приложение, отвалился интернет или сервер упал с 500-й ошибкой?
Проблема в том, что современные LLM-модели не умеют отвечать за 400 миллисекунд. Генерация качественного ответа на 2000 токенов занимает секунды, а иногда и десятки секунд. Бэкенд упирается в вычислительные лимиты GPU. Мы оказались в ситуации, когда ускорить сам процесс генерации на стороне ИИ невозможно. Значит, нам нужно ускорять восприятие времени.
В этой статье я разберу инженерную эволюцию того, как мы прячем сетевые задержки за UX-иллюзиями. Мы пройдем путь от примитивных скелетонов до Server-Sent Events и искусственного сглаживания потока, а также посмотрим, как интеграция RouterAPI решает проблему нестабильных ответов.
Фаза 1: Спиннеры мертвы. Да здравствуют скелетоны
Исторически первым решением был спиннер — бесконечно крутящееся колесо загрузки. Сегодня спиннер считается антипаттерном в тяжелых интерфейсах. Он символизирует неизвестность. Спиннер говорит «жди», но не дает никакой информации о том, сколько еще ждать и что именно происходит. Пассивное ожидание всегда кажется дольше активного.
Мы заменили спиннеры на скелетные загрузки (skeleton screens). Скелетон выполняет важнейшую психологическую задачу: он дает ложное чувство прогресса. Пользователь видит структуру будущего ответа — серые прямоугольники заголовков, списков и абзацев. Мозг дорисовывает картину и успокаивается.
Чтобы скелетон работал, он не должен быть статичным. Мы используем эффект «shimmer» (мерцание), который плавно движется слева направо, имитируя направление чтения.
.skeleton-line {
height: 16px;
background: #e2e8f0;
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.4) 20%,
rgba(255, 255, 255, 0) 40%
);
background-size: 200px 100%;
background-repeat: no-repeat;
animation: shimmer 1.5s infinite linear;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: -200px 0; }
100% { background-position: calc(200px + 100%) 0; }
}
Но у скелетонов есть предел прочности. Магия рассеивается примерно через 3 секунды. Если за это время реальные данные не начали поступать, пользователь понимает, что его обманули. Интерфейс выглядит загруженным, но остается пустым. Нам нужно подавать реальный контент.
Фаза 2: SSE-стриминг и RouterAPI
Ждать полного завершения генерации от LLM — самоубийство для конверсии. Текст нужно показывать по мере его появления, токен за токеном.
Первой мыслью инженера часто становятся WebSockets. Но тащить двунаправленный протокол, настраивать пинги, понги и следить за состоянием соединения ради однонаправленной задачи — это архитектурный оверинжиниринг. Нам не нужно слушать клиента, нам нужно непрерывно пушить ему текстовые чанки.
Идеальный инструмент для этого — Server-Sent Events (SSE). Он работает поверх обычного HTTP, поддерживает автоматическое переподключение из коробки и легко балансируется через стандартный Nginx.
Здесь мы сталкиваемся с суровой реальностью бэкенда. Зоопарк ИИ-моделей (OpenAI, Anthropic, локальные LLM через vLLM) отдает потоки по-разному. У одних провайдеров отваливается коннект на середине слова, у других меняется структура JSON в чанках, третьи уходят в таймаут. Писать обработчики под каждого провайдера на клиенте — значит превратить фронтенд в хрупкий монолит.
В нашем стеке эту проблему полностью закрывает интеграция RouterAPI. RouterAPI выступает единым интеллектуальным шлюзом. Фронтенд отправляет один стандартный POST-запрос, а RouterAPI берет на себя всю грязную работу. Он маршрутизирует запрос к оптимальному провайдеру, нормализует стоимость, а главное — отдает нам чистый, стандартизированный SSE-поток.
Если под капотом отвалится условный OpenAI, RouterAPI прозрачно переключится на фоллбек-модель. Для клиентского приложения поток просто немного замедлится, но не прервется ошибкой 502. Мы получаем мгновенный отклик: первый токен прилетает на фронтенд за те самые заветные 400 миллисекунд, заменяя скелетон на реальный текст.
Фаза 3: Эффект печатной машинки (The Typing Effect)
Казалось бы, задача решена. Бери чанки из SSE и вставляй в DOM. Но сеть непредсказуема. Токены приходят не равномерно, а рваными пачками (bursts). Текст на экране начинает дергаться: то замирает на секунду, то выплевывает сразу три абзаца текста. Это разрушает UX. Исчезает иллюзия того, что ИИ "думает и общается с тобой".
Чтобы исправить это, мы внедряем паттерн "буферизованного вывода". Мы не рендерим токены сразу. Мы складываем их в очередь и отдаем в UI с фиксированной, контролируемой скоростью, имитируя плавную печать текста человеком.
Вот как выглядит реализация такого механизма на React:
import { useState, useEffect, useRef } from 'react';
export function useStreamingText(apiUrl, requestBody) {
const [displayedText, setDisplayedText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const buffer = useRef('');
const isTyping = useRef(false);
useEffect( => {
if (!apiUrl) return;
let abortController = new AbortController;
const fetchStream = async => {
setIsGenerating(true);
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: abortController.signal
});
const reader = response.body.getReader;
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read;
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Парсим SSE формат (data: {..})
const lines = chunk.split('\n').filter(line => line.trim !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.replace('data: ', '');
if (dataStr === '[DONE]') continue;
try {
const parsed = JSON.parse(dataStr);
const token = parsed.choices[0].delta.content || '';
buffer.current += token;
// Запускаем процесс "печати", если он еще не идет
if (!isTyping.current) {
processBuffer;
}
} catch (e) {
console.error('Ошибка парсинга чанка', e);
}
}
}
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Ошибка стриминга:', error);
}
} finally {
setIsGenerating(false);
}
};
const processBuffer = => {
if (buffer.current.length === 0) {
isTyping.current = false;
return;
}
isTyping.current = true;
// Откусываем первый символ из буфера
const char = buffer.current.charAt(0);
buffer.current = buffer.current.slice(1);
setDisplayedText(prev => prev + char);
// Динамическая задержка: если буфер переполнен, печатаем быстрее
const delay = buffer.current.length > 50 ? 5 : 20;
setTimeout(processBuffer, delay);
};
fetchStream;
return => abortController.abort;
}, [apiUrl, requestBody]);
return { displayedText, isGenerating };
}
В этом коде кроется главная хитрость: динамическая задержка (delay). Если сеть тормозит и буфер почти пуст, мы печатаем символы медленно (20 мс на символ), растягивая удовольствие и маскируя сетевую задержку. Если же сеть прорвало и в буфере скопилось 50+ символов, мы ускоряем печать до 5 мс, чтобы текст не отставал от реального ответа сервера слишком сильно.
Этот простой алгоритм превращает дерганый сетевой поток в гипнотическое, плавное появление текста. Пользователь перестает обращать внимание на задержки, потому что его взгляд прикован к постоянно обновляющемуся контенту.
Инженерия восприятия
Качественный UX строится на инженерии восприятия времени. Мы не можем заставить нейросеть с миллиардами параметров генерировать ответы мгновенно. Законы физики и архитектура трансформеров диктуют свои правила.
Но комбинируя правильные инструменты, мы берем время под контроль. Скелетоны покупают нам первые три секунды спокойствия. Надежный SSE-стриминг через RouterAPI гарантирует, что поток данных не оборвется и придет в едином формате, независимо от того, какая модель работает под капотом. А клиентский буфер с эффектом печатной машинки сглаживает сетевые флуктуации, превращая ожидание в залипательный процесс наблюдения.
В итоге пользователь не ждет ответа системы. Он видит, как система работает для него в реальном времени. И именно эта иллюзия отличает хороший продукт от выдающегося.