Оптимизация производительности Generative UI
Как сделать AI-интерфейсы быстрыми: стратегии стриминга, оптимизация бандла и паттерны рендеринга.
Парадокс производительности
Парадокс прост: 300 мс может ощущаться вечностью, а 1.2 секунды — мгновением. И в Generative UI это не теоретическое утверждение. У меня в продакшене был случай, когда переход с in-memory кэша на стриминг скелетонов снизил воспринимаемое время загрузки в три раза — при том, что суммарное время до полного компонента выросло на 80 мс.
LLM-инференс — это 200–800 мс для простого ответа и несколько секунд для мульти-инструментных. CDN, SSG и edge-кэширование эту задержку не уберут: шаг принятия решения LLM сидит на критическом пути каждого запроса. Но это не значит, что интерфейс обязан ощущаться медленным.
Эта статья — не «10 советов по производительности». Это попытка отделить, когда оптимизировать стоит, а когда — самообман и инженерный гольдплейтинг, и какая стратегия решает какую конкретную проблему. С реальными цифрами из моего продакшена, а не из бенчмарков на блог-постах.
Когда НЕ нужно оптимизировать
Прежде чем читать про шесть стратегий ниже, ответьте на три вопроса:
- Вы измерили текущую производительность? Если нет — закройте эту вкладку и поставьте трекинг TTFC/TTIC. Половина моих клиентов, прибежавших с «у нас всё тормозит», обнаруживали p50 в 600 мс и сходящих с ума пользователей из-за дёргающегося макета (CLS), а не задержки.
- Ваш p95 уже < 1.5 сек? Тогда стриминг скелетонов и оптимистичный UI дадут вам ~20% улучшения восприятия — но обойдутся в неделю работы. Лучше потратить эту неделю на функциональность.
- У вас < 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-4o | 400–800 мс | Высокая | Лучшее |
| GPT-4o-mini | 200–400 мс | Дешевле в 10 раз | Хорошее |
| Claude Haiku | 150–300 мс | Дешевле в 5 раз | Хорошее |
| Gemini Flash | 100–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 p50 | 580 мс | 145 мс | 90 мс |
| TTFC p95 | 1100 мс | 320 мс | 240 мс |
| TTIC p50 | 1400 мс | 720 мс | 380 мс (cache hit) |
| TTIC p95 | 2800 мс | 1500 мс | 1300 мс (cache miss) |
| CLS | 0.18 | 0.04 | 0.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? Давайте поговорим — оптимизация на всём стеке является одной из наших специализаций.
Alex
Generative UI Engineer & Consultant
Senior-инженер, специализирующийся на AI-интерфейсах и системах Generative UI. Помогаю продуктовым командам шипить быстрее с правильным GenUI-стеком.
Похожие статьи
Κατασκευάζοντας το Πρώτο σας Generative UI με το Vercel AI SDK
Βήμα-βήμα οδηγός για τη δημιουργία της πρώτης σας AI-powered διεπαφής με streaming συστατικά.
CopilotKit vs Vercel AI SDK vs Thesys: Σύγκριση Frameworks
Μια ειλικρινής σύγκριση των τριών κύριων frameworks Generative UI, με πλεονεκτήματα, μειονεκτήματα και πότε να χρησιμοποιείτε το καθένα.
Προσβασιμότητα σε Generative UI: Δημιουργία Συμπεριληπτικών AI Διεπαφών
Πρακτικός οδηγός για προσβάσιμα γεννητικά interfaces — screen readers, πλοήγηση με πληκτρολόγιο και συνδυαστικά προβλήματα προσβασιμότητας.
Будьте в курсе Generative UI
Еженедельные статьи, обновления фреймворков и практические руководства — прямо в почту.
Нужна помощь с реализацией прочитанного?
Записаться на бесплатную консультацию