Node.js e TypeScript per streaming real-time di LLM: architettura per chat AI a bassa latenza
Il 12 febbraio 2026 mi sono messo davanti a un problema che molte PMI italiane affrontano quando decidono di aggiungere una chat AI al loro gestionale Laravel: la latenza percepita. Nella mia pipeline personale di automazione AI, un endpoint Laravel che chiamava l'API di Anthropic in modalità non-streaming restituiva risposte complete in 8-14 secondi per prompt da 1.500 token di output. Dal punto di vista dell'utente finale, 8 secondi di schermata vuota con uno spinner sono indistinguibili da un'applicazione rotta. L'infrastruttura gira su un server dedicato Hetzner AX52 (Ryzen 7 7700, 64 GB RAM DDR5, 2 NVMe da 1 TB in RAID 1) con stack LEMP tradizionale - Nginx 1.26, PHP-FPM 8.3, MySQL 8.4, Laravel 12. La soluzione architetturale è stata separare il layer di streaming dal backend di dominio: un servizio dedicato in Node.js 22 LTS con TypeScript 5.7, in esecuzione su un Hetzner CPX41 (8 vCPU, 16 GB RAM, 240 GB NVMe), che intercetta le chiamate conversazionali e fa da proxy streaming verso Claude Sonnet 4.6, lasciando a Laravel la gestione di sessione, permessi, quote utente e persistenza. In quattro settimane di esercizio su traffico interno, il time-to-first-token è sceso stabilmente sotto i 400 millisecondi.
Perché separare Node.js dal backend Laravel invece di usare PHP per lo streaming?
La risposta corta è che il modello di esecuzione di PHP-FPM non è pensato per mantenere migliaia di connessioni HTTP aperte contemporaneamente. Ogni richiesta occupa un worker per tutta la sua durata, e una risposta LLM in streaming può restare aperta per 10-40 secondi. Con un pool da 50 worker PHP, 50 chat concorrenti saturano la macchina e ogni altro endpoint - fatturazione, login, gestione catalogo - comincia a restituire 502. Node.js, invece, ha un event loop non-bloccante che gestisce senza fatica 10.000 connessioni Server-Sent Events aperte su un singolo processo, perché ognuna consuma qualche kilobyte di heap e nessun thread dedicato.
Questo non significa buttare via PHP. Significa sceglierlo dove brilla - logica di dominio, ORM, validation, policy, migration - e non imporgli un pattern di I/O per cui altri runtime sono strutturalmente migliori. È lo stesso principio per cui in una pipeline precedente ho usato FastAPI come orchestrator di LLM per un backend Laravel: la logica AI vive dove esiste l'ecosistema, la business rule vive dove esiste il dominio. Cambia il componente, non cambia il principio.
Se vuoi vedere come progetto architetture AI dove ogni componente sta al posto giusto senza vendor lock-in, nel mio hub dedicato all'integrazione AI per aziende trovi articoli tecnici che partono dallo stesso presupposto: production-grade, non demo-grade, con metodologia applicata e perimetro dichiarato.
L'architettura a tre livelli
La topologia è esplicita. Il browser parla al reverse proxy Nginx, che instrada il traffico /chat/* al servizio Node.js e il resto a PHP-FPM. Il servizio Node.js, prima di aprire lo stream, valida il JWT emesso da Laravel - stesso secret, stesso claim schema - e fa una chiamata HTTP sincrona a Laravel su /internal/chat/authorize per verificare quota residua, permessi e contesto di sessione. Solo se questa verifica passa, Node.js apre il canale SSE verso il browser e, in parallelo, il canale streaming verso Anthropic.
+----------------+
Browser --SSE---> | Nginx 1.26 |
| reverse proxy |
+--------+-------+
|
/chat/* | /*
| | |
v v v
+------------+ +----------+ +-----------+
| Node.js 22 |-| Laravel | | PHP-FPM |
| streaming | | authZ | | app logic |
+------+-----+ +----------+ +-----------+
|
v
Anthropic API
(stream)La chiave di questa separazione è che nessuna delle chiamate Node.js accede direttamente al database applicativo. Laravel è l'unico owner delle tabelle users, conversations, usage_ledger. Node.js tiene in memoria solo lo stato effimero della connessione (stream identifier, token buffer, abort controller) e persiste il risultato finale - prompt, output completo, token usage, costo - via una POST /internal/chat/persist a Laravel alla chiusura dello stream. Se domani decido di sostituire il layer di streaming con un'altra tecnologia, l'unica superficie di rottura sono quei due endpoint HTTP interni.
Implementazione del servizio SSE in Node e TypeScript
Uso Fastify 5 al posto di Express perché il supporto per streaming via Web Streams API è più solido e il router è significativamente più veloce sotto carico. Il core è un handler che apre lo stream verso Anthropic e reinoltra i delta token al browser nel formato SSE standard descritto in MDN Server-Sent Events.
import Fastify from 'fastify';
import Anthropic from '@anthropic-ai/sdk';
const app = Fastify({ logger: true });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
app.post('/chat/stream', async (request, reply) => {
const { conversationId, prompt, jwt } = request.body as ChatRequest;
// Autorizzazione via Laravel (quota, permessi, contesto)
const authz = await fetch(`${LARAVEL_URL}/internal/chat/authorize`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId })
});
if (!authz.ok) return reply.code(403).send({ error: 'unauthorized' });
const { systemPrompt, history, userId, quotaRemaining } = await authz.json();
if (quotaRemaining <= 0) return reply.code(402).send({ error: 'quota_exceeded' });
// Header SSE: no buffering, no transform, keep-alive
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
const abortController = new AbortController();
request.raw.on('close', () => abortController.abort());
try {
const stream = await anthropic.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 2048,
system: systemPrompt,
messages: [...history, { role: 'user', content: prompt }]
}, { signal: abortController.signal });
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
reply.raw.write(`event: token\ndata: ${JSON.stringify({ t: event.delta.text })}\n\n`);
}
}
const final = await stream.finalMessage();
await persistToLaravel(userId, conversationId, prompt, final, jwt);
reply.raw.write(`event: done\ndata: ${JSON.stringify({ usage: final.usage })}\n\n`);
} catch (error) {
reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: 'stream_failed' })}\n\n`);
app.log.error({ error }, 'stream error');
} finally {
reply.raw.end();
}
});L'header X-Accel-Buffering: no è non negoziabile: senza di esso, Nginx bufferizza la risposta e l'utente vede il testo arrivare tutto insieme alla chiusura dello stream, vanificando l'intero progetto. Lo stesso vale per Cache-Control: no-transform, che impedisce a CDN e proxy intermedi di applicare gzip e spezzare la semantica SSE. L'AbortController agganciato all'evento close del socket client è la garanzia che, se l'utente chiude la scheda del browser a metà risposta, la chiamata verso Anthropic viene interrotta subito - altrimenti continui a consumare token (e a pagare) per una risposta che nessuno sta più leggendo.
Come gestire la backpressure quando il client è più lento del modello?
Il modello produce token a 60-120 al secondo, il browser tipicamente li consuma più lentamente, soprattutto se sta renderizzando Markdown con syntax highlighting in parallelo. Se il buffer TCP sul lato server si riempie, reply.raw.write() restituisce false: da quel momento in poi, continuare a scrivere riempie l'heap Node.js fino al crash del processo. La regola è ascoltare l'evento drain prima di riprendere.
async function writeWithBackpressure(socket: NodeJS.WritableStream, chunk: string): Promise<void> {
const canContinue = socket.write(chunk);
if (canContinue) return;
return new Promise(resolve => socket.once('drain', resolve));
}Nel loop principale sostituisci la write diretta con await writeWithBackpressure(reply.raw, ssePayload). Questo blocca il loop di lettura da Anthropic finché il canale TCP verso il browser non si libera. In pratica, Claude continua a produrre token - il suo stream HTTP/2 ha il suo flow control indipendente - ma noi li consumiamo al ritmo del client più lento, senza gonfiare la memoria del nostro processo. Con 500 sessioni SSE concorrenti e client diversificati (desktop, mobile 4G, connessioni satellitari lente), il residente del processo Node rimane stabile intorno agli 800 MB invece di oscillare senza limiti verso l'out-of-memory.
Reconnect lato client e ripresa dello stream
Il client browser usa EventSource, che gestisce il reconnect automatico su connection drop. Il problema è che alla riconnessione riparte da zero: l'utente vede il testo azzerarsi o, peggio, duplicarsi. La soluzione SSE-idiomatic è l'header Last-Event-ID: ogni evento che mando ha un id: progressivo, e il browser alla riconnessione lo rimanda come header. Lato server, quando ricevo una riconnessione con Last-Event-ID: 128, riprendo l'invio dal token 129 - se ce l'ho ancora in un buffer Redis con time-to-live di 5 minuti.
app.post('/chat/stream', async (request, reply) => {
const lastEventId = parseInt(request.headers['last-event-id'] as string ?? '0', 10);
const streamKey = `chat:stream:${conversationId}`;
if (lastEventId > 0) {
const buffered = await redis.lrange(streamKey, lastEventId, -1);
for (const payload of buffered) {
await writeWithBackpressure(reply.raw, payload);
}
}
let eventId = lastEventId;
for await (const event of stream) {
if (event.type === 'content_block_delta') {
eventId += 1;
const payload = `id: ${eventId}\nevent: token\ndata: ${JSON.stringify({ t: event.delta.text })}\n\n`;
await redis.rpush(streamKey, payload);
await redis.expire(streamKey, 300);
await writeWithBackpressure(reply.raw, payload);
}
}
});In questo modo una connessione che si rompe al 60% dello stream - tipico su reti mobili scadenti o su VPN aziendali con NAT aggressivo - si ricompone senza buchi e senza ripartire da capo. Il costo è un Redis condiviso tra i nodi Node.js e un TTL generoso abbastanza da coprire la finestra tipica di riconnessione EventSource (30-60 secondi).
Cost tracking e governance delle quote
Il costo di una chiamata Claude non è prevedibile ex-ante: dipende dal numero di token di input e output effettivi. Nella mia pipeline personale traccio ogni chiamata in una tabella usage_ledger con colonne user_id, conversation_id, input_tokens, output_tokens, cached_tokens, model, cost_usd, timestamp. Il calcolo avviene lato Laravel al momento del persist, usando la tariffa corrente letta da un file di configurazione versionato: se domani Anthropic aggiorna i prezzi, un solo file cambia e tutta la fatturazione interna si allinea automaticamente.
Un pezzo che il consulente AI medio italiano dimentica è il cost cap per conversazione. Un utente malizioso con accesso al chatbot può generare prompt che producono risposte da 8.192 token ripetute, trasformando la sua quota settimanale in una bolletta Anthropic da centinaia di dollari in una notte. Nel mio servizio Node, al momento dell'autorizzazione Laravel mi restituisce quotaRemaining in dollari - non in token - e lo stream si chiude con un event: quota_exhausted non appena il consumo cumulato sulla conversazione supera il cap. OWASP lo chiama Unbounded Consumption nel punto LLM10 della Top 10 2025 per LLM application e la categoria esiste esattamente per questo: il denial-of-wallet è un'attack surface reale, non teorica, e colpisce il fornitore del servizio molto prima del provider LLM.
Il deployment: singolo VPS o architettura separata?
Nella fase di sperimentazione tengo tutto sulla stessa macchina AX52, con il servizio Node.js esposto su 127.0.0.1:3001 e Nginx che instrada via proxy_pass. La configurazione minimale del reverse proxy è questa:
location /chat/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
}Le direttive proxy_buffering off e proxy_cache off sono il corrispettivo server-side dell'X-Accel-Buffering: no lato applicazione: se ne dimentichi una sola, lo streaming smette di essere streaming. Il proxy_read_timeout a 3600 secondi copre i casi in cui il modello pensa per decine di secondi su prompt di ragionamento esteso; il default di 60 secondi di Nginx tronca prematuramente questi stream con un 504.
Quando il traffico supera le 200 chat concorrenti, sposto il servizio Node.js su un VPS dedicato - il CPX41 citato all'apertura - e aggiungo un secondo nodo dietro un load balancer. Lo stato della sessione vive in Redis condiviso, quindi una chat iniziata sul nodo A può riconnettersi sul nodo B senza perdere il contesto. A differenza di un'architettura basata su Laravel Reverb per WebSocket real-time, che è la scelta giusta quando devi fare broadcast multi-utente senza toccare LLM, qui l'asimmetria del pattern è strutturale: Reverb è bravo a moltiplicare un evento server verso molti client, Node.js con SSE è bravo a moltiplicare una connessione client verso un singolo upstream LLM. Stack diversi per problemi diversi.
Se il modello è self-hosted via Ollama su GPU dedicata - un pattern che ho trattato nell'articolo sugli LLM self-hosted su VPS per PMI italiane - il codice Node.js cambia solo nell'SDK chiamato (fetch verso l'endpoint locale invece di Anthropic SDK), ma il resto dell'architettura - autorizzazione, backpressure, reconnect, cost tracking - resta identico. È proprio questo il vantaggio di tenere il layer di streaming vendor-agnostic: cambiare provider non richiede riscrittura del sistema di governance.
Osservabilità e sicurezza del layer Node.js
Espongo due endpoint di health separati: /healthz restituisce 200 se il processo risponde, /readyz restituisce 200 solo se la verifica verso Laravel e Redis è andata a buon fine nei 30 secondi precedenti. Prometheus fa scrape di /metrics e registra per ogni stream: durata totale, token ricevuti, dimensione del prompt, errori, chiusure anomale. Quando il tasso di errore supera il 2% su finestra di 5 minuti, scatta un alert verso il mio bot Slack interno.
Sulla sicurezza applicativa applico il framework OWASP LLM Top 10 in modo sistematico. Il servizio Node non accetta mai il system prompt dal client: systemPrompt arriva solo dalla risposta Laravel autorizzata - mitigazione di LLM01 Prompt Injection per la parte di system prompt contamination. Gli output token non vengono mai interpretati lato server come istruzioni: nessun eval, nessun exec, nessun template engine che riceve l'output LLM - mitigazione di LLM05 Improper Output Handling. Il JWT ha scope limitato a chat:stream e scadenza di 10 minuti, quindi anche in caso di compromissione del client non sblocca chiamate arbitrarie verso Laravel. La chiave API Anthropic vive in variabile d'ambiente caricata da systemd con permessi 400 sul file .env, e il processo Node gira come utente non privilegiato in una unit con ProtectSystem=strict e NoNewPrivileges=true. È la stessa mentalità defense-in-depth che applico in audit sul codice AI-generated nelle applicazioni web: non esiste una singola mitigazione che regge da sola, esiste un insieme di layer che fallisce solo se cadono tutti insieme.
Quando questo pattern non è la scelta giusta
Se la tua applicazione ha meno di 10 chat concorrenti totali, un proxy PHP in streaming con flush() e header opportuni regge senza problemi - la complessità di un secondo servizio Node.js non ripaga. Se il tuo stack è già pieno di microservizi Python e stai usando FastAPI per l'orchestration AI, tieni lì anche lo streaming via StreamingResponse e non aggiungere un terzo runtime. Se il tuo caso d'uso non è conversazionale ma batch - generazione di report, classificazione di ticket, estrazione di entità da documenti - lo streaming è ornamento, non requisito: un endpoint sincrono con job queue asincrona è più semplice e altrettanto efficace.
Il pattern ibrido Node.js-per-lo-streaming si giustifica quando hai contemporaneamente tre condizioni: un backend PHP consolidato che non vuoi smontare, traffico conversazionale che cresce oltre la decina di chat concorrenti, e un requisito di time-to-first-token sotto il secondo. In questi casi l'investimento in un runtime aggiuntivo si ripaga nel giro di poche settimane, perché l'alternativa - riscrivere la business logic in Node.js o costringere PHP-FPM a fare un lavoro per cui non è progettato - è sensibilmente più costosa.
Quando un cliente ti dice che la chat AI del suo gestionale "si blocca dopo dieci secondi" oppure che "rallenta tutto il backoffice quando qualcuno sta chattando", quello che senti è il sintomo di un'architettura che ha forzato PHP-FPM a fare un lavoro che non è pensato per fare. La soluzione non è aggiungere worker o salire di taglia sul server dedicato: è spostare il layer di streaming su un runtime che gestisce nativamente migliaia di connessioni HTTP aperte, tenendo la business logic dove è sempre stata. Non è complessità aggiuntiva per il gusto di aggiungerla - è la complessità corretta, quella che esiste perché i due problemi sono davvero diversi e meritano trattamenti diversi.
Se hai un progetto concreto in cui vuoi aggiungere streaming AI a un gestionale Laravel, Symfony o a una codebase PHP legacy, e vuoi capire se l'approccio ibrido Node.js-per-lo-streaming è adatto al tuo caso - oppure se un pattern più semplice basta - il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene, e se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.