Devince
Development,  Benchmarks,  AI

Dwa z sześciu modeli AI sprzedały więcej, niż było w magazynie. Baza danych by tego nie naprawiła

Data publikacji

Puste miejsce na półce z winylami, pod nim rozsypane identyczne paragony w przyciemnionym magazynie sklepu płytowego

Dwa z sześciu modeli językowych napisały sklep, który przy stu sztukach na magazynie sprzedał 133. Nasuwa się oczywista poprawka: zamiast pliku użyć prawdziwej bazy danych.

Sprawdziłem to. Wziąłem tę samą naiwną logikę — odczytaj liczbę zamówień, sprawdź czy jest mniejsza od stu, dopisz zamówienie — i przepuściłem ją przez dziesięć wariantów warstwy zapisu, po 400 równoległych prób zakupu i trzy przebiegi na wariant.

Baza nie pomogła. Na SQLite ten kod sprzedał 139 sztuk ze stu, na Postgresie 122–127, w obu przypadkach bez jednego błędu zwróconego klientowi. Podniesienie izolacji do REPEATABLE READ niczego nie zmieniło.

Równe sto trafiły cztery warianty: plik z blokadą, SQLite z BEGIN IMMEDIATE, Postgres z SELECT … FOR UPDATE i Postgres z warunkiem wpisanym w samą aktualizację.

Warstwa zapisu

Sprzedanych ze 100

Błędy zgłoszone klientom

plik JSON bez locka

14–21, raz plik nieczytelny

303–395

SQLite, autocommit

139

0

SQLite, BEGIN DEFERRED

2–7

0 (ale ~395 odrzuceń)

SQLite, BEGIN IMMEDIATE

100

0

Postgres, READ COMMITTED

122–127

0

Postgres, REPEATABLE READ

121–126

0

Postgres, SERIALIZABLE

25–29

0 (ale ~375 odrzuceń)

Postgres, SELECT … FOR UPDATE

100

0

Postgres, UPDATE … WHERE sold poniżej limitu

100

0

Poniżej: co konkretnie zepsuły te dwa modele, dlaczego REPEATABLE READ nie pomaga i dlaczego wersja z bazą jest w praktyce groźniejsza od wersji z plikiem.

Skąd to się wzięło

Buduję zestaw testów koderskich dla modeli LLM. Test czwarty brzmi tak: sprzedajemy limitowaną edycję winyla, dokładnie sto sztuk, ani jednej więcej. Start ogłaszamy w newsletterze na konkretną godzinę. Jedna osoba, jedna sztuka. Bez bazy danych, stan w pliku JSON.

W promptcie nie ma słowa „współbieżność", „race condition" ani „lock". Jest za to informacja o starcie ogłoszonym na godzinę, z której wynika, że kilkaset osób kliknie w tej samej sekundzie.

Sześć modeli, identyczne zadanie. Każde rozwiązanie uruchomiłem z czystym stanem, przy jednym i przy czterech workerach uvicorna, i wysłałem 400 requestów przy 60 równoległych.

Model

Punkty

Sprzedanych przy 4 workerach

opus5

10

100

fable5

10

100

gpt56sol

10

100

gpt56luna

9

100

deepseekv4pro

5

133

kimi3

4

rozpad pliku, potem 125 w audycie

Cztery na sześć utrzymały pulę. To wynik jakościowo inny niż w poprzednim teście, gdzie pytałem o rzecz, o której prompt milczał, i poległo siedem na siedem. Kiedy wymaganie jest napisane wprost, większość modeli je dowozi.

Dwa, które nie dowiozły, zawiodły w sposób wart opisania.

deepseekv4pro: brak locka, w każdej konfiguracji

Kod wygląda tak: odczytaj stan z pliku, sprawdź czy coś zostało, zmniejsz licznik, zapisz plik. Bez żadnej ochrony między odczytem a zapisem.

Przy czterech workerach sprzedał 133 sztuki ze stu. Trzydziestu trzem osobom trzeba oddać pieniądze i wytłumaczyć, że jednak nie.

Ciekawsze jest to, co się dzieje przy jednym workerze: 702 błędy parsowania i 351 odpowiedzi 500. Gorzej niż przy czterech. Powód jest prosty i łatwo go przeoczyć: 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.

kimi3: poprawny na jednym procesie, katastrofa na czterech

Ten przypadek jest ciekawszy, bo na pierwszy rzut oka wszystko jest na miejscu. Jest threading.Lock, jest with lock wokół całej operacji, 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 workerze: sto sprzedanych, zero błędów. Wszystko się zgadza.

Przy czterech: 782 błędy parsowania, 394 odpowiedzi 500, a w niezależnym pomiarze audytu 125 kupujących z numerem zamówienia dla edycji stu sztuk, w tym ten sam numer wydany dwóm różnym osobom.

Zawiodły dwie rzeczy naraz i obie są pouczające.

threading.Lock obowiązuje w obrębie procesu. Cztery workery to cztery niezależne locki, które nic o sobie nie wiedzą.

Druga przyczyna jest subtelniejsza. Plik tymczasowy ma stałą nazwę:

[ TU WSTAW BLOK KODU #1 — JĘZYK: PYTHON ]

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. Atomowość zostaje formalnie zachowana i praktycznie nic nie znaczy. Od tego momentu stan jest nieczytelny i każdy kolejny request to błąd, aż do ręcznej naprawy.

Warto zauważyć, że fable5 ma dokładnie tę samą konstrukcję ze stałą nazwą pliku tymczasowego. Ratuje go wyłącznie to, że zapis siedzi wewnątrz filelock, który działa między procesami. Zdejmij ten lock przy refaktorze i masz drugie kimi3.

Pytanie, które się samo nasunęło

Skoro problemem jest plik, to co by było z prawdziwą bazą? Baza ma transakcje, blokady wierszy, poziomy izolacji. Wygląda na to, że problem znika sam.

Sprawdziłem, zamiast zgadywać. Napisałem tę samą logikę aplikacji w dziesięciu wariantach warstwy zapisu, zmieniając wyłącznie sposób trwałości:

[ TU WSTAW BLOK KODU #2 — JĘZYK: PYTHON ]

400 prób, 40 wątków równolegle, limit sto. Trzy niezależne przebiegi, żeby wykluczyć przypadek. Skrypt jest w repozytorium, odtwarza się jedną komendą.

Wyniki są w tabeli na górze. Cztery rzeczy z nich wynikają.

1. Musiałem odwołać własną tezę o SQLite

Zanim uruchomiłem eksperyment, powiedziałem coś takiego: SQLite przypadkiem uratuje naiwny kod, bo ma jednego pisarza naraz i sam serializuje zapisy.

To nieprawda. SQLite wypadł gorzej niż Postgres: 139 sztuk ze stu, powtarzalnie we wszystkich trzech przebiegach.

Mechanizm jest taki: blokada w SQLite serializuje pojedyncze operacje zapisu, a nie ciąg „odczytaj, zdecyduj, zapisz". Przy domyślnym autocommit każde zdanie SQL jest osobną transakcją, więc SELECT COUNT(*) kończy się i zwalnia blokadę, zanim w ogóle zacznie się INSERT. Dwa wątki spokojnie mieszczą się w tej szczelinie.

Ta sama baza z BEGIN IMMEDIATE trafia w równe sto. Różnica nie leży w bazie, tylko w tym, kiedy bierze się blokadę zapisu: z góry czy po podjęciu decyzji.

2. Wyższy poziom izolacji nie wystarcza

Spodziewałem się, że REPEATABLE READ załatwi sprawę. Nie załatwił: 121–126 sprzedanych, praktycznie tyle samo co przy READ COMMITTED.

Powód jest pouczający. REPEATABLE READ chroni przed konfliktem zapisu do tego samego wiersza. Tutaj każdy kupujący wstawia własny, nowy wiersz zamówienia. Dwie transakcje widzą po 99 zamówień, obie dopisują setne, żadna nie koliduje z drugą, obie się commitują. W terminologii baz danych to fantom, a nie utracona aktualizacja, i dopiero SERIALIZABLE go wykrywa.

Dokumentacja Postgresa mówi to zresztą wprost o READ COMMITTED: „This behavior makes Read Committed mode unsuitable for commands that involve complex search conditions". Warto to przeczytać, zanim się założy, że domyślny poziom izolacji cokolwiek gwarantuje.

3. Poziomy, które działają, mają cenę i drugie dno

SERIALIZABLE faktycznie nie dopuścił nadsprzedaży. Odrzucił za to około 375 z 400 transakcji błędem serializacji. Bez pętli ponawiającej próbę sprzedaż kończy się na 25 sztukach ze stu.

To samo z BEGIN DEFERRED w SQLite: 393–398 requestów dostało SQLITE_BUSY, sprzedały się 2–7 sztuk.

Obie te konfiguracje wyglądają w tabeli na bezpieczne, bo w kolumnie „nadsprzedaż" mają zero. Praktycznie oznaczają, że wyprzedaż się nie odbyła i został ci pełny magazyn. To pułapka dla kogoś, kto sprawdzi tylko, czy nie sprzedał za dużo.

4. Działa dokładnie jedna rzecz

Cztery warianty trafiły w równe sto: plik z lockiem, SQLite z BEGIN IMMEDIATE, Postgres z SELECT … FOR UPDATE i Postgres z warunkiem wpisanym w samą aktualizację.

[ TU WSTAW BLOK KODU #3 — JĘZYK: SQL ]

Wszystkie robią pojęciowo to samo: sprawiają, że decyzja i zapis są jedną niepodzielną operacją. To nie jest różnica między plikiem a bazą. To różnica między „sprawdzam, a potem zapisuję" a „sprawdzam i zapisuję jednym ruchem".

Model, który nie rozumie tej różnicy przy pliku, nie zrozumie jej przy Postgresie. Zmieni się tylko to, czy zobaczysz jego błąd.

Najważniejsza liczba w całym eksperymencie

Zestawmy dwa wiersze:

Miara

plik bez locka

SQLite autocommit

Rekordów w magazynie

14–21, raz nieczytelny

139

Błędy zgłoszone klientom

303–395

0

Wysłanych potwierdzeń

5–97

139

Wersja plikowa krzyczy. Sypie setkami błędów, plik przestaje się parsować, nie da się tego przeoczyć na żadnym środowisku testowym. Pierwszy przebieg u dewelopera i wiadomo, że coś jest nie tak.

Wersja z bazą milczy. Sto trzydzieści dziewięć osób dostaje zieloną odpowiedź i numer zamówienia. Zero błędów w logach, monitoring czysty, wykresy zielone. Dowiadujesz się przy inwentaryzacji albo z reklamacji trzydziestu dziewięciu klientów.

Z punktu widzenia biznesu druga sytuacja jest gorsza, mimo że wygląda lepiej. I to jest, moim zdaniem, główny powód, dla którego ta klasa błędów przeżywa code review i wchodzi na produkcję.

Co znalazły audyty ponad to, co zmierzyłem

Na dwóch rozwiązaniach puściłem osobny audyt bezpieczeństwa. Zwrócił rzeczy, których mój test w ogóle nie sprawdzał, bo testowałem tylko endpoint zakupu.

W opus5, czyli w rozwiązaniu, które wygrało test, panel administracyjny jest domyślnie otwarty. Zabezpieczenie istnieje, ale włącza się dopiero po ustawieniu zmiennej środowiskowej. Przy standardowym uruchomieniu curl /api/admin/orders bez żadnych nagłówków zwraca adresy e-mail wszystkich kupujących. Ten sam brak zabezpieczenia otwiera przycisk „wyślij ponownie wszystkie nieudane maile".

Drugie znalezisko jest ciekawsze, bo dotyczy logiki, nie kodu. Nie ma płatności ani potwierdzenia adresu, a rezerwacje nigdy nie wygasają. Prosty skrypt zgarnia cały nakład w 1,11 sekundy, wpisując zmyślone adresy. Winyl nie zostaje przesprzedany, tylko zablokowany, a prawdziwi klienci widzą „wyprzedane" w pierwszej sekundzie. Z perspektywy biznesu to gorszy scenariusz niż nadsprzedaż, a mój test w ogóle go nie mierzył.

Audyt poprawił mi też jeden pomiar: przy jednym workerze kimi3 sprzedaje równe sto, nie 97 jak zmierzyłem. Moja liczba była artefaktem po stronie klienta. Ta poprawka czyni to rozwiązanie groźniejszym, nie mniej groźnym: na środowisku deweloperskim wygląda bezbłędnie.

Co z tym zrobić u siebie

Sprawdź, gdzie masz check-then-act

Wszędzie tam, gdzie kod czyta stan, podejmuje decyzję w języku aplikacji i dopiero potem zapisuje, masz ten sam błąd co kimi3 i deepseek. Limitowana pula, rezerwacja miejsca, ostatnia sztuka w koszyku, przyznanie kodu rabatowego, cokolwiek z ograniczoną liczbą.

Nie licz na poziom izolacji

READ COMMITTED nie chroni, REPEATABLE READ też nie, gdy każdy wstawia własny wiersz. SERIALIZABLE chroni, ale wymaga obsługi ponowień, bez której zamienia problem nadsprzedaży w problem niesprzedania.

Testuj przy wielu procesach

Kimi3 przy jednym workerze wygląda bezbłędnie i dostałoby wysoką notę. Cała różnica między „działa" a „katastrofa" siedzi we fladze --workers. Jeśli twój test integracyjny odpala jeden proces, nie testujesz tego, co pójdzie na produkcję.

Licz rekordy, nie kody odpowiedzi

Przy bazie nadsprzedaż nie generuje ani jednego błędu. Jedyny sposób, żeby ją zobaczyć, to policzyć wiersze i porównać z limitem.

Co dalej

Naturalna druga runda: to samo zadanie, ale z Postgresem zamiast pliku, i pytanie, czy modele, które przy pliku zapomniały o locku, sięgną po FOR UPDATE albo warunek w UPDATE. Obstawiam, że nie, bo problemem nie jest znajomość API, tylko brak wyobrażenia sobie dwóch requestów w tej samej milisekundzie. Jeśli obstawiam dobrze, to będzie mocniejszy wynik niż cokolwiek, co mam teraz.

Drugie pytanie, które chcę zmierzyć: ile instrukcji w promptcie wystarcza. Tutaj wymaganie „dokładnie sto sztuk" było napisane wprost i cztery modele je dowiozły. W poprzednim teście, gdzie o autoryzacji nie było ani słowa, nie dowiózł nikt. Gdzie jest granica?

Skrypt eksperymentu odtwarza się jedną komendą, a prompt testowy to kilkanaście linijek. Jeśli ktoś odpali to na innych modelach albo na innej bazie, chętnie porównam wyniki.