Аналіз кроку отримання даних: порівняння шести інструментів для спостережуваності на одному агенті RAG
Один агент RAG було простежено за допомогою шести інструментів для огляду LLM: Opik, MLflow, Langfuse, Phoenix, AcruxCore та Helicone, порівнюючи декоратори, OpenTelemetry та проксі.
Джерела: Tracing a RAG pipeline: how six LLM observability tools handle the step that isn’t a model call
Генерація з підтримкою пошуку — це двокроковий процес, і лише один з кроків передбачає виклик моделі. Щоб побачити, як інструменти для спостереження фіксують другу половину процесу, той самий невеликий агент було оснащено засобами відстеження через шість платформ: Opik, MLflow, Langfuse, Phoenix, AcruxCore та Helicone.
Чому пошук обходить звичайні механізми обгортки
Виклик однієї моделі легко відстежувати: достатньо змінити клієнт OpenAI, і кожен виклик chat.completions.create() буде фіксуватися як окремий етап. Додавання функції пошуку здається дрібним кроком. Контекст завантажується, модель викликається, і та сама зміна має охоплювати все. На практиці вона охоплює лише половину.
Іншими словами, підхід RAG складається принаймні з двох окремих етапів, які потрібно відстежувати. Один з них — це етап отримання даних, який залишається непоміченим клієнтським обгортком. Інший — це етап генерації, який схожий на звичайний виклик моделі. Експеримент був спрямований саме на цей розрив, залишаючи все інше незмінним між запусками.
Коротко про результати
- Декоратори домінують. Opik, MLflow та Langfuse кожен перетворюють функцію на етап, який можна відстежувати, додавши один рядок, не торкаючись її тіла.
- OpenTelemetry працює у двох середовищах. Phoenix та AcruxCore обидва підтримують OTLP, тож один вручну створений спан може бути надісланий до будь-кого з них шляхом зміни кінцевої точки. Той самий підхід було використано для роботи з обома.
RERANKER, GUARDRAIL та EVALUATOR; ці типи враховуються на основі коду SDK, а не документації.Що таке тип інтервалу роботи
Уявіть собі трек як список етапів, через які проходить запит. Кожен етап — це спан, який має назву, час початку та кінця, а також вхідні та вихідні дані. Це розкриває, що виконувалося та скільки часу це тривало, але не характер роботи: спан під назвою step_2 тривалістю 900 мс може бути як запитом до SQL, так і викликом LLM чи отриманням даних через HTTP. Розрізнення цих варіантів — завдання типу спану, тега, який це визначає. Кожен інструмент визначає закритий набір категорій, і кожен спан отримує одну з них.
Візьмемо агента RAG. Один запит створює три спани:
┌────────────────┬────────────────────────────────┬────────────────┐
│ 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 │
└────────────────┴────────────────────────────────┴────────────────┘
Тут search_docs — це просто назва, обрана розробником, тоді як процес отримання даних здійснюється зі списку значень, які дозволяє SDK. Переглядач відображає це як чіп Retrieval перед search_docs та чіп LLM перед результатом завершення пошуку.
Важливий не лише назва, а й тип, оскільки інструмент працює з ним. Він вибирає іконку та формат відображення: так чіп Retrieval показує отримані документи, а чіп моделі — кількість токенів та витрати. Це дозволяє фільтрувати інформацію, тож запит на кшталт „Які кроки отримання даних тривали більше секунди?“ працює з усіма записами, навіть якщо розробники дали своїм функціям інші назви. Крім того, це сприяє аналітиці: кількість затримки, пов’язаної з отриманням даних порівняно з їх генерацією, відображається у стовпці типу.
Довжина словників варіюється. AcruxCore дозволяє використовувати сім: llm, tool, retrieval, embedding, agent, chain та інші. MLflow дозволяє п’ятнадцять, серед яких RERANKER, GUARDRAIL, EVALUATOR, PARSER та MEMORY. Opik дозволяє лише чотири: general, tool, llm та guardrail.
Експериментальний агент
Агент спеціально робиться простим. Він індексує п’ять сторінок документації OpenTelemetry, розділених приблизно на фрагменти по 1 000 символів, вбудовує їх за допомогою text-embedding-3-small та зберігає у колекції Chroma в оперативній пам’яті. Одне фіксоване запитання про те, що таке span та як батьківські та дочірні spans утворюють trace, отримує чотири фрагменти, і gpt-4o-mini формує відповідь на основі цих фрагментів.
Жодна з шести платформ навмисно не володіє документами. Якби агент відповідав на запитання щодо одного з порівнюваних інструментів, тест став би циклічним, а текст відповіді з’являвся б на кожному скріншоті.
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))
Ця функція, крок розділення на частини та виклик ембеддингу не змінюються під час шести запусків. Єдиною змінною є інструментарій, який їх оточує. Кожен інструмент працював на локальній машині: п’ять з них були розміщені у Docker, а також AcruxCore. LangSmith та W&B Weave були пропущені, оскільки існують лише у вигляді хостованих сервісів. Судячи з його SDK, LangSmith міг би стати четвертим прикладом декоратора з сімома типами запуску, проте оскільки не спостерігалося жодних слідів його роботи, немає підстав щось додатково стверджувати про нього.
Шаблон 1: декоратор
Саме тут інструменти збігаються, і це цілком логічно: тіло функції залишається абсолютно таким, як написано. Існуючий код просто отримує анотації. Формат Opik відповідає його документації:
@opik.track(name="search_docs", project_name=PROJECT_NAME)
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
MLflow має таку саму структуру з доданим типом проміжку:
@mlflow.trace(name="search_docs", span_type=SpanType.RETRIEVER)
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
Langfuse пропонує @observe; його легко не помітити, коли спочатку зустрічаються приклади використання контекст-менеджерів:
@observe(name="search_docs", as_type="retriever")
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
Виклик моделі автоматично вкладається у всі три підходи. Кожен з них надає модифікований клієнт OpenAI (track_openai(), mlflow.openai.autolog() та langfuse.openai), тож процес генерації залежить від тієї функції, яка наразі виконується. Отже, один декоратор створює дерево двох рівнів глибини.
Langfuse дав найбільш охайний результат. Час пошуку документів типу retriever-tracked становив 1,05 с, а час генерації, зафіксований самим інструментом, — 4,14 с, з урахуванням загальної кількості операцій та токенів. У MLflow ті самі два етапи показують час пошуку у 0,47 с та час генерації у 3,37 с. Opik також створює правильне дерево, за винятком естетичної особливості: етапи trace та root мають однакову назву, тому rag-agent відображається двічі після search_docs.
Шаблон 2: менеджер контексту від постачальника
Langfuse також надає доступ до самого об’єкта span, який є інструментом для вирішення проблеми, коли межі span не збігаються з межами функції:
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})
Перевагою є точність, адже вхідні та вихідні дані можна встановити саме у потрібний момент, а діапазон може охоплювати частину функції. Недоліком є втрата розділення, оскільки код відстеження опиняється всередині бізнес-логіки. Коли одиницею є ціла функція, @observe досягає того ж результату без цього недоліку.
Шаблон 3: стандартний OpenTelemetry
У Phoenix немає власних декораторів. Використовується безпосередньо трекер OpenTelemetry, а інструментатор OpenInference автоматично обгортає клієнта, тож виклик генерації все одно залишається вкладеним:
with tracer.start_as_current_span("search_docs") as span:
context = retrieve_context(collection, QUESTION)
span.set_attribute("output.value", context)
Перевага полягає у тому, що ніщо тут не належить Phoenix. Це стандартні сегменти OTel, тож та сама система вимірювання може передавати дані до будь-якого бекенду, сумісного з OTLP, як тільки зміниться кінцева точка. Це твердження пройшло справжню перевірку, адже портативність часто є теоретичною на папері, але не функціонує на практиці. AcruxCore також підтримує OTLP, тому до нього було спрямовано точно такий самий підхід: ідентичний код with tracer.start_as_current_span(...), ідентичний інструментатор, нова кінцева точка.
tracer_provider = otel.register(service_name="rag-agent-observability")
OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
Кожна платформа має допоміжну функцію register(), яка об’єднує TracerProvider, BatchSpanProcessor та експортер OTLP у один виклик. Жодна з цих допоміжних функцій не є обов’язковою, і самостійно створені конвеї працюють з обома. Спани надходять у правильно вкладеному вигляді. Атрибут RETRIEVER у OpenInference став типом Retrieval, а автоматично зафіксований спан моделі вказує на саму модель та 1 341 токен.
Отже, портативність зберігається, і це найкраща причина писати інструментацію у простому форматі OTel: спани записуються лише один раз, а те, який бекенд їх отримає, стає питанням налаштувань. Витрати виникають заздалегідь – виклик register(), об’єкт інструментатора та об’єкт трейсера передують першому спану, що є більшою кількістю структурних елементів, ніж потрібно будь-якому декоратору для цих двох спанів.
Шаблон 4: збір даних поза межами мережі
Окрім OTLP, AcruxCore пропонує п’ятий спосіб, якого немає в інших інструментах. Замість спостереження за процесом код описує спани та надсилає їх. ID трейсу об’єднує все разом: передавши його до шлюзу, спан, створений на серверній стороні, приєднується до того самого трейсу. Ці два способи доповнюють один одного.
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
)
Для цього потрібно менше викликів, ніж для створення трейсера, а часові мітки встановлює той, хто викликає, що підходить для завдань, які вже завершилися та не залишили живої стек-структури викликів для під’єднання.
Проблема полягає у флагу wait=False. У стандартному режимі ingest() чекає на свій POST-запит та повертає ідентифікатор трейсу, створений сервером, тож запит виконує два подорожі по мережі перед початком виклику моделі. З wait=False дані потрапляють у локальний буфер SDK, а ідентифікатор створюється на стороні клієнта. На місці виклику нічого не підтверджується, а невдала спроба надсилання записується як попередження та відкидається, подібно до експортера OTel. Вибір полягає між повільною та гучною реакцією на помилку або швидкою та тихою реакцією; кожен варіант є прийнятним, за умови, що він є свідомим.
Шаблон 5: перебування у шляху запиту
Два з цих інструментів реалізовані безпосередньо всередині самого дзвінка, тож модельний спан надається безкоштовно. Helicone найчіткіше це демонструє: достатньо змінити base_url на відповідний, і процес доповнення тексту записується без жодного коду SDK. Гейтвей AcruxCore дотримується такого ж підходу. Дзвінок, направлений через gateway.chat(), має свій llm-спан, який створює сервер, тому клієнту не потрібні додаткові інструменти.
Цей підхід має одну перевагу та одну обмеження, спільні для обох варіантів. Усе, що знаходиться у шляху запиту, бачить лише HTTP-трафік, спрямований до постачальника моделей. Запит Chroma не генерує такого трафіку, тому залишається непомітним незалежно від використаного інструменту.
Вони відрізняються тим, що супроводжує їх. Гейтвей AcruxCore приймає ідентифікатор траєкту, що дозволяє елементу даних, зареєстрованому через інший маршрут, OTel або ingest(), виступати як батьківський елемент, щоб модель даних, створена на сервері, потрапила до того ж траєкту. Helicone пропонує лише проксі, без SDK для обгортання довільної функції. Це залишає два варіанти: або пропустити отримання даних з траєкту, або створити фальшиве завершення для його передачі, причому другий варіант заповнює телеметрію вигадками. Тож точне твердження є вужчим, ніж просто те, що проксі не можуть відстежувати отримання даних: проксі сам по собі не може цього зробити, і значимість цього залежить від наявності другого маршруту.
Одна застереження щодо реалізації, яка не стосується дизайну: система Helicone, налаштована для самостійної роботи, у цьому тесті повертала код 500 від свого кінцевого пункту для запису вручну через відсутність змінної S3_REGION, що заважало запису навіть половини даних моделі. Це недолік саме цього варіанту обробки зображень, а не методу в цілому.
Що можна назвати інтервалом
Два інтервали свідчать про правильну роботу системи, але вони не можуть розрізнити ранкер та елемент захисту через півроку. Тому допустимі значення були взяті з кожного встановленого SDK, а не з його документації.
MLflow значно випереджає інших у цій категорії. RERANKER, GUARDRAIL, EVALUATOR, PARSER та MEMORY є вбудованими функціями, тоді як Opik має лише чотири типи та відносить все інше до метаданих. Сім типів у AcruxCore підтримують технологію RAG, але без більш детального розгляду. Phoenix успадковує свій список зі стандарту OpenInference, тому він є спільним для всіх інструментів, які дотримуються цього стандарту, і не є функцією Phoenix.
Саме цей ряд найчастіше піддається неправильній оцінці на сторінках маркетингу. Будь-який з цих списків можна перевірити щодо встановлених версій за кілька рядків коду:
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 докладає особливої уваги. Його ObservationTypeLiteral, який знаходиться у langfuse._client.span, складається з вкладених типів Literal, тому typing.get_args повертає внутрішні псевдоніми замість рядків, і результат потрібно рекурсивно розгортати. Це пояснює, чому кількості, наведені в його документації, часто не збігаються з даними SDK.
Вибір
- MLflow підходить командам, які хочуть точну семантику зараз та оцінку пізніше. Його словниковий запас значно ширший, хоча цей інструмент важчий за чистий інструмент відстеження.
- Opik генерує правильну структуру ще того ж дня, без зайвих формальностей, і має лише чотири типи span.
- Langfuse пропонує як декоратор, так і можливість ручного вирішення проблем за допомогою сильного типування, але це супроводжується наявністю двох наборів документації.
- Phoenix підходить для команд, які можуть змінити бекенд або вже використовують OTel. Він не вимагає API від постачальника та потребує найбільше налаштувань перед тим, як з’явиться перший елемент даних.
- AcruxCore підходить у випадку, коли елемент моделі має бути створений автоматично, але для отримання даних все ще потрібен запис. Він має три комбіновані шляхи обробки, словниковий запас, менший за MLflow, Phoenix чи Langfuse, а також буферизоване завантаження даних, яке мовчки припиняє роботу у разі проблем.
- Helicone підходить для конвеєра, який насправді є лише викликом моделі. Для збору даних не потрібен код, а кроки, що не є частиною моделі, не мають куди записуватися.
Справжнім уроком тут є відсутність рейтингу. Запитання про те, чи відстежує інструмент процес отримання даних, насправді містило п’ять окремих запитань, і кожна відповідь є рішенням з боку проектувальника, яке має видиму ціну: рядок коду, блок with, робота з налаштуваннями, перехід через мережу чи «сліпа зона». Виберіть ту ціну, яка завдасть найменшої шкоди, та уважно перегляньте словниковий запас, перш ніж приймати рішення, адже саме там з’являються несподіванки у пізній момент.