Перейти к содержимому
6 мин чтения

Экспорт трасс для eval должен быть воспроизводимым

Экспорт трасс для eval без лишнего объема: фильтры по стоимости, задержке, ошибкам и сценариям, манифест выборки и проверка trace_id.

Экспорт трасс для eval должен быть воспроизводимым

Экспорт трасс для eval должен быть воспроизводимым. Если аналитик сегодня отобрал медленные и дорогие запросы, а инженер завтра не может получить тот же набор trace_id, это не датасет для оценки. Это разовая подборка, которой нельзя доверять при сравнении моделей, промптов и маршрутов.

Проблема редко лежит в формате CSV или Parquet. Обычно команда смешивает три разные операции: определение целевой совокупности, извлечение содержимого трасс и подготовку примеров для прогона. После этого фильтр по стоимости внезапно считает цену повторных попыток дважды, задержка означает то время модели, то всю цепочку, а экспорт после маскирования теряет часть строк без явного отчета.

Нужен контракт выборки. Он задает, какие завершенные пользовательские трассы входят в eval, какие поля объясняют отбор и как проверить, что выгрузка не подменила набор. Такой контракт выглядит скучно ровно до первого спора о том, почему новая модель стала «лучше» на другом датасете.

Единицей отбора должна быть пользовательская трасса

Для eval выбирайте завершенную корневую трассу, которая соответствует одному пользовательскому намерению или одному запуску фоновой задачи. Не выбирайте спаны вызова модели, поиска, базы данных или инструмента как самостоятельные примеры.

Одна LLM-задача почти всегда оставляет дерево: входящий HTTP-запрос, классификация, retrieval, один или несколько вызовов модели, tool calls, повторные попытки, постобработка и отправка ответа. Если брать дочерние спаны, одна неудачная задача превращается в пять строк. В наборе появляется перекос в пользу технически сложных запросов, а оценщик начинает считать качество отдельных кусочков вместо результата, который увидел пользователь.

В OpenTelemetry trace описывает путь запроса, а span описывает отдельную операцию. У спана есть идентификатор трассы, родитель, временные метки, атрибуты, события и статус. Это дает достаточно данных, чтобы строить выборку по корню дерева, а не по случайному узлу.

Заведите на корневом спане минимальный набор полей, которые относятся к задаче целиком:

  • app.scenario, например support_rag, document_extract или agent_action;
  • app.request_id, если он уже есть в прикладном контуре;
  • gen_ai.operation.name или собственное поле операции;
  • app.eval.eligible, если часть запросов по смыслу нельзя использовать в оценке;
  • идентификатор версии приложения или промпта, но не как замену типа сценария.

Не ставьте в app.scenario имя модели, провайдера или конкретный URL маршрута. Сценарий отвечает на вопрос, что пытался сделать пользователь. Модель и маршрут отвечают на другой вопрос: каким способом система это сделала. Смешаете их, и после смены модели потеряете возможность сравнить старые и новые данные внутри одного сценария.

Еще одна частая ошибка: считать асинхронную работу продолжением HTTP-трассы любой ценой. Если очередь запускает обработчик позже, создайте отдельную трассу и свяжите ее с исходной через span link или прикладной request_id. Корневое время HTTP-запроса и время фоновой обработки тогда не будут притворяться одной задержкой.

Фильтр надо описывать как неизменяемый контракт

Фильтр является не интерфейсным состоянием в observability-системе, а версионируемым объектом. У него должны быть имя, временной интервал, правила включения, правила исключения, версия схемы и порядок вычисления производных полей.

Достаточно хранить контракт в YAML рядом с кодом eval или в таблице конфигурации. Главное, чтобы фильтр можно было прочитать, выполнить и проверить без догадок о переключателях в интерфейсе.

selection_id: eval-support-rag-slow-errors-v3
source_window:
  started_at_gte: "2026-06-01T00:00:00Z"
  started_at_lt: "2026-06-08T00:00:00Z"
unit: root_trace
where:
  scenario_in:
    - support_rag
  completed: true
  environment: production
  latency_ms_gte: 8000
  total_cost_usd_gte: 0.02
  outcome_in:
    - success
    - terminal_error
exclude:
  - synthetic_traffic
  - deleted_user_data
  - missing_root_input
pricing_version: provider-rates-2026-06-01
schema_version: trace-eval-v2

Такой файл делает две вещи. Он отделяет намерение выборки от реализации SQL, а еще не дает незаметно расширить период, поменять порог или добавить типы ошибок в середине эксперимента.

Не допускайте неопределенных формулировок вроде «дорогие трассы» или «проблемные ответы». У каждой границы должна быть единица измерения и включающее правило. latency_ms_gte: 8000 означает задержку не меньше 8 000 миллисекунд. Формулировка «около восьми секунд» не означает ничего, когда один запрос попал в одну выгрузку, а на повторном запуске нет.

Отдельно зафиксируйте временную границу. Используйте полуоткрытый диапазон [started_at_gte, started_at_lt), а не «с 1 по 7 июня включительно». Полуоткрытый диапазон не дублирует записи, если вы соединяете недельные выгрузки, и не зависит от того, как конкретная система трактует конец суток.

Стоимость должна принадлежать всей трассе

Фильтр по стоимости полезен, когда он отвечает на вопрос о деньгах, потраченных на пользовательскую задачу. Цена одного model span отвечает на более узкий вопрос: сколько стоил конкретный вызов. Эти числа нельзя подменять друг другом.

Считайте total_cost на уровне трассы как сумму фактически выполненных вызовов, относящихся к одной задаче. Если система сделала retrieval, вызвала недорогую модель для маршрутизации, затем основную модель, а после таймаута повторила запрос, пользовательская трасса включает стоимость всех этих действий. Иначе самые болезненные случаи исчезнут из фильтра именно потому, что вы разрезали их на части.

Но не складывайте стоимость каждого технического спана. Один и тот же вызов может быть представлен HTTP client span, span SDK и собственным span обертки. Нужен один канонический слой учета, обычно прикладной span, который знает model call ID, входные и выходные токены, валюту и факт биллинга. Остальные спаны оставьте для диагностики.

Практическая схема полей выглядит так:

{
  "trace_id": "9df1...a02c",
  "app.scenario": "support_rag",
  "usage.input_tokens": 1842,
  "usage.output_tokens": 516,
  "cost.amount": 0.02384,
  "cost.currency": "USD",
  "cost.pricing_version": "provider-rates-2026-06-01",
  "billing.call_id": "call_01J..."
}

Суммируйте только уникальные billing.call_id. Если идентификатора нет, сначала исправьте инструментирование. Дедупликация по паре «время плюс модель плюс число токенов» иногда спасает старые данные, но она не является надежным учетом: два одинаковых запроса могут произойти в одну миллисекунду.

Нулевая стоимость тоже требует объяснения. Она может означать локальную open-weight модель, кэшированный ответ, тестовый маршрут или просто пропущенное поле. Не смешивайте эти значения. Введите cost.accounting_state: billed, estimated, cached, local, unknown. В пороговый фильтр по деньгам должны входить только billed и, если это принято правилами команды, estimated. Значение unknown не должно тихо проходить как ноль.

AI Router может дать единый OpenAI-совместимый путь к разным провайдерам, но правила агрегации стоимости все равно принадлежат вашему приложению и вашему контракту выборки. Маршрутизация не отменяет необходимости фиксировать версию тарифов и идентификатор каждого оплаченного вызова.

Задержка без границы измеряет случайный фрагмент

Для продуктового eval задержка трассы равна времени от начала корневого пользовательского запроса до появления финального результата или терминального отказа. Это и есть число, по которому пользователь судит о медленности системы.

Внутри одной трассы полезно хранить еще несколько времен: model_latency_ms, retrieval_latency_ms, tool_latency_ms, queue_wait_ms. Они объясняют причину, но не заменяют end_to_end_latency_ms.

Правило расчета должно выдерживать ретраи. Допустим, запрос начался в 10:00:00, поиск занял 300 мс, первый вызов модели завершился таймаутом через 4 000 мс, второй вернул ответ через 3 500 мс, а постобработка заняла 200 мс. Пользователь ждал около 8 000 мс, а не 3 500 мс. В экспорт надо положить полную задержку, число попыток и максимальную длительность одного model call. Тогда вы сможете отдельно оценить качество медленных ответов и понять, вызвала ли медлительность модель, сеть или повтор.

Не исключайте отмененные запросы автоматически. Отмена со стороны пользователя после 20 секунд часто важнее обычной ошибки. Отметьте ее как отдельный исход cancelled, сохраните наблюдаемое время и решите в контракте, входит ли такой класс в текущий eval. Смешивать отмены с успешными ответами нельзя, но выкидывать их по привычке тоже не стоит.

OpenTelemetry рекомендует принимать решение о sampling в начале трассы и распространять его дальше. Это полезно для производственной телеметрии, но не решает задачу выборки для eval: head sampling может выкинуть редкую дорогую ошибку до того, как вы узнаете ее стоимость и задержку.

Поэтому сохраняйте компактный итоговый record корневой трассы даже там, где полное тело и все дочерние спаны собираются выборочно. В этом record достаточно trace_id, сценария, результата, длительности, счетчиков токенов, стоимости, версии приложения и признака доступности полезной нагрузки. Полные промпты и ответы можно получать позже только для утвержденного списка trace_id.

Ошибка в телеметрии и плохой ответ не одно и то же

Контролируйте ключи eval-контура
В AI Router rate-limits применяются на уровне ключа.

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

Эти классы нужно хранить раздельно. Для корневой трассы я обычно использую outcome со значениями success, terminal_error, cancelled, policy_blocked и partial. Отдельно добавляю quality_signal, если его уже выдал пользователь, автоматический проверяющий или ручная разметка. Не превращайте отсутствие quality signal в признак хорошего ответа.

Документация OpenTelemetry по recording errors говорит, что не всякий код статуса одинаково означает ошибку. HTTP 404 может быть проблемой, если приложение ожидало ресурс, и нормальным итогом, если оно проверяло его наличие. Она также советует не записывать как ошибку операцию, где сбой был обработан и система завершила работу штатно.

Для LLM-приложения последствия прямые. Ошибка первого вызова модели, после которого fallback дал пользователю корректный ответ, не должна превращать корневую трассу в terminal_error. Сохраните событие попытки и retry_count, а корневой итог оставьте success. Иначе выборка «ошибок» будет состоять из запросов, которые пользователи получили нормально.

С другой стороны, не ставьте OK на корне только потому, что HTTP-ответ ушел с кодом 200. Если API вернул структурированный ответ с finish_reason=error, если агент исчерпал лимит шагов или если ваш валидатор отверг результат, это прикладной неуспех. Он достоин отдельного outcome, даже когда транспорт работал безупречно.

Поле error.type полезнее свободного текста, потому что его можно стабильно группировать. OpenTelemetry рекомендует выставлять его, когда операция завершилась ошибкой, а детали статуса не должны содержать чувствительные данные. Сырым сообщением исключения в фильтре пользоваться не надо: оно часто содержит PII, URL, фрагменты запроса и слишком много вариаций.

Тип сценария нельзя восстанавливать по имени модели

Тип сценария задает контекст, в котором оценщик интерпретирует вход и выход. Режим «ответ по внутренней базе знаний» требует проверки опоры на контекст. «Извлечение реквизитов из документа» требует проверки структуры, полноты и точности полей. «Вызов действия» требует проверки выбранного инструмента, аргументов и побочных эффектов.

Если вы экспортируете все трассы одной продуктовой точки без сценария, набор быстро становится непригодным. Короткие классификации численно победят длинные ответы с retrieval. Простые задачи создадут впечатление, что модель работает хорошо, хотя дорогой агент часто ошибается. Одна средняя оценка по такому набору ничего не говорит CTO и почти ничего не говорит команде, которая должна исправлять систему.

Сценарий ставьте при входе в прикладной workflow, а не выводите после факта из имени span. Имена спанов должны описывать статистически интересный класс операции, а не отдельный экземпляр с высококардинальными параметрами. Это совпадает с рекомендацией OpenTelemetry использовать для имени обобщенную, понятную человеку операцию.

Если один запрос проходит несколько сценариев, выберите основной сценарий для отбора и запишите дополнительные как массив. Не создавайте две копии трассы в одном экспорте. При этом можно строить отдельные срезы по вторичным меткам, но манифест должен явно сказать, какая метка определяет принадлежность.

Полезный минимум для каждого сценария:

  • определение ожидаемого результата;
  • нужные входные поля и допустимые маски;
  • способ оценки, автоматический или ручной;
  • классы ожидаемых отказов;
  • правило, какие tool calls и retrieval-контекст надо сохранить.

Эта спецификация защищает от еще одной дорогой ошибки: команда выгрузила красивый набор, но не может объяснить разметчику, что именно считать правильным ответом.

Сначала выбирайте идентификаторы, потом извлекайте содержимое

Сверяйте провайдерские расходы
API тарифицируется по ставкам провайдеров без наценки AI Router.

Двухфазный экспорт уменьшает объем и делает состав набора проверяемым. На первой фазе вы строите компактную таблицу кандидатов, где одна строка равна одной корневой трассе. На второй фазе извлекаете полезную нагрузку только по утвержденному списку trace_id.

Первая фаза не должна читать полные prompt, completion, документы retrieval и stack trace. Она работает по индексируемым колонкам: времени, сценарию, окружению, итоговой задержке, итоговой стоимости, outcome и наличию нужного содержимого.

WITH selected AS (
  SELECT
    trace_id,
    app_scenario,
    started_at,
    end_to_end_latency_ms,
    total_cost_usd,
    outcome,
    retry_count
  FROM trace_roots
  WHERE started_at >= TIMESTAMP '2026-06-01 00:00:00+00'
    AND started_at < TIMESTAMP '2026-06-08 00:00:00+00'
    AND environment = 'production'
    AND completed = TRUE
    AND app_scenario = 'support_rag'
    AND end_to_end_latency_ms >= 8000
    AND total_cost_usd >= 0.02
    AND outcome IN ('success', 'terminal_error')
    AND synthetic_traffic = FALSE
    AND root_input_available = TRUE
)
SELECT * FROM selected;

Результат этой операции становится реестром выбора. Сохраните его как неизменяемый файл или таблицу с selection_id. После этого вторая фаза имеет право делать только соединение по trace_id с таблицами полезной нагрузки, сведений о вызовах модели и retrieval. Она не имеет права повторно применять диапазон времени, порог стоимости или фильтр результата по своему усмотрению.

Именно здесь часто появляется тихая потеря данных. Например, export job использует inner join с таблицей промптов. Трассы, для которых промпт был удален по политике хранения, исчезают. Аналитик получает «готовый набор», но это уже не тот набор, который прошел фильтр. Правильное поведение другое: left join, явное поле payload_state=missing_or_redacted и отдельный отчет о причинах неполноты.

Полезные нагрузки должны пройти маскирование до передачи разметчикам или внешнему оценщику. Это относится не только к имени и телефону. В контексте LLM PII часто прячется в свободном тексте, JSON инструмента, URL, имени файла и сообщении об ошибке. Храните исходные данные в контролируемом контуре, а в export record помещайте маскированные поля и версию правила маскирования.

Проверка состава выборки должна ловить замену строк

Проверка «в CSV столько же строк, сколько вернул запрос» слишком слаба. Она не заметит, что один trace_id исчез, а другой добавился, что дубликат вытеснил уникальную трассу или что job экспортировал данные из соседнего временного окна.

После первой фазы сформируйте манифест выбора. Он нужен каждой выгрузке, даже если вы делаете ее вручную раз в месяц.

{
  "selection_id": "eval-support-rag-slow-errors-v3",
  "schema_version": "trace-eval-v2",
  "source_window": {
    "started_at_gte": "2026-06-01T00:00:00Z",
    "started_at_lt": "2026-06-08T00:00:00Z"
  },
  "trace_count": 184,
  "unique_trace_count": 184,
  "trace_id_sha256": "sha256(sorted trace_id values)",
  "filter_sha256": "sha256(canonical selection yaml)",
  "pricing_version": "provider-rates-2026-06-01",
  "payload_policy_version": "redaction-v4"
}

Считайте trace_id_sha256 по отсортированному списку идентификаторов с однозначным разделителем строк. Не хешируйте экспортный JSON целиком: порядок полей, формат времени и маскирование текста будут меняться, хотя состав выборки остался прежним.

После второй фазы сравните четыре значения:

  • количество строк в реестре и в export record;
  • количество уникальных trace_id;
  • контрольный хеш отсортированного списка trace_id;
  • распределение причин неполноты полезной нагрузки.

Первые три должны совпасть. Четвертое не обязано быть нулевым, но оно обязано быть явным. Если пять трасс потеряли контекст после redaction, это не повод молча удалить пять строк. Это повод решить, допускает ли конкретный eval неполный пример, и зафиксировать решение в новом контракте.

Добавьте тест на идемпотентность. Запустите экспорт дважды на одном snapshot источника и сравните манифесты. Если хеш идентификаторов меняется, причина почти всегда в плавающем now(), недетерминированном лимите без ORDER BY, обновляемой таблице тарифов или join, который размножает строки.

Случайная выборка после фильтра требует стратификации

Оставляйте след для проверки
В AI Router доступны аудит-логи для контроля обращений к API.

После строгих порогов у вас часто остается слишком много трасс для ручной проверки. Простая случайная выборка выглядит честно, но почти всегда переоценивает самый частый тип запроса. Она не гарантирует присутствие терминальных ошибок, дорогих ретраев, длинных контекстов и редких сценариев.

Стратифицируйте уже отобранный реестр. Страты должны отражать причины, по которым вы хотите принимать техническое решение: outcome, диапазон стоимости, диапазон задержки, сценарий, наличие fallback, категория языка или тип инструмента. Не делайте десятки пересечений. Если в страте один пример, вы построили не план выборки, а коллекцию исключений.

Для каждой строки добавьте детерминированное случайное число, полученное из trace_id и зафиксированного seed. Затем выбирайте нужное число строк внутри каждой страты по этому числу. При повторном запуске тот же seed даст тот же набор, а новый seed создаст другой набор без скрытой зависимости от порядка хранения.

Сохраните в манифесте sampling_method, sampling_seed и квоты по стратам. Отдельно храните полный реестр кандидатов. Тогда команда сможет ответить на два разных вопроса: «какие трассы соответствовали условиям?» и «какие из них попали в ручной eval?» Это не одна и та же сущность.

Экспорт нужно тестировать как часть eval-пайплайна

Экспорт трасс ломается не только из-за плохого SQL. Его ломают новые версии схемы, переименование атрибутов, изменение redaction, поздно пришедшие спаны, новые типы результата и разработчик, который решил заменить left join на inner join ради «чистых данных».

Поставьте проверки в CI или в задачу оркестратора. Они не требуют полной продовой базы. Достаточно небольшого фикстурного набора трасс, где есть обычный успех, дорогой ретрай, терминальная ошибка, отмена, отсутствующая полезная нагрузка и две трассы с одинаковым числом токенов.

Пайплайн обязан подтвердить следующее:

  • фильтр выбирает только корневые завершенные трассы;
  • ретраи складываются в одну трассу и не дублируют стоимость;
  • unknown не попадает в денежный порог как ноль;
  • full export сохраняет все trace_id реестра, даже при отсутствующем содержимом;
  • повторный запуск на одном snapshot дает тот же manifest hash.

Не подменяйте эти тесты мониторингом объема. Метрика «сегодня выгрузили столько же строк, сколько вчера» не поймает изменение состава. В хорошей системе экспорт сам публикует манифест, diff по стратам и отчет о неполных полезных нагрузках.

Если ваша команда использует AI Router для доступа к разным моделям, особенно внимательно разделяйте поля сценария, фактически выбранной модели и расчетной стоимости. Иначе смена маршрута будет выглядеть как изменение качества, хотя вы сравнили разные классы запросов.

Сначала сделайте компактный реестр корневых трасс и научитесь воспроизводить его хеш. После этого CSV, Parquet, разметчики и автоматические судьи становятся обычной инженерной работой. До этого они только придают случайной подборке убедительный вид.

Часто задаваемые вопросы

Что считать одной записью при экспорте трасс для eval?

Для eval экспортируют не отдельные спаны, а завершенные пользовательские трассы с одним стабильным корневым идентификатором. Иначе один запрос распадется на десятки технических операций, и дорогая генерация будет выглядеть как множество разных примеров.

Можно ли фильтровать трассы по стоимости, если цены моделей меняются?

Да, если стоимость вычислена по одной и той же версии тарифов и правилам учета токенов. В манифесте надо зафиксировать валюту, формулу, версию прайсинга и время расчета, иначе фильтр по стоимости нельзя будет воспроизвести через неделю.

Нужно ли включать ретраи в выборку для eval?

Нет. Ретраи нужно свернуть в один пользовательский сценарий, но сохранить их количество, причины и суммарную стоимость как поля трассы. Если экспортировать каждую попытку отдельно, оценка будет завышать долю ошибок и платить за один инцидент несколько раз.

Как правильно измерять задержку LLM-сценария?

Обычно нет. Полезная метрика для пользовательского опыта измеряет время от начала корневого запроса до финального результата, включая нужные вызовы инструментов и повторные попытки. Время одного вызова модели пригодно как диагностическое поле, но не заменяет полную задержку.

Любой статус Error означает, что трассу надо экспортировать?

Не всегда. OpenTelemetry прямо допускает контекстную классификацию: например, HTTP 404 может быть ошибкой или нормальным результатом проверки существования ресурса. Для eval отделите ошибки инфраструктуры и политики от ожидаемых бизнес-ответов, иначе фильтр соберет бессмысленную смесь.

Как уменьшить объем экспорта без потери нужных примеров?

Сначала экспортируйте только метаданные, идентификаторы, хеши и поля, нужные для фильтра. Полные промпты, ответы и документы подтягивайте лишь для уже выбранных трасс, после маскирования PII и проверки прав доступа.

Как доказать, что экспорт не изменил выбранную выборку?

Нужны минимум три проверки: число строк, множество trace_id и контрольный хеш отсортированного списка идентификаторов. Сравнение количества строк ловит только грубые ошибки, а хеш списка обнаруживает замену одной трассы другой.

Как задавать тип сценария в трассировке LLM-приложения?

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

Можно ли использовать SQL или ETL для выгрузки eval-датасета?

Да, но слой должен работать после того, как вы определили набор trace_id, а не выбирать данные самостоятельно. Его задача состоит в том, чтобы выгрузить поля и содержимое для уже утвержденного списка, а не незаметно применить вторую версию фильтра.

Нужно ли стратифицировать трассы перед eval?

Не отправляйте трассы в eval без группировки по причинам отбора. Отдельно проверяйте обычные успешные запросы, медленные успешные запросы, ошибки, дорогие запросы и редкие сценарии. Иначе большой класс дешевых типовых запросов скроет регрессии там, где продукт теряет деньги или пользователей.