Jak agent sztucznej inteligencji naprawiającej dokumenty oszukał samego siebie: fałszywie pozytywne wyniki w środowisku Docker Sandbox
Agent naprawczy LangGraph wysłał rozwiązanie bez żadnych efektów, a następnie spowodował problemy w pakietach src-layout. Przyczyną była błąd w środowisku Docker sandbox, a prawdziwą naprawą okazała się ta znaleziona w komponencie click.
Źródła: **I built an AI Doc-Fixing Agent. Here’s every way it fooled itself first.**
Agent sztucznej inteligencji stworzony w celu naprawy uszkodzonych przykładów kodu w dokumentacji dał dziwny wynik w swoim pierwszym prawdziwym repozytorium. „Naprawił” błąd, którego w rzeczywistości nie istniało – wziął doskonale poprawny wiersz, niczego nie zmieniając, i opublikował identyczny tekst jako potwierdzoną poprawkę.
- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)
Oba wersje są zgodne. Agent przekonał samego siebie, że musi naprawić coś, co nigdy nie było błędne, a to było dopiero pierwszym z trzech sposobów, w jakie oszukiwał samego siebie, zanim pojawił się prawdziwy winowajca.
Co robi DocuPatch
DocuPatch to agent napędzany LangGraph. Przechodzi przez pliki Markdown w repozytorium GitHub, wyodrębnia bloki kodu Python i uruchamia je w izolowanym kontenerze Docker. Przykłady, które rzeczywiście zawodzą, są wysyłane do Gemini w celu naprawy; naprawa jest ponownie sprawdzana w tej samej izolacji, a tylko potwierdzony wynik staje się gałęzią oraz prośbą o połączenie.
Struktura repozytorium:
agent/ingest.pyklonuje repozytorium i znajduje pliki Markdown.agent/graph.pyuruchamia pipeline LangGraph dla każdego pliku.agent/parser.pywyodrębnia bloki kodu Python.agent/tester.pyuruchamia każdy fragment kodu wewnątrz kontenera Docker.
agent/context_collector.py zbiera odpowiednie pliki i kod w języku Python potrzebne do utworzenia fragmentu kodu.agent/fixer.py tworzy naprawiony fragment kodu.agent/github_client.py tworzy fork, zapisuje poprawkę i otwiera prośby o zmianę.tests/ zawiera testy dla każdego elementu.Zasada jest prosta: kod dokumentacyjny ulega degradacji. API się zmieniają, funkcje są przemieniane w nazwy, przykłady przestają działać, a nikt tego nie zauważa, dopóki ktoś nie wklei niepracującego fragmentu z pliku README. Agent automatyzuje ten proces wykrywania problemów. Znajduje podejrzany fragment, sprawdza, czy jest uszkodzony, wykonując go, prosi sztuczną inteligencję o poprawkę, sprawdza, czy poprawka działa, ponownie ją uruchamiając, i dopiero wtedy otwiera prośbę o zmianę.
Proces składa się z etapu klonowania i analizy, który dostarcza dane do pętli weryfikacyjnej, a poprawka zostaje przyjęta dopiero po dwóch potwierdzeniach: oryginał nie zadziałał, natomiast zamiennik tak.
Zasadą przewodnią jest evidence-gating. Nic nie trafia do LLM, chyba że test w środowisku sandbox pokaże, że występuje usterka, a nic nie jest publikowane, chyba że ponowny test w tym środowisku potwierdzi, że naprawa działa. Ta zasada przetrwała. Samo środowisko sandbox przez długi czas już nie istniało.
Pierwszy test: tqdm
Pierwotny test w rzeczywistych warunkach dotyczył tqdm/tqdm:
Found 9 Markdown file(s). Starting audit...
Auditing: .github\ISSUE_TEMPLATE\bug.md
Generating repair for snippet #1
Verifying generated repair for snippet #1
Publishing verified repair(s)...
VERIFIED REPAIR: Branch published
Dwa pliki zostały „naprawione” i opublikowane, co na papierze wyglądało jak sukces. Następnie sprawdzono rzeczywisty rozbieżność:
- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)
Rzeczywiście, obie linie są identyczne. Gemini nic nie zmieniło i zregenerowało kod bajt po bajcie. System przetestował fragment kodu, uznał go za uszkodzony, „naprawił” go, zwracając ten sam kod, przetestował ponownie, zobaczył sukces i wysłał komit bez żadnych zmian na forku.
To nie był błąd naprawczy. Był to fałszywy pozytyw na poziomie testowania, ponieważ oryginalny fragment kodu nigdy nie był uszkodzony. Linia importująca tqdm i sys oraz wyświetlająca informacje o wersji to coś zupełnie normalnego w przypadku użycia tqdm, więc dlaczego sandbox zgłosił błąd? Sprawa została zaklasyfikowana jako podejrzana, a następnie sprawdzono inny repozytorium, aby uzyskać drugi punkt danych przed wyciąganiem wniosków.
Drugi test: pallets/click
W projekcie pallets/click wzorzec był nie do przeoczenia:
Auditing: docs\advanced.md
Generating repair for snippet #2
Verifying generated repair for snippet #2
[Reflection Loop 2/3] Revising repair for snippet #2
...
[Reflection Loop 3/3] Revising repair for snippet #3
NOT PUBLISHED: Generated repair failed verification:
ModuleNotFoundError: No module named 'click'
Za każdym razem ten sam błąd. Sześć plików przeszło przez po trzy pełne pętle refleksji, a każda awaria była taka sama: ModuleNotFoundError: No module named 'click'.
To powtarzanie się jest wskaźnikiem. Gdy sześć plików i sześć oddzielnych prób z użyciem LLM zawodzi z powodu tego samego błędu importu, wspólnym czynnikiem nie jest kod, lecz środowisko, w którym działają. Przepisanie fragmentu kodu nie naprawi braku modułu, jeśli sandbox nie może dostrzec tego pakietu, bez względu na to, co zostanie napisane.
Otwarcie pliku tester.py ujawniło mechanizm:
command.extend(["-v", f"{project_dir}:/workspace", "-w", "/workspace", "-e", "PYTHONPATH=/workspace"])
Wewnątrz kontenera sklonowany repozytorium jest zamontowany, a zmienna PYTHONPATH ustawiona jest na najwyższy poziom tego repozytorium. To działa poprawnie, gdy katalog pakietu znajduje się dokładnie tam, ale przestaje działać, gdy pakiet znajduje się o poziom niżej. Projekt Click jest zagnieżdżony: jego plik pyproject.toml przechowuje rzeczywisty kod w katalogu src/click/, zamiast w katalogu click/ znajdującym się na korzeniu, co jest standardem dla wielu nowoczesnych projektów. Przy użyciu PYTHONPATH=/workspace interpreter szukał w katalogu, w którym w ogóle nie było katalogu click.
Ręczna weryfikacja poza procesem przetwarzania potwierdziła to:
docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace python:3.10-slim python -c "import click"
# ModuleNotFoundError: No module named 'click'
docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace/src python:3.10-slim python -c "import click; print(click.__version__)"
# import works... but:
# importlib.metadata.PackageNotFoundError: No package metadata was found for click
Drugi wynik dał najwięcej informacji. Wskazanie PYTHONPATH na właściwą folder naprawiło problem z importem, jednak click.__version__ nadal wywoływał błędy, ponieważ obecne wersje click generują __version__ za pomocą importlib.metadata, który czyta metadane zainstalowanych pakietów, a nie pliki źródłowe. Takie metadane powstają dopiero po rzeczywistym pip install, a nigdy po prostym manipulowaniu ścieżką.
Zatem pojawiły się dwa błędy. PYTHONPATH wskazywał na niewłaściwą folder dla repozytoriów typu src-layout. A nawet jeśli wskazywał poprawnie, zwykły trik z PYTHONPATH nie zapewnia prawidłowej instalacji pakietu, podczas gdy coraz więcej pakietów oczekuje pełnej instalacji, a nie tylko możliwości importu.
Jeden objazd zasługuje na wzmiankę, ponieważ był najbardziej pouczającym odcinkiem. Sporo czasu poświęcono przypuszczeniom dotyczącym dziwactwa montowania Dockera dostępnego tylko w systemie Windows, przeprowadzając eksperymenty z użyciem cudzysłowów, różnych styli ścieżek oraz list argumentów w porównaniu z ciągami znaków w shellu. Prawdziwe wyjaśnienie okazało się banalne: folder testowy był pusty, ponieważ wcześniejsze polecenie git clone bezgłośnie nie udało się go wypełnić, a nikt tego nie sprawdził. Pusty wynik polecenia dir C:\temp\click_test nie oznaczał „jeszcze brak odpowiedzi”, lecz był samą odpowiedzią, a został zignorowany trzy razy, zanim ktoś zatrzymał się, by go przeczytać.
Rozwiązanie: zainstaluj, nie zgaduj
Prawdziwym rozwiązaniem nie jest trafniejsze przewidywanie ścieżki, lecz całkowite unikanie domysłów. pip install -e . czyta własny plik pyproject.toml lub setup.cfg projektu, określa właściwą lokalizację pakietu niezależnie od jego struktury i tworzy prawdziwe metadane instalacji, dzięki czemu wywołania importlib.metadata zachowują się tak samo jak u każdego rzeczywistego użytkownika biblioteki.
Nowa logika pliku tester.py działa w następującej kolejności:
- Jeśli istnieje plik
pyproject.toml,setup.pylubsetup.cfg, środowisko sandbox tworzy obraz Dockera i uruchamia w nimpip install -e .. To tzw. „tryb instalacji”, w którym błąd importu może być uznany za autentyczny. - Jeśli brakuje metadanych pakietowania, ale istnieje plik
requirements.txt, stosuje się wcześniejszy sposób oparty na PYTHONPATH, czyli „tryb pythonpath”.
Po tej zmianie istniejący zestaw testów pokazał 26 z 27 przetestowanych przypadków jako udanych. Jedyna nieudana próba była właściwa, ponieważ sprawdzała stary podpis funkcji, który został celowo zmieniony. Oczekiwania zostały zaktualizowane, a trzy nowe testy objęły ten przypadek krawędziowy, dzięki czemu całkowita liczba udanych testów wyniosła 74.
Prawdziwy błąd w dokumentacji click
Dzięki temu, że sandbox instalował projekt i już nie zgadywał, click został uruchomiony ponownie. Sześć plików, które wcześniej zawodziły w identyczny sposób, teraz miało szansę, a jeden z nich ujawnił rzeczywisty, dotąd nieodkryty błąd we własnej dokumentacji click.
docs/commands.md opisuje łańcuchowe procesy w interfejsie CLI, a jedna z funkcji przykładowych zawiera następujący tekst:
@cli.result_callback()
def process_pipeline(processors, fin):
- iterator = (x.rstrip("\r\n") for x in input)
+ iterator = (x.rstrip("\r\n") for x in fin)
Funkcja faktycznie otrzymuje fin, czyli identyfikator pliku dostarczony przez funkcję click. Dokumentacja natomiast przechodzi przez pętlę nad input, co oznacza użycie wbudowanej funkcji Pythona input(), a nie pliku. Przechodzenie przez pętlę nad nią jest nieprawidłowe i powoduje błąd już przy uruchomieniu kodu, ale czytelnik tego nie zauważy, ponieważ nazwa wydaje się prawdopodobna.
DocuPatch wykrył ten problem, potwierdził rzeczywisty błąd w środowisku sandbox, stworzył poprawkę, sprawdził, że poprawiona wersja działa poprawnie od początku do końca, i ją opublikował. Był to pierwszy wynik w całym dochodzeniu, który stanowił zarówno prawdziwy błąd w dokumentacji, jak i prawdziwą poprawkę, bez żadnych zakłóceń ze strony środowiska sandbox ani fałszywych wyników. W tym właśnie celu istnieje ten projekt.
Lekcje z debugowania
- Sprawdź wynik przed zaufaniem mu. Etykiety takie jak „Przejdź” lub „Opublikuj” są tak samo niezawodne, jak test, który znajduje się poniżej nich. Problem z tqdm pojawił się wyłącznie dlatego, że otwarto plik z różnicami i nie przyjęto za pewnik zielonego znaku zatwierdzenia.
- Identyczne błędy w wielu przypadkach wskazują na wspólną przyczynę. Sześć plików, sześć niezależnych prób z użyciem LLM oraz jedna wiadomość o błędzie stanowią sygnał na poziomie systemu.
- Obserwuj, zanim będziesz formułować teorie. Minęły godziny na spekulacje dotyczące ścieżek w Dockerze i Windows, zanim ktoś sprawdził, czy folder z testami rzeczywiście zawiera jakiekolwiek pliki.
- Błądujący test może jedynie potwierdzić coś, a nie uszkodzić system. Gdy zmiana sygnatury funkcji
run_snippetspowodowała błąd w jednym teście, właściwym pytaniem było to, co ten test faktycznie sprawdzał, a nie to, co zostało uszkodzone.
Teraz błąd w środowisku testowym został naprawiony, objęty testami i potwierdzony w repozytorium, z którym wcześniej miał problemy. Dokumentacja Click zawierała prawdziwy defekt – nazwę zmiennej, która powoduje błąd po uruchomieniu przykładu przez kogokolwiek – a agent ją zlokalizował, poprawnie ją naprawił i sprawdził naprawę przed publikacją. To właśnie jest centralna idea projektu, udowodniona na rzeczywistym przypadku, a nie syntetycznym.