02AI5 min

Każdy nowy model psuje mi coś innego

Sześć migracji modelu w półtora roku. Żadna nie polegała na zmianie jednego napisu, choć każda tak się zaczynała.

Autor: Salva Castells

Zdjęcie okładkowe: Tim Green, CC BY 2.0.
W tym wpisie

W semantycznej wyszukiwarce anime mam reranker, który układa dwadzieścia pięć kandydatów przy pomocy LLM-a. Od pierwszego dnia miał temperature: 0, co jest oczywiste, gdy chcesz powtarzalnej kolejności. Zmieniłem model na rozumujący i wywołanie zaczęło zwracać błąd: ten model odrzuca temperature. Nie ignoruje jej, odrzuca.

Naprawa nie polegała na skasowaniu linii. Polegała na zastąpieniu jej przez reasoning_effort, wyciągnięciu tego do stałej z domyślnym low i przepchnięciu tego również przez drugie miejsce, gdzie wywoływano ten sam model — skrypt ingestii syntetyzujący opisy franczyz. Trzy pliki, jedna nowa zmienna środowiskowa i dokument do zaktualizowania. Za jeden parametr.

To zdarzyło się sześć razy w półtora roku i za każdym razem inaczej.

Cztery rodzaje pęknięć

Po ich zebraniu powtarzają się zawsze te same cztery:

  • Parametr, który znika. temperature w modelach rozumujących. Nie ma dokładnego odpowiednika: reasoning_effort to nie to samo, to inna dźwignia. Nie migrujesz wartości, decydujesz o nowej.
  • Kształt payloadu. Przejście z Chat Completions na Responses API dla parsera wizyjnego to nie zmiana nazwy metody. messages staje się input, {"type": "text"} staje się input_text, a image_url przestaje być obiektem {url, detail} i staje się napisem, z detail jako rodzeństwem na tym samym poziomie.
  • Kształt odpowiedzi. W tej samej migracji response.choices[0].message.content zamieniło się w response.output_text. A licznik tokenów przestał być jeden: teraz są osobno input_tokens i output_tokens. To wymusiło zmianę sygnatury funkcji z -> str na -> tuple[str, int, int] i ręczne sumowanie przy zbieraniu równoległych future'ów.
  • Jakość zmienia się bez ostrzeżenia. To jest najgorsze, bo nie daje błędu. Model odpowiada, JSON się waliduje, a wyniki są gorsze.

Trzy pierwsze zobaczysz przy pierwszym deployu. Czwartą jesz przez tygodnie.

OpenRouter i LangChain przesuwają problem

Logiczną reakcją jest wsadzenie warstwy na wierzch. Zrobiłem to dwa razy.

W SaaS-ie do transkrypcji miałem lib/claude.ts z processWithClaude(text, prompt) wołającym wprost SDK Anthropic. Skasowałem to w całości i zastąpiłem singletonem AIService przeciwko OpenRouterowi, z modelName i temperature jako parametrami wędrującymi z FormData endpointu. Realny zysk: zmiana modelu stała się wysłaniem innego napisu w żądaniu.

W aplikacji do czatu z projektami i kontekstami warstwą był LangChain, z @langchain/anthropic i @langchain/google-genai, żeby mieć rozwijaną listę pogrupowaną po dostawcach. Tam nauczyłem się drugiej połowy: warstwa też się psuje. Jeden z commitów z pierwszych dni nazywa się dosłownie "update dependencies for @langchain/anthropic and zod to latest versions". Aktualizowanie do latest w abstrakcjach nad ruchomymi API to zamiana problemu, który rozumiesz, na taki, którego nie rozumiesz.

To, co te warstwy robią, jest realne, ale ograniczone: normalizują kształt wywołania. Nie normalizują możliwości. Jeśli model A przyjmuje temperature, a B nie, warstwa pozwoli ci przekazać oba parametry i błąd dostaniesz tak samo, tylko teraz z wnętrza biblioteki, ze stack trace'em trzy poziomy niżej.

Koszt zmiany w połowie produktu

W dniu, w którym zmieniłem model generatora planów zajęć z google/gemini-3-flash-preview na deepseek/deepseek-v3.2, diff dotknął trzech plików: app/api/generate/route.ts, app/api/adjust-schedule/route.ts i app/api/verify-checks/route.ts. Ten sam literał, skopiowany trzy razy wewnątrz trzech różnych JSON.stringify. To nie jest wina dostawcy, to moja wina i to jest prawdziwy koszt: kiedy nazwa modelu jest wbetonowana w miejsce wywołania, migracja to find-and-replace i modlitwa, żeby nie przeoczyć jednego.

W usłudze do analizy dokumentów próbowałem to naprawić, centralizując modele i liczenie kosztu w pliku stałych. Kosztowało mnie to trzy refaktory w trzy tygodnie: jeden 18-go, drugi dzień później, a wcześniej jeszcze jeden. Nazwa jednego z commitów ma w sobie literówkę, "reafactor constants", co sporo mówi o tamtym popołudniu. Warstwa stałych nie chwyciła za pierwszym razem, bo próbowałem wcisnąć w jedną strukturę dwie różne rzeczy: jakiego modelu używać i ile kosztuje.

Jak dziś izoluję wywołanie

Bez ceremonii. Trzy zasady.

Model nigdy nie jest literałem w miejscu wywołania. Zawsze stała czytana ze środowiska z wartością domyślną, w stylu RERANK_MODEL = process.env.RERANK_MODEL || "gpt-5.4-mini". Zmiana modelu na produkcji nie powinna wymagać deployu.

Parametry specyficzne dla modelu podróżują razem z modelem. Jeśli jeden model potrzebuje reasoning_effort, a inny temperature, to jest konfiguracja modelu, a nie funkcji. Jeden obiekt na model, a nie luźny parametr wleczony przez sześć sygnatur.

Obrona na brzegu, zawsze. Kiedy dodawałem timeout do wywołań OpenAI, wstawiłem też to do wsadu embeddingów:

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

Cztery linie. Zamieniają ciche rozjechanie się wektorów z tekstami — przez które baza zapełnia się embeddingami przypisanymi do złego anime — w natychmiastowy błąd. AbortSignal.timeout(30_000) z tego samego commita robi to samo z zawieszonymi wywołaniami.

Co uznaję za stabilne

Stabilne: że jest endpoint HTTP, że przyjmuje JSON, że zwraca JSON. Tu się kończy.

Niestabilne: nazwa modelu, parametry próbkowania, kształt wiadomości, kształt odpowiedzi, skąd bierze się licznik tokenów, cena i to, czy dzisiejszy model będzie istniał za pół roku.

Nie projektuję pod model, którego używam. Projektuję pod dzień, w którym go zabraknie.