Pytaj o dane z NetFlow językiem naturalnym

To jeden z tych projektów, które miały iść do szuflady lub żyć zaledwie parę dni. Miałem pod ręką sporo danych z NetFlow i wpadłem na pomysł by napisać chatbota. Aby pytać o te dane za pomocą języka naturalnego. W sumie nigdy nie pisałem chyba chatbota więc why not? Ponieważ głównie dotychczas programowałem modele w OpenAI albo w Azure. Niekoniecznie chciałem dane z własnego NetFlow udostępniać publicznym modelom, więc padło na uruchomienie modelu na własnym serwerze. Do tego to był ten tydzień gdzie gruchnęła wiadomość o chińskim modelu DeepSeek. W ostateczności i tak dane do modelu nie trafiają. Nie muszą bo są w lokalnym ElasticStack, a zmiana koncepcji przyszła już w trakcie pisania, ale chęć użycia lokalnego modelu pozostała. I parę osób prosiło o opowiedzenie czegoś więcej o projekcie i udostępnienie kodu. Zapraszam zatem do lektury i do repozytorium.

Architektura

Zacznijmy od krótkiego omówienia architektury.

Diagram architektury skryptu chatbota

Dane Netflow z dwóch firewalli zbierane są przez kolektor ElasticFlow. To bardzo wygodny i w wielu przypadkach darmowy kolektor NetFlow/sFlow/IPFIX z wbudowanymi mechanizmami eksportu danych na przykład do ELK. ElasticSearch wykorzystywany jest zarówno do odbierania danych z ElasticFlow jak i udostępniania tych danych innym aplikacjom – na przykład Kibana do wizualizacji lub mojemu skryptowi.

Modele LLM uruchomione są na dedykowanym serwerze z odpowiednim GPU. Modele uruchamiam za pomocą oprogramowania Ollama, które w sieci lokalnej wystawia interfejs API do zapytań.

Skrypt przyjmuje zapytanie użytkownika sformułowane w naturalnym języku. Następnie wysyła to zapytanie do modelu, aby uzyskać od niego w odpowiedzi strukturę filtra DSL. Filtr ten jest niezbędny aby wydobyć z Elasticsearch tylko interesujące nas rekordy. Wygenerowany przez model filtr wykorzystywany jest zapytaniu do Elasticsearch. Otrzymana odpowiedź ma strukturę JSON, więc jest ponownie parsowana przez model LLM, aby przetłumaczyć ją na język naturalny i wyświetlić użytkownikowi.

Zapytania do Elasticsearch

Zacznijmy trochę nie po kolei bo od zapytań do Elasticsearch. Za ich obsługę odpowiada metoda query_es() zdefiniowana w module ex_client.py. Komunikacja jest bardzo prosta ponieważ wykorzystywane jest tutaj API Elasticsearch. Musimy odpytać się o dane dostępne pod wskazanym indeksem lub grupą indeksów oraz zawęzić kryteria zapytania  za pomocą flitra DSL. Filtr DSL (Domain Specific Language) w Elasticsearch to specyficzny język zapytań umożliwiający definiowanie precyzyjnych operacji wyszukiwania i filtrowania danych w indeksach. Jego składnia opiera się na formacie JSON, co sprawia, że zapytania są czytelne i łatwo integrowalne z innymi systemami wykorzystującymi ten popularny format danych. Oto przykład:

{
   "query":{
      "bool":{
         "must":[
            {
               "term":{
                  "destination.port":9200
               }
            },
            {
               "range":{
                  "@timestamp":{
                     "gte":"now-15m",
                     "lt":"now"
                  }
               }
            }
         ]
      }
   },
   "aggs":{
      "unique_hosts":{
         "terms":{
            "field":"source.ip",
            "size":1000
         }
      }
   },
   "size":0
}

Elasticserarch udostępnia dobrze udokumentowane API. Początkowo próbowałem wykorzystać standardową bibliotekę requests do tego, aby wysyłać i odbierać dane za pomocą tego API. Niestety pojawiły się problemy związane ze sposobem formatowania kluczy w strukturze DSL, która wysyłana jest w body zapytania. API wymagało by nazwy kluczy były w cudzysłowach, zaś biblioteka requests zmieniała je na apostrofy. Szybko więc przesiadłem się na bibliotekę elasticsearch-py gdzie obsługa API realizowana jest za pomocą dedykowanych metod. Do nawiązania połączenia utworzyłem metodę get_es_client(). Metodzie search() z tej bilioteki przekazuję tylko informację o indeksach, które mają być przekazywane oraz filtr DSL. W odpowiedzi otrzymuję albo dane, albo błąd.

Poproś LLM by napisał filtr DSL

Input użytkownika pobieramy z konsoli. Temu co napisał użytkownik musimy nadać jednak pewien kontekst w samym zapytaniu do modelu LLM. Dlatego zapytanie użytkownika umieszczamy w większym prompt ADAPT_ES_QUERY zdefinowanym w pliku prompt_templates.py. Nadajemy tym samym zapytaniu kontekst i uzupełniamy go o dodatkowe dane. Prompt ten zawiera też szczegółowe informacje jaki wynik chcemy od modelu uzyskać. W pierwszej iteracji programu przekazywałem do generowanego promptu jedynie zapytanie użytkownika, potem rozszerzając je o dwie dodatkowe informacje, o czym za chwilę. 

Za komunikację z API Ollama odpowiada metoda generate_text() z modułu llm_client.py. Wykorzystuje ona bibliotekę requests do wysłania zapytania POST umieszczając w payload zapytania informację o to do jakiego modelu chcemy się odwołać i treść zapytania. Tam też umieszczamy dodatkowe flagi jak na przykład wyłączenie strumieniowania odpowiedzi.

Tą samą metodę i podejście wykorzystuję do analizy odpowiedzi z Elasticsearch i przetłumaczenia jej w kontekście zapytania użytkownika na zrozumiałą dla niego odpowiedź. Wykorzystuję do tego tylko inny prompt, którego szablon jest zapisany w GENERATE_FINAL_ANSWER.

Pierwsze uruchomienine

Mamy zatem wydaje się gotowy produkt, który działa następująco:

  1. Użytkownik wpisuje swoje zapytanie na konsoli
  2. Skrypt prosi model LLM o przetłumaczenie zapytania użytkownika na filtr DSL do Elasticsearch
  3. Wykorzystując otrzymany filtr skrypt wykonuje kwerendę wskazanych indeksów w Elasticsearch definiując paramtery zapytania za pomocą utworzonego filtra DSL.
  4. Otrzymaną z Elasticsearch odpowiedź w formacie JSON przekazujemy do modelu LLM by w kontekście zadanego pytania przetłumaczył odpowiedź z Elasticseach na język naturalny
LLM-Chatobot

Easy, what can go wrong? 🙂

Problemy z odpowiedziami z LLM

Generowanie odpowiedzi było przeze mnie testowane na modelach Llama3, DeepSeek-R1 (z parametrami 14B, 32B i 70B) oraz qwen2.5-coder (z parametrami 3B i 32B). O znaczeniu parametrów napiszę za chwilę. Pierwszy problem był nieco inny. Niektóre modele nie chciały zwracać w odpowiedzi jednie struktury JSON filtra DSL. Mówię tu przede wszystkim od DeepSeek-R1, które zawsze ignorowało zawarte w prompt żądanie o zwrócenie jedynie struktury JSON. Zawsze w odpowiedzi był załączony cały tok „rozumowania” modelu, jakbym prosił o takie wyjaśnienie.

Drugi problem polegał na tym że niektóre modele czasem nie zwracały czystego obiekt JSON zgodnie z moim żądaniem. Dodawały na przykład formatowanie Markup Language. Dlatego w module llm_client.py dodałem metodę extract_json_from_llm_response(), która odpowiedzialna jest za usunięcie z odpowiedzi wszystkich nadmiarowych informacji. Wywoływana jest ona w samej metodzie generate_text()

Tu uwaga – przypominam, że to jest kod proof-of-concept. W module llm_client.py powinno być o wiele więcej obsługi wyjątków, sprawdzania poprawności danych, itp. 

Rozwiązanie pierwszego problemu uwidoczniło drugi, poważniejszy. Okazało się, że modele nie generują poprawnych struktur DSL. Ta niepoprawność objawiała się na dwa sposoby. Po pierwsze model „wymyślał” sobie nazwy pól z danych z NetFlow, które wykorzystywał w filtrach. Na przykład w pytaniu o ruch na port docelowy raz poszukiwał pola „destination.port„, innym razem „port” a jeszcze innym „destination_port„. Pojawiały się też często inne wartości. Drugi problem to nieprawidłowa struktura samego filtra DSL. Znane mu słowa kluczowe takie jak „aggs” czy „size” umieszczane były w niewłaściwych miejscach struktury, przez co cała konstrukcja była niepoprawna.

Jak działa LLM?

I tu dochodzimy do części teoretycznej dlaczego tak się dzieje. Od razu zaznaczam, że informacje w tym akapicie są nieco uproszczone, aby były zrozumiałe także dla osób, które nie wiedzą jak takie modele działają. Bez nadmiernego zagłębiania się w detale.

LLM-how-it-works
Obrazek poglądowy, nie przedstawia rzeczywisego diagramu jak działają modele LLM

Niepoprawne generowanie filtrów DSL może wynikać z kilku czynników. Trzy podstawowe to:

  1. Brak precyzyjnych danych treningowych i specyficznych wzorców: 
    Modele językowe uczą się na ogromnych zbiorach danych, które mogą nie zawierać wystarczająco szczegółowych i ujednoliconych przykładów zapytań DSL do Elasticsearch. Szczególnie dla specyficznych zastosowań jak analiza ruchu NetFlow. W rezultacie, model „halucynuje” nazwy pól i może błędnie układać strukturę zapytania, umieszczając kluczowe elementy w niewłaściwych miejscach. Jest to efekt braku silnego nadzoru nad precyzyjną składnią i strukturą DSL, co powoduje, że model improwizuje na podstawie niespójnych lub niepełnych wzorców występujących w danych treningowych.
  2. Ograniczenia modelu w zakresie rozumienia struktury domenowej:
    Modele LLM potrafią generować tekst na podstawie prawdopodobieństwa kolejnych słów, jednak bez wyraźnego mechanizmu do walidacji struktury wynikowego kodu lub zapytania, mogą popełniać błędy syntaktyczne i semantyczne. Brak mechanizmu walidacji w trakcie generowania powoduje, że nawet jeśli model „wie”, jakie słowa kluczowe powinny być użyte, nie zawsze potrafi poprawnie ustawić je w hierarchii wymaganej przez DSL.
  3. Wybrany został model o zbyt małej liczbie parametrów:
    Można potencjalnie zwiększać zdolność modelu do przechwytywania złożonych wzorców i relacji w danych, co teoretycznie przekłada się na bardziej precyzyjne odpowiedzi. Jednak większa liczba parametrów również niesie ze sobą wyzwania, takie jak wyższe wymagania obliczeniowe, większa podatność na „halucynacje” oraz trudności w precyzyjnym kontrolowaniu generowanego tekstu w bardzo specyficznych dziedzinach. Mniejsza liczba parametrów oznacza, że model może mieć ograniczoną zdolność do modelowania bardzo skomplikowanych zależności, co również może wpływać na precyzję w generowaniu specjalistycznych struktur DSL, choć może być bardziej „ostrożny” w improwizacji. Liczba przyrostków takich jak „70B” czy „32B” w nazwie modelu odnosi się do liczby parametrów w modelu (odpowiednio 70 miliardów i 32 miliardy).

Tuning LLM

Problemy z LLM można próbować rozwiązać. Pierwszym pomysłem było przejście na wykorzystanie modeli w OpenAI albo w Azure. Szczególności tych, których nie uruchomię lokalnie na swoim serwerze. Takie modele zwracały o wiele bardziej poprawne dane, szczególnie jeżeli chodzi o poprawność samej struktury DSL. Widać są nieco lepiej dotrenowane. Natomiast ideą projektu było, że korzystam z modelu lokalnego.

Druga opcja, to wzbogacenie promptu zapytania do LLM. Dlatego w szablonie ADAPT_ES_QUERY przekazuję dwie dodatkowe informacje. Jako base_query_template przekazuję przykładową strukturę filtra DSL na wzór. Ma to pomóc modelowi lepiej konstruować odpowiedź. Przykładowy filtr zapisany jest w pliku w repozytorium.

Drugi parametr index_mapping to mapowanie pól w indeksie Elasticseach. Ma to pomóc modelowi używać prawidłowe słowa kluczowe. Indeks waży kilka megabajtów i jest pobierany z Elasticflow za pomocą API.

Wprowadzenie tych zmian spowodowało, że zapytanie do modelu jest o wiele większe a co za tym idzie potrzebne są o wiele większe zasoby na jego obsłużenie. Spowodowało to nieznaczną poprawę zwracanych przez model danych, niestety nie na tyle by można było powiedzieć że problem został rozwiązany.

O wiele lepiej model sobie tu radzi z tłumaczeniem odpowiedzi z Elasticsearch na język naturalny. O ile oczywiście odpowiedź zawiera dane, o które pytał użytkownik, I tu znowu dochodzimy do kwestii tego że jest to projekt proof-of-concept i odpowiednie mechanizmy weryfikacji odpowiedzi z Elasticsearch powinny zostać zaimplementowane. W przeciwnym razie interpretacja pustych lub niewłaściwych danych przez model wygeneruje użytkownikowi nieprawidłową odpowiedź.

Dalsze pomysły na tuning

I tu dochodzimy do sekcji pod tytułem „co jeszcze można zrobić”. Tych pomysłów na chwilę obecną nie testowałem.

  1. Wykorzystanie odpowiednio wytrenowanego modelu:
    W swoich testach jestem ograniczony wydajnością serwera i karty graficznej na której uruchamiane są modele LM oraz dostępności samych modeli. Modele takie jak o1 z OpenAI potrafiły chyba lepiej generować odpowiedzi niż Llama3. Testowałem też polecony mi model qwen2.5-coder. Model ten został fine-tunowany na dużych zbiorach danych zawierających kod oraz dokumentację techniczną, co umożliwia mu generowanie spójnego, poprawnego i kontekstowo dopasowanego kodu w różnych językach programowania, takich jak Python, JavaScript, Java, C++ i inne. Dzięki temu model potrafi nie tylko tworzyć nowe fragmenty kodu, ale również pomagać w debugowaniu, refaktoryzacji czy analizie istniejących rozwiązań. Niestety, ja nie widziałem za bardzo różnicy między nim a na przykład DeepSeek-R1.
  2. Poszukanie modelu wytrenowanego do pracy z Elasticsearch:
    Niestety nie znalazłem publicznie dostępnego modelu, który byłby już wytrenowany na danych specyficznych do komunikacji z Elasticsearch. Czy taki model istnieje w formie darmowej lub komercyjnej? Tego nie wiem. Czy powstanie? To nie jest pytanie do mnie , ale producenci Elasticseach dość mocno patrzą na wykorzystanie AI więc może coś takiego się pojawi.
  3. Własnoręczne dotrenowanie wybranego modelu:
    Próba własnoręcznego fine-tuningu modelu też wchodzi w grę. Wiąże się to jednak z koniecznością posiadania danych treningowych i odpowiedniego ich przygotowania. Trenowanie modelu nie polega na wrzuceniu w jakiś sposób w niego pliku tekstowego i  oczekiwaniu, że model magicznie się z tego nauczy jak z danych korzystać. Nie posiadam też odpowiednich danych treningowych ponieważ na co dzień z Elasticsearch nie pracuję.
  4. Modyfikacja parametrów zapytania:
    Przy korzystaniu z interfejsów API modeli językowych, do zapytania można przekazać szereg parametrów, które wpływają na sposób generowania odpowiedzi. To są parametry, których w interfejsach webowych nie widać. Temperature określa poziom „losowości” w generowaniu tekstu. Wartość bliska 0 powoduje, że model generuje bardziej przewidywalne i deterministyczne odpowiedzi, natomiast wyższe wartości (np. 0.8 lub 1.0) zwiększają różnorodność wyników, co może prowadzić do bardziej kreatywnych, ale czasami mniej spójnych odpowiedzi. top_p (nucleus sampling) ustawia próg prawdopodobieństwa, w ramach którego model wybiera kolejne tokeny. Przykładowo, wartość top_p=0.9 oznacza, że model będzie wybierał spośród tokenów, których łączna suma prawdopodobieństwa wynosi 90%. Ten parametr pomaga w kontrolowaniu dystrybucji prawdopodobieństw i może wpłynąć na kreatywność generowanego tekstu. Tym sposobem możnaby spróbować kontrolować poziom „kreatywności” generowanych odpowiedzi.
W repozytorium projektu umieściłem poprawny filtr DSL do zapytania o to, które urządzenia w sieci ciągu ostatnich 10 minut komunikowały się na porcie 9200. Wywołując skrypt z parametrem --override-dsl możecie samodzielnie sprawdzić jak bardzo generowanie odpowiedzi z modelu LLM odbiega od oczekiwanego efektu zadając odpowiednie pytanie. Możecie też sprawdzić i jak model LLM zinterpretuje odpowiedź z Elasticsearch. Dodatkowo wywołanie z --debug wyświetl dużo informacji na ekranie, a nie tylko komunikaty błędów.

Podsumowanie

Ten mały projekt pokazał mi, że jestem w stanie napisać prostego chatbota w ciągu jednego wieczora oraz także jakie ograniczenia mają obecne modele LLM. Pamiętajmy że nieco inaczej pracujemy z nimi jeżeli mamy interfejs chatu na stronie, a inaczej programowalnie. Projekt uznaje za sukces, ponieważ pokazał, że taki chatbot potencjalnie może powstać. 

A co się przy tym nauczyłem dodatkowo to moje 🙂

E-BOOK

Zaczynasz swój pierwszy projekt związany z automatyzacją?

Ten e-book jest dla Ciebie! Zawiera sprawdzone podejście, które realizowałem w wielu projektach. Sprawdź co możesz zrobić, by odnieść sukces!


Subscribe
Powiadom o
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 komentarzy
najstarszy
najnowszy oceniany
Inline Feedbacks
View all comments

ZdradziĆ Ci sekretY udanego projektu automatyzacji?

(link otwiera się w nowym oknie)