Ten sam problem co dwa lata temu, inny endpoint
Jest 15 czerwca 2023. Dokładnie dwa lata temu, w czerwcu 2021, opisywałem tutaj eksperyment z endpointem Completions i modelem davinci: few-shot prompt, kilkanaście rekordów użytkownika, wynik zatwierdzony ręcznie i wrzucony do repozytorium. Nie przepisuję tamtego wpisu i nie zmieniam jego wniosków, bo one nie dotyczyły modelu, tylko niedeterminizmu i miejsca, w którym mieszka zatwierdzony fixture.
Problem, który mam dzisiaj, jest ten sam co wtedy. Potrzebuję kilkudziesięciu zamówień, żeby ekran listy i raport miesięczny nie wyglądały jak test1, test2, test3. Zmieniły się dwie rzeczy techniczne. Adres to teraz /v1/chat/completions, a model, do którego mam dostęp, to gpt-3.5-turbo, dostępny w API od 1 marca. Zamiast jednego bloku tekstu wysyłam listę wiadomości z rolami, w tym rolę systemową, w której raz ustawiam format odpowiedzi na całą rozmowę. To akurat jest realne ułatwienie przy danych, bo “zwracaj tylko linie JSON” przestaje być zdaniem, które model gubi po trzecim akapicie promptu.
Jedno zdanie o tym, czego tu nie ma, żeby nie było wątpliwości przy czytaniu kodu. API gpt-4 nadal jest za waitlistą, tak samo jak w marcu, kiedy porównywałem oba modele w interfejsie Plus. Wszystko, co niżej wołam ze skryptu, chodzi na gpt-3.5-turbo. Gdybym wpisał w ten wpis kod wołający gpt-4, byłaby to fikcja, a nie tutorial.
Zmieniło się natomiast to, co jest najważniejsze, czyli nic z warstwy niżej. Model językowy nie zastępuje ani fabryki z ziarnem, ani resetu bazy. Cztery warstwy danych testowych, które rozpisałem we wrześniu 2021 w strategii danych na .NET, stoją dokładnie tam, gdzie stały, a generator z LLM nie zajmuje żadnej z nich. Jest krokiem przed nimi: produkuje kandydata na wiersz, który dopiero po recenzji trafia do pliku, z którego seeduje fabryka.
Najpierw kontrakt danych, potem prompt
Zasada numer jeden jest taka, że model nie dostaje polecenia “wymyśl zamówienia”. Dostaje kształt. Kiedy pytam otwarcie, wraca do mnie wypracowanie z polami, o których nie było mowy, i z wartościami, które wyglądają na prawdziwe. Kiedy pytam o dopisanie kolejnych elementów do serii, którą sam zdefiniowałem, dostaję coś, co da się sparsować.
Kontrakt do tego wpisu wymyśliłem od zera i nie ma w nim ani jednego pola z żadnego projektu, w którym pracuję. Syntetyczny Order, dokładnie trzy pola:
public sealed record Order(
string OrderNumber, // ORD-2023-NNNNNN, unikalny
string CustomerEmail, // musi istnieć w tabeli klientów
decimal TotalGross); // 2 cyfry po kropce, 10.00 - 4999.99Trzy pola wystarczają, żeby pokazać wszystkie trzy klasy problemów, o których jest ten wpis: unikalność, klucz obcy i format liczby. CustomerEmail jest tu celowo kluczem obcym, a nie ozdobą. W bazie testowej klienci powstają z fabryki z ziarnem, więc zbiór dozwolonych adresów jest znany z góry i zamknięty.
Prompt składam z tych samych klocków, które opisywałem w lutym przy strukturze promptu do przypadków testowych: rola, ograniczenia, format wyjścia, przykłady. Różnica jest jedna i jest istotna. Tam wynik czytałem oczami. Tutaj wynik idzie do parsera, więc format wyjścia przestaje być kwestią wygody i staje się kontraktem.
Wiadomość systemowa jest krótka i to jest cała jej wartość:
Zwracasz wyłącznie linie JSON, po jednym obiekcie w linii.
Bez wstępu, bez komentarza, bez bloku markdown, bez podsumowania na końcu.Wiadomość użytkownika jest długa i to w niej siedzi kontrakt:
Generujesz syntetyczne dane testowe. Schemat rekordu, dokładnie trzy pola:
- orderNumber: string, wzorzec ORD-2023-NNNNNN (sześć cyfr), unikalny w całej odpowiedzi
- customerEmail: string, wyłącznie jedna z wartości z listy dozwolonych poniżej
- totalGross: liczba, kropka jako separator, dokładnie dwie cyfry po kropce, od 10.00 do 4999.99
Dozwolone adresy (nie wymyślaj innych):
anna.kowalska@example.com
jan.novak@example.org
b@example.com
Zakazy: żadnych innych pól, żadnych innych domen, żadnego tekstu poza liniami JSON.
Przykłady:
{"orderNumber": "ORD-2023-000101", "customerEmail": "anna.kowalska@example.com", "totalGross": 249.90}
{"orderNumber": "ORD-2023-000102", "customerEmail": "b@example.com", "totalGross": 10.00}
Dopisz 28 kolejnych linii tej samej serii.Cztery decyzje są tu celowe i wszystkie są dokładnie tymi samymi, które podjąłem w 2021 roku.
Adresy są w domenach example.com i example.org, czyli w domenach zarezerwowanych do dokumentacji. Nic z tego nikomu nie wyśle maila, jeżeli konfiguracja środowiska testowego okaże się dziurawa. Trzeci przykład jest brzydki i taki ma być, bo bez niego dostaję dwadzieścia wariantów tej samej Anny. Format to jeden obiekt w linii, a nie tablica, bo tablica wymaga od modelu pilnowania przecinków i domknięcia nawiasu przez całą odpowiedź. Liczba rekordów jest w promptcie jawnie, żeby wynik dał się policzyć przed parsowaniem.
I zasada, która jest ważniejsza od całej reszty tego wpisu. Do promptu nie wkleja się niczego produkcyjnego. Ani jednego prawdziwego adresu klienta, ani jednego nazwiska z bazy, ani fragmentu dumpa, ani zrzutu z narzędzia analitycznego. Pokusa jest realna, bo “model podchwyci nasz format” brzmi rozsądnie i faktycznie działa. To jest wysyłanie cudzych danych osobowych do zewnętrznej usługi i żaden format nie jest tego wart. Lista dozwolonych adresów w moim promptcie pochodzi z fabryki, a nie z bazy.
Klucz do API trzymam w zmiennej środowiskowej OPENAI_API_KEY i tylko tam. Nie ma go w repozytorium, nie ma go w logu, nie ma go w zmiennych CI, bo CI w tym schemacie w ogóle nie woła API.
Od odpowiedzi do seeda
Odpowiedź modelu nie jest fixture’em. Jest wsadem do recenzji i pierwsze, co ją spotyka, to parser, który nie ma dobrego dnia.
private static readonly HashSet<string> Contract =
new() { "orderNumber", "customerEmail", "totalGross" };
private static readonly HashSet<string> AllowedEmails = new()
{
"anna.kowalska@example.com",
"jan.novak@example.org",
"b@example.com"
};
private static readonly Regex NumberPattern = new(@"^ORD-2023-\d{6}$");
public static Order Parse(string line, ISet<string> alreadyApproved)
{
var raw = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(line)
?? throw new FormatException("pusty obiekt");
var extra = raw.Keys.Except(Contract).ToArray();
if (extra.Length > 0)
throw new FormatException($"pola spoza kontraktu: {string.Join(", ", extra)}");
var missing = Contract.Except(raw.Keys).ToArray();
if (missing.Length > 0)
throw new FormatException($"brak pól: {string.Join(", ", missing)}");
var number = raw["orderNumber"].GetString() ?? "";
if (!NumberPattern.IsMatch(number))
throw new FormatException($"orderNumber poza wzorcem: {number}");
if (!alreadyApproved.Add(number))
throw new FormatException($"orderNumber: duplikat {number}");
var email = raw["customerEmail"].GetString() ?? "";
if (!AllowedEmails.Contains(email))
throw new FormatException($"customerEmail poza listą: {email}");
if (raw["totalGross"].ValueKind != JsonValueKind.Number)
throw new FormatException("totalGross nie jest liczbą");
var gross = raw["totalGross"].GetDecimal();
if (gross < 10.00m || gross > 4999.99m)
throw new FormatException($"totalGross poza zakresem: {gross}");
if (gross.Scale != 2)
throw new FormatException($"totalGross: wymagane dwie cyfry po kropce, jest {gross}");
return new Order(number, email, gross);
}To nie jest defensywność na wszelki wypadek. Każda z tych asercji odpaliła się u mnie w ciągu pierwszej godziny. Parser przerywa na pierwszym naruszeniu, a kontrakt pól sprawdzam najwcześniej, więc ta linia wywaliła się na currency i tyle o niej wiem:
{"orderNumber": "ORD-2023-000101", "customerEmail": "anna.kowalska@gmail.com", "totalGross": 1249.9, "currency": "PLN"}Wynik przebiegu narzędzia recenzującego wygląda tak:
$ dotnet run --project tools/OrderFixtureReview
odczytane linie: 28
odrzucone: 5
zatwierdzone do przeglądu: 23
linia 7:
- pole spoza kontraktu: currency
linia 12:
- orderNumber: duplikat ORD-2023-000101
linia 18:
- customerEmail poza listą: anna.kowalska@gmail.com
linia 21:
- totalGross: wymagane dwie cyfry po kropce, jest 1249,9Cztery klasy błędów w jednym przebiegu, każda na innej linii. Pole currency, którego nie ma w schemacie, dorzucone dlatego, że przy zamówieniu pasuje. Skopiowany numer zamówienia wprost z przykładu, który miał być wzorem, a nie treścią: alreadyApproved zasiewam numerami z przykładów w promptcie i z wcześniej zatwierdzonych plików, więc taki duplikat nie ma gdzie się schować. Domena, której na liście nie było, i to domena, na którą naprawdę można wysłać maila. Jedna cyfra po kropce zamiast dwóch. Osobno, w innym przebiegu, dostałem "totalGross": 1249,90 z przecinkiem i to wysypało się już na JsonSerializer, czyli w najlepszym możliwym momencie.
Dopiero to, co przeszło przez parser i przez moje oczy, ląduje w repozytorium:
fixtures/
orders.approved.jsonlPlik przechodzi review jak każdy inny kod, bo jest kodem: wchodzi w diff, widać w nim zmianę, da się do niego wrócić po trzech miesiącach. Skrypt wołający API leży obok, w tools/, i uruchamiam go ręcznie przy biurku. Zestaw testów nie zna adresu OpenAI i nie ma prawa wyjść do sieci.
Seed jest tam, gdzie był we wrześniu 2021, i plik z modelu jest tylko jednym z jego wejść:
public sealed class OrderSeedFixture : IAsyncLifetime
{
private readonly IContainer _sql = new ContainerBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2019-latest")
.WithEnvironment("ACCEPT_EULA", "Y")
.WithEnvironment("MSSQL_SA_PASSWORD", SaPassword)
.WithPortBinding(1433, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
.Build();
public async Task InitializeAsync()
{
await _sql.StartAsync();
await MigrationRunner.RunAsync(ConnectionString);
// klienci z fabryki z ziarnem: to oni definiują listę dozwolonych adresów
await BulkInsert.CustomersAsync(ConnectionString, new CustomerFactory().Build(3));
// zamówienia z zatwierdzonego pliku, nie z wywołania API
await BulkInsert.OrdersAsync(ConnectionString, ApprovedOrders.Load("fixtures/orders.approved.jsonl"));
}
public Task DisposeAsync() => _sql.DisposeAsync().AsTask();
}Kontener podnoszę biblioteką Testcontainers z linii 3.x, tą z nowym ContainerBuilder, o której podstawach pisałem przy MSSQL z poziomu testu. Kolejność jest ta sama co dwa lata temu i nie ma tu miejsca na skrót: najpierw migracje, potem klienci z fabryki, na końcu zamówienia z pliku. Odwrotna kolejność to gwarantowane naruszenie klucza obcego, bo zamówienie nie ma do czego się podpiąć. Kiedy baza już stoi, testy integracyjne wchodzą przez WebApplicationFactory, a stan między klasami rozdzielam fixture’ami xUnit dokładnie tak, jak rozpisywałem to w notatce o xUnit i NUnit. Ekran listy sprawdzam Playwrightem w 1.35.0 i on też czyta ten sam zatwierdzony plik, więc asercja na tabeli zależy od zawartości repozytorium, a nie od tego, co model wymyślił w dniu przebiegu.
Cena i pin modelu
Rachunek jest głównym powodem, dla którego wracam do tego tematu po dwóch latach. gpt-3.5-turbo kosztuje 0,002 dolara za tysiąc tokenów, czyli dziesięciokrotnie mniej niż linia, na której robiłem eksperyment z 2021 roku. Wygenerowanie dwustu kandydatów na wiersz to dziś wydatek, którego nie zauważam na wyciągu, i to zmienia kalkulację: nie zastanawiam się już, czy warto próbować.
Nie zmienia natomiast kalkulacji na wywołaniu w pętli. Generowanie danych przy każdym uruchomieniu zestawu testów to płacenie co przebieg za coś, co i tak zaraz wyrzucę, plus wpuszczenie cudzego uptime’u do własnego sygnału czerwony/zielony. Raz, ręcznie, do pliku. Zero razy w CI.
Pin modelu robię tak samo, jak pinuję tagi obrazów. W skrypcie mam snapshot gpt-3.5-turbo-0301, a nie alias gpt-3.5-turbo, bo alias przesuwa się pod spodem i wtedy nie umiem powiedzieć, czy wynik za miesiąc różni się od dzisiejszego przez mój prompt, czy przez cichą podmianę modelu. Pin nie daje mi powtarzalności odpowiedzi, bo tej w tym API nie ma. Daje mi jedną zmienną mniej przy porównywaniu dwóch przebiegów.
Dwa dni temu, 13 czerwca, do API weszło function calling razem ze snapshotami gpt-3.5-turbo-0613 i gpt-4-0613: schemat funkcji deklaruje się w polu functions, a model zwraca argumenty w JSON-ie zgodnym z tym schematem. Widzę, po co mi to może być kiedyś przy danych, bo połowa moich odrzuceń to dryf formatu, a nie zła treść. Nie przenoszę na to niczego dzisiaj i nie buduję na tym pipeline’u w tym wpisie. Dwa dni to nie jest doświadczenie, na którym pinuje się narzędzie do generowania wsadu, a parser z poprzedniej sekcji musiałby istnieć tak czy inaczej, bo zgodność ze schematem to nie to samo co zgodność z regułą biznesową.
Plan B na sytuację, w której gpt-3.5-turbo uparcie łamie jedno pole, jest ręczny: wklejam ten sam kontrakt do interfejsu ChatGPT Plus, oglądam dwadzieścia linii oczami i przepisuję to, co przeszło. To jest czynność przy biurku, nie krok w skrypcie i nie pin w CI. Do API gpt-4 mam formularz na waitliście i nic więcej, więc żadna z moich automatyzacji nie może go dziś wołać.
Pułapki, które kosztowały mnie wieczór
Lista jest krótka, bo każdą z tych rzeczy widziałem na własnym wsadzie w ciągu jednego wieczoru.
Halucynowana unikalność. Proszę o trzydzieści unikalnych numerów zamówienia i dostaję dwadzieścia siedem unikalnych plus trzy powtórzenia, przy czym model nie ma żadnego sygnału, że coś jest nie tak. “Unikalny” to warunek, którego on nie umie sprawdzić, bo nie widzi zbioru, tylko własny ostatni token. Zbiór jest po mojej stronie i dlatego alreadyApproved w parserze trzyma również numery z wcześniej zatwierdzonych plików, nie tylko z tej jednej odpowiedzi.
Złamany klucz obcy, podany z pełnym przekonaniem. Adresów spoza listy dostałem kilka i wszystkie były wiarygodne: prawidłowa składnia, imię i nazwisko pasujące do stylu pozostałych, domena, której nie było w promptcie. Insert takiego wiersza wywala się na kluczu obcym, i to jest dobry scenariusz. Zły scenariusz jest wtedy, kiedy ktoś kiedyś “tymczasowo” zdjął constraint na środowisku testowym: wiersz wchodzi, test przechodzi, a sierotę w tabeli podrzędnej znajduje się miesiąc później.
Dane zbyt prawdziwe. Model bardzo chętnie produkuje coś, co wygląda jak prawdziwy człowiek pod prawdziwym adresem w prawdziwej domenie pocztowej. Przy dwudziestu wierszach do przeglądu to widać. Przy dwustu już nie, więc reguła jest twarda i wykonuje ją parser, a nie moja czujność: tylko domeny dokumentacyjne, wszystko inne leci na odrzut. To samo dotyczy kwot i numerów, które wyglądają na skopiowane z prawdziwego systemu.
Locale i separatory. 1249,90 zamiast 1249.90, pl_PL zamiast pl-PL, kwota jako string w cudzysłowie, kwota z trzema cyframi po kropce. Pierwsza z tych rzeczy psuje parsowanie natychmiast i to jest komfortowa sytuacja. Ostatnia przechodzi przez JsonSerializer bez mrugnięcia okiem i wywraca się dopiero na kolumnie decimal(10,2) albo, gorzej, cicho się zaokrągla.
Off-by-one w datach. To jest powód, dla którego mój Order ma trzy pola i żadnego z nich nie jest data. Kiedy poprosiłem o zamówienia “z ostatnich trzydziestu dni”, dostałem 31 czerwca, jedną datę w przyszłości i granicę północy w strefie, której nie ustaliliśmy. Daty liczę w kodzie, względem jawnie podanego punktu w czasie, tak jak w fabryce z 2021 roku. Model nie zna kalendarza, zna kształt daty.
Pewność przy złej treści. Wszystkie powyższe błędy przyszły do mnie bez żadnego zastrzeżenia w odpowiedzi. Nie ma tu sygnału niskiej pewności, na którym dałoby się oprzeć automatyczną bramkę. Dlatego bramka jest deterministyczna i napisana przeze mnie, a model dostaje dokładnie tyle zaufania, ile ma go plik tekstowy od nieznanego autora.
Podsumowanie
Po tygodniu grzebania w tym mam wniosek, który mieści się w jednym zdaniu: gpt-3.5-turbo jest tanim generatorem kandydata na wiersz, a nie źródłem danych testowych.
Rozbijam to na trzy zdania, które chcę móc przeczytać za rok. Chat Completions z rolą systemową trzyma format wyjścia wyraźnie lepiej niż prompt few-shot z 2021 roku, więc mniej czasu schodzi na sklejanie odpowiedzi, i przy dziesięciokrotnie niższej cenie próbowanie przestało być decyzją budżetową. Nic z tego nie zmienia warstwy niżej: unikalność, klucze obce i reguły biznesowe pozostają moje, sprawdzane deterministycznym parserem, a nie zaufaniem do modelu. Źródłem prawdy zostaje schemat po jednej stronie i reset bazy po drugiej, a pomiędzy nimi leży zatwierdzony plik w repozytorium, który przechodzi review jak kod.
Czego z tego nie wynika. Nie wynika, że mam nowe narzędzie w pipeline’ie, bo zestaw testów nadal nie woła żadnego API. Nie wynika, że przestałem potrzebować fabryki z ziarnem: do tysiąca powtarzalnych rekordów, na których stoją asercje, nadal używam fabryki, bo ona jest darmowa, natychmiastowa i identyczna przy każdym uruchomieniu. Nie wynika też, że mam dostęp do gpt-4 w API, bo mam formularz na waitliście, a interfejs Plus to czynność przy biurku.
Podział, który od dziś stosuję domyślnie: dane masowe i powtarzalne z fabryki, kilkadziesiąt “ludzko wyglądających” wierszy do obejrzenia przez człowieka z modelu, izolacja z kontenera, a decyzja o tym, co jest poprawne, z kontraktu zapisanego w kodzie. Właścicielem danych testowych zostaję ja, bo podpisuję się pod plikiem, który wchodzi do repozytorium, a podpisać się można tylko pod czymś, co się przeczytało.
Za miesiąc chcę sprawdzić coś, co wisi obok tego tematu i pojawiło się przy okazji: czy ten sam model umie mi pomóc czytać wynik przebiegu, a nie tylko go zasilać. Migotliwe testy i grupowanie podobnych awarii to problem, w którym mam znacznie więcej danych niż w danych testowych, bo one leżą w logach z każdego builda. Do tego czasu ładniejsze dane nie naprawiają mi procesu. Sprawiają tylko, że wcześniej widać, gdzie schemat był niedopowiedziany.

