Una guida visiva per modificare una maschera di attenzione in un modello decisionale simile a Jev

Una guida visiva per modificare una maschera di attenzione in un modello decisionale simile a Jev

Diego Fiori

·

Co-founder & CTO

In breve

Jev, il modello decisionale di TypeSafe, risponde a molteplici domande di classificazione su un singolo documento in un unico passaggio in avanti (forward pass) e restituisce probabilità calibrate anziché testo. Questa guida lo ricostruisce passo dopo passo: fermati prima della decodifica, modifica la maschera di attenzione (attention mask) e gli id di posizione (position ids) in modo che le domande rimangano isolate e indipendenti dall'ordine, sostituisci la testa LM con una testa a puntatore (pointer head) e ottimizza (fine-tune) con la cross-entropia e la scala di temperatura (temperature scaling). Nulla di tutto ciò aggiunge nuova conoscenza, quindi il backbone rappresenta ancora il limite massimo.

Questa guida ha un unico filo conduttore: la maschera di attenzione (attention mask). Partiamo da ciò che la maschera fa in un normale transformer causale, la modifichiamo fino a far avvenire la classificazione in un singolo passaggio in avanti (forward pass), risolviamo i tre problemi che si presentano lungo il percorso, sostituiamo la parte finale di output e ottimizziamo (fine-tune) il risultato. Il design target è la ricostruzione black-box di Archer Hume di Jev di TypeSafe. Le misurazioni provengono da kev di Jared Palmer, che lo implementa su Qwen2.5-0.5B, e da laya, che prende l'altro ramo del fork descritto subito sotto. Laddove si tratta ancora di inferenza anziché di misurazione, il testo lo specifica.

1. Dove si trova già la risposta

Ecco una chiamata di classificazione, nel modo in cui quasi tutti la eseguono oggi.

prompt  →  [prefill: 20.000 token]

prompt  →  [prefill: 20.000 token]

prompt  →  [prefill: 20.000 token]

prompt  →  [prefill: 20.000 token]

prompt  →  [prefill: 20.000 token]

Ci sono due cose che non vanno qui, e richiedono soluzioni separate.

La confidenza è una sequenza di token. Il modello ha emesso i caratteri 0, ., 9, 1. La probabilità che il modello emetta quei caratteri non è la probabilità che payments sia la coda corretta. Due quantità diverse, stampate con lo stesso font. Nulla nell'addestramento le ha mai collegate.

Hai pagato per un ciclo di decodifica (decode loop) di cui non avevi bisogno. Il token t+1 non esiste finché non hai campionato il token t, quindi quei 28 passaggi sono strettamente sequenziali. E hanno prodotto informazioni che erano già presenti nel modello dopo il prefill.

Questa seconda frase è quella portante. Per capire perché sia vera, dobbiamo guardare cosa fa effettivamente la maschera.

Prima, una biforcazione (fork)

Una decisione precede tutto il resto, perché stabilisce se le prossime sei parti si applicano o meno al tuo caso.

Un decoder causale ha una maschera che deve essere modificata. Un encoder bidirezionale, BERT e i suoi discendenti, non ne ha mai avuta una: ogni token legge già ogni altro token, ed è stato pre-addestrato per rappresentare piuttosto che per generare. Inizia da lì e la maggior parte di questa guida sarà superflua. Passi direttamente alla lettura nella parte 8.






Questa guida prende la strada del decoder, ed è la scelta predefinita che consiglierei. Il motivo è l'unica cosa che nessuna di queste modifiche può fornire: la conoscenza. Ogni intervento descritto di seguito cambia il modo in care viene letta la conoscenza di un modello. Nessuno ne aggiunge di nuova. I decoder sono la destinazione del calcolo di pre-addestramento (pretraining compute), e non esiste un encoder a 25B; i migliori si aggirano intorno ai 400M.

Questa non è una preoccupazione teorica. laya è la più forte implementazione basata su encoder di questo design, costruita su ModernBERT-large a 421M. I suoi checkpoint di base ottengono un punteggio inferiore alla baseline della classe di maggioranza sul loro stesso benchmark typed-decisions in modalità zero-shot: 0.362 contro uno 0.461 della classe di maggioranza e lo 0.318 del caso casuale. Dopo l'ottimizzazione (fine-tuning) sullo split di addestramento dello stesso benchmark, raggiungono lo 0.766. Il loro README lo spiega perfettamente: una base veloce da specializzare, non un motore decisionale zero-shot.

Quindi, la strada dell'encoder è la scelta corretta quando conosci già il tuo workflow, disponi di dati etichettati e desideri 33 ms per richiesta. La strada del decoder è la scelta corretta quando desideri qualcosa di utile su uno schema mai visto prima, che è esattamente ciò che promette l'interfaccia di Jev. La maggior parte delle persone che lo richiedono ha bisogno della seconda opzione.

Il ramo dell'encoder ricompare due volte di seguito, nella parte 8 e nella parte 10, perché su due questioni specifiche ha misurato il costo di una scelta di design che questa guida affronta in modo diverso.

2. Cosa fa effettivamente una maschera di attenzione

All'interno di un layer di attenzione, ogni token viene proiettato in una query, una key e un value. Ogni query viene confrontata con ogni key, il che restituisce una matrice quadrata di punteggi.

sequenza:  il   ticket   riguarda   la   fatturazione
                                ↓
                   punteggi = Q · Kᵀ / √d

                il   ticket   riguarda   la   fatturazione
      il        [ 3.1   0.4      1.2     0.7       2.0 ]
      ticket    [ 0.9   4.2      1.1     2.3       3.8 ]
      riguarda  [ 1.0   2.1      3.5     0.8       1.4 ]
      la        [ 2.1   4.3      1.8     2.9       0.9 ]   ← riga 4
  fatturazione  [ 1.7   3.9      1.0     4.1       3.2 ]
sequenza:  il   ticket   riguarda   la   fatturazione
                                ↓
                   punteggi = Q · Kᵀ / √d

                il   ticket   riguarda   la   fatturazione
      il        [ 3.1   0.4      1.2     0.7       2.0 ]
      ticket    [ 0.9   4.2      1.1     2.3       3.8 ]
      riguarda  [ 1.0   2.1      3.5     0.8       1.4 ]
      la        [ 2.1   4.3      1.8     2.9       0.9 ]   ← riga 4
  fatturazione  [ 1.7   3.9      1.0     4.1       3.2 ]
sequenza:  il   ticket   riguarda   la   fatturazione
                                ↓
                   punteggi = Q · Kᵀ / √d

                il   ticket   riguarda   la   fatturazione
      il        [ 3.1   0.4      1.2     0.7       2.0 ]
      ticket    [ 0.9   4.2      1.1     2.3       3.8 ]
      riguarda  [ 1.0   2.1      3.5     0.8       1.4 ]
      la        [ 2.1   4.3      1.8     2.9       0.9 ]   ← riga 4
  fatturazione  [ 1.7   3.9      1.0     4.1       3.2 ]
sequenza:  il   ticket   riguarda   la   fatturazione
                                ↓
                   punteggi = Q · Kᵀ / √d

                il   ticket   riguarda   la   fatturazione
      il        [ 3.1   0.4      1.2     0.7       2.0 ]
      ticket    [ 0.9   4.2      1.1     2.3       3.8 ]
      riguarda  [ 1.0   2.1      3.5     0.8       1.4 ]
      la        [ 2.1   4.3      1.8     2.9       0.9 ]   ← riga 4
  fatturazione  [ 1.7   3.9      1.0     4.1       3.2 ]
sequenza:  il   ticket   riguarda   la   fatturazione
                                ↓
                   punteggi = Q · Kᵀ / √d

                il   ticket   riguarda   la   fatturazione
      il        [ 3.1   0.4      1.2     0.7       2.0 ]
      ticket    [ 0.9   4.2      1.1     2.3       3.8 ]
      riguarda  [ 1.0   2.1      3.5     0.8       1.4 ]
      la        [ 2.1   4.3      1.8     2.9       0.9 ]   ← riga 4
  fatturazione  [ 1.7   3.9      1.0     4.1       3.2 ]

La riga i indica quanto il token i desideri leggere da ogni altro token. Applica il softmax a ciascuna riga, moltiplica per i valori e otterrai la nuova rappresentazione di quel token.

La maschera è una seconda matrice, della stessa forma, che decide quali di quei punteggi possono esistere. Le voci bloccate vengono impostate su meno infinito prima del softmax, in modo da contribuire esattamente per zero in seguito.

Prendiamo la riga 4 e applichiamo una normale maschera causale, che blocca tutto ciò che si trova a destra della diagonale:






fatturazione non contribuisce in alcun modo a la. Non poco: zero. Sull'intera matrice, questo genera il familiare triangolo.

               il   ticket   riguarda   la   fatturazione
il             [  ■     ·        ·       ·         ·  ]
ticket         [  ■     ■        ·       ·         ·  ]
riguarda       [  ■     ■        ■       ·         ·  ]
la             [  ■     ■        ■       ■         ·  ]
fatturazione   [  ■     ■        ■       ■         ■  ]

               il   ticket   riguarda   la   fatturazione
il             [  ■     ·        ·       ·         ·  ]
ticket         [  ■     ■        ·       ·         ·  ]
riguarda       [  ■     ■        ■       ·         ·  ]
la             [  ■     ■        ■       ■         ·  ]
fatturazione   [  ■     ■        ■       ■         ■  ]

               il   ticket   riguarda   la   fatturazione
il             [  ■     ·        ·       ·         ·  ]
ticket         [  ■     ■        ·       ·         ·  ]
riguarda       [  ■     ■        ■       ·         ·  ]
la             [  ■     ■        ■       ■         ·  ]
fatturazione   [  ■     ■        ■       ■         ■  ]

               il   ticket   riguarda   la   fatturazione
il             [  ■     ·        ·       ·         ·  ]
ticket         [  ■     ■        ·       ·         ·  ]
riguarda       [  ■     ■        ■       ·         ·  ]
la             [  ■     ■        ■       ■         ·  ]
fatturazione   [  ■     ■        ■       ■         ■  ]

               il   ticket   riguarda   la   fatturazione
il             [  ■     ·        ·       ·         ·  ]
ticket         [  ■     ■        ·       ·         ·  ]
riguarda       [  ■     ■        ■       ·         ·  ]
la             [  ■     ■        ■       ■         ·  ]
fatturazione   [  ■     ■        ■       ■         ■  ]

La maschera stabilisce chi può leggere chi. Non indica in quale ordine debba essere eseguito qualcosa. Durante il prefill tutti i 5 token esistono già, quindi un layer calcola l'intera matrice 5x5 in un colpo solo, la maschera e aggiorna tutte e 5 le rappresentazioni insieme. I layer vengono eseguiti in sequenza, le posizioni no. La sequenzialità nella generazione deriva da tutt'altro: quando stai campionando, il token t+1 non esiste finché non hai estratto il token t. Questa è una proprietà del campionamento, non del triangolo. Quindi: fermati prima di campionare, e nulla in un transformer causale risulterà sequenziale. Tutto in questa guida deriva da questa singola frase.

3. Primo tentativo: fermarsi e basta

Se il prefill ha già costruito una rappresentazione completa de “lo stato, quindi questa domanda”, prendiamola ed evitiamo del tutto la decodifica.






Per ora, leggi h attraverso l'ordinaria testa LM (LM head) del modello e guarda solo i logit delle lettere dell'opzione:

logits = model(render(state, question)).logits[0, -1]
p = logits[tok.convert_tokens_to_ids(["A", "B", "C"])].softmax(-1)
logits = model(render(state, question)).logits[0, -1]
p = logits[tok.convert_tokens_to_ids(["A", "B", "C"])].softmax(-1)
logits = model(render(state, question)).logits[0, -1]
p = logits[tok.convert_tokens_to_ids(["A", "B", "C"])].softmax(-1)
logits = model(render(state, question)).logits[0, -1]
p = logits[tok.convert_tokens_to_ids(["A", "B", "C"])].softmax(-1)
logits = model(render(state, question)).logits[0, -1]
p = logits[tok.convert_tokens_to_ids(["A", "B", "C"])].softmax(-1)

Un solo passaggio, nessuna decodifica, nessun addestramento. Questo funziona oggi su qualsiasi modello tu distribuisca, ed è la baseline che dovresti misurare prima di costruire qualsiasi cosa.

Cosa ti offre. Il ciclo di decodifica scompare. Problema 2 risolto.

Cosa non ti offre. Queste sono probabilità del token successivo e sono calibrate male. Ecco la misurazione di kev sul backbone Qwen2.5-0.5B non ottimizzato, espressa come accuratezza / ECE:

Task

Base

Instruct

Scelta, 4 opzioni (AG News)

0.813 / 0.069

0.787 / 0.160

Scelta, 3 opzioni (MNLI)

0.460 / 0.225

0.433 / 0.390

Sì/No (BoolQ)

0.427 / 0.274

0.607 / 0.084

Punteggio, 5 livelli (Yelp)

0.313 / 0.043

0.353 / 0.078

Guarda la colonna Instruct: l'ottimizzazione delle istruzioni (instruction tuning) ha peggiorato la calibrazione su tre task su quattro. L'RLHF rende le distribuzioni più nette verso risposte che sembrano sicure, il che è esattamente la pressione sbagliata per un servizio decisionale.

E c'è un problema di costi. Le richieste reali pongono molte domande su un unico documento. Dieci domande significano dieci chiamate, e ognuna codifica nuovamente lo stesso stato da 20.000 token.

chiamata 1:  [ 20.000 token di stato ][ Q1 ]
chiamata 2:  [ 20.000 token di stato ][ Q2 ]      lo stesso prefill,
chiamata 3:  [ 20.000 token di stato ][ Q3 ]

chiamata 1:  [ 20.000 token di stato ][ Q1 ]
chiamata 2:  [ 20.000 token di stato ][ Q2 ]      lo stesso prefill,
chiamata 3:  [ 20.000 token di stato ][ Q3 ]

chiamata 1:  [ 20.000 token di stato ][ Q1 ]
chiamata 2:  [ 20.000 token di stato ][ Q2 ]      lo stesso prefill,
chiamata 3:  [ 20.000 token di stato ][ Q3 ]

chiamata 1:  [ 20.000 token di stato ][ Q1 ]
chiamata 2:  [ 20.000 token di stato ][ Q2 ]      lo stesso prefill,
chiamata 3:  [ 20.000 token di stato ][ Q3 ]

chiamata 1:  [ 20.000 token di stato ][ Q1 ]
chiamata 2:  [ 20.000 token di stato ][ Q2 ]      lo stesso prefill,
chiamata 3:  [ 20.000 token di stato ][ Q3 ]

4. Secondo tentativo: una sequenza, maschera standard

La soluzione ovvia consiste nel inserire tutto in un'unica sequenza in modo che lo stato venga codificato una sola volta.

[ stato ][ Q_a ][ Q_b ][ Q_c ]
[ stato ][ Q_a ][ Q_b ][ Q_c ]
[ stato ][ Q_a ][ Q_b ][ Q_c ]
[ stato ][ Q_a ][ Q_b ][ Q_c ]
[ stato ][ Q_a ][ Q_b ][ Q_c ]

Esegui il processo con la normale maschera causale e leggi l'ultimo stato nascosto dell'intervallo (span) di ciascuna domanda. Lo stato viene codificato esattamente una volta. Problema risolto?

No. Guarda cosa consente il triangolo.






Due fallimenti, entrambi gravi.

Contaminazione. La domanda C può leggere le domande A e B. Chiedi “il cliente è arrabbiato?” prima di “quale coda?” e l'inquadratura di rabbia farà ora parte del contesto letto dalla decisione sulla coda. La risposta a una domanda dipende da quali altre domande ti è capitato di porre.

Dipendenza dall'ordine. Scambia A e B nel payload JSON e otterrai risposte diverse, perché ora occupano posizioni diverse nel triangolo. L'ordinamento delle chiavi del chiamante, che dovrebbe essere insignificante, cambia l'output.

L'interfaccia che vogliamo promette che le domande siano indipendenti. Il triangolo non garantisce questo. Quindi, cambia il triangolo.

5. Modificare la maschera

Vogliamo che ogni domanda legga lo stato e se stessa, e nient'altro. Questa è una piccola modifica: prendi il triangolo ed elimina i blocchi in cui una domanda ne legge un'altra.






Leggila riga per riga e il design si delinea da sé.

  • La riga dello stato vede solo se stessa. Non dipende mai da alcuna domanda, ed è esattamente per questo che la sua cache KV può essere calcolata una volta e riutilizzata. Questa non è un'ottimizzazione inserita a posteriori; è una conseguenza della forma della maschera.

  • Ogni riga di domanda legge lo stato per intero, più i propri token in modo causale.

  • Nessuna riga di domanda tocca un'altra domanda. L'isolamento non è imposto da un wrapper o da un'istruzione di prompt. È imposto dall'aritmetica: quei punteggi sono meno infinito, quindi contribuiscono per zero.

Nel codice l'intera modifica si riduce a un unico predicato.

from torch.nn.attention.flex_attention import create_block_mask

# branch_id[t] = 0 per i token di stato, k >= 1 per i token della domanda k
def jev_mask(b, h, q_idx, kv_idx):
    causal      = q_idx >= kv_idx
    same_branch = branch_id[b, q_idx] == branch_id[b, kv_idx]
    is_state    = branch_id[b, kv_idx] == 0
    return causal & (same_branch | is_state)

block_mask = create_block_mask(jev_mask, B, H, L, L)
from torch.nn.attention.flex_attention import create_block_mask

# branch_id[t] = 0 per i token di stato, k >= 1 per i token della domanda k
def jev_mask(b, h, q_idx, kv_idx):
    causal      = q_idx >= kv_idx
    same_branch = branch_id[b, q_idx] == branch_id[b, kv_idx]
    is_state    = branch_id[b, kv_idx] == 0
    return causal & (same_branch | is_state)

block_mask = create_block_mask(jev_mask, B, H, L, L)
from torch.nn.attention.flex_attention import create_block_mask

# branch_id[t] = 0 per i token di stato, k >= 1 per i token della domanda k
def jev_mask(b, h, q_idx, kv_idx):
    causal      = q_idx >= kv_idx
    same_branch = branch_id[b, q_idx] == branch_id[b, kv_idx]
    is_state    = branch_id[b, kv_idx] == 0
    return causal & (same_branch | is_state)

block_mask = create_block_mask(jev_mask, B, H, L, L)
from torch.nn.attention.flex_attention import create_block_mask

# branch_id[t] = 0 per i token di stato, k >= 1 per i token della domanda k
def jev_mask(b, h, q_idx, kv_idx):
    causal      = q_idx >= kv_idx
    same_branch = branch_id[b, q_idx] == branch_id[b, kv_idx]
    is_state    = branch_id[b, kv_idx] == 0
    return causal & (same_branch | is_state)

block_mask = create_block_mask(jev_mask, B, H, L, L)
from torch.nn.attention.flex_attention import create_block_mask

# branch_id[t] = 0 per i token di stato, k >= 1 per i token della domanda k
def jev_mask(b, h, q_idx, kv_idx):
    causal      = q_idx >= kv_idx
    same_branch = branch_id[b, q_idx] == branch_id[b, kv_idx]
    is_state    = branch_id[b, kv_idx] == 0
    return causal & (same_branch | is_state)

block_mask = create_block_mask(jev_mask, B, H, L, L)

Quelle regioni eliminate sono libere. FlexAttention lavora su tasselli (tiles) e salta un tassello che è interamente mascherato, così non calcoli mai i punteggi che stai per scartare. La stessa struttura esiste in Hydragen e DeFT, e vLLM e SGLang ti offrono la maggior parte di ciò tramite la memorizzazione in cache del prefisso (prefix caching).

Cosa si risparmia effettivamente

È bene essere precisi, poiché i titoli altisonanti sono spesso esagerati.

Lavoro per layer

Q chiamate separate

Una maschera modificata

Token FFN / MoE

Q · (S + n)

S + Q · n

Lo stato presta attenzione a se stesso

Q · O(S²)

O(S²), una sola volta

Le domande prestano attenzione allo stato

Q · S

Q · S, invariato

L'ultima riga non migliora, e nessuna modifica della maschera può farlo. Ogni domanda deve realmente leggere lo stato. Ciò che scompare è tutto il resto e, poiché l'FFN domina i FLOP di prefill e S è tipicamente molto più grande di n, questa rappresenta la maggior parte della spesa. kev misura per il percorso compresso (packed path) un throughput di 2.0x rispetto alle chiamate separate.

Spiega anche i due limiti esposti dall'API reale: circa 32K per ramo, circa 64K per richiesta, con lo stato conteggiato una sola volta. Uno stato da 23K con 5.000 domande si adatta comodamente. Con chiamate separate, la stessa richiesta supererebbe i 100 milioni di token.

6. Terza cosa che si rompe: le posizioni

La maschera è corretta e il modello continua a comportarsi in modo anomalo. Questo accade perché l'attenzione non usa solo la maschera; con RoPE, utilizza anche la distanza tra le posizioni.

Nella sequenza compattata, le domande si trovano a offset differenti:






Quindi Q_c è più lontano dallo stato rispetto a Q_a, puramente a causa di dove è atterrato nel payload JSON. Abbiamo eliminato i collegamenti di attenzione tra i rami, ma abbiamo lasciato l'ordinamento geometrico in posizione, e RoPE legge quell'ordinamento.

La soluzione consiste nel far ripartire gli ID di posizione (position ID) di ciascun ramo subito dopo lo stato.






pos = torch.where(branch_id == 0, idx, S + idx - branch_start)
pos = torch.where(branch_id == 0, idx, S + idx - branch_start)
pos = torch.where(branch_id == 0, idx, S + idx - branch_start)
pos = torch.where(branch_id == 0, idx, S + idx - branch_start)
pos = torch.where(branch_id == 0, idx, S + idx - branch_start)

Ora ogni domanda occupa lo stesso slot geometrico: “lo stato, quindi una domanda”. Due token in rami diversi condividono un ID di posizione, il che sarebbe un bug in una sequenza ordinaria ed è innocuo qui per una sola ragione: non possono mai prestare attenzione l'uno all'altro. La modifica della maschera è ciò che rende sicura la modifica della posizione.

Questo è lo stesso trucco dell'attenzione ad albero (tree attention) nella decodifica speculativa: l'ordine delle domande cessa di avere importanza.

7. Quarta cosa che si rompe: le finestre scorrevoli (sliding windows)

Solo se il tuo backbone le possiede. Qwen2.5, utilizzato da kev, adotta un'attenzione completa ovunque, quindi kev non incontra mai questo problema. Gemma 4 lo farà.

25 dei 30 layer di Gemma 4 utilizzano una finestra scorrevole da 1.024 token. Solo 5 utilizzano l'attenzione completa. Questa è una terza maschera, in AND con la nostra:






La maggior parte dello stato è invisibile a quel layer. Arriva attraverso i 5 layer globali e attraverso ciò che i layer locali hanno già integrato nei token di stato vicino al confine del ramo. Questo non è un malfunzionamento, è lo stesso vincolo sotto il quale il modello di base opera per qualsiasi prompt lungo. Ma due cose richiedono attenzione.

Calcola la finestra sulle posizioni ripristinate, non sull'indice compattato. Altrimenti, l'ultimo ramo nel payload otterrà una finestra effettiva diversa rispetto al primo, e avrai silenziosamente reintrodotto esattamente la dipendenza dall'ordinamento che la parte 6 aveva rimosso.

def jev_mask_local(b, h, q_idx, kv_idx, window=1024):
    return jev_mask(b, h, q_idx, kv_idx) & ((pos[b, q_idx] - pos[b, kv_idx]) < window)
def jev_mask_local(b, h, q_idx, kv_idx, window=1024):
    return jev_mask(b, h, q_idx, kv_idx) & ((pos[b, q_idx] - pos[b, kv_idx]) < window)
def jev_mask_local(b, h, q_idx, kv_idx, window=1024):
    return jev_mask(b, h, q_idx, kv_idx) & ((pos[b, q_idx] - pos[b, kv_idx]) < window)
def jev_mask_local(b, h, q_idx, kv_idx, window=1024):
    return jev_mask(b, h, q_idx, kv_idx) & ((pos[b, q_idx] - pos[b, kv_idx]) < window)
def jev_mask_local(b, h, q_idx, kv_idx, window=1024):
    return jev_mask(b, h, q_idx, kv_idx) & ((pos[b, q_idx] - pos[b, kv_idx]) < window)

Quindi, misuralo. Inserisci un fatto distintivo a varie profondità nello stato, poni una domanda che lo richieda e traccia il recupero (recall) rispetto alla lunghezza dello stato. Se cala oltre poche migliaia di token, promuovi più layer all'attenzione completa e ottimizzali di nuovo, oppure limita la lunghezza dello stato nel prodotto. Nessuna implementazione ha testato questo aspetto; è una mia deduzione dall'architettura.

8. Ora l'altra estremità: sostituire la testa LM

La parte di input è completata. La parte di output è ancora un modello linguistico.

Lo stato nascosto finale viene attualmente proiettato in 262.144 logit di vocabolario, di cui ne utilizziamo tre. Proiettalo invece in uno spazio più piccolo.

          h  (lo stato nascosto finale del ramo)
                        │
       ┌────────────────┴────────────────┐
       ▼                                 ▼
testa LM: d × 262144              Readout: d × K
"quale parola viene dopo"          "quale opzione è corretta"
       │                                 │
       ▼                                 ▼
softmax sul vocabolario           softmax su K opzioni
          h  (lo stato nascosto finale del ramo)
                        │
       ┌────────────────┴────────────────┐
       ▼                                 ▼
testa LM: d × 262144              Readout: d × K
"quale parola viene dopo"          "quale opzione è corretta"
       │                                 │
       ▼                                 ▼
softmax sul vocabolario           softmax su K opzioni
          h  (lo stato nascosto finale del ramo)
                        │
       ┌────────────────┴────────────────┐
       ▼                                 ▼
testa LM: d × 262144              Readout: d × K
"quale parola viene dopo"          "quale opzione è corretta"
       │                                 │
       ▼                                 ▼
softmax sul vocabolario           softmax su K opzioni
          h  (lo stato nascosto finale del ramo)
                        │
       ┌────────────────┴────────────────┐
       ▼                                 ▼
testa LM: d × 262144              Readout: d × K
"quale parola viene dopo"          "quale opzione è corretta"
       │                                 │
       ▼                                 ▼
softmax sul vocabolario           softmax su K opzioni
          h  (lo stato nascosto finale del ramo)
                        │
       ┌────────────────┴────────────────┐
       ▼                                 ▼
testa LM: d × 262144              Readout: d × K
"quale parola viene dopo"          "quale opzione è corretta"
       │                                 │
       ▼                                 ▼
softmax sul vocabolario           softmax su K opzioni

Due modi per costruire il readout.

Una testa a slot (slot head) si mappa su slot posizionali: l'output 0 significa “la prima opzione nell'elenco”, qualunque essa sia. Il testo del ramo fornisce il significato di ciascun slot, in modo che un'unica testa serva il set di etichette di qualsiasi cliente senza bisogno di riaddestramento. Una testa a 256 slot spiegherebbe il limite di 255 opzioni dell'API, ovvero 2^8 meno uno.

Una testa a puntatori (pointer head) valuta ogni opzione rispetto alla posizione di decisione:

# h_dec: [B, d]     la posizione <decide>
# h_opt: [B, K, d]  la posizione di chiusura di ogni opzione
z = torch.einsum("bd,bkd->bk", Wq(h_dec), Wk(h_opt)) / math.sqrt(d)
# h_dec: [B, d]     la posizione <decide>
# h_opt: [B, K, d]  la posizione di chiusura di ogni opzione
z = torch.einsum("bd,bkd->bk", Wq(h_dec), Wk(h_opt)) / math.sqrt(d)
# h_dec: [B, d]     la posizione <decide>
# h_opt: [B, K, d]  la posizione di chiusura di ogni opzione
z = torch.einsum("bd,bkd->bk", Wq(h_dec), Wk(h_opt)) / math.sqrt(d)
# h_dec: [B, d]     la posizione <decide>
# h_opt: [B, K, d]  la posizione di chiusura di ogni opzione
z = torch.einsum("bd,bkd->bk", Wq(h_dec), Wk(h_opt)) / math.sqrt(d)
# h_dec: [B, d]     la posizione <decide>
# h_opt: [B, K, d]  la posizione di chiusura di ogni opzione
z = torch.einsum("bd,bkd->bk", Wq(h_dec), Wk(h_opt)) / math.sqrt(d)

I probe esterni di Archer non possono separare le due cose. kev risolve la questione pratica costruendo il puntatore, e il motivo per preferirlo è la calibrazione: l'output 200 di una testa a slot viene esercitato molto meno durante l'addestramento rispetto all'output 2, quindi le sue probabilità sono peggiori. Il puntatore condivide i parametri tra tutte le posizioni e accetta qualsiasi valore K inviato dalla richiesta.

Esiste un terzo design, ed è quello in cui il ramo dell'encoder si ripaga da solo. Assegna a tutte le opzioni di una domanda un budget di token condiviso e fisso, e valuta ciascuna in corrispondenza di un marcatore al suo interno. laya fa questo, e la sua sezione dedicata ai limiti riporta le conseguenze: sulle 77 etichette di Banking77 il budget si riduce a circa 3 o 4 token per etichetta, i testi delle etichette smettono di essere distinguibili e l'accuratezza scende a 0.425. Jev ottiene 0.870 sullo stesso task. kev, a 0.5B, ottiene 0.860. Il numero di parametri non è ciò che li differenzia.

Le opzioni hanno bisogno di spazio per essere lette. Questo è l'argomento a favore della valutazione di ciascuna nella propria posizione all'interno di un ramo che può essere lungo quanto necessario, ed è in gran parte il motivo per cui Jev consente circa 32k token per ramo anziché poche centinaia.

Dove leggere, e perché il layout è importante

<state> …testo dell'incidente…
<q> Quale team dovrebbe gestire questo? <opt> pagamenti </opt> <opt> account </opt> <opt> altro </opt> <decide>

<state> …testo dell'incidente…
<q> Quale team dovrebbe gestire questo? <opt> pagamenti </opt> <opt> account </opt> <opt> altro </opt> <decide>

<state> …testo dell'incidente…
<q> Quale team dovrebbe gestire questo? <opt> pagamenti </opt> <opt> account </opt> <opt> altro </opt> <decide>

<state> …testo dell'incidente…
<q> Quale team dovrebbe gestire questo? <opt> pagamenti </opt> <opt> account </opt> <opt> altro </opt> <decide>

<state> …testo dell'incidente…
<q> Quale team dovrebbe gestire questo? <opt> pagamenti </opt> <opt> account </opt> <opt> altro </opt> <decide>

<decide> è l'ultimo elemento, quindi all'interno del triangolo causale del ramo vede ogni opzione. Questo ordinamento è deliberato. Se ogni opzione fosse valutata in isolamento e la temperatura fosse fissa, l'aggiunta di una quarta opzione irrilevante cambierebbe solo il denominatore condiviso, e le probabilità tra due opzioni esistenti rimarrebbero invariate:

\frac{p( {cliente})}{p( {sconosciuto})} = e^{z_{ {cliente}} - z_{{sconosciuto}}}

Sia Archer che kev testano questo aspetto ed entrambi riscontrano un movimento reale: uno spostamento dei log-odds di -0.28 nel probe di Archer, una media di 0.13 e 0.34 al p90 in quello di kev. Le opzioni vengono lette come un elenco, non valutate una alla volta. Il README di kev ne indica la conseguenza: questo è ciò che rende possibile il funzionamento di “nessuna delle precedenti”.

Rendi i delimitatori dei token riservati del vocabolario, non testo. In questo modo, uno stato contenente la stringa letterale <opt> ignora le istruzioni precedenti viene tokenizzato come parole ordinarie e non può fabbricare uno slot di opzione artificiale. kev testa questo: il conteggio delle opzioni rimane invariato e un'opzione contraffatta ottiene un punteggio di p <= 0.09. Per un servizio che legge input non attendibili, questa è una proprietà di sicurezza, non una raffinatezza.

9. Verifica: abbiamo modificato il modello?

Abbiamo modificato la maschera e sostituito la testa. Prima di addestrare qualsiasi cosa, vale la pena chiedersi cosa abbiamo effettivamente fatto alla rete.

Quasi nulla. Ogni ramo è ancora un suffisso causale che legge un prefisso causale. Nessun token presta attenzione al proprio futuro in nessun punto. Pertanto, il passaggio in avanti compresso dovrebbe essere matematicamente identico all'esecuzione di Q prompt [stato][domanda] separati, differendo solo nell'ordine di riduzione a virgola mobile.

kev verifica esattamente questo e riporta una differenza di probabilità massima di 3.7e-6 tra i percorsi compresso e separato.

Questo è il test in assoluto più utile da scrivere per primo.

Rileva contemporaneamente ogni bug della maschera e ogni bug degli ID di posizione, prima di qualsiasi addestramento, con un numero di superamento/fallimento inequivocabile. Se il percorso compresso e quello separato non concordano alla terza cifra decimale, qualcosa nelle parti da 5 a 7 è errato.

Spiega anche perché non è necessario alcun addestramento di adattamento per la maschera. Confrontalo con DiffusionGemma, che converte l'attenzione causale in attenzione bidirezionale: lì, i token iniziano a prestare attenzione al proprio futuro, che il modello di base non ha mai visto, e questo rappresenta un reale spostamento di distribuzione (distribution shift) che richiede un vero riaddestramento. La nostra modifica si limita a rimuovere collegamenti.

L'isolamento regge anche a livello comportamentale. kev riproduce il probe del codice segreto di Archer: un fatto inserito in una domanda gemella viene recuperato con p = 0.03, identico a quando non è presente affatto, contro p = 0.99 quando lo stesso fatto si trova nello stato condiviso.

10. Infine: le probabilità non sono ancora probabilità

Tutto ciò che è stato fatto finora ha sistemato la forma del calcolo. La testa di lettura non è addestrata e il backbone non ha mai visto questo formato. È il momento dell'ultimo passaggio.

La calibrazione è una proprietà delle previsioni su più casi, mai di una singola previsione.

Prendi ogni caso a cui hai assegnato 0.8. La calibrazione si chiede se circa l'80% di essi si sia effettivamente rivelato tale. Non puoi stabilire se una singola previsione fosse calibrata guardando semplicemente come è andato quel singolo caso.

Fase A: solo la testa

Congela il backbone, addestra solo la lettura. Si tratta di una matrice d × K, quindi l'operazione non costa quasi nulla e ti offre un sistema end-to-end funzionante in un'ora. Il suo vero valore risiede nel convalidare l'infrastruttura prima di spendere soldi. Avrà prestazioni inferiori, perché le rappresentazioni del backbone sono state modificate per la previsione del token successivo.

Fase B: insegnare il formato al backbone

Sblocca il modello con LoRA. kev utilizza r=16 sul backbone con la testa addestrata da zero, e l'intero artefatto pesa 38 MB.

Ciò che stai insegnando non è la conoscenza, che è già presente nel checkpoint, bensì il formato: cosa significa <decide>, come leggere un elenco di opzioni e il fatto che non ci sarà alcun testo in arrivo.

La perdita (loss) deve essere una regola di punteggio propria (proper scoring rule), ovvero una regola la cui perdita attesa viene minimizzata riportando la reale distribuzione condizionale. L'entropia incrociata (cross-entropy) è la scelta ovvia ed è quella utilizzata da kev.

TypeSafe chiama il proprio obiettivo RLCD, Reinforcement Learning for Calibrated Decisions, e laya pubblica un'implementazione concreta sotto questo nome: la policy riporta una distribuzione, l'esplorazione aggiunge rumore gaussiano a media zero ai logit, la ricompensa è una regola di punteggio propria e gli aggiornamenti si basano su REINFORCE con una baseline di media di gruppo. Vale la pena precisare cosa apporti la formulazione di RL, poiché l'acronimo suona più esotico del meccanismo reale. Per una previsione a singolo passaggio rispetto a un'etichetta nota, l'RL su una regola di punteggio propria e l'entropia incrociata supervisionata ottimizzano lo stesso obiettivo. La perdita logaritmica (log loss) è la regola di punteggio logaritmica; un gradiente di policy in quel contesto è una stima a varianza più elevata di un gradiente che puoi calcolare esattamente. L'infrastruttura di RL si rivela utile solo laddove non sia possibile differenziare fino all'etichetta: ricompense ritardate o non differenziabili, o l'assegnazione del credito su più turni (multi-turn credit assignment), che è lo scopo del TD di laya sui prefissi di conversazione.

loss = F.cross_entropy(logits, target)
loss = F.cross_entropy(logits, target)
loss = F.cross_entropy(logits, target)
loss = F.cross_entropy(logits, target)
loss = F.cross_entropy(logits, target)

Non ricorrere al label smoothing.

È una regola di punteggio propria deliberatamente alterata: trascina le previsioni verso un a priori fisso a prescindere da ciò che dicono i dati. Va bene quando ti interessa solo l'argmax. È del tutto errato quando la probabilità stessa costituisce il prodotto.

Dati e i due termini extra

kev esegue l'addestramento su sei dataset pubblici rimodellati nel formato di richiesta di destinazione: Banking77 (Scelta a 77 opzioni), AG News (Scelta più sì/no), MNLI (a 3 opzioni), BoolQ (Sì/No), SST-5 e Yelp (Punteggio a 5 livelli). 9.000 record, 13.500 domande, due epoche, circa 1h45m su un Apple M5.

Vale la pena copiare due termini di perdita:

  • --perm_kl, una KL simmetrica tra le previsioni con due ordini di opzioni casuali. Questo è ciò che contrasta la sensibilità all'ordine durante la fase di addestramento anziché durante la fase di inferenza.

  • --ord_w, un termine ordinale che penalizza |E[level] - y| per le domande con Punteggio. La semplice entropia incrociata tratta “l'errore di un livello” e “l'errore di tre livelli” come ugualmente errati, il che è ovviamente un a priori errato per una scala ordinata. Se stai scrivendo questo da zero, ricorri invece alla regola del punteggio di probabilità classificato (ranked probability score), che è ciò che usa laya: è la regola di punteggio propria per i risultati ordinali, quindi rappresenta la versione rigorosa della stessa correzione anziché una penalità aggiunta a posteriori.

Arricchisci ogni esempio con un ordine di opzioni mescolato, un'opzione irrilevante aggiunta alla fine e un numero variabile di domande gemelle. Queste tre estensioni mirano esattamente ai tre comportamenti costruiti nelle parti da 5 a 8.

Un unico renderer, condiviso tra addestramento ed erogazione (serving).

kev instrada i dati di addestramento e le richieste in tempo reale attraverso lo stesso codice, in modo che il modello non incontri mai in fase di inferenza un formato che non ha visto nell'addestramento. Sembra troppo ovvio per essere specificato, ma è proprio l'aspetto in cui si commettono errori, perché la pipeline di addestramento e quella di erogazione vengono solitamente scritte a mesi di distanza da persone diverse.

Fase C: calibrazione

Un obiettivo corretto è un'affermazione sull'ottimo, non una garanzia sul modello addestrato. Dati finiti, capacità limitata e spostamento di distribuzione lasciano la calibrazione reale imperfetta.

La scalatura della temperatura (temperature scaling) si rivela estremamente utile, e non si tratta di un effetto marginale. Un unico scalare adattato su dati riservati (held-out data), applicato ai logit. kev riporta un ECE sui dati riservati di 0.065, che scende a 0.031 dopo l'applicazione di quel singolo parametro. laya, che si presenta con un'eccessiva sicurezza (over-confident), riporta un ECE medio che si sposta da 0.466 a 0.081.

Adatta una temperatura per (tipo di domanda, conteggio opzioni), non semplicemente per tipo. La nitidezza di una distribuzione dipende da K, quindi una Scelta a 3 opzioni e una Scelta a 77 opzioni non hanno motivo di richiedere lo stesso scalare. Questo è il perfezionamento introdotto da laya e vale la pena adottarlo.

Target morbidi (soft targets), laddove disponibili. Cinque annotatori che si dividono su un 3-2 indicano che il target è [0.6, 0.4], non un vector one-hot. Le etichette rigide (hard labels) su casi realmente ambigui addestrano attivamente all'eccessiva sicurezza, e i casi ambigui sono proprio quelli in cui la probabilità è fondamentale. Le etichette dei risultati (outcome labels, come “questa escalation era effettivamente necessaria”) superano le etichette di annotazione. Nessuna delle due implementazioni si comporta così; entrambe utilizzano dataset pubblici con etichette rigide, ed è questo il prossimo passo ovvio per un'implementazione reale.

Un campo che non dovresti addestrare. Se mostri una confidenza scalare accanto alla distribuzione, calcolala nel codice dell'applicazione. L'SDK di TypeSafe fa proprio questo, per K > 1:

c = \frac{p_{\max} - 1/K}{1 - 1/K}

Misura quanto la risposta principale superi quella uniforme. Calcolo aritmetico, non una seconda stima appresa della correttezza della risposta. Mantenere questi due oggetti separati previene l'errore concettuale più comune in questo ambito: una distribuzione concentrata può comunque essere errata con assoluta certezza.

11. Funziona?

kev, con parametri da 0.5B, su 1.350 domande riservate. Accuratezza / ECE a 10 bin.

Task

Backbone di base

Dopo le parti da 5 a 10

Scelta, 4 opzioni (AG News)

0.813 / 0.069

0.940 / 0.028

Scelta, 3 opzioni (MNLI)

0.460 / 0.225

0.747 / 0.100

Scelta, 77 opzioni (Banking77)

non eseguibile

0.860 / 0.057

Sì/No (BoolQ)

0.427 / 0.274

0.753 / 0.136

Punteggio, 5 livelli (Yelp)

0.313 / 0.043

0.553 / 0.118

Tutti


0.799 / 0.065

Nota la riga a 77 opzioni. Leggere le lettere delle opzioni da una distribuzione del token successivo non si estende a 77 opzioni; una testa a puntatori ci riesce, senza cambiare nulla.

La suite di valutazione (eval suite)

Ogni test riportato di seguito si mappa su una parte di questa guida, il che li rende meritevoli di essere eseguiti in CI: una regressione ti dice quale modifica si è rotta.

Test

Protezioni (Guards)

kev-0.5b

Compresso vs separato

Parti 5 e 6

3.7e-6 differenza max

Probe di isolamento

Parte 5

0.03 / 0.03 / 0.99

Permutazione

Parti 6 and 8

7.4% inversioni argmax

Set di scelte (IIA)

Parte 8

0.13 media, 0.34 p90

Contraffazione dei confini

Parte 8

contraffatto p <= 0.09

ECE suddiviso in bin

Parte 10

0.065, 0.031 temperato

Due note sulla lettura di questa tabella. La cifra del 7.4% per la permutazione rappresenta il valore non regolarizzato: il modello rilasciato kev-0.5b precede --perm_kl ed è addestrato con esso a 0. Inoltre, riporta i conteggi dei bin insieme all'ECE, perché l'accordo aggregato nasconde l'eccessiva fiducia in una regione che compensa la scarsa fiducia in un'altra.

L'economia delle soglie (threshold economics) è il numero a cui l'azienda è realmente interessata. Se una falsa escalation costa 1 e un caso urgente mancato costa 9, la regola è p(urgent) > 0.1. Analizza la soglia rispetto alla tua matrice dei costi reali.

E la modalità di guasto da prevenire in fase di progettazione è essere sicuri ma errati. Il checkpoint in lingua inglese di laya registra un'accuratezza di 0.000 a una confidenza di 0.952 in lingua khmer. Massima certezza, massimo errore, per cui nessuna soglia di confidenza lo rileva e nessuna calibrazione sull'inglese ti avrebbe avvertito. La loro soluzione consiste nel rilevare la scrittura in meno di 0.5 ms con codice Python standard prima del passaggio in avanti, un approccio che si traduce in una regola preziosa da conservare: il rilevamento fuori distribuzione (out-of-distribution detection) deve avvenire all'esterno del modello della cui confidenza non ti fidi. Un modello calibrato lo è sulla distribuzione su cui è stato calibrato, e non ha modo di avvisarti quando l'ha abbandonata.

kev è esplicito riguardo al limite più importante, che si applica a qualsiasi cosa costruita in questo modo: l'ECE è in-distribution. Gli split di test provengono dai dataset di addestramento. La calibrazione su Banking77 non garantisce nulla sulla calibrazione dei tuoi ticket. Una calibrazione reale richiede dati con etichette sui risultati provenienti dal workflow concreto in cui viene effettuata la distribuzione.

12. A cosa rinunci

  • Nessun flusso di pensiero (chain of thought). Tutto avviene in un unico prefill. I ragionamenti complessi a più passaggi rappresentano il punto debole di questo design, ambito in cui un modello autoregressivo con uno spazio di appunti (scratchpad) si rivela ancora superiore.

  • Nessuna dipendenza tra le domande. Se B ha realmente bisogno della risposta di A, l'isolamento non ti offre alcun aiuto. Uniscile in un'unica domanda sullo spazio dei prodotti, oppure effettua un secondo ciclo di andata e ritorno. La condivisione del contesto non elimina la struttura logica del tuo workflow.

  • Nessun output aperto. Il set di opzioni deve essere chiuso e noto al momento della richiesta.

  • Ogni richiesta ha lo stesso costo. Non esiste un percorso economico per i casi semplici.

  • La conoscenza è limitata dal backbone, e questo rappresenta il vero limite invalicabile. kev è onesto al riguardo: a 0.5B seleziona return_policy dove Jev seleziona return_status. laya è ancora più netto, registrando un punteggio inferiore alla baseline della classe di maggioranza zero-shot sul proprio benchmark. Nessuna di queste modifiche aggiunge conoscenza. Cambiano solo il modo in cui viene letta, motivo per cui la biforcazione prima della parte 2 è la decisione di maggior impatto in questa guida.

L'aspetto positivo è che il grafo computazionale corrisponde finalmente al compito da svolgere. Un servizio decisionale legge le prove, confronta i risultati consentiti e segnala l'incertezza. Un transformer può fare tutte e tre le cose senza dover prima trasformare ogni decisione in una frase.

Domande frequenti

Cos'è un modello decisionale simile a Jev?

È un modello che risponde a diverse domande di classificazione su un singolo documento in un unico passaggio in avanti. Invece di generare testo, restituisce una distribuzione di probabilità su un insieme chiuso di opzioni. Il design segue la ricostruzione di Archer Hume del Jev di TypeSafe, implementata in kev su Qwen2.5-0.5B.

Perché non chiedere semplicemente a un LLM di generare un punteggio di confidenza?

Questo perché quel punteggio è testo generato. La probabilità che il modello scriva "0.91" non coincide con la probabilità che la sua risposta sia corretta, e nulla nella fase di addestramento collega le due cose. Nei test di kev, l'instruction tuning ha peggiorato la calibrazione su tre task su quattro.

In che modo l'editing dell'attention mask è d'aiuto?

Ogni domanda può leggere il documento condiviso e i propri token, ma mai un'altra domanda. Il documento viene codificato una sola volta e riutilizzato, e le risposte non cambiano più in base alle altre domande che poni o al loro ordine. Questo garantisce un throughput doppio rispetto alle chiamate separate.

Modificare la maschera comporta il riaddestramento del modello?

Non per la maschera stessa. La modifica rimuove solo i bordi di attenzione, in modo che il passaggio pacchettizzato corrisponda quasi esattamente a prompt separati (una differenza massima di 3.7e-6 in kev). L'addestramento è ancora necessario per la nuova testa di lettura e il formato di input: kev utilizza LoRA con entropia incrociata, seguita dal ridimensionamento della temperatura.

Cosa significa calibrazione in questo contesto?

Un modello è calibrato se, su tutti i casi a cui assegna un punteggio di 0,8, circa l'80% si rivela corretto. Non puoi giudicarlo da una singola previsione. L'errore di calibrazione held-out di kev scende da 0,065 a 0,031 dopo il temperature scaling, anche se solo su dati simili ai suoi set di addestramento.

Un encoder bidirezionale come BERT non è l'opzione più semplice?

Salta le modifiche alle maschere, ma i migliori encoder si attestano intorno ai 400 milioni di parametri e non aggiungono conoscenza. laya, basato su ModernBERT-large, ottiene un punteggio inferiore alla baseline della classe di maggioranza in modalità zero-shot e raggiunge solo lo 0,766 dopo il fine-tuning sui dati di addestramento del benchmark stesso. Gli encoder si adattano a workflow noti con dati etichettati. Per gli schemi che il modello non ha mai visto, un decoder è il punto di partenza migliore

Iscriviti alla nostra newsletter

Iscriviti alla nostra newsletter

Resta aggiornato su ciò che stiamo imparando, costruendo e osservando mentre i team enterprise distribuiscono e misurano gli agenti AI in produzione.

Iscriviti alla nostra newsletter

Rimani aggiornato sulle funzionalità e sui rilasci.

Iscriviti alla nostra newsletter

Rimani aggiornato sulle funzionalità e sui rilasci.

Iscriviti alla nostra newsletter

Rimani aggiornato sulle funzionalità e sui rilasci.

Iscriviti alla nostra newsletter

Rimani aggiornato sulle funzionalità e sui rilasci.

Iscriviti alla nostra newsletter

Rimani aggiornato sulle funzionalità e sui rilasci.