Зачем нужен tail based sampling LLM-трасс?
Tail based sampling LLM-трасс сохраняет дорогие, медленные и ошибочные цепочки, а обычный трафик оставляет в контрольной выборке.

Редкая LLM-ошибка почти всегда проигрывает обычному трафику, если выбирать трассы случайно. Успешные короткие запросы приходят тысячами, а один агент застревает на третьем вызове инструмента, тратит деньги на повторы и исчезает из наблюдаемости с той же вероятностью, что и безобидный чат.
Tail based sampling исправляет именно этот перекос. Сначала система получает достаточно spans, чтобы увидеть итог трассы, затем сохраняет полностью то, что дорого, медленно или сломано, а нормальный поток оставляет по небольшой доле. Это не способ «сэкономить место в хранилище». Это способ перестать выбрасывать материал для расследований.
Для LLM это работает лучше, чем для многих обычных HTTP-сервисов, потому что итог запроса складывается в конце: известны модель и маршрут, число токенов, реальная стоимость, число повторов, финальное состояние агента и исход каждого tool call. Head sampling принимает решение слишком рано. Он не знает, что спокойный на старте запрос через две секунды станет самым дорогим случаем дня.
Tail sampling выбирает итог, а не обещание
Head sampler принимает решение в SDK, обычно при создании корневого span. Он дешевый и хорошо защищает приложение от лишней телеметрии, но видит только начало операции. Если он отбросил trace_id, поздний span с ошибкой инструмента уже не вернет историю обратно.
Tail sampler работает в коллекторе. Он собирает spans по trace_id, ждет заданное время, оценивает накопленную трассу и выпускает либо все ее spans, либо ни одного. Документация OpenTelemetry Collector Contrib прямо указывает, что для эффективного решения все spans одной трассы должны попасть в один экземпляр коллектора. Она также перечисляет правила по задержке, коду статуса, строковым и числовым атрибутам, булевым признакам, числу spans, вероятности и ограничению потока.
Слово «полностью» здесь важно. Отдельный span с ERROR без корневого span, модели, маршрута и соседних попыток почти бесполезен. Вам нужен весь причинный путь: пользовательская операция, выбор модели, генерация, инструмент, повтор, финальный ответ и запись результата.
У tail sampling есть цена. Коллектор временно хранит незавершенные трассы, принимает решение с задержкой и требует аккуратной маршрутизации. Для служебного endpoint, где нужна только средняя задержка, это может быть лишним. Для агентных цепочек, RAG, платежных проверок, медицинских подсказок и дорогих генераций это обычно оправдано.
Не путайте tail sampling с логированием каждого prompt. Отбор трассы отвечает на вопрос, какую операцию сохранить. Политика данных отвечает на другой вопрос: какие поля вообще можно отправить в коллектор. Второй вопрос решают раньше первого.
p99 нельзя записать в правило как магическое число
Фраза «сохраняем p99» звучит точно, но в конфигурации она часто превращается в ошибку. p99 является свойством набора наблюдений за период, а не одной трассы. Нельзя посмотреть на span длительностью 4,2 секунды и честно назвать его p99, пока не определены окно, группа сравнения и распределение.
Сравнение всех LLM-запросов одним p99 особенно вредно. Генерация краткого резюме и многошаговая проверка контракта имеют разную нормальную длительность. Быстрая модель и рассуждающая модель тоже живут в разных распределениях. Если смешать их, порог окажется слишком низким для одного потока и бессмысленно высоким для другого.
Сначала стройте метрики задержки до семплирования, с минимально нужными измерениями:
operation.name, напримерsupport.replyилиcontract.check;- идентификатор семейства модели или класса маршрута;
- тип операции: генерация, embedding, rerank, tool execution;
- код исхода.
Span Metrics Connector OpenTelemetry агрегирует из spans метрики запросов, ошибок и длительности. Он пригоден для расчета квантилей независимо от того, какие полные трассы вы сохраните потом.
После этого превратите результат анализа в рабочий порог. Например, раз в неделю команда видит, что для contract.check на конкретном маршруте пограничная задержка хвоста равна 8 секундам. Это не «вечный p99». Это текущий эксплуатационный порог, который вы пересматриваете, когда меняете модель, провайдера, кэш или сам workflow.
В корневой span запишите измеренную длительность, а для удобства добавьте признак, который вычисляет приложение или processor:
llm.trace.duration_ms = 8427
llm.tail.latency_outlier = true
llm.operation = "contract.check"
llm.route_class = "reasoning"
Число оставляйте всегда. Булев признак полезен для простого правила и для расследований, но скрывает величину отклонения. Если вы пересмотрите порог, число позволит заново классифицировать уже сохраненные трассы.
Не ставьте правило «сохранять все свыше 5000 мс» на весь кластер только потому, что оно красиво выглядит в YAML. Оно быстро превратит медленный, но штатный маршрут в бесконечный источник дорогих трасс.
Дорогой запрос и длинный запрос не одно и то же
Длительность часто коррелирует со стоимостью, но это разные сигналы. Долгий запрос может ждать внешний API или очередь инструмента и почти не потреблять токены. Короткая генерация может стоить дорого из-за большого контекста, дорогой модели или нескольких параллельных веток агента.
Стоимость лучше считать там, где приложение уже знает факты. После ответа провайдера или шлюза соберите входные и выходные токены, примените тариф, учтите повторные попытки и запишите результат в корневой span. Если у вас есть кэширование prompt, отдельно фиксируйте кэшированные токены, иначе стоимость по грубой формуле будет ложной.
Например:
llm.usage.input_tokens = 18640
llm.usage.output_tokens = 2176
llm.observed_cost_usd = 0.1374
llm.retry_count = 2
Название атрибута здесь намеренно прикладное. Не ждите, что стандарт телеметрии будет знать вашу валюту, договорную ставку, скидку, кэш или внутреннюю стоимость GPU. OpenTelemetry GenAI semantic conventions стандартизируют сведения о модели, токенах и, при явном включении, содержимом prompt, completion, tool call и результата инструмента. Тарифная логика остается обязанностью вашей системы.
Выберите денежный порог отдельно для каждой операции. Для массового классификатора 5 центов могут быть поводом для расследования. Для редкого юридического workflow это может быть нормальная цена. Затем сохраняйте трассы выше порога полностью, независимо от их длительности и статуса.
Распространенная плохая рекомендация звучит так: «Оставляйте только запросы с большим числом токенов». Ее любят, потому что счетчик токенов доступен почти везде. Но токены не объясняют, почему запрос дорогой. В трассе с 40 тысячами входных токенов причиной может быть ожидаемый длинный документ. В трассе с 6 тысячами токенов причиной может быть три повтора из-за неверного tool schema. Семплер должен ловить оба случая, но помечать их разными причинами.
Успешный HTTP-ответ может скрывать провал агента
В LLM-приложении ошибка транспорта и ошибка задачи часто расходятся. Провайдер вернул HTTP 200, модель сформировала JSON, инструмент получил аргументы, но запрос к CRM отклонили по правам. Или инструмент вернул пустой результат, агент повторил действие, исчерпал лимит и написал пользователю уверенный, но бесполезный ответ.
Если вы отбираете только status.code = ERROR, вы пропустите значительную часть таких случаев. Ошибкой должен стать финальный исход прикладного действия, а не только неуспех HTTP-клиента.
Я обычно добавляю один span на фактическое выполнение инструмента и записываю в него минимум:
name = "tool.execute"
llm.tool.name = "customer_lookup"
llm.tool_call.index = 2
llm.tool_call.failed = true
llm.tool_failure.kind = "permission_denied"
llm.tool.retryable = false
Если операция действительно не состоялась, поставьте span в ERROR. Если инструмент выполнился, но вернул ожидаемый пустой результат, не объявляйте его ошибкой ради семплирования. Добавьте явный результат вроде llm.tool.result = "empty" и решите отдельно, интересен ли он. Иначе метрика ошибок станет шумной, а инженеры перестанут ей верить.
Сама трасса должна получать итоговый статус после завершения workflow. Корневой span может иметь ERROR, если агент не достиг цели, даже когда все сетевые вызовы успешно завершились. Полезно также записывать llm.workflow.outcome: completed, failed, abandoned, guardrail_blocked. Значения задает ваша команда, поэтому задокументируйте их и не меняйте написание от сервиса к сервису.
Именно здесь tail sampling выигрывает у правил на уровне отдельных логов. Он видит, что одна неудача инструмента была компенсирована следующей удачной попыткой, а другая сорвала всю операцию. Сохранять обе без разбора дорого. Выбрасывать обе означает не видеть отказов, которые бьют по пользователям.
Правила должны идти от исключений к контрольной группе
Хорошая стратегия состоит из обязательных причин сохранения и одной контрольной выборки. В вашем порядке мысли сначала идут ошибки workflow, неудачные инструменты, превышение порога стоимости и задержки. В конце остается малая вероятностная доля успешных обычных операций.
Контрольная группа нужна не для расследования аварии. Она показывает, как выглядит нормальная трасса после релиза, помогает сравнить выбранные исключения с фоном и позволяет заметить новый класс проблем, который вы еще не научились помечать. Без нее вы видите только уже известные виды боли.
Ниже пример конфигурации для OpenTelemetry Collector Contrib. Имена атрибутов и пороги вы должны заменить своими. Синтаксис использует поддерживаемые политики status_code, boolean_attribute, numeric_attribute, latency и probabilistic; документация процессора описывает их именно как правила выбора по статусу, атрибутам, длительности и доле трафика.
processors:
tail_sampling:
decision_wait: 20s
num_traces: 50000
expected_new_traces_per_sec: 250
decision_cache:
sampled_cache_size: 100000
non_sampled_cache_size: 100000
policies:
- name: workflow-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: failed-tool-call
type: boolean_attribute
boolean_attribute:
key: llm.tool_call.failed
value: true
- name: expensive-request
type: numeric_attribute
numeric_attribute:
key: llm.observed_cost_usd
min_value: 0.05
- name: slow-contract-check
type: and
and:
and_sub_policy:
- name: contract-check
type: string_attribute
string_attribute:
key: llm.operation
values: [contract.check]
- name: contract-tail
type: latency
latency:
threshold_ms: 8000
- name: ordinary-control-group
type: probabilistic
probabilistic:
sampling_percentage: 1
Этот вариант намеренно не пытается затолкать все условия в один монолитный policy. Отдельные правила проще объяснить на разборе инцидента: трассу сохранила ошибка workflow, ошибка инструмента, цена, задержка конкретной операции или контрольная выборка. Если версия коллектора позволяет записывать политику, которая приняла решение, включите эту возможность и проверяйте ее в backend. У процессора есть feature gate, добавляющий атрибуты tailsampling.policy, tailsampling.composite_policy и признак решения из кэша.
Не добавляйте в конец always_sample с надеждой, что он будет «запасным вариантом». Он отменит всю экономию. Если нужна выборка обычного трафика, используйте вероятностное правило с явно заданной долей.
Сначала маскируйте поля, потом держите трассу в буфере
Полная трасса LLM особенно опасна, потому что в ней легко оказываются документы, персональные данные, номера счетов, медицинские сведения, аргументы инструментов и ответ внутренней системы. Аргумент «мы же потом не сохраним большинство трасс» не спасает: до решения tail sampler уже получил их и некоторое время хранит.
Разделите данные на три уровня. Первый можно использовать в правилах и хранить широко: статус, задержка, токены, стоимость, имя операции, тип ошибки, число повторов, хэш шаблона. Второй разрешен только в защищенном хранилище и по короткому сроку: редактированный фрагмент ответа, идентификатор документа, код причины отказа. Третий не должен попадать в telemetry pipeline без отдельного согласованного основания: исходные документы, сырые prompt, содержимое tool arguments и ответы с PII.
Практический прием: маскирующий processor ставьте перед tail sampler. Он должен удалить или заменить чувствительное значение, а не рассчитывать на exporter после решения. Для диагностики часто хватает формы данных:
llm.prompt.template_id = "claim-review-v7"
llm.prompt.characters = 48122
llm.prompt.sha256 = "..."
llm.tool.arguments_schema = "customer_lookup:v3"
llm.tool.arguments_bytes = 312
Хэш не делает данные автоматически безопасными. Для коротких или предсказуемых значений его можно перебрать. Он полезен для корреляции одинакового input, но не должен служить оправданием для отправки секретов в чужую среду.
Для команд в Казахстане вопрос размещения данных часто решается архитектурой, а не полем согласия в форме. AI Router может дать единый OpenAI-совместимый endpoint и размещение части open-weight моделей на собственной GPU-инфраструктуре, но маскирование атрибутов и правила хранения трасс все равно должны работать до любого экспортера. Никакой API-шлюз не исправит трассу, в которую приложение уже записало паспортные данные пользователя.
decision_wait выбирают по опоздавшим spans, а не по интуиции
Tail sampler не знает, что трасса «точно закончена», если приложение не посылает ему специальный сигнал завершения, а дочерние spans могут прийти позже корневого. Поэтому он использует временное окно decision_wait. Слишком короткое окно отрежет поздний tool span и создаст неполную историю. Слишком длинное поднимет задержку экспорта и расход памяти.
В документации процессора есть прямое предупреждение: трасса может быть отброшена, если ее удалили из кольцевого буфера до decision_wait. Авторы советуют смотреть возраст удаления и сравнивать его перцентили с самим decision_wait; близкие значения означают риск потери при росте нагрузки.
Не выбирайте 30 секунд только потому, что это значение по умолчанию. Сделайте измерение:
- Начните с окна, которое покрывает обычные asynchronous tool calls в вашем workflow.
- Соберите распределение возраста поздних spans и число трасс, удаленных из-за заполнения буфера.
- Увеличьте окно, если значимая часть нужных дочерних spans приходит после решения.
- Увеличьте емкость или число экземпляров, если трассы исчезают до истечения окна.
- Снова проверьте целостность сохраненных длинных трасс вручную.
У процессора есть метрики для этого: возраст удаления трассы, возраст позднего span, число новых trace_id, глобальное число решений и задержка таймера решений. otelcol_processor_tail_sampling_sampling_decision_timer_latency тоже нельзя игнорировать. Если сам проход решений занят больше секунды, он добавляет задержку и повышает риск потерять данные до решения.
Большинство команд смотрят только на объем отправленных данных. Это поздняя метрика. Сначала следите за потерей внутри самого sampler, иначе вы оптимизируете экспорт, не замечая, что буфер уже выбросил дорогую трассу.
Одна трасса должна приходить в одно место
В Kubernetes и многорегиональных системах самая неприятная поломка выглядит тихо. Корневой span попадает в collector A, spans инструментов в collector B, а финальный span агента в collector C. Каждый экземпляр видит кусок без решающего признака. В backend появляются обрывки, и команда ошибочно винит инструментирование.
Перед tail sampler нужен маршрут по trace_id. Он должен быть детерминированным: все telemetry records с одним идентификатором направляются к одному и тому же экземпляру группы. Простое round-robin распределение после SDK не годится. Документация Collector отдельно подчеркивает требование совместного попадания spans одной трассы в один экземпляр.
Проверьте это не диаграммой, а тестом. Запустите одну искусственную агентную операцию с корневым span, двумя параллельными tools и финальным span с ERROR. Добавьте атрибут test.case_id на каждый span. В backend должна появиться одна трасса с ожидаемым числом spans и одной причиной выборки. Если вы видите две или три трассы с тем же test.case_id, ваша маршрутизация уже сломана.
Также предусмотрите поздние spans. Процессор хранит кэш решений, чтобы дать одинаковый исход spans, пришедшим после удаления накопленной трассы. Размер кэша должен соответствовать потоку и времени жизни задержанных spans. Иначе часть истории сохранится, а часть исчезнет после уже принятого положительного решения.
Объем ограничивают байтами, а не самообманом в процентах
Процентная выборка говорит, какую долю трасс вы пытаетесь сохранить. Она не говорит, сколько места они займут. Одна обычная трасса с пятью spans может быть в сотни раз меньше агентного расследования с длинными events, несколькими инструментами и фрагментами ответов.
Поэтому измеряйте две величины отдельно: число сохраненных трасс и их байты. В tail sampling processor есть policy bytes_limiting, работающая с token bucket по фактическому protobuf-размеру traces. Она ограничивает устойчивый поток данных и допускает заданный burst, что полезно, когда после инцидента внезапно растет число полных ошибок.
Ограничитель байтов не должен молча отбрасывать все ошибки. Сначала решите, какой бюджет вы готовы выделить на обязательные классы: ошибки workflow, failed tool calls, дорогие запросы. Для контрольной выборки оставьте остаток. Если поток обязательных ошибок превышает бюджет, это уже сигнал об инциденте, а не повод сделать его невидимым.
Полезная проверка на практике: раз в несколько дней берите сохраненные трассы из каждого класса и считайте средний размер, число spans, число событий, наличие prompt-полей и долю с повторными попытками. Так вы увидите, что именно раздувает хранение. Часто виноват не сам LLM span, а подробный event с телом HTTP-ответа инструмента, который кто-то добавил «временно».
Семплирование надо проверять как бизнес-логику
Правила отбора являются кодом, хотя лежат в YAML. Их нужно тестировать при изменении схемы атрибутов, модели, SDK и workflow. Переименовали llm.tool_call.failed в tool.failed, забыли обновить policy, и через неделю вы обнаружите, что расследовать нечего.
Соберите набор синтетических трасс, который прогоняется в CI или на тестовом коллекторе. В нем должны быть как минимум: обычный успешный запрос, дорогой быстрый запрос, медленный штатный workflow, ошибка корневой операции, неудачный tool call при HTTP 200 и ошибка, которая затем была успешно исправлена повтором. Для каждого случая заранее укажите ожидаемый результат и policy name.
Проверяйте и сами последствия. Ошибочная трасса должна содержать весь путь до причины. Дорогая должна иметь значение стоимости и расход токенов. Контрольная успешная должна показывать нормальное дерево spans. Если backend получает только отдельные дочерние spans, тест считается проваленным даже при формальном совпадении доли выборки.
Когда это заработает, не гонитесь за все более сложной матрицей условий. Добавляйте новое правило только после того, как сможете назвать класс инцидента, который оно ловит, и действие инженера после находки. Хороший tail sampling оставляет немного трасс, но в каждой есть причина открыть ее утром, а не просто еще один успешный ответ модели.
Часто задаваемые вопросы
Чем tail based sampling отличается от head sampling для LLM?
Head sampling решает судьбу трассы в момент ее начала, когда еще неизвестны итоговая задержка, расход и результат tool call. Tail based sampling ждет завершения трассы или заданного окна и выбирает ее по фактическим признакам. Для LLM-цепочек разница особенно заметна: самая интересная ошибка часто появляется в последнем дочернем span.
Можно ли оставить только случайную выборку LLM-трасс?
Нет, если вы пытаетесь ловить редкие дорогие и медленные запросы. Случайная выборка годится как контрольная группа для обычного трафика, но она пропускает длинный хвост пропорционально своей доле. Сохранение ошибок, неудачных инструментов и дорогих трасс должно идти отдельными правилами до вероятностного правила.
Как сохранять p99-трассы, если p99 меняется?
p99 не является атрибутом одной трассы, это квантиль распределения за период и сегмент. Сначала вычислите пороги по метрикам отдельно для модели, маршрута и типа операции, затем записывайте в span числовую длительность или признак превышения порога. После этого tail sampler сможет принять решение по конкретной трассе.
Нужно ли считать tool call ошибкой при HTTP 200 от LLM?
Ставьте span инструмента в ERROR или добавляйте булев атрибут вроде llm.tool_call.failed=true, если бизнес-операция не состоялась. HTTP 200 от модели не означает успех: модель могла сформировать неверные аргументы, инструмент мог вернуть отказ, а агент мог исчерпать лимит повторов. Семплер должен видеть именно итог прикладной операции.
Безопасно ли хранить полные LLM-трассы для расследований?
Да, но сначала удалите или замаскируйте чувствительные поля. Tail sampler держит незавершенные трассы до решения, поэтому нельзя отправлять туда сырые промпты, документы и аргументы инструментов в надежде, что несэмплированная трасса исчезнет позже. Оставляйте идентификаторы, длины, хэши и классификационные признаки, если они нужны для отбора.
Как отбирать дорогие запросы по стоимости?
Определите стоимость на уровне приложения после того, как известны модель, входные и выходные токены, а также цена. Запишите ее числом в корневой span и используйте numeric_attribute policy. Не пытайтесь выводить деньги из длины prompt в коллекторе: это ломается при кэше, повторной попытке, разных тарифах и мультимодальном вводе.
Как выбрать decision_wait для tail sampling?
Единого значения нет. Окно должно быть длиннее обычной задержки доставки дочерних spans и завершения фоновых инструментов, но не таким большим, чтобы память коллектора заполнялась незавершенными трассами. Начните с измерения возраста поздних spans и корректируйте decision_wait по этому распределению.
Почему tail sampling теряет часть распределенных трасс?
Да. В распределенной схеме все spans одного trace_id должны попадать в один экземпляр tail sampler, иначе ни один экземпляр не увидит трассу целиком. Делайте маршрутизацию по trace_id перед группой коллекторов или используйте архитектуру с централизованным уровнем, который сохраняет эту привязку.
Какие метрики показывают, что tail sampling настроен правильно?
Сначала проверяйте долю трасс, выбранных каждым правилом, возраст удаленных трасс и процент поздних spans. Затем возьмите несколько сохраненных случаев каждого класса и убедитесь, что в них есть корневой span, стоимость, итог инструмента и нужные события. Снижение объема без этой проверки часто оказывается потерей именно тех данных, ради которых схему внедряли.
Как связать tail sampling с маршрутизацией моделей через AI Router?
AI Router можно использовать как единый OpenAI-совместимый шлюз, не меняя клиентские SDK, а решение о сборе трасс оставлять в вашем приложении и OpenTelemetry Collector. Для расследований полезно записывать в телеметрию выбранную модель, маршрут, токены, стоимость и результат инструмента, но содержимое запросов должно пройти маскирование до отправки в коллектор.