Главная / Статьи

Анализ этапа извлечения информации: сравнение шести инструментов для наблюдаемости на примере одного агента RAG

Один агент RAG был проанализирован с использованием шести инструментов для наблюдаемости LLM: Opik, MLflow, Langfuse, Phoenix, AcruxCore и Helicone, при этом сравнивались декораторы, OpenTelemetry и прокси.

Издатель: REACTAPP.TOP10 мин чтения2399 слов

Источники: 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, поэтому один ручно созданный элемент отслеживания можно отправить в любую из них, просто изменив адрес конечной точки. На обеих средах тестировался тот же пайплайн.
  • В пути запроса находятся два продукта. Прокси Helicone и шлюз AcruxCore фиксируют информацию о модели без использования клиентского кода, но ни один из них не замечает этапов, которые не связаны с поставщиком моделей.
  • У Helicone нет решения для извлечения данных, поскольку прокси является единственным их продуктом, и не существует SDK для записи информации об этих этапах.
  • MLflow предлагает наиболее полный набор терминов для описания этапов, включая пятнадцать типов, таких как 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 и other. MLflow поддерживает пятнадцать типов, среди которых RERANKER, GUARDRAIL, EVALUATOR, PARSER и MEMORY. Opik поддерживает только четыре типа: general, tool, llm и guardrail.

    Тестируемый агент

    Агент специально сделан простым. Он индексирует пять страниц документации OpenTelemetry, разделенных примерно на фрагменты по 1 000 символов, встраивает их с помощью модели text-embedding-3-small и хранит во внутренней коллекции Chroma. Один фиксированный вопрос о том, что такое span и как родительские и дочерние spans формируют трейс, запрашивает четыре фрагмента, после чего 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 составило 1,05 секунды, а время генерации, зафиксированное им самостоятельно, — 4,14 секунды; при этом суммы затрат и количества токенов также учитывались. В MLflow те же два этапа показывают время поиска в 0,47 секунды и время генерации Completions в 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 предлагает пятый способ, отсутствующий у других инструментов. Вместо отслеживания процесса код описывает отдельные этапы и отправляет их. Идентификатор трейса связывает все элементы воедино: передав его в шлюз, модель этапа, созданная на стороне сервера, присоединяется к тому же трейсу. Эти два способа дополняют друг друга.

    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, работа по настройке, сетевой переход или «слепая зона». Выберите вариант с наименьшими издержками и тщательно изучите словарь элементов перед принятием решения, ведь именно там позже могут возникнуть неожиданности.