Prześledzenie kroku odzyskiwania danych: sześć narzędzi obserwowalności porównanych w jednym agencie RAG
Jeden agent RAG zlokalizowano za pomocą sześciu narzędzi do obserwowalności LLM: Opik, MLflow, Langfuse, Phoenix, AcruxCore i Helicone, porównując dekoratory, OpenTelemetry oraz proxy’i.
Źródła: Tracing a RAG pipeline: how six LLM observability tools handle the step that isn’t a model call
Retrieval-augmented generation to proces składający się z dwóch kroków, przy czym tylko jeden z nich polega na wywołaniu modelu. Aby zobaczyć, jak narzędzia do obserwacji rejestrują drugą połowę procesu, ten sam mały agent został wyposażony w narzędzia pomiarowe za pomocą sześciu platform: Opik, MLflow, Langfuse, Phoenix, AcruxCore i Helicone.
Dlaczego proces pobierania danych omija zwykłe narzędzia otaczające
Wywołanie pojedynczego modelu jest łatwe do obserwacji: wystarczy modyfikować klienta OpenAI, a każde wywołanie chat.completions.create() pojawia się jako osobny etap. Dodanie funkcji pobierania danych wydaje się prostym krokiem. Dane kontekstowe są pobierane, model jest wywoływany, a ta sama modyfikacja powinna nadal obejmować wszystko. W praktyce obejmuje ona jednak tylko połowę.
Innymi słowy, pipeline RAG składa się przynajmniej z dwóch odrębnych elementów, które należy śledzić. Jeden to krok pobierania danych, którego nie zauważa żaden wrapper klienta. Drugi to krok generowania treści, który przypomina zwykłą próbę wywołania modelu. Eksperyment skupił się właśnie na tym punkcie połączenia, przy jednoczesnym utrzymaniu wszystkich innych elementów niezmienionych pomiędzy kolejnymi wykonyaniami.
Podsumowanie wyników
- Dekoratory dominują. Opik, MLflow i Langfuse każdy przekształca funkcję w krok podlegający śledzeniu poprzez dodanie jednej linijki kodu, pozostawiając resztę kodu nietkniętą.
- OpenTelemetry działa w dwóch miejscach. Phoenix i AcruxCore oba obsługują format OTLP, więc jeden ręcznie utworzony obiekt danych może zostać wysłany do obu narzędzi poprzez zmianę endpointu. Ten sam pipeline został uruchomiony w obu przypadkach.
- Dwa produkty znajdują się w ścieżce żądania. Proxy Helicone oraz brama AcruxCore rejestrują zakres modelu bez kodu klienta, ale żaden z nich nie zauważa kroku, który w ogóle nie kontaktuje się z dostawcą modelu.
- Helicone nie ma rozwiązania dla operacji pobierania, ponieważ proxy stanowi cały jego produkt, a nie istnieje SDK do rejestrowania tego kroku.
- MLflow oferuje najbogatszą bazę terminologii dotyczącej zakresów, zawierającą piętnaście typów, w tym
RERANKER,GUARDRAILiEVALUATOR, które pochodzą z źródeł SDK, a nie z dokumentacji.
Czym jest typ zakresu
Traktuj ślad jako listę etapów, przez które przechodziła prośba. Każdy etap to element o nazwie, czasie rozpoczęcia i zakończenia oraz danych wejściowych i wyjściowych. To pokazuje, co zostało wykonywane i jak długo to trwało, ale nie charakter pracy: element o nazwie step_2 trwający 900 ms może być równie dobrze zapytaniem SQL, wywołaniem LLM lub żądaniem HTTP. Rozróżnienie ich to zadanie typu elementu, tagu, który to ustala. Każde narzędzie definiuje zamknięty zbiór kategorii, a każdy element otrzymuje jedną z nich.
Weźmy tu agenta RAG. Jedna prośba generuje trzy elementy:
┌────────────────┬────────────────────────────────┬────────────────┐
│ Span name │ What it did │ Span type │
├────────────────┼────────────────────────────────┼────────────────┤
│ rag-agent │ the whole request │ agent or chain │
├────────────────┼────────────────────────────────┼────────────────┤
│ search_docs │ queried Chroma for four chunks │ retrieval │
├────────────────┼────────────────────────────────┼────────────────┤
│ ChatCompletion │ called gpt-4o-mini │ llm │
└────────────────┴────────────────────────────────┴────────────────┘
Tutaj search_docs to po prostu nazwa wybrana przez programistę, natomiast proces pobierania danych pochodzi z listy wartości dostępnych w SDK. Widok prezentuje to jako element Retrieval przed search_docs oraz element LLM przed wynikami uzupełniania tekstu.
Rodzaj ma znaczenie, a nie tylko nazwa, ponieważ narzędzie na nim opiera swoje działanie. Wybiera ikonę i układ, dzięki czemu sekcja Retrieval pokazuje pobraane dokumenty, natomiast sekcja modelu pokazuje liczbę tokenów i koszt. Umożliwia również filtrowanie – pytanie typu „Które kroki pobierania danych trwały ponad sekundę?” działa we wszystkich zapisach, nawet jeśli programiści nazwali swoje funkcje inaczej. Ponadto służy do analiz: określenie, ile opóźnienia pochodzi z procesu pobierania danych w porównaniu z generowaniem, jest możliwe dzięki kolumnie typu.
Ilść dostępnych słowników różni się. AcruxCore umożliwia siedem: llm, tool, retrieval, embedding, agent, chain i other. MLflow oferuje piętnaście, w tym RERANKER, GUARDRAIL, EVALUATOR, PARSER i MEMORY. Opik dopuszcza tylko cztery: general, tool, llm i guardrail.
Testowany agent
Agent jest celowo prosty. Indeksuje pięć stron dokumentacji OpenTelemetry, podzielonych na fragmenty o długości około 1000 znaków, wstawia je za pomocą modelu text-embedding-3-small i przechowuje w pamięci w formie kolekcji Chroma. Jedno stałe pytanie dotyczące tego, czym jest span oraz jak spany rodzicielskie i potomne tworzą ślad, przywołuje cztery takie fragmenty, a gpt-4o-mini pisze odpowiedź na podstawie nich.
Żadna z sześciu platform celowo nie posiada tych dokumentów. Gdyby agent odpowiadał na pytania dotyczące jednego z porównywanych narzędzi, test stałby się błędny, a tekst odpowiedzi pojawiłby się na każdym zrzutku ekranu.
def retrieve_context(collection, question: str, top_k: int = 4) -> str:
[query_embedding] = embed_texts([question])
results = collection.query(query_embeddings=[query_embedding], n_results=top_k)
chunks = results["documents"][0]
sources = [m["source"] for m in results["metadatas"][0]]
return "\n\n".join(f"[{src}]\n{chunk}" for src, chunk in zip(sources, chunks))
Ta funkcja, krok dzielenia na fragmenty oraz wywołanie embeddingu nie zmieniają się podczas sześciu prób. Jedyną zmienną jest instrumentacja otaczająca je. Każde narzędzie zostało uruchomione na lokalnym komputerze: pięć z nich działało w Dockerze, a także AcruxCore. LangSmith i W&B Weave zostały pominęte, ponieważ istnieją jedynie jako usługi hostowane. Sądząc po ich SDK, LangSmith mógłby stanowić czwarty przykład dekoratora z siedmioma typami wykonywania, jednak ze względu na brak jakichkolwiek obserwowalnych śladów nie można o nim nic więcej stwierdzić.
Wzorzec 1: dekorator
Tutaj narzędzia się łączą, i to w sposób rozsądny: ciało funkcji pozostaje dokładnie takie, jak zostało napisane. Kod, który już istnieje, jest jedynie adnotowany. Forma implementacji Opika odpowiada jego dokumentacji:
@opik.track(name="search_docs", project_name=PROJECT_NAME)
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
MLflow ma taką samą strukturę z dołączonym typem rozciągłości:
@mlflow.trace(name="search_docs", span_type=SpanType.RETRIEVER)
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
Langfuse oferuje @observe, co łatwo przeoczyć, gdy najpierw spotyka się przykłady z użyciem menedżerów kontekstu:
@observe(name="search_docs", as_type="retriever")
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
Wywołanie modelu jest automatycznie umieszczane w wszystkich trzech rozwiązaniach. Każde z nich dostarcza ulepszoną wersję klienta OpenAI (track_openai(), mlflow.openai.autolog() oraz langfuse.openai), dzięki czemu proces generowania jest powiązany z funkcją, która jest aktualnie wykonywana. Dlatego jeden dekorator pozwala na utworzenie drzewa o głębokości dwóch poziomów.
Langfuse dało najbardziej uporządkowany wynik. Czas wyszukiwania dokumentów typu retriever trwał 1,05 s, a czas generowania samodzielnie zarejestrowany wyniósł 4,14 s, przy czym łączne koszty i liczba tokenów zostały zebrane razem. W MLflow te same dwa etapy pokazują czas wyszukiwania na poziomie 0,47 s oraz czas generowania Completions na poziomie 3,37 s. Opik również tworzy poprawne drzewo, z wyjątkiem drobnego problemu estetycznego: obszary trace i root mają ten sam nazwę, więc rag-agent jest wymieniony dwa razy powyżej elementu search_docs.
Wzorzec 2: menedżer kontekstu dostawcy
Langfuse udostępnia również sam obiekt span, który jest narzędziem do użycia, gdy granice span nie pokrywają się z granicami funkcji:
with langfuse.start_as_current_observation(name="search_docs", as_type="span") as span:
context = retrieve_context(collection, QUESTION)
span.update(input={"query": QUESTION}, output={"context": context})
Zyskiem jest precyzja, ponieważ dane wejściowe i wyjściowe mogą być ustawione dokładnie w odpowiednim momencie, a określony przedział może obejmować część funkcji. Stratą jest separacja, ponieważ kod śledzący znajduje się wewnątrz logiki biznesowej. Gdy jednostką jest cała funkcja, @observe daje ten sam efekt bez tych wad.
Wzorzec 3: standardowy OpenTelemetry
Phoenix nie posiada własnego dekoratora. Bezpośrednio używany jest śledzący z OpenTelemetry, a narzędzie OpenInference automatycznie otacza klienta, dzięki czemu wywołanie generowania nadal pozostaje wewnątrz tej struktury:
with tracer.start_as_current_span("search_docs") as span:
context = retrieve_context(collection, QUESTION)
span.set_attribute("output.value", context)
Zaletą jest to, że nic tutaj nie należy do Phoenix. Są to standardowe obszary OTel, więc ta sama infrastruktura pomiarowa może dostarczać dane do dowolnego backendu kompatybilnego z OTLP po zmianie punktu końcowego. To twierdzenie przeszło rzetelną weryfikację, ponieważ przenośność często wydaje się prawdziwa na papierze, ale w praktyce zawodzi. AcruxCore również obsługuje OTLP, więc ten sam pipeline został dostosowany do niego: identyczna struktura with tracer.start_as_current_span(...), identyczny narzędzie pomiarowe, nowy punkt końcowy.
tracer_provider = otel.register(service_name="rag-agent-observability")
OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
Każda platforma posiada pomocnik register(), który łączy w jedną wywołanie TracerProvider, BatchSpanProcessor oraz eksporter OTLP. Żaden z tych pomocników nie jest obowiązkowy, a ręcznie budowane łańcuchy przetwarzania działają z obydwoma. Spany zostały przesłane w prawidłowo zagnieżdżonej formie. Atrybut RETRIEVER w OpenInference stał się rodzajem Retrieval, a automatycznie zapisany span modelu wymieniał model oraz 1 341 tokenów.
Zatem przenośność jest zachowana, i to najlepszy powód do pisania narzędzi pomiarowych w czystym OTel: spany są zapisywane tylko raz, a to, który backend je otrzyma, staje się kwestią ustawień. Koszt jest znany z góry – wywołanie register(), narzędzie pomiarowe oraz obiekt śledzący poprzedzają pierwszy span, co stanowi więcej elementów strukturalnych niż potrzebuje jakikolwiek dekorator dla tych samych dwóch spanów.
Wzorzec 4: pobieranie poza standardowym łączeniem
Oprócz OTLP, AcruxCore oferuje piątą metodę, której brakuje w innych narzędziach. Zamiast obserwować proces, kod opisuje okresy działania i je publikuje. Identyfikator śledzenia łączy wszystko ze sobą: przekazując go bramie, opisany po stronie serwera model okresu działania dołącza do tego samego śledzenia. Te dwie metody uzupełniają się wzajemnie.
reported = await hub.traces.ingest({
"name": "rag-agent",
"spans": [{
"spanId": "search_docs", "name": "search_docs", "kind": "retrieval",
"input": {"query": QUESTION}, "output": {"context": context},
"startTime": started, "endTime": _now(),
}],
}, wait=False) # buffered, returns at once
result = await hub.gateway.chat(
"openai/gpt-4o-mini", messages, provider=PROVIDER,
trace={"trace_id": reported.trace_id}, # generation lands in the same trace
)
Wymaga mniej wywołań niż budowa narzędzia śledzenia, a daty czasowe są ustalane przez osoby wywołujące, co pasuje do zadań, które już się zakończyły i nie pozostawiły żywego stosu wywołań do dołączenia.
Prymatem jest flaga wait=False. W trybie domyślnym ingest() czeka na żądanie POST i zwraca identyfikator śledzenia wygenerowany przez serwer, więc żądanie musi przejść przez całą trasę sieciową przed rozpoczęciem wywołania modelu. Przy użyciu wait=False dane trafiają do lokalnego bufora SDK, a identyfikator jest tworzony po stronie klienta. Na miejscu wywołania nic nie jest potwierdzane, a nieudane wysyłanie jest rejestrowane jako ostrzeżenie i odrzucane, podobnie jak w przypadku eksportera OTel. Decyzja dotyczy tego, czy w przypadku błędu mieć wolniejsze, głośniejsze działanie, czy szybsze, cichsze. Obie opcje są uzasadnione, o ile wybór jest świadomy.
Wzorzec 5: obecność w ścieżce żądania
Dwa z tych narzędzi znajdują się wewnątrz samej rozmowy, w wyniku czego informacje o modelu są dostępne bez dodatkowych kosztów. Helicone pokazuje to najwyraźniej: wystarczy zmienić base_url tak, by wskazywał na nie, a uzupełnianie tekstu odbywa się bez żadnego kodu SDK. Gateway AcruxCore postępuje w ten sam sposób. Rozmowa przekierowana przez gateway.chat() ma swoje informacje o llm zapisywane przez serwer, więc klient nie potrzebuje żadnych dodatkowych narzędzi.
Taka metoda ma jedną zaletę i jedną wadę, obie wspólne dla obu rozwiązań. Wszystko, co znajduje się w ścieżce żądania, widzi jedynie ruch HTTP skierowany do dostawcy modelu. Zapytanie Chroma nie generuje takiego ruchu, więc pozostaje niewidoczne niezależnie od użytego narzędzia.
Różnią się tym, co im towarzyszy. Brama AcruxCore przyjmuje identyfikator śledzenia, który pozwala na to, by fragment zapisany przez inną trasę, OTel lub ingest(), pełnił rolę elementu nadrzędnego, dzięki czemu model zapisany na serwerze trafia do tego samego śledzenia. Helicone oferuje jedynie proxy, bez SDK do otaczania dowolnej funkcji. Pozostają zatem dwie opcje: pominięcie pobierania danych ze śledzenia lub stworzenie fałszywego zakończenia w celu jego przeniesienia, przy czym ta druga metoda wypełnia dane telemetryczne fikcją. Dokładniejszy opis jest zatem szerszy niż stwierdzenie o niemożności śledzenia pobierania danych przez proxy: proxy sam w sobie nie może tego zrobić, a znaczenie tego faktu zależy od tego, czy istnieje druga trasa.
Jedna uwaga dotycząca wdrożenia, niezwiązana z projektem: wersja Helicone uruchamiana lokalnie w tym teście odpowiadała na żądanie dotyczące pliku logów ręcznych kodem 500, ponieważ brakowało zmiennej S3_REGION, co uniemożliwiło zapisanie nawet połowy danych modelu. To wada konkretnej wersji oprogramowania, a nie samego podejścia.
Co można nazwać „spanem”
Dwa „spany” pokazują, że cała infrastruktura funkcjonuje prawidłowo, ale nie są w stanie odróżnić narzędzia do ponownego rankowania od mechanizmu ochronnego po upływie pół roku. Dlatego dozwolone wartości zostały pobrane z każdego z zainstalowanych SDK, a nie z ich dokumentacji.
MLflow znacznie wyprzedza pozostałe w tej kategorii. RERANKER, GUARDRAIL, EVALUATOR, PARSER oraz MEMORY są wbudowane, natomiast Opik oferuje cztery typy, a wszystko inne trafia do metadanych. Siedem typów dostępnych w AcruxCore obsługuje RAG, ale nie w bardziej szczegółowy sposób. Phoenix odziedziczył swój wykaz z konwencji OpenInference, więc jest on wspólny dla wszystkich narzędzi stosujących ten standard i nie stanowi funkcji specyficznej dla Phoenixa.
To właśnie ten wiersz najczęściej jest błędnie oceniany na podstawie stron marketingowych. Każdy z tych wykazów można sprawdzić w odniesieniu do zainstalowanych wersji w zaledwie kilku liniach kodu:
from mlflow.entities import SpanType
from openinference.semconv.trace import OpenInferenceSpanKindValues
print([v for k, v in vars(SpanType).items() if isinstance(v, str) and not k.startswith("_")])
print([e.value for e in OpenInferenceSpanKindValues])
Langfuse przykłada szczególną uwagę. Jego ObservationTypeLiteral, dostępny w langfuse._client.span, jest tworzony z nawarstwionych typów Literal, dlatego typing.get_args zwraca wewnętrzne aliasy zamiast ciągów znaków, a wynik musi być rekurencyjnie spłaszczany. To wyjaśnia, dlaczego dane z jego dokumentacji często różnią się od tych dostępnych w SDK.
Wybór
- MLflow nadaje się dla zespołów, które chcą precyzyjnej semantyki już teraz oraz możliwości oceny później. Jego słownictwo jest zdecydowanie najszersze, choć cała struktura jest cięższa niż w przypadku czystego narzędzia do śledzenia.
- Opik umożliwia uruchomienie poprawnego drzewa strukturalnego jeszcze tego samego popołudnia, przy minimalnych formalnościach i z zaledwie czterema typami span.
- Langfuse oferuje zarówno dekorator, jak i możliwość ręcznego rozwiązania problemów dzięki silnemu typowaniu, ale kosztem dwóch zestawów dokumentacji.
Prawdziwą lekcją tutaj jest brak rankingu. Zapytanie o to, czy narzędzie rejestruje proces pozyskiwania danych, okazało się zawierać pięć odrębnych pytań, a każda odpowiedź to decyzja projektowa, która ma swoją widoczną cenę: linijkę kodu, blok with, prace przygotowawcze, skok w sieci lub „ślepy punkt”. Wybierz tę opcję, która kosztuje najmniej, i sprawdź słownictwo używane do rejestrowania danych przed podjęciem decyzji, ponieważ to właśnie tam pojawiają się niespodzianki w późniejszym etapie.