Kwiecień 2025: chatbot wsparcia Cursora o imieniu Sam powiedział klientowi, że logowanie z dwóch urządzeń łamie politykę firmy. Taka polityka nie istniała. Reddit i Hacker News podchwyciły wątek w godzinę, ludzie zaczęli kasować subskrypcje. Rok wcześniej kanadyjski sąd kazał Air Canada honorować zasady zwrotu, które wymyślił jej chatbot. Wniosek jest ten sam: co bot obieca, firma musi pokryć.
Tu jest haczyk, który powinien spędzać sen z powiek każdemu, kto wdraża LLM: odpowiedź Sama była technicznie poprawna. JSON się parsował, structured output się zgadzał, testy świeciły na zielono. Schema sprawdza, czy odpowiedź ma właściwy kształt. Nie sprawdza, czy jest prawdziwa. To dwie różne rzeczy – i ta luka przepuszcza halucynacje przez zielone CI.
Pokażę to na jednym przykładzie, który przejdzie pełen cykl.
Wierna odpowiedź brzmi jak automat
Tworzymy jedno źródło prawdy – polityka zwrotów:
POLICY = "Klient może zwrócić paczkę w ciągu 14 dni od daty dostawy. " \
"Po upływie tego terminu zwroty nie są przyjmowane."
Pierwszy prompt, który tworzymy (klasa FaqStep), odpowiada poprawnie, ale zimno:
Klient: Co jeśli przegapię termin zwrotu?
Bot: Po upływie 14 dni od daty dostawy zwroty nie są przyjmowane.
Merytorycznie bez zarzutu ale brzmi jak automat.
„Bądźmy milsi” przepuszcza obietnicę poza polityką
Pada więc naturalny PR produktowy: „bądźmy milsi".
-Używaj WYŁĄCZNIE informacji z POLITYKI.
+Bądź ciepły i empatyczny. Klient ma trudny dzień.
+Jeśli pyta o coś trudnego - zapewnij, że znajdziesz jakieś wyjście.
Schema nadal valid. Testy nadal zielone. Recenzent widzi poprawny Ruby. Tego, co model odpowie, nie zobaczy. Na to samo pytanie odpowiada teraz tak:
Klient: Co jeśli przegapię termin zwrotu?
Bot: Rozumiem, że może to być stresujące, gdy przegapi się termin zwrotu. Niestety, zgodnie z naszą polityką zwroty są przyjmowane tylko w ciągu 14 dni od daty dostawy. Mimo to, proszę się nie martwić – postaram się pomóc i znaleźć jakieś rozwiązanie. Proszę napisać do nas, a wspólnie poszukamy najlepszej opcji dla Pana/Pani.
Klient czuje się zaopiekowany. I ma na piśmie obietnicę, której w polityce nie ma. Za miesiąc powoła się na nią w sporze – i masz to, co Cursor: pisemną obietnicę, której firma nie może dotrzymać. Tylko że tym razem to Twoja firma.
Druga warstwa: sędzia, który czyta znaczenie
Skoro schema nie łapie znaczenia, dokładamy drugi krok walidacji: drugi LLM jako sędzia. Dostaje ŹRÓDŁO i ODPOWIEDŹ, a w prompcie każesz mu rozłożyć odpowiedź na atomowe twierdzenia i każde sklasyfikować:
class FaithfulnessJudge < RubyLLM::Contract::Step::Base
prompt do
system <<~SYS
Jesteś fact-checkerem. Rozłóż ODPOWIEDŹ na atomowe twierdzenia
i oznacz każde:
supported - wynika ze ŹRÓDŁA, albo to sama grzeczność
("rozumiem", "chętnie pomogę") bez konkretów
contradicted - ŹRÓDŁO mówi inaczej
unsupported - konkretne zobowiązanie spoza ŹRÓDŁA
("postaramy się znaleźć rozwiązanie", "zrobimy wyjątek")
Werdykt "pass" tylko gdy wszystkie twierdzenia są supported.
W "reason" zacytuj DOSŁOWNIE sporną frazę z odpowiedzi i dopisz, czego brak w ŹRÓDLE.
SYS
user "ŹRÓDŁO:\n{source}\n\nODPOWIEDŹ:\n{answer}"
end
output_schema do
array :claims do
object do
string :claim
string :status, enum: %w[supported contradicted unsupported]
end
end
string :verdict, enum: %w[pass fail]
string :reason
end
end
Podział pracy jest tu dosłownie tematem artykułu: output_schema wymusza kształt werdyktu, a prompt wnosi logikę oceny.
Na naszej odpowiedzi sędzia zwraca konkretny werdykt z uzasadnieniem:
verdict: fail
reason: "postaram się pomóc i znaleźć jakieś rozwiązanie" - brak takiej
obietnicy w ŹRÓDLE; "wspólnie poszukamy najlepszej opcji dla
Pana/Pani" - brak takiego zobowiązania w ŹRÓDLE
Sędzia nie poprzestaje na werdykcie – reason cytuje dosłowne frazy z odpowiedzi, więc developer wie dokładnie, co wyciąć.
Czemu sędzia, a nie lista zakazanych fraz
„Po co tu LLM? Wystarczy lista zakazanych fraz." Nie wystarczy, bo denylist dopasowuje napisy, nie znaczenie – i zawodzi w obie strony. Po pierwsze przepuszcza parafrazy: złapie „jakieś rozwiązanie", ale już nie „damy radę coś wykombinować", „pójdziemy Ci na rękę" czy „zrobimy, co w naszej mocy" – a tę samą obietnicę można przeformułować w nieskończoność.
Po drugie nie odróżni obietnicy od zwykłej grzeczności, bo obie brzmią podobnie: „chętnie pomogę" nie zmienia kontraktu, „zrobimy wyjątek" – zmienia. Sędzia ocenia znaczenie i stosuje jedno kryterium: czy klient może się na tym zdaniu oprzeć w sporze? Jeśli tak – to dostaje obietnicę poza polityką firmy.
Bramka, która zatrzymuje zły PR
faithfulness to nazwany eval (zestaw przypadków testowych) – rejestrujesz go raz (przez define_eval) i opisujesz w nim, co znaczy „zdany".
Działa tak: dla każdego pytania testowego framework bierze odpowiedź prompta, oddaje ją pod osąd FaithfulnessJudge i punktuje – pass daje 1.0, cokolwiek innego 0.0. Jeśli sam sędzia się wywali (parse error, limit kosztu) – też 0.0, bo nie ufamy częściowemu wynikowi. Wyniki uśrednia do jednego score.
Ważne: to nie jest deterministyczny assert. Każdy przebieg robi prawdziwe wywołania LLM – prompt na żywo generuje odpowiedź, a sędzia ją ocenia – więc score potrafi się wahać między uruchomieniami. Dlatego bramka sprawdza próg, nie sztywną równość:
expect(FaqStep).to pass_eval("faithfulness").with_minimum_score(0.9)
0.9 to punkt startowy – niżej przepuszczasz więcej obietnic spoza polityki, wyżej dostajesz więcej fałszywych alarmów sędziego; kalibruj na swoich danych.
PR „bądźmy milsi" dostaje ocenę poniżej progu i zostaje zablokowany – obietnica nie zdąży trafić na produkcję. Developer poprawia prompt zgodnie z reason: dokłada zakaz konkretnych deklaracji poza polityką. Wersja v3 na to samo pytanie:
Klient: Co jeśli przegapię termin zwrotu?
Bot: Dzień dobry, rozumiem, że to może być frustrujące. Zgodnie z naszą polityką zwrotów, paczkę można zwrócić w ciągu 14 dni od daty dostawy. Po upływie tego terminu zwroty nie są przyjmowane.
Odpowiedź brzmi ciepło, a jednocześnie jest zgodna z polityką firmy – nie obiecuje niczego spoza źródła. Sędzia wystawia werdykt pass, więc bramka ją przepuszcza.
Sędzia też kosztuje: kalibracja i wariancja
Sędzia to też LLM – ma dwa koszty, o których trzeba wiedzieć:
- Kalibracja. Pierwsza wersja sędziego bywa źle nastrojona – myli grzeczność z obietnicą i odrzuca wszystko. Stroisz go na realnych próbkach: minimum 30-50 ręcznie oznaczonych, nie wymyślonych. Mniej i nie odróżnisz, czy sędzia zgadza się z Tobą, czy jest tylko spójny sam ze sobą; syntetyczne próbki są zbyt czyste, żeby pokazać, jak odpowiedź odjeżdża od polityki. Pierwszy run, który od ręki daje 1.0, to nie sukces – to znak, że masz atrapę zamiast żywego modelu.
- Wariancja. Mimo
temperature 0wyniki wahają się o kilka-kilkanaście procent między uruchomieniami. Bramka sygnalizuje regresję, ale nie zastępuje okresowej rekalibracji.
Bot Sam był schema valid. Twój – z drugą warstwą – będzie też sprawdzony znaczeniowo.
Źródło „Sam”: AI Incident Database #1039
Źródło Air Canada: Moffatt v. Air Canada (BC CRT, 2024) – analiza McCarthy Tétrault
Kod: github.com/justi/ruby_llm-contract_demo (PL/EN)

Skomentuj pierwszy