p95 растёт по миллисекунде в день: как не приучить мониторинг к деградации LLM API

В комментариях к прошлому материалу возник хороший вопрос: откуда брать допустимый коридор для p95 — из истории или из продуктовых ожиданий?

Если использовать только фиксированный порог, алерты будут шуметь на сезонных пиках. Если полностью довериться rolling baseline, мониторинг может медленно «привыкнуть» к деградации. Latency растёт понемногу, коридор ползёт следом, и через месяц система считает нормой то, что раньше было инцидентом.

Ниже — практическая схема для LLM API: сезонный baseline, жёсткий SLO, защита от дрейфа и небольшой Python-модуль, который можно встроить в canary или provider router.

Почему одного p95 недостаточно

p95 без контекста смешивает запросы, которые ведут себя совершенно по-разному:

  • короткий prompt без истории;
  • контекст на 20 тысяч токенов;
  • tool call с несколькими шагами;
  • streaming и обычный ответ;
  • запрос с cache hit и без него;
  • разные модели и провайдеры.

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

Поэтому сначала определяем профиль:

from dataclasses import dataclass @dataclass(frozen=True) class RequestProfile: provider: str model: str context_bucket: str # short, medium, long tool_calls: bool streaming: bool cache_hit: bool

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

Два слоя вместо одного порога

Рабочая схема состоит из двух независимых границ.

Первая — динамический сезонный baseline. Он отвечает на вопрос: «Что обычно происходит с этим профилем в аналогичное время?»

Вторая — жёсткий продуктовый SLO. Он отвечает на другой вопрос: «Какую задержку пользователь или downstream-сервис ещё может принять?»

Финальный предел — минимальное значение из двух:

effective_limit = min(dynamic_limit, hard_slo)

История помогает не шуметь на ожидаемых пиках, но не имеет права переписать продуктовые требования.

Сезонность нужно моделировать, а не прощать

Сравнивать утро понедельника с ночью воскресенья бесполезно. Для начала достаточно разбить историю по простым временным бакетам:

from dataclasses import dataclass from datetime import datetime @dataclass(frozen=True) class SeasonalBucket: weekday: int hour: int def bucket_for(ts: datetime) -> SeasonalBucket: return SeasonalBucket( weekday=ts.weekday(), hour=ts.hour, )

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

Считаем устойчивую динамическую границу

В production перцентили обычно считает Prometheus, ClickHouse или observability-платформа. Для примера оставим функцию без внешних зависимостей:

def percentile(values: list[float], q: float) -> float: if not values: raise ValueError("empty sample") ordered = sorted(values) pos = (len(ordered) - 1) * q left = int(pos) right = min(left + 1, len(ordered) - 1) weight = pos - left return ordered[left] * (1 - weight) + ordered[right] * weight

Просто взять исторический p95 недостаточно: несколько выбросов могут сильно двигать границу. Добавим устойчивую оценку разброса через median absolute deviation.

from statistics import median def mad(values: list[float]) -> float: center = median(values) return median(abs(value - center) for value in values) def build_dynamic_limit( healthy_window_p95: list[float], margin_mad: float = 3.0, ) -> float: center = median(healthy_window_p95) spread = mad(healthy_window_p95) return center + margin_mad * max(spread, 1.0)

В список передаётся не latency отдельных запросов, а p95 нескольких сопоставимых здоровых окон. Например, значения пятиминутных окон для этого профиля по понедельникам с 11:00 до 12:00.

Не обучаем baseline на инцидентах

Самая опасная ошибка — автоматически добавлять в историю любое завершившееся окно. Тогда авария становится тренировочными данными для будущей нормы.

Окно не должно обновлять baseline, если в нём были активный инцидент, deploy, смена модели или system prompt, новый route, массовые retry, резкое изменение структуры трафика или недостаточное число запросов.

@dataclass class Window: p95_ms: float request_count: int error_rate: float incident: bool deploy: bool traffic_shift: bool def is_healthy(window: Window) -> bool: return ( window.request_count >= 100 and window.error_rate < 0.01 and not window.incident and not window.deploy and not window.traffic_shift )

Если система сейчас в неизвестном состоянии, baseline лучше заморозить, чем быстро адаптировать.

Защищаемся от «кипящей лягушки»

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

Сохраним предыдущую границу и ограничим её рост, например, двумя процентами за пересчёт:

def clamp_baseline_growth( previous_limit: float, candidate_limit: float, max_growth: float = 0.02, ) -> float: max_allowed = previous_limit * (1 + max_growth) return min(candidate_limit, max_allowed)

Ограничение не означает, что новый уровень автоматически станет нормой через несколько пересчётов. Параллельно нужен отдельный детектор долгого дрейфа.

Простейшая проверка сравнивает медиану свежих окон с долгосрочным якорем:

def slow_drift_ratio( recent_p95: list[float], anchor_p95: list[float], ) -> float: return median(recent_p95) / median(anchor_p95) ratio = slow_drift_ratio(last_24h, healthy_30d_anchor) if ratio > 1.15: alert("p95 is 15% above the long-term healthy anchor")

Rolling baseline отвечает за локальную сезонность. Долгосрочный anchor не обновляется автоматически каждый день и ловит медленное ухудшение.

Собираем решение в один evaluator

from dataclasses import dataclass @dataclass class LatencyPolicy: hard_slo_ms: float warning_ratio: float = 0.85 @dataclass class Evaluation: status: str observed_ms: float effective_limit_ms: float dynamic_limit_ms: float hard_slo_ms: float def evaluate_p95( observed_ms: float, dynamic_limit_ms: float, policy: LatencyPolicy, ) -> Evaluation: effective = min(dynamic_limit_ms, policy.hard_slo_ms) if observed_ms > effective: status = "critical" elif observed_ms > effective * policy.warning_ratio: status = "warning" else: status = "healthy" return Evaluation( status=status, observed_ms=observed_ms, effective_limit_ms=effective, dynamic_limit_ms=dynamic_limit_ms, hard_slo_ms=policy.hard_slo_ms, )

В алерт нужно отправлять обе границы. Иначе дежурный увидит только «p95 превышен», но не поймёт, сработала продуктовая граница или отклонение от сезонной нормы.

Что делать после смены модели или system prompt

Старый baseline нельзя бесшовно переносить на новую конфигурацию. Но и мгновенно обучать новый на production опасно.

  1. Старый baseline остаётся контрольным якорем.
  2. Новая конфигурация получает отдельную версию профиля.
  3. Репрезентативные запросы запускаются в shadow-режиме.
  4. Новый baseline строится только после минимального объёма данных.
  5. Canary сравнивает новую и старую версии на одинаковых профилях.
  6. После переключения старый anchor хранится ещё несколько недель.

Версию полезно включать прямо в ключ метрики:

profile_key = ( provider, model, system_prompt_version, tool_schema_version, context_bucket, )

Иначе улучшение коротких запросов может скрыть регрессию длинных, а смена prompt — выглядеть как сезонное колебание.

Почему p95 иногда врёт

У перцентилей есть минимальный объём выборки. Если в окне 12 запросов, p95 фактически описывает один самый медленный вызов.

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

def percentile_status(request_count: int) -> str: if request_count < 20: return "insufficient_data" if request_count < 100: return "low_confidence" return "reliable"

При low_confidence не стоит автоматически выключать провайдера. Лучше потребовать подтверждение error rate, timeout rate или нескольких последовательных окон.

Что вывести на дашборд

Для каждого профиля полезно показывать:

  • текущий p50, p95 и p99;
  • динамический seasonal limit;
  • жёсткий product SLO;
  • долгосрочный healthy anchor;
  • число запросов в окне;
  • error, timeout и retry rate;
  • версию модели, prompt и tool schema;
  • причину, по которой окно не попало в baseline;
  • скорость изменения границы за день и неделю.

Отдельный алерт нужен на сам baseline. Если он растёт несколько дней подряд, это сигнал даже тогда, когда текущий p95 формально остаётся внутри коридора.

Минимальная схема для небольшого проекта

  1. Разделить latency по профилю запроса.
  2. Считать p95 по окнам с минимальным числом запросов.
  3. Сравнивать одинаковые сезонные бакеты.
  4. Обновлять историю только здоровыми окнами.
  5. Замораживать baseline во время инцидентов и deploy.
  6. Ограничивать скорость роста динамической границы.
  7. Хранить отдельный долгосрочный anchor.
  8. Никогда не превышать жёсткий product SLO.
  9. После изменения модели сначала запускать shadow и canary.
  10. Алертить не только p95, но и дрейф самого коридора.

Итог

Исторический baseline и продуктовый SLO не заменяют друг друга. Они отвечают на разные вопросы.

История помогает понять сезонную норму конкретного профиля. SLO запрещает мониторингу объявить приемлемой задержку, которая уже ломает пользовательский сценарий. Долгосрочный anchor ловит медленную деградацию, к которой rolling window успел бы привыкнуть.

Хороший коридор должен адаптироваться к трафику, но не иметь права самостоятельно снижать требования к системе.

А как вы строите latency-пороги для LLM: фиксированный SLO, rolling

baseline, сезонные бакеты или комбинацию нескольких сигналов?

p95 растёт по миллисекунде в день: как не приучить мониторинг к деградации LLM API