Метрики и мониторинг с Prometheus и Grafana¶
Шаг 1¶
В прошлом разделе мы научили приложение создавать структурированные записи о каждом HTTP-запросе и обращении к модели. По Request ID теперь можно найти конкретный запрос и подробно разобраться, что с ним произошло.
Но логи плохо подходят для наблюдения за приложением в целом. Представим, что нам нужно узнать, стало ли больше ошибок за последний час, сколько токенов было использовано за день или насколько увеличилось время ответа модели. Для этого пришлось бы найти множество JSON-записей, извлечь из них нужные значения и самостоятельно выполнить расчёты.
Такую работу удобнее выполнять с помощью метрик. Приложение будет накапливать числовые показатели, Prometheus — регулярно собирать и хранить их, а Grafana — показывать результаты на графиках. Благодаря этому мы сможем увидеть общую проблему, а затем при необходимости перейти к логам и подробно разобрать отдельный запрос.
Метрика это числовой показатель работы приложения. Вместо подробностей отдельного запроса она хранит общее значение. Например:
- количество успешных и ошибочных обращений к модели
- число использованных входных и выходных токенов
- продолжительность генераций
- накопленную приблизительную стоимость
В этом разделе мы свяжем три компонента:
И перед тем, как переходить к коду, давайте для работы с метриками установим официальный Python-клиент Prometheus:
После установки обновите зависимости проекта.
Перед тем как переходить к коду, давайте разберём два типа метрик, которые будем использовать в нашем приложении: Counter и Histogram. Они хранят данные по-разному и подходят для разных показателей.
Counter, или счётчик, хранит значение, которое только увеличивается. Например после каждого обращения к модели мы прибавляем единицу:
Таким способом удобно учитывать общее количество вызовов, использованных токенов и накопленную стоимость. Счётчик не уменьшается вручную и начинает заново только после перезапуска процесса.
Histogram, или гистограмма, нужна для значений, которые могут каждый раз отличаться. В нашем случае это длительность обращения к модели, ведь один ответ может занять 1 секунду, другой — 3, а третий — 8 секунд.
Гистограмма распределяет измерения по диапазонам, например:
Благодаря этому можно определить, какая часть запросов выполнялась быстро, а какая заметно дольше. Позднее 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, добавьте импорт:
Внутри create_api() подключите маршрутизатор рядом с остальными:
Запустите FastAPI и откройте Swagger UI.
И чтобы в метриках появились первые значения, воспользуемся обычными эндпоинтами чата. Сначала через POST /chat/sessions создайте сессию и сохраните полученный session_id. Затем передайте его в POST /chat/sessions/{session_id}/messages и отправьте сообщение пользователю.
Теперь просто откройте в браузере 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 по официальной инструкции для своей операционной системы.
В корне проекта создайте каталог 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 должен обращаться к приложению за новыми значениями метрик:
В нашем примере опрос выполняется каждые пять секунд. Благодаря этому после обращения к модели изменения быстро появятся в 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-deskmetrics_pathуказывает адрес эндпоинта с метриками —/metricstargetsсодержит адрес, по которому работает FastAPI
Вместе эти параметры сообщают Prometheus, что каждые пять секунд нужно отправлять запрос:
Мы не используем 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-бэкенд из корня проекта такой командой:
Параметр --host 0.0.0.0 разрешает принимать подключения не только с самого процесса, но и из контейнера Docker. Это подходит для локальной демонстрации. При публикации доступ ограничивают сетевыми правилами и reverse proxy.
Не закрывая FastAPI, откройте второй терминал в корне проекта и запустите контейнеры:
Параметр -d запускает контейнеры в фоне. При первом запуске Docker сначала загрузит образы, поэтому потребуется немного времени. Проверить состояние можно командой:
У сервисов prometheus и grafana должен отображаться статус Up. Теперь откройте:
На странице 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 и выполните запрос:
Значение 1 означает, что источник метрик доступен.
Значение 0 показало бы, что очередной опрос завершился неудачно.
Теперь через Swagger UI выполните несколько запросов, которые обращаются к модели. Например, создайте чат-сессию и отправьте в неё несколько сообщений. После каждого вызова LLMObserver будет увеличивать счётчик обращений к модели.
Подождите несколько секунд, чтобы Prometheus успел снова получить данные из /metrics. Затем откройте страницу запросов Prometheus и выполните выражение:
В результате появятся отдельные значения для уже использованных операций и их статусов.
На вкладке Graph можно увидеть изменение показателя:
При прямом открытии /metrics мы видели только текущие значения из памяти FastAPI. Prometheus же регулярно сохраняет эти значения вместе со временем получения. Поэтому на вкладке Graph можно выбрать временной диапазон и посмотреть, как количество обращений к модели изменялось во время работы приложения.
Останавливать контейнеры между этой и следующим шагом не нужно. Но если потребуется завершить работу позднее, используйте:
Обычная команда down удалит контейнеры и сеть, но сохранит именованные тома с данными. При следующем запуске Prometheus и Grafana смогут продолжить работу с прежним состоянием.
Сейчас данные уже собираются, но встроенный интерфейс Prometheus предназначен главным образом для проверки запросов. В следующем шаге подключим его к Grafana и соберём отдельный дашборд: вызовы модели, ошибки, токены, длительность и приблизительная стоимость будут отображаться на понятных панелях.
Шаг 3¶
Prometheus уже регулярно забирает метрики нашего AI-бэкенда и сохраняет их во времени. Его встроенный интерфейс позволяет выполнить запрос и быстро посмотреть результат, но для постоянного наблюдения удобнее собрать отдельный дашборд.
Для этого используем Grafana. Она не обращается напрямую к FastAPI и не заменяет Prometheus. Grafana подключается к Prometheus как к источнику данных, выполняет запросы на языке PromQL и превращает результаты в графики, таблицы и числовые панели.
Контейнер Grafana мы уже запустили через Docker Compose. Откройте в браузере:
При первом входе используйте имя admin и пароль admin. Grafana может попросить сразу заменить стандартный пароль. Для локальной учебной среды можно задать собственный простой пароль, но в опубликованной системе учётные данные обязательно должны храниться и передаваться безопасно.
Сначала нужно объяснить Grafana, где находятся данные. Откройте раздел Connections, выберите Data sources(слева в меню), нажмите Add data source и выберите Prometheus.
В поле адреса сервера укажите:
Здесь снова нельзя использовать 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 вставьте:
Нажмите Run queries, чтобы Grafana выполнила запрос и показала результат. Выражение считает, сколько обращений к модели произошло за последние пять минут, и отдельно показывает успешные и неудачные вызовы:
В правой части редактора выберите тип визуализации Time series. Затем найдите поле Panel options → Title и укажите название:
После настройки запроса, визуализации и названия панели нажмите Save dashboard. В открывшемся окне укажите название:
Затем ещё раз нажмите Save. Панель сохранится вместе с дашбордом.
Если график окажется пустым, выполните несколько AI-запросов через Swagger UI, подождите два-три цикла сбора Prometheus и нажмите Refresh в Grafana.
Вернитесь к созданному дашборду AI Support Desk. В верхней части страницы нажмите Add → Visualization, а затем снова выберите источник данных Prometheus. В редакторе новой панели переключите запрос A в режим Code и вставьте выражение для подсчёта токенов:
Она покажет, сколько входных и выходных токенов добавилось за последние пять минут. Метка 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.
Последняя панель покажет накопленную оценку стоимости:
Для неё удобно выбрать тип 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,
)