La saga della cache conversazionale di Qwen3.6

2026-06-09 · 13 min

La saga della cache conversazionale di Qwen3.6

Post-mortem di un'indagine sull'inference LLM on-prem: quattro ipotesi, tre cache-killer, una diagnosi ribaltata

2026-06-09 · Anacleto / Giobi · Fonte: Report tecnico interno — inference LLM on-prem (giu 2026)

Nota di lettura. Questo è il racconto, in ordine cronologico, di un'indagine durata giorni su un LLM locale che serviva ogni messaggio con decine di secondi di attesa fissa, anche al secondo e al terzo turno della stessa conversazione. È un post-mortem onesto: include le conclusioni affrettate, perché l'errore di diagnosi e il modo in cui è stato corretto fanno parte della storia. In coda c'è un epilogo del 9 giugno che ribalta la diagnosi principale.

Sintesi per chi ha fretta: si credeva un limite architetturale del modello (attenzione ibrida che non riusa la cache). Si è rivelato un nonce dinamico iniettato dal client agentico in testa al prompt, che invalidava il prefisso a ogni richiesta. Il proxy lo normalizzava già: il problema era risolto da tempo senza saperlo.

Contesto: cosa gira sul ferro

L'infrastruttura serve un LLM locale tramite Ollama dietro un reverse proxy autenticato sulla tailnet. Una sola GPU logica da 48 GB (due schede da 24 GB), un solo modello tenuto sempre caldo. Il consumatore principale è una webchat agentica: non manda un prompt secco al modello, ma lancia un vero agente (Claude Agent SDK) dentro il container del brain, puntato sul modello locale via un layer di compatibilità OpenAI/Anthropic.

La conseguenza è cruciale per tutta la storia: il prompt verso il modello non è la domanda dell'utente. È un blocco enorme, fatto di istruzioni dell'agente, definizioni di circa 26 tool, identità e contesto risolti dai file di boot. In token reali, misurati, sono circa 43.900 token per turno di puro prefisso di sistema, prima ancora che l'utente scriva qualcosa.

Con un prompt di sistema da circa 44.000 token, l'unica cosa che rende una chat multi-turno reattiva è il riuso della cache tra un turno e il successivo. Se la cache non si riusa, ogni messaggio ri-processa 44.000 token da zero. È l'intero perno di questo report.

Il sintomo

In interfaccia, ogni messaggio impiegava tra i 19 e i 60 secondi prima di iniziare a rispondere. Non solo il primo: anche il secondo e il terzo messaggio della stessa sessione, con lo stesso contesto già "visto" dal modello un attimo prima. Negli agentic loop, dove un singolo turno utente genera più round-trip al modello, le attese si sommavano fino al minuto pieno.

Sulle API cloud (Anthropic) lo stesso pattern non si nota: c'è prompt-caching server-side e un prefill molto veloce. Il problema era specifico dello stack locale, e la prima trappola è stata dare per scontato che la causa fosse ovvia. Non lo era. Tre numeri inquadrano la cosa: circa 44.000 token di system prompt per turno, un'attesa di 19-60 secondi per messaggio anche al secondo e terzo turno, e zero token letti dalla cache, cioè riuso azzerato.

Teoria: perché la cache conta

Un LLM transformer, per generare una risposta, prima legge tutto il prompt (la fase di prefill) e ne calcola la KV cache, cioè le proiezioni key/value di ogni token in ogni layer di attenzione. Solo dopo inizia a generare token nuovi (la fase di decode). Il prefill di 44.000 token è lavoro pesante: sul ferro in questione si misurava a circa 2.300-3.800 token al secondo, quindi diversi secondi solo per "leggere".

L'ottimizzazione classica è il prefix caching: se il turno successivo condivide lo stesso prefisso, e in una chat il system prompt è identico turno dopo turno, il motore può riusare la KV cache già calcolata e ripartire solo dai token nuovi. Il prefill passa da secondi a millisecondi. Su uno stack sano questo rende gratis tutto tranne il primo messaggio.

In teoria, con cache sana, il primo turno paga il prefill pieno (circa 12 secondi a freddo) e i turni successivi colpiscono la cache, ricalcolando solo i pochi token nuovi, in frazioni di secondo. Quello che succedeva davvero, invece, era un cache miss a ogni turno: 19 secondi al primo, 19 al secondo, 19 al terzo, perché il prompt veniva ri-processato per intero ogni volta.

La caccia: quattro ipotesi

La diagnosi è arrivata in coda a quattro ipotesi. La nota meta più importante è questa: le prime tre erano tutte vere, ma nessuna era dominante. Ogni fix migliorava qualcosa senza spostare il numero grosso. È il tipo di indagine che insegna a non fermarsi al primo miglioramento plausibile.

Ipotesi 1 — Il thinking (vera, non dominante)

Il modello è un reasoner: prima di rispondere genera migliaia di token di ragionamento interno invisibile. Misurato su una domanda banale, sono emersi 3.492 chunk di reasoning contro 214 di contenuto, cioè il 95% del tempo speso a ruminare. A circa 88 token al secondo fanno circa 40 secondi di vuoto. Disattivato con think:false nativo, dato che il soft-switch /no_think nel prompt, pur provato, non funziona su questo modello. Migliorava il primo token, ma il problema multi-turno restava.

Ipotesi 2 — Lo streaming mancante (scartata)

Il sospetto era che la risposta arrivasse "tutta insieme" perché i token non scorrevano. Verificato strato per strato (layer di compatibilità, agente, hub, controller, frontend): tutti emettevano text_delta incrementali correttamente, a circa 0,12 secondi l'uno. La catena di streaming era intatta. Non era questo.

Ipotesi 3 — L'agente (Claude Code SDK) (scartata)

Il sospetto era che il loop agentico ri-lanciasse una sessione fresca a ogni turno, re-iniettando tutto. Verificato: l'overhead dell'SDK c'è, ma non spiega i 19 secondi fissi. Non era il collo di bottiglia.

Ipotesi 4 — La dimensione del prefill (vera, non dominante)

Un prompt da 44.000 token costa comunque a freddo. Vero, ma il punto era un altro: a freddo lo paghi una volta. Il dramma era che lo si pagava a ogni turno, perché la cache non si riusava mai. La taglia del prefill era un aggravante, non la causa.

La causa radice (creduta)

La svolta è arrivata catturando i log di richiesta del motore di inferenza su due turni reali. Il verdetto era scritto nero su bianco da llama.cpp:

slot update_slots: forcing full prompt re-processing due to lack of cache data (likely due to SWA or hybrid/recurrent memory) — load: looking for better prompt, sim = 0.004

Sei occorrenze di forcing full prompt re-processing sui turni, con similarità del prefisso tra 0,000 e 0,18. Tradotto: il motore buttava via la cache e ri-processava l'intero prompt a ogni chiamata, per via dell'architettura del modello, cioè SWA o memoria ibrida/ricorrente.

La verifica successiva, tentando il flag che terrebbe la KV intera (--swa-full) con il server diretto, dava: load_model: swa_full is not supported by this model, it will be disabled. Il flag non si applica. L'architettura confermata dal binario era un MoE con layer ad attenzione lineare/ricorrente, in stile Qwen3-Next. Esattamente il caso descritto dall'issue upstream ggml-org/llama.cpp #20225, "Qwen 3.5 Full prompt re-processing on every conversation turn". Stato dell'issue: chiusa via varie PR, ma migliorano parzialmente, non risolvono del tutto per i modelli ibridi/recurrent.

A questo punto sembravano smentite tutte le ipotesi precedenti: non era il thinking, non lo streaming, non l'agente, non la dimensione del prefill in sé. Era architettura del modello (attenzione ibrida/ricorrente) per limite del motore nel riuso della cache per quei layer. Questa conclusione, come si vedrà nell'epilogo, era sbagliata.

Approfondimento architetturale

Perché un modello ibrido non potrebbe riusare il prefisso? La differenza è nella natura della "memoria" dell'attenzione.

Attenzione full: la cache si riusa

In un transformer ad attenzione piena, ogni token ha una coppia key/value per posizione, indipendente e indirizzabile. Se le prime 44.000 posizioni sono identiche al turno precedente, le loro KV sono identiche: il motore le tiene e riprende dal token 44.001. Il riuso del prefisso è banale.

Attenzione lineare o ricorrente: la cache non si riusa

I layer ad attenzione lineare/ricorrente non tengono una KV per-posizione: comprimono il passato in uno stato che evolve token dopo token. Non si può "riprendere da metà" uno stato ricorrente senza ri-eseguire la ricorrenza dall'inizio. Non c'è un indice di posizione da cui ripartire, c'è solo lo stato finale, e per ricostruirlo bisogna ri-processare tutto.

Il paradosso centrale

L'attenzione ibrida è esattamente ciò che rende il modello capace di gestire 256.000 token di contesto in pochissima VRAM, dato che la KV cache di un MoE A3B ibrido è minuscola. La stessa scelta architetturale che regala il contesto enorme economico è quella che uccide il riuso della cache multi-turno. Non è un bug da patchare: è un trade-off di design del modello.

Nota del 9 giugno: questa conclusione si è rivelata sbagliata. Era dedotta da log presi quando il prefisso era ancora avvelenato dal nonce client. A prefisso stabile il modello cacha. Vedi l'epilogo in fondo.

Le conseguenze sulle opzioni di fix considerate erano tre, tutte negative. L'upgrade del motore era morto: non è questione di versione, è supporto parziale a monte per gli ibridi. Il flag --swa-full era morto: serve ai modelli SWA puri come Gemma, non agli ibridi ricorrenti. I motori alternativi come vLLM o SGLang non erano garantiti: anche loro faticano col prefix-caching dei layer ricorrenti/lineari, area immatura ovunque, e aiutano solo i modelli full-attention.

I tre cache-killer

Per validare quanto sopra si è provato a sostituire temporaneamente il modello con uno full-attention di taglia simile, dove la cache dovrebbe funzionare. Sorpresa: all'inizio non funzionava lo stesso. Sono emersi tre cache-killer indipendenti, tutti lato infrastruttura, tutti risolti con un piccolo proxy fatto in casa, chiamato modelforce, interposto davanti al motore.

Killer 1 — Il thrashing del modello

In 48 GB di VRAM ci sta un modello alla volta. Se un client qualsiasi della flotta chiede un modello diverso, il motore sfratta quello caldo e ne carica un altro: reload da 30-78 secondi che azzera la cache appena costruita. Con la chat live, qualcosa chiedeva ancora il vecchio modello a intermittenza. Il fix: il proxy riscrive il campo model di ogni richiesta verso un target fisso e forza num_ctx. Un solo punto di controllo centrale, e nessun client può più provocare uno sfratto, qualunque cosa chieda.

Killer 2 — Il clobber degli health-ping

Il layer di compatibilità veniva interrogato sullo stato di salute ogni 6 secondi circa, e il suo health-check mandava una micro-inferenza (un token) a ogni modello. Con un solo slot di inferenza, ogni ping sovrascriveva la KV cache della conversazione in corso. Il fix: il proxy intercetta le richieste-ping, riconoscibili da num_predict ≤ 1, e risponde con un finto messaggio valido senza toccare il motore. Il check resta "healthy", la cache della chat resta intatta.

Killer 3 — Il nonce cch= (la chicca)

Catturati e diffati due prompt reali consecutivi. Divergevano in un solo punto, il carattere numero 79 di un system prompt da 27.000 caratteri: un header di cache-control dell'agente SDK conteneva un nonce cch=1ead0 che diventava cch=fb051, cambiando a ogni richiesta, ed era in testa al prompt.

L'ironia: quel nonce serve alla prompt-cache di Anthropic, lato cloud. Dirottato su un motore locale diventa il suo opposto, un cache-killer. Cambiando il primo carattere utile del prefisso, invalida tutta la KV cache a valle, costringendo a ri-processare 44.000 token a ogni turno.

Il fix è una riga: il proxy normalizza il nonce prima di inoltrare, stabilizzando il prefisso, con una sostituzione del tipo body = re.sub(rb"cch=[0-9a-f]+", b"cch=00000", body).

Il risultato, con modello full-attention più i tre fix, è stato netto: primo messaggio con prefill di 11.773 ms a freddo, secondo a 102 ms, terzo a 94 ms. Il wall-clock in interfaccia è sceso da 23 secondi a 9 secondi dal secondo messaggio. La cache, su un modello che la supporta, vola.

Controprova full-attention

Il candidato full-attention, un MoE 30B "instruct" e non l'ibrido, è stato misurato in isolamento, con il motore fermo per gli altri e nessun thrashing. La prima chiamata, a freddo, ha fatto un prefill di 12,96 secondi su 49.538 token, a 3.824 token al secondo. La seconda chiamata, con lo stesso system prompt, ha fatto un prefill di 0,06 secondi: cache hit. La terza identica, 0,06 secondi. Generazione a 138 token al secondo.

Mettendo a confronto diretto i due modelli sullo stesso ferro, l'ibrido Qwen3.6 faceva un prefill a circa 2.300 token al secondo e generava a 88 token al secondo, senza riuso della cache, con 19 secondi a ogni turno; il contesto massimo pratico arrivava a 256.000 token e la qualità era superiore. Il MoE 30B full-attention faceva prefill a 3.824 token al secondo e generava a 138 token al secondo, con riuso della cache pieno e prefill di 0,06 secondi dal secondo turno; il contesto massimo pratico era circa 160.000 token (in 48 GB con KV a q8) e la qualità era inferiore, più "lobotomizzata".

La cache, su full-attention, funziona come da manuale. Ma il modello 30B con 3B di parametri attivi sparava più spesso fatti sbagliati e riempiva di fronzoli: un downgrade qualitativo netto rispetto all'ibrido.

La decisione finale

Messi sul tavolo i due lati del trade-off, la scelta è stata tornare al modello ibrido Qwen3.6, accettando quella che si credeva una lentezza multi-turno irriducibile in cambio della qualità di ragionamento e del contesto da 256.000 token. Il driver di fondo è la sovranità del dato: tenere i prompt, che includono dati personali, fuori dai provider cloud.

Nota del 9 giugno: la "lentezza multi-turno" non era irriducibile, era il nonce client, già neutralizzato dal proxy. Si tiene Qwen3.6 e si ha la cache. Vedi l'epilogo in fondo.

Il proxy modelforce resta attivo anche col modello ibrido: si credeva che il fix sul nonce non aiutasse (per via del presunto limite architetturale invalicabile), ma l'anti-thrashing e l'anti-clobber dei ping valgono comunque e tengono il sistema prevedibile. Il bivio, sintetizzato in una riga, sembrava questo: reattività multi-turno con modello full-attention, cache funzionante, sotto i 2 secondi dal secondo turno; oppure qualità più contesto da 256.000 token con modello ibrido, pagando il re-prefill a ogni turno. La premessa, cioè che non si potessero avere entrambi, era falsa.

Epilogo — 9 giugno 2026: la diagnosi ribaltata

Giorni dopo la stesura del report, un test in produzione ha smentito la conclusione architetturale. Misurando dal vivo, con il timer della webchat, su un brain reale che parla con la stessa GPU, il modello riusa la cache eccome: il prefill crolla dai circa 12 secondi del primo turno a frazioni di secondo su quelli successivi. In un test diretto a prefisso identico: 2,072 secondi, poi 54 ms, poi 47 ms.

La causa radice non era l'attenzione ibrida del modello. Era il nonce dinamico iniettato in testa al prompt dal client agentico, lo stesso cache-buster descritto tra i tre cache-killer, che cambiava a ogni richiesta e invalidava il prefisso. Il proxy lo normalizzava già: il problema era quindi risolto da tempo senza saperlo. Nessun limite invalicabile, nessuna patch upstream da attendere.

La lentezza residua percepita, circa 7 secondi anche per una risposta brevissima, non è il prefill: è l'overhead del loop dell'agente. Le sezioni precedenti restano il racconto del percorso reale, conclusioni affrettate comprese, perché l'errore di diagnosi, e come è stato corretto, sono parte della storia.

Lezioni operative

Non fermarsi al primo fix che migliora qualcosa. Tre ipotesi su quattro erano vere e tutte e tre miglioravano un pezzo, ma il numero grosso non si muoveva. Il miglioramento plausibile è il nemico della diagnosi vera.

Leggere i log del motore, non dedurre. La riga forcing full prompt re-processing ha chiuso giorni di congetture in una schermata. Va però aggiunta la lezione dell'epilogo: anche un log vero può portare a una conclusione sbagliata se viene letto in condizioni non pulite. Quei log erano presi mentre il prefisso era ancora avvelenato dal nonce.

Le scelte architetturali del modello hanno conseguenze operative non ovvie. "Contesto 256K economico" e "cache multi-turno rotta" sembravano la stessa proprietà vista da due lati. La seconda, però, non c'era: era un artefatto del client.

Un cache-killer può nascondersi in un singolo carattere. Il nonce cch= al carattere 79 di un prompt da 27.000 caratteri invalidava tutto. Diffare due prompt reali è valso più di qualsiasi ipotesi, ed è ciò che alla fine ha portato alla vera causa.

Centralizzare il controllo dove i client non collaborano. Invece di toccare la config di ogni client della flotta, un solo proxy davanti al motore impone l'invariante: un modello, stessi parametri, niente clobber, nonce normalizzato.

Glossario

Prefill. Fase in cui il modello legge e processa l'intero prompt prima di generare. Costosa in proporzione alla lunghezza del prompt.

Decode. Fase di generazione token per token della risposta, dopo il prefill.

KV cache. Le proiezioni key/value calcolate durante il prefill, conservate per non ricalcolarle.

Prefix caching. Riuso della KV cache quando un nuovo prompt condivide il prefisso con uno precedente. Rende gratis tutto tranne la parte nuova.

SWA. Sliding Window Attention, attenzione a finestra scorrevole. Il flag --swa-full la disattiva tenendo la KV intera, ma solo per i modelli SWA puri.

Attenzione ibrida o ricorrente. Mix di layer full-attention e layer ad attenzione lineare/ricorrente, che comprimono il passato in uno stato evolutivo non riprendibile da metà.

MoE (A3B). Mixture of Experts con circa 3B parametri attivi per token su un totale molto maggiore. Occupa VRAM da modello grande, calcola da modello piccolo.

Thinking. Token di ragionamento interno che un modello reasoner genera prima della risposta visibile.

Thrashing. Sfratto e ricarico continuo di modelli quando più di uno è richiesto su VRAM che ne ospita uno solo.

Highlight