Перейти к содержимому
29 сент. 2025 г.·8 мин чтения

Сквозной trace_id для LLM-запросов без слепых зон

Сквозной trace_id для LLM-запросов помогает собрать в один разбор ответ модели, поиск, вызовы инструментов и логи приложения.

Сквозной trace_id для LLM-запросов без слепых зон

Где теряется картина инцидента

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

Часть из них попадает в логи приложения, часть - в API-шлюз, часть - в поиск, часть - в вызовы инструментов и провайдера модели. Если у каждого слоя свой идентификатор, общая картина распадается. В одном месте это request_id, в другом chat_id, в третьем tool_call_id, а где-то команда ищет только по времени.

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

В сценариях с LLM проблема заметнее. Ответ часто собирается по частям: модель получила фрагменты из поиска, потом вызвала CRM, потом ушла в запасной путь после таймаута. Финальный текст у вас есть, а весь маршрут до него - нет. Вы знаете, что система сказала, но не понимаете, почему она сказала именно это.

Обычно это выглядит прозаично. Поддержка приносит жалобу: "бот сказал, что данных нет". В логах приложения есть ошибка 500 от внутреннего инструмента. В логах поиска - пустой результат. У провайдера модели ответ вообще успешный. Без общей цепочки команда спорит уже не о причине сбоя, а о том, какое событие вообще относится к этому запросу.

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

Что связывать одним идентификатором

Один trace_id должен проходить через весь путь запроса, а не жить только в HTTP-логе. Финальный ответ модели сам по себе почти бесполезен. Чтобы понять, почему система ошиблась, затормозила или вернула пустой результат, нужен общий след для каждого шага, который повлиял на ответ.

Начните с входного запроса. Здесь обычно нужны HTTP-метод, маршрут, пользователь или сервис, время начала, версия приложения и короткая метка сценария: чат, поиск, суммаризация, поддержка. Если trace_id появляется уже на входе, дальше не придется гадать, какой запрос запустил цепочку событий.

Следом идет вызов модели. Здесь полезно связать trace_id с внутренним request_id и с идентификатором ответа у провайдера. Иначе при спорном ответе вы увидите текст в интерфейсе, но не найдете конкретный вызов у шлюза, у провайдера модели или в своей очереди ретраев.

Отдельный слой - поиск по базе знаний. Странный ответ часто связан не с моделью, а с тем, какие документы попали в контекст. Поэтому один и тот же trace_id должен связывать поисковый запрос, его нормализованную форму, список document_id, версию индекса, число отобранных фрагментов и факт пустой выдачи или низкого score.

То же правило работает для инструментов. Если агент вызывал CRM, калькулятор тарифа, внутренний API или SQL-функцию, trace_id должен попасть в запись о самом вызове, аргументах, результате и времени выполнения. Иначе будет казаться, что модель "ошиблась", хотя инструмент вернул старые данные или получил пустой параметр.

Ошибки приложения тоже нельзя хранить отдельно. Таймауты, ретраи, отмена запроса пользователем, превышение rate limit, падение JSON-парсера, маскирование PII перед отправкой в модель - все это части одного инцидента. Когда эти события связаны одним trace_id, разбор занимает минуты: видно, какой запрос пришел, что нашел поиск, какой инструмент сработал криво и какой идентификатор ответа вернул провайдер.

Как проходит один запрос по системе

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

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

Дальше приложение кладет тот же trace_id в контекст запроса и передает его по всему маршруту. Если у вас есть API-шлюз, оркестратор, поиск по базе знаний, ранжирование документов, вызовы SQL, CRM или биллинга, каждый шаг должен получить этот же идентификатор. В OpenTelemetry это обычно одна трасса с дочерними спанами, но и обычные JSON-логи уже сильно помогают, если trace_id везде одинаковый.

Типичный путь выглядит так:

  • интерфейс принимает вопрос пользователя;
  • backend создает trace_id и пишет входной лог;
  • поиск и инструменты получают тот же идентификатор;
  • модель формирует ответ, а приложение пишет финальный статус.

Представим простой случай. Клиент пишет: почему не прошел платеж. Сервис создает trace_id на входе. Потом модуль поиска идет в базу знаний за правилами возврата, ранжирование отбирает два документа, инструмент обращается в платежную систему за статусом транзакции, а LLM собирает ответ для оператора или клиента. Если платежный сервис вернул таймаут, вы увидите это в той же цепочке - рядом с промптом, найденными документами и итоговым текстом модели.

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

Если команда отправляет запросы через единый шлюз вроде AI Router, логика не меняется. Тот же trace_id должен доехать до вызова на api.airouter.kz, а затем вернуться в логи приложения, поиска и инструментов. Иначе цепочка оборвется на границе API.

Как ввести trace_id по шагам

Сквозной trace_id лучше вводить не на уровне логов, а в самой первой точке входа. Если человек нажал кнопку в UI, создайте идентификатор там. Если запрос пришел сразу в API, создайте его на gateway или в первом backend-сервисе. После этого один и тот же trace_id должен пройти весь путь: оркестратор, вызов модели, поиск, tool calls и логи приложения.

Обычно хватает такого порядка:

  1. Сгенерировать trace_id при первом входе запроса.
  2. Положить его в request context внутри приложения.
  3. Передавать его в headers при каждом внутреннем и внешнем вызове.
  4. Писать его в структурированные логи вместе с user_id, session_id и request path.
  5. Для каждого шага создавать свой span_id, чтобы видеть не только цепочку, но и место сбоя.

Trace_id отвечает на вопрос: это один и тот же запрос или нет. Span_id отвечает на другой: на каком шаге все сломалось. Поэтому поиск по базе знаний, вызов CRM, запрос к модели и обращение к внешнему API должны иметь общий trace_id, но разные span_id.

Если у вас уже есть OpenTelemetry, не заводите второй формат идентификаторов. Это частая ошибка. Берите тот trace_id, который уже живет в traceparent, и пишите его в логи в том же виде. Иначе вы получите две почти одинаковые цепочки, которые никто не захочет склеивать вручную ночью во время инцидента.

Отдельный случай - retry. Если система повторяет тот же запрос из-за таймаута или 429, trace_id менять не нужно. Это все еще один пользовательский запрос. Достаточно нового span_id или поля retry_count, которое покажет, что это повторная попытка.

Еще одна полезная договоренность - единый набор headers для всех сервисов. Это особенно удобно, если вы отправляете LLM-запросы через OpenAI-совместимый шлюз и хотите сохранить текущие SDK и код без отдельной схемы для трассировки. Тогда инцидент читается слева направо: вход в UI, поиск, вызов модели, ответ провайдера, запись в логах поддержки.

Перед запуском проверьте три вещи:

  • любой лог находится по trace_id;
  • любой внешний вызов несет тот же trace_id в headers;
  • retry не создает новый trace_id без причины.

Если это правило соблюдается с первого дня, разбор сбоев занимает минуты, а не полдня.

Какие поля писать в каждый лог

Сверяйте ответы по логам
Сводите аудит-логи шлюза с вашим trace_id и разбирайте сбои без ручного пазла.

Один и тот же trace_id должен жить во всех событиях, которые относятся к одному запросу: во входящем HTTP-вызове, в запросе к модели, в поиске, в инструментах и в ответе пользователю. Рядом с ним пишите span_id, чтобы видеть не только всю цепочку, но и отдельный шаг внутри нее.

Если у вас много клиентов и длинные сессии, этого мало. Добавьте session_id, tenant_id и безопасный хэш user_id. Сырой user_id лучше не хранить в логах: хэш позволяет связать события одного пользователя и не тянуть лишние персональные данные в разбор.

Для вызова модели почти всегда нужны одни и те же поля: model, provider, latency_ms, расход токенов на входе и выходе, request_id провайдера и prompt_version. Этот набор быстро отвечает на простой вопрос: что именно произошло и где.

Если запрос идет через шлюз к разным провайдерам, provider и request_id сразу сужают поиск. Не нужно гадать, уперлись вы в сеть, лимит, конкретную модель или в неверную версию промпта.

Для инструментов и поиска пишите отдельные события с тем же trace_id, но со своими полями. Обычно хватает tool_name, status, error_code, retry_count и собственной задержки. Если инструмент ходит в поиск, полезно добавить число найденных документов, источник данных и короткий идентификатор запроса к индексу. Тогда видно, модель ошиблась сама или получила пустой контекст.

Status лучше держать коротким и скучным: ok, timeout, rate_limited, validation_error, provider_error. Когда команды пишут в логах свободный текст вместо кода ошибки, разбор расползается. Один инженер ищет 429, другой too many requests, третий quota exceeded, а это один и тот же случай.

Не забывайте про кэш. Поле cache_hit или cache_status часто объясняет странную разницу в задержке и стоимости. Если один ответ пришел за 300 мс, а другой за 4 секунды, причина нередко в том, что первый попал в prompt cache, а второй нет.

Хорошая запись должна отвечать на вопрос без чтения десяти соседних строк. Например: tenant_id=bank-a, trace_id=abc123, model=gpt-4.1, provider=openai, tool_name=crm_lookup, status=timeout, retry_count=2, latency_ms=1810, prompt_version=v17, cache_hit=false. По такой строке уже понятно, где копать первым делом.

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

Как связать поиск и вызовы инструментов

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

В retrieval фиксируйте не только факт вызова, но и то, что реально влияло на ответ. Чаще всего хватает исходного запроса, значения top_k, списка document_id и времени выполнения. Этого достаточно, чтобы быстро понять, модель опиралась на нужные документы или поиск ушел в сторону.

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

Что сохранять у инструментов

У каждого вызова инструмента держите тот же trace_id и свой span_id. В лог лучше писать входные параметры и итог работы, но без лишних персональных данных. Если инструмент получает номер телефона, ИИН или адрес, маскируйте их до записи. Для разбора почти всегда хватает структуры запроса, статуса, кода ошибки и короткого результата.

Минимальный набор для инструмента такой:

  • имя инструмента;
  • очищенный ввод;
  • статус выполнения;
  • итог или код ошибки;
  • latency.

Внешние API помечайте отдельно. Для каждого такого выхода создавайте дочерний спан, даже если вызов один и кажется простым. Тогда видно, где именно выросла задержка: в модели, в поиске, в CRM, в платежном сервисе или в вашем коде.

Где trace_id часто теряют

Чаще всего цепочка рвется на очередях и фоновых задачах. Приложение с LLM ставит задачу на обогащение ответа, воркер ее выполняет, но trace_id в payload не передали. После этого инцидент уже не собрать.

Проверьте три места:

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

Простой пример: бот поддержки ищет инструкцию, затем вызывает внутренний сервис доставки и потом отправляет ответ клиенту. Если поиск вернул document_id 184 и 221, сервис доставки ответил 504, а retrieval пришел из кэша, вы увидите всю цепочку сразу. Без общего идентификатора это будут просто три разрозненные записи.

Пример разбора сбоя в поддержке

Маскируйте PII в проде
Проверьте маскирование PII и метки контента, если внедряете LLM под требования Казахстана.

Клиент пишет в чат: "Где моя заявка по подключению?" С виду это обычный запрос, но ответ уехал не туда из-за двух сбоев сразу. Поиск поднял старую карточку, а CRM не успела вернуть актуальный статус.

Без общего идентификатора команда увидела бы только куски: текст вопроса в чате, 504 в логе CRM и готовый ответ модели. По одному trace_id картина собирается за минуту.

В реальном разборе цепочка выглядела так:

  • 10:14:03 - чат принял вопрос клиента и создал trace_id для всей обработки;
  • 10:14:04 - сервис поиска нашел карточку по номеру телефона, но выбрал запись двухмесячной давности;
  • 10:14:05 - оркестратор вызвал CRM за свежим статусом, но получил 504 по таймауту;
  • 10:14:06 - слой инструментов вернул ошибку, а в контекст модели попали только данные из старой карточки;
  • 10:14:08 - модель ответила клиенту, что заявка "ожидает ответа", хотя на деле ее уже передали в монтаж.

Такой сбой часто выглядит как "модель придумала". На самом деле модель просто собрала ответ из того, что ей дали. Если в логах нет связки между поиском, вызовом CRM и финальным ответом, команда начинает чинить не то место: промпт, температуру или выбор модели. Это пустая трата времени.

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

После разбора команда поменяла три вещи. Сначала увеличила таймаут CRM до реалистичного уровня и добавила один retry для 5xx. Потом ввела фильтр свежести в поиске: старые карточки больше не попадают в основной контекст без явной пометки. И наконец добавила правило для модели: если источник статуса недоступен, нужно прямо сказать, что статус сейчас нельзя подтвердить, а не отвечать по архивной записи.

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

Частые ошибки

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

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

Где цепочка ломается

Первая ошибка - каждый микросервис генерирует свой trace_id. Локально это удобно, но для расследований бесполезно. В итоге у вас пять "правильных" идентификаторов на один запрос, и ни один не связывает всю историю.

Вторая ошибка - trace_id пишут только в access logs API-шлюза. Там видно, что запрос пришел и ушел, но не видно, какой промпт собрал оркестратор, какой retriever вернул пустой результат и какой инструмент ответил ошибкой.

Третья ошибка - идентификатор теряется в очередях, webhook и фоновых задачах. Это самый частый проблемный участок: синхронная часть еще выглядит связной, а все, что ушло в асинхронную обработку, выпадает из общей картины.

Даже если вы используете единый шлюз для моделей, это само по себе не решает задачу. Шлюз помогает собрать вызовы моделей в одном месте, но очередь, CRM-вебхук и воркер с постобработкой все равно должны получить тот же trace_id и записать его у себя.

Что потом мешает разбору

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

И последний промах - писать в логи персональные данные вместо стабильного идентификатора пользователя или сессии. Это создает лишний риск и почти не помогает диагностике. Намного полезнее хранить user_id, session_id, conversation_id и связывать их с trace_id. Тогда корреляция событий в OpenTelemetry и обычных логах работает без ручной чистки чувствительных данных.

Если коротко, ошибка почти всегда одна: команда трассирует не запрос, а отдельные куски системы. Для LLM этого мало.

Быстрая проверка перед запуском

Соберите вызовы в одном месте
Проведите LLM-запросы через AI Router и быстрее связывайте вызовы моделей с логами приложения.

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

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

Что должно сойтись:

  • один пользовательский запрос получает один trace_id, и он не меняется до финального ответа;
  • логи поиска, модели, инструмента и приложения идут в понятном порядке по времени;
  • retry создает новый спан или attempt_id, но сохраняет исходный trace_id;
  • ошибка 5xx сразу открывает связанный промпт, имя инструмента и провайдера модели;
  • дежурный инженер может собрать инцидент по одному идентификатору, без поиска по нескольким системам.

Проверьте это на конкретном примере. Допустим, чат поддержки ищет заказ в базе, потом вызывает инструмент для статуса доставки, а запрос к модели уходит через внешнего провайдера. Если на шаге с инструментом пришел 500, вы должны увидеть один и тот же trace_id в access logs, в логах поиска, в записи о вызове инструмента и в событии от LLM-шлюза.

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

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

Что сделать дальше

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

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

Минимальный план такой:

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

Отдельно проверьте границу с внешним LLM-шлюзом. Если вы работаете через AI Router, имеет смысл сразу договориться, как передавать trace_id в запрос к api.airouter.kz и как потом сводить запись из аудит-логов с логом приложения. Это особенно полезно, когда команда использует один OpenAI-совместимый endpoint для разных моделей и провайдеров и не хочет терять цепочку на внешнем вызове.

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

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

Зачем нужен `trace_id`, если логи уже есть?

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

Где лучше создавать `trace_id`?

Создавайте trace_id в самой первой точке входа: в UI, на gateway или в первом backend-сервисе. Если вы добавите его позже, часть событий уже уйдет в логи без связи с остальной историей.

Нужно ли менять `trace_id` при повторной попытке?

Нет, при retry оставляйте тот же trace_id. Это все еще один пользовательский запрос, а новую попытку отмечайте через span_id, retry_count или attempt_id.

Чем `trace_id` отличается от `span_id`?

Trace_id отвечает на простой вопрос: это один и тот же запрос или нет. Span_id показывает, на каком шаге цепочки возникла задержка, ошибка или таймаут.

Какие поля стоит писать в каждый лог?

Начните с trace_id, span_id, session_id или безопасного хэша user_id, статуса, задержки и кода ошибки. Для вызова модели добавьте model, provider, токены, prompt_version и request_id провайдера, чтобы инженер сразу видел, где копать.

Как не потерять `trace_id` в очередях и фоновых задачах?

Передавайте тот же trace_id в headers, в payload очереди и в контекст фоновой задачи. Воркер должен поднять его при старте и записать с ним результат, иначе цепочка оборвется на асинхронном шаге.

Как связать поиск по базе знаний и вызовы инструментов?

Пишите один trace_id во все события поиска и инструментов. Для поиска обычно хватает запроса, document_id, top_k, версии индекса и признака cache_hit, а для инструмента — имени, очищенных аргументов, статуса, кода ошибки и задержки.

Что делать с `trace_id`, если запрос идет через внешний LLM-шлюз?

Протягивайте тот же trace_id до внешнего вызова и сохраняйте у себя provider и внешний request_id. Если вы шлете запрос через AI Router, цепочка должна идти дальше без разрыва: из приложения в шлюз и обратно в ваши логи.

Как быстро проверить, что трассировка настроена правильно?

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

Что делать, если в системе уже есть `request_id`, `chat_id` и `tool_call_id`?

Выберите один формат и сделайте его главным, обычно это trace_id из OpenTelemetry или из первого входного сервиса. Остальные идентификаторы не удаляйте, но всегда привязывайте их к этому следу, чтобы request_id, chat_id и tool_call_id не жили отдельно.