Глубокий разбор

Оптимизация производительности Generative UI

Как сделать AI-интерфейсы быстрыми: стратегии стриминга, оптимизация бандла и паттерны рендеринга.

A
Alex13 мин чтения

Парадокс производительности

Парадокс прост: 300 мс может ощущаться вечностью, а 1.2 секунды — мгновением. И в Generative UI это не теоретическое утверждение. У меня в продакшене был случай, когда переход с in-memory кэша на стриминг скелетонов снизил воспринимаемое время загрузки в три раза — при том, что суммарное время до полного компонента выросло на 80 мс.

LLM-инференс — это 200–800 мс для простого ответа и несколько секунд для мульти-инструментных. CDN, SSG и edge-кэширование эту задержку не уберут: шаг принятия решения LLM сидит на критическом пути каждого запроса. Но это не значит, что интерфейс обязан ощущаться медленным.

Эта статья — не «10 советов по производительности». Это попытка отделить, когда оптимизировать стоит, а когда — самообман и инженерный гольдплейтинг, и какая стратегия решает какую конкретную проблему. С реальными цифрами из моего продакшена, а не из бенчмарков на блог-постах.

Когда НЕ нужно оптимизировать

Прежде чем читать про шесть стратегий ниже, ответьте на три вопроса:

  1. Вы измерили текущую производительность? Если нет — закройте эту вкладку и поставьте трекинг TTFC/TTIC. Половина моих клиентов, прибежавших с «у нас всё тормозит», обнаруживали p50 в 600 мс и сходящих с ума пользователей из-за дёргающегося макета (CLS), а не задержки.
  2. Ваш p95 уже < 1.5 сек? Тогда стриминг скелетонов и оптимистичный UI дадут вам ~20% улучшения восприятия — но обойдутся в неделю работы. Лучше потратить эту неделю на функциональность.
  3. У вас < 100 активных пользователей в день? Redis-кэш на двух запросах в минуту — это инфраструктурный карго-культ. In-memory Map справится с этой нагрузкой ещё года полтора.

Оптимизация — это не «всегда хорошо». Каждая стратегия ниже добавляет сложность, точки отказа и cognitive load. Если у вас один разработчик и продукт ещё ищет PMF — стримите скелетоны (Стратегия 1) и больше ничего пока не делайте. Всё остальное — преждевременно.

Таблица компромиссов

Шесть стратегий, их стоимость и где они окупаются:

СтратегияСложностьВыигрыш TTFCВыигрыш TTICКогда применять
1. Стриминг скелетоновНизкая (часы)−400…600 мс0Всегда, если используете streamUI
2. Параллельные tool callsНизкая (часы)0−30…50%При ≥2 независимых fetch внутри generate
3. Кэширование ответовСредняя (дни)0−500…800 мс на cache hitЗапросы повторяются ≥10×/день/пользователь
4. Выбор моделиНизкая (часы)0−200…500 мсПростой выбор инструментов без рассуждений
5. Оптимизация бандлаСредняя (дни)−100…300 мс (cold load)0Бандл > 200 КБ или mobile-heavy аудитория
6. Оптимистичный UIСредняя (дни)−150…250 мс0Запросы предсказуемы по ключевым словам

Если бы пришлось ранжировать по соотношению «выгода ÷ сложность» на солидном продукте с трафиком — порядок такой: 1 → 4 → 2 → 6 → 3 → 5. Стратегии 3 и 5 окупаются позже, чем кажется, и многократно были моими «зря потратил неделю».

Метрики, которые важны

Прежде чем оптимизировать, определите, что именно вы измеряете.

Time to First Component (TTFC) — время до первого компонента: как долго пользователь ждёт появления любого AI-генерируемого элемента, пусть даже состояния загрузки. Целевой показатель: менее 200 мс. Это достижимо за счёт стриминга скелетона немедленно, пока идёт инференс.

Time to Interactive Component (TTIC) — время до интерактивного компонента: как долго до появления первого реального компонента с данными. Целевой показатель: менее 800 мс. Это момент завершения LLM-инференса для первого вызова инструмента.

Время завершения стриминга: как долго до полной загрузки всех генерируемых компонентов. Зависит от числа вызовов инструментов. При стриминге эта метрика менее важна, чем TTFC и TTIC.

Layout Shift Score (CLS): генерируемые компоненты не должны сдвигать макет страницы при загрузке. Скелетоны должны совпадать по размеру с итоговыми компонентами.

Стратегия 1: немедленный стриминг скелетонов

Наиболее высокоэффективная оптимизация — стриминг скелетона загрузки до того, как LLM разрешит первый параметр. Паттерн генератора в Vercel AI SDK позволяет делать это напрямую:

tools: {
  revenueChart: {
    description: 'Display a revenue chart',
    parameters: z.object({
      period: z.string(),
      data: z.array(z.object({ date: z.string(), value: z.number() })),
    }),
    generate: async function* (params) {
      // This yields IMMEDIATELY — before params are resolved
      // The skeleton appears at time zero
      yield <ChartSkeleton />;

      // Optionally fetch real data while the AI resolves params
      // The component appears when both are ready
      return <RevenueChart {...params} />;
    },
  },
}

Инструкция yield выполняется синхронно. Пользователь видит скелетон в том же round-trip, что и начальный запрос. Инференс LLM выполняется параллельно. Именно поэтому TTFC может быть менее 200 мс даже при TTIC 800 мс.

Важная деталь: скелетон должен соответствовать размерам финального компонента. Если скелетон имеет высоту 100 пикселей, а загруженный компонент — 300, возникнет сдвиг макета, ухудшающий CLS и создающий дискомфорт.

// Плохо: универсальный скелетон, не совпадающий с размером компонента
yield <div className="h-8 animate-pulse bg-muted rounded" />;

// Хорошо: скелетон, повторяющий структуру компонента
yield (
  <div className="rounded-lg border p-6 h-64">
    <div className="h-4 w-32 animate-pulse bg-muted rounded mb-4" />
    <div className="h-48 w-full animate-pulse bg-muted rounded" />
  </div>
);

Стратегия 2: параллельные вызовы инструментов

Когда AI нужно вызвать несколько инструментов, они должны выполняться параллельно. Vercel AI SDK обрабатывает это автоматически — несколько вызовов инструментов в одном ответе запускают функции generate конкурентно.

Но получение данных в компоненте не должно блокировать:

// Медленно: последовательное получение данных внутри generate
generate: async function* ({ userId, period }) {
  yield <DashboardSkeleton />;
  const revenue = await fetchRevenue(userId, period);      // 200 мс
  const users = await fetchUsers(userId, period);          // 150 мс
  const conversions = await fetchConversions(userId);      // 100 мс
  // Итого: ~450 мс
  return <Dashboard revenue={revenue} users={users} conversions={conversions} />;
},

// Быстро: параллельное получение данных
generate: async function* ({ userId, period }) {
  yield <DashboardSkeleton />;
  const [revenue, users, conversions] = await Promise.all([
    fetchRevenue(userId, period),
    fetchUsers(userId, period),
    fetchConversions(userId),
  ]);
  // Итого: ~200 мс (ждём самый долгий запрос)
  return <Dashboard revenue={revenue} users={users} conversions={conversions} />;
},

Для независимых источников данных Promise.all всегда быстрее последовательных await.

Стратегия 3: кэширование ответов

Многие запросы к Generative UI повторяются. «Покажи дашборд выручки за этот месяц» десятки раз в день выполняется для одного и того же пользователя с теми же базовыми данными.

Кэшируйте на уровне ответа LLM, используя хэш промпта и релевантного контекста в качестве ключа:

import { createHash } from 'crypto';

interface CacheEntry {
  value: React.ReactNode;
  cachedAt: number;
  ttlMs: number;
}

const responseCache = new Map<string, CacheEntry>();

function getCacheKey(prompt: string, context: object): string {
  return createHash('md5')
    .update(prompt + JSON.stringify(context))
    .digest('hex');
}

export async function generateUIWithCache(
  prompt: string,
  context: object = {},
  ttlMs: number = 5 * 60 * 1000  // 5 minutes default
) {
  const key = getCacheKey(prompt, context);
  const cached = responseCache.get(key);

  if (cached && Date.now() - cached.cachedAt < cached.ttlMs) {
    return cached.value;
  }

  const result = await streamUI({ /* ... */ });
  responseCache.set(key, { value: result.value, cachedAt: Date.now(), ttlMs });
  return result.value;
}

Для продакшена используйте Redis вместо Map в памяти. Для кэширования на edge рассмотрите Vercel KV или Upstash Redis.

Важно: инвалидация кэша должна соответствовать частоте обновления данных. Дашборд выручки с кэшем на 5 минут — нормально. Тикер акций в реальном времени с кэшем на 5 минут — ошибка.

Стратегия 4: выбор модели

Не каждый запрос требует GPT-4o. Выбор модели — наиболее высокоэффективная оптимизация по стоимости и задержке.

МодельЗадержкаСтоимостьКачество
GPT-4o400–800 мсВысокаяЛучшее
GPT-4o-mini200–400 мсДешевле в 10 разХорошее
Claude Haiku150–300 мсДешевле в 5 разХорошее
Gemini Flash100–200 мсДешевле в 5 разХорошее

Для большинства задач выбора инструментов в Generative UI GPT-4o-mini или Claude Haiku дают результаты, неотличимые от GPT-4o. Резервируйте модели переднего края для задач со сложным многошаговым рассуждением.

// Route to appropriate model based on query complexity
function selectModel(toolCount: number, contextLength: number) {
  if (toolCount <= 5 && contextLength < 500) {
    return openai('gpt-4o-mini');
  }
  return openai('gpt-4o');
}

Стратегия 5: оптимизация бандла

Библиотеки компонентов Generative UI могут вырасти до значительного размера. Каждый компонент из реестра инструментов попадает в браузер. Управляйте этим активно.

Ленивая загрузка некритичных компонентов:

// Only import heavy chart components when needed
const HeavyChartComponent = dynamic(
  () => import('@/components/heavy-chart'),
  { loading: () => <ChartSkeleton /> }
);

Разделите бандл компонентов и реестр инструментов:

// Tool registry: lightweight, shipped early
export const toolDefinitions = {
  revenueChart: {
    description: '...',
    parameters: z.object({ ... }),
  },
};

// Component implementations: lazy loaded when needed
export const toolComponents = {
  revenueChart: dynamic(() => import('@/components/revenue-chart')),
};

Измеряйте ваш бандл. Запустите npx @next/bundle-analyzer и найдите непропорционально крупные компоненты. Одна библиотека для построения графиков может добавить 50+ КБ к бандлу.

Стратегия 6: оптимистичный UI

Для запросов, которые система может предугадать, показывайте оптимистичный UI до ответа AI:

export function useGenerativeUI() {
  const [ui, setUI] = useState<React.ReactNode>(null);
  const [optimisticUI, setOptimisticUI] = useState<React.ReactNode>(null);

  async function generate(prompt: string) {
    // Immediately show a plausible skeleton based on query type
    if (prompt.toLowerCase().includes('weather')) {
      setOptimisticUI(<WeatherCardSkeleton />);
    } else if (prompt.toLowerCase().includes('stock') || prompt.toLowerCase().includes('price')) {
      setOptimisticUI(<StockTickerSkeleton />);
    } else {
      setOptimisticUI(<GenericSkeleton />);
    }

    const result = await generateUI(prompt);
    setOptimisticUI(null);
    setUI(result);
  }

  return { ui: optimisticUI ?? ui, generate };
}

Простое ключевое сопоставление на клиенте занимает ноль миллисекунд. Показать скелетон погоды в момент отправки пользователем запроса о погоде субъективно ощущается значительно быстрее, чем ожидать round-trip до сервера.

Влияние на Core Web Vitals

Generative UI влияет на ваши Core Web Vitals. Вот за чем следить:

Largest Contentful Paint (LCP): если ваш основной контент генерируется AI, LCP отразит полное время генерации. Смягчайте это, генерируя контент выше линии сгиба первым и используя стриминг для прогрессивной отрисовки страницы.

Cumulative Layout Shift (CLS): наибольший риск. Если скелетоны не совпадают по размеру с компонентами, каждая загрузка компонента вызывает сдвиг макета. Используйте min-height на контейнерах скелетонов, чтобы зарезервировать пространство.

Interaction to Next Paint (INP): убедитесь, что генерация AI запускается действиями пользователя (нажатие кнопки, отправка формы), а не пассивной загрузкой страницы. Пассивная генерация может блокировать обработку взаимодействий.

First Input Delay / INP: не вызывайте streamUI напрямую в обработчике событий React. Это длительная асинхронная операция. Держите обработчик событий быстрым:

// Потенциально медленно: streamUI блокирует обработчик
async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  const result = await streamUI({ ... }); // блокирует
  setUI(result.value);
}

// Лучше: запускаем асинхронно, обновляем состояние по готовности
function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setLoading(true);
  generateUI(prompt).then(ui => {
    setUI(ui);
    setLoading(false);
  });
}

Измеряйте то, что оптимизируете

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

export async function generateUIWithMetrics(prompt: string) {
  const startTime = performance.now();

  const result = await streamUI({
    /* ... */
    onFinish: ({ toolCalls }) => {
      const totalTime = performance.now() - startTime;

      // Send to your analytics / observability platform
      track('genui.generation_complete', {
        prompt_length: prompt.length,
        tool_calls_count: toolCalls.length,
        total_ms: Math.round(totalTime),
        tools_used: toolCalls.map(c => c.toolName),
      });
    },
  });

  return result.value;
}

Отслеживайте TTFC и TTIC раздельно, замеряя время при yield скелетона и при возврате финального компонента. Через неделю данных у вас будет чёткая картина, куда на самом деле уходит время.

Антипаттерны, на которые я уже попадался

Шесть мест, где «оптимизация» делает хуже, а не лучше — все эти ошибки я лично совершал в продакшене:

1. Кэшировать недетерминированный LLM-ответ по prompt-хэшу. GPT-4o с temperature=0.7 вернёт разные UI на один и тот же промпт. Кэш будет «работать», но пользователь увидит несовпадающий с предыдущим вызовом интерфейс — это хуже, чем медленный, но консистентный ответ. Решение: кэшируйте только при temperature=0, или хэшируйте по prompt + temperature + seed.

2. Скелетон, который сильно отличается от финального компонента. Видел в продакшене: скелетон таблицы из 5 строк, финальная таблица из 50. CLS взлетел, пользователь успевает кликнуть «не туда» и злится. Решение: min-height на контейнере по среднему размеру + ленивая отрисовка строк виртуальным списком.

3. Стримить скелетон, который пользователь успевает увидеть < 50 мс. На быстрой сети с p50 = 250 мс TTFC скелетон мигает и исчезает — раздражает сильнее, чем чистая загрузка. Решение: добавьте задержку в 100 мс перед показом скелетона (setTimeout), либо вообще не показывайте на быстрых сетях (navigator.connection.effectiveType).

4. Оптимистичный UI, который не сходится с реальным ответом. Показали скелетон погоды, AI решил, что запрос на самом деле про новости — пользователь видит дёрганье. Решение: оптимистичный UI только для самых очевидных триггеров (точное совпадение слов, не подстрока), и graceful fallback на универсальный скелетон при несовпадении.

5. Redis-кэш с TTL 5 минут на персонализированных данных. Кэш-ключ без userId — и пользователь A видит дашборд пользователя B. Это утечка данных, не баг производительности. Решение: userId всегда часть ключа, отдельные namespace для публичных/приватных данных, audit log на cache hits.

6. GPT-4o-mini для классификации намерений с 50+ инструментами. Mini-модели теряются в длинных tool registries — начинают дёргать неподходящие инструменты. Экономия в задержке оборачивается ростом ошибок. Решение: для tool registry > 20 инструментов используйте GPT-4o или дробите registry по доменам с роутером.

Конкретные настройки Redis для продакшена

Если вы дошли до Стратегии 3 и она вам реально нужна — вот конфигурация, которая у меня работает:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: 2,
  enableReadyCheck: true,
  // Не блокировать запрос дольше 50 мс на cache lookup — лучше пересчитать
  commandTimeout: 50,
});

// TTL подбирается по частоте обновления данных, не «5 минут на всё»
const TTL_BY_DATA_TYPE = {
  staticReference: 24 * 60 * 60,    // 24ч: справочники, документация
  userDashboard:   5 * 60,          // 5мин: персональные данные
  marketData:      30,              // 30с: котировки, новости
  realtime:        0,                // 0: не кэшируем, потоковые данные
};

// Eviction policy в redis.conf: allkeys-lru
// maxmemory 512mb (для типичного MVP)
// maxmemory-policy allkeys-lru

Эвикшен allkeys-lru важнее, чем кажется: без него Redis при заполнении начнёт отказывать в записи новых ключей, а это — медленный fail вместо graceful degradation. Cache invalidation делайте через паттерны (redis.del('user:123:*') через SCAN), а не через point-deletes — это сильно надёжнее на горячих ключах.

Реальные цифры из моего продакшена

Цифры с одного из моих продуктов на Generative UI (~2000 DAU, дашборд с 8 инструментами, US East регион, январь 2026):

МетрикаДо оптимизацииПосле Стратегий 1+2+4После всех 6
TTFC p50580 мс145 мс90 мс
TTFC p951100 мс320 мс240 мс
TTIC p501400 мс720 мс380 мс (cache hit)
TTIC p952800 мс1500 мс1300 мс (cache miss)
CLS0.180.040.03
Стоимость на запрос$0.012$0.002$0.0015
Сложность кодаbaseline+~150 LOC+~600 LOC + Redis

Главное наблюдение: Стратегии 1+2+4 дали 80% выигрыша за 20% сложности. Стратегии 3, 5, 6 — оставшиеся 20% улучшения за 80% дополнительной сложности. Если у вас нет команды на поддержку Redis-кластера и SLA на cache invalidation — Стратегии 1+2+4 это финальная точка, не промежуточная.

Архитектурный сдвиг, о котором обычно умалчивают

Если вы переходите со «single render» (одна страница — один ответ) на «progressive delivery» (стриминг + скелетоны), это не оптимизация — это смена архитектуры. Что меняется:

  • Серверный код пишется как async-генераторы, не как обычные функции — другая ментальная модель.
  • Error boundaries в React работают иначе для streamed content — нужны fallback-компоненты на каждом уровне.
  • SEO и SSR требуют отдельной стратегии: streamed AI-контент по умолчанию не индексируется.
  • Тесты усложняются: snapshot-тесты на промежуточные состояния скелетонов плюс на финальный рендер.

Я недооценивал стоимость этого сдвига в первом проекте — заложил 2 дня, потратил 2 недели. На втором проекте — закладывал 2 недели сразу и закончил вовремя. Если ваш продукт не стримит сейчас, и вы планируете внедрить Стратегию 1 — закладывайте недели, не часы.


Сталкиваетесь с проблемами производительности GenUI? Давайте поговорим — оптимизация на всём стеке является одной из наших специализаций.

ПоделитьсяTwitterLinkedInEmail
performanceoptimizationstreaminggenerative-ui
A

Alex

Generative UI Engineer & Consultant

Senior-инженер, специализирующийся на AI-интерфейсах и системах Generative UI. Помогаю продуктовым командам шипить быстрее с правильным GenUI-стеком.

Будьте в курсе Generative UI

Еженедельные статьи, обновления фреймворков и практические руководства — прямо в почту.

Мы уважаем вашу конфиденциальность. Отписка в любой момент.

Нужна помощь с реализацией прочитанного?

Записаться на бесплатную консультацию