- Главная
- Блог
- «Найдите тот ответ, который клиент увидел вчера вечером» — как traceparent от Angular до GigaChat перестал превращать разбор инцидента в раскопки
«Найдите тот ответ, который клиент увидел вчера вечером» — как traceparent от Angular до GigaChat перестал превращать разбор инцидента в раскопки

Понедельник, конец рабочего дня. В чате поддержки прилетает скриншот от коммерческого директора одного из наших заказчиков. AI-секретарь бодро подтвердил встречу «завтра в 11:00 в переговорке Восток», которой в календаре нет и не было. Директор в бешенстве, менеджер клиента в панике, наш дежурный инженер открывает Kibana и начинает грепать логи по фамилии.
Через минут сорок картина складывается: где-то под конец прошлой недели в контекст секретаря затянуло старый черновик встречи из письма, пересланного несколько недель назад. Модель послушно достала его из pgvector и склеила в ответ. Логично, красиво и абсолютно не то, что нужно.
Сорок минут — это не отладка, это раскопки. И к каждому такому инциденту прикладывается ещё несколько часов постмортемов, разговоров с клиентом и починки промпта. В какой-то момент я честно сказал команде: если мы делаем ИИ-агентов для бизнеса, а на каждый разбор жалобы уходит полдня — мы делаем что-то не то. Мы потратили спринт на нормальный distributed tracing поверх всего LLM-конвейера. Ниже — что именно поменяли, где сначала промахнулись и что это дало.
Почему стандартный APM в AI-агенте не работает
Первое, что пришло в голову, — «ну поставим APM как у всех, span-ы, водопадики, всё же уже придумано». Не сработало. И вот почему.
Классические трейсы устроены просто: приходит HTTP-запрос, вы прогоняете его через несколько сервисов, каждый пишет span с длительностью, и на выходе получаете аккуратный «водопад» вызовов. С ИИ-агентом эта картинка ломается по трём причинам.
Во-первых, один клиентский клик в Angular порождает не один, а десятки вложенных обращений: маршрутизатор моделей, retriever с гибридным поиском, реренкинг, вызов инструмента в MCP-сервере, финальный вызов LLM, LLM-судья на проверке, отправка ответа. Стандартный трейс превращается в кашу, где не поймёшь, кто кому родитель.
Во-вторых, часть работы асинхронная. Пользователь закрыл вкладку, а фоновый воркер продолжил дописывать письмо и через какое-то время сохранил его в CRM. HTTP-контекст к этому моменту давно умер, и связывать эти события «по времени» — путь в никуда.
В-третьих — и это главное — важен не только span с длительностью, а сами данные: какой промпт ушёл в модель, что вернул retriever, какие чанки попали в контекст. Без этого запись «span длительностью почти пять секунд» не даёт ровным счётом ничего. Ты видишь, что было медленно, но не видишь, почему модель ответила ерунду.
Что мы носим через границы сервисов
Взяли за основу OpenTelemetry и договорились о едином trace_id, который живёт от первого клика в Angular до последнего вызова GigaChat Pro и обратно.
На фронте перехватчик Angular создаёт корневой span и кладёт в заголовок traceparent по спецификации W3C. Дальше FastAPI-мидлварь достаёт его, прокидывает в contextvars и во все дочерние вызовы. В очереди задач trace_id едет прямо в payload сообщения, чтобы фоновый воркер продолжил тот же трейс, а не начал новый. Мелочь, но именно на ней мы сначала обожглись: первые два дня трейсы из воркеров упорно жили отдельной вселенной, пока не сообразили, что «положить в заголовок HTTP» и «положить в тело сообщения RabbitMQ» — это разные упражнения.
Отдельно интересен MCP-сервер. Мы протащили traceparent через MCP-запрос как расширение заголовка. Клиент MCP подхватывает его на другой стороне и продолжает span. Теперь вызов инструмента «получить сделки клиента» из LLM не выглядит как изолированное чёрное окошко — он часть общей истории, с родителем, детьми и атрибутами.
# fastapi middleware
async def otel_middleware(request, call_next):
ctx = extract(request.headers)
with tracer.start_as_current_span(
f"{request.method} {request.url.path}",
context=ctx,
kind=SpanKind.SERVER,
) as span:
span.set_attribute("tenant_id", request.state.tenant_id)
span.set_attribute("user_id", request.state.user_id)
response = await call_next(request)
span.set_attribute("http.status_code", response.status_code)
return response
Кейс: письмо с несуществующей встречей, разобранное до кофе
Через месяц после раскатки прилетает новый инцидент. AI-секретарь снова придумал встречу, на этот раз у руководителя отдела продаж дистрибьютора стройматериалов. Клиент прислал скриншот, сказал «это было утром».
Дежурный инженер открывает Grafana Tempo, находит трейс по user_id и грубому временному окну. Одна ссылка — и перед ним развёрнутый водопад: клик в веб-интерфейсе, роутинг, retriever отдал больше десятка чанков, реренкер оставил половину, вызов GigaChat Pro на несколько секунд, ответ. Каждый span подписан.
Он раскрывает span retriever и видит атрибуты: список ID чанков, их косинусные расстояния, source (email/crm/docs). Один из чанков — email с приглашением, которое так и не было принято. Метка типа документа стоит email.draft, но фильтр в промпте её проигнорировал.
Всё расследование заняло примерно столько же, сколько занимает утренний кофе. Дальше починка — уже другая история: правится фильтр в SQL-запросе retriever, добавляется снапшот-тест на подобный сценарий, ставится алерт на попадание черновиков в контекст. Раньше на этом этапе мы бы ещё грепали логи и переспрашивали клиента, во сколько именно всё случилось.
Semantic conventions для LLM: что записывать в span
OpenTelemetry ещё не догнал LLM-специфику своим стандартом до конца, поэтому мы завели внутренние соглашения об атрибутах. Не бог весть какая наука, но без этих договорённостей каждый сервис в проекте начнёт называть одно и то же по-разному, а потом ты не сможешь построить нормальный дэшборд. Ключевое:
llm.provider— GigaChat, YandexGPT, локальная модель на T-Litellm.model— конкретная версияllm.request.prompt_hash— SHA-256 первых восьми килобайт промпта, чтобы не хранить сам текст в трейсеllm.request.prompt_ref— ссылка на полный промпт в S3-совместимом хранилище с TTL 14 днейllm.request.tokens_in,llm.response.tokens_outllm.tools.called— список вызванных инструментовrag.retrieved_chunks— массив ID и оценокrag.reranker.top_k— сколько осталось после реренкераjudge.verdict— что сказал LLM-судья и с какой уверенностью
Разделение prompt_hash и prompt_ref не случайное. Хэш всегда рядом со span, по нему легко искать одинаковые запросы и находить кластеры «а вот эта формулировка ломается вот здесь». А полный текст лежит отдельно и открывается по клику из UI, но при этом попадает под общий TTL и правила 152-ФЗ — мы анонимизируем ПДн ещё до записи в S3. Иначе в трейсах внезапно оказывается вся переписка клиента, и это уже не отладка, а инцидент безопасности.
Sampling: как не утопить трейсинг в собственных логах
Первая наивная версия писала всё подряд. Через пару дней стало понятно, что при таком подходе цена ИИ-агента для нас же самих будет складываться в основном из счёта за хранение трейсов, а не из GPU и вызовов моделей. Пришлось делать tail-based sampling с несколькими правилами:
- 100% запросов, где
judge.verdict = suspectилиjudge.confidenceниже порога - 100% запросов с длительностью выше 95-го перцентиля
- 100% запросов с ошибками и ретраями
- 100% запросов от новых промпт-версий в первые сутки после раскатки
- небольшая случайная выборка из «зелёных» запросов — на всякий случай
Логика решается в OpenTelemetry Collector, а не в приложении. Приложение всегда пишет полный трейс в буфер, дальше коллектор ждёт где-то полминуты после закрытия корневого span, оценивает по правилам и либо сохраняет в Tempo, либо выбрасывает. Это дорого по памяти, но дёшево по хранению — а память мы уже пережили, докинули коллектору ресурсов и успокоились.
Отдельно ловушка: 100 секунд у нашего входящего прокси. Если корневой span висит открытым дольше — коллектор не дождётся сигнала о закрытии и всё равно выкинет трейс, потому что решит, что запрос потерялся. Пришлось явно закрывать корневой span сразу после отдачи ответа пользователю и запускать «фоновую» часть уже отдельным span-ом, привязанным к тому же trace_id.
Что мы получили в итоге
Считать эффект в процентах не буду — не хочу заниматься псевдонаукой. Скажу по-человечески.
Раньше на разбор жалобы «модель мне вчера что-то не то ответила» уходил час, иногда полдня — с переспрашиванием клиента, попытками восстановить контекст, чтением логов из четырёх сервисов. Теперь это уверенно вписывается в кофе-брейк: инженер открывает Tempo, находит трейс, раскрывает нужный span, видит промпт и чанки. Дальше уже думает, как чинить, а не как искать.
Появился слой превентивных находок. Алерты на аномальные значения judge.confidence и на ситуации, когда rag.reranker.top_k схлопнулся в ноль, начали ловить проблемы до того, как клиент их замечает. Несколько раз за квартал это оказывались полноценные будущие жалобы, которые мы просто прикрыли до того, как кто-то увидел странный ответ.
Побочный эффект — заметно проще стало онбордить новых инженеров. Раньше на объяснение «как устроена одна сессия секретаря» уходило полдня и белая доска. Теперь я открываю один трейс и минут за десять показываю всю анатомию: где промпт, где retrieval, где вызов инструмента, где судья. Люди, которые пришли из мира n8n и ИИ-агентов на low-code, обычно первый раз в жизни видят, что под капотом у «умного бота» реально происходит, и это сильно ускоряет разговор о том, где эта архитектура заканчивается и почему для серьёзной автоматизации приходится писать код руками.
Что стоит забрать в свой проект
Если у вас в проде ИИ-агент сложнее чата с одним промптом, distributed tracing перестаёт быть роскошью. Три вещи, которые важнее всего.
Единый trace_id от фронта до последнего фонового воркера. Без этого разбирать инциденты — квест по логам разных сервисов с угадыванием времени. И да, отдельно проверьте, что он действительно доезжает через очередь и через MCP, а не «должен доезжать по документации».
Атрибуты, специфичные для LLM: хэш промпта, ссылка на полный текст, ID чанков retriever, вердикт судьи. Просто длительности span вам ничего не расскажут — они скажут «было медленно», а вам нужно «было неправильно».
Разумный sampling: сохраняйте всё подозрительное и малую случайную выборку остального. Иначе трейсы либо разорят вас, либо не будут отвечать на вопросы — оба варианта одинаково бесполезны.
Стоит ли ради этого поднимать OpenTelemetry Collector, Tempo и настраивать правила? Если вы всерьёз занимаетесь ИИ-агентами и автоматизацией для бизнеса, где на другом конце сидит живой клиент с деньгами — да, окупается за первый же серьёзный инцидент. А как у вас сейчас устроен разбор жалобы вида «модель ответила ерунду, посмотрите, что там было»?
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




