02AI5 min

Elk nieuw model breekt iets anders bij me

Zes modelmigraties in anderhalf jaar. Geen enkele was het veranderen van een tekstregel, ook al leek het er steeds op.

Door Salva Castells

Omslagafbeelding: Tim Green, CC BY 2.0.
In dit bericht

In een semantische zoekmachine voor anime heb ik een reranker die vijfentwintig kandidaten ordent met een LLM. Die stond vanaf dag één op temperature: 0, wat voor de hand ligt als je een reproduceerbare volgorde wilt. Ik verving het model door een redeneermodel en de aanroep begon een fout terug te geven: dat model weigert temperature. Het negeert het niet, het weigert het.

De fix was niet de regel verwijderen. De fix was hem vervangen door reasoning_effort, dat naar een constante trekken met low als standaard, en het ook doorgeven op de andere plek waar hetzelfde model werd aangeroepen, een ingestiescript dat premissen van franchises samenvat. Drie bestanden, één nieuwe omgevingsvariabele en een document om bij te werken. Voor één parameter.

Dat is zes keer gebeurd in anderhalf jaar, en elke keer op een andere manier.

De vier soorten breuk

Nadat ik ze had verzameld, herhalen zich altijd dezelfde vier:

  • De parameter die verdwijnt. temperature in redeneermodellen. Er is geen exact equivalent: reasoning_effort is niet hetzelfde, het is een andere hendel. Je migreert de waarde niet, je kiest een nieuwe.
  • De vorm van de payload. Van Chat Completions naar de Responses API gaan voor een vision-parser is niet de naam van de methode veranderen. messages wordt input, {"type": "text"} wordt input_text, en image_url is niet langer een object {url, detail} maar een string met detail als broer op hetzelfde niveau.
  • De vorm van het antwoord. In diezelfde migratie werd response.choices[0].message.content tot response.output_text. En de tokenteller is er niet langer één: nu zijn er input_tokens en output_tokens apart. Dat dwong me de signatuur van de functie te veranderen van -> str naar -> tuple[str, int, int] en ze met de hand op te tellen bij het ophalen van de futures in parallel.
  • De kwaliteit verandert zonder waarschuwing. Dit is de ergste, want er komt geen fout. Het model antwoordt, de JSON valideert, en de resultaten zijn slechter.

De eerste drie zie je bij de eerste deploy. De vierde vreet weken.

OpenRouter en LangChain verplaatsen het probleem

De logische reactie is er een laag bovenop leggen. Dat heb ik twee keer gedaan.

In een transcriptie-SaaS had ik een lib/claude.ts met een processWithClaude(text, prompt) die direct de SDK van Anthropic aanriep. Ik heb hem helemaal verwijderd en vervangen door een AIService-singleton tegen OpenRouter, met modelName en temperature als parameters die vanuit de FormData van het endpoint meereizen. Echte winst: van model wisselen werd een andere string meesturen in het verzoek.

In een chatapp met projecten en contexten was de laag LangChain, met @langchain/anthropic en @langchain/google-genai om een dropdown per provider gegroepeerd te kunnen hebben. Daar leerde ik de andere helft: de laag gaat ook stuk. Een commit uit de eerste dagen heet letterlijk "update dependencies for @langchain/anthropic and zod to latest versions". Naar latest updaten in abstracties over API's die bewegen, is een probleem dat je begrijpt inruilen voor een probleem dat je niet begrijpt.

Wat die lagen doen is echt maar beperkt: ze normaliseren de vorm van de aanroep. Ze normaliseren niet de mogelijkheden. Als model A temperature accepteert en B niet, laat de laag je beide parameters doorgeven en krijg je de fout toch, alleen nu van binnenuit een library, met de stack trace drie niveaus dieper.

De kosten van halverwege het product wisselen

Op de dag dat ik het model van een roostergenerator veranderde van google/gemini-3-flash-preview naar deepseek/deepseek-v3.2, raakte de diff drie bestanden: app/api/generate/route.ts, app/api/adjust-schedule/route.ts en app/api/verify-checks/route.ts. Dezelfde literal, drie keer gekopieerd in drie verschillende JSON.stringify-aanroepen. Dat is geen fout van de provider, die is van mij, en dat zijn de echte kosten: als de modelnaam is ingebakken op het aanroeppunt, is migreren een find-and-replace en bidden dat je er geen vergeet.

In een dienst voor documentanalyse probeerde ik dit op te lossen door modellen en kostenberekening te centraliseren in een bestand met constanten. Het kostte me drie refactors in drie weken: één op de 18e, één de dag erna, en daarvoor nog één. De naam van een van de commits bevat de typefout, "reafactor constants", wat genoeg zegt over die middag. De laag met constanten lukte niet in één keer omdat ik twee verschillende dingen in dezelfde structuur probeerde te proppen: welk model te gebruiken en wat het kost.

Hoe ik de aanroep vandaag isoleer

Zonder ceremonie. Drie regels.

Het model is nooit een literal op het aanroeppunt. Altijd een constante die uit de omgeving wordt gelezen met een default, zoals RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Van model wisselen in productie zou geen deploy moeten vereisen.

Modelspecifieke parameters reizen mee met het model. Als het ene model reasoning_effort nodig heeft en het andere temperature, dan is dat configuratie van het model, niet van de functie. Eén object per model, geen losse parameter die je door zes signaturen sleept.

Verdediging aan de rand, altijd. Toen ik de timeout aan de OpenAI-aanroepen toevoegde, zette ik dit ook in de embeddingsbatch:

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

Vier regels. Ze veranderen een stille misalignment tussen vectoren en teksten — die je database volstopt met embeddings die aan de verkeerde anime zijn toegewezen — in een directe fout. De AbortSignal.timeout(30_000) uit dezelfde commit doet hetzelfde met hangende aanroepen.

Wat ik als stabiel beschouw

Stabiel: dat er een HTTP-endpoint is, dat het JSON accepteert, dat het JSON teruggeeft. Daar houdt het op.

Niet stabiel: de naam van het model, de sampling-parameters, de vorm van het bericht, de vorm van het antwoord, waar de tokenteller vandaan komt, de prijs, en dat het model van vandaag over zes maanden nog bestaat.

Ik ontwerp niet voor het model dat ik gebruik. Ik ontwerp voor de dag dat het er niet meer is.