Проблема: привязка к платформе
Два прошлых урока дали важную вещь — трейсы. Но оба способа вендор-специфичны: @traceable знает только про LangSmith, @observe — только про Langfuse. Пока инструмент один, это ок. В реальном проде — нет:
- Смена платформы = переписать код. Решил уйти с LangSmith на Langfuse — меняешь все декораторы и SDK по всему проекту.
- Агент — часть большой системы. Вокруг LLM есть API, очереди, БД, другие микросервисы, у которых уже есть телеметрия — в Jaeger, Grafana, Datadog. LLM-трейсы в отдельной песочнице разрывают общую картину запроса.
- Несколько бэкендов сразу. Хочется LLM-специфику в Langfuse, а инфраструктурные метрики — в Grafana. С вендорным SDK так не сделать.
Нужен единый стандарт инструментирования, отвязанный от того, кто в итоге хранит и показывает данные. Ровно эту проблему в мире бэкенда решил OpenTelemetry — и теперь он же стал стандартом и для LLM.
OTel (проект CNCF, как Kubernetes) — это спецификация + SDK + протокол для сбора телеметрии: трейсов, метрик, логов. Он не хранит и не показывает данные — он их собирает и отправляет в любой совместимый бэкенд. Аналогия: OTel — это USB-C для телеметрии. Один разъём (стандарт) — любые устройства (бэкенды) на другом конце. Инструментируешь раз — подключаешь что угодно.
Модель OTel: traces, spans, context
Хорошая новость: концептуально OTel — это тот же трейс из прошлых уроков, только стандартизованный и с точными терминами. LangSmith/Langfuse под капотом по сути повторяют эту модель.
- Span — единица работы с началом, концом, статусом и атрибутами. Это «run» из LangSmith на языке стандарта: один LLM-вызов, один tool-вызов, один шаг.
- Trace — дерево связанных spans одного запроса. У каждого span есть
trace_id(общий на весь запрос) иspan_id, а вложенность задаётся ссылкой на родителя. - Attributes — пары ключ-значение на span (модель, число токенов, имя инструмента). Именно здесь живёт LLM-специфика.
- Context propagation — механизм, передающий
trace_idмежду функциями и через границы сервисов, чтобы дерево не разрывалось. - Exporter / OTLP — как spans уходят наружу. OTLP (OpenTelemetry Protocol) — стандартный провод до бэкенда или Collector.
GenAI semantic conventions
Span сам по себе — просто «отрезок времени с атрибутами». Чтобы бэкенд понял, что это LLM-вызов, а не запрос к БД, атрибуты должны называться единообразно. Для этого в OTel есть semantic conventions — согласованный словарь имён. У GenAI-части свои ключи под LLM:
gen_ai.system— провайдер (openai, anthropic…).gen_ai.request.model/gen_ai.response.model— модель.gen_ai.usage.input_tokens/output_tokens— токены (отсюда бэкенд считает стоимость).gen_ai.operation.name— тип операции (chat, embeddings…).
Смысл в том, что любой OTel-совместимый бэкенд понимает эти имена одинаково. Ты пишешь gen_ai.usage.input_tokens — и Langfuse, и кастомный дашборд в Grafana видят токены без всякой подгонки.
Semantic conventions для GenAI в OTel относительно молодые и активно дорабатываются — часть ключей может иметь статус experimental и меняться между версиями. Держи в голове принцип (стандартные имена атрибутов под LLM: система, модель, токены, операция), а точные ключи и их стабильность сверяй с актуальной спецификацией OTel своей версии, а не заучивай.
Инструментирование агента
На практике почти всё делают авто-инструментирующие библиотеки (например, OpenLLMetry/Traceloop, OpenInference и др.): подключаешь пакет — и вызовы OpenAI/Anthropic/LangChain автоматически превращаются в spans с правильными GenAI-атрибутами. Ручные spans добавляешь только для своей логики.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
# 1. Куда отправлять — ТОЛЬКО здесь задаётся бэкенд (сменить = сменить endpoint)
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces") # OTLP → Collector/бэкенд
))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("my-agent")
# 2. Ручной span для своей логики (LLM-вызовы обычно инструментируются авто-либой)
def research(topic: str) -> str:
with tracer.start_as_current_span("agent.research") as span:
span.set_attribute("agent.topic", topic) # свои атрибуты
docs = search_web(topic) # если инструментирован — вложится в этот span
result = summarize(docs)
span.set_attribute("gen_ai.usage.output_tokens", count_tokens(result))
return result
Обрати внимание: имя бэкенда встречается в коде ровно один раз — в endpoint экспортёра. Переехать с одного бэкенда на другой = поменять endpoint (или настройку Collector), а не трогать инструментирование. Это и есть обещанная развязка кода и платформы.
Collector — отдельный процесс между приложением и бэкендами: принимает OTLP, обрабатывает (фильтрует, семплирует, маскирует PII, обогащает) и раздаёт в один или несколько бэкендов. С ним приложение вообще не знает, куда в итоге идут данные, — маршрутизация настраивается в Collector, без передеплоя кода. Хочешь LLM-spans в Langfuse, а всё вместе в Grafana — это конфиг Collector, а не правка агента.
Когда OTel, а когда нативный SDK
OTel мощнее, но и сложнее нативных @traceable/@observe. Выбор зависит от масштаба системы.
| Ситуация | Выбор |
|---|---|
| Один инструмент, только LLM-приложение, быстрый старт | Нативный SDK (LangSmith/Langfuse) |
| Агент — часть большой системы с существующей телеметрией | OpenTelemetry |
| Возможна смена платформы observability в будущем | OpenTelemetry (нет lock-in) |
| Нужно слать в несколько бэкендов сразу | OpenTelemetry + Collector |
| Строгие требования по PII/маскированию в пайплайне | OpenTelemetry + Collector-обработка |
Langfuse и (частично) LangSmith сами принимают OTLP — значит, можно инструментировать агента по OTel и всё равно смотреть трейсы в привычном LLM-UI. Часто это лучший вариант: стандартный, переносимый способ сбора + удобный специализированный бэкенд для просмотра. OTel не отменяет прошлые уроки — он их обобщает и делает переносимыми.
Типичные ошибки
Если у тебя одно LLM-приложение и один инструмент — OTel это лишняя сложность (Collector, экспортёры, конвенции). Нативный SDK даст результат за минуты. OTel окупается на масштабе и в гетерогенных системах.
Если между сервисами/потоками не пробросить контекст, дерево распадётся на несвязанные куски и trace_id не сойдётся. При переходе границы сервиса (HTTP, очередь) контекст надо передавать явно.
Назовёшь токены my_tokens вместо gen_ai.usage.input_tokens — бэкенды не распознают их как LLM-метрику, дашборды по стоимости не построятся. Следуй semantic conventions.
Полные промпты и ответы в spans удобны, но это PII и объём. Настраивай маскирование/семплирование в Collector, иначе рискуешь приватностью и раздуванием телеметрии.
Шпаргалка
OTEL = открытый вендор-НЕЗАВИСИМЫЙ стандарт телеметрии (CNCF): traces+metrics+logs
«USB-C для observability»: инструментируй РАЗ → шли КУДА УГОДНО
это стандарт, а не продукт — сам не хранит/не показывает данные
ЗАЧЕМ (боль нативных SDK): смена платформы = переписать код · агент в большой
системе с готовой телеметрией · нужно слать в несколько бэкендов
МОДЕЛЬ (= трейс из прошлых уроков, но стандартизован):
span = единица работы (≈ run): начало/конец/статус/attributes
trace = дерево spans (общий trace_id) · вложенность через parent
attributes = ключ-значение на span (тут LLM-специфика)
context propagation = проброс trace_id между функциями И сервисами
OTLP = стандартный протокол экспорта · Exporter = куда слать
GENAI SEMANTIC CONVENTIONS (общий словарь под LLM, статус — развивается):
gen_ai.system · gen_ai.request.model · gen_ai.usage.input/output_tokens · operation.name
→ любой бэкенд понимает токены/модель/стоимость одинаково
ИНСТРУМЕНТИРОВАНИЕ: авто-либы (OpenLLMetry/OpenInference) + ручные spans для своей логики
имя бэкенда в коде РОВНО раз (endpoint экспортёра) → переезд = сменить endpoint
COLLECTOR = рубильник: приём OTLP → обработка (фильтр/семпл/маскировка) → раздача в N бэкендов
ВЫБОР: одно приложение/старт → нативный SDK · большая/гетерогенная система,
риск смены платформы, много бэкендов, PII-обработка → OTel
НЕ или-или: Langfuse/LangSmith принимают OTLP → OTel + удобный LLM-UI
ГРАБЛИ: OTel для мелкого проекта · рвать context · свои имена атрибутов · PII без маскировки
Практическое задание
Задание: инструментируй агента по OTel
- Подключи авто-инструментирующую OTel-библиотеку (OpenLLMetry/OpenInference) к агенту из модулей 03–05 и настрой OTLP-экспорт. Убедись, что LLM-вызовы стали spans.
- Найди в span'ах GenAI-атрибуты (модель, токены). По каким ключам они названы? Сверь с semantic conventions.
- Добавь ручной span для своей бизнес-логики (например,
agent.research) с парой своих атрибутов. Проверь, что вложенные вызовы попали внутрь него. - Настрой экспорт трейсов в Langfuse через OTLP (Langfuse принимает OTLP). Тот же код — но данные в LLM-UI. Что изменилось в коде при смене бэкенда?
- Подними OTel Collector и настрой раздачу трейсов в два бэкенда одновременно (например, Langfuse + Jaeger/консоль). Где настраивается маршрутизация — в коде или в Collector?
- Для своего проекта реши по таблице: нативный SDK или OTel? Аргументируй тремя факторами.
- Со звёздочкой: добавь в Collector обработку — маскирование содержимого промптов/PII перед отправкой в бэкенд.
Что дальше
Трейсы (LangSmith, Langfuse, OTel) показывают структуру запроса. Но рядом нужен второй сигнал — логи: что именно и в каком виде записывать, чтобы их можно было искать и агрегировать. Следующий урок — про structured logging для агентов.