Po co testerowi model językowy
O GPT-3 czytam od roku i przez ten rok widziałem głównie dwie rzeczy: dema, w których model pisze wiersz, i dema, w których model udaje rozmówcę. Ani jedno, ani drugie nie jest moim problemem zawodowym. Mój problem zawodowy nazywa się “skąd wziąć dwadzieścia sensownych rekordów użytkownika, żeby ekran listy nie wyglądał jak test1, test2, test3“.
Dlatego kiedy dostałem dostęp do API, nie poszedłem w stronę rozmowy. Poszedłem w stronę endpointu Completions, czyli tekst wchodzi, tekst wychodzi. Interesuje mnie jedno pytanie: czy da się tym wygenerować fixture, który wkładam do repozytorium z testami, i czy ta droga jest tańsza od napisania fabryki danych ręcznie.
Od razu ustawiam oczekiwania, bo inaczej ten wpis brzmiałby jak reklama. To jest eksperyment. Nie przestawiam na to swojego zestawu regresji i nie namawiam nikogo, żeby przestawiał swój.
Czym jest API w czerwcu 2021
OpenAI ogłosiło API 11 czerwca 2020, dokładnie rok temu. Od tamtej pory dostęp jest na zaproszenie: wypełnia się formularz i czeka na swoją kolej. Piszę to wprost na początku, bo jeżeli ktoś chce powtórzyć ten wpis linijka po linijce dzisiaj, to pierwszym krokiem nie jest pip install, tylko wpis na listę oczekujących. Sam czekałem swoje.
Sam interfejs jest zaskakująco prosty i to jest jego najmocniejsza strona. Nie ma formularza z polami, nie ma zestawu osobnych metod na osobne zadania. Jest jedna operacja: wysyłam tekst, dostaję jego przedłużenie. Zadanie opisuję w tym samym tekście, w którym proszę o wynik.
Modele są cztery i różnią się mocą oraz ceną: ada, babbage, curie i davinci. ada jest najszybsza i najtańsza, davinci najmocniejszy i najdroższy. W eksperymencie zostaję przy davinci, bo chcę zobaczyć górną granicę tego, co dziś jest możliwe. Gdyby to miało kiedykolwiek trafić do regularnego użycia, pierwszą rzeczą do sprawdzenia byłoby, czy curie nie wystarcza.
Endpoint, o którym mowa, wygląda tak:
POST https://api.openai.com/v1/engines/davinci/completionsNazwa silnika jest częścią adresu. Zmiana davinci na curie to zmiana jednego słowa w URL-u i to jest w zasadzie cały mechanizm przełączania modelu.
Jedno pojęcie warto wyjaśnić, bo wróci w dalszej części. Praca opisana w artykule “Language Models are Few-Shot Learners” z maja 2020 pokazuje, że model tej klasy uczy się zadania z kilku przykładów podanych wprost w tekście zapytania. Nie douczam modelu, nie mam własnego zbioru treningowego. Pokazuję trzy albo osiem przykładów i proszę o kolejny element tej samej serii. To jest dokładnie ten tryb, który interesuje mnie przy danych testowych.
Setup: klucz, klient openai==0.7.0, pierwsze wywołanie
Klucz trzymam w zmiennej środowiskowej i tylko tam:
$ export OPENAI_API_KEY="sk-..."Trzy zasady, których się przy tym trzymam. Klucz nie trafia do repozytorium, nawet do pliku konfiguracyjnego “tylko na chwilę”. Klucz nie trafia do logów, więc nie ma u mnie print(api_key) ani wypisywania nagłówków zapytania przy debugowaniu. Klucz nie trafia do zmiennych CI, bo w tym eksperymencie CI w ogóle nie woła API, o czym piszę niżej.
Oficjalny klient Pythona jest na PyPI. Przypinam wersję:
$ python --version
Python 3.9.5
$ pip install openai==0.7.00.7.0 została opublikowana 11 czerwca, cztery dni temu. Wcześniej używałem 0.6.4 z maja. Pinuję dokładną wersję z tego samego powodu, dla którego pinuję tagi obrazów: chcę, żeby kolega odtwarzający mój skrypt za miesiąc dostał tę samą bibliotekę, a nie tę, która akurat wyjdzie.
Najprostsze wywołanie, jakie ma sens:
import os
import openai
openai.api_key = os.environ["OPENAI_API_KEY"]
response = openai.Completion.create(
engine="davinci",
prompt="Lista trzech miast w Polsce:\n1.",
max_tokens=40,
temperature=0.7,
stop=["\n\n"],
)
print(response["choices"][0]["text"])Cztery parametry, które od tej pory ustawiam świadomie w każdym wywołaniu.
engine to wybór modelu. max_tokens to twardy limit długości odpowiedzi. Nie jest to limit znaków ani słów, tylko tokenów, czyli kawałków tekstu, na jakie model dzieli wejście i wyjście. Ustawiam go zawsze, bo bez niego model potrafi mielić dalej, niż potrzebuję, a płacę za to, co wygeneruje.
temperature steruje losowością. Przy 1.0 dostaję kreatywność, której przy danych testowych zwykle nie chcę. Przy 0 odpowiedzi są najbardziej zachowawcze i najbardziej powtarzalne, choć nie umiem tego nazwać gwarancją determinizmu, bo to nie jest kontrakt, tylko obserwacja z kilkudziesięciu wywołań.
stop to sekwencja, po której model ma przestać. To najbardziej niedoceniany parametr w całym API. Bez niego model po wygenerowaniu mojego obiektu JSON leci dalej i dopisuje komentarz, kolejny nagłówek albo zdanie o tym, jak przydatne są dane testowe. Z stop=["\n\n"] kończy tam, gdzie chcę.
Wariant dla .NET, bo w projektach klienckich pracuję na .NET 5. Oficjalnego SDK dla C# nie ma, więc idę wprost HttpClient-em:
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
Environment.GetEnvironmentVariable("OPENAI_API_KEY"));
var payload = new
{
prompt = prompt,
max_tokens = 160,
temperature = 0.2,
stop = new[] { "\n\n" }
};
var body = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
var response = await http.PostAsync(
"https://api.openai.com/v1/engines/davinci/completions",
body);
response.EnsureSuccessStatusCode();To jest zwykły POST z nagłówkiem Authorization: Bearer. Nazwy pól w ciele zapytania są takie same jak argumenty klienta Pythona, więc jedno tłumaczy drugie. Zwracam uwagę na EnsureSuccessStatusCode i na to, że nie loguję ani nagłówka, ani całego obiektu żądania. Klucz w logu buildu to incydent bezpieczeństwa, a nie niedogodność.
Prompt few-shot na fixture JSON
Teraz sedno. Nie proszę modelu o “wygeneruj dane testowe”, bo dostaję wtedy wypracowanie. Proszę o dopisanie kolejnego elementu do serii, którą sam zacząłem. Prompt jest listą gotowych obiektów, po jednym w linii, urwaną w połowie kolejnego:
Lista syntetycznych użytkowników testowych sklepu, po jednym obiekcie JSON w linii.
{"email": "anna.kowalska@example.com", "locale": "pl-PL", "displayName": "Anna Kowalska", "newsletter": true}
{"email": "jan.novak@example.org", "locale": "cs-CZ", "displayName": "Jan Novak", "newsletter": false}
{"email": "boundary.case.with.a.very.long.local.part@example.com", "locale": "en-GB", "displayName": "A", "newsletter": false}
{"email":Cztery decyzje są tu celowe.
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.
Format to jeden obiekt w linii, a nie tablica. Tablica wymaga od modelu domknięcia nawiasu i pilnowania przecinków przez całą odpowiedź, a to jest dokładnie ta klasa błędu, która psuje mi parsowanie. Linia po linii jest odporniejsza i łatwiej ją uciąć.
Trzeci przykład jest brzydki i taki ma być. Bardzo długa część lokalna adresu, displayName o długości jednego znaku. Pokazuję modelowi, że w tej serii mieszczą się przypadki brzegowe, bo inaczej dostanę dwadzieścia wariantów Anny Kowalskiej.
Prompt urywa się na {"email":. Model nie ma wtedy miejsca na wstęp typu “Oczywiście, oto kolejny użytkownik”. Jedyne sensowne przedłużenie tekstu to reszta obiektu.
Odpowiedź nie trafia od razu do repozytorium. Trafia najpierw do walidacji:
import json
REQUIRED = {"email", "locale", "displayName", "newsletter"}
def parse_line(raw: str) -> dict:
record = json.loads(raw)
missing = REQUIRED - record.keys()
if missing:
raise ValueError(f"brak pól: {sorted(missing)}")
extra = record.keys() - REQUIRED
if extra:
raise ValueError(f"pola spoza kontraktu: {sorted(extra)}")
if "@" not in record["email"] or not record["email"].endswith(
("example.com", "example.org")
):
raise ValueError(f"email spoza domen dokumentacyjnych: {record['email']}")
if not isinstance(record["newsletter"], bool):
raise ValueError("newsletter nie jest bool")
return recordTo nie jest defensywność na wszelki wypadek. Każda z tych czterech asercji odpaliła mi się w praktyce w ciągu pierwszej godziny zabawy. W projekcie .NET to samo robię deserializacją do konkretnej klasy z JsonSerializer i ustawieniem, które odrzuca nieznane pola, zamiast je cicho ignorować.
Co działa, a co psuje testy
Zacznę od tego, co wyszło dobrze, bo wyszło.
Model naprawdę trzyma się kształtu serii. Dostaję poprawne obiekty z sensownymi kombinacjami imienia, adresu i locale, w tym takie, na które sam bym nie wpadł: nazwiska z diakrytykami, których nie ma w polskim alfabecie, i locale regionów, o których zapomniałem. Do wypełnienia listy na ekranie, do przeglądu wizualnego i do prezentacji dla zespołu to jest materiał lepszy niż test1, test2, test3, i powstaje w kilkanaście sekund zamiast w pół godziny.
Teraz lista rzeczy, które psują test, jeżeli się na to nie umówić z góry.
Niedeterminizm. Dwa identyczne wywołania dają dwa różne wyniki. To nie jest usterka, tylko cecha. Jeżeli test asertuje na treści rekordu, a rekord powstaje w trakcie przebiegu, to test jest z definicji migotliwy i żadne temperature=0 mnie przed tym nie uratuje.
Halucynowany format. Model potrafi dorzucić pole, którego nie było w przykładach, na przykład phone albo age, bo przy użytkowniku pasuje. Potrafi napisać pl_PL z podkreśleniem zamiast pl-PL. Potrafi wstawić "newsletter": "true" jako string. Każde z tych trzech przechodzi przez json.loads bez mrugnięcia okiem. Pierwsze i trzecie łapie walidacja wyżej, ale pl_PL jest dla niej poprawnym stringiem, więc na locale trzeba dołożyć osobne sprawdzenie, jeżeli nie chcę, żeby wybuchło dopiero na asercji albo, gorzej, w warstwie aplikacji.
Koszt. Rozliczenie jest za tokeny i liczy się cały ruch, czyli mój prompt razem z odpowiedzią. Prompt few-shot z ośmioma przykładami jest wysyłany w każdym wywołaniu. Nie podaję tu stawek, bo cennik jest rzeczą, którą trzeba sprawdzić u źródła w dniu, w którym się z niego korzysta. Podaję wniosek: generowanie danych w pętli przy każdym uruchomieniu zestawu testów to płacenie co przebieg za coś, co i tak zaraz wyrzucę.
Limity i dostępność. To jest usługa zewnętrzna po HTTP, z limitem zapytań i z możliwością bycia niedostępną. Wpuszczenie jej do przebiegu testów oznacza, że czerwony wynik może już nie znaczyć “aplikacja jest zepsuta”, tylko “cudze API miało zły dzień”. To jest ten sam problem, od którego uciekałem mockując API partnera WireMockiem, i byłoby dość zabawne wpuścić go z powrotem drzwiami, którymi właśnie go wyprowadziłem.
Uprzedzenia modelu. W ogłoszeniu API OpenAI wprost pisze o ograniczeniach i uprzedzeniach modeli tej rodziny. Przy generowaniu ludzkich imion, nazwisk i danych demograficznych to nie jest uwaga akademicka. Wygenerowany zestaw użytkowników potrafi być mniej różnorodny, niż wygląda na pierwszy rzut oka, więc nie traktuję go jako reprezentatywnej próbki populacji.
Wniosek z tego wszystkiego jest jeden i jest prosty. API woła się raz, ręcznie, przy biurku. Wynik przechodzi walidację, potem czyta go człowiek, a zatwierdzone rekordy lądują w pliku w repozytorium:
fixtures/
users.approved.jsonTest czyta plik. Test nie zna klucza API i nie ma prawa wyjść do sieci. Plik podlega review jak każdy inny kod. Dzięki temu wynik przebiegu zależy od zawartości repozytorium, a nie od losowości modelu i od czyjegoś uptime’u.
Ostatnia zasada, najważniejsza z całego wpisu: do promptu nie wkleja się danych produkcyjnych. Ani jednego prawdziwego adresu, ani jednego prawdziwego nazwiska klienta, ani fragmentu dumpa z bazy. Pokusa jest realna, bo “model podchwyci nasz format” brzmi rozsądnie. To wysyłanie cudzych danych osobowych do zewnętrznej usługi i żaden format tego nie jest wart. Przykłady w moim promptcie są wymyślone od zera i osadzone w domenach dokumentacyjnych.
Kiedy nie używać GPT-3
Po dwóch wieczorach zabawy mam całkiem konkretną listę sytuacji, w których to jest zły wybór.
Kiedy potrzebuję tysiąca rekordów, a nie dwudziestu. Fabryka z ziarnem generuje je lokalnie, w milisekundach, za darmo i identycznie przy każdym uruchomieniu. W .NET używam do tego Bogusa, w Pythonie równie dobrze sprawdza się prosta funkcja z random i ustawionym ziarnem. Sto rekordów z fabryki to jedna linijka pętli. Sto rekordów z API to kilkanaście płatnych wywołań, w każdym pełny prompt liczony od nowa, i sto okazji do halucynacji formatu.
Kiedy dane mają być powtarzalne. To jest ta sama myśl co pinowanie tagu obrazu w Dockerze dla QA. Zestaw danych, na którym opiera się asercja, ma być tą samą rzeczą w styczniu i w marcu. Ziarno mi to daje, model nie.
Kiedy wymagana jest zgodność z regułami biznesowymi. Poprawna suma kontrolna numeru rachunku, kod pocztowy pasujący do kraju, spójność między locale a formatem adresu. Model wygeneruje coś, co wygląda poprawnie, ale nie liczy sum kontrolnych. Tu potrzebny jest generator, który zna regułę, a nie taki, który zna kształt.
Osobno chcę wyprostować jedno nieporozumienie, bo już je usłyszałem. Kiedy w kwietniu opisywałem podnoszenie bazy z poziomu testu, rozwiązywałem problem infrastruktury: skąd wziąć czystą instancję MSSQL i jak ją posprzątać. To jest zupełnie inna warstwa niż ta z dzisiaj. Testcontainers daje mi pustą bazę. Nie ma zdania na temat tego, co ma być w rekordzie. GPT-3 ma zdanie na temat treści rekordu i nie ma żadnego na temat tego, gdzie ten rekord zamieszka. Te dwie rzeczy się nie zastępują i nie konkurują ze sobą.
Podsumowanie
Zostaję z takim podziałem. Do masowych, powtarzalnych danych, na których stoją asercje, używam fabryki z ziarnem. Do niewielkich, “ludzko wyglądających” zestawów, które i tak ogląda człowiek, dopuszczam GPT-3 jako narzędzie przy biurku, z wynikiem zatwierdzonym i wrzuconym do repozytorium. Do infrastruktury pod te dane mam kontenery.
Czy to jest nowy standard danych testowych u mnie? Nie. To jest eksperyment, który dał ciekawy wynik i który kosztował mnie dwa wieczory oraz drobne rozliczenie za tokeny. Dostęp jest za waitlistą, wynik jest niedeterministyczny, a każdy wygenerowany rekord i tak musi przejść przez walidację i przez czyjeś oczy, zanim wyląduje w repozytorium. To bardzo dużo warunków jak na coś, co miało oszczędzić czas.
Zostaje mi za to pytanie szersze od samego narzędzia i chodzi za mną od dłuższego czasu: skąd w ogóle biorą się dane w moich zestawach testowych, kto jest ich właścicielem i co się z nimi dzieje między przebiegami. Generator to tylko jedno z możliwych źródeł, obok fabryki, snapshotu i anonimizowanego dumpa. Chcę to na jesieni poukładać w jedną, spójną strategię i opisać osobno.
Zanim jednak dołożę kolejne narzędzie do zestawu, wracam do pytania, które zadaję sobie za każdym razem: gdzie to się mieści w całym procesie testowym. Ładniejsze dane nie naprawiają procesu. Sprawiają tylko, że wcześniej widać, co w nim nie działa.

