Перейти к содержанию

Метрики и мониторинг с Prometheus и Grafana

Шаг 1

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

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

Такую работу удобнее выполнять с помощью метрик. Приложение будет накапливать числовые показатели, Prometheus — регулярно собирать и хранить их, а Grafana — показывать результаты на графиках. Благодаря этому мы сможем увидеть общую проблему, а затем при необходимости перейти к логам и подробно разобрать отдельный запрос.

Метрика это числовой показатель работы приложения. Вместо подробностей отдельного запроса она хранит общее значение. Например:

  • количество успешных и ошибочных обращений к модели
  • число использованных входных и выходных токенов
  • продолжительность генераций
  • накопленную приблизительную стоимость

В этом разделе мы свяжем три компонента:

И перед тем, как переходить к коду, давайте для работы с метриками установим официальный Python-клиент Prometheus:

pip install prometheus-client

После установки обновите зависимости проекта.

Перед тем как переходить к коду, давайте разберём два типа метрик, которые будем использовать в нашем приложении: Counter и Histogram. Они хранят данные по-разному и подходят для разных показателей.

Counter, или счётчик, хранит значение, которое только увеличивается. Например после каждого обращения к модели мы прибавляем единицу:

первый вызов  → 1
второй вызов  → 2
третий вызов  → 3

Таким способом удобно учитывать общее количество вызовов, использованных токенов и накопленную стоимость. Счётчик не уменьшается вручную и начинает заново только после перезапуска процесса.

Histogram, или гистограмма, нужна для значений, которые могут каждый раз отличаться. В нашем случае это длительность обращения к модели, ведь один ответ может занять 1 секунду, другой — 3, а третий — 8 секунд.

Гистограмма распределяет измерения по диапазонам, например:

до 1 секунды
до 2,5 секунды
до 5 секунд
до 10 секунд

Благодаря этому можно определить, какая часть запросов выполнялась быстро, а какая заметно дольше. Позднее Grafana использует эти данные, чтобы показать распределение времени и, например оценить длительность, быстрее которой завершилось 95% обращений к модели.

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

  • ai_support_llm_calls_total — количество обращений к модели
  • ai_support_llm_tokens_total — количество токенов
  • ai_support_llm_duration_seconds — продолжительность вызовов
  • ai_support_llm_estimated_cost_total — накопленная оценка стоимости

У метрик могут быть метки, или labels. Например счётчик вызовов удобно разделить по операции generate, generate_structured и stream, а также по результату success или error.

Значения меток должны браться из небольшого контролируемого набора. Уникальные request_id, session_id и response_id нельзя использовать как метки. Иначе для каждого запроса будет появляться новая временная серия, объём данных начнёт бесконтрольно расти. Эти идентификаторы остаются в логах, где они действительно нужны.

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

Откройте app/observability/llm.py и дополните импорты:

import logging

from prometheus_client import (
    CollectorRegistry,
    Counter,
    Histogram,
)

from app.llm.cost import LLMCostEstimator

Теперь полностью замените конструктор LLMObserver:

def __init__(
    self,
    cost_estimator: LLMCostEstimator,
) -> None:
    self.registry = CollectorRegistry()
    self._cost_estimator = cost_estimator

    self._calls = Counter(
        "ai_support_llm_calls",
        "Количество завершённых обращений к модели.",
        ("operation", "status"),
        registry=self.registry,
    )
    self._tokens = Counter(
        "ai_support_llm_tokens",
        "Количество токенов, сообщённых провайдером.",
        ("operation", "direction"),
        registry=self.registry,
    )
    self._duration = Histogram(
        "ai_support_llm_duration_seconds",
        "Продолжительность обращения к модели.",
        ("operation",),
        registry=self.registry,
    )
    self._estimated_cost = Counter(
        "ai_support_llm_estimated_cost",
        "Накопленная приблизительная стоимость.",
        ("currency",),
        registry=self.registry,
    )

CollectorRegistry объединяет созданные показатели в один реестр. Позднее эндпоинт /metrics прочитает именно его. Каждый Counter получает имя, описание и допустимые метки. В коде имя счётчика указано без окончания _total, но библиотека добавит его в экспортируемом формате автоматически.

Полностью замените метод record_success():

def record_success(
    self,
    operation: str,
    model: str,
    finish_reason: str | None,
    prompt_tokens: int | None,
    completion_tokens: int | None,
    total_tokens: int | None,
    response_id: str,
    duration_seconds: float,
) -> None:
    self._calls.labels(
        operation=operation,
        status="success",
    ).inc()
    self._duration.labels(operation=operation).observe(
        duration_seconds
    )

    if prompt_tokens is not None:
        self._tokens.labels(
            operation=operation,
            direction="input",
        ).inc(prompt_tokens)

    if completion_tokens is not None:
        self._tokens.labels(
            operation=operation,
            direction="output",
        ).inc(completion_tokens)

    estimate = self._cost_estimator.estimate(
        prompt_tokens=prompt_tokens,
        completion_tokens=completion_tokens,
    )

    estimated_cost: float | None = None
    cost_currency: str | None = None

    if estimate is not None:
        estimated_cost = float(estimate.amount)
        cost_currency = estimate.currency
        self._estimated_cost.labels(
            currency=estimate.currency
        ).inc(estimated_cost)

    logger.info(
        "llm_call_completed",
        extra={
            "operation": operation,
            "model": model,
            "finish_reason": finish_reason,
            "prompt_tokens": prompt_tokens,
            "completion_tokens": completion_tokens,
            "total_tokens": total_tokens,
            "estimated_cost": estimated_cost,
            "cost_currency": cost_currency,
            "response_id": response_id,
            "duration_seconds": duration_seconds,
        },
    )

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

После расчёта стоимости сумма добавляется к отдельному счётчику. Если тарифы не настроены, метрики вызовов, токенов и времени всё равно продолжат работать. В конце сохраняется уже знакомый JSON-лог. Таким образом, один вызов наблюдателя одновременно создаёт подробное событие и обновляет общую статистику.

Теперь полностью замените record_failure():

def record_failure(
    self,
    operation: str,
    error_type: str,
    duration_seconds: float,
) -> None:
    self._calls.labels(
        operation=operation,
        status="error",
    ).inc()
    self._duration.labels(operation=operation).observe(
        duration_seconds
    )
    logger.error(
        "llm_call_failed",
        extra={
            "operation": operation,
            "error_type": error_type,
            "duration_seconds": duration_seconds,
        },
    )

Неудачная попытка увеличивает счётчик с меткой status="error" и тоже передаёт длительность гистограмме. Токены и стоимость не добавляются, потому что при ошибке провайдер может не вернуть статистику.

Теперь метрики создаются внутри процесса, но получить их по HTTP пока нельзя. Создайте app/api/routes/metrics.py:

from typing import Annotated

from fastapi import APIRouter, Depends, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest

from app.api.dependencies import get_container
from app.application import ApplicationContainer

router = APIRouter()

@router.get("/metrics", include_in_schema=False)
def read_metrics(
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> Response:
    content = generate_latest(
        container.llm_observer.registry
    )
    return Response(
        content=content,
        media_type=CONTENT_TYPE_LATEST,
    )

Эндпоинт получает контейнер через уже знакомую зависимость FastAPI. generate_latest() читает реестр наблюдателя и преобразует его в текстовый формат Prometheus. Это не JSON: каждая строка содержит имя показателя, метки и числовое значение.

include_in_schema=False скрывает технический маршрут из Swagger UI, чтобы он не смешивался с пользовательскими операциями. Но это не защита. В опубликованном приложении /metrics обычно оставляют во внутренней сети или закрывают правилами reverse proxy. В нашем локальном запуске он будет доступен напрямую.

Откройте app/api/main.py, добавьте импорт:

from app.api.routes.metrics import router as metrics_router

Внутри create_api() подключите маршрутизатор рядом с остальными:

application.include_router(metrics_router)

Запустите FastAPI и откройте Swagger UI.

И чтобы в метриках появились первые значения, воспользуемся обычными эндпоинтами чата. Сначала через POST /chat/sessions создайте сессию и сохраните полученный session_id. Затем передайте его в POST /chat/sessions/{session_id}/messages и отправьте сообщение пользователю.

{
  "message": "Здравствуйте! Где сейчас находится заказ 8041?"
}

Теперь просто откройте в браузере http://127.0.0.1:8000/metrics и посмотрите на ответ:

# HELP ai_support_llm_calls_total Количество завершённых обращений к модели.
# TYPE ai_support_llm_calls_total counter
ai_support_llm_calls_total{operation="stream",status="success"} 2.0
# HELP ai_support_llm_calls_created Количество завершённых обращений к модели.
# TYPE ai_support_llm_calls_created gauge
ai_support_llm_calls_created{operation="stream",status="success"} 1.789194679989038e+09
# HELP ai_support_llm_tokens_total Количество токенов, сообщённых провайдером.
# TYPE ai_support_llm_tokens_total counter
ai_support_llm_tokens_total{direction="input",operation="stream"} 1158.0
ai_support_llm_tokens_total{direction="output",operation="stream"} 39.0
# HELP ai_support_llm_tokens_created Количество токенов, сообщённых провайдером.
# TYPE ai_support_llm_tokens_created gauge
ai_support_llm_tokens_created{direction="input",operation="stream"} 1.789194679989111e+09
ai_support_llm_tokens_created{direction="output",operation="stream"} 1.7891946799891179e+09
# HELP ai_support_llm_duration_seconds Продолжительность обращения к модели.
# TYPE ai_support_llm_duration_seconds histogram
ai_support_llm_duration_seconds_bucket{le="0.005",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.01",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.025",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.05",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.075",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.1",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.25",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.5",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="0.75",operation="stream"} 0.0
ai_support_llm_duration_seconds_bucket{le="1.0",operation="stream"} 2.0
ai_support_llm_duration_seconds_bucket{le="2.5",operation="stream"} 2.0
ai_support_llm_duration_seconds_bucket{le="5.0",operation="stream"} 2.0
ai_support_llm_duration_seconds_bucket{le="7.5",operation="stream"} 2.0
ai_support_llm_duration_seconds_bucket{le="10.0",operation="stream"} 2.0
ai_support_llm_duration_seconds_bucket{le="+Inf",operation="stream"} 2.0
ai_support_llm_duration_seconds_count{operation="stream"} 2.0
ai_support_llm_duration_seconds_sum{operation="stream"} 1.8016241249861196
# HELP ai_support_llm_duration_seconds_created Продолжительность обращения к модели.
# TYPE ai_support_llm_duration_seconds_created gauge
ai_support_llm_duration_seconds_created{operation="stream"} 1.789194679989065e+09
# HELP ai_support_llm_estimated_cost_total Накопленная приблизительная стоимость.
# TYPE ai_support_llm_estimated_cost_total counter
ai_support_llm_estimated_cost_total{currency="RUB"} 0.6779459999999999
# HELP ai_support_llm_estimated_cost_created Накопленная приблизительная стоимость.
# TYPE ai_support_llm_estimated_cost_created gauge
ai_support_llm_estimated_cost_created{currency="RUB"} 1.7891946799901319e+09

В полученном ответе видно, что чат выполнил два потоковых обращения к модели. Это нормально для сценария с инструментом: первый вызов выбирает нужную функцию, а второй формирует окончательный ответ. Вместе вызовы использовали 1158 входных и 39 выходных токенов, заняли около 1,8 секунды и стоили приблизительно 0,678 рубля. Строки с окончанием _created создаются библиотекой автоматически и содержат время появления метрики — в дальнейшем дашборде они нам не понадобятся.

Можно использовать и другие AI-эндпоинты проекта, анализ обращения, документа или изображения. Наблюдение подключено к общему LLMClient, поэтому оно учитывает все обращения к модели независимо от выбранного сценария.

Пока это только текущее состояние памяти работающего процесса. После перезапуска значения обнулятся, а вручную открытый адрес не создаёт историю и графики. В следующем шаге мы запустим отдельный сервер Prometheus. Он будет регулярно обращаться к /metrics и сам сохранять изменения во времени.

Шаг 2

Теперь наше приложение умеет отдавать метрики по адресу /metrics. Но сам эндпоинт ничего не хранит. Каждый раз он показывает только значения, которые находятся в памяти текущего процесса FastAPI. После перезапуска счётчики начнутся заново, а предыдущые значения исчезнут.

Чтобы увидеть изменения за час, день или неделю, метрики нужно регулярно забирать и сохранять вместе со временем получения. Эту задачу выполняет Prometheus.

Prometheus работает отдельно от AI-бэкенда. Через заданный промежуток времени он самостоятельно отправляет запрос к /metrics, читает показатели и сохраняет их как временные ряды. Такой способ называют pull-моделью: приложение не отправляет данные в Prometheus, а Prometheus забирает их из приложения.

Для локального запуска мы будем использовать Docker Compose, сейчас сильно в него углубляться мы не будем, но он позволит нам одной командой поднять два независимых контейнера: Prometheus и Grafana. В этом шаге мы настроим сбор данных в Prometheus, а к интерфейсу Grafana перейдём в следующем шаге.

Если вы раньше не работали с Docker, на этом этапе не нужно заранее разбираться во всех его возможностях. Пока мы используем Docker Compose как готовый способ запустить Prometheus и Grafana в изолированных окружениях, не устанавливая эти программы вручную в основную систему.

Подробно с Docker мы познакомимся в следующем разделе. Там разберём, чем образ отличается от контейнера, как устроен Dockerfile, каким образом передаются переменные окружения и связываются порты, а затем поместим в контейнер уже сам AI-бэкенд. Сейчас достаточно установить Docker, выполнить приведённые команды и понять, что Prometheus и Grafana работают как отдельные сервисы рядом с FastAPI.

У вас должен быть установлен и запущен Docker Desktop, проверить его доступность можно командами:

docker --version
docker compose version

Если команды не найдены, установите Docker по официальной инструкции для своей операционной системы.

В корне проекта создайте каталог monitoring. В нём будут находиться конфигурация Prometheus и описание контейнеров:

ai-support-desk/
├── app/
├── tests/
├── monitoring/
│   ├── prometheus.yml
│   └── docker-compose.yml
├── main.py
└── requirements.txt

Создайте monitoring/prometheus.yml:

global:
  scrape_interval: 5s

scrape_configs:
  - job_name: ai-support-desk
    metrics_path: /metrics
    static_configs:
      - targets:
          - host.docker.internal:8000

В этом коде параметр scrape_interval указывает, как часто Prometheus должен обращаться к приложению за новыми значениями метрик:

scrape_interval: 5s

В нашем примере опрос выполняется каждые пять секунд. Благодаря этому после обращения к модели изменения быстро появятся в Prometheus и Grafana. В реальном проекте интервал выбирают с учётом необходимой точности и нагрузки, ведь чем чаще выполняется опрос, тем подробнее история, но тем больше данных приходится собирать и хранить.

В блоке scrape_configs указываются приложения, с которых Prometheus должен забирать метрики:

scrape_configs:
  - job_name: ai-support-desk
    metrics_path: /metrics
    static_configs:
      - targets:
          - host.docker.internal:8000

Здесь:

  • job_name задаёт понятное имя источника — ai-support-desk
  • metrics_path указывает адрес эндпоинта с метриками — /metrics
  • targets содержит адрес, по которому работает FastAPI

Вместе эти параметры сообщают Prometheus, что каждые пять секунд нужно отправлять запрос:

http://host.docker.internal:8000/metrics

Мы не используем localhost:8000, потому что Prometheus будет работать внутри Docker-контейнера. Для контейнера localhost означает его собственное окружение, а FastAPI запущен снаружи — на основном компьютере. То есть специальное имя host.docker.internal позволяет контейнеру обратиться к компьютеру, на котором работает Docker. Поэтому через этот адрес Prometheus сможет получить метрики локально запущенного FastAPI.

Теперь создайте monitoring/docker-compose.yml:

services:
  prometheus:
    image: prom/prometheus:latest
    container_name: ai-support-prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    extra_hosts:
      - "host.docker.internal:host-gateway"

  grafana:
    image: grafana/grafana:latest
    container_name: ai-support-grafana
    ports:
      - "3000:3000"
    volumes:
      - grafana_data:/var/lib/grafana
    depends_on:
      - prometheus

volumes:
  prometheus_data:
  grafana_data:

Первый сервис запускает Prometheus и открывает его веб-интерфейс на порту 9090. Файл конфигурации подключается в контейнер только для чтения. Том prometheus_data хранит собранные временные ряды отдельно от контейнера, поэтому обычный перезапуск не удалит историю.

extra_hosts добавляет адрес основной системы. На macOS и Windows Docker Desktop обычно предоставляет его автоматически, а значение host-gateway помогает сохранить ту же схему на современных Linux-установках.

Второй сервис запускает Grafana на порту 3000. Том grafana_data сохранит настройки и будущий дашборд. depends_on определяет порядок запуска контейнеров, но не настраивает источник данных — это сделаем вручную в следующем шаге.

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

Prometheus должен обращаться к FastAPI не только через локальный интерфейс 127.0.0.1, а через сетевой интерфейс компьютера. Поэтому запустите AI-бэкенд из корня проекта такой командой:

fastapi dev app/api/main.py --host 0.0.0.0

Параметр --host 0.0.0.0 разрешает принимать подключения не только с самого процесса, но и из контейнера Docker. Это подходит для локальной демонстрации. При публикации доступ ограничивают сетевыми правилами и reverse proxy.

Не закрывая FastAPI, откройте второй терминал в корне проекта и запустите контейнеры:

docker compose -f monitoring/docker-compose.yml up -d

Параметр -d запускает контейнеры в фоне. При первом запуске Docker сначала загрузит образы, поэтому потребуется немного времени. Проверить состояние можно командой:

docker compose -f monitoring/docker-compose.yml ps

У сервисов prometheus и grafana должен отображаться статус Up. Теперь откройте:

http://localhost:9090/targets

На странице Targets найдите задачу ai-support-desk. Статус UP означает, что Prometheus смог обратиться к FastAPI и прочитать /metrics.

Если отображается DOWN, сначала убедитесь, что FastAPI продолжает работать, адрес http://127.0.0.1:8000/metrics открывается на основном компьютере, а сервер запущен с --host 0.0.0.0. После исправления подождите следующий пятисекундный опрос.

Перейдите по адресу http://localhost:9090/query и выполните запрос:

up{job="ai-support-desk"}

Значение 1 означает, что источник метрик доступен.

Значение 0 показало бы, что очередной опрос завершился неудачно.

Теперь через Swagger UI выполните несколько запросов, которые обращаются к модели. Например, создайте чат-сессию и отправьте в неё несколько сообщений. После каждого вызова LLMObserver будет увеличивать счётчик обращений к модели.

Подождите несколько секунд, чтобы Prometheus успел снова получить данные из /metrics. Затем откройте страницу запросов Prometheus и выполните выражение:

ai_support_llm_calls_total

В результате появятся отдельные значения для уже использованных операций и их статусов.

На вкладке Graph можно увидеть изменение показателя:

При прямом открытии /metrics мы видели только текущие значения из памяти FastAPI. Prometheus же регулярно сохраняет эти значения вместе со временем получения. Поэтому на вкладке Graph можно выбрать временной диапазон и посмотреть, как количество обращений к модели изменялось во время работы приложения.

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

docker compose -f monitoring/docker-compose.yml down

Обычная команда down удалит контейнеры и сеть, но сохранит именованные тома с данными. При следующем запуске Prometheus и Grafana смогут продолжить работу с прежним состоянием.

Сейчас данные уже собираются, но встроенный интерфейс Prometheus предназначен главным образом для проверки запросов. В следующем шаге подключим его к Grafana и соберём отдельный дашборд: вызовы модели, ошибки, токены, длительность и приблизительная стоимость будут отображаться на понятных панелях.

Шаг 3

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

Для этого используем Grafana. Она не обращается напрямую к FastAPI и не заменяет Prometheus. Grafana подключается к Prometheus как к источнику данных, выполняет запросы на языке PromQL и превращает результаты в графики, таблицы и числовые панели.

Контейнер Grafana мы уже запустили через Docker Compose. Откройте в браузере:

http://localhost:3000

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

Сначала нужно объяснить Grafana, где находятся данные. Откройте раздел Connections, выберите Data sources(слева в меню), нажмите Add data source и выберите Prometheus.

В поле адреса сервера укажите:

http://prometheus:9090

Здесь снова нельзя использовать localhost:9090. Grafana работает внутри своего контейнера, поэтому localhost указывал бы на саму Grafana. Docker Compose создал общую сеть, в которой контейнер Prometheus доступен по имени сервиса prometheus.

Остальные параметры для локального запуска можно оставить без изменений. Нажмите Save & test. Сообщение об успешном соединении означает, что Grafana может выполнять запросы к Prometheus.

Создаём дашборд AI-бэкенда

В Grafana откройте раздел Dashboards и нажмите New → New dashboard. На пустом дашборде выберите Add visualization. Grafana предложит указать источник данных — выберите подключённый ранее Prometheus.

Откроется редактор новой панели. В его нижней части находится блок запросов с обозначением A. Если редактор работает в режиме Builder, переключите его в режим Code, чтобы можно было ввести готовое выражение PromQL.

В поле запроса A вставьте:

sum by (status) (
  increase(ai_support_llm_calls_total[5m])
)

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

success → успешные обращения
error   → обращения, завершившиеся ошибкой

В правой части редактора выберите тип визуализации Time series. Затем найдите поле Panel options → Title и укажите название:

Вызовы модели за 5 минут

После настройки запроса, визуализации и названия панели нажмите Save dashboard. В открывшемся окне укажите название:

AI Support Desk

Затем ещё раз нажмите Save. Панель сохранится вместе с дашбордом.

Если график окажется пустым, выполните несколько AI-запросов через Swagger UI, подождите два-три цикла сбора Prometheus и нажмите Refresh в Grafana.

Вернитесь к созданному дашборду AI Support Desk. В верхней части страницы нажмите Add → Visualization, а затем снова выберите источник данных Prometheus. В редакторе новой панели переключите запрос A в режим Code и вставьте выражение для подсчёта токенов:

sum by (direction) (
  increase(ai_support_llm_tokens_total[5m])
)

Она покажет, сколько входных и выходных токенов добавилось за последние пять минут. Метка direction="input" относится к контексту запроса, а direction="output" — к сгенерированному результату. Назовите панель «Токены за 5 минут».

Следующая панель будет отображать 95-й процентиль длительности:

histogram_quantile(
  0.95,
  sum by (le, operation) (
    rate(ai_support_llm_duration_seconds_bucket[5m])
  )
)

Гистограмма хранит число наблюдений в диапазонах, которые обозначаются меткой le. Функция histogram_quantile() оценивает значение, быстрее которого завершилось примерно 95% вызовов. Иначе говоря, редкие самые медленные запросы не определяют показатель целиком, но их влияние становится заметно.

Группировка по operation создаст отдельную линию для обычной, структурированной, потоковой и vision-генерации. Назовите панель «P95 длительности LLM-вызовов» и укажите единицу измерения seconds.

Последняя панель покажет накопленную оценку стоимости:

sum by (currency) (
  ai_support_llm_estimated_cost_total
)

Для неё удобно выбрать тип Stat, который показывает одно крупное число. Назовите панель «Оценка стоимости с момента запуска». Если тарифы в .env не заданы, соответствующая метрика не появится.

Это значение предназначено для наблюдения за расходами, а не для точного бухгалтерского учёта. Prometheus может пропустить опрос, процесс может перезапуститься, а тариф в настройках — устареть. Точные списания по-прежнему нужно сверять с отчётами провайдера.

В правом верхнем углу дашборда установите диапазон Last 1 hour и автоматическое обновление раз в пять секунд.

Теперь выполните несколько разных сценариев:

  • отправьте обычное сообщение в чат
  • задайте вопрос, для которого потребуется инструмент
  • выполните структурированный анализ обращения
  • проанализируйте документ или изображение

После очередного сбора значения на панелях изменятся.

То есть дашборд отвечает на общие вопросы, а увеличилось ли количество ошибок, выросло ли время ответа, какие операции расходуют больше токенов и как меняется приблизительная стоимость. Если график показывает проблему, разработчик переходит к структурированным логам и по Request ID разбирает конкретные запросы.

Таким образом, логи и метрики дополняют друг друга:

  • Grafana помогает заметить отклонение на уровне всего приложения
  • Prometheus хранит числовую историю и выполняет расчёты
  • структурированные логи помогают исследовать отдельный запрос
  • Response ID позволяет при необходимости сопоставить вызов с провайдером

В Grafana также можно настроить предупреждения, например при росте доли ошибок или слишком большом P95 времени ответа. Мы не будем добавлять уведомления в учебный проект: для них нужно выбрать каналы связи, допустимые пороги и правила дежурства команды. Главное, что необходимые показатели уже собираются, и такое развитие не потребует менять AI-сервисы.

Мы также не отправляли структурированные логи в Grafana. Для их централизованного хранения понадобился бы отдельный компонент, например Loki или другая система журналирования. Сейчас JSON-события остаются в стандартном выводе приложения, а Grafana отображает только метрики из Prometheus. Это осознанное упрощение, которое позволяет понять каждую часть цепочки отдельно.

Давайте подведём итоги раздела. Наш AI-бэкенд создаёт показатели каждого обращения к модели и отдаёт их через /metrics. Prometheus регулярно забирает эти значения и сохраняет временные ряды. Grafana использует Prometheus как источник данных и показывает вызовы, ошибки, токены, длительность и стоимость на одном дашборде.

Теперь наблюдаемость перестала быть набором строк в терминале. Мы можем сначала заметить общую проблему на графике, а затем перейти к связанным JSON-событиям и разобраться в конкретном запросе. Это важная часть подготовки AI-бэкенда к реальной эксплуатации.

Шаг 4

Какой тип метрики подходит для накопительного количества завершённых обращений к модели?

Правильный ответ
  • ✅ Counter

Шаг 5

Зачем продолжительность LLM-вызовов записывается в Histogram?

Правильный ответ
  • ✅ Чтобы видеть распределение времени и рассчитывать квантили

Шаг 6

Какие значения подходят для меток Prometheus с небольшой кардинальностью?

Выберите все правильные варианты.

Правильный ответ
  • ✅ Направление input или output
  • ✅ Операция generate или stream
  • ✅ Код валюты RUB или USD
  • ✅ Статус success или error

Шаг 7

Что показывает этот запрос?

histogram_quantile(
  0.95,
  sum by (le, operation) (
    rate(ai_support_llm_duration_seconds_bucket[5m])
  )
)
Правильный ответ
  • ✅ Приблизительный p95 длительности LLM-вызовов за последние 5 минут

Шаг 8

Создание метрик LLM-вызовов

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

Задача

Реализуйте конструктор класса LLMMetrics.

Требования

  • Создайте self.registry = CollectorRegistry().
  • Сохраните переданный калькулятор в self._cost_estimator.
  • Создайте Counter с именем "ai_support_llm_calls" и метками ("operation", "status").
  • Создайте Counter с именем "ai_support_llm_tokens" и метками ("operation", "direction").
  • Создайте Histogram с именем "ai_support_llm_duration_seconds" и меткой ("operation",).
  • Создайте Counter с именем "ai_support_llm_estimated_cost" и меткой ("currency",).
  • Во все четыре конструктора передайте registry=self.registry.
  • Сохраните метрики в self._calls, self._tokens, self._duration и self._estimated_cost.

Пример результата

metrics = LLMMetrics(cost_estimator)

metrics.registry       # отдельный CollectorRegistry
metrics._calls         # Counter вызовов
metrics._duration      # Histogram длительности

Классы из prometheus_client уже импортированы. Вторым аргументом каждого Counter и Histogram передайте краткое строковое описание метрики.

Правильный ответ
from prometheus_client import CollectorRegistry, Counter, Histogram

class LLMMetrics:
    def __init__(self, cost_estimator) -> None:
        self.registry = CollectorRegistry()
        self._cost_estimator = cost_estimator

        self._calls = Counter(
            "ai_support_llm_calls",
            "Количество LLM-вызовов",
            ("operation", "status"),
            registry=self.registry,
        )

        self._tokens = Counter(
            "ai_support_llm_tokens",
            "Количество токенов LLM",
            ("operation", "direction"),
            registry=self.registry,
        )

        self._duration = Histogram(
            "ai_support_llm_duration_seconds",
            "Длительность LLM-вызовов в секундах",
            ("operation",),
            registry=self.registry,
        )

        self._estimated_cost = Counter(
            "ai_support_llm_estimated_cost",
            "Накопленная оценочная стоимость LLM-вызовов",
            ("currency",),
            registry=self.registry,
        )

Шаг 9

Метрики успешного LLM-вызова

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

Задача

Реализуйте метод record_success(...) класса LLMMetricsRecorder. Все объекты метрик и калькулятор уже передаются конструктору.

Требования

  • Увеличьте _calls.labels(operation=operation, status="success") на единицу.
  • Передайте duration_seconds в _duration.labels(operation=operation).observe(...).
  • Если prompt_tokens не равен None, увеличьте серию токенов с direction="input".
  • Если completion_tokens не равен None, увеличьте серию с direction="output".
  • Один раз вызовите калькулятор с именованными аргументами токенов.
  • Если оценка получена, увеличьте _estimated_cost.labels(currency=estimate.currency) на float(estimate.amount).
  • Если оценка равна None, метрику стоимости не изменяйте.
  • Метод должен вернуть None.

Пример результата

operation="generate", prompt_tokens=100, completion_tokens=20

calls{operation="generate",status="success"} += 1
tokens{operation="generate",direction="input"} += 100
tokens{operation="generate",direction="output"} += 20
Правильный ответ
class LLMMetricsRecorder:
    def __init__(
        self,
        calls,
        tokens,
        duration,
        estimated_cost,
        cost_estimator,
    ) -> None:
        self._calls = calls
        self._tokens = tokens
        self._duration = duration
        self._estimated_cost = estimated_cost
        self._cost_estimator = cost_estimator

    def record_success(
        self,
        operation: str,
        prompt_tokens: int | None,
        completion_tokens: int | None,
        duration_seconds: float,
    ) -> None:
        self._calls.labels(
            operation=operation,
            status="success",
        ).inc()

        self._duration.labels(
            operation=operation,
        ).observe(duration_seconds)

        if prompt_tokens is not None:
            self._tokens.labels(
                operation=operation,
                direction="input",
            ).inc(prompt_tokens)

        if completion_tokens is not None:
            self._tokens.labels(
                operation=operation,
                direction="output",
            ).inc(completion_tokens)

        estimate = self._cost_estimator.estimate(
            prompt_tokens=prompt_tokens,
            completion_tokens=completion_tokens,
        )

        if estimate is not None:
            self._estimated_cost.labels(
                currency=estimate.currency,
            ).inc(float(estimate.amount))

Шаг 10

Эндпоинт метрик Prometheus

В заготовке уже создан объект app, а контейнер приложения содержит реестр метрик LLM-наблюдателя. Вам нужно добавить служебный адрес GET /metrics, с которого Prometheus сможет забирать данные.

Задача

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

Требования

  • Не изменяйте декоратор @app.get("/metrics", include_in_schema=False) и сигнатуру функции.
  • Передайте container.llm_observer.registry в generate_latest().
  • Сохраните полученные байты в локальной переменной.
  • Верните объект Response с content, равным полученным байтам.
  • В media_type передайте CONTENT_TYPE_LATEST.
  • Эндпоинт не должен появляться в OpenAPI-схеме.

Пример результата

GET /metrics

200 OK
Content-Type: text/plain; version=0.0.4

ai_support_llm_calls_total{operation="generate",status="success"} 2.0

Все импорты, FastAPI-приложение и зависимость контейнера уже находятся в заготовке.

Невозможно решить задачу. Ошибка: ModuleNotFoundError: No module named 'prometheus_client'

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

@Ilmir_Nigmatullin, Поправил код проверки.

Правильный ответ
from typing import Annotated

from fastapi import Depends, FastAPI, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest

from task_environment import ApplicationContainer, get_container

app = FastAPI()

@app.get("/metrics", include_in_schema=False)
def read_metrics(
    container: Annotated[
        ApplicationContainer,
        Depends(get_container),
    ],
) -> Response:
    content = generate_latest(container.llm_observer.registry)

    return Response(
        content=content,
        media_type=CONTENT_TYPE_LATEST,
    )