Post-mortem · Inference on-prem

La saga della cache conversazionale di Qwen3.6

Diario tecnico di un'indagine durata giorni: perché un LLM locale serviva ogni messaggio con ~19–60 secondi di attesa fissa, anche al secondo e terzo turno della stessa conversazione. Quattro ipotesi corrette ma non dominanti, una causa radice creduta architetturale, tre cache-killer separati e un proxy fatto in casa per neutralizzarli. Con un epilogo che ribalta la diagnosi: un test in produzione, giorni dopo, ha mostrato che il modello la cache la riusa eccome — il vero colpevole era lato client, e già risolto.

AutoreAnacleto / Giobi Periodo04–07 giu 2026 StatoRisolto / accettato DominioLLM inference, KV cache Hardware2× GPU 24GB (48GB)

Aggiornamento — 9 giugno 2026: la diagnosi è stata ribaltata

Giorni dopo la stesura di questo 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 ~12 secondi del primo turno a frazioni di secondo su quelli successivi. In un test diretto a prefisso identico: 2.072s → 54ms → 47ms.

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

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

01  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 48GB (due schede da 24GB), 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 — istruzioni dell'agente, definizioni di ~26 tool, identità e contesto risolti dai file di boot. In token reali, misurati: ~43.900 token per turno di puro prefisso di sistema, prima ancora che l'utente scriva qualcosa.

Nota di framing
Con un prompt di sistema da ~44K 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 44K token da zero. È l'intero perno di questo report.

02  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.

~44K
token system / turno
agente + 26 tool + boot
19–60s
attesa per messaggio
anche 2° e 3° turno
0
cache_read token
riuso azzerato

03  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 — 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 44K token è lavoro pesante: sul ferro in questione si misurava a ~2.300–3.800 token/secondo, quindi diversi secondi solo per "leggere".

L'ottimizzazione classica: 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. Prefill da secondi a millisecondi. È il prefix caching, e su uno stack sano dovrebbe rendere gratis tutto tranne il primo messaggio.

# Cosa dovrebbe succedere (cache sana)
turno 1: prefill 44.000 token  -> ~12s   (freddo)
turno 2: prefill 44.000 token  -> ~0.1s  # cache HIT, ricalcola solo i token nuovi
turno 3: prefill 44.000 token  -> ~0.1s  # cache HIT

# Cosa succedeva davvero
turno 1: prefill 44.000 token  -> ~19s
turno 2: prefill 44.000 token  -> ~19s   # cache MISS, ri-processa tutto
turno 3: prefill 44.000 token  -> ~19s   # cache MISS

04  La caccia: quattro ipotesi

La diagnosi è arrivata in coda a quattro ipotesi. La nota meta importante: 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.

H1Il thinkingVera, non dominante
Il modello è un reasoner: prima di rispondere genera migliaia di token di ragionamento interno invisibile. Misurato su una domanda banale: 3492 chunk di reasoning contro 214 di contenuto — il 95% del tempo speso a ruminare. A ~88 token/s sono ~40s di vuoto. Disattivato con think:false nativo (il soft-switch /no_think nel prompt — provato — non funziona su questo modello). Migliorava il primo token, ma il problema multi-turno restava.
H2Lo streaming mancanteScartata
Sospetto: la risposta arriva "tutta insieme" perché i token non scorrono. Verificato strato per strato — layer di compatibilità, agente, hub, controller, frontend: tutti emettono text_delta incrementali correttamente, ~0.12s l'uno. La catena di streaming era intatta. Non era questo.
H3L'agente (Claude Code SDK)Scartata
Sospetto: il loop agentico ri-lancia una sessione fresca a ogni turno e re-inietta tutto. Verificato: l'overhead dell'SDK c'è, ma non spiega i 19s fissi. Non era il collo.
H4La dimensione del prefillVera, non dominante
Un prompt da 44K 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.

05  La causa radice

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   # similarità cache ~ zero

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 — SWA o memoria ibrida/ricorrente.

Verifica successiva, tentando il flag che terrebbe la KV intera (--swa-full) con il server diretto:

load_model: swa_full is not supported by this model, it will be disabled

Il flag non si applica. L'architettura confermata dal binario: MoE con layer ad attenzione lineare/ricorrente (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".

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.

06  Approfondimento architetturale

Perché un modello ibrido non può 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. Riuso del prefisso banale.

Attenzione lineare / ricorrente (la cache non si riusa) il caso di Qwen3.6

I layer ad attenzione lineare/ricorrente non tengono una KV per-posizione: comprimono il passato in uno stato che evolve token dopo token. Non puoi "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 devi ri-processare tutto.

Il paradosso centrale

L'attenzione ibrida è esattamente ciò che rende il modello capace di gestire 256K token di contesto in pochissima VRAM (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'aggiornamento in cima.

Conseguenze sulle opzioni di fix considerate:

  • Upgrade del motore: morto. Non è questione di versione, è supporto parziale a monte per gli ibridi.
  • Flag --swa-full: morto. Serve ai modelli SWA puri (es. Gemma), non agli ibridi ricorrenti.
  • Motori alternativi (vLLM / SGLang): non garantiti. Anche loro faticano col prefix-caching dei layer ricorrenti/lineari — area immatura ovunque. Aiutano solo i modelli full-attention.

07  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 48GB, un modello solo

In 48GB 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.

Fix: il proxy riscrive il campo model di ogni richiesta verso un target fisso e forza num_ctx. Un solo punto di controllo centrale: nessun client può più provocare uno sfratto, qualunque cosa chieda.

Killer #2 — Il clobber degli health-ping slot singolo

Il layer di compatibilità veniva interrogato sullo stato di salute ogni ~6 secondi, 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.

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 a un solo punto, il carattere 79 di un system prompt da 27.000 caratteri: un header di cache-control dell'agente SDK conteneva un nonce cch=1ead0cch=fb051 che cambia a ogni richiesta, ed era in testa al prompt.

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 — ri-processo di 44K token a ogni turno.

Fix: il proxy normalizza il nonce prima di inoltrare, stabilizzando il prefisso.

# normalizzazione del nonce nel proxy
body = re.sub(rb"cch=[0-9a-f]+", b"cch=00000", body)
Risultato con modello full-attention + tre fix
msg1: prefill 11.773 ms (freddo) · msg2: prefill 102 ms · msg3: prefill 94 ms. Wall-clock in UI da 23s a 9s dal secondo messaggio. La cache, su un modello che la supporta, vola.

08  Controprova full-attention

Il candidato full-attention (un MoE 30B "instruct", non l'ibrido) è stato misurato in isolamento — motore fermo per gli altri, nessun thrashing:

call1 (freddo):  prefill 12.96s  (3824 tok/s, 49.538 token)
call2 (stesso system): prefill 0.06s   # CACHE HIT
call3:                 prefill 0.06s   # CACHE HIT
generazione: 138 tok/s
Confronto diretto sullo stesso ferro (2× GPU 24GB)
MetricaQwen3.6 (ibrido)MoE 30B (full-attention)
Prefill2.300 tok/s3.824 tok/s
Generazione88 tok/s138 tok/s
Riuso cacheNo — 19s ogni turnoSì — 0.06s dal 2°
Contesto max pratico256K~160K (in 48GB, KV q8)
QualitàSuperioreInferiore (più "lobotomizzato")

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.

09  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 256K. 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'aggiornamento in cima.

Il proxy modelforce resta attivo anche col modello ibrido: il fix sul nonce non aiuta (limite architetturale invalicabile), ma l'anti-thrashing e l'anti-clobber dei ping valgono comunque e tengono il sistema prevedibile.

Il bivio reale, in una riga
Reattività multi-turno → modello full-attention, la cache funziona, dal 2° turno <2s.  |  Qualità + contesto 256K → modello ibrido, paghi il re-prefill a ogni turno. Non si possono avere entrambi sullo stesso modello.

10  Lezioni operative

  1. 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.
  2. Leggere i log del motore, non dedurre. La riga forcing full prompt re-processing ha chiuso giorni di congetture in una schermata. La verità era nel log fin dall'inizio.
  3. Le scelte architetturali del modello hanno conseguenze operative non ovvie. "Contesto 256K economico" e "cache multi-turno rotta" sono la stessa proprietà vista da due lati.
  4. Un cache-killer può nascondersi in un singolo carattere. Il nonce cch= al carattere 79 di un prompt da 27K invalidava tutto. Diffare due prompt reali è valso più di qualsiasi ipotesi.
  5. 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".

11  Glossario

TermineSignificato
PrefillFase in cui il modello legge e processa l'intero prompt prima di generare. Costosa in proporzione alla lunghezza del prompt.
DecodeFase di generazione token-per-token della risposta, dopo il prefill.
KV cacheLe proiezioni key/value calcolate durante il prefill, conservate per non ricalcolarle.
Prefix cachingRiuso della KV cache quando un nuovo prompt condivide il prefisso con uno precedente. Rende gratis tutto tranne la parte nuova.
SWASliding 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 / ricorrenteMix 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 ~3B parametri attivi per token su un totale molto maggiore. Occupa VRAM da modello grande, calcola da modello piccolo.
ThinkingToken di ragionamento interno che un modello reasoner genera prima della risposta visibile.
ThrashingSfratto e ricarico continuo di modelli quando più di uno è richiesto su VRAM che ne ospita uno solo.