$ cat cztery-remisy.md

Napisałem zadanie porządnie. Sędzia cztery razy ogłosił remis

Te same agenty, ten sam sędzia, zmienia się tylko opis zadania. Osiem rozwiązań, wszystkie poprawne, sędzia nie ma czego rozstrzygać. A potem znajduję zamówienie, na którym różnią się o sto złotych.

część 4 · laaj 1 2 3 4

Czwarta część serii. W poprzedniej dałem dwóm agentom AI zadanie opisane jednym zdaniem — funkcję liczącą rabat. Dostałem osiem rozwiązań z zielonymi testami. I sześć rodzajów decyzji, których nie podjął nikt: agent po cichu zgadywał, a zgadywankę ubierał w dokumentację. Na końcu obiecałem ten sam eksperyment z porządnie opisanym zadaniem. Oto on.

Teza

Jedna, i to stara. W P.S. do szablonu zadań napisałem, że zadanie napisane tak, żeby nie zgadywał człowiek, jest tym samym zadaniem, przy którym nie zgaduje agent. Powtórzyłem to w dwójce. Wiem to z praktyki — szablon powstał dla klienta, z realnej pracy, nie z teorii. Ale nigdy Wam tego nie pokazałem: żadnego jawnego testu, żadnych liczb, tylko moje słowo.

Badanie

Zmieniam jedną rzecz: opis zadania. Wszystko inne zostaje jak w poprzednim wpisie. Dwóch agentów pisze kod niezależnie od siebie (claude-opus-5 jako worker A, grok-4.6 jako worker B). Sędzia-LLM najpierw sprawdza każde rozwiązanie osobno, punkt po punkcie. Potem porównuje oba ze sobą — dwa razy, z zamienioną kolejnością, żeby nie faworyzował tego, które zobaczył pierwsze. Na końcu decyduję ja. Celowo nie wdrożyłem przed tą serią żadnej z poprawek zapowiedzianych w trójce. Jeśli zmienię dwie rzeczy naraz, nie będę wiedział, która zadziałała.

Eksperyment ma więc dwie wersje, które dalej nazywam ramionami.

Ramię A to testy z poprzedniego wpisu. Zadanie brzmiało tak:

a function that calculates a discount: 10% above 500 PLN,
20% above 1000 PLN, discounts do not stack with promo codes

Cztery przebiegi, w każdym dwóch agentów, czyli osiem rozwiązań.

Ramię B to nowe testy: ta sama funkcja, znowu cztery przebiegi i osiem rozwiązań, ale zadanie opisane według mojego szablonu. Oto ono w całości, tak jak poszło do agentów:

TITLE: Add a function that calculates the amount payable with a threshold discount

GOAL: The shop gives a discount on larger orders. The function will be used
in the cart summary to compute the amount payable.

DESCRIPTION:
- Module: src/calc.py, the function takes the order amount in PLN
  and an optional promo code.
- How it works: threshold discount of 10% for orders above 500 PLN,
  20% for orders of 1000 PLN and up. Threshold discounts do not stack
  with promo codes.

ACCEPTANCE CRITERIA:
1. The function returns the amount PAYABLE (after discount), not the discount amount.
2. An order of exactly 500.00 PLN → no discount. 500.01 PLN → 10%.
3. An order of exactly 1000.00 PLN → 20% (closed threshold).
4. A valid promo code disables the threshold discount. The list of valid
   codes is a function parameter (a collection of strings). A code not on
   the list, an empty string and None do NOT disable the threshold discount.
5. Amounts are computed with Decimal; the result is rounded to whole
   grosze (ROUND_HALF_UP).
6. Tests cover both thresholds (values exactly on the threshold and just
   next to it) and the case of a code not on the list.

MATERIALS: none.

OPEN DECISIONS: I don't know how to treat negative and zero amounts —
decide yourself and describe the decision in your summary.

Dwie rzeczy wstawiłem tu celowo jako test.

Pierwsza to próg 1000 zł. W ramieniu A (czyli w testach z poprzedniego wpisu) zadanie mówiło „20% above 1000” i wszystkie osiem rozwiązań zgadło tak samo: zamówienie za równo 1000 zł dostaje tylko 10%. Teraz zdecydowałem odwrotnie — równo 1000 zł to już 20% — i zapisałem to dwa razy: w opisie („1000 PLN and up”) i w kryterium 3. Sprawdzam, czy agent pójdzie za tym, co napisałem, czy za swoim nawykiem.

Druga to pole OPEN DECISIONS. Szablon obiecuje, że gdy czegoś nie wiem, mogę napisać „zdecyduj sam” — agent nie utknie, podejmie decyzję i opisze ją w podsumowaniu. Sprawdzam, czy tak będzie.

Opis zadania i moje przewidywania wyników leżą w repo od 2 września (commit 8fd40e1) — piętnaście dni przed pierwszym przebiegiem ramienia B, więc nie mogłem ich dopasować do wyników. Jedna różnica techniczna: w ramieniu A sędzia sam układał sobie listę kontrolną z jednego zdania. W ramieniu B dostaje moje sześć kryteriów, słowo w słowo.

Wynik

Ramię A: jedno zdanie (poprzedni wpis) Ramię B: szablon (ten wpis)
Sześć rodzajów niepodjętych decyzji z poprzedniego wpisu wracają w każdym przebiegu 0 w 8 rozwiązaniach na 8
Ile różnych rozumień „do not stack” trzy jedno
Zamówienie za równo 1000 zł 8/8 zgadło: 10% 8/8 zgodnie z kryterium: 20%
„Zdecyduj sam” wróciło opisane w podsumowaniu nie dotyczy 8/8, za każdym razem ta sama decyzja
Ile razy sędzia odesłał kod do poprawki 0 0
Werdykty sędziego remis, A, remis, A remis, remis, remis, remis
Moje decyzje na końcu 1 zgoda z sędzią, 2 wybory przy remisie, 1 odrzucenie obu 4 × rzut monetą
Średni koszt jednego przebiegu 1,26 $ 1,80 $

Teza się broni — dokładnie tak, jak się spodziewałem. Wszystkie kryteria spełnione w ośmiu rozwiązaniach na osiem. Równo 1000 zł dostaje 20%, wbrew nawykowi. Pieniądze liczone na Decimal, a nie na ułamkach zmiennoprzecinkowych. Funkcja zwraca kwotę do zapłaty, a nie kwotę rabatu. „Zdecyduj sam” zadziałało tak, jak obiecuje szablon: obaj agenci w każdym przebiegu podjęli decyzję i opisali ją w podsumowaniu — i za każdym razem była to ta sama decyzja: zero to normalny pusty koszyk, kwota ujemna to błąd (ValueError). W pierwszym przebiegu dwa modele od dwóch dostawców, w odizolowanych klonach, napisały nawet ten sam komunikat błędu, co do znaku: "order amount must not be negative".

Dla pewności odtworzyłem wszystkie osiem rozwiązań i uruchomiłem ich testy samodzielnie, poza pipeline’em: od 14 do 17 testów na rozwiązanie, wszystkie zielone.

Jedno moje przewidywanie się nie sprawdziło i zostawiam je na widoku, jak poprzednio. Obstawiałem, że moje kryteria będą surowsze od listy, którą sędzia układał sobie sam, więc w ramieniu B sędzia przynajmniej raz odeśle kod do poprawki. Nie odesłał ani razu. Dobre kryteria nie zaostrzyły kontroli — sprawiły, że nie było czego poprawiać.

Sędzia nie miał czego sądzić

Ciekawszy jest wiersz z werdyktami. W ramieniu A sędzia dwa razy wskazał zwycięzcę. W ramieniu B ogłosił remis cztery razy na cztery — w obu porównaniach, niezależnie od kolejności. Trudno mu się dziwić: rozwiązania różniły się głównie nazwami zmiennych.

Samo porównywanie dwóch rozwiązań kosztowało w ramieniu B średnio 0,67 $ na przebieg — 37% całego rachunku — i nie rozstrzygnęło niczego. A ja? Cztery razy rzuciłem monetą. Przy remisie któryś kod musi przejść dalej, więc brałem dowolny i wpisywałem w panelu preference: none, czyli „bez preferencji”. W poprzednim wpisie zapisywałem takie rzuty jak prawdziwe oceny. Sędzia miał się potem z nich uczyć. Tym razem już nie.

Nie wyciągam z tego wniosku, że sędzia jest zbędny. Wyciągam węższy: spora część mojego pipeline’u z dwójki — dwóch agentów, konkurs między nimi, podwójne porównanie — nadrabiała zły opis zadania. Gdy opis zamknął decyzje, konkurs nie miał czego rozstrzygać. Czy tak samo jest przy zadaniu, którego nie da się zamknąć sześcioma kryteriami — tego ten eksperyment nie mówi.

Czego szablon nie zamknął

Przewidywałem też, że nawet przy dobrym opisie pojawi się jakaś nowa niepodjęta decyzja — taka, której nie było na mojej liście sześciu. Pojawiła się, tylko trzeba było poszukać, bo sędzia w każdym uzasadnieniu pisał, że oba rozwiązania działają identycznie („identical, correct semantics”). Wziąłem więc wszystkie osiem rozwiązań i podałem każdemu ten sam zestaw nietypowych danych.

Zamówienie za 999,995 zł — kwota, jaką dostajesz z mnożenia ceny przez wagę albo z przeliczenia netto na brutto. Siedem rozwiązań każe zapłacić 900,00 zł. Jedno — 800,00:

# przebieg 2, worker A
amount = _to_decimal(amount).quantize(GROSZ, rounding=ROUND_HALF_UP)  # 999.995 → 1000.00
...
if amount >= Decimal("1000"):                                         # → 20%

To rozwiązanie najpierw zaokrągla kwotę do 1000,00 zł, a dopiero potem sprawdza próg — i klient łapie się na 20%. Pozostałe sprawdzają próg na 999,995 zł, więc dają 10%. Zaokrąglić przed sprawdzeniem progu czy po? Moje kryterium 5 mówi „the result is rounded”. O wejściu milczy. Sto złotych różnicy między rozwiązaniami, które przeszły wszystkie testy, dostały od sędziego „gotowe” i werdykt „remis”.

Dwa kolejne przypadki tego samego rodzaju. Jeśli ktoś przez pomyłkę poda listę ważnych kodów jako zwykły napis "SAVE10" zamiast listy, pięć rozwiązań na osiem zaczyna szukać kodu wewnątrz tego napisu — kod "AV" zostaje uznany za ważny, rabat znika i klient płaci 1000 zamiast 800 zł. Trzy pozostałe zgłaszają błąd. A dwa rozwiązania, dostając jako kwotę True, spokojnie zwracają 1.00.

To jest ten sam mechanizm co w trójce, piętro niżej. W ramieniu A agenci zgadywali w sprawach podstawowych: gdzie dokładnie jest próg, jak liczyć pieniądze, co funkcja ma zwracać. W ramieniu B podstawy są czyste, a zgadywanie przeniosło się do rzadkich, nietypowych przypadków — tam, gdzie nie sięgnęła moja wyobraźnia, kiedy pisałem kryteria. Szablon zamyka te decyzje, o których wiem, że je podejmuję. Nie zamyka świata.

Zastrzeżenia. To zadanie-zabawka: jedna funkcja, dwadzieścia linii kodu, po cztery przebiegi na każdą wersję opisu, jedna para modeli. Worker A w obu ramionach pisał testy bez możliwości ich uruchomienia — moja lista dozwolonych komend przepuszcza pytest, a w kontenerze jest tylko python -m pytest; zostawiłem to bez zmian, żeby nie dokładać drugiej zmiennej, a testy sprawdziłem po fakcie. Drugi task ramienia B wystartował, zanim zamknąłem pierwszy, wbrew mojej własnej procedurze; zapis działań obu agentów w tym przebiegu nie pokazał ani jednej próby zajrzenia poza własny katalog.

Puenta

Najtańszą poprawką jakości w całym tym pipelinie okazało się dwadzieścia kilka linijek tekstu przed startem agentów. Nie drugi model, nie drugie porównanie u sędziego, nie odsyłanie kodu do poprawki — opis, w którym decyzje podjął ten, kto ma do nich uprawnienia.

Tyle że te linijki pisałem dłużej, niż agenci pisali kod. I znałem odpowiedzi, bo poprzedni wpis wypunktował mi wszystkie sześć pułapek. W prawdziwej kolejce zadań nikt nie dostaje takiej ściągi.

Co dalej

Dwa wątki, oba wprost z tego wyniku.

Wywiad zamiast szablonu. Nikt nie będzie pisał tak każdego zadania — ja też nie. Ale w trójce opus w ośmiu przebiegach na osiem sam nazywał niejasności specu, tylko robił to w ostatniej wiadomości, której nikt nie czytał. Pytania już są; stoją po złej stronie pipeline’u. Następny eksperyment: warstwa, która bierze jedno zdanie, zadaje pytania przed startem i z moich jednozdaniowych odpowiedzi składa spec według szablonu. Miarą będzie to, ile z sześciu rodzajów niepodjętych decyzji i trzech dzisiejszych nietypowych przypadków pokryją jej pytania.

Zadania, których nie da się zamknąć. Z doświadczenia wiem, że przy dużych zadaniach wąskie gardła zostają nawet przy dobrym opisie. Tam rozwiązania znów zaczną się od siebie różnić — i tam się okaże, ile naprawdę jest wart sędzia. Na funkcji rabatowej nie miał szansy tego pokazać.

$ prev
Przyłapałem własny pipeline AI na ściąganiu. A potem zmierzyłem, czego LLM-as-a-Judge nie widzi

Osiem przebiegów, dwóch agentów, sędzia z podwójnym przejściem i człowiek na końcu. Wszystkie testy zielone. Po drodze: worker kopiujący rozwiązania z sąsiednich zadań, trzy sprzeczne interpretacje jednego zdania specu i licznik decyzji, których nie podjął nikt.

← czytaj
↑