← LLMOps and agent observability: debugging the black box
01 / 07 · 40 min

Traces and spans: why printf debugging dies

An agent is a non-deterministic distributed process: why log lines cannot answer “what went wrong”, the anatomy of trace/span/generation, and the OTel GenAI semantic conventions.

Lesson content is currently available in Russian.

What you will learn

  • →Объяснять, почему построчные логи не восстанавливают картину прогона агента.
  • →Читать дерево спанов: trace, span, generation, tool-спаны и их атрибуты.
  • →Называть ключевые атрибуты OTel GenAI semconv и понимать, зачем контент промптов делают opt-in.

Почему printf умирает вместе с агентом

Отладка через print() и построчные логи работает, пока программа — один детерминированный процесс: поставил вывод перед подозрительной строкой, воспроизвёл баг, прочитал значение. Агент ломает все три условия сразу. Один прогон — это десятки LLM-вызовов, вызовы инструментов, ретраи, ветвления и иногда параллельные под-агенты; логи от них перемешиваются в общем потоке, и связь «этот вызов инструмента породила вот эта генерация» из строк не восстанавливается никаким grep. Продакшен-вопрос звучит не «что в переменной x», а «почему агент ответил именно так пользователю Y вчера в 14:32» — и для ответа нужен весь прогон целиком: каким получился промпт после сборки контекста, что вернул retriever, с какими аргументами агент вызвал инструмент и что тот ответил.

Вторая причина — недетерминизм. Тот же вход завтра пройдёт другим путём: модель выберет другой инструмент, ретраи лягут иначе. «Воспроизведу локально и посмотрю» перестаёт работать — воспроизводить нечего, случившийся прогон существует в единственном экземпляре. Либо вы записали его полностью в момент исполнения, либо потеряли навсегда.

Третья причина — масштаб: при тысячах прогонов в день интересен не один баг, а распределение. Сколько прогонов зациклилось? У какой доли обрезался контекст? Строки логов не агрегируются в такие ответы, потому что в них нет структуры. Вывод: единица наблюдения LLM-системы — не строка лога, а трейс, полная структурированная запись одного прогона.

Анатомия трейса: trace, span, generation

Trace — запись одного сквозного прогона (один запрос пользователя, один ход диалога), устроенная как дерево спанов. Span — один шаг работы с временными границами. У каждого спана есть: name, trace_id (общий для всего дерева), span_id, parent_span_id (ссылка на родителя — так строится дерево), таймстемпы начала и конца, status (OK/ERROR), произвольные attributes (ключ-значение) и точечные events.

В LLM-наблюдаемости спаны типизируют по ролям:

  • generation — один вызов модели: модель, параметры, промпт, ответ, usage (input/output tokens) и стоимость;
  • tool — вызов инструмента: имя, аргументы, результат или ошибка;
  • retrieval — запрос к базе знаний и возвращённые документы со скорами;
  • event — точечное событие без длительности (решение роутера, срабатывание guardrail).

В Langfuse trace содержит observations типов span/generation/event; в LangSmith то же дерево называется run tree с типами llm/tool/chain/retriever — терминология разная, структура одна.

Дерево агента поддержки выглядит так: trace support-agent → generation plan (модель решает, что делать) → tool search_kb → внутри него retrieval vector-store → generation final-answer. По таймстемпам спанов сразу видно, какой шаг съел латентность; сумма usage по generation-спанам даёт стоимость прогона; статусы tool-спанов показывают, где инструменты падали. Один взгляд на дерево заменяет полчаса раскопок в логах.

Контекст-пропагация: как спаны склеиваются в дерево

Дерево не собирается само: каждый новый спан должен узнать trace_id и span_id родителя. Этот механизм называется распространением контекста. Внутри одного процесса его обеспечивает рантайм: в Python — contextvars, в Node.js — AsyncLocalStorage; вызов start_as_current_span кладёт текущий спан в контекст, и дочерние спаны подхватывают его автоматически даже сквозь async-код.

Между сервисами контекст передаётся по стандарту W3C Trace Context: HTTP-заголовок traceparent вида 00-<trace_id 32 hex>-<span_id 16 hex>-01 несёт идентификаторы через любую границу. Принимающий сервис извлекает их и продолжает то же дерево. Через очереди сообщений (Kafka, SQS) заголовков нет — trace_id кладут в метаданные сообщения вручную и восстанавливают на консьюмере.

Типичные поломки: фоновая задача, запущенная без передачи контекста, порождает «осиротевший» трейс без родителя; забытая пропагация через очередь режет один логический прогон на несколько несвязанных трейсов; и наоборот — вечно живущий воркер, не закрывающий корневой спан, склеивает тысячи запросов в один бесконечный трейс.

Правило: trace_id рождается один раз на входе системы и протаскивается везде. Для многоходовых диалогов сверху добавляют бизнес-идентификаторы атрибутами: session_id склеивает трейсы ходов одного диалога, user_id — все сессии пользователя. Trace отвечает на вопрос «что случилось в этом ходе», session — «как развивался диалог», user — «что этот пользователь видит от системы вообще».

Что писать в generation-спан: OTel GenAI semconv

Чтобы дашборды и алёрты работали одинаково для любого провайдера, атрибуты LLM-вызовов стандартизируют. OpenTelemetry ведёт семантические конвенции GenAI — единый словарь имён:

  • gen_ai.operation.name — тип операции (chat, embeddings, execute_tool);
  • gen_ai.request.model — какую модель запросили; gen_ai.response.model — какая реально ответила (они расходятся, когда провайдер переключает алиас — важная улика);
  • gen_ai.request.temperature, gen_ai.request.max_tokens — параметры запроса;
  • gen_ai.usage.input_tokens, gen_ai.usage.output_tokens — расход токенов, основа всей экономики;
  • gen_ai.response.finish_reasons — почему генерация закончилась.

finish_reason — кладезь диагностики: stop — модель закончила сама; length — упёрлась в лимит токенов (обрезанный ответ или распухший контекст); content_filter — сработала модерация; tool_calls — модель запросила инструмент. Рост доли length — один из самых ранних сигналов деградации агента.

Сам контент промптов и ответов в атрибуты по умолчанию не пишут: это PII и коммерческие данные, которые утекут в систему наблюдаемости с другим режимом доступа. Захват контента делают осознанным opt-in (в OTel-инструментациях — переменная OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT), а платформы вроде Langfuse дают маскирование полей. Практичный компромисс: метаданные и usage — всегда, контент — только там, где это разрешено политикой данных, с маскированием персональных полей.

Practice

Карта одного прогона

Возьмите свой агент или пайплайн (либо любой открытый пример агента с инструментом) и по его текущим логам вручную восстановите полное дерево одного прогона: таблица со столбцами «спан, родитель, тип (generation/tool/retrieval), длительность, ключевые атрибуты». Отметьте места, где данных в логах не хватило.

Deliverable

Таблица или диаграмма дерева спанов одного прогона плюс список из пяти конкретных вопросов о прогоне (стоимость, источник ответа, причина задержки), на которые текущие логи ответить не позволяют.

Checkpoint

Объясните коллеге, чем трейс отличается от лога и почему printf-отладка не масштабируется на агентов.

  • ✓ Названы недетерминизм, ветвление на десятки вызовов и невозможность воспроизвести прогон как причины смерти printf.
  • ✓ Описана структура: trace как дерево спанов, generation/tool/retrieval-спаны, атрибуты и статусы.
  • ✓ Упомянуты usage-атрибуты и finish_reasons из GenAI semconv и причина, по которой контент промптов пишут только по opt-in.

Check yourself

Почему grep по логам не восстанавливает картину прогона агента?

01Почему grep по логам не восстанавливает картину прогона агента?

Что связывает спаны в одно дерево внутри трейса?

02Что связывает спаны в одно дерево внутри трейса?

О чём говорит рост доли finish_reason = length в generation-спанах?

03О чём говорит рост доли finish_reason = length в generation-спанах?

Почему контент промптов и ответов не записывают в трейсы по умолчанию?

04Почему контент промптов и ответов не записывают в трейсы по умолчанию?

Зачем к трейсам добавляют session_id атрибутом, если trace_id уже есть?

05Зачем к трейсам добавляют session_id атрибутом, если trace_id уже есть?

Sources

Traces and spans: why printf debugging dies · LLMOps and agent observability: debugging the black box · VORCL