Perché temperature=0 non fa ciò che quasi tutti si aspettano, e come ottenere comunque risultati affidabili da un LLM.
Durante uno dei nostri test, un modello di ragionamento si è bloccato. Prompt e parametri erano sempre gli stessi, e temperature era impostata a zero per avere più "consistenza". Il modello ha risolto correttamente l'enigma, ma poi non si è fermato: ha continuato ad accumulare sinonimi di "boat crossing" fino a esaurire il limite di token, e lo ha fatto a ogni esecuzione.
Con temperature a 1.0 e i valori di top_p e top_k consigliati da chi pubblica il modello, su 36 esecuzioni non si è verificato nessun loop.
Se questi nomi non ti dicono ancora nulla, ecco il principio in poche frasi. Un modello linguistico non va a cercare una risposta già pronta. La costruisce parola per parola, e a ogni passo ha davanti una classifica dei possibili seguiti. Temperature, top_p e top_k decidono come sceglie da quella classifica: se prende sempre il primo o se ogni tanto pesca un po' più in basso. Non fanno altro. Esistono in ogni API per LLM, i loro nomi non dicono nulla di ciò che fanno, i valori predefiniti cambiano da un provider all'altro, e di solito li si imposta copiando un valore da un articolo di blog, senza più toccarli.
Il risultato iniziale contraddice l'idea che quasi tutti si fanno di queste manopole. Forse qualcuna di queste situazioni ti suona familiare:
- Hai impostato
temperature: 0per rendere l'output riproducibile. - Hai omesso del tutto
temperaturedalla richiesta, dando per scontato che la piattaforma scelga un valore sensato. - La tua suite di valutazione passa il lunedì e fallisce il giovedì, senza che tu abbia cambiato nulla.
- Un modello ha cominciato a ripetersi, e così hai usato
frequency_penalty. - Hai letto tre spiegazioni di top_p e top_k e ancora non sapresti dire quando usare l'uno o l'altro.
Nessuno di questi istinti è irragionevole. La maggior parte però non fa quello che ci si aspetta, e il primo è il modo più sicuro per far fallire un modello di ragionamento.
Proprio temperature=0, l'impostazione a cui gli sviluppatori ricorrono quando vogliono consistenza, è quella che più facilmente crea problemi. E anche la consistenza che dovrebbe garantire funziona diversamente da come pensa la maggior parte delle persone: se invii due volte la stessa richiesta a un endpoint molto carico, puoi ricevere due risposte diverse. La causa non sta nella richiesta né nei parametri, e nessun seed può rimediare.
Ogni misurazione che indichiamo come nostra è un'esecuzione reale sulla nostra API, e puoi ripeterle tutte. Quando un numero viene dal lavoro di altri, lo diciamo.
In breve
I modelli linguistici danno risposte diverse alla stessa domanda per due motivi: nella scelta della parola successiva c'è una componente casuale voluta, e un server molto carico cambia senza avvisare l'aritmetica sottostante. Nessuno dei due è un bug, ed entrambi si possono gestire una volta capito quale dei due si ha davanti.
Se sviluppi con un'API per LLM, di questo articolo ti servono soprattutto cinque punti:
- Imposta
temperaturein ogni richiesta. Omettere il parametro non è una scelta neutra: sulla maggior parte dei modelli qui significa greedy decoding, l'impostazione più rischiosa che ci sia. - Usa il valore consigliato da chi pubblica il modello. Di solito è 1.0, non 0. Più avanti trovi una tabella con i valori per ogni modello.
- Con
temperature: 0rischi i loop di ripetizione. Un modello di ragionamento può restare bloccato a riformulare lo stesso concetto finché non esaurisce i token. Aumentaremax_tokenspeggiora le cose invece di migliorarle. frequency_penaltyepresence_penaltynon hanno alcun effetto su questa API. Il parametro che funziona èrepetition_penalty. Passalo inextra_bodye resta a 1.2 o meno.- Non scrivere mai un test che verifica l'output esatto. Regge oggi e si rompe al prossimo aggiornamento del modello, e su un endpoint molto carico potrebbe non reggere nemmeno oggi. Verifica invece delle proprietà: JSON valido, lo schema giusto, un numero entro un intervallo.
Come un modello sceglie il token successivo
Quando un LLM genera testo, non recupera risposte già scritte. Per ogni token che produce, esegue tre passi:
- Calcolare i logit: un punteggio grezzo per ogni token del vocabolario
- Applicare la softmax: trasformare i punteggi in probabilità
- Sampling: scegliere un token in base a quelle probabilità
Temperature, top_p e top_k agiscono ciascuno su un passo diverso.
Passo 1: il modello assegna un punteggio a ogni token che conosce
L'ultimo strato del modello restituisce un numero per ogni token del vocabolario, di solito più di 100.000 valori. Questi punteggi si chiamano logit.
Un logit non è una probabilità. È un punteggio senza limiti, che può essere positivo o negativo, per esempio -15,3 o +8,7. Il termine viene dalla statistica, dove abbrevia "logistic unit" e indica il logaritmo delle quote, i log-odds. Nelle reti neurali indica l'output grezzo prima della funzione di attivazione finale.
Ecco come potrebbero apparire i logit in un singolo passo di generazione:
| Token | Logit |
|---|---|
| "the" | +5,2 |
| "a" | +3,1 |
| "Paris" | +2,8 |
| "quantum" | -4,3 |
Un logit di +5,2 non significa una probabilità del 520%. Significa solo che quel token è relativamente più probabile dei token con punteggi più bassi. I punteggi diventano probabilità solo nel passo successivo.
Passo 2: dai punteggi alle probabilità
I punteggi grezzi, così come sono, servono a poco. Alcuni sono negativi, la loro somma non ha un valore preciso, e "+5,2" non dice quanto "the" sia più probabile di "a". Al modello serve una vera distribuzione di probabilità, in cui ogni valore è positivo e la somma è esattamente 1.
Di questo si occupa la funzione softmax, in due passaggi. Prima eleva e alla potenza di ogni punteggio: così tutti i numeri diventano positivi e i punteggi alti si allontanano molto da quelli bassi. Poi divide ogni risultato per il totale, in modo che l'insieme sommi a 1.
Per i nostri quattro candidati il risultato è questo:
| Token | Logit | Probabilità |
|---|---|---|
| "the" | +5,2 | 82,4% |
| "a" | +3,1 | 10,1% |
| "Paris" | +2,8 | 7,5% |
| "quantum" | -4,3 | 0,006% |
L'esponenziale allarga le distanze più di quanto si immagini. Sui punteggi grezzi "the" supera "a" di appena 2,1, che non sembra un vantaggio decisivo. Dopo l'esponenziale il rapporto è di otto a uno. Piccole differenze di punteggio diventano grandi differenze di probabilità, ed è proprio su questa leva che agisce temperature.
Resta l'ultima riga. "quantum" ottiene -4,3, come continuazione non ha alcun senso, eppure riceve comunque una probabilità maggiore di zero. Nessun token è mai escluso del tutto: ogni token del vocabolario conserva una minima possibilità. Per questo un modello a volte dice qualcosa di strano, e per questo esiste un parametro apposta per tagliare questa coda.
Passo 3: il modello sceglie un token
A questo punto ci sono le probabilità. Il sampling consiste nell'estrarre un token a caso, pesato in base a quelle probabilità.
Si può immaginare un segmento da 0 a 1, su cui ogni token occupa un tratto largo quanto la sua probabilità:
Si estrae un numero casuale tra 0 e 1, e vince il token nel cui tratto cade. 0,372 cade in "the", 0,891 cade in "a". Su molte estrazioni "the" vince in circa l'82% dei casi, ma anche i token meno probabili di tanto in tanto hanno il loro turno.
È qui che entra in gioco il caso: a parità di probabilità, un numero estratto diverso porta a un token diverso.
Dove interviene temperature
Temperature agisce proprio dentro questo passo esponenziale. Prima che i punteggi vengano elevati a potenza, ciascuno viene diviso per il valore di temperature. Tutto il meccanismo sta in questa sola divisione.
Con un valore inferiore a 1, i punteggi diventano più grandi e si allontanano tra loro: il favorito prende il largo e la distribuzione diventa più netta. Con un valore superiore a 1 si avvicinano, e anche gli outsider hanno una possibilità concreta. Con esattamente 1 non cambia nulla, ed è per questo che 1.0 è il valore di partenza e non il massimo.
| Temperature | Effetto |
|---|---|
| T = 1.0 | Probabilità come da addestramento: il valore di partenza |
| T < 1.0 | Più netta: i token ad alta probabilità dominano di più |
| T > 1.0 | Più piatta: i token a bassa probabilità guadagnano peso |
| T → 0 | Picco: vince sempre il token più probabile |
Temperature 1.0 quindi non significa "massima casualità". È il valore di partenza, quello in cui la distribuzione resta come il modello l'ha appresa durante l'addestramento. È come il contrasto di una foto: T=1 è l'originale, ogni altro valore è una modifica.
Con T=0 la distribuzione si riduce a un unico picco. Un token ottiene ~100%, tutti gli altri ~0%. Questo si chiama greedy decoding: il modello sceglie sempre il token più probabile, senza alcuna casualità.
Dove intervengono top_p e top_k
Entrambi i parametri filtrano i candidati dopo la softmax e prima del sampling:
Top-k tiene solo i k token con la probabilità più alta:
| Token | Prima di top_k=2 | Dopo top_k=2 |
|---|---|---|
| "the" | 82,4% | 89,1% (rinormalizzato) |
| "a" | 10,1% | 10,9% (rinormalizzato) |
| "Paris" | 7,5% | rimosso |
| "quantum" | 0,006% | rimosso |
Top-p (nucleus sampling) ordina i token per probabilità e tiene il gruppo più piccolo che, sommato, raggiunge p:
| Token | Prima di top_p=0.9 | Dopo top_p=0.9 |
|---|---|---|
| "the" | 82,4% (mantenuto) | 89,1% |
| "a" | 10,1% (mantenuto) | 10,9% |
| "Paris" | 7,5% (scartato) | rimosso |
| "quantum" | 0,006% (scartato) | rimosso |
Da solo "the" arriva all'82,4%, sotto il 90%. Con "a" si sale al 92,5%, la soglia è superata e il gruppo è completo. Tutto il resto viene scartato, e i token rimasti vengono rinormalizzati.
Top-p si adatta a quanto il modello è sicuro: se un token ha il 95% di probabilità e p=0.9, resta solo quel token. Se le probabilità sono distribuite, restano più candidati.
Domanda ovvia: T=0 non equivale a top_k=1?
Nel risultato sì, perché in entrambi i casi viene sempre scelto il token più probabile. In teoria però agiscono in punti diversi: T=0 trasforma la distribuzione in un picco, top_k=1 lascia un solo candidato dopo che le probabilità sono state calcolate.
In pratica, dividere un logit per zero non è definito, quindi ogni implementazione gestisce questo caso a parte. Nell'API di Infercom i due sono un unico meccanismo, con una conseguenza:
top_k ha effetto solo se temperature vale almeno 0.001. Sotto questa soglia, anche quando ometti del tutto temperature, il tuo valore di top_k viene scartato, top_k viene forzato a 1 e la decodifica è greedy. Nella risposta nulla indica che il tuo valore è stato ignorato.
Quindi {"top_k": 100} da solo non allarga nulla: restituisce l'argmax.
È un errore facile da commettere: una richiesta dall'aspetto ragionevole, accettata per intero, in cui uno dei due parametri non ha alcun effetto e l'altro è fermo sul valore più rischioso della scala.
Ci sono altri due aspetti di top_k su questa API. Non fa parte dello standard OpenAI, quindi gli SDK di OpenAI lo rifiutano come argomento di primo livello: passalo tramite extra_body. Inoltre il valore deve essere almeno 1, perché a differenza di altri provider -1 e 0 non vengono accettati per disattivarlo. Se non ti serve, omettilo.
Approfondimenti nel nostro glossario
Un esempio concreto
Ogni blocco che segue è un'esecuzione reale su gpt-oss-120b tramite l'API di Infercom, misurata il 18 settembre 2026. Il prompt era: "Write a creative one-word name for a cat. Reply with the name only." Ogni variante è stata eseguita cinque volte, perché tre esecuzioni non bastano a distinguere un output fissato da un caso fortunato.
Temperature: consistenza o varietà
| Esecuzione | temperature=0 | temperature=1.0 |
|---|---|---|
| 1 | Quasar | Nebulyn |
| 2 | Quasar | Nebula |
| 3 | Quasar | Mistral |
| 4 | Quasar | Mistral |
| 5 | Quasar | Nimbus |
Con T=0 il modello restituisce ogni volta lo stesso nome, con T=1.0 varia.
La trappola: temperature assente
Ora lo stesso prompt senza alcun campo temperature, una volta anche con top_k: 100. A prima vista, questa richiesta chiede un ampio pool di candidati:
| Esecuzione | Senza temperature | top_k=100, senza temperature |
|---|---|---|
| 1 | Quasar | Quasar |
| 2 | Quasar | Quasar |
| 3 | Quasar | Quasar |
| 4 | Quasar | Quasar |
| 5 | Quasar | Quasar |
Entrambe le varianti procedono in modo greedy e restituiscono il risultato di T=0. L'API di Infercom non applica alcun valore predefinito di temperature a livello di servizio. Omettere il parametro quindi non significa "usa il valore predefinito del modello": sulla maggior parte dei modelli equivale a temperature 0, e rende silenziosamente inutile il tuo top_k.
Per completezza: anche top_k=1 con temperature=1.0 restituisce Quasar cinque volte su cinque. Filtrare fino a un solo candidato porta al greedy decoding per un'altra strada.
Solo impostando insieme temperature e top_k il pool si apre:
| Esecuzione | top_k=100, temperature=1.0 |
|---|---|
| 1 | Nebula |
| 2 | Nimbus |
| 3 | Purrcello |
| 4 | Mistral |
| 5 | Luminara |
Seed: fissa l'output, all'interno di un deployment
Stesso prompt, con temperature=1.0:
| Esecuzione | seed=42 | seed=7 | Senza seed |
|---|---|---|---|
| 1 | Nebulyn | Zephyrus | Nebulite |
| 2 | Nebulyn | Zephyrus | Nebula |
| 3 | Nebulyn | Zephyrus | Velvetine |
| 4 | Nebulyn | Zephyrus | Nebulous |
| 5 | Nebulyn | Zephyrus | Nimbus |
Con un seed l'output resta uguale, un seed diverso dà un output diverso e senza seed l'output varia. Ecco come si comporta un seed che funziona, ed è lo strumento giusto quando ti serve un'esecuzione di valutazione ripetibile. Ha però tre limiti, a cui è dedicata una sezione più avanti.
Vale la pena guardare di nuovo cosa è successo. Tre dei sei blocchi hanno restituito Quasar cinque volte su cinque, e in due di questi, a prima vista, avevamo chiesto varietà. Nel greedy decoding si cade molto più facilmente di quanto se ne esca.
E questo ci riporta al modello che non riusciva a smettere di parlare di barche.
Il vero rischio di T=0: i loop di ripetizione
Il greedy decoding ha un modo di fallire tutto suo, ed è peggiore di una risposta incoerente.
Gli LLM sono autoregressivi: ogni token dipende da tutti i token precedenti, compreso l'output del modello stesso. Se il modello entra in uno schema che si autoalimenta, può restarci bloccato.
Con temperature sopra 0, il sampling offre una via d'uscita, perché il modello può scegliere un token un po' meno probabile che porta altrove. Con temperature a 0 questa via d'uscita non c'è: il modello segue ogni volta lo stesso percorso, e se questo porta in un loop, ci resta per sempre.
Lo abbiamo osservato direttamente con i modelli di ragionamento. Con temperature=0, un modello è finito in un ciclo infinito di sinonimi:
... boat crossings. / moves. / steps. / transitions. / state changes.
... boat trips. / crossings. / voyages. / journeys.Ha risolto il problema correttamente, ma poi non ha mai emesso un token di stop e ha continuato ad accumulare quasi-sinonimi fino al limite di token. Con temperature 0 è andato in loop in tutte e tre le esecuzioni. Con le impostazioni consigliate da chi pubblica il modello (temperature=1.0, top_p=0.95, top_k=40), 36 esecuzioni si sono concluse normalmente.
Il loop è un punto fisso nella distribuzione di probabilità del modello: a ogni passo, il token successivo più probabile riporta dentro il ciclo. Il greedy decoding segue quel percorso all'infinito. Con il sampling il modello sceglie ogni tanto un token un po' meno probabile, e basta questo per uscirne.
Chi pubblica modelli di ragionamento consiglia di tenere temperature sopra 0, e questo è uno dei motivi principali. Non si tratta di creatività, ma di non restare bloccati.
Aumentare max_tokens non risolve il problema. Un loop con più spazio resta un loop, e ogni suo token viene fatturato.
Non è una curiosità da laboratorio. Lo abbiamo incontrato per la prima volta durante un test. Da allora abbiamo visto tre clienti finirci dentro in produzione, e tutti ci sono arrivati allo stesso modo: avevano abbassato temperature di proposito, per una ragione sensata. Uno voleva numeri di benchmark ripetibili, un altro un voice agent dal comportamento prevedibile. Proprio l'istinto che spinge ad abbassare temperature è quello che questo tipo di errore punisce.
Il parametro contro le ripetizioni che funziona, e i due che non funzionano
Supponiamo che temperature sia impostata in modo sensato e che qualche ripetizione resti comunque. Chi arriva dall'API di OpenAI ricorre d'istinto a frequency_penalty o presence_penalty.
Sull'API di Infercom nessuno dei due ha effetto. Vengono accettati per compatibilità e non vengono applicati su nessun modello. Ricevi un HTTP 200, una risposta dall'aspetto normale e nessun segnale che il valore sia stato ignorato. Ecco lo stesso prompt con temperature 0, misurato il 18 settembre 2026:
| Richiesta | Output | Token di completamento |
|---|---|---|
| riferimento | "Red / Blue / Green" | 83 |
| frequency_penalty = 2.0 | "Red / Blue / Green" | 83 |
| frequency_penalty = 99 | "Red / Blue / Green" | 83 |
Gli output sono identici byte per byte, fino al numero di token, con un valore ben oltre l'intervallo documentato.
È il tipo di sorpresa più costoso, perché un parametro che il provider ignora sembra identico a un parametro che non aiuta. Entrambe le letture sono coerenti con ciò che vedi sullo schermo. Una ti manda alla documentazione, l'altra ti spinge a regolarlo ancora di più. Un nostro cliente ha passato una settimana sulla seconda strada.
La regola generale non vale solo per noi: compatibile con OpenAI non significa equivalente a OpenAI. Ogni provider implementa solo una parte di quell'API, questa parte cambia da provider a provider e da una release all'altra, e il corpo della risposta non dice mai quali parti hai ottenuto. Prima di regolare qualsiasi parametro di sampling, su qualsiasi piattaforma, verifica che la piattaforma lo legga.
Se le tue richieste contengono uno dei due parametri, rimuovilo o impostalo a 0. Oggi non ha alcun effetto, ma una prossima release della piattaforma rifiuterà con un errore qualsiasi altro valore. Le richieste senza questi parametri, o con valore 0, non sono interessate. Meglio sistemarlo adesso che scoprirlo durante un deploy.
Il parametro che qui funziona è repetition_penalty:
| Richiesta | Output | Token di completamento |
|---|---|---|
| repetition_penalty = 1.2 | "Red / Blue / Green" | 90 |
La generazione cambia, la risposta resta la stessa. Per usarlo valgono cinque regole:
- Passalo tramite
extra_body. Non fa parte dello standard OpenAI, quindi gli SDK lo rifiutano come argomento di primo livello. - L'intervallo accettato va da 1 a 2, e 1.0 significa nessuna penalità. Qualsiasi valore fuori intervallo restituisce un
400, ed è proprio il comportamento che vuoi: a differenza delle due penalità viste sopra, questo parametro ti avvisa quando qualcosa non va. - Resta a 1.2 o meno. Valori più alti fanno pensare più a lungo un modello di ragionamento prima di rispondere. Su
gpt-oss-120b, una risposta di una sola frase ha usato 104 token di completamento con 1.0 e 244 con 1.3, quindi unmax_tokensstretto comincia a troncare. Con 1.8 il modello non restituisce alcun contenuto. - Non sostituisce direttamente
frequency_penalty.repetition_penaltypenalizza anche i token presenti nel tuo prompt, cosa che le penalità di OpenAI non fanno mai. Con un prompt di sistema lungo, per esempio per un voice agent, può sopprimere proprio il vocabolario su cui il prompt si basa. - Funziona solo su
/v1/chat/completions./v1/responsese/v1/messagesaccettano il valore e lo scartano. Tienilo presente se sposti un'integrazione da un endpoint all'altro: il parametro funziona ancora, ma non viene più letto.
E imposta prima temperature. Le ripetizioni causate dal greedy decoding sono un problema di temperature, e vanno risolte lì, non con una penalità.
Perché le richieste degli altri cambiano il tuo output
Hai impostato temperature a un valore sensato e aggiunto un seed. Ora l'output è ripetibile, e il passo successivo sembra ovvio: un test che verifica la stringa esatta.
Meglio non farlo. Quel test può fallire senza che dalla tua parte sia cambiato nulla.
In effetti, nella maggior parte dei casi ripetere una richiesta con gli stessi parametri restituisce gli stessi byte. Ed è proprio questo che rende il problema insidioso: tutto sembra stabile finché all'improvviso non lo è più, e la causa non sta nel tuo codice, né nella tua richiesta, né altrove dalla tua parte.
La spiegazione di sempre, e perché è sbagliata
Se chiedi perché l'inferenza degli LLM non è riproducibile, ottieni una risposta standard: l'addizione in virgola mobile non è associativa, (a + b) + c non sempre è uguale a a + (b + c), le GPU eseguono migliaia di thread che finiscono ogni volta in un ordine diverso, quindi le somme cambiano e i logit oscillano.
La prima metà è vera, la seconda no.
Horace He e Thinking Machines Lab hanno smontato questa spiegazione in Defeating Nondeterminism in LLM Inference (settembre 2025), e il risultato è che i singoli kernel GPU sono già deterministici da un'esecuzione all'altra. Se esegui mille volte la stessa moltiplicazione di matrici sugli stessi dati, ottieni ogni volta risultati identici bit per bit. Il forward pass di un LLM non contiene nemmeno un'addizione atomica, cioè proprio l'operazione su cui si regge la spiegazione comune. Lungo la dimensione del batch c'è abbastanza parallelismo da non dover far competere i thread lungo la dimensione di riduzione.
Quindi il problema non sono i kernel, ma la tua richiesta.
Invarianza rispetto al batch, ovvero: chi altro stava facendo richieste
Un endpoint in produzione non elabora la tua richiesta da sola. La raggruppa in un batch con tutto ciò che è arrivato nella stessa finestra di tempo, e la dimensione del batch varia con il carico.
I kernel che contano qui, cioè RMSNorm, moltiplicazione di matrici e attention, non sono invarianti rispetto al batch. Se cambia la dimensione del batch, la riduzione viene suddivisa in modo diverso, l'arrotondamento cambia, e i numeri che escono per la tua riga cambiano anche se il tuo input è rimasto lo stesso.
Il tuo output dipende da quante altre richieste arrivavano allo stesso endpoint nello stesso momento. Non è un'impostazione che puoi controllare, non sta nella tua richiesta, e nessun seed può fissarlo.
Nella maggior parte dei casi questo non cambia nulla, perché il token in testa vince con ampio margine e una differenza di arrotondamento alla sesta cifra decimale non lo raggiunge. Il problema sono i quasi-pareggi. Immagina due candidati separati da un milionesimo: plus a 3,700001 contro + a 3,699999. In un batch di quattro richieste vince plus. In un batch di 32 l'arrotondamento va dall'altra parte, e vince + con lo stesso margine invisibile.
Con temperature 0 da lì non si torna indietro. Il greedy decoding si impegna su una scelta e ne segue le conseguenze fino alla fine della generazione, quindi un solo token che cambia può riscrivere tutto ciò che viene dopo.
Su Qwen3-235B con temperature 0, mille richieste identiche hanno prodotto ottanta completamenti diversi. Il più frequente è comparso 78 volte. La prima divergenza è arrivata al token 103, dove 992 esecuzioni hanno scritto "Queens, New York" e 8 "New York City", e da lì le risposte si sono separate.
Si può risolvere, ma ha un costo
Thinking Machines ha scritto versioni invarianti rispetto al batch dei tre kernel e le ha pubblicate. Con queste, lo stesso esperimento ha restituito mille completamenti identici su mille. Due settimane dopo SGLang ha introdotto l'inferenza deterministica sullo stesso principio.
Il prezzo si paga in latenza. Nel loro benchmark, il percorso standard di vLLM ha elaborato 1000 sequenze in 26 secondi; quello invariante rispetto al batch ha impiegato 42 secondi con un kernel di attention migliorato, e 55 secondi prima di quell'ottimizzazione. Diciamo un fattore 1,6, su kernel che nessuno ha ancora finito di ottimizzare.
Il compromesso, in una riga: un output identico bit per bit si può avere, e si paga in throughput. Nessuna grande API di inferenza lo attiva di default, perché la maggior parte dei carichi di lavoro preferisce il throughput.
Cosa significa per le tue applicazioni
Il meccanismo appartiene all'inferenza in batch, non a un singolo fornitore o a un tipo di chip. Qualsiasi motore che raggruppa le richieste e riduce lungo una dimensione di batch variabile ne è esposto, e raggruppare le richieste è proprio il modo in cui ogni stack di inferenza in produzione ottiene il suo throughput. Thinking Machines lo ha dimostrato su GPU con vLLM perché era lo stack che aveva a disposizione, non perché sia un problema delle GPU.
C'è un secondo modo di perdere l'identità byte per byte, indipendente dal carico, ed è quello che la maggior parte dei team incontra per primo: due deployment compilati in modo diverso dello stesso modello danno due risposte diverse allo stesso prompt, ciascuna perfettamente stabile. Il nome del modello e i parametri sono gli stessi, cambia solo la build. Un aggiornamento del modello fa proprio questo, di proposito, ogni volta.
Da qui una regola chiara:
Un output identico byte per byte si può osservare, ma non ci si può costruire sopra: non attraverso un picco di carico, non attraverso un deployment, non attraverso una versione del modello.
Quindi non scrivere test che verificano una stringa esatta. Verifica le proprietà che ti servono: JSON valido, lo schema giusto, un numero entro un intervallo, un'affermazione supportata dalla fonte. Test di questo tipo reggono anche a una giornata di picco e a un aggiornamento del modello.
E il seed?
All'interno di un deployment, però, la ripetibilità si può ottenere di proposito e non per caso, e seed è il modo per chiederla.
Il seed controlla il generatore di numeri casuali nel terzo passo, quello che sceglie il token. Con seed=42 il generatore (RNG) produce la stessa sequenza di estrazioni per la stessa richiesta. Le stesse estrazioni applicate alle stesse probabilità danno gli stessi token, ed è per questo che Nebulyn è tornato cinque volte su cinque.
Funziona solo dove c'è sampling. Con temperature 0 non c'è sampling, quindi l'RNG non entra mai in gioco e il seed non ha effetto. L'output identico che ottieni con temperature 0 non è un seed fissato: è l'argmax, e l'argmax si sposta quando si spostano i numeri sottostanti.
Non tutti i modelli lo rispettano. Alcuni modelli del catalogo accettano un seed ma lo ignorano, e nulla nella risposta ti dice quali. La pagina sulla compatibilità con OpenAI indica dove viene rispettato. Controllala prima di costruire una pipeline di valutazione basata su un seed.
Fissa le estrazioni, non la distribuzione. Un seed blocca i numeri casuali, non le probabilità a cui quei numeri vengono applicati, quindi non può proteggerti da un cambio nella dimensione del batch né da un aggiornamento del modello. Usalo per una sessione di debug o un ciclo di revisione, non come garanzia a lungo termine.
Quale consistenza puoi ottenere
Una stringa su cui i tuoi test possano contare per i prossimi due anni non la otterrai. La consistenza di cui la tua applicazione ha bisogno, invece, sì.
Parti dai parametri consigliati da chi pubblica il modello. Non sono scelti a caso:
| Modello | temperature | top_p | top_k |
|---|---|---|---|
| MiniMax-M2.7 | 1.0 | 0.95 | 40 |
| gpt-oss-120b | 1.0 | 1.0 | - |
| gemma-4-31B-it | 1.0 | 0.95 | 64 |
| DeepSeek-V3.2 | 1.0 | 0.95 | - |
| DeepSeek-V3.1 | - | - | - |
| Meta-Llama-3.3-70B-Instruct | - | - | - |
Un trattino significa che chi pubblica il modello non indica un valore. Nota che tutti usano T=1.0, cioè il valore di partenza, quello della distribuzione appresa in addestramento.
Imposta sempre temperature in modo esplicito. L'API di Infercom non applica alcun valore predefinito a livello di servizio. Se il parametro manca, gpt-oss-120b, gemma-4-31B-it, DeepSeek-V3.2 e Meta-Llama-3.3-70B-Instruct decodificano tutti in modo greedy. MiniMax-M2.7 è l'eccezione e usa il sampling. Un client che non invia mai temperature lavora a T=0 su quattro modelli su cinque senza saperlo, cioè con l'impostazione più rischiosa per il loop descritto sopra.
Lo stesso vale per /v1/responses e /v1/messages. Se porti codice dall'SDK di Anthropic, tieni presente che l'API di Anthropic documenta per temperature un valore predefinito di 1.0, mentre noi non ne applichiamo nessuno. Senza un valore esplicito, il codice portato passa al greedy decoding.
Prova le manopole nel playground. Le trovi tutte e tre su cloud.infercom.ai/playground (serve un account Infercom), sotto Tuning Parameters in alto a destra. Lì noterai due cose. L'interruttore Sampling è spento quando apri la pagina, e finché resta spento non viene inviato né temperature, né top_p, né top_k: è proprio la richiesta senza temperature vista prima in questo articolo. Attivalo e compaiono le tre manopole. Il playground limita temperature a 1.0, quindi i valori sopra il punto di partenza si possono impostare solo tramite API.
Usa lo structured output per un formato coerente. La modalità JSON o il function calling vincolano la forma senza vincolare il contenuto. Se ti serve un formato preciso, è più affidabile che regolare temperature. Imposta un max_tokens generoso se lo schema è grande.
Usa un seed per esecuzioni ripetibili. Sui modelli che lo rispettano, un seed fissa l'output e rende così riproducibili le esecuzioni di valutazione e le segnalazioni di bug. Controlla la tabella di compatibilità per il tuo modello, e ricalcola i tuoi valori di riferimento dopo ogni aggiornamento del modello.
Abbassa temperature per avere meno varianza, ma non fino a zero. Vuoi output più focalizzati? Prova T=0.3-0.7. Ottieni meno varietà senza i problemi del greedy decoding.
Verifica le proprietà, non le stringhe. Basta questa modifica perché una suite di test smetta di fallire a intermittenza.
Il compromesso
In questo articolo due cose diverse si sono presentate entrambe come varianza, e tutta la lezione sta nel tenerle distinte.
La prima è il sampling. L'hai chiesto tu, è ciò che permette al modello di provare una formulazione diversa o di uscire da un loop, ed è stato proprio spegnerlo a tenere bloccato un modello di ragionamento su un enigma di barche per mille token. Tienilo.
La seconda è il rumore numerico di un batch che non hai scelto tu. Quello non lo vuole nessuno. Si può eliminare, perché i kernel esistono, funzionano, e mille esecuzioni sono tornate mille volte identiche, ma il prezzo è di circa un fattore 1,6 sul throughput, ed è per questo che nessuna grande API te lo fa pagare di default. Se il tuo carico di lavoro ha bisogno di una riproducibilità bit per bit, è un prezzo che vale la pena conoscere. Alla maggior parte dei carichi di lavoro serve qualcosa di più economico e più onesto: un output corretto ogni volta, più che identico ogni volta.
Quindi la domanda non è mai stata "come elimino la varianza?", ma "quale varianza ho davanti, e vale la pena pagare per eliminarla?"
Il modello rimasto bloccato sull'enigma delle barche lo aveva già risolto. La risposta era lì, nel suo ragionamento, tre righe sopra il punto in cui è cominciato il loop. Quello che non riusciva a fare era fermarsi. Con temperature a 0, il token che chiude la frase deve battere a ogni singolo passo il token che avvia un altro sinonimo, e la prima volta che ha perso quel confronto l'ha perso per sempre. Sarebbe bastato un pizzico di casualità.
Usa i parametri consigliati e imposta temperature in modo esplicito. Controlla l'output con la struttura e con un seed, non forzando il sampling a zero. E prima di passare una settimana a regolare un parametro, prenditi un minuto per verificare che abbia qualche effetto. Non serve altro.
Per i valori predefiniti di ogni modello, vedi Recommended sampling parameters. Per sapere quali parametri rispetta ciascun modello, vedi OpenAI compatibility.
Letture correlate
Fonti
- Horace He e Thinking Machines Lab, Defeating Nondeterminism in LLM Inference, 10 settembre 2025: fonte del risultato sull'invarianza rispetto al batch, dell'esperimento su Qwen3-235B e dei dati di latenza citati sopra
- thinking-machines-lab/batch_invariant_ops: i loro kernel invarianti rispetto al batch, open source
- LMSYS, Towards Deterministic Inference in SGLang, 22 settembre 2025
Scritto da Thomas Vits, con assistenza dell'AI.
