02IA5 min

Ogni modello nuovo mi rompe qualcosa di diverso

Sei migrazioni di modello in un anno e mezzo. Nessuna è stata cambiare una stringa di testo, anche se tutte sembravano esserlo all'inizio.

Di Salva Castells

Immagine di copertina: Tim Green, CC BY 2.0.
In questo post

In un motore di ricerca semantico di anime ho un reranker che ordina venticinque candidati con un LLM. Aveva temperature: 0 dal primo giorno, che è l'ovvio quando vuoi un ordine riproducibile. Ho cambiato il modello con uno di ragionamento e la chiamata ha iniziato a restituire errore: quel modello rifiuta temperature. Non lo ignora, lo rifiuta.

La correzione non è stata cancellare la riga. È stata sostituirla con reasoning_effort, tirarlo fuori in una costante con low di default, e passarlo anche nell'altro punto dove si chiamava lo stesso modello, uno script di ingestione che sintetizza premesse di franchise. Tre file, una variabile d'ambiente nuova e un documento da aggiornare. Per un parametro.

È successo sei volte in un anno e mezzo, e ogni volta in modo diverso.

I quattro tipi di rottura

Dopo averle collezionate, si ripetono sempre le stesse quattro:

  • Il parametro che scompare. temperature nei modelli di ragionamento. Non c'è un equivalente esatto: reasoning_effort non è la stessa cosa, è un'altra leva. Non migri il valore, ne decidi uno nuovo.
  • La forma del payload. Passare da Chat Completions alla Responses API per un parser di visione non è cambiare il nome del metodo. messages diventa input, {"type": "text"} diventa input_text, e image_url smette di essere un oggetto {url, detail} per diventare una stringa con detail come fratello allo stesso livello.
  • La forma della risposta. In quella stessa migrazione, response.choices[0].message.content è diventato response.output_text. E il contatore di token ha smesso di essere uno: ora ci sono input_tokens e output_tokens separati. Questo ha obbligato a cambiare la firma della funzione da -> str a -> tuple[str, int, int] e ad accumularli a mano raccogliendo i future in parallelo.
  • La qualità cambia senza avvisare. Questo è il peggiore perché non dà errore. Il modello risponde, il JSON valida, e i risultati sono peggiori.

I primi tre li vedi al primo deploy. Il quarto te lo mangi per settimane.

OpenRouter e LangChain spostano il problema

La reazione logica è mettere uno strato sopra. L'ho fatto due volte.

In un SaaS di trascrizione avevo un lib/claude.ts con un processWithClaude(text, prompt) che chiamava direttamente l'SDK di Anthropic. L'ho cancellato tutto e l'ho sostituito con un AIService singleton verso OpenRouter, con modelName e temperature come parametri che viaggiano dal FormData dell'endpoint. Guadagno reale: cambiare modello è diventato mandare un'altra stringa nella richiesta.

In un'app di chat con progetti e contesti, lo strato è stato LangChain, con @langchain/anthropic e @langchain/google-genai per poter avere un menu a tendina raggruppato per provider. Lì ho imparato l'altra metà: anche lo strato si rompe. Un commit dei primi giorni si chiama letteralmente "update dependencies for @langchain/anthropic and zod to latest versions". Aggiornare a latest su astrazioni sopra API che si muovono significa scambiare un problema che capisci con uno che non capisci.

Quello che fanno questi strati è reale ma limitato: normalizzano la forma della chiamata. Non normalizzano le capacità. Se il modello A accetta temperature e il B no, lo strato ti lascia passare entrambi i parametri e l'errore ti arriva lo stesso, solo che ora da dentro una libreria, con lo stack trace tre livelli più in basso.

Il costo di cambiare a metà prodotto

Il giorno in cui ho cambiato il modello di un generatore di orari da google/gemini-3-flash-preview a deepseek/deepseek-v3.2, il diff toccava tre file: app/api/generate/route.ts, app/api/adjust-schedule/route.ts e app/api/verify-checks/route.ts. Lo stesso letterale, copiato tre volte dentro tre JSON.stringify diversi. Non è un difetto del provider, è mio, ed è il costo vero: quando il nome del modello è incastrato nel punto di chiamata, migrare è un find-and-replace e pregare di non dimenticartene uno.

In un servizio di analisi di documenti ho provato a risolverlo centralizzando modelli e calcolo dei costi in un file di costanti. Mi è costato tre refactor in tre settimane: uno il 18, un altro il giorno dopo, e prima un altro ancora. Il nome di uno dei commit ha il refuso dentro, "reafactor constants", il che dice abbastanza di com'era il pomeriggio. Lo strato di costanti non ha attecchito al primo colpo perché stavo provando a mettere nella stessa struttura due cose diverse: quale modello usare e quanto costa.

Come isolo oggi la chiamata

Senza cerimonie. Tre regole.

Il modello non è mai un letterale nel punto di chiamata. Sempre una costante letta dall'ambiente con un default, tipo RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Cambiare modello in produzione non dovrebbe richiedere un deploy.

I parametri specifici del modello viaggiano con il modello. Se un modello ha bisogno di reasoning_effort e un altro di temperature, quella è configurazione del modello, non della funzione. Un oggetto per modello, non un parametro sciolto che trascini per sei firme.

Difesa al bordo, sempre. Quando ho aggiunto il timeout alle chiamate di OpenAI, ho messo anche questo nel batch di embedding:

if (embeddings.length !== input.length) {
  throw new Error(
    `Embedding provider returned ${embeddings.length} vectors for ${input.length} inputs`
  );
}

Quattro righe. Trasformano un disallineamento silenzioso tra vettori e testi — che ti lascia il database pieno di embedding assegnati all'anime sbagliato — in un errore immediato. L'AbortSignal.timeout(30_000) dello stesso commit fa lo stesso con le chiamate appese.

Cosa do per stabile

Stabile: che c'è un endpoint HTTP, che accetta JSON, che restituisce JSON. Lì finisce.

Non stabile: il nome del modello, i parametri di campionamento, la forma del messaggio, la forma della risposta, da dove arriva il contatore di token, il prezzo, e che il modello di oggi esista ancora tra sei mesi.

Non progetto per il modello che uso. Progetto per il giorno in cui non ci sarà più.