KINDLE HOME

Il byte spezzato: anatomia di una risposta persa

Un job asincrono orfano, un carattere UTF-8 tagliato a metà, e il cambio di paradigma nello streaming delle interfacce web per LLM.

Domenica mattina, un utente scrive «ciao chi sei?» a un assistente AI su una webchat. Lo spinner gira, gira, e dopo cinque minuti si arrende. Nessuna risposta. L'utente riprova, o rinuncia. Dal suo punto di vista il sistema è rotto in un modo qualsiasi: la rete, il modello, il server. La verità, ricostruita nei log, è più interessante: il modello aveva risposto in sei secondi, la risposta era completa e ben scritta, salvata in un file sul server. A perderla per strada è stato un singolo carattere tagliato a metà.

Questa è la cronaca di quel bug — un job asincrono rimasto orfano su ABChat — e di quello che insegna sul modo in cui le interfacce web per LLM gestiscono lo streaming. La seconda parte è la più utile: il confronto con come risolvono lo stesso problema le codebase mature, e la proposta di cambio di paradigma che ne è uscita.

Dove muore davvero il messaggio

L'architettura in gioco ha quattro attori. Il browser apre la chat e aspetta. Un worker PHP prende in carico il job dalla coda. Un hub fa da intermediario e lancia il processo del modello dentro un container isolato. Il processo scrive il proprio output, evento per evento, in un file NDJSON — una riga JSON per ogni evento dello stream. L'hub segue quel file con un tail e rimanda i byte al worker; il worker li accoda a una colonna del database; il browser legge la colonna a intervalli e mostra il testo che cresce.

Nel caso in esame, il primo tratto della catena ha funzionato alla perfezione. Il container ha eseguito, il modello ha generato la sua presentazione — con tanto di emoji e accentate, trattandosi di italiano — il file NDJSON conteneva l'evento finale con il testo completo e il codice di uscita era zero. Il guasto è avvenuto nel tratto successivo: il worker PHP, ricevuti i byte dall'hub, doveva accodarli alla colonna output del database. Lì è esplosa un'eccezione SQL. E per un difetto nella gestione dell'errore — ci arriviamo — il job non è mai stato marcato come fallito: è rimasto in stato running, congelato, finché un processo di pulizia lo ha chiuso d'ufficio cinque minuti dopo. La risposta, mai salvata come messaggio, è rimasta sepolta nel file dentro il container.

Byte e caratteri non sono la stessa cosa

Il codice incriminato stava in un metodo di quattro righe:

$this->update([
  'output' => DB::raw("COALESCE(output,'') || "
      . DB::connection()->getPdo()->quote($chunk))
]);

Tradotto: prendi il pezzo di testo appena arrivato dallo stream, passalo a PDO::quote() perché lo prepari per l'SQL (apici attorno, apici interni raddoppiati), e incollalo dentro la query di aggiornamento. La query finale viene costruita per concatenazione di stringhe: il testo generato dal modello diventa parte del codice SQL.

Il problema nasce un livello sotto. Lo stream HTTP viene letto a blocchi di 4096 byte, ma il testo è UTF-8, dove un carattere occupa da uno a quattro byte: una «è» ne occupa due, un'emoji tre o quattro. Il confine del blocco cade dove capita, e ogni tanto capita in mezzo a un carattere. Il blocco che ne risulta è fatto di byte legittimi ma è una sequenza UTF-8 invalida: finisce con mezza lettera.

E qui scatta la trappola: quote() sul driver Postgres non si limita all'escape degli apici — valida anche l'encoding della stringa. Davanti a UTF-8 invalido fallisce, e invece della stringa quotata restituisce vuoto. PHP concatena quel vuoto senza protestare, e la query spedita al database diventa:

update brain_exec_jobs set output = COALESCE(output,'') || , "updated_at" = ...

Dopo l'operatore di concatenazione non c'è niente. Postgres risponde con un errore di sintassi, il worker va in eccezione, e la cascata è servita. Il colpo di grazia lo dà il blocco catch: la sua prima istruzione era un nuovo tentativo di svuotare il buffer — lo stesso buffer con lo stesso mezzo carattere — che esplodeva identico, impedendo al codice successivo di marcare il job come fallito e di salvare il testo parziale. Il job restava orfano.

Un dettaglio rende il caso istruttivo: il bug era intermittente per natura. Scattava solo quando il confine dei 4096 byte beccava un carattere multi-byte nel momento del flush. Nei log di produzione, una volta saputo cosa cercare, la stessa firma è comparsa sette volte in un mese su un'installazione e ventinove su un'altra: ogni occorrenza era un utente davanti a uno spinner infinito, e nessuno aveva mai collegato quei sintomi sparsi a una causa comune. Il sintomo visibile era «la risposta sparisce»; l'errore SQL stava tre strati sotto, in un log che nessuno legge finché non serve.

Tre strati di difesa

Il fix applicato lavora su tre livelli indipendenti, e vale la pena distinguerli perché rispondono a domande diverse.

Il primo livello previene: il buffer ora viene svuotato verso il database solo per righe NDJSON complete. Se l'ultimo pezzo ricevuto si ferma a metà riga — e quindi, potenzialmente, a metà carattere — quel pezzo resta in memoria finché non arriva il resto. Il confine tra byte e testo si sposta sul newline, che per un formato a righe è sempre un punto di taglio sicuro.

Il secondo livello pulisce: prima di scrivere, il codice verifica che il blocco sia UTF-8 valido e, se non lo è, scarta i byte monchi. Nel funzionamento normale non fa nulla; agisce solo su dati già corrotti.

Il terzo livello cambia il modo in cui il dato incontra l'SQL: niente più concatenazione, ma binding parametrico. La query parte con un segnaposto e il testo viaggia come dato allegato, in una busta separata dal codice. Qualunque cosa contenga, non può alterare la sintassi della query. È il modo canonico di fare la cosa — l'ORM lo fa di default, e il codice originale ne era uscito deliberatamente con DB::raw.

A completare, il catch è stato blindato: il tentativo di flush in uscita è ora protetto, così un suo fallimento non può più impedire la marcatura dello stato terminale e il salvataggio del parziale. Se qualcosa va storto di nuovo, il job fallisce pulito e l'utente vede il testo già generato con una nota, invece del nulla.

Come lo risolvono gli altri

Niente di tutto questo è nuovo. Il problema del carattere spezzato è vecchio quanto i socket, e le codebase che fanno interfacce web per LLM — LibreChat, OpenWebUI, gli SDK ufficiali di OpenAI e Anthropic — convergono da anni sugli stessi tre pattern.

Il primo è il framing prima del testo. Nessun parser serio tratta i blocchi TCP come testo. Lo standard de facto per lo streaming LLM è Server-Sent Events: eventi delimitati da doppio newline, e i parser accumulano byte finché non hanno un evento completo; solo allora decodificano e interpretano il JSON. Chi usa NDJSON fa lo stesso con il newline singolo. La regola generale: si taglia sul delimitatore di protocollo, mai sul contatore di byte.

Il secondo è il decoder incrementale, per i punti dove non si può aspettare la riga completa. Un decoder con stato sa di essere rimasto a metà di un carattere e tiene i byte in sospeso fino al blocco successivo: TextDecoder con stream: true nel browser, codecs.getincrementaldecoder in Python. PHP non ha un equivalente nativo, ed è il motivo per cui lì la via pragmatica resta il buffering per righe.

Il terzo pattern riguarda la persistenza, ed è quello che apre il discorso più grosso: le interfacce mature non scrivono i frammenti nel database. Streammano i delta al client tenendo lo stato in memoria o in Redis, e salvano il messaggio completo una volta sola, a generazione finita. L'accodamento SQL per ogni blocco — la scelta di ABChat — è inusuale: ogni append è un UPDATE che riscrive l'intero valore della colonna, con costo che cresce col crescere dell'output, per un dato che è transitorio per natura.

Il cambio di paradigma

La domanda naturale a quel punto era: si può passare al modello LibreChat, con lo stato in memoria e il salvataggio alla fine? La risposta è arrivata guardando l'architettura esistente con occhi freschi: il pezzo mancante c'era già.

ABChat ha una peculiarità di design che le interfacce citate hanno raggiunto solo in seguito: il processo del modello è sganciato dalla connessione. Scrive il suo output in un file dentro il container, e sopravvive a qualunque caduta di rete, refresh del browser, riavvio del worker. L'hub sa riprendere il tail di quel file da un cursore arbitrario, per lo stesso job. Concettualmente è ciò che fa ChatGPT con le generazioni che continuano server-side mentre il client si riconnette. Quel file, durevole e già scritto dal processo stesso, è l'equivalente funzionale del Redis di LibreChat — senza aggiungere un componente all'infrastruttura.

Il paradosso dell'implementazione attuale è che quel file viene copiato riga per riga dentro una colonna del database al solo scopo di farlo arrivare al browser: il database usato come tubo, con il worker a fare da pompa. Tutta la superficie del bug raccontato sopra — il chunking, l'encoding, l'SQL — vive in quella copia.

Il cambio di paradigma proposto elimina la copia. Il browser apre il suo stream verso un endpoint che fa da proxy al tail dell'hub: il cursore diventa un offset nel file, la riconnessione è gestita dove il dato vive. Il worker si riduce all'essenziale: lancia l'esecuzione, aspetta la fine, interpreta l'evento di risultato e scrive nel database una volta sola — il messaggio completo, lo stato, i metadati. Il database torna a fare il suo mestiere: stato e persistenza, non transito. La classe di bug dell'encoding sparisce non perché difesa da tre strati, ma perché il percorso che la rendeva possibile non esiste più.

I costi sono onesti e piccoli: un processo PHP occupato per ogni spettatore attivo di uno stream — irrilevante alla scala di installazioni da qualche decina di utenti, ed era già vero per lo streaming sincrono — e un hub che diventa ancora più centrale, ma lo era già. Il lavoro vero è il refactoring: worker, endpoint, semantica del cursore nel frontend, e il deploy sulla flotta. Da fare su staging, con calma, come area separata dal fix d'emergenza.

Quello che resta

Due lezioni portabili, al netto del caso specifico. La prima è tecnica: ogni confine dove i byte diventano testo è un punto di rottura potenziale, e va attraversato con un pattern esplicito — framing sul delimitatore, decoder con stato, o buffering per unità complete. Se il codice tratta un blocco di rete come una stringa senza chiedersi dove è stato tagliato, il bug non è un'ipotesi ma una scadenza.

La seconda è architetturale: quando un canale di transito coincide con lo strato di persistenza, ogni fragilità del transito diventa corruzione dello stato. Il database che fa da tubo funziona finché funziona, ma concentra su di sé rischi che non gli competono. Separare i due ruoli — il file per il flusso, il database per lo stato — non è una raffinatezza: è ciò che ha reso il bug di domenica mattina possibile da un lato e superfluo dall'altro.

La risposta dell'assistente, per la cronaca, è stata recuperata dal file e consegnata in chat il giorno stesso. L'utente l'ha letta con un giorno di ritardo senza sapere niente di tutto questo. Come deve essere.

- FINE -
1