Dwa z sześciu modeli AI napisały sklep, który sprzedaje więcej, niż jest na magazynie
Data publikacji

Dałem sześciu modelom językowym to samo zadanie: napisz sklep sprzedający limitowaną edycję winyla, dokładnie sto sztuk, ani jednej więcej. Potem uruchomiłem każde rozwiązanie i wysłałem w nie 400 requestów, 60 równolegle.
Jeden model sprzedał 133 sztuki. Drugi 125, i przy okazji zniszczył własny plik z zamówieniami. Cztery pozostałe utrzymały równe sto.
Model | Sprzedanych ze 100 | Uwagi |
|---|---|---|
opus5 | 100 | poprawnie także przy 8 workerach |
fable5 | 100 | poprawnie |
gpt56sol | 100 | poprawnie |
gpt56luna | 100 | poprawnie |
deepseekv4pro | 133 | brak jakiejkolwiek ochrony |
kimi3 | 125 | poprawnie na jednym procesie, rozpad na czterech |
Poniżej: jak wyglądało zadanie, co dokładnie zepsuły te dwa modele i dlaczego jedno z nich wygląda na napisane poprawnie.
Zadanie
Prompt był krótki i celowo nie zawierał ani słowa o współbieżności, wyścigach czy blokadach. Sprawdziłem to grepem przed wysłaniem, bo jedno takie słowo zmieniłoby test w sprawdzanie, czy model umie wykonać polecenie.
1Sprzedajemy limitowaną edycję winyla — dokładnie 100 sztuk, ani jednej więcej.2Start sprzedaży ogłaszamy w newsletterze na konkretną godzinę.34Napisz w FastAPI endpoint POST /buy, który przyjmuje email kupującego.5Jeśli są jeszcze sztuki: rezerwuje jedną i zwraca numer zamówienia.6Jeśli nie ma: zwraca stosowną odpowiedź.7Jedna osoba (email) może kupić maksymalnie jedną sztukę.8Po udanym zakupie wyślij kupującemu e-mail z potwierdzeniem (mamy firmowy SMTP).9Bez bazy danych — stan trzymaj w pliku JSON. Podaj kompletny kod.
Informacja, z której wynika ryzyko, jest w drugim zdaniu: start ogłaszany w newsletterze na konkretną godzinę. To znaczy, że kilkaset osób kliknie w tej samej sekundzie. Nie napisałem tego wprost i o to chodziło.
Każde rozwiązanie uruchomiłem z czystym stanem, w dwóch konfiguracjach: jeden proces i cztery procesy uvicorna. Ta druga to standard produkcyjny.
Deepseekv4pro: nic nie chroni zapisu
Kod robi cztery rzeczy po kolei: odczytuje stan z pliku, sprawdza czy coś zostało, zmniejsza licznik, zapisuje plik. Między odczytem a zapisem nie ma żadnej ochrony.
Przy czterech procesach dwa requesty odczytują tę samą liczbę, oba widzą wolną sztukę, oba ją sprzedają. Efekt: 133 sztuki ze stu. Trzydziestu trzem osobom trzeba oddać pieniądze.
Ciekawsze jest to, co dzieje się przy jednym procesie: 702 błędy parsowania i 351 odpowiedzi 500. Gorzej niż przy czterech. Powód jest łatwy do przeoczenia: handler to zwykły def, a nie async def, więc FastAPI wykonuje go w puli wątków. Wątki w jednym procesie deptają sobie po pliku dokładnie tak samo jak osobne procesy.
Nie ma konfiguracji, w której ten kod działa poprawnie.
Kimi k33: wygląda poprawnie i przez większość czasu działa
Ten przypadek jest ciekawszy, bo na pierwszy rzut oka wszystko jest na miejscu. Jest threading.Lock. Jest with lock obejmujący całą operację. Jest zapis przez plik tymczasowy i os.replace, czyli podręcznikowy atomowy zapis. README zapewnia, że „rezerwacja sztuki jest atomowa, więc race condition nie sprzeda dwóm osobom tej samej sztuki".
Przy jednym procesie: sto sprzedanych, zero błędów. Wszystko się zgadza.
Przy czterech: 782 błędy parsowania, 394 odpowiedzi 500, a w niezależnym pomiarze 125 kupujących z numerem zamówienia, w tym ten sam numer wydany dwóm różnym osobom. W pliku zostaje sto rekordów, więc dwadzieścia pięć sprzedaży istnieje wyłącznie w skrzynkach klientów.
Zawiodły dwie rzeczy naraz.
threading.Lock obowiązuje w obrębie jednego procesu. Cztery workery to cztery niezależne blokady, które nic o sobie nie wiedzą.
Druga przyczyna jest subtelniejsza i to ona niszczy plik. Nazwa pliku tymczasowego jest stała:
1tmp = STATE_FILE.with_suffix(".json.tmp") # ta sama nazwa we wszystkich procesach2tmp.write_text(json.dumps(state, indent=2))3tmp.replace(STATE_FILE) # atomowe, ale źródło jest współdzielone
os.replace jest atomowy, ale tylko wtedy, gdy plik źródłowy należy wyłącznie do ciebie. Cztery procesy piszą do tego samego state.json.tmp, więc podmiana przenosi na miejsce plik sklejony z dwóch połówek. Od tego momentu stan jest nieczytelny i każdy kolejny request kończy się błędem, aż do ręcznej naprawy.
Cała różnica między „działa bezbłędnie" a „rozsypuje się i sprzedaje za dużo" siedzi w jednej fladze uruchomieniowej.
Co to znaczy po wdrożeniu
Newsletter wychodzi o dwunastej. W pierwszej minucie wchodzi fala.
Przy kodzie deepseeka trzydzieści trzy osoby dostają potwierdzenie zakupu towaru, którego nie ma. To są zwroty, maile do obsługi i opinie w sieci.
Przy kodzie kimi3 jest gorzej, bo dochodzi utrata danych. Część potwierdzonych zamówień znika z pliku przy kolejnym zapisie, a po chwili aplikacja przestaje odpowiadać w ogóle. Masz klientów z dowodem zakupu, których nie ma w systemie, i nie wiesz nawet, kto to jest.
Warto dodać, że przy jednym procesie kimi3 zachowuje się bez zarzutu. Na maszynie deweloperskiej, w testach integracyjnych i na staging z jednym workerem ten kod wygląda na poprawny.
Dwa wnioski
Test na jednym procesie nie testuje produkcji
Gdybym uruchomił tylko domyślną konfigurację, kimi3 dostałoby wysoką ocenę. Różnica między poprawnym a katastrofalnym wynikiem to flaga --workers. Jeśli twój zestaw testów integracyjnych startuje jeden proces, nie sprawdzasz tego, co pójdzie na serwer.
Weryfikacja jest tania. Jedna pętla wystarczy:
1uvicorn main:app --workers 4 &2seq 1 400 | xargs -P 60 -I{} curl -s -o /dev/null \3 -X POST localhost:8000/buy \4 -H 'Content-Type: application/json' \5 -d '{"email":"k{}@test.pl"}'6
README to deklaracja, nie pomiar
Kimi3 opisało w dokumentacji zabezpieczenie, które nie działa poza jednym procesem, i nigdzie nie zaznaczyło tego ograniczenia. Dokumentacja pokazuje uruchomienie z --reload i nic poza tym, więc to ona popycha operatora do skalowania przed dużym ruchem.
Nie chodzi o złą wolę modelu. Chodzi o to, że opis własnego kodu nigdy nie zastąpi jego uruchomienia.
Czego ten test nie mierzy
To jest jedno uruchomienie na model. Temperatura i losowość samplingu wpływają na wynik, więc traktuję to jako sondę, a nie metrykę.
Test sprawdza też tylko odruch przy wymaganiu podanym wprost. Prompt mówił „dokładnie sto sztuk", więc modele wiedziały, czego pilnować. Cztery z sześciu to dowiozły i to jest wynik lepszy, niż się spodziewałem.
Naturalne następne pytanie brzmi, czy zamiana pliku na prawdziwą bazę danych cokolwiek by tu zmieniła. Sprawdziłem to na dziesięciu wariantach warstwy zapisu i wynik zaskoczył mnie na tyle, że zasługuje na osobny tekst.