02KI5 min

Jedes neue Modell macht mir etwas anderes kaputt

Sechs Modellmigrationen in anderthalb Jahren. Keine davon war der Austausch eines Strings, auch wenn alle so anfingen.

Von Salva Castells

Titelbild: Tim Green, CC BY 2.0.
In diesem Beitrag

In einer semantischen Anime-Suche habe ich einen Reranker, der fünfundzwanzig Kandidaten mit einem LLM sortiert. Er lief vom ersten Tag an mit temperature: 0, was naheliegt, wenn man eine reproduzierbare Reihenfolge will. Ich habe das Modell gegen ein Reasoning-Modell getauscht und der Aufruf fing an, einen Fehler zurückzugeben: Dieses Modell lehnt temperature ab. Es ignoriert es nicht, es lehnt es ab.

Der Fix war nicht, die Zeile zu löschen. Er war, sie durch reasoning_effort zu ersetzen, das in eine Konstante mit low als Default zu ziehen und es auch an der anderen Stelle durchzureichen, an der dasselbe Modell aufgerufen wurde: ein Ingest-Skript, das Franchise-Prämissen synthetisiert. Drei Dateien, eine neue Umgebungsvariable und ein Dokument zum Aktualisieren. Für einen Parameter.

Das ist in anderthalb Jahren sechsmal passiert, und jedes Mal anders.

Die vier Arten von Bruch

Nachdem ich sie gesammelt habe, wiederholen sich immer dieselben vier:

  • Der Parameter, der verschwindet. temperature in Reasoning-Modellen. Es gibt kein exaktes Äquivalent: reasoning_effort ist nicht dasselbe, es ist ein anderer Hebel. Du migrierst den Wert nicht, du entscheidest dich für einen neuen.
  • Die Form des Payloads. Von Chat Completions auf die Responses API zu wechseln für einen Vision-Parser ist nicht das Umbenennen einer Methode. messages wird zu input, {"type": "text"} wird zu input_text, und image_url ist kein Objekt {url, detail} mehr, sondern ein String mit detail als Geschwister auf derselben Ebene.
  • Die Form der Antwort. In derselben Migration wurde aus response.choices[0].message.content ein response.output_text. Und der Token-Zähler ist nicht mehr einer: Jetzt gibt es input_tokens und output_tokens getrennt. Das zwang mich, die Signatur der Funktion von -> str auf -> tuple[str, int, int] zu ändern und sie beim Einsammeln der parallelen Futures von Hand zu akkumulieren.
  • Die Qualität ändert sich ohne Vorwarnung. Das ist die schlimmste, weil sie keinen Fehler wirft. Das Modell antwortet, das JSON validiert, und die Ergebnisse sind schlechter.

Die ersten drei siehst du beim ersten Deploy. Die vierte frisst du wochenlang.

OpenRouter und LangChain verschieben das Problem

Die logische Reaktion ist, eine Schicht darüberzulegen. Ich habe das zweimal getan.

In einem Transkriptions-SaaS hatte ich eine lib/claude.ts mit einem processWithClaude(text, prompt), das direkt das Anthropic-SDK aufrief. Ich habe sie komplett gelöscht und durch ein AIService-Singleton gegen OpenRouter ersetzt, mit modelName und temperature als Parametern, die aus dem FormData des Endpoints kommen. Echter Gewinn: Modellwechsel wurde zu "einen anderen String in der Anfrage schicken".

In einer Chat-App mit Projekten und Kontexten war die Schicht LangChain, mit @langchain/anthropic und @langchain/google-genai, um ein nach Anbieter gruppiertes Dropdown zu haben. Da habe ich die andere Hälfte gelernt: Die Schicht geht auch kaputt. Einer der Commits aus den ersten Tagen heißt wörtlich "update dependencies for @langchain/anthropic and zod to latest versions". In Abstraktionen über APIs, die sich bewegen, auf latest zu aktualisieren heißt, ein Problem, das du verstehst, gegen eines zu tauschen, das du nicht verstehst.

Was diese Schichten leisten, ist real, aber begrenzt: Sie normalisieren die Form des Aufrufs. Sie normalisieren nicht die Fähigkeiten. Wenn Modell A temperature akzeptiert und B nicht, lässt dich die Schicht beide Parameter durchreichen, und der Fehler erreicht dich trotzdem, nur jetzt aus dem Inneren einer Bibliothek, mit dem Stacktrace drei Ebenen tiefer.

Die Kosten eines Wechsels mitten im Produkt

An dem Tag, an dem ich das Modell eines Stundenplangenerators von google/gemini-3-flash-preview auf deepseek/deepseek-v3.2 umgestellt habe, berührte der Diff drei Dateien: app/api/generate/route.ts, app/api/adjust-schedule/route.ts und app/api/verify-checks/route.ts. Dasselbe Literal, dreimal kopiert, in drei verschiedenen JSON.stringify. Das ist kein Fehler des Anbieters, das ist meiner, und das sind die echten Kosten: Wenn der Modellname am Aufrufpunkt einbetoniert ist, ist Migrieren ein Find-and-Replace plus die Hoffnung, keins übersehen zu haben.

In einem Dokumentenanalyse-Dienst habe ich versucht, das zu beheben, indem ich Modelle und Kostenberechnung in einer Konstantendatei zentralisierte. Das hat mich drei Refactors in drei Wochen gekostet: einen am 18., einen am Tag darauf und davor noch einen. Der Name eines der Commits enthält den Tippfehler, "reafactor constants", was einiges über diesen Nachmittag aussagt. Die Konstantenschicht saß nicht beim ersten Versuch, weil ich versuchte, zwei verschiedene Dinge in dieselbe Struktur zu pressen: welches Modell zu benutzen ist und was es kostet.

Wie ich den Aufruf heute isoliere

Ohne Zeremonie. Drei Regeln.

Das Modell ist nie ein Literal am Aufrufpunkt. Immer eine Konstante aus der Umgebung mit Default, Typ RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Ein Modellwechsel in Produktion sollte kein Deploy erfordern.

Modellspezifische Parameter reisen mit dem Modell. Wenn ein Modell reasoning_effort braucht und ein anderes temperature, dann ist das Konfiguration des Modells, nicht der Funktion. Ein Objekt pro Modell, kein loser Parameter, den du durch sechs Signaturen schleppst.

Verteidigung am Rand, immer. Als ich den Timeout für die OpenAI-Aufrufe einbaute, kam auch das hier in den Embeddings-Batch:

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

Vier Zeilen. Sie verwandeln eine stille Fehlausrichtung zwischen Vektoren und Texten — die dir die Datenbank voller Embeddings hinterlässt, die dem falschen Anime zugeordnet sind — in einen sofortigen Fehler. Das AbortSignal.timeout(30_000) aus demselben Commit tut dasselbe für hängende Aufrufe.

Was ich als stabil ansehe

Stabil: dass es einen HTTP-Endpoint gibt, dass er JSON annimmt, dass er JSON zurückgibt. Da hört es auf.

Nicht stabil: der Modellname, die Sampling-Parameter, die Form der Nachricht, die Form der Antwort, wo der Token-Zähler herkommt, der Preis, und dass es das heutige Modell in sechs Monaten noch gibt.

Ich entwerfe nicht für das Modell, das ich benutze. Ich entwerfe für den Tag, an dem es weg ist.