Coraz więcej odpowiedzialności za wyszukiwanie i interpretowanie informacji powierzamy sztucznej inteligencji. Przez dobrze już znane okienko ulubionego czatu pytamy o coraz bardziej skomplikowane tematy: analizę prawną, kwestie księgowe czy poradę, co może oznaczać dziwny stukot w samochodzie.
Modele językowe posiadają ogromną ilość wiedzy zdobytej podczas treningu, ale nie są przecież encyklopedią, w której każda informacja została zapisana w oryginalnej postaci. Wiedza jest rozproszona w parametrach modelu, a jej odtworzenie nie zawsze jest dokładne.
Dlatego współczesne aplikacje AI coraz częściej korzystają również z wyszukiwarek internetowych, zewnętrznych narzędzi i baz wiedzy, dostarczając modelowi informacje bezpośrednio w trakcie rozmowy. Ten sprytny mechanizm nadal może jednak nie wystarczyć, ponieważ dane w sieci bywają błędne, nieaktualne lub przedstawione w sposób, którego model nie zinterpretuje poprawnie.
Nic dziwnego – większość treści powstawała z myślą o ludziach, a nie o maszynach. Dlatego nawet bardzo zaawansowany model, który świetnie radzi sobie z programowaniem czy złożonymi problemami matematycznymi, może pomylić się przy odczytywaniu planu lekcji z tabeli na stronie pobliskiego liceum. Twórcy modeli stale poprawiają ich zdolność rozumienia kontekstu, rozpoznawania wiarygodnych informacji i radzenia sobie z niejednoznacznym tekstem.
Mimo to nadal zdarza się, że model, mający wszystkie potrzebne dane pod ręką, wyciąga z nich błędne wnioski, pomija drobny warunek albo po prostu wymyśla odpowiedź.
Problem zauważył już pewnie każdy, kto korzystał z AI nieco dłużej. Dlatego przy wdrażaniu firmowych baz wiedzy zwykle zachowujemy ostrożność i wymagamy, aby istotne informacje otrzymane od modelu były dodatkowo weryfikowane. W przeciwnym razie system pozostaje przede wszystkim pomocnikiem dostarczającym wstępny research, a do obiecywanych w reklamach wirtualnych pracowników i wszechwiedzących asystentów nadal jest mu daleko.
Czy to oznacza, że AI nigdy nie zastąpi człowieka? Tego nie wiem. Mam natomiast pomysł, co możemy zrobić, aby sprawdzić, czy nasz system poprawnie interpretuje firmowe procedury, wykrywać problemy jeszcze przed wdrożeniem i ograniczyć liczbę niespodzianek po kolejnej aktualizacji dokumentacji.
Możemy do tego wykorzystać dobrze znane z testowania API kasety VCR, które pozwalają nagrywać odpowiedzi modeli i odtwarzać je podczas kolejnych testów. Podczas nagrywania sprawdzimy, czy model poprawnie interpretuje procedury, a później wykorzystamy zaakceptowane odpowiedzi do regresji naszego kodu.
Co więcej, możemy zrobić to stosunkowo tanio, bez odpytywania modeli przy każdym uruchomieniu CI/CD i bez przechowywania kluczy API w zwykłych testach regresyjnych.
Założenie
Załóżmy, że przygotowujemy bota obsługującego wewnętrzny helpdesk. Firma posiada procedury dotyczące resetowania haseł, konfiguracji VPN, drukowania dokumentów i zgłaszania problemów ze sprzętem. Chcemy, aby pracownik mógł opisać swój problem własnymi słowami, a bot znalazł odpowiednią procedurę i wskazał mu, co powinien zrobić. Jeżeli sprawa wymaga interwencji administratora, bot powinien przekazać zgłoszenie do właściwej osoby.
W teorii wystarczy udostępnić modelowi dokumentację i przygotować odpowiedni prompt systemowy. W praktyce musimy jeszcze wiedzieć, czy bot prawidłowo rozumie zasady, czy potrafi wybrać właściwą procedurę i czy po zmianie jednego dokumentu nie zacznie udzielać błędnych odpowiedzi na pytania, z którymi wcześniej radził sobie bez problemu.
Będziemy więc potrzebowali dwóch uzupełniających się sposobów testowania:
- rzeczywistych wywołań modelu, pozwalających oceniać jakość jego odpowiedzi
- oraz powtarzalnych testów aplikacji na zapisanych odpowiedziach.
Do tego drugiego zadania wykorzystamy VCR.
Przypadki testowe
Do naszej bazy wiedzy przygotujmy przypadki testowe. Możemy wygenerować je na podstawie dokumentacji za pomocą AI, ale niekoniecznie będą one odpowiadały temu, o co rzeczywiście pytają użytkownicy. Model prawdopodobnie dobrze poradzi sobie z przygotowaniem pytań dotyczących informacji, które znalazł w procedurach.
Znacznie trudniej przewidzieć pytania zawierające niedopowiedzenia, literówki, potoczne określenia albo kilka problemów opisanych jednocześnie.
Pracownik rzadko napisze przecież: „Proszę o przedstawienie procedury diagnostycznej dotyczącej lokalnej kolejki wydruku i komunikacji ze współdzieloną drukarką”.
Znacznie bardziej prawdopodobne, że zapyta: „Drukarka drukuje stronę testową z panelu, ale dokumenty z laptopa stoją w kolejce. Co mam zrobić?”.
Człowiek znający firmowe procedury zauważy, że skoro urządzenie potrafi samodzielnie wydrukować stronę testową, a dokumenty zatrzymują się na komputerze, warto rozpocząć diagnostykę od kolejki drukowania, sterownika albo połączenia z drukarką. Model musi zauważyć dokładnie ten sam szczegół, zamiast automatycznie uznać, że urządzenie jest zepsute i zalecić jego ponowne uruchomienie. Taka odpowiedź mogłaby wyglądać rozsądnie, ale nie byłaby zgodna z kolejnością diagnostyki opisaną w naszej procedurze.
Dobre praktyki
Dlatego dobrą praktyką jest zbieranie rzeczywistych pytań użytkowników, na przykład z istniejącego systemu zgłoszeniowego, komunikatora lub historii wyszukiwania w firmowej bazie wiedzy. Możemy też zapisywać pytania kierowane do nowego bota, oczywiście po odpowiedniej anonimizacji i zgodnie z zasadami przetwarzania danych w organizacji.
Z czasem uzyskamy zbiór pokazujący nie tylko to, co znajduje się w dokumentacji, ale przede wszystkim to, jak pracownicy próbują z niej korzystać. Do takich pytań warto dołożyć przypadki wygenerowane przez model:
- inne sposoby sformułowania tego samego problemu,
- pytania z brakującymi informacjami,
- sytuacje wymagające połączenia kilku procedur,
- przypadki, w których bot powinien poprosić o doprecyzowanie albo przekazać sprawę człowiekowi.
Wygenerowane scenariusze są jednak propozycjami do sprawdzenia, a nie automatycznie poprawnymi testami. Model może nie zauważyć przypadków, których nie opisano w dokumentacji, dlatego rzeczywiste zgłoszenia i wiedza pracowników helpdesku nadal są potrzebne.
Każdy przypadek testowy powinien mieć pytanie, zestaw informacji dostępnych dla modelu oraz oczekiwany rezultat. Nie zawsze musimy porównywać dosłowną treść odpowiedzi, ponieważ model może przekazać tę samą informację na wiele sposobów.
Znacznie lepiej sprawdzać konkretne decyzje, które podejmuje nasza aplikacja. W przypadku drukarki, która wykonuje wydruk testowy z własnego panelu, ale nie drukuje dokumentów wysyłanych z laptopa, oczekujemy rozpoznania problemu po stronie komputera lub komunikacji z urządzeniem i rozpoczęcia diagnostyki od kolejki drukowania. Nie ma znaczenia, czy model napisze „sprawdź kolejkę wydruku”, czy „otwórz listę dokumentów oczekujących na wydruk”.
Możemy wymagać od modelu ustrukturyzowanej odpowiedzi, zawierającej kategorię zgłoszenia i proponowaną akcję. Wtedy test nie musi rozstrzygać, które zdanie brzmi ładniej. Sprawdza, czy system podjął właściwą decyzję.
Przykładowy scenariusz
Przykładowy scenariusz możemy zapisać w pliku PRINT-003.yaml. Dla uproszczenia umieścimy w nim zarówno pytanie, jak i niewielki fragment firmowej procedury. W większej aplikacji dokumentacja prawdopodobnie będzie przechowywana osobno, dlatego scenariusze warto wiązać z identyfikatorami i wersjami wykorzystywanych dokumentów.
id: PRINT-003
procedure:
id: printing
version: 3
text: >
Jeśli drukarka drukuje stronę testową ze swojego panelu,
ale dokumenty z komputera pozostają w kolejce,
najpierw sprawdź kolejkę wydruku. Jeśli to nie pomoże,
sprawdź połączenie komputera z drukarką.
Jeśli drukarka nie drukuje nawet strony testowej,
zacznij diagnostykę od samego urządzenia.
question: >
Drukarka drukuje stronę testową z panelu,
ale dokumenty z laptopa stoją w kolejce.
Co mam zrobić?
expected:
category: client_printing_issue
action: check_print_queue
Przygotowując takie przypadki, możemy odkryć jeszcze jeden problem.
Firmowe procedury, nawet jeśli są poprawne z punktu widzenia ich autorów, nie zawsze są jednoznaczne. Jedna może mówić, aby przy problemach z drukowaniem ponownie uruchomić drukarkę, a druga zalecać sprawdzenie kolejki i sterowników, jeżeli urządzenie potrafi wykonać wydruk testowy.
Pracownik helpdesku prawdopodobnie domyśli się, że w opisanym przypadku należy zacząć od drugiej procedury. Model nie musi tego wiedzieć, jeżeli nigdzie nie zapisaliśmy takiej zależności.
Testy pomagają więc nie tylko sprawdzić jakość odpowiedzi, ale również znaleźć miejsca, w których sama baza wiedzy wymaga doprecyzowania. Gdy zauważymy, że różne modele inaczej interpretują ten sam przypadek, otrzymujemy dodatkowy sygnał do przeanalizowania procedury. Możemy wówczas sprawdzić, czy problem wynika z niejednoznacznego dokumentu, sposobu wyszukiwania informacji, promptu systemowego czy możliwości konkretnego modelu.
W ten sposób poprawiamy nie tylko testy, ale również jakość wiedzy, na której będzie pracował nasz asystent.
Kasety VCR
Jeżeli VCR kojarzy się z magnetowidem i kasetami VHS, to dobre skojarzenie. Skrót pochodzi od Video Cassette Recorder, a w naszym przypadku kaseta będzie zawierała zapisaną komunikację z API. Tak jak kiedyś mogliśmy nagrać transmisję telewizyjną i obejrzeć ją później, tak samo możemy zapisać odpowiedź otrzymaną od modelu i odtworzyć ją podczas następnego testu.
Pomysł nagrywania komunikacji z API nie jest niczym nowym. Istnieją biblioteki rozwijane od lat, które pozwalają przechwycić żądanie HTTP wraz z odpowiedzią, zapisać je w pliku, a następnie odtworzyć podczas kolejnego uruchomienia testu. Skoro większość modeli udostępniana jest przez API, możemy wykorzystać dokładnie ten sam mechanizm. Nie potrzebujemy do tego specjalnego modelu ani dostawcy oferującego osobną funkcję testowania. Wystarczy, że potrafimy kontrolować komunikację HTTP wykonywaną przez naszą aplikację.
Działanie
Podczas nagrywania test wysyła do API modelu pytanie użytkownika, odpowiednie fragmenty firmowej dokumentacji i parametry żądania. Otrzymana odpowiedź trafia do kasety, a aplikacja przetwarza ją tak samo jak podczas normalnej pracy. Możemy od razu sprawdzić, czy model poprawnie zinterpretował procedurę, a zaakceptowaną odpowiedź zachować do kolejnych testów. Przy późniejszych uruchomieniach biblioteka rozpoznaje wcześniej zapisane żądanie i zwraca nagraną odpowiedź bez kontaktowania się z dostawcą.
Możemy więc uruchamiać ten sam test wielokrotnie, nie płacąc ponownie za inferencję i nie zastanawiając się, czy model tym razem użyje innego sformułowania. Powtarzalność dotyczy tutaj odtworzonej komunikacji. Dzięki niej sprawdzamy, czy nasz kod nadal prawidłowo przetwarza wcześniej zweryfikowane odpowiedzi, podczas gdy osobne testy wykonywane na rzeczywistym API pozwalają oceniać aktualne zachowanie modelu. Ten podział jest korzystny również podczas debugowania, ponieważ łatwiej ustalić, czy problem powstał w aplikacji, czy podczas generowania odpowiedzi.
Ustalenie dopasowania kasety
Ważne jest również ustalenie, kiedy kaseta nadal pasuje do wykonywanego testu. Nie wystarczy zapamiętać adresu API, ponieważ wszystkie pytania mogą być wysyłane pod ten sam endpoint.
Musimy uwzględnić przynajmniej treść żądania:
- prompt systemowy,
- pytanie użytkownika,
- fragmenty firmowych procedur,
- identyfikator modelu,
- parametry wpływające na generowanie odpowiedzi.
W API typu Chat Completions są one zwykle przesyłane razem w JSON-ie, lecz w niektórych integracjach część konfiguracji wynika także z adresu endpointu. Możemy obliczyć skrót, czyli fingerprint, z uporządkowanej reprezentacji tych danych i wykorzystać go do identyfikacji nagrania. Dzięki temu zmiana pytania lub procedury nie zostanie przypadkowo obsłużona odpowiedzią przygotowaną dla poprzedniej wersji.
W bardziej rozbudowanym rozwiązaniu warto zapisywać również wersję źródła wiedzy, konfigurację modelu i informacje o tym, kto zatwierdził nagraną odpowiedź. Nie każda techniczna różnica w serializacji JSON powinna jednak oznaczać zmianę scenariusza. Biblioteki VCR mogą porównywać przetworzoną treść żądania, a przy własnym mechanizmie fingerprintów możemy dodatkowo zadbać o jej kanoniczną reprezentację. Istotne jest, aby nie ignorować parametrów, które rzeczywiście wpływają na działanie modelu.
Ponowne nagranie kasety
Jeżeli zmieniła się treść procedury albo pytanie w teście, odpowiednią kasetę trzeba nagrać ponownie. Nie robiłbym tego jednak automatycznie podczas zwykłego uruchomienia CI/CD. Brak pasującej kasety powinien oznaczać błąd testu, a nie niejawne wysłanie zapytania do płatnego API.
Nagrywanie uruchamiamy świadomie, w osobnym procesie, w którym mamy dostęp do klucza API, możemy obejrzeć nowe odpowiedzi i zdecydować, czy nadają się do zatwierdzenia. Nowe kasety najlepiej zapisywać najpierw w katalogu roboczym; jeśli model po zmianie procedury zacznie popełniać błędy, poprzednie zatwierdzone nagrania pozostaną nienaruszone.
Dla ważniejszych przypadków warto również wykonać kilka rzeczywistych prób i sprawdzić, jak często model wybiera poprawną decyzję. Jedna udana odpowiedź nie mówi nam jeszcze, czy model równie dobrze poradzi sobie z kolejnymi pytaniami tego samego rodzaju. Dopiero po sprawdzeniu wyników zapisujemy zatwierdzone kasety razem z testami.
W codziennej pracy otrzymujemy w ten sposób prosty proces:
- przygotowujemy scenariusz,
- wykonujemy nagranie,
- sprawdzamy odpowiedź,
- zatwierdzamy kasetę i wykorzystujemy ją podczas regresji.
Gdy później zmienimy prompt, model albo dokumentację, brak dopasowania lub wykryta zmiana zależności będzie sygnałem do przeprowadzenia nowej ewaluacji.
Błędna interpretacja
Jeżeli po nagraniu odpowiedzi okaże się, że model źle zinterpretował firmową procedurę, otrzymujemy informację, którą możemy wykorzystać podczas poprawiania systemu. Być może dokumentacja jest niejednoznaczna, być może brakuje w niej istotnego warunku albo model nie otrzymał odpowiedniego fragmentu. Możemy poprawić procedurę, ponownie wykonać test i porównać wynik.
Co ciekawe, taka praca może prowadzić również do obniżenia kosztów działania gotowego systemu. Dobrze przygotowana baza wiedzy, jasne reguły pierwszeństwa i odpowiednio ograniczone zadanie mogą pozwolić tańszemu modelowi poradzić sobie z pytaniami, dla których wcześniej potrzebowaliśmy znacznie droższego rozwiązania.
Możemy to sprawdzić, uruchamiając wspólne scenariusze na kilku rodzinach modeli i porównując odsetek poprawnych odpowiedzi, liczbę błędów oraz koszt wykonania zadania. Warto uwzględnić modele o różnej cenie, wielkości i sposobie trenowania, ponieważ ich zachowanie może być odmienne nawet przy identycznych danych wejściowych. Różnice mogą dotyczyć rozumienia języka polskiego, lokalnych pojęć, interpretowania formalnych instrukcji czy obsługi ustrukturyzowanych odpowiedzi.
Nie zakładałbym z góry, który model poradzi sobie najlepiej z naszą dokumentacją. Lepiej to po prostu zmierzyć.
Przykłady bibliotek – Python i .NET
Do przygotowania takiego rozwiązania nie musimy od razu budować własnego frameworka.
- W Pythonie możemy wykorzystać VCR.py, dostępne jako pakiet vcrpy, które przechwytuje komunikację wykonywaną przez obsługiwanych klientów HTTP i zapisuje ją w plikach YAML. Działa niezależnie od frameworka testowego, więc równie dobrze możemy używać go z pytest, jak i z innym narzędziem.
- W .NET podobny efekt uzyskamy za pomocą WireMock.Net, uruchamiając lokalny serwer proxy.
Różnica jest istotna: VCR.py przechwytuje żądanie naszego klienta, natomiast WireMock.Net odbiera je na osobnym adresie i podczas nagrywania przekazuje dalej do dostawcy API. W obu przypadkach podczas zwykłej regresji aplikacja otrzymuje wcześniej zapisaną odpowiedź, bez kontaktowania się z zewnętrznym modelem.
Python
Zacznijmy od Pythona. Dotychczasowe testy moglibyśmy napisać jako pojedynczą funkcję wykonującą request i sprawdzającą JSON, ale nie byłaby to jeszcze sensowna demonstracja regresji aplikacji. Jeżeli cały prompt, parser i wywołanie HTTP znajdą się w teście, zmiana właściwego kodu helpdesku pozostanie dla niego niewidoczna.
Dlatego wydzielmy krótką funkcję aplikacyjną classify_ticket() do pliku helpdesk.py. Odpowiada ona za zbudowanie żądania z aktualnego promptu i podanej procedury, wykonanie połączenia i walidację odpowiedzi. Dopiero test wywoła tę funkcję i porówna wynik z oczekiwaniami zapisanymi w PRINT-003.yaml.
W przykładzie wykorzystujemy endpoint Chat Completions, a model domyślnie ustawiamy na gpt-6-luna z jawnym reasoning_effort="none". To przykład konkretnej konfiguracji, a nie obietnica, że alias modelu będzie zawsze oznaczał tę samą wersję.
Plik helpdesk.py:
"""Minimalny fragment kodu aplikacji, niezależny od pytest i VCR."""
import json
import os
import requests
from jsonschema import validate
API_URL = "https://api.openai.com/v1/chat/completions"
MODEL = os.getenv("HELPDESK_MODEL", "gpt-6-luna")
RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["client_printing_issue", "printer_hardware_issue", "other"],
},
"action": {
"type": "string",
"enum": ["check_print_queue", "check_printer", "ask_for_details"],
},
},
"required": ["category", "action"],
"additionalProperties": False,
}
SYSTEM_PROMPT = (
"Klasyfikuj problem na podstawie dołączonej procedury. "
"Wybierz pierwszy właściwy krok diagnostyczny. "
"Odpowiedz JSON-em zgodnym z otrzymanym schematem."
)
def classify_ticket(question: str, procedure: dict) -> dict:
"""Zwróć decyzję asystenta po wywołaniu rzeczywistego klienta HTTP."""
prompt = (
f"{SYSTEM_PROMPT}\n"
f"Procedura: {procedure['id']}, wersja {procedure['version']}\n"
f"{procedure['text']}"
)
response = requests.post(
API_URL,
headers={
"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY') or 'VCR_REPLAY'}"
},
json={
"model": MODEL,
"reasoning_effort": "none",
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": question},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "helpdesk_decision",
"strict": True,
"schema": RESPONSE_SCHEMA,
},
},
},
timeout=30,
)
response.raise_for_status()
choice = response.json()["choices"][0]
if choice.get("finish_reason") != "stop":
raise ValueError("Odpowiedź modelu jest niekompletna")
message = choice["message"]
if message.get("refusal"):
raise ValueError("Model odmówił odpowiedzi")
result = json.loads(message["content"])
validate(instance=result, schema=RESPONSE_SCHEMA)
return result
Kluczowym szczegółem jest to, że prompt oraz sposób interpretowania odpowiedzi należą do kodu aplikacji, a nie do testu. Jeśli programista przypadkowo zmieni SYSTEM_PROMPT, zapytanie HTTP będzie inne i dotychczasowa kaseta przestanie pasować. Jeżeli zmieni sposób odczytywania choices[0] lub walidowania wyniku, test wykona nową wersję tego kodu na tej samej nagranej odpowiedzi i może wykryć regresję.
W schemacie wymieniliśmy dozwolone kategorie i akcje, więc model nie musi zgadywać nazw naszych wewnętrznych identyfikatorów. Structured Outputs pomaga uzyskać JSON zgodny z obsługiwanym podzbiorem JSON Schema, ale pozostawiamy walidację również po stronie aplikacji, ponieważ jest elementem jej kontraktu. Prawidłowy format nadal nie gwarantuje właściwego wyboru procedury.
Drugi plik, tests/test_helpdesk.py, zawiera sam scenariusz regresyjny i konfigurację kaset:
"""Test regresyjny: VCR zastępuje API, nie testowany kod aplikacji."""
import os
from pathlib import Path
import vcr
import yaml
from helpdesk import classify_ticket
ROOT = Path(__file__).resolve().parent
CASE = yaml.safe_load((ROOT / "scenarios/PRINT-003.yaml").read_text(encoding="utf-8"))
RECORDING = os.getenv("VCR_RECORD") == "1"
CASSETTE = ROOT / "cassettes" / ("_pending" if RECORDING else "") / f"{CASE['id']}.yaml"
if RECORDING:
CASSETTE.parent.mkdir(parents=True, exist_ok=True)
def sanitize_response(response):
if response["status"]["code"] != 200:
return None
headers = response.get("headers", {})
for name in list(headers):
if name.lower() in {"set-cookie", "openai-organization", "openai-project", "x-request-id"}:
del headers[name]
return response
recorder = vcr.VCR(
record_mode="all" if RECORDING else "none",
match_on=["method", "uri", "body"],
filter_headers=["authorization"],
before_record_response=sanitize_response,
record_on_exception=False,
drop_unused_requests=RECORDING,
decode_compressed_response=True,
)
@recorder.use_cassette(str(CASSETTE))
def test_helpdesk_printing_problem():
if RECORDING:
assert os.getenv("OPENAI_API_KEY"), "Nagrywanie wymaga OPENAI_API_KEY"
result = classify_ticket(CASE["question"], CASE["procedure"])
assert result == CASE["expected"]
Do uruchomienia przykładu potrzebujemy Pythona 3.10+ oraz pakietów requests, pytest, PyYAML, jsonschema i vcrpy==8.4.0. Wersja VCR.py jest tutaj istotna. W starszych wydaniach połączenie ponownego nagrywania w trybie all z drop_unused_requests=True mogło zapisać pustą kasetę; problem naprawiono w wydaniu 8.4.0. W trybie VCR_RECORD=1 test zapisuje rezultat do tests/cassettes/_pending/PRINT-003.yaml.
Dopiero po sprawdzeniu odpowiedzi i przejrzeniu pliku przenosimy go do zatwierdzonego katalogu tests/cassettes. Codzienne CI uruchamiamy bez VCR_RECORD, z record_mode="none", a więc bez możliwości niejawnego dogrania brakującej interakcji. Jeśli prompt systemowy, procedura, model lub istotny parametr żądania ulegną zmianie, test nie odnajdzie pasującego nagrania.
Jeżeli zmienimy parser lub logikę obsługi odpowiedzi, nadal otrzyma zapisany JSON i sprawdzi aktualny kod aplikacji. record_on_exception=False chroni przed utrwaleniem nieudanego przebiegu, ale nie zastępuje ręcznego zatwierdzania nowych kaset.
Zwróćmy też uwagę na czyszczenie nagrań. filter_headers usuwa wskazany nagłówek żądania, natomiast funkcja sanitize_response usuwa wybrane nagłówki odpowiedzi i pomija nieudane statusy HTTP. decode_compressed_response=True pozwala zapisać część skompresowanych odpowiedzi w czytelnej postaci; przy Brotli potrzebna jest dostępna obsługa tego formatu.
To nadal nie wystarczy do automatycznej anonimizacji dokumentacji ani pytań użytkowników.
Przed zapisaniem kasety w repozytorium trzeba przejrzeć również jej treść. Nieudane odpowiedzi HTTP, błędny JSON i brakujące pola możemy natomiast testować osobno na przygotowanych ręcznie danych, bez czekania, aż model rzeczywiście popełni taki błąd.
W przykładzie nie korzystamy ze streamingu; odpowiedzi SSE wymagałyby innego sposobu odczytu i odtwarzania zdarzeń.
.NET
W .NET możemy wykorzystać WireMock.Net w wersji 2.19.0 z C# 12 lub nowszym. Poniższy fragment pokazuje konfigurację serwera, do którego aplikacyjny HttpClient wysyła żądania zamiast do prawdziwego endpointu. Nie jest to pełny test xUnit – sama aplikacja nadal musi wywołać własną metodę klasyfikowania zgłoszeń, a test powinien porównać jej wynik z expected w naszym scenariuszu.
Podobnie jak w Pythonie, nie kopiujemy promptu do testu. W trybie nagrywania WireMock pełni funkcję proxy i zapisuje mapowania żądań na odpowiedzi, a podczas regresji odczytuje je lokalnie.
using WireMock.Server;
using WireMock.Settings;
var recording =
Environment.GetEnvironmentVariable("VCR_RECORD") == "1";
using var server = WireMockServer.Start(
new WireMockServerSettings
{
Urls = ["http://127.0.0.1:9095"],
ReadStaticMappings = !recording,
ProxyAndRecordSettings = recording
? new ProxyAndRecordSettings
{
Url = "https://api.openai.com",
SaveMapping = true,
SaveMappingToFile = true,
ExcludedHeaders =
[
"Authorization",
"User-Agent",
"traceparent",
"x-request-id",
"Accept-Encoding"
],
SaveMappingSettings = new ProxySaveMappingSettings
{
StatusCodePattern = "2xx"
}
}
: null
});
Warto po nagraniu zajrzeć do wygenerowanych plików mapowań, zwykle umieszczanych w katalogu __admin/mappings, i sprawdzić rzeczywiste reguły dopasowania.
WireMock.Net potrafi porównywać JSON, ale może również uwzględnić nagłówki HTTP, które różnią się między uruchomieniami, dlatego wykluczyliśmy kilka zmiennych lub poufnych nagłówków. Lista zależy od konkretnego klienta API.
Należy także zadbać o katalog roboczy dla nowych nagrań, wykluczyć dane poufne z treści, dobrać osobny port dla współbieżnych testów i sprawdzić, czy brak mapowania prowadzi do niepowodzenia testu – nie do połączenia z produkcyjnym API. WireMock zapisuje mapowanie po otrzymaniu odpowiedzi HTTP i nie wie, czy późniejsze asercje xUnit przeszły.
To ważna różnica względem naszego przykładu Python: zatwierdzenie mapowań w .NET musi być osobnym krokiem, na przykład po pozytywnej weryfikacji testów i przeglądzie zmian w repozytorium. Sam filtr statusu 2xx nie dowodzi, że odpowiedź modelu była poprawna.
Czy poprawny JSON oznacza poprawną odpowiedź?
Wróćmy na chwilę do naszego zgłoszenia dotyczącego drukarki. Model może zwrócić prawidłowy JSON, zawierający wszystkie wymagane pola, a mimo to wybrać niewłaściwą kategorię. Walidacja schematu potwierdzi jedynie, że odpowiedź ma oczekiwaną strukturę. Nie dowiemy się z niej, czy zaproponowana akcja jest zgodna z firmową procedurą.
Dlatego warto rozdzielić dwa rodzaje kontroli.
- Pierwszy sprawdza techniczny format odpowiedzi: wymagane pola, typy danych i dozwolone wartości.
- Drugi porównuje znaczenie odpowiedzi z oczekiwanym rezultatem przygotowanym dla danego scenariusza.
W naszym przykładzie możemy dopuścić różne sformułowania komunikatu dla pracownika, ale decyzja o sprawdzeniu kolejki drukowania przed diagnozowaniem awarii samego urządzenia powinna pozostać taka sama. Structured Outputs pomaga nam uzyskać poprawny format danych, ale nie zastępuje sprawdzenia, czy decyzja wybrana przez model rzeczywiście ma sens.
Wyrocznia testowa
W terminologii testowania mechanizm określający, czy otrzymany wynik jest poprawny, nazywamy test oracle, czyli wyrocznią testową.
W najprostszym przypadku składają się na nią zatwierdzone oczekiwania oraz sposób porównania ich z rzeczywistym wynikiem. Nie zawsze potrzebujemy do tego kolejnego LLM-a. Jeżeli model zwraca ustrukturyzowane dane, wiele testów możemy wykonać zwykłymi asercjami, tak jak w przykładzie z Pythonem. Dopiero przy odpowiedziach opisowych, wymagających oceny znaczenia całego tekstu, może przydać się bardziej zaawansowana ewaluacja, czasem wykorzystująca drugi model.
Taki evaluator również może popełniać błędy, dlatego dla ważnych scenariuszy warto mieć próbki ocenione przez człowieka i sprawdzać, czy automatyczna ocena pozostaje z nimi zgodna. Nie chciałbym doprowadzić do sytuacji, w której jeden model wymyśla odpowiedź, drugi uznaje ją za poprawną, a test kończy się sukcesem tylko dlatego, że oba zgodnie się pomyliły.
Testy kilku rodzin modeli
W testach obejmujących kilka rodzin modeli możemy zauważyć różnice, które trudno dostrzec podczas pojedynczej rozmowy.
Ten sam zestaw pytań pozwala sprawdzić, jak poszczególne modele interpretują firmowe procedury, czy potrafią rozpoznać istotne szczegóły w wypowiedziach użytkowników oraz jak radzą sobie z pytaniami, na które odpowiedź wymaga połączenia informacji z kilku dokumentów. Możemy przy okazji porównać obsługę JSON Schema, formatowanie odpowiedzi i zgodność z kontraktem naszej aplikacji.
Przy korzystaniu z modeli albo dostawców bez ścisłego wymuszania schematu zdarza się, że model dobrze rozumie pytanie, ale zwraca dane w niewłaściwym formacie.
W trybie Structured Outputs ze ścisłym schematem eliminujemy wiele takich błędów technicznych; nadal jednak możemy otrzymać poprawny JSON zawierający błędną decyzję. Dzięki wspólnym przypadkom testowym możemy rozdzielić te problemy, porównać wyniki i sprawdzić, czy poprawienie dokumentacji rzeczywiście przyniosło oczekiwany efekt. Z czasem taki zbiór staje się również dobrym punktem odniesienia przy aktualizacji modelu albo zmianie dostawcy API.
Co zrobić, kiedy zmieni się procedura?
Załóżmy, że po kilku tygodniach działania helpdesku aktualizujemy procedurę dotyczącą drukowania.
Nowa wersja wyraźnie rozróżnia problem z samą drukarką od sytuacji, w której urządzenie wykonuje wydruk testowy, ale dokumenty zatrzymują się w kolejce na komputerze. W tradycyjnym podejściu moglibyśmy ponownie uruchomić wszystkie testy na rzeczywistych modelach i sprawdzić, czy coś się zmieniło. Przy kilkudziesięciu scenariuszach nie będzie to jeszcze duży problem, ale przy kilku tysiącach pytań i wielu modelach zaczynamy wykonywać sporo niepotrzebnej pracy.
Jeżeli wiemy, które przypadki testowe korzystają z procedury drukowania, możemy ponownie nagrać odpowiedzi związane ze zmienionym dokumentem, a pozostałe kasety wykorzystać w zwykłej regresji. Takie podejście pozwala ograniczyć liczbę płatnych wywołań i szybciej sprawdzić najbardziej prawdopodobne skutki wprowadzenia zmiany. Przy okazji możemy porównać wyniki sprzed aktualizacji i po niej, zamiast polegać wyłącznie na subiektywnej ocenie, że nowa procedura jest bardziej zrozumiała.
Zależności
Musimy jednak uważać na zależności między dokumentami. Jeżeli każde zapytanie zawiera całą bazę wiedzy w jednym ogromnym prompcie, zmiana choćby jednego akapitu wpływa na treść wszystkich żądań. Wtedy nie możemy bez dodatkowego uzasadnienia uznać, że pozostałe kasety nadal odpowiadają aktualnej konfiguracji.
Znacznie łatwiej zarządzać testami, kiedy wiemy, które procedury zostały wykorzystane do przygotowania odpowiedzi. W systemie korzystającym z RAG możemy zapisywać identyfikatory i wersje fragmentów dokumentacji zwróconych przez wyszukiwarkę. Przy prostszej aplikacji wystarczy jawne przypisanie scenariuszy do procedur, tak jak w naszym pliku YAML. Na tej podstawie wyznaczamy testy wymagające odświeżenia.
Warto przy tym pamiętać, że zmiana dokumentu może wpłynąć również na wyszukiwanie i ranking fragmentów, dlatego selektywna regresja powinna być uzupełniana okresowym pełnym przebiegiem testów na rzeczywistym systemie. Jeżeli korzystamy przy tym z zewnętrznego API embeddingów, również tę zależność musimy kontrolować podczas testów offline. Samo nagranie końcowej odpowiedzi modelu nie oznacza jeszcze, że pozostałe elementy systemu przestały potrzebować dostępu do sieci.
Pull request
Tak przygotowane kasety możemy przechowywać w repozytorium i uruchamiać podczas każdego pull requestu. Programista zmienia kod odpowiedzialny za obsługę odpowiedzi modelu, uruchamia testy i natychmiast widzi, czy aplikacja nadal poprawnie przetwarza wcześniej zatwierdzone wyniki. Nie potrzebuje do tego klucza API ani dostępności konkretnego dostawcy. Jeżeli w kodzie aplikacji zmieni się prompt, model lub dokumentacja przekazywana w żądaniu, test może zakończyć się błędem z powodu braku pasującej kasety.
Sama zmiana pliku w repozytorium, którego aplikacja nie używa w danym teście, nie zostanie natomiast magicznie wykryta przez VCR. Do tego potrzebujemy kontrolować zależności i wersje danych.
W bardziej rozbudowanym rozwiązaniu możemy również wykrywać takie zmiany wcześniej na podstawie fingerprintów i mapy zależności. Następnie uruchamiamy kontrolowane nagrywanie, sprawdzamy nowe odpowiedzi, przeprowadzamy review i zatwierdzamy zmianę. W ten sposób odpowiedzi modelu stają się kontrolowanymi danymi testowymi, a nie czymś, co za każdym uruchomieniem pojawia się na nowo i znika po zakończeniu pipeline’u.
Ile można na tym zaoszczędzić?
Spróbujmy policzyć koszty na naszym przykładzie helpdesku.
Przyjmijmy, że przygotowaliśmy 60 scenariuszy testowych i chcemy sprawdzić je na trzech modelach. Jeden pełny przebieg oznacza 180 wywołań API. Jeżeli pipeline CI/CD uruchamia się 80 razy w miesiącu i każde uruchomienie rzeczywiście odpytuje modele, wykonamy łącznie 14 400 zapytań, chociaż większość z nich będzie dotyczyć tych samych pytań i procedur. Przy czysto hipotetycznym średnim koszcie 0,002 USD za wywołanie otrzymujemy 28,80 USD miesięcznie.
Samo to nie jest imponującym rachunkiem, zwłaszcza jeśli korzystamy z bardzo taniego modelu. Dla porównania, przy modelu kosztującym 0,10 USD za milion tokenów wejściowych i 0,50 USD za milion wyjściowych, jedno żądanie zużywające 1000 tokenów wejściowych i 200 wyjściowych kosztowałoby około 0,0002 USD, czyli wszystkie 14 400 wywołań około 2,88 USD. Przy dłuższych dokumentach, bardziej wymagających modelach i tokenach rozumowania koszty będą inne. Dlatego poniższe wartości mają pokazać mechanizm skalowania, a nie deklarować typowy rachunek dla każdego helpdesku.
Koszt kaset VCR
Z kasetami VCR wygląda to inaczej. Pierwszy przebieg nadal wymaga 180 rzeczywistych zapytań, ponieważ musimy przygotować nagrania. Przy założeniu 0,002 USD za wywołanie kosztuje to 0,36 USD. Następnie możemy uruchamiać regresję aplikacji wielokrotnie bez nowych opłat za inferencję, korzystając z zatwierdzonych odpowiedzi.
Jeżeli w pierwszym miesiącu odświeżymy 20% scenariuszy dla wszystkich trzech modeli, czyli wykonamy 36 dodatkowych wywołań, a ponadto raz uruchomimy pełną ewaluację na rzeczywistych modelach obejmującą 180 pytań, otrzymamy 396 płatnych wywołań za 0,792 USD.
W kolejnych miesiącach, bez jednorazowego nagrania początkowego, będzie to 216 wywołań za 0,432 USD. Oznacza to ponad 97% mniej płatnych wywołań w przedstawionym pierwszym miesiącu i 98,5% mniej w następnych.
Porównujemy jednak inne harmonogramy testów, a nie równoważne częstotliwości sprawdzania bieżącej jakości modelu: w pierwszym wariancie odpytujemy API 80 razy, w drugim uruchamiamy codzienną regresję na kasetach, a ewaluację rzeczywistych modeli przeprowadzamy okresowo. Jeżeli rzeczywiście potrzebujemy 80 pełnych ewaluacji modelu na żywo, nie zastąpimy ich kasetami bez utraty tej częstotliwości kontroli.
Duża skala
Przy większej skali różnica staje się bardziej widoczna. Tysiąc scenariuszy, trzy modele i 80 uruchomień to już 240 000 wywołań. Jeśli jedno żądanie zawiera przykładowo 8000 tokenów wejściowych i generuje 400 wyjściowych, a uśrednione stawki dla wybranej mieszanki modeli wynoszą 1 USD za milion wejściowych i 5 USD za milion wyjściowych, koszt jednego wywołania wynosi 0,01 USD, a wszystkich – 2400 USD. To nie jest cennik GPT-6 Luna ani pomiar konkretnego wdrożenia, lecz jawnie zdefiniowany wariant dla bardziej kosztownych modeli i długiego kontekstu.
Rzeczywiste koszty mogą być niższe dzięki cache’owaniu wspólnego prefiksu promptu, a okresowe ewaluacje można wykonywać przez Batch API ze zniżką kosztem opóźnionego otrzymania wyników. Takie mechanizmy warto wykorzystać, ale nie rozwiązują jednak innego problemu: codzienny test regresyjny uruchamiany przy pull requeście powinien możliwie szybko zwrócić powtarzalny wynik bez zależności od zewnętrznego API.
Przedstawione liczby obejmują wyłącznie hipotetyczne koszty wywołań modeli. Nie uwzględniają przygotowania scenariuszy, przeglądania odpowiedzi, nieudanych prób, utrzymywania kaset ani infrastruktury. Przy bardzo tanich modelach czas pracy zespołu może kosztować znacznie więcej niż sama inferencja.
Właśnie dlatego przydatność VCR nie sprowadza się do rachunku za tokeny. Testy przestają zależeć od chwilowej dostępności dostawcy, można je wykonywać lokalnie, a analiza błędów staje się prostsza, ponieważ aplikacja za każdym razem otrzymuje tę samą odpowiedź. Znika też potrzeba udostępniania kluczy API w codziennym CI/CD, co ogranicza ryzyko przypadkowych wywołań i ułatwia kontrolowanie wydatków.
Dodatkowo wyniki okresowych ewaluacji możemy wykorzystać do wyboru modeli dla poszczególnych kategorii pytań: tańszy model może obsługiwać proste zgłoszenia, bardziej zaawansowany trudniejsze, a niejednoznaczne przypadki wciąż trafiać do pracownika helpdesku. Nie jest to funkcja kaset samych w sobie, lecz kolejny praktyczny rezultat dobrze prowadzonego procesu testowania.
Podsumowanie
Na początek nie potrzebujemy rozbudowanej platformy do ewaluacji modeli. Wystarczy wybrać kilka reprezentatywnych pytań z naszej bazy wiedzy, przygotować dla nich oczekiwane wyniki i nagrać pierwsze odpowiedzi przy użyciu VCR.py albo WireMock.Net. Następnie możemy uruchomić testy bez dostępu do rzeczywistego API, sprawdzić zachowanie aplikacji po zmianach kodu i zobaczyć, ile czasu oraz płatnych wywołań udało się wyeliminować.
Gdy mechanizm zacznie działać, możemy rozszerzyć go o fingerprinty, wersjonowanie procedur, selektywne odświeżanie kaset i porównywanie różnych modeli. Równolegle warto okresowo uruchamiać testy na rzeczywistych modelach, aby sprawdzać ich aktualne zachowanie i uzupełniać scenariusze o nowe pytania użytkowników. Dzięki temu zbiór testowy będzie rozwijał się razem z aplikacją i firmową bazą wiedzy, a każda istotna zmiana będzie miała swoje miejsce w procesie weryfikacji.
Docelowo możemy zbudować w ten sposób asystenta o mierzonej i kontrolowanej jakości, który stopniowo przejmuje coraz większą część typowych pytań pracowników, a trudniejsze lub niejednoznaczne przypadki przekazuje człowiekowi.
Zamiast wymagać od modelu, aby od pierwszego dnia wiedział wszystko i nigdy się nie pomylił, stopniowo poszerzamy zakres zadań, z którymi potrafi sobie poradzić. Każde nowe zgłoszenie, przy którym potrzebna była pomoc eksperta, może stać się kolejnym przypadkiem testowym, a po dopracowaniu procedury i potwierdzeniu jakości odpowiedzi – kolejną sprawą obsługiwaną automatycznie.
Z czasem udział takich zgłoszeń może więc maleć, a pracownicy helpdesku będą mogli skupić się na problemach, które rzeczywiście wymagają ich doświadczenia. Co ważne, możemy rozwijać ten proces w sposób kontrolowany, mierzyć skuteczność odpowiedzi, ograniczać koszty testowania i wykazywać na podstawie wyników, czy każda kolejna zmiana faktycznie poprawia działanie naszego asystenta.
To pozwala stopniowo ograniczać udział ręcznej obsługi tam, gdzie jakość automatycznych decyzji została już potwierdzona.
Zostaw komentarz