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.
temperaturew modelach rozumujących. Nie ma dokładnego odpowiednika:reasoning_effortto 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.
messagesstaje sięinput,{"type": "text"}staje sięinput_text, aimage_urlprzestaje być obiektem{url, detail}i staje się napisem, zdetailjako rodzeństwem na tym samym poziomie. - Kształt odpowiedzi. W tej samej migracji
response.choices[0].message.contentzamieniło się wresponse.output_text. A licznik tokenów przestał być jeden: teraz są osobnoinput_tokensioutput_tokens. To wymusiło zmianę sygnatury funkcji z-> strna-> 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.
