Трейсинг 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 SDK | 4.14.5 |
| Self-hosted Langfuse | 3.205.0 |
| Python | 3.13.9 |
| Fixture release | 6401879 |
На удалённом сервере прошли три сценария:
- happy path — оба tools отвечают;
- первый вызов subscription tool завершается по timeout, второй успешен;
- refund tool возвращает terminal error, после чего ответ уходит на human review.
В отдельном процессе exporter был направлен на недоступный 127.0.0.1:9. Приложение
всё равно вернуло тот же fixture result. Это не обещание, что telemetry никогда не
потеряется. Результат уже: в проверенном workflow сбой exporter не изменил поведение
приложения.
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 зафиксируйте свою таблицу:
| Observation | Level | Что означает в fixture |
|---|---|---|
| Неудачный tool attempt | ERROR | Эта операция не завершилась |
| Восстановленный root | DEFAULT | Пользователь получил ожидаемый результат |
| Degraded root | WARNING | Есть безопасный, но неполный результат |
| Успешный sibling | DEFAULT | Его собственная операция завершилась |
Иначе количество 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 к нему пришёл.