Модель в продакшене: мониторинг, инциденты, постмортемы
Что ломается после релиза LLM-сервиса и как это замечать: три слоя мониторинга, дрейф входов через PSI и тест Колмогорова — Смирнова на числах, момент поломки через CUSUM, алерты без шума, типичные инциденты, откат и канарейка, дежурство и постмортем без поиска виноватых по шаблону.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Вот история из домашней работы этого урока. Ассистент поддержки маркетплейса на RAG спокойно работает три недели. В четверг в 15:00 команда выкатывает новую версию промпта: шесть примеров хороших ответов, тон стал лучше, регрессионный набор в CI зелёный. Сервис отвечает кодом 200, задержка в норме, ошибок в логах нет. Через четыре дня приходит жалоба клиента: «бот не находит ответы, которые есть в документах». Разбор покажет, что за семь суток плохой ответ получили 186 пользователей из 499, а у китайскоязычных запросов доля плохих ответов выросла с 11% до 55%.
Это типичный инцидентинцидентEN incidentНезапланированное ухудшение сервиса, заметное пользователям или угрожающее SLO. Требует реакции дежурного и после завершения — разбора. ML-системы. Обычный сервис ломается громко: падает, отвечает 500. ML-сервис чаще ломается тихо, как тихие баги из урока 11.1.2: всё работает, только ответы хуже. Поэтому мониторинг LLM-сервиса смотрит не только на технику, но и на качество и на данные.
Онлайн-метрики, трейсы, контрольную карту и PSI по категориям вы собрали в уроке 8.4.2. Здесь — работа дежурного инженера: от мониторинга и алертов до разбора, после которого инцидент не повторится.
Что мониторить: три слоя
Заголовок раздела «Что мониторить: три слоя»flowchart LR T["Техника<br/>задержка, ошибки, GPU, KV-кэш"] --> A["Алерты"] Q["Качество<br/>отказы, дизлайки, судья, люди"] --> A D["Данные<br/>дрейф входов и ответов"] --> A A --> O["Дежурный"] --> M["Смягчение:<br/>откат, канарейка"] M --> P["Постмортем"] --> X["Действия:<br/>тесты, алерты, процесс"] X -.-> T X -.-> Q X -.-> D
| Слой | Что смотреть | Пример для LLM-сервиса | Когда узнаём |
|---|---|---|---|
| Техника | задержка p50/p95/p99 (TTFT и полное время), пропускная способность, доля ошибок и таймаутов, загрузка GPU, заполнение KV-кэша, длина очереди | p95 полного ответа вырос с 15 до 26 с | секунды |
| Качество | доля отказов, дизлайков, перегенераций и передач оператору, оценка LLM-судьи на 10–20% трафика, выборочная проверка людьми, жалобы | доля плохих по судье 8% → 22% | минуты (судья), дни (жалобы) |
| Данные | дрейф входов: длина, язык, тема, доля обрезанного контекста; дрейф ответов: длина, доля отказов, язык ответа не тот, что у вопроса | доля обрезанного контекста 0.4% → 31% | минуты |
Технический слой SRE-инженеры Google описывают через четыре золотых сигналачетыре золотых сигналаEN four golden signalsМинимальный набор метрик сервиса из книги Google SRE: задержка (latency), трафик (traffic), ошибки (errors) и насыщение ресурсов (saturation).: задержка, трафик, ошибки и насыщение. Для LLM-сервиса насыщение — это загрузка GPU и, главное, заполнение KV-кэшаKV-кэшEN KV cache: когда он полон, новые запросы ждут в очереди. Задержки описывают перцентилями p95 и p99, а не средним, — это хвостовые задержкихвостовая задержка (p95, p99)EN tail latencyЗадержка, которую не превышают 95% или 99% запросов. Среднее скрывает медленные запросы, а пользователи помнят именно их. из урока 8.4.2.
Качество приходит с опозданием и не целиком. Судья из урока 7.3.2 оценивает выборку, люди — десятки диалогов в неделю, а жалоба приходит через дни. Поэтому слой данных — ранний сигнал: длина входа и доля обрезанного контекста видны в первую же минуту после выкатки, задолго до первой жалобы.
Два правила для всех трёх слоёв:
- Каждую метрику режут по версии модели и промпта, языку, теме. Средняя по сервису прячет сегмент, где всё сломалось: в истории выше русские запросы пострадали умеренно (9.5% → 16.7% плохих), а китайские — катастрофически.
- Каждую выкатку пишут в лог — версию в каждой строке и событие в журнал изменений. Без этого разбор превращается в гадание.
Дрейф входов: PSI и тест Колмогорова — Смирнова
Заголовок раздела «Дрейф входов: PSI и тест Колмогорова — Смирнова»Дрейф данныхдрейф данныхEN data driftИзменение распределения входов со временем — новый язык, новые темы, сезонность. Эвал-набор при этом перестаёт отражать реальный трафик. — изменение распределения входов. В уроке 8.4.2 вы считали PSIиндекс стабильности популяции (PSI)EN population stability indexМера сдвига распределения признака: сумма (aᵢ − eᵢ)·ln(aᵢ/eᵢ) по категориям или по бинам числового признака. Пороги — эмпирическое правило: меньше 0.1 — мало, больше 0.25 — заметный сдвиг. для категорий (языков). Теперь — числовые признаки: длина входа в токенах, длина ответа, задержка.
PSI для чисел: бины по квантилям эталона
Заголовок раздела «PSI для чисел: бины по квантилям эталона»Числа сначала раскладывают по бинам. Удобнее всего брать границы по квантилям эталонного периода: при четырёх бинах это 25-й, 50-й и 75-й перцентили прошлой недели. Тогда в эталоне в каждом бине ровно четверть значений, и пустых бинов там не бывает. Дальше — знакомая формула:
Пример. На прошлой неделе доли по квартилям длины входа были . На этой неделе в те же бины попало : запросы стали длиннее. PSI — сильный дрейф по распространённому эмпирическому правилу (меньше 0.1 — нет, 0.1–0.25 — умеренный, больше 0.25 — сильный). Это договорённость практиков, а не статистический тест: у порогов нет p-value, и на малых выборках их приходится поднимать (ниже).
Три ловушки:
- Пустой бин. Если в текущем периоде появилась новая категория (язык, которого не было), её эталонная доля 0, и — бесконечность. Доли заменяют на маленькое , например . Но вклад новой категории тогда зависит от : для доли 0.1 это , а при — около 0.46. Фиксируйте и пишите его рядом с числом.
- Маленькие выборки. Две выборки из одного и того же распределения по 200 значений при 10 бинах дают PSI в среднем около 0.09, а в 5% случаев больше 0.17 (проверено симуляцией). Это шум: оценка доли в каждом бине гуляет. Приближённо шум равен для бинов. Пороги 0.1 и 0.25 придуманы для тысяч наблюдений — на сотнях их нужно поднимать.
- Дрейф от вашей же выкатки. В истории из начала урока длина входа «выросла» на 2800 токенов, потому что вырос системный промпт. Самый сильный дрейф не обязательно пришёл извне.
Тест Колмогорова — Смирнова
Заголовок раздела «Тест Колмогорова — Смирнова»тест Колмогорова — Смирноватест Колмогорова — СмирноваEN Kolmogorov–Smirnov testСравнивает два распределения числового признака без бинов. Статистика D — наибольшее расстояние между эмпирическими функциями распределения; p-value — по распределению Колмогорова. сравнивает распределения без бинов. Эмпирическая функция распределения — доля значений выборки, не больших . Статистика — наибольшее расстояние между двумя такими функциями:
Пример: длины ответов в двух сменах и (в сотнях токенов).
В точке : , , разница — больше, чем в любой другой точке, значит
. p-valuep-value (p-значение)EN p-valueКак часто случай дал бы результат не менее экстремальный, чем наблюдаемый, если бы эффекта не было. Не вероятность того, что эффекта нет, и не размер эффекта. Порог «значимости» по соглашению — 0.05. даёт распределение Колмогорова: и
. Здесь и
: на пяти и шести точках разница не значима. Формула асимптотическая, она точна для больших выборок;
для таких крошечных scipy.stats.ks_2samp считает точное значение — вывод тот же.
Обратная проблема — большой трафик. Две недели по 100 000 запросов, входы длиннее всего на 3%: симуляция даёт и , а PSI — 0.005. При большом значим почти любой сдвиг: p-value отвечает «есть ли разница», а не «важна ли она» (урок 1.3.4).
| PSI | Тест КС | |
|---|---|---|
| Признаки | числа (через бины) и категории | только числа |
| Отвечает на вопрос | насколько велик сдвиг | есть ли сдвиг вообще |
| На маленьких выборках | шумит, пороги нужно поднимать | честно говорит «не значимо» |
| На большом трафике | устойчив | значимо почти всё — смотрите на сам |
| Типичное правило | больше 0.25 — разбираться | больше заданного порога и мал |
Обе меры на NumPy — основа того, что вы напишете в домашней работе:
import numpy as np
def psi(e, a, eps=1e-4): e, a = np.asarray(e, float), np.asarray(a, float) e = np.maximum(e / e.sum(), eps) # счётчики → доли; пустой бин → eps a = np.maximum(a / a.sum(), eps) return float(np.sum((a - e) * np.log(a / e)))
def psi_numeric(ref, cur, n_bins=10): edges = np.unique(np.quantile(ref, np.arange(1, n_bins) / n_bins)) # квантили ЭТАЛОНА count = lambda x: np.bincount(np.searchsorted(edges, x), minlength=len(edges) + 1) return psi(count(ref), count(cur))
def ks(a, b): a, b = np.sort(a), np.sort(b) grid = np.concatenate([a, b]) fa = np.searchsorted(a, grid, side="right") / len(a) # эмпирическая F(x) fb = np.searchsorted(b, grid, side="right") / len(b) return np.abs(fa - fb).max()
rng = np.random.default_rng(0)week1 = rng.lognormal(np.log(3000), 0.4, 1000) # длина входа, токеновsame = rng.lognormal(np.log(3000), 0.4, 1000)longer = rng.lognormal(np.log(3000), 0.4, 1000) + 2800 # системный промпт вырос на 2800 токеновprint(f"та же неделя: PSI = {psi_numeric(week1, same):.3f}, D = {ks(week1, same):.3f}")print(f"промпт длиннее: PSI = {psi_numeric(week1, longer):.3f}, D = {ks(week1, longer):.3f}")та же неделя: PSI = 0.020, D = 0.052промпт длиннее: PSI = 6.545, D = 0.799side="right" в ks важен при повторах: считается после всех значений, равных . Ответы LLM дрейфуют
так же, как входы: длина ответа, доля отказов, доля ответов не на языке вопроса — это такие же признаки, и их
сравнивают теми же мерами. Отдельно бывает дрейф понятийдрейф понятийEN concept driftВходы те же, а правильные ответы изменились — поменялись тарифы, устарели документы, обновились правила. PSI по входам его не видит, заметно только по качеству.: входы те же, а правильные ответы изменились (новые тарифы,
устаревшие документы). Его PSI не видит — только качество.
Когда именно сломалось: CUSUM
Заголовок раздела «Когда именно сломалось: CUSUM»Дрейф отвечает на вопрос «что изменилось». Второй вопрос — «когда». Посуточная доля плохих ответов в истории выше: 11%, 7%, 13%, 7%… — а потом 22%, 24%, 28%. По дням ступеньку видно глазом, но по часам — нет. При семи запросах в час один плохой ответ из пяти — уже 20%, и почасовой ряд прыгает от нуля до 40% без всякого инцидента.
Это задача обнаружения точки измененияобнаружение точки измененияEN change point detectionПоиск момента, когда у временного ряда изменились свойства — например, доля ошибок скачком выросла. Онлайн-методы (CUSUM) решают по прошлому, офлайн-методы смотрят на весь ряд.. Контрольная карта из урока 8.4.2 смотрит на каждую точку отдельно и ловит большие скачки. CUSUMCUSUM (кумулятивная сумма)EN cumulative sum control chartДетектор сдвига: S = max(0, S + x − цель − k), тревога при S > h. Копит небольшие, но устойчивые отклонения, которые порог по каждой точке не видит. Предложен Пейджем в 1954 году. (Пейдж, 1954) копит небольшие, но устойчивые отклонения по каждому запросу:
где для плохого ответа и 0 для хорошего, — нормальная доля плохих, — допуск, обычно половина сдвига, который хотим ловить. Тревога — когда . Начало изменения — первый запрос серии, в которой ни разу не обнулился.
Числа. Пусть , а ловить хотим рост до 0.25, тогда . Плохой ответ добавляет к , хороший отнимает . В норме в среднем убывает: за запрос — и держится у нуля. При доле плохих 0.28 он растёт на за запрос и превысит примерно за 76 запросов.
import numpy as np
def cusum(x, target, k, h): s, start = 0.0, 0 for t, v in enumerate(x): s = max(0.0, s + v - target - k) if s == 0.0: start = t + 1 # серия начнётся не раньше следующего запроса elif s > h: return t, start # (момент тревоги, начало изменения) return None, None
rng = np.random.default_rng(1)bad = np.concatenate([rng.random(600) < 0.10, # 600 запросов в норме: 10% плохих rng.random(300) < 0.28]) # потом 28% плохихprint(cusum(bad.astype(float), target=0.10, k=0.075, h=8))(656, 600)Тревога через 56 запросов после поломки, а начало найдено точно — запрос 600. Разница между моментом тревоги и началом важна для разбора: искать причину нужно у начала, а не у момента, когда заметили.
Алерты без шума
Заголовок раздела «Алерты без шума»Алерт будит человека. Если он срабатывает зря, человек привыкает его игнорировать. Это усталость от алертовусталость от алертовEN alert fatigueКогда алерты часто срабатывают зря, люди перестают на них реагировать или отключают их — и пропускают настоящий инцидент., и в истории из начала урока она сыграла роль: почасовой алерт «p95 задержки больше 25 с» 9 и 10 сентября сработал дважды — ночью по единственному медленному запросу и на 50-минутном всплеске после перезапуска сервера. Его перевели в ежедневную сводку, где до инцидента он отметился ещё пять раз без всяких проблем с качеством, и рост p95 с 15 до 26 с после выкатки никто не увидел.
Почему простые пороги шумят — это множественные сравнения: проверка раз в час — 720 проверок в месяц. Даже если каждая ошибается с вероятностью 1%, ложных тревог будет около семи. Что помогает:
- Алертить на симптомы, а не на причины. Пользователю важна доля плохих ответов и время ответа, а не загрузка GPU. Причины — на дашборд, симптомы — в алерты. Каждый алерт должен требовать действия, и у каждого должен быть ранбукранбук (инструкция дежурного)EN runbookПошаговая инструкция для конкретного алерта: что проверить, как откатить, кого позвать. Пишется заранее, чтобы ночью не придумывать.: что проверить и как откатить.
- Порог, который зависит от числа запросов. Не «больше 25%», а : ночью, при малом , порог выше. Плюс минимальное в окне.
- Длинное окно и несколько проверок подряд. Окно в сутки гасит часовые всплески и суточную сезонность, требование «три часа подряд» отсекает одиночные выбросы.
- Два окна — для большого трафика. В книге Google по SRE алерты строят по SLOцелевой уровень сервиса (SLO)EN service level objectiveИзмеримая цель, например «p95 TTFT меньше 2 с при 8 пользователях». По ней планируют железо и поднимают тревоги. и бюджету ошибокбюджет ошибокEN error budgetСколько «плохих» событий разрешает SLO за период: при SLO 99% хороших ответов за 30 дней бюджет — 1% запросов. Скорость его расхода (burn rate) — основа алертов по SLO.. Если SLO — 99% хороших ответов за 30 дней, бюджет — 1% запросов. Скорость расхода (burn rate) 14.4 означает, что за час уходит месячного бюджета. Алерт горит, когда скорость высокая и в длинном окне (час), и в коротком (5 минут): длинное даёт уверенность, короткое — что проблема ещё идёт.
- Сезонность. Если в выходные доля плохих всегда выше (другие пользователи, другие вопросы), сравнивайте с тем же днём недели, а не с прошлым часом.
Что это даёт на данных домашней работы (около 7 запросов в час): правило «доля плохих за 24 часа выше , не меньше 30 запросов в окне, три часа подряд» срабатывает через 9 часов после выкатки и ни разу за три недели до неё — включая всплеск, где за час таймаутом закончились 4 запроса из 9. Девять часов — медленно, но это вместо четырёх дней до жалобы. При тысячах запросов в минуту окна можно сжать до минут.
Типичные инциденты LLM-сервиса
Заголовок раздела «Типичные инциденты LLM-сервиса»| Что случилось | Симптомы | Где видно раньше всего | Первое действие |
|---|---|---|---|
| Обновили модель (свою или провайдер под тем же именем) | другая длина ответа, больше отказов, ломается JSON | дрейф ответов, доля невалидного JSON | откат версии; закрепить снимок модели (урок 8.4.1) |
| Обновили промпт | ступенька длины входа, обрезка контекста, отказы в одном сегменте | дрейф входа, срез по версии промпта | откат промпта, сравнение внутри сегментов |
| Выросла длина контекста, кончился KV-кэш | растут очередь и TTFT p99, падает пропускная способность, запросы вытесняются | kv_cache_usage_perc около 1, очередь | лимит длины входа, меньше параллельных запросов, вторая реплика |
| Поменялся формат входных данных | HTML вместо текста, другая кодировка, пустые поля | дрейф длины и языка, валидатор схемы | откат источника или фильтр; контракт данных (урок 11.1.1) |
| Сломался внешний инструмент агента | ошибки в одной теме, агент зацикливается или выдумывает | доля ошибок инструмента по темам | отключить инструмент и честно отказывать; тест контракта инструмента |
| Кончились лимиты или ключ API провайдера | ошибки 429 и 401, повторы | доля ошибок по кодам | резервный провайдер, лимиты на пользователя (урок 8.4.3) |
Про KV-кэш — на числах из урока 8.4.3. У Qwen3-8B около 144 КБ кэша на токен, и 32 пользователя с документами по
8000 токенов требуют ГБ. На RTX 4080 после весов (16.4 ГБ) при
gpu-memory-utilization 0.9 остаётся ГБ, а за вычетом служебных буферов vLLM — около
11 ГБ, порядка 70–75 тысяч токенов (оценка из урока 7.1.2), то есть 8–9 таких пользователей.
Остальные ждут, а vLLM вытесняет часть запущенных запросов (preemption). Модель не стала хуже, но сервис для
пользователя сломался. Сломанный инструмент агента вы разберёте в практике ниже.
Что делать во время инцидента
Заголовок раздела «Что делать во время инцидента»flowchart TD
A["Алерт или жалоба"] --> B["Дежурный подтверждает:<br/>реальная проблема?"]
B -->|нет| N["Закрыть, поправить алерт"]
B -->|да| C["Оценить влияние:<br/>кто и сколько пострадал"]
C --> D{"Была выкатка<br/>незадолго до начала?"}
D -->|да| R["Откат"]
D -->|нет| F["Смягчение: отключить функцию,<br/>лимиты, резервный провайдер"]
R --> E["Диагностика: срезы, дрейф, трейсы"]
F --> E
E --> V["Проверить восстановление по метрикам"]
V --> P["Постмортем"]Сначала смягчить, потом разбираться. Если деградация началась вскоре после выкатки, первое действие — откатоткатEN rollbackВозврат к предыдущей работающей версии кода, модели, промпта или конфигурации. Первое действие, если инцидент начался после выкатки.. В истории из начала урока от жалобы до отката прошло три дня: первые сутки подозревали дрейф данных — нового клиента с китайскими запросами. Откат промпта в первый же час после жалобы сэкономил бы три дня плохих ответов. Поэтому у дежурного должно быть право откатить без согласований, а откат должен занимать минуты — одна команда или переключатель в конфиге.
Дежурство. Дежурныйдежурство (on-call)EN on-callПорядок, при котором один инженер из команды по графику отвечает на алерты и руководит реакцией на инциденты. по графику принимает алерты, подтверждает или закрывает их и координирует реакцию. В большом инциденте роли разделяют: координатор решает, исполнители чинят, отдельный человек пишет статус для пользователей и руководства. Хронологию ведут прямо во время инцидента — потом её не восстановить.
Канарейка вместо «сразу на всех». Канареечный выпускканареечный выпускEN canary releaseНовая версия сначала получает малую долю трафика (например, 5%); её онлайн-метрики сравнивают со старой, и только потом трафик переключают целиком. из урока 8.4.2 — лучшая защита от инцидентов с выкаткой. Новая версия получает 10% трафика, метрики сравнивают по версиям. Сколько ждать — считается как в уроке 1.3.4. При 4000 запросов в сутки на канарейку за сутки придёт 400. Если старая версия даёт 10% плохих ответов, а новая 20%, стандартная ошибка разницы , и разница 0.10 — почти пять стандартных ошибок. Суток хватит, чтобы остановить выкатку, и пострадают 10% пользователей, а не все.
A/B-тест — когда нужно не поймать поломку, а принять решение по небольшому эффекту: «v13 лучше по тону, но не хуже ли по точности?». Рандомизация по пользователям, размер выборки заранее по формуле мощности из урока 1.3.4, одна главная метрика и сегменты, которые смотрим обязательно.
Как искать причину. Найдите момент начала (CUSUM), выпишите все изменения рядом с ним (выкатки, работы, новые клиенты) и проверяйте каждое по одному вопросу: начался ли рост сразу после него, внутри сегментов? Если после выкатки выросла доля китайских запросов, а на китайском всегда хуже, рост средней доли плохих может быть просто сменой состава. Проверка: посчитайте, сколько плохих ответов было бы после, если бы доля плохих внутри каждого языка не изменилась, и сравните с наблюдаемым. В домашней работе ожидалось 39, наблюдалось 97 — значит, сломалось внутри языков, и дело не только в составе.
Постмортем без поиска виноватых
Заголовок раздела «Постмортем без поиска виноватых»Постмортемпостмортем (разбор инцидента)EN postmortemПисьменный разбор после инцидента — хронология, влияние, причина, почему не поймали раньше, действия с владельцами и сроками. Пишется без поиска виноватых (blameless). — письменный разбор после инцидента. В книге Google по SRE главная идея сформулирована так: постмортем ищет, что в системе и процессе позволило ошибке случиться, и не ищет, кого наказать. Если за ошибку наказывают, люди скрывают ошибки, и следующий разбор начнётся без половины фактов. «Инженер не проверил длинные запросы» — тупик. «Почему системе было легко не проверить длинные запросы?» — начало полезного ответа.
| Вместо | Пишите |
|---|---|
| Инженер выкатил промпт, не проверив длинные запросы | В регрессионном наборе нет запросов длиннее 4000 токенов, поэтому CI не мог заметить обрезку контекста |
| Дежурный проморгал рост задержки | Алерт на p95 был шумным и переведён в сводку; рост p95 после выкатки никто не видел |
| Причина — человеческий фактор | Выкатка шла сразу на 100% трафика без сравнения версий |
Шаблон. Его же вы заполните в домашней работе:
- Кратко — 3–5 предложений для руководителя: что, когда, насколько плохо, сколько длилось.
- Влияние — числа: запросы, плохие ответы сверх нормы, пострадавшие пользователи, сегменты.
- Хронология — события с датой и временем по порядку: выкатки, первые признаки в данных, когда узнали, гипотезы, откат, восстановление.
- Причина — главная причина с механизмом и доводами, отдельно — способствующие факторы.
- Почему не поймали раньше — какие защиты должны были сработать и почему не сработали.
- Что помогло — что сохранить.
- Что меняем — таблица действий: что, тип (обнаружение, предотвращение, смягчение), владелец, срок.
- Уроки — 2–3 мысли на будущее.
«Пять почему» помогает дойти от симптома до системной причины. Плохие ответы — почему? Обрезался контекст. Почему? Вход не влез в бюджет 7168 токенов. Почему? Системный промпт вырос с 700 до 3500 токенов, а бюджет никто не пересчитал. Почему CI не заметил? В наборе нет длинных запросов. Почему не заметили в продакшене? Не было алерта на качество и канарейки, а алерт по задержке заглушили из-за шума. Каждый ответ даёт действие: длинные запросы в набор, бюджет от длины промпта, алерт на долю плохих, канарейка, тихий алерт по задержке.
Действие без владельца и срока не будет сделано, а «быть внимательнее» — не действие. Действие — «добавить в регрессионный набор 60 запросов длиной 4000–7000 токенов; владелец — команда ассистента; срок — 16 октября».
Практика
Заголовок раздела «Практика»Агент поддержки три недели отвечает на вопросы о заказах, доставке, аккаунте и курсах валют; для последних он вызывает внешний API. Лог генерируется в коде с фиксированным seed. Посчитайте PSI каждого признака по неделям против первой, найдите признак с дрейфом и день, с которого выросла доля ошибок. Для дня используйте офлайн-метод «лучший разрез»: перебрать все точки, разрезать ряд на две части и выбрать разрез с минимальной суммой квадратов отклонений от средних частей.
import numpy as np
import pandas as pd
def make_log(seed=7):
"""Синтетический лог агента поддержки за три недели (2–22 марта 2026), по строке на запрос.
Столбцы: ts, lang, cat, in_tok, out_tok, error (1 — запрос закончился ошибкой).
Агент для вопросов о курсах валют (cat == "fx") вызывает внешний API. Числа выдуманы.
"""
rng = np.random.default_rng(seed)
start = pd.Timestamp("2026-03-02")
parts = []
for day in range(21):
n = 130 if day % 7 < 5 else 70 # в выходные запросов меньше
secs = np.sort(rng.integers(0, 86400, n))
p_zh = 0.08 + 0.32 * min(max((day - 7) / 10, 0), 1) # с 9 марта растёт доля китайского
u = rng.random(n)
lang = np.where(u < p_zh, "zh", np.where(u < p_zh + 0.07, "en", "ru"))
cat = rng.choice(["orders", "delivery", "fx", "account"], n, p=[0.3, 0.3, 0.2, 0.2])
in_tok = rng.lognormal(np.log(1800), 0.35, n).astype(int)
out_tok = rng.lognormal(np.log(220), 0.5, n).astype(int) + 10
p_err = np.full(n, 0.04 + 0.01 * (day % 7 >= 5))
if day >= 15: # 17 марта внешний API сменил формат
p_err = np.where(cat == "fx", 0.8, p_err)
error = (rng.random(n) < p_err).astype(int)
ts = start + pd.Timedelta(days=day) + pd.to_timedelta(secs, unit="s")
parts.append(pd.DataFrame({"ts": ts, "lang": lang, "cat": cat, "in_tok": in_tok,
"out_tok": out_tok, "error": error}))
return pd.concat(parts, ignore_index=True)
def psi(expected, actual, eps=1e-4):
"""PSI = Σ (a − e) · ln(a / e) по долям.
expected, actual — доли или счётчики (счётчики нормируйте к сумме 1). Доли меньше eps замените на eps,
иначе пустой бин даст ln 0. Пример: psi([0.5, 0.3, 0.2], [0.3, 0.3, 0.4]) ≈ 0.2408.
"""
# TODO
raise NotImplementedError
def feature_psi(ref, cur, n_bins=5, eps=1e-4):
"""PSI одного признака между эталонной (ref) и текущей (cur) выборками (pandas Series).
Числовой признак (pd.api.types.is_numeric_dtype): внутренние границы — квантили ref на уровнях
1/n_bins, …, (n_bins−1)/n_bins, склеенные np.unique; бины (−∞, e1], (e1, e2], …, (ek, +∞).
Номер бина — np.searchsorted(edges, x, side="left"), счётчики — np.bincount(..., minlength=len(edges) + 1).
Остальные признаки — категории: объединение значений ref и cur, счётчики через value_counts().reindex(...).
"""
# TODO
raise NotImplementedError
def weekly_psi(df, features, n_bins=5):
"""PSI каждой недели против первой.
Неделя — (ts − полночь первого дня).days // 7 + 1. Возвращает DataFrame: индекс — номера недель 1, 2, …,
столбцы — features, значения — PSI признака этой недели против недели 1 (у недели 1 — нули).
"""
# TODO
raise NotImplementedError
def daily_error_rate(df):
"""Доля ошибок по дням: Series с индексом — полночь каждого дня (Timestamp)."""
# TODO
raise NotImplementedError
def change_day(rates, min_size=3):
"""Лучший разрез ряда на две части: день, с которого начинается правая часть.
Для каждого k от min_size до len − min_size: стоимость = сумма квадратов отклонений левой части от её
среднего + то же для правой. Верните rates.index[k] с минимальной стоимостью.
"""
# TODO
raise NotImplementedError
try:
df = make_log()
table = weekly_psi(df, ["lang", "cat", "in_tok", "out_tok"])
print(table.round(3))
rates = daily_error_rate(df)
print("Ступенька ошибок начинается", change_day(rates).date())
except NotImplementedError:
print("Допишите функции — и здесь появится таблица дрейфа по неделям и день ступеньки.")
Что должно получиться. К третьей неделе сильно сдвинулся язык: PSI 0.54, доля китайского выросла с 9% до
39%. Тема, длина входа и длина ответа стабильны (PSI меньше 0.02). Ступенька ошибок — 17 марта: до этого доля ошибок
по дням была от 2% до 9%, а с 17-го — от 13% до 23%. Соблазнительный вывод «ошибки из-за китайских клиентов» неверен: язык менялся плавно
с 9 марта, а ошибки выросли скачком. Срез df.groupby("cat")["error"].mean() по третьей неделе покажет, что
ошибок много только в теме fx (65% против 3–6% в остальных) — сломался API курсов валют. Дрейф и инцидент —
разные вещи, и их надо проверять разными инструментами.
Проверь себя
Заголовок раздела «Проверь себя»- Мониторинг LLM-сервиса — три слоя: техника (задержка p95/p99, ошибки, KV-кэш, очередь), качество (отказы, дизлайки, судья, люди) и данные (дрейф входов и ответов). Каждую метрику режут по версии и сегментам, каждую выкатку пишут в лог.
- PSI числового признака считают по бинам из квантилей эталона, пустой бин заменяют на ; на маленьких выборках PSI шумит. Тест КС не требует бинов, но на большом трафике значим почти любой сдвиг — смотрите на .
- CUSUM находит момент начала устойчивого роста доли плохих ответов, а не момент, когда его стало видно. Алерт без шума — длинное окно, порог от , несколько проверок подряд, только прошлые данные; при большом трафике — два окна и бюджет ошибок.
- В инциденте сначала смягчают (откат за минуты), потом разбираются. Канарейка на 10% трафика ловит плохую выкатку за сутки, пока пострадала малая доля пользователей. Причину проверяют внутри сегментов, чтобы не спутать поломку со сменой состава.
- Постмортем — без поиска виноватых: кратко, влияние, хронология, причина, почему не поймали раньше, что помогло, действия с владельцем и сроком, уроки.
Дальше: урок 11.1.5 — деньги и компромиссы. Сколько стоит LLM-функция через API и на своих GPU, где точка безубыточности и как обосновать решение руководителю на одной странице.