Наблюдаемость AI-систем

Сквозной trace ID для AI-агента, MCP и внутренних API

Один агентный запрос может пройти через модель, несколько инструментов и бизнес-систему. Если каждый компонент пишет собственный несвязанный журнал, ответ на вопрос «почему изменился заказ?» превращается в ручное сопоставление времени, параметров и догадок.

Уровень: продвинутый Чтение: 8 минут Результат: сквозная трассировка агентного запроса

Что именно нужно связать

Trace ID — идентификатор всей операции от входного запроса до последнего внутреннего вызова. Он должен сопровождать:

  1. получение задачи агентом;
  2. решение вызвать инструмент;
  3. MCP-запрос и выполнение обработчика;
  4. HTTP-вызов внутреннего API;
  5. изменение состояния бизнес-системы;
  6. итоговый ответ агента или ошибку.

Одного trace ID недостаточно для описания ветвления. Поэтому у каждого отдельного шага есть span ID, а у дочернего шага — parent span ID. Trace ID отвечает на вопрос «какая это операция?», span ID — «какой именно шаг?».

Целевая схема

Клиент
  └─ Агент: trace=T, span=A
       ├─ Решение модели: trace=T, span=M, parent=A
       └─ MCP-вызов: trace=T, span=P, parent=A
            └─ Внутренний API: trace=T, span=I, parent=P
                 └─ Изменение заказа: trace=T, span=B, parent=I

Во всех журналах сохраняется один trace_id, но каждый компонент создаёт собственный span_id. Это позволяет найти операцию простым поиском и восстановить дерево исполнения по полям span_id и parent_span_id.

Шаг 1. Зафиксируйте формат контекста

Для HTTP удобно передавать стандартный заголовок traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Это пример, а не значение для копирования в продакшен. Поля разделены дефисами:

версия-trace_id-parent_span_id-флаги

Trace ID содержит 32 шестнадцатеричных символа, span ID — 16. Нулевые значения недопустимы. Флаг 01 в примере означает, что трасса выбрана для записи.

В MCP-контекст можно положить те же значения в метаданные запроса. Конкретный способ зависит от используемого SDK, поэтому полезно определить внутренний нейтральный объект:

{
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "trace_flags": "01"
}

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

Шаг 2. Создайте и проверьте идентификаторы на входе

Ниже — минимальный пример на Python без внешних зависимостей. Он генерирует криптографически случайные идентификаторы и проверяет входной traceparent.

import re
import secrets
from dataclasses import dataclass

TRACEPARENT_RE = re.compile(
    r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$"
)

@dataclass(frozen=True)
class TraceContext:
    trace_id: str
    span_id: str
    trace_flags: str = "01"

def new_trace_id() -> str:
    value = secrets.token_hex(16)
    return value if value != "0" * 32 else new_trace_id()

def new_span_id() -> str:
    value = secrets.token_hex(8)
    return value if value != "0" * 16 else new_span_id()

def parse_traceparent(value: str | None) -> TraceContext | None:
    if not value or len(value) > 128:
        return None

    match = TRACEPARENT_RE.fullmatch(value.strip())
    if not match:
        return None

    trace_id, parent_span_id, flags = match.groups()
    if trace_id == "0" * 32 or parent_span_id == "0" * 16:
        return None

    return TraceContext(trace_id, parent_span_id, flags)

def start_request(incoming: str | None, trust_incoming: bool) -> TraceContext:
    parent = parse_traceparent(incoming) if trust_incoming else None
    return TraceContext(
        trace_id=parent.trace_id if parent else new_trace_id(),
        span_id=new_span_id(),
        trace_flags=parent.trace_flags if parent else "01",
    )

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

Шаг 3. Передайте контекст в MCP

Перед вызовом инструмента агент создаёт дочерний span. Названия полей метаданных ниже — внутреннее соглашение примера; адаптируйте их к своему MCP SDK.

def child_context(parent: TraceContext) -> TraceContext:
    return TraceContext(
        trace_id=parent.trace_id,
        span_id=new_span_id(),
        trace_flags=parent.trace_flags,
    )

tool_ctx = child_context(agent_ctx)

mcp_request = {
    "name": "update_order",
    "arguments": {
        "order_id": order_id,
        "status": target_status
    },
    "_meta": {
        "trace_id": tool_ctx.trace_id,
        "span_id": tool_ctx.span_id,
        "parent_span_id": agent_ctx.span_id,
        "trace_flags": tool_ctx.trace_flags
    }
}

MCP-сервер проверяет метаданные так же строго, как HTTP-заголовок. Если контекст отсутствует или повреждён, сервер создаёт новую трассу и пишет событие trace_context_rejected. Молча принимать произвольные строки не следует.

Шаг 4. Продолжите трассу во внутреннем API

MCP-обработчик создаёт новый span для исходящего HTTP-запроса, сохраняя trace ID:

def format_traceparent(ctx: TraceContext) -> str:
    return f"00-{ctx.trace_id}-{ctx.span_id}-{ctx.trace_flags}"

api_ctx = child_context(tool_ctx)

headers = {
    "traceparent": format_traceparent(api_ctx),
    "content-type": "application/json"
}

Здесь span ID в заголовке становится родительским для принимающего API. Само API создаёт новый локальный span, а полученное значение сохраняет как parent_span_id.

Trace ID не является механизмом авторизации или идемпотентности. Для повторяемого изменения бизнес-состояния передавайте отдельный ключ идемпотентности и проверяйте обычные права доступа.

Шаг 5. Пишите структурированные журналы

Каждая запись должна быть отдельным JSON-объектом. Минимальный набор полей:

{
  "timestamp": "2026-01-15T10:24:31.482Z",
  "level": "INFO",
  "service": "order-mcp",
  "event": "tool.completed",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "7a31c94d8e1a22f0",
  "parent_span_id": "00f067aa0ba902b7",
  "tool_name": "update_order",
  "duration_ms": 84,
  "outcome": "success"
}

Запись целиком является примером. Не копируйте дату и идентификаторы как постоянные значения.

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

{
  "service": "agent-runtime",
  "event": "model.tool_selected",
  "trace_id": "...",
  "span_id": "...",
  "parent_span_id": "...",
  "tool_name": "update_order",
  "model_request_id": "...",
  "outcome": "success"
}

model_request_id добавляйте только если провайдер действительно возвращает его. Не генерируйте фиктивное значение под видом идентификатора провайдера.

На границе бизнес-системы полезно записать тип операции и идентификатор сущности, если политика журналирования это разрешает:

{
  "service": "orders-api",
  "event": "order.status_changed",
  "trace_id": "...",
  "span_id": "...",
  "parent_span_id": "...",
  "entity_type": "order",
  "entity_id": "example-order-id",
  "outcome": "success"
}

Не журналируйте полный промпт, тело ответа модели, токены, платёжные данные и другие чувствительные поля по умолчанию.

Проверка результата

Выполните один запрос в тестовой среде и сохраните выданный системой trace ID. Затем найдите все записи локального JSONL-журнала безопасной командой только для чтения:

TRACE_ID='4bf92f3577b34da6a3ce929d0e0e4736'
rg --fixed-strings -- "$TRACE_ID" ./logs/*.jsonl

Значение переменной здесь демонстрационное. Подставьте trace ID фактического тестового запроса. Не используйте пользовательский ввод как имя файла или фрагмент команды.

Успешная проверка должна показать:

  1. одинаковый trace_id во всех задействованных сервисах;
  2. уникальный ненулевой span_id у каждого шага;
  3. корректную цепочку parent_span_id;
  4. событие выбора инструмента до события начала MCP-вызова;
  5. завершение внутреннего API до успешного завершения инструмента;
  6. явный outcome и длительность для каждого завершённого шага.

Для автоматической проверки структуры можно сохранить найденные записи в trace.jsonl и выполнить:

python3 - < trace.jsonl <<'PY'
import json
import sys

rows = [json.loads(line) for line in sys.stdin if line.strip()]
assert rows, "трасса пуста"

trace_ids = {row.get("trace_id") for row in rows}
assert len(trace_ids) == 1, f"найдено trace_id: {trace_ids}"

span_ids = [row.get("span_id") for row in rows if row.get("span_id")]
assert len(span_ids) == len(set(span_ids)), "span_id повторяется"

known = set(span_ids)
missing = {
    row["parent_span_id"]
    for row in rows
    if row.get("parent_span_id")
    and row["parent_span_id"] not in known
}
print({
    "records": len(rows),
    "trace_id": next(iter(trace_ids)),
    "external_or_missing_parents": sorted(missing),
})
PY

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

Как локализовать сбой

Последнее наблюдаемое событие Вероятная зона поиска
model.tool_selected, но нет tool.started Планировщик агента, очередь или MCP-клиент
tool.started, но внутреннее API не видит trace ID Формирование или передача traceparent
API завершилось успешно, инструмент сообщил ошибку Разбор ответа API или постобработка MCP
Инструмент успешен, но агент отвечает иначе Передача результата инструмента модели или финальная генерация
Есть изменение сущности, но нет родительского span Потеря контекста перед бизнес-операцией или фоновая задача

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

Типовые ошибки

Один span ID на весь запрос
Так невозможно отделить задержку модели от MCP и внутреннего API. Trace ID общий, span ID создаётся заново для каждого шага.
Новый trace ID внутри MCP
Связь с агентом теряется. Новый trace ID допустим только при отсутствии, недоверии или повреждении входного контекста; отказ нужно журналировать.
Trace ID используется как ключ идемпотентности
Повторная попытка может оставаться в той же трассе, но быть отдельной попыткой. Для защиты бизнес-операции нужен самостоятельный ключ.
Контекст хранится в глобальной переменной
Параллельные запросы перемешиваются. Передавайте контекст явно либо используйте безопасный для асинхронного выполнения механизм локального контекста.
В журнал попадают аргументы инструмента целиком
Это создаёт риск утечки данных. Используйте разрешённый список полей, маскирование и ограничения размера.
Успех записывается до фиксации изменения
Событие бизнес-успеха следует писать после подтверждённой транзакции. Иначе журнал утверждает то, чего система могла не сохранить.
Повторные попытки неразличимы
Оставляйте общий trace ID, создавайте новый span для каждой попытки и добавляйте числовое поле attempt.

Ограничения

  • Trace ID показывает связь событий, но сам по себе не гарантирует полноту или порядок доставки журналов.
  • Сэмплирование может удалить часть span-записей. Ошибочные и бизнес-критичные операции обычно требуют отдельной политики хранения.
  • Асинхронная очередь разрывает обычный стек вызовов. Контекст нужно помещать в метаданные сообщения, проверять у потребителя и создавать дочерний span.
  • Для долгоживущих процессов одна трасса может стать чрезмерно большой. В таком случае операции разделяют, а связь сохраняют отдельным correlation ID или ссылкой между трассами.
  • Единый trace ID не заменяет метрики, аудит, журнал транзакций и контроль доступа.
  • Часы сервисов могут расходиться. Дерево parent-child надёжнее простого упорядочивания по временным меткам.

Минимальный критерий готовности

Решение можно считать рабочим, если по trace ID инженер находит решение вызвать инструмент, выполнение MCP, обращение к API и подтверждённое изменение бизнес-сущности; видит родительские связи, длительность, повторные попытки и точку ошибки — без чтения промптов и поиска по приблизительному времени.

Дальнейшие материалы собраны в разделе практических руководств, а определения observability, span, correlation ID и других терминов — в глоссарии.