Трейсинг AI-агента в Langfuse: tools, retry и ошибки

Автор:

TL;DR

  • -Один root observation должен соответствовать задаче пользователя. Retrieval, generations и каждый tool attempt становятся его children.
  • -Retry нельзя записывать поверх неудачной попытки: отдельные observations сохраняют причину сбоя и факт восстановления.
  • -Terminal error относится к tool child, а level корня описывает результат всей задачи. Успешные siblings не должны становиться ошибочными.
  • -Маскируйте чувствительные значения до вызова Langfuse SDK, а для публичного export используйте allowlist полей.
  • -Реальный запуск с Python SDK 4.14.5 и self-hosted Langfuse 3.205.0 нашёл ошибку в ID-контракте, которую пропустили fake tests.

Шесть тестов проходили, workflow возвращал правильный ответ, но в Langfuse появлялся только root span. Ни retrieval, ни generations, ни tool calls до сервера не доходили.

Проблема оказалась в одном имени поля. Fake-клиент и adapter ожидали observation_id, а установленный Langfuse Python SDK 4.14.5 отдавал ID span через id. Тестовый двойник подтверждал нашу же ошибочную гипотезу. Только запуск против реального self-hosted сервера показал, что parentage сломан.

После исправления рабочая схема выглядит так:

Создайте один root observation для задачи пользователя. Retrieval, model steps и каждый tool attempt пишите отдельными children. Ошибку оставляйте на конкретном tool, итог задачи — на root. Чувствительные данные удаляйте до отправки telemetry.

Здесь не будет повторной установки Langfuse. Для неё есть отдельный пошаговый туториал. Метрики, alerts и операционная модель разобраны в гайде по LLM observability в production.

Какой workflow мы проверили

Fixture имитирует ответ службы поддержки на вопрос о возврате. Все данные синтетические, model responses записаны заранее, tools детерминированы. Так повторный запуск проверяет структуру trace, а не случайное поведение провайдера.

answer-support-question
├── retrieve-account-policy
├── draft-plan
├── tool:get-subscription
├── tool:get-refund-window
└── compose-answer

Окружение зафиксировано 26 августа 2026 года:

КомпонентВерсия
Langfuse Python SDK4.14.5
Self-hosted Langfuse3.205.0
Python3.13.9
Fixture release6401879

На удалённом сервере прошли три сценария:

  1. happy path — оба tools отвечают;
  2. первый вызов subscription tool завершается по timeout, второй успешен;
  3. refund tool возвращает terminal error, после чего ответ уходит на human review.

В отдельном процессе exporter был направлен на недоступный 127.0.0.1:9. Приложение всё равно вернуло тот же fixture result. Это не обещание, что telemetry никогда не потеряется. Результат уже: в проверенном workflow сбой exporter не изменил поведение приложения.

Санитизированная схема Langfuse trace с двумя отдельными retry attempts

Root — это задача пользователя, а не model call

Удобная граница trace — действие, результат которого видит пользователь: ответить на тикет, составить маршрут, проверить pull request. Если сделать корнем только один model call, retrieval и tools окажутся рядом, но не внутри той же причинной цепочки.

Согласно актуальному описанию Langfuse SDK, trace состоит из observations с общим trace ID, а сами observations отображаются на OpenTelemetry spans. В одном call stack вложенность может передаваться автоматически. В актуальном Python instrumentation guide отдельно описан trace_context с trace_id и parent_span_id.

Мы выбрали explicit parentage. Такой adapter можно использовать и там, где события приходят через queue или от другого service. Для child нужны реальные trace_id и id родительского SDK wrapper:

parent = self.observations[event.parent_id]
trace_context = {
    "trace_id": parent.trace_id,
    "parent_span_id": parent.id,
}

observation = self.client.start_observation(
    name=event.name,
    as_type=event.kind,
    trace_context=trace_context,
    metadata=safe_metadata(event),
    version=self.fixture_version,
    model="recorded-fixture-v1" if event.kind == "generation" else None,
)

В первой версии стоял parent.observation_id. Телеметрические исключения были изолированы, поэтому основной workflow не падал. Побочный эффект неприятный: зеленый ответ приложения маскировал неполный trace.

После замены на parent.id сервер получил root и всех children. Практический вывод: если adapter сам управляет parentage, fake test недостаточно. Нужен хотя бы один integration run с той версией SDK, которая пойдёт в production.

Каждый retry — отдельный tool observation

Не обновляйте первую попытку результатом второй. Это две сетевые операции, даже если у них одинаковое имя tool.

В timeout-сценарии сервер сохранил такую структуру:

answer-support-question                 DEFAULT
├── tool:get-subscription  attempt=1    ERROR   TimeoutError
├── tool:get-subscription  attempt=2    DEFAULT
├── tool:get-refund-window attempt=1    DEFAULT
└── остальные успешные children         DEFAULT

У двух subscription calls общий parent, но разные observation IDs. Первая попытка заканчивается до начала второй. Поэтому по trace видно и причину задержки, и успешное восстановление.

Если оставить только финальный attempt=2, timeout исчезнет из истории. Если повторно использовать observation с уровнем ERROR, успешная задача будет выглядеть сломанной. Отдельные attempts не заставляют выбирать между этими фактами.

Минимальная логика выглядит так:

for attempt in (1, 2):
    observation_id = f"subscription-{attempt}"
    # Application wrapper, not a Langfuse SDK method.
    trace.start_tool(
        observation_id=observation_id,
        name="tool:get-subscription",
        parent_id=root_id,
        attempt=attempt,
    )
    try:
        subscription = tools.get_subscription(account_id)
    except TimeoutError as error:
        trace.end_tool(
            observation_id=observation_id,
            level="ERROR",
            error_type=type(error).__name__,
        )
        if attempt == 2:
            raise
        continue

    trace.end_tool(observation_id=observation_id, level="DEFAULT")
    break

В полном fixture нет специального start_tool: там используется общий TraceSink. Пример выше показывает decision point, а downloadable code — проверенную реализацию.

Tool error и провал всей задачи — не одно и то же

Tool может упасть, а agent — повторить вызов или вернуть безопасный неполный ответ. Поэтому level конкретной операции и outcome всей задачи нужно хранить отдельно.

В terminal-сценарии tool:get-refund-window получил ERROR. Успешный retrieval, subscription lookup и обе generations остались DEFAULT. Root завершился как WARNING: приложение сформировало ответ, но попросило человека подтвердить право на возврат.

tool:get-refund-window → ERROR: TerminalToolError
answer-support-question → WARNING: требуется human review
успешные siblings → DEFAULT

Это наша telemetry policy, а не обязательная семантика Langfuse. До настройки alerts зафиксируйте свою таблицу:

ObservationLevelЧто означает в fixture
Неудачный tool attemptERRORЭта операция не завершилась
Восстановленный rootDEFAULTПользователь получил ожидаемый результат
Degraded rootWARNINGЕсть безопасный, но неполный результат
Успешный siblingDEFAULTЕго собственная операция завершилась

Иначе количество child errors легко принять за количество проваленных пользовательских задач. В общей архитектуре production LLM stack нужны обе метрики: operation failures и task outcomes.

Маскирование должно происходить до export

Во входе fixture есть синтетические email и account ID. Они заменяются в момент создания trace attributes — до передачи события Langfuse adapter. Поля observation input и output в этом эксперименте вообще не отправляются.

EMAIL_PATTERN = re.compile(
    r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}",
    re.I,
)
ACCOUNT_PATTERN = re.compile(r"\bacct_[A-Z0-9]+\b", re.I)


def redact_text(value: str) -> str:
    value = EMAIL_PATTERN.sub("[EMAIL_REDACTED]", value)
    return ACCOUNT_PATTERN.sub("[ACCOUNT_REDACTED]", value)

Эти regex знают только два формата из fixture. Для production нужен data inventory и набор тестов под реальные identifiers продукта. В официальной документации по masking Langfuse рекомендует менять данные до выхода из приложения и описывает hooks для inputs, outputs, metadata и OpenTelemetry attributes.

У публичного evidence есть ещё одна граница. В полном authenticated API response мы нашли Langfuse public key внутри metadata SDK scope. Это не secret key, но в переносимом примере он не нужен. Sanitizer теперь собирает новый объект по allowlist и отклоняет файл, если в нём встречается любой из ключей.

Получается три независимых контроля:

  • source redaction очищает значения до отправки;
  • allowlist не копирует посторонние server metadata;
  • финальный negative scan проверяет именно публикуемый файл.

Exporter не должен управлять бизнес-результатом

Workflow принимает интерфейс TraceSink. Обёртка SafeTraceSink ловит ошибки delegate и считает пропущенные events, не вмешиваясь в ответ приложения:

@dataclass
class SafeTraceSink:
    delegate: TraceSink
    dropped_events: int = 0

    def record(self, event: TraceEvent) -> None:
        try:
            self.delegate.record(event)
        except Exception:
            self.dropped_events += 1

Langfuse пишет, что SDK errors перехватываются и логируются, а export выполняется асинхронно. В Python instrumentation guide для короткоживущих processes также указан явный flush(). Наш application guard не заменяет поведение SDK — он отделяет контракт workflow от конкретного exporter.

Компромисс честный: приложение продолжит работать, но trace может оказаться неполным. Dropped telemetry нужно считать отдельным operational signal. Ломать продукт ради идеально заполненного dashboard — плохой обмен.

Как повторить проверку

В публичный artifact входят workflow, Langfuse adapter, tests на стандартной библиотеке, integration runner, санитизированный JSON и схема trace. Код опубликован под MIT, evidence report и diagram — под CC BY 4.0.

Сначала запустите contract suite без внешних dependencies. Команда выполняется из директории со скачанными файлами:

python3 -m unittest discover -s . -p 'test_*.py' -v

Для integration run передайте отдельные project credentials через environment и запустите зафиксированную версию SDK в isolated environment:

uv run --isolated --with langfuse==4.14.5 python \
  run_integration.py \
  --output /tmp/futurecraft-langfuse-evidence.json

Не печатайте credentials и не сохраняйте полный authenticated response. Runner минимизирует данные в памяти, проверяет parentage и levels, ищет synthetic identifiers и keys, затем записывает только безопасную структуру.

Границы результата

  • Recorded model и tools не показывают поведение live providers.
  • Millisecond timestamps одного локального запуска — не benchmark latency.
  • Одна пара SDK/server versions не гарантирует совместимость следующих releases.
  • Два regex из fixture не являются универсальным PII detector.
  • Полный trace не доказывает правильность ответа модели.

Проверено другое: explicit parentage собрало одну причинную цепочку; timeout и retry остались отдельными observations; terminal child error не испортил статусы успешных шагов; identifiers были удалены до export; недоступный exporter не изменил fixture result.

Такой trace уже можно использовать в разборе инцидента. Он показывает не только финальный ответ, но и путь, по которому agent к нему пришёл.