Ottimizzazione delle performance per la Generative UI
Come rendere veloci le interfacce AI: strategie di streaming, ottimizzazione del bundle e pattern di rendering. Con numeri reali dalla produzione.
Il paradosso delle performance
Il paradosso è semplice: 300ms possono sembrare un'eternità, mentre 1,2 secondi possono sembrare un istante. Nella Generative UI non è un'affermazione teorica. In produzione ho vissuto un caso in cui passare da una cache in memoria allo streaming di skeleton ha ridotto di tre volte il tempo di caricamento percepito — pur avendo il tempo totale fino al componente finale aumentato di 80ms.
L'inferenza LLM richiede 200–800ms per una risposta semplice e diversi secondi per quelle con più strumenti. CDN, SSG e caching edge non possono eliminare questa latenza: la fase decisionale dell'LLM si trova nel percorso critico di ogni richiesta. Ma questo non significa che l'interfaccia debba sembrare lenta.
Questo articolo non è una lista di "10 consigli sulle performance". È un tentativo di distinguere quando vale la pena ottimizzare da quando si tratta di autoinganni e gold plating ingegneristico, e quale strategia risolve quale problema specifico. Con numeri reali dalla mia produzione, non da benchmark di post sul blog.
Quando NON ottimizzare
Prima di leggere le sei strategie qui sotto, rispondi a tre domande:
- Hai misurato le performance attuali? Se no, chiudi questa scheda e imposta il tracciamento di TTFC/TTIC. Metà dei clienti che arrivavano da me con "tutto è lento" scoprivano un p50 di 600ms e utenti impazziti per il layout instabile (CLS), non per la latenza.
- Il tuo p95 è già sotto 1,5 secondi? Lo streaming di skeleton e la UI ottimistica ti daranno circa il 20% di miglioramento percepito — ma costeranno una settimana di lavoro. Meglio spendere quella settimana sulle funzionalità.
- Hai meno di 100 utenti attivi al giorno? Una cache Redis su due richieste al minuto è cargo cult infrastrutturale. Una
Mapin memoria regge tranquillamente quel carico per un altro anno e mezzo.
L'ottimizzazione non è "sempre positiva". Ogni strategia qui sotto aggiunge complessità, punti di guasto e carico cognitivo. Se sei uno sviluppatore solista e il prodotto sta ancora cercando il PMF — usa lo streaming di skeleton (Strategia 1) e per ora non fare altro. Tutto il resto è prematuro.
Tabella dei compromessi
Sei strategie, il loro costo e quando si ripagano:
| Strategia | Complessità | Guadagno TTFC | Guadagno TTIC | Quando applicare |
|---|---|---|---|---|
| 1. Streaming skeleton | Bassa (ore) | −400…600ms | 0 | Sempre, se usi streamUI |
| 2. Tool call in parallelo | Bassa (ore) | 0 | −30…50% | Con ≥2 fetch indipendenti dentro generate |
| 3. Caching delle risposte | Media (giorni) | 0 | −500…800ms su cache hit | Query ripetute ≥10×/giorno/utente |
| 4. Selezione del modello | Bassa (ore) | 0 | −200…500ms | Selezione semplice degli strumenti senza ragionamento |
| 5. Ottimizzazione del bundle | Media (giorni) | −100…300ms (cold load) | 0 | Bundle > 200KB o audience prevalentemente mobile |
| 6. UI ottimistica | Media (giorni) | −150…250ms | 0 | Query prevedibili per parole chiave |
Se dovessi classificare per rapporto "beneficio ÷ complessità" su un prodotto con traffico reale, l'ordine sarebbe: 1 → 4 → 2 → 6 → 3 → 5. Le strategie 3 e 5 si ripagano più tardi di quanto sembri e sono state le mie "settimana sprecata" più volte.
Le metriche che contano
Prima di ottimizzare, definisci cosa stai misurando.
Time to First Component (TTFC): Quanto tempo passa prima che l'utente veda qualsiasi elemento generato dall'AI, anche uno stato di caricamento. Obiettivo: sotto i 200ms. È raggiungibile con lo streaming immediato dello skeleton mentre l'inferenza è in corso.
Time to Interactive Component (TTIC): Quanto tempo passa prima che appaia il primo componente reale con i dati. Obiettivo: sotto gli 800ms. Questo corrisponde al completamento dell'inferenza dell'LLM per la prima tool call.
Streaming Completion Time: Quanto tempo prima che tutti i componenti generati siano caricati. Dipende dal numero di tool call. Con lo streaming, questa metrica è meno importante di TTFC e TTIC.
Layout Shift Score (CLS): I componenti generati non devono spostare il layout della pagina durante il caricamento. Gli skeleton devono corrispondere alle dimensioni del componente finale.
Strategia 1: streaming immediato degli skeleton
La singola ottimizzazione con il maggiore impatto è trasmettere in streaming uno skeleton di caricamento prima che l'LLM risolva il primo parametro. Il pattern a generatore del Vercel AI SDK lo abilita direttamente:
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) {
// Questo yield avviene IMMEDIATAMENTE — prima che i parametri siano risolti
// Lo skeleton appare al tempo zero
yield <ChartSkeleton />;
// Opzionalmente recupera i dati reali mentre l'AI risolve i parametri
// Il componente appare quando entrambi sono pronti
return <RevenueChart {...params} />;
},
},
}
L'istruzione yield viene eseguita in modo sincrono. L'utente vede lo skeleton nello stesso round trip della richiesta iniziale. L'inferenza dell'LLM avviene in parallelo. Ecco perché il TTFC può essere sotto i 200ms anche quando il TTIC è a 800ms.
Dettaglio critico: Lo skeleton deve corrispondere alle dimensioni del componente finale. Se lo skeleton è alto 100px e il componente caricato è alto 300px, si genera un layout shift che peggiora il CLS e risulta fastidioso.
// Male: skeleton generico che non corrisponde alle dimensioni del componente
yield <div className="h-8 animate-pulse bg-muted rounded" />;
// Bene: skeleton che replica la struttura del componente
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>
);
Strategia 2: tool call in parallelo
Quando l'AI deve chiamare più strumenti, devono essere eseguiti in parallelo. Il Vercel AI SDK lo gestisce automaticamente — più tool call in una singola risposta eseguono le loro funzioni generate in modo concorrente.
Ma il data fetching del tuo componente non deve bloccare:
// Lento: data fetching sequenziale all'interno di generate
generate: async function* ({ userId, period }) {
yield <DashboardSkeleton />;
const revenue = await fetchRevenue(userId, period); // 200ms
const users = await fetchUsers(userId, period); // 150ms
const conversions = await fetchConversions(userId); // 100ms
// Totale: ~450ms
return <Dashboard revenue={revenue} users={users} conversions={conversions} />;
},
// Veloce: data fetching in parallelo
generate: async function* ({ userId, period }) {
yield <DashboardSkeleton />;
const [revenue, users, conversions] = await Promise.all([
fetchRevenue(userId, period),
fetchUsers(userId, period),
fetchConversions(userId),
]);
// Totale: ~200ms (dipende dal fetch più lento)
return <Dashboard revenue={revenue} users={users} conversions={conversions} />;
},
Per sorgenti dati indipendenti, Promise.all è sempre più veloce degli await sequenziali.
Strategia 3: caching delle risposte
Molte query Generative UI vengono ripetute. "Mostrami la dashboard dei ricavi di questo mese" viene eseguita decine di volte al giorno per lo stesso utente con gli stessi dati sottostanti.
Metti in cache a livello di risposta LLM, usando come chiave un hash del prompt e del contesto rilevante:
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;
}
Per la produzione, usa Redis invece di una Map in memoria. Valuta Vercel KV o Upstash Redis per il caching su edge.
Importante: L'invalidazione della cache deve corrispondere alla frequenza di aggiornamento dei dati. Una dashboard dei ricavi con 5 minuti di cache va bene. Un ticker azionario in tempo reale con 5 minuti di cache è un errore.
Strategia 4: selezione del modello
Non ogni query richiede GPT-4o. La selezione del modello è l'ottimizzazione con il miglior rapporto costo/latenza disponibile.
| Modello | Latenza | Costo | Qualità |
|---|---|---|---|
| GPT-4o | 400–800ms | Alto | Migliore |
| GPT-4o-mini | 200–400ms | 10x più economico | Buona |
| Claude Haiku | 150–300ms | 5x più economico | Buona |
| Gemini Flash | 100–200ms | 5x più economico | Buona |
Per la maggior parte dei compiti di selezione degli strumenti nella Generative UI, GPT-4o-mini o Claude Haiku producono risultati indistinguibili da GPT-4o. Riserva i modelli frontier per ragionamenti complessi a più passi.
// 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');
}
Strategia 5: ottimizzazione del bundle
Le librerie di componenti Generative UI possono crescere molto. Ogni componente nel registro degli strumenti viene distribuito al browser. Gestisci questo aspetto attivamente.
Carica in lazy i componenti non critici:
// Importa i componenti grafici pesanti solo quando necessario
const HeavyChartComponent = dynamic(
() => import('@/components/heavy-chart'),
{ loading: () => <ChartSkeleton /> }
);
Separa il bundle dei componenti dal registro degli strumenti:
// Tool registry: leggero, distribuito presto
export const toolDefinitions = {
revenueChart: {
description: '...',
parameters: z.object({ ... }),
},
};
// Implementazioni dei componenti: caricate in lazy quando necessario
export const toolComponents = {
revenueChart: dynamic(() => import('@/components/revenue-chart')),
};
Misura il tuo bundle. Esegui npx @next/bundle-analyzer e cerca componenti sproporzionatamente grandi. Una singola libreria di grafici può aggiungere 50KB+ al bundle.
Strategia 6: UI ottimistica
Per le query che il sistema può prevedere, mostra una UI ottimistica prima che l'AI risponda:
export function useGenerativeUI() {
const [ui, setUI] = useState<React.ReactNode>(null);
const [optimisticUI, setOptimisticUI] = useState<React.ReactNode>(null);
async function generate(prompt: string) {
// Mostra immediatamente uno skeleton plausibile in base al tipo di query
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 };
}
Il semplice keyword matching sul client ha latenza zero. Mostrare uno skeleton meteo nel momento in cui l'utente invia una query meteo risulta percettivamente molto più veloce che aspettare il round trip al server.
Impatto sui Core Web Vitals
La Generative UI influenza i Core Web Vitals. Ecco cosa monitorare:
Largest Contentful Paint (LCP): Se il contenuto principale è generato dall'AI, l'LCP rifletterà l'intero tempo di generazione. Mitigalo generando prima il contenuto above-the-fold e usando lo streaming per renderizzare la pagina progressivamente.
Cumulative Layout Shift (CLS): Il rischio maggiore. Se gli skeleton non corrispondono alle dimensioni dei componenti, ogni caricamento causa layout shift. Usa min-height sui container degli skeleton per riservare lo spazio.
Interaction to Next Paint (INP): Assicurati che la generazione AI sia attivata da azioni dell'utente (click su pulsanti, invio di form), non da eventi passivi al caricamento della pagina. La generazione passiva può bloccare la gestione delle interazioni.
First Input Delay / INP: Non eseguire streamUI direttamente in un event handler React. È un'operazione asincrona di lunga durata. Mantieni l'event handler veloce:
// Potenzialmente lento: streamUI blocca l'handler
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = await streamUI({ ... }); // blocca
setUI(result.value);
}
// Meglio: avvia l'operazione asincrona, aggiorna lo stato quando pronto
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
generateUI(prompt).then(ui => {
setUI(ui);
setLoading(false);
});
}
Misurare ciò che si ottimizza
Senza misurazione, l'ottimizzazione è approssimativa. Aggiungi il tracciamento delle performance fin dall'inizio:
export async function generateUIWithMetrics(prompt: string) {
const startTime = performance.now();
const result = await streamUI({
/* ... */
onFinish: ({ toolCalls }) => {
const totalTime = performance.now() - startTime;
// Invia alla tua piattaforma di analytics / osservabilità
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;
}
Traccia TTFC e TTIC separatamente: cronometra lo yield dello skeleton e il return del componente finale. Dopo una settimana di dati avrai un quadro chiaro di dove va realmente il tempo.
Anti-pattern in cui sono già incappato
Sei casi in cui "ottimizzare" peggiora invece di migliorare — tutti errori che ho commesso in produzione:
1. Mettere in cache una risposta LLM non deterministica con un hash del prompt. GPT-4o con temperature=0.7 restituirà UI diverse per lo stesso prompt. La cache "funzionerà", ma l'utente vedrà un'interfaccia diversa da quella della chiamata precedente — peggio di una risposta lenta ma coerente. Soluzione: metti in cache solo con temperature=0, o includi prompt + temperature + seed nella chiave.
2. Skeleton che differisce molto dal componente finale. Ho visto in produzione: skeleton di una tabella da 5 righe, tabella finale da 50. Il CLS è schizzato, l'utente riesce a cliccare nel posto sbagliato e si arrabbia. Soluzione: min-height sul container basato sulla dimensione media + rendering lazy delle righe con virtual list.
3. Fare streaming di uno skeleton che l'utente vede per meno di 50ms. Su una rete veloce con p50 = 250ms di TTFC, lo skeleton lampeggia e sparisce — è più fastidioso di un caricamento pulito. Soluzione: aggiungi un ritardo di 100ms prima di mostrare lo skeleton (setTimeout), oppure non mostrarlo affatto su reti veloci (navigator.connection.effectiveType).
4. UI ottimistica che non converge con la risposta reale. Hai mostrato lo skeleton meteo, ma l'AI ha deciso che la query era in realtà sulle notizie — l'utente vede un salto. Soluzione: UI ottimistica solo per i trigger più ovvi (corrispondenza esatta di parole, non sottostringa), con graceful fallback su uno skeleton generico in caso di mancata corrispondenza.
5. Cache Redis con TTL di 5 minuti su dati personalizzati. Chiave senza userId — e l'utente A vede la dashboard dell'utente B. È una data leak, non un bug di performance. Soluzione: userId è sempre parte della chiave, namespace separati per dati pubblici/privati, audit log sui cache hit.
6. GPT-4o-mini per la classificazione degli intenti con 50+ strumenti. I modelli mini si perdono nei tool registry lunghi — iniziano a invocare strumenti inappropriati. Il risparmio in latenza si traduce in un aumento degli errori. Soluzione: per tool registry con più di 20 strumenti, usa GPT-4o oppure suddividi il registry per dominio con un router.
Configurazione Redis per la produzione
Se sei arrivato alla Strategia 3 e ne hai davvero bisogno, ecco la configurazione che funziona nel mio caso:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 2,
enableReadyCheck: true,
// Non bloccare la richiesta più di 50ms per il cache lookup — meglio ricalcolare
commandTimeout: 50,
});
// Il TTL si calibra sulla frequenza di aggiornamento dei dati, non su "5 minuti per tutto"
const TTL_BY_DATA_TYPE = {
staticReference: 24 * 60 * 60, // 24h: riferimenti, documentazione
userDashboard: 5 * 60, // 5min: dati personali
marketData: 30, // 30s: quotazioni, notizie
realtime: 0, // 0: non mettere in cache, dati in streaming
};
// Eviction policy in redis.conf: allkeys-lru
// maxmemory 512mb (per un MVP tipico)
// maxmemory-policy allkeys-lru
La policy di eviction allkeys-lru è più importante di quanto sembri: senza di essa Redis, quando si riempie, inizierà a rifiutare la scrittura di nuove chiavi — ovvero un fail lento invece di un graceful degradation. Gestisci l'invalidazione della cache tramite pattern (redis.del('user:123:*') via SCAN), non tramite point-delete — è molto più affidabile sulle chiavi calde.
Numeri reali dalla mia produzione
Dati da uno dei miei prodotti su Generative UI (~2000 DAU, dashboard con 8 strumenti, regione US East, gennaio 2026):
| Metrica | Prima dell'ottimizzazione | Dopo le strategie 1+2+4 | Dopo tutte e 6 |
|---|---|---|---|
| TTFC p50 | 580ms | 145ms | 90ms |
| TTFC p95 | 1100ms | 320ms | 240ms |
| TTIC p50 | 1400ms | 720ms | 380ms (cache hit) |
| TTIC p95 | 2800ms | 1500ms | 1300ms (cache miss) |
| CLS | 0,18 | 0,04 | 0,03 |
| Costo per richiesta | $0,012 | $0,002 | $0,0015 |
| Complessità del codice | baseline | +~150 LOC | +~600 LOC + Redis |
Osservazione principale: le strategie 1+2+4 hanno prodotto l'80% del guadagno con il 20% della complessità. Le strategie 3, 5 e 6 hanno portato il restante 20% di miglioramento con l'80% della complessità aggiuntiva. Se non hai un team che gestisce un cluster Redis e un SLA sull'invalidazione della cache, le strategie 1+2+4 sono il punto di arrivo, non intermedio.
Il cambiamento architetturale di cui non si parla
Se stai passando da "single render" (una pagina — una risposta) a "progressive delivery" (streaming + skeleton), non si tratta di un'ottimizzazione — è un cambio di architettura. Cosa cambia:
- Il codice server si scrive come generatori asincroni, non come funzioni normali — un modello mentale diverso.
- Gli error boundary in React si comportano diversamente per i contenuti in streaming — servono componenti di fallback a ogni livello.
- SEO e SSR richiedono una strategia separata: il contenuto AI in streaming non viene indicizzato per default.
- I test si complicano: snapshot test sugli stati intermedi degli skeleton più sul render finale.
Ho sottovalutato il costo di questo cambiamento nel primo progetto — avevo previsto 2 giorni, ne ho impiegati 2 settimane. Nel secondo progetto ho previsto 2 settimane dall'inizio e ho finito in tempo. Se il tuo prodotto non fa streaming adesso e stai pianificando la Strategia 1, calcola settimane, non ore.
Hai sfide di performance con la GenUI? Parliamone — l'ottimizzazione sull'intero stack è una nostra specializzazione.
Alex
Ingegnere e Consulente Generative UI
Ingegnere senior specializzato in interfacce AI e sistemi Generative UI. Aiuta i team di prodotto a rilasciare più velocemente con il giusto stack GenUI.
Articoli correlati
Κατασκευάζοντας το Πρώτο σας 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, πλοήγηση με πληκτρολόγιο και συνδυαστικά προβλήματα προσβασιμότητας.
Resta aggiornato su Generative UI
Articoli settimanali, aggiornamenti sui framework e guide pratiche di implementazione — direttamente nella tua casella di posta.
Hai bisogno di aiuto per implementare quello che hai appena letto?
Prenota una consulenza gratuita