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

Как OpenTelemetry для LLM сводит провайдеров к одной схеме

OpenTelemetry для LLM: схема полей для моделей, токенов, стоимости, кэша, инструментов, ошибок и защищённого raw payload.

Как OpenTelemetry для LLM сводит провайдеров к одной схеме

Наблюдаемость LLM ломается не потому, что у команды нет трассировок. Она ломается, когда один дашборд называет входные токены prompt_tokens, другой input_tokens, третий смешивает их с токенами из кэша, а четвёртый считает успешным вызов, который пользователь получил только после трёх fallback-попыток.

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

Нормальная схема описывает факты, а не названия API

Одна запись вызова должна фиксировать то, что произошло, а не повторять словарь конкретного поставщика. Провайдер может назвать токены usage.input_tokens, usageMetadata.promptTokenCount или prompt_tokens. Для аналитика это один факт: сколько входных токенов обработала операция.

Я обычно разделяю поля на три слоя.

  • Общий слой llm.* хранит нормализованные факты и остаётся контрактом для дашбордов, бюджетов и правил алертов.
  • Слой gen_ai.* повторяет совместимые атрибуты OpenTelemetry там, где семантика совпадает.
  • Слой llm.raw.* хранит ссылку на исходный запрос и ответ, их контрольные суммы, тип контента и версию адаптера, но не тащит весь JSON в атрибуты span.

Такое разделение не дублирует данные ради красоты. Стандарты GenAI в OpenTelemetry ещё меняются: например, проект отдельно предупреждает существующие инструментирования не переключать по умолчанию версии соглашений, использовавшихся до v1.36.0, и вводить переключатель стабильности для миграции. Если вся ваша отчётность привязана к одному меняющемуся имени атрибута, обновление библиотеки превращается в инцидент аналитики.

Минимальный контракт для одной LLM-операции может выглядеть так:

{
  "llm.schema.version": "1.0",
  "llm.operation": "chat",
  "llm.provider.requested": "openai",
  "llm.provider.resolved": "anthropic",
  "llm.model.requested": "smart-assistant",
  "llm.model.resolved": "claude-sonnet",
  "llm.request.stream": true,
  "llm.response.finish_reason": "tool_call",
  "llm.outcome": "success",
  "llm.raw.request_ref": "obj://telemetry/req/7f2c",
  "llm.raw.response_ref": "obj://telemetry/res/7f2c",
  "llm.raw.adapter": "anthropic-messages-v3"
}

Здесь нет поля model без уточнения. Это намеренно. Одинокое model почти всегда создаёт спор: оно означает модель, которую попросил клиент, модель, которую выбрал роутер, или строку из финального ответа? Через месяц никто уже не помнит, а график расходов выглядит убедительно и врёт.

Запрошенная модель и фактическая модель не одно и то же

llm.model.requested описывает намерение вызывающего кода. llm.model.resolved описывает исполнение. Между ними может быть алиас, правило маршрутизации, проверка доступности, отказ провайдера или перевод на другой регион.

Этот разрыв нельзя скрывать даже в самой простой интеграции. Представьте, что продуктовая команда отправляет customer-support, а маршрутизатор сначала выбирает одну модель, получает ограничение по мощности и выполняет запрос на другой. Если в трассировке осталась только строка customer-support, SRE не увидит источник роста задержки. Если осталась только финальная физическая модель, владелец продукта не поймёт, какое бизнес-правило вызвало расход.

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

Полезный набор выглядит так:

llm.provider.requested
llm.provider.resolved
llm.model.requested
llm.model.resolved
llm.route.id
llm.route.reason
llm.fallback.count

llm.route.reason не должен быть свободным текстом из исключения. Ограничьте словарь: direct, cost_policy, latency_policy, capacity, provider_error, data_residency. Иначе вы получите тысячи уникальных значений и бесполезную агрегацию.

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

Токены нужно хранить по назначению, а не одной суммой

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

Для нормализованной схемы используйте такие счётчики:

llm.usage.input_tokens
llm.usage.output_tokens
llm.usage.reasoning_output_tokens
llm.usage.cache_read_input_tokens
llm.usage.cache_creation_input_tokens
llm.usage.total_tokens_reported
llm.usage.source

llm.usage.source должен принимать небольшое число значений: provider, gateway, estimated, unavailable. Это поле часто спасает расследование. Если финансы увидели расхождение с инвойсом, они сразу понимают, идёт ли речь об ответе API или о приблизительном подсчёте локальным токенизатором.

OpenTelemetry рекомендует сообщать billable tokens, когда система отдельно выдаёт использованные и тарифицируемые токены. Это разумное правило для метрик расходов, но не повод выбрасывать технические счётчики. Держите биллинговые токены в расчёте стоимости, а технические категории сохраняйте для анализа контекста, кэша и генерации.

Не выдумывайте нули. Если провайдер не вернул кэшированные токены, 0 означает, что кэша точно не было. Это другой факт, чем «поставщик не сообщил значение». В JSON-атрибутах лучше не писать поле вовсе, а в нормализованном хранилище использовать null вместе с llm.usage.source=unavailable или отдельным признаком доступности разбивки.

Ещё одна ловушка: не складывайте cache_read_input_tokens поверх input_tokens, пока не зафиксировали семантику адаптера. У одних API входной счётчик уже включает чтение из кэша, у других категории даны отдельно. Внутренний контракт должен прямо говорить, является ли input_tokens полным входом или только некэшированным входом. Я советую определить его как полный входной объём, а кэшированные категории хранить как разрезы. Тогда формула не требует угадывать, что исключили из базового поля.

Стоимость должна иметь происхождение и состояние расчёта

Стоимость нельзя честно вывести из имени модели в конце месяца. Прайс зависит от даты, региона, режима обработки, типа токенов, batch-режима и иногда от самого канала доступа. Если система записывает только llm.cost.usd=0.004, вы не сможете объяснить это число аудитору, владельцу бюджета или инженеру, который меняет роутинг.

Записывайте не только сумму:

{
  "llm.cost.currency": "USD",
  "llm.cost.amount": 0.00428,
  "llm.cost.status": "final",
  "llm.cost.method": "provider_usage",
  "llm.cost.rate_card_id": "2026-07-usage-v4",
  "llm.cost.input_amount": 0.00120,
  "llm.cost.output_amount": 0.00308
}

llm.cost.status полезнее, чем кажется. Я использую четыре состояния: final, когда сумма построена по подтверждённым usage и действующему тарифу; estimated, когда известна только часть счётчиков или цена взята из приблизительной таблицы; pending, когда стрим ещё не завершился; unavailable, когда стоимость не удалось вычислить. Ноль не заменяет ни одно из них.

Финансовые отчёты должны суммировать только final, а отчёты оперативного контроля могут показывать final + estimated с явной подписью. Иначе команда тратит неделю на поиск «аномалии», которая на деле оказалась ещё не закрывшимся stream-вызовом.

Не пишите стоимость в label метрики. Деньги имеют слишком высокую кардинальность. Передавайте стоимость как числовую метрику или вычисляйте её в хранилище из событий. В labels оставьте модель, провайдера, операцию, среду и статус результата. Версия тарифной таблицы тоже редко годится для основной метрики, зато хорошо работает в trace или записи о расчёте.

Инструменты требуют отдельной причинной цепочки

Данные внутри Казахстана
Собственная GPU-инфраструктура AI Router хостит open-weight модели для команд с требованиями data residency.

Tool call не является просто finish reason. Он меняет форму всей операции: модель выдала намерение вызвать инструмент, приложение выполнило действие, затем нередко снова обратилось к модели с результатом. Если сжать это в один span, вы увидите общую задержку, но не поймёте, где именно пропало время и кто вернул ошибку.

Создайте родительский span пользовательской операции и дочерние spans для обращений к модели и выполнения инструментов. Для одного запуска инструмента достаточно следующих атрибутов:

llm.tool.name
llm.tool.call_id
llm.tool.type
llm.tool.attempt
llm.tool.outcome
llm.tool.duration_ms
llm.tool.argument_size_bytes
llm.tool.result_size_bytes

Имя инструмента можно ставить в span name, если ваш бэкенд трассировки умеет с этим жить и число имён ограничено. Соглашения OpenTelemetry для GenAI отдельно ужесточили требование имени инструмента для операции выполнения tool call. Но не добавляйте в имя span идентификатор заказа, пользователя или путь файла. Это прямой путь к взрыву кардинальности.

Аргументы и результаты инструмента почти никогда не годятся для атрибутов. Внутри часто лежат адреса, номера документов, SQL, данные клиента или целые страницы из внутренней базы. Сохраните размер, тип, хэш и ссылку на защищённый диагностический объект. Если инженеру нужен конкретный payload, он должен запросить его по trace ID с проверяемым доступом, а не открыть общий экран observability.

Полезно различать tool_requested, tool_executed и tool_rejected. Первый факт означает, что модель попросила действие. Второй означает, что приложение действие запустило. Третий означает, что политика, валидация схемы или пользователь остановили выполнение. Многие команды пишут «tool error» во всех трёх случаях, а затем делают неверный вывод о качестве модели.

Ошибка попытки не всегда делает операцию неуспешной

У LLM-вызова есть минимум три уровня результата: транспортная попытка, вызов провайдера и пользовательская операция. HTTP 429 на первой попытке, которая через 400 миллисекунд успешно повторилась, является ошибкой попытки. Она не является ошибкой операции пользователя.

OpenTelemetry рекомендует оставлять статус span unset при успешном завершении без ошибки и ставить Error вместе с error.type, когда операция завершается ошибкой. В тех же рекомендациях прямо сказано не записывать на span ошибки, которые были повторены или обработаны так, что операция завершилась нормально.

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

  • Дочерний span каждой попытки получает Error, если эта попытка закончилась сетевой ошибкой, таймаутом, 429 или ответом, который клиент считает неуспешным.
  • Родительский span LLM-операции получает Error только если все допустимые попытки и fallback завершились неудачей.
  • Событие llm.retry на родителе фиксирует причину, номер попытки и задержку ожидания, но не содержит полный текст ответа.
  • error.type берите из ограниченного справочника: rate_limit, timeout, network, auth, invalid_request, provider_unavailable, content_policy, tool_failure.

Не кладите строку ошибки провайдера в error.type. Сообщение меняется от запроса к запросу, иногда содержит фрагмент промпта, а агрегировать тысячи уникальных строк невозможно. Текст можно оставить в защищённом диагностическом объекте. Для неперехваченного исключения OpenTelemetry задаёт событие с именем exception и рекомендует указывать тип, сообщение и stack trace. Записывайте его один раз там, где исключение действительно завершает операцию, а не в каждом слое обёрток.

Отдельно помечайте отмену пользователем. cancelled не равно timeout, а content_policy не равно provider_unavailable. У этих исходов разные владельцы и разные действия: интерфейс может исправить отмену, команда промптов проверит policy, а SRE займётся недоступностью.

Исходный payload храните отдельно и с понятной политикой доступа

Контролируйте ключи API
Rate-limits на уровне ключа помогают разделять нагрузку разных интеграций.

Фраза «сохраним всё на случай отладки» обычно заканчивается персональными данными в индексируемых атрибутах, дорогим хранилищем и невозможностью безопасно дать доступ к трассировкам разработчикам. Полный payload нужен редко, но в сложных инцидентах он нужен по-настоящему. Поэтому его надо хранить отдельно, а не делать вид, что он не существует.

Схема работы простая. До отправки запроса адаптер создаёт диагностический объект, маскирует известные PII-поля, ограничивает размер и сохраняет результат в защищённом объектном хранилище. В span он передаёт только request_ref, SHA-256, размер, классификацию чувствительности и результат маскирования. После ответа происходит то же самое для response.

Пример атрибутов:

{
  "llm.raw.request_ref": "obs://llm/2026/07/23/ab12/request",
  "llm.raw.request_sha256": "a63b...",
  "llm.raw.request_bytes": 18422,
  "llm.raw.response_ref": "obs://llm/2026/07/23/ab12/response",
  "llm.raw.redaction": "pii_masked",
  "llm.raw.retention_class": "debug_7d"
}

request_ref не должен быть URL, доступным из браузера без проверки прав. Это идентификатор, который ваша диагностическая служба разрешает раскрыть по trace ID, роли и причине доступа. Для особо чувствительных потоков не сохраняйте payload вообще: оставьте длину, хэш, тип операции, версию промпта и счётчики токенов. Хэш не восстанавливает текст, но помогает доказать, что два запроса были идентичны.

Содержимое промптов также не нужно бездумно записывать в OpenTelemetry GenAI-атрибуты. В соглашениях GenAI новые поля входных и выходных сообщений не записываются по умолчанию при выключенном захвате контента. Это правильная осторожность, а не недостающая функция.

Один trace должен отвечать на один пользовательский вопрос

Не создавайте отдельный trace для каждого SDK-вызова, если пользователь совершил одно действие. Родительский trace должен начинаться на границе запроса, задачи очереди или фонового процесса и сохранять контекст через весь путь: retrieval, выбор маршрута, LLM-вызовы, tool calls, проверку ответа и запись результата.

Для LLM-части я бы использовал такую иерархию:

POST /support/reply
  llm.workflow support_reply
    llm.route select_model
    gen_ai.chat attempt=1
    llm.tool.execute search_customer
    gen_ai.chat attempt=2
    llm.output.validate

llm.workflow отвечает на вопрос «что увидел пользователь». gen_ai.chat отвечает на вопрос «что сделал конкретный вызов модели». llm.route отвечает на вопрос «почему выбрали это исполнение». Не смешивайте эти сущности в один огромный span, иначе его поля постоянно перезаписываются последней попыткой.

Метрики строятся поверх той же схемы, но не заменяют trace. На метриках отслеживайте p50, p95 и p99 длительности, долю ошибок по error.type, токены по типу, стоимость и долю fallback. В traces расследуйте один запрос: какой контекст пришёл, какая ветка маршрута сработала, сколько времени занял инструмент и где возникла ошибка.

Стандартная метрика gen_ai.client.token.usage использует тип токена как атрибут и рекомендует передавать её, когда счётчики доступны без дорогого приблизительного подсчёта. Не пытайтесь отправлять один histogram с «общими токенами» и затем угадывать по нему структуру затрат.

Адаптер провайдера должен быть тупым и проверяемым

Сохраните привычный SDK
Замените base_url на AI Router, сохранив существующие SDK, код и промпты.

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

Проверьте адаптер на фиксированных примерах. Один пример должен включать обычный ответ с usage. Второй, потоковый ответ, где usage приходит только в финальном фрагменте. Третий, cache hit и cache creation. Четвёртый, tool call. Пятый, 429 с успешным повтором. Шестой, fallback на другую модель. Без этих примеров команда обычно проверяет только happy path, а затем месяцами неверно считает именно дорогие или проблемные вызовы.

Ниже пример функции нормализации, которую можно покрыть контрактными тестами независимо от SDK:

def normalize_usage(raw: dict) -> dict:
    usage = raw.get("usage") or {}
    return {
        "llm.usage.input_tokens": usage.get("input_tokens"),
        "llm.usage.output_tokens": usage.get("output_tokens"),
        "llm.usage.reasoning_output_tokens": usage.get("reasoning_tokens"),
        "llm.usage.cache_read_input_tokens": usage.get("cache_read_tokens"),
        "llm.usage.cache_creation_input_tokens": usage.get("cache_write_tokens"),
        "llm.usage.source": "provider" if usage else "unavailable",
    }

Код выше намеренно не заменяет отсутствующие значения на нули и не выводит total_tokens самостоятельно. В настоящем адаптере добавьте проверку семантики конкретного API: некоторые ответы сообщают полный input, другие отдают только части, а stream может закончиться без финального usage при обрыве соединения.

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

Версия схемы важнее красивого набора полей

Телеметрическая схема будет меняться. Добавятся новые типы токенов, серверные инструменты, retrieval, multimodal-ввод и новые причины маршрутизации. Это не аргумент в пользу свободного JSON без контракта. Это аргумент в пользу версии и дисциплины миграции.

Пишите llm.schema.version в каждой записи. Новые поля добавляйте так, чтобы старые потребители продолжали работать. Изменение смысла существующего поля считайте несовместимым изменением, даже если имя осталось прежним. Если вы решили, что input_tokens теперь означает «некэшированные токены» вместо «весь вход», создайте новое поле и период миграции. Иначе исторический график начнёт сравнивать разные вещи.

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

Хорошая телеметрия LLM не обязана хранить каждый байт и не обязана предугадывать все будущие API. Она обязана не путать намерение с исполнением, неизвестное с нулём, ошибку попытки с ошибкой пользователя и технический счётчик со счётом за деньги. Этого достаточно, чтобы пережить следующую замену модели без слепого ремонта всех отчётов.

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

Можно ли построить одну схему OpenTelemetry для OpenAI, Anthropic и Gemini?

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

Чем requested model отличается от resolved model в трассировке LLM?

Это разные величины. Запрошенная модель приходит из вашего приложения, а фактическая модель приходит в ответе или известна маршрутизатору после выбора. Если записать одну строку model, вы потеряете и факт намерения, и факт исполнения при fallback или алиасах.

Как учитывать кэшированные токены у разных LLM-провайдеров?

Показывайте отдельно входные, выходные, reasoning, cache read и cache creation токены, если провайдер их отдаёт. В общую сумму включайте только те категории, которые участвуют в расчёте счёта по правилам данного провайдера. Не пересчитывайте токены локальным токенизатором для финансового отчёта, если ответ API уже содержит usage.

Нужно ли рассчитывать стоимость LLM-вызова прямо в span?

Храните валюту, сумму, статус расчёта и версию прайс-листа или идентификатор тарифного снимка. Деньги нельзя надёжно восстановить только из имени модели и числа токенов: тарифы, режимы обработки и кэш меняют цену. Если счёт ещё не рассчитан, так и пометьте запись, а не подставляйте ноль.

Стоит ли писать аргументы tool call в OpenTelemetry?

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

Как правильно помечать retries и ошибки 429 в LLM-трейсах?

Ошибка HTTP, ошибка провайдера и ошибка бизнес-логики имеют разные причины и разные владельцы. Например, 429 при первой попытке, которая затем успешно повторилась, не должен превращать итоговый span в failed. Сохраните попытку как событие или дочерний span, а статус родительской операции оставьте успешным.

Нужно ли сохранять полный prompt и response в трассировке?

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

Нужно ли использовать только gen_ai.* semantic conventions?

Стандартизированные поля GenAI полезны для переносимости, но сами соглашения продолжают развиваться. Поэтому пишите совместимые gen_ai.* атрибуты для внешних инструментов и параллельно держите небольшую внутреннюю схему llm.* с версией. Не заставляйте аналитиков зависеть от переименования одного экспериментального атрибута.

Что лучше для LLM-наблюдаемости: метрики, трейсы или логи?

Метрики подходят для скорости, ошибок и распределения токенов по сервисам, моделям и операциям. Трейсы нужны, когда инженер расследует один пользовательский запрос, цепочку tool call, fallback или конкретный неожиданный счёт. Логи оставьте для подробностей ошибки и контролируемых диагностических записей.

Как телеметрия работает через OpenAI-совместимый LLM-шлюз?

В OpenAI-совместимой схеме приложению обычно достаточно заменить base_url, но наблюдаемость всё равно должна различать модель, выбранную приложением, и модель, которая фактически обработала запрос. Если шлюз маршрутизирует запрос или применяет fallback, он должен передавать это как отдельный факт исполнения, а не подменять исходное намерение клиента.