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
Sources