Anthillo /blog

Od prompt-stuffingu do RAG: lekcje z budowy własnego chatbota

Anthillo Team

Zbudowaliśmy własnego chatbota, który odpowiada na pytania o Anthillo — kim jesteśmy, co robimy, jak się z nami skontaktować. To widget czatu wpięty jako wtyczka WordPress na anthillo.com, z backendem w Javie (Spring Boot, Spring AI). To nie jest produkt dla klienta zewnętrznego — to dogfooding: mały zespół uczy się budować funkcje AI na własnym, niskostawkowym przypadku, marketingowym FAQ-bocie. Po drodze popełniliśmy kilka typowych błędów początkującego AI-engineeringu i chcemy się nimi podzielić, bo są bardziej pouczające niż niejeden success story.

Wersja 1: cały opis firmy w jednym system prompcie

Pierwsza implementacja (ChatbotGPTServiceImpl) była najprostszą rzeczą, jaka mogła zadziałać: wołaliśmy OpenAI bezpośrednio przez Spring AI ChatClient, doklejając do pytania użytkownika ogromny, hardkodowany SYSTEM_PROMPT z pełnym opisem firmy — usługi, obszary kompetencji, dane kontaktowe. Zero retrievalu, zero bazy wiedzy — po prostu „wrzuć wszystko do kontekstu i miej nadzieję, że model wyłowi właściwy fragment”.

ChatbotGPTServiceImpl.java (v1 — przed)
private static final String SYSTEM_PROMPT = """
    You are an assistant representing Anthillo.
    Anthillo provides Software development, Infrastructure...
    Anthillo provides Software development, Infrastructure...
    Anthillo provides Software development, Infrastructure...
    Contact: office@anthillo.com
    """; // ten sam blok wklejony trzy razy

public String askAnthillo(String question, String lang) {
    String prompt = SYSTEM_PROMPT + languageInstruction(lang) + question;
    return chatClient.call(prompt);
}

Kiedy zajrzeliśmy głębiej w ten string, okazało się, że ten sam blok opisu firmy jest w nim wklejony trzykrotnie — akapity o „Software development”, „Infrastructure”, „Security” powtórzone jeden po drugim. Klasyczny efekt iteracyjnego dopisywania promptu: ktoś dodał sekcję, potem ktoś inny dodał „podobną” gdzieś dalej w pliku, nikt nie zrobił porządku. Efekt: każde zapytanie do modelu, niezależnie od tego, o co pytał użytkownik, niosło ten sam nadmiarowy tekst trzy razy. Wyższy koszt tokenów, większe ryzyko, że model „zgubi się” w powtórzonym kontekście, i zero możliwości skalowania wiedzy — dodanie nowej podstrony do bota oznaczało edycję kodu Javy i redeploy całej usługi. Wiedza firmy żyła w kodzie aplikacji, a nie w osobnej, zarządzalnej warstwie, i nikt nie chciał robić code review nad akapitem marketingowym.

Wersja 2: wydzielony serwis RAG

Drugie podejście (ChatbotServiceImpl, dziś podłączone w kontrolerze) wygląda inaczej: usługa Java nie zna już treści o firmie. Zamiast tego woła osobny serwis RAG przez zwykłe HTTP GET, przekazując pytanie i język, i dostaje z powrotem gotową odpowiedź opartą na retrievalu nad bazą wiedzy o firmie.

ChatbotServiceImpl.java (v2 — po)
public String askAnthillo(String question, String lang) {
    String url = ragServiceUrl + "/query/?question=" + encode(question)
        + "&generation_language=" + lang;

    JSONObject response = restClient.get(url, JSONObject.class);
    return response.getString("response");
}

Zysk jest wyraźny: wiedza firmowa jest oddzielona od kodu usługi i można ją aktualizować niezależnie, bez redeployu Javy. Kod aplikacji przestał być magazynem treści marketingowych, a stał się cienką warstwą orkiestracji.

Wniosek. Prompt stuffing działa jako prototyp, ale nie skaluje się — gdy wiedza rośnie, potrzebujesz osobnej warstwy retrievalu, nie coraz większego stringa w kodzie.

Czego zabrakło: sesji, throttlingu, moderacji

RAG rozwiązał jeden problem, ale nie wszystkie. Backend jest w pełni bezstanowy — jedno pytanie, jedna odpowiedź, brak pamięci konwersacji, brak jakiejkolwiek bazy danych po stronie usługi. Nasza lista TODO w widgecie wprost to przyznaje: „zapisz kontekst użytkownika”, „zapisz pytania użytkownika”, „throttling użytkownika”, „integracja z API moderacji” — nic z tego nie zostało zaimplementowane. Dla publicznego widgetu na stronie firmowej to nie jest kosmetyczny brak. Bez throttlingu każdy odwiedzający może wygenerować dowolnie dużo zapytań do płatnego API. Bez moderacji nie mamy żadnej gwarancji, co ktoś wpisze w pole czatu i jak model na to zareaguje. Bez sesji każda odpowiedź jest oderwana od kontekstu rozmowy, co ogranicza jakość UX, ale przynajmniej nie generuje dodatkowego ryzyka — to jedyny „brak”, który na razie świadomie akceptujemy jako kompromis dla prostego FAQ-bota.

Niespójne kontrakty JSON między serwisami

Osobna lekcja: kod usługi Javy parsował odpowiedź RAG-backendu jako pole content, podczas gdy realna odpowiedź serwisu (i przykład użyty w teście jednostkowym) miała strukturę {"query": "...", "response": "..."}. Kod i test się rozjechały, i nikt tego nie zauważył, bo integracja między dwoma serwisami była testowana ręcznie, nie kontraktowo. Gdyby backend RAG kiedyś realnie zmienił nazwę pola, dostalibyśmy JSONException na produkcji, a nie czerwony test w CI. Wniosek jest prosty: kontrakt HTTP między dwoma serwisami, nawet wewnętrznymi, zasługuje na test kontraktowy, a nie tylko na „działa u mnie w Postmanie”.

Sekrety nie należą do kodu ani do logów

Ostatnia, najbardziej bolesna lekcja dotyczy higieny sekretów. Klucz API do dostawcy LLM (u nas OpenAI) trzeba traktować jak hasło produkcyjne — nigdy nie loguj go, nawet na poziomie DEBUG czy INFO, bo logi trafiają w miejsca, których nie kontrolujesz tak ściśle jak repozytorium kodu. Nigdy nie commituj plików konfiguracyjnych IDE (.idea/) ani magazynów kluczy (*.p12) — to dokładnie ten rodzaj pliku, w którym sekret ląduje przypadkiem, bo IDE zapisuje zmienne środowiskowe uruchomieniowe razem z resztą ustawień projektu. Hasła i klucze powinny trafiać do menedżera sekretów albo zmiennych środowiskowych wstrzykiwanych w runtime, nie do application.yml w repo. I ostatnia rzecz: jeśli kiedykolwiek podejrzewasz, że sekret mógł wyciec — nawet „tylko lokalnie” — rotuj go od razu. Rotacja jest tania, sprzątanie po incydencie nie jest.

Nasz chatbot dziś działa, ale lista TODO jest dłuższa niż lista „zrobione”. Kolejne kroki to sesje z krótką pamięcią rozmowy, throttling per użytkownik i podłączenie moderacji treści, zanim zaczniemy traktować ten widget jako coś więcej niż eksperyment. Największą wartością tego projektu nie jest sam chatbot — to konkretna checklista błędów, którą teraz odhaczamy w każdym kolejnym projekcie AI, zanim cokolwiek wystawimy publicznie.