Horus uruchomił program, w którym co tydzień jedna osoba z zespołu ma trzy dni na jeden eksperyment z AI i jeden dzień na opisanie wyniku. Publikujemy także to, co nie zadziałało, bo w tej dziedzinie ślepe uliczki kosztują więcej czasu niż implementacja. Ten tekst opisuje tydzień pierwszy.
Problem
Zespół Horusa rozwija dla klientów korporacyjnych systemy w Javie i Springu. Materiały, biblioteki i przykłady dotyczące modeli językowych powstają głównie w Pythonie. Naturalna reakcja to postawienie osobnej usługi w Pythonie obok istniejących systemów, co oznacza drugi runtime, drugi pipeline wdrożeniowy i drugi zestaw kompetencji do utrzymania. Koszt tej decyzji ujawnia się nie w pierwszym miesiącu, tylko w trzecim.
Chcieliśmy sprawdzić alternatywę: czy ekosystem javowy dojrzał na tyle, żeby zbudować rozmawiającego agenta bez wychodzenia poza stos, który zespół już utrzymuje.
Agentem miał być doradca oceniający, na jakim etapie przygotowania jest projekt automatyzacji klienta: co jest już ustalone, czego brakuje i jaki jest następny krok. Ocena opiera się na Pragmatic Automation Framework, czyli metodyce, według której Horus prowadzi projekty automatyzacji (opis podejścia). Zakres na trzy dni obejmował działającą rozmowę end-to-end, prowadzoną według zadanej instrukcji, na modelu dostępnym przez API oraz na LLM uruchomionym we własnej serwerowni.
Działanie
Wybór frameworka zajął kwadrans
Spring AI i LangChain4j oferują dziś zbliżony zestaw możliwości: klienta czatu, pamięć rozmowy, tool calling, RAG i obsługę MCP. Wybraliśmy Spring AI, ponieważ backend stoi na Spring Boocie, a integracja z autokonfiguracją i Actuatorem oszczędza pracy.
Praktycznym kryterium przy tej decyzji nie jest lista funkcji, tylko wersja frameworka. Spring AI 2.0 wymaga Spring Boota 4.0 lub 4.1. Projekty pozostające na Boocie 3.x korzystają z linii 1.1.x do czasu aktualizacji frameworka. Zaczynaliśmy od zera, więc wzięliśmy Boot 4.1 i Spring AI 2.0. LangChain4j byłby rozsądniejszy przy Quarkusie lub przy nietypowych dostawcach modeli.
Streaming odpowiedzi
Rozmowa, w której użytkownik czeka kilkanaście sekund na całą odpowiedź, jest odbierana jako
zawieszona. Odpowiedź modelu przekazujemy więc token po tokenie przez Server-Sent Events.
Po stronie Springa sprowadza się to do zamiany call() na stream(), co zwraca Flux<String>,
oraz zadeklarowania typu odpowiedzi:

Nie ma tu ręcznego składania zdarzeń SSE ani własnego bufora na tokeny. Backend jest reaktywny,
więc strumień z modelu trafia w Flux bez warstwy pośredniej. To mocniejszy argument za WebFluksem
w tym zastosowaniu niż zwykłe porównania wydajności.
Pamięć rozmowy
Model domyślnie nie pamięta niczego między żądaniami, a agent diagnostyczny prowadzi wielokrokowy wywiad. Pamięć wpięliśmy deklaratywnie: advisor przy budowie klienta czatu plus identyfikator konwersacji przekazywany w każdym żądaniu. Ten sam identyfikator oznacza tę samą historię, inny otwiera czysty wątek. Konfiguracja zajmuje cztery linie i zadziałała za pierwszym razem.
Zastosowaliśmy okno 20 ostatnich wiadomości trzymane w pamięci procesu. To rozwiązanie na PoC i jego dwa ograniczenia opisujemy niżej.
Instrukcja agenta poza kodem
Instrukcja systemowa agenta ma 14 281 znaków i zmieniała się kilka razy dziennie: to ona zawiera wiedzę o etapach metodyki, regułach prowadzenia rozmowy i granicach, których agent nie przekracza. Trzymanie takiej treści w kodzie Javy wymusza przebudowę aplikacji przy każdej korekcie zdania.
Ładujemy ją przez mechanizm zasobów Springa, więc może pochodzić z classpath (wersjonowana w repozytorium) albo z pliku zewnętrznego edytowanego bez przebudowy jara. Odczyt następuje raz, przy starcie aplikacji, poza ścieżką obsługi żądania. Brak pliku, pusty plik lub błąd odczytu nie zatrzymują startu: aplikacja wstaje z ostrzeżeniem w logu i bez wiadomości systemowej.
Przy dostrajaniu instrukcji warto znać jedną właściwość Spring AI: metoda defaultSystem()
renderuje przekazaną treść jako szablon, więc każde wyrażenie w nawiasach klamrowych jest
traktowane jak zmienna do podstawienia. Instrukcja z przykładem JSON-a przechodzi start aplikacji
i wywraca się dopiero przy pierwszym pytaniu użytkownika. Utrzymujemy ją bez nawiasów klamrowych;
alternatywą jest zmiana ograniczników szablonu na inne znaki.
Podmiana modelu na własny LLM
Pytanie o to, czy treść rozmów trafia do publicznego dostawcy modelu, pada dziś w niemal każdym projekcie. W bankowości, ubezpieczeniach i usługach medycznych jest to warunek wstępny, nie preferencja. Dlatego od początku zakładaliśmy dwa źródła modelu: publiczne API i LLM uruchomiony we własnej serwerowni.
Wewnętrzny LLM wystawia API zgodne z OpenAI, co pozwala przełączać dostawcę modelu bez zmian w kodzie. Całość różnicy mieści się w profilu Springa:

Uruchomienie z flagą --spring.profiles.active=horusllm kieruje ruch do własnej serwerowni,
uruchomienie bez niej do OpenAI. Klucz w obu przypadkach pochodzi z tej samej zmiennej
środowiskowej. Wybór dostawcy modelu jest więc decyzją wdrożeniową, a nie architektoniczną:
klient nie musi rozstrzygać jej na etapie projektu, bo zmiana nie kosztuje przepisywania
aplikacji.
Jedno zastrzeżenie do wersji skonteneryzowanej: obraz wymaga osobnej konfiguracji zaufania do wewnętrznego serwera, więc profil z własnym modelem działa dziś tylko lokalnie. W kontenerze zostaje OpenAI. To świadomy dług, nie przeszkoda techniczna.
Rezultat
Po trzech dniach działa rozmowa end-to-end: interfejs czatu w przeglądarce, streaming odpowiedzi, pamięć kontekstu w obrębie wątku, instrukcja systemowa z pliku, dwa wymienne modele i wersja uruchamiana w Dockerze.
| Miara | Wartość |
|---|---|
| Praca techniczna | 3 dni, 17 commitów |
| Kod Javy w backendzie, bez komentarzy | 104 linie |
| Kod czatu we froncie (React/TSX) | 240 linii |
| Instrukcja systemowa agenta | 14 281 znaków |
| Podłączenie własnego LLM razem z certyfikatem | 15 minut |
Najważniejsza liczba w tej tabeli to relacja dwóch pozycji: instrukcja agenta jest objętościowo mniej więcej dwukrotnie większa niż kod, który ją obsługuje. Praca w projekcie z modelem językowym przenosi się z implementacji do treści instrukcji i do jej testowania. Wycena takiego projektu oparta na liczbie funkcji do zaprogramowania rozminie się z rzeczywistością.
Drugi wniosek dotyczy bariery wejścia. Warstwa AI w backendzie to jeden kontroler, jedna klasa konfiguracyjna i jeden rekord na żądanie. Zespół javowy nie potrzebuje osobnej usługi w Pythonie, żeby zacząć.

Co dalej
To jeszcze nie jest agent w pełnym znaczeniu tego słowa, tylko pętla rozmowy z pamięcią i rozbudowaną instrukcją. Nie ma tool callingu, structured output ani testów regresyjnych instrukcji. Kosztów działania również nie podajemy: zbieramy dane i opublikujemy je, kiedy będą oparte na pomiarze, a nie na szacunku.
Te braki wyznaczają plan na kolejne tygodnie programu: ocena kompletności rozmowy przez structured output, zabezpieczenia sprawdzone serią ataków prompt injection, testy regresyjne instrukcji uruchamiane jak testy jednostkowe oraz porównanie modelu z API z modelem uruchomionym lokalnie. Wyniki każdego z tych tygodni opiszemy tak samo, razem z tym, co nie wyszło.