Перейти к содержимому
Ступень 88.4 LLM-приложение в продакшене

Eval-driven development и наблюдаемость

Как эвалы рождаются из логов продакшена, как устроены три уровня проверок (ассерты, откалиброванный LLM-судья, люди), какие онлайн-метрики смотреть, как трейсить вызовы LLM и инструментов, ставить регрессионный прогон в CI и замечать дрейф входных данных.

УРОКstage-8/8-4-production/02-evals-observability.mdx3 чУрок 2 из 3
После урока вы сможете
  • Превращать логи продакшена в тест-кейсы — выборка, разметка ошибок, наборы возможностей и регрессии
  • Калибровать LLM-судью по ручной разметке и поправлять наблюдаемую долю хороших ответов на его ошибки
  • Считать онлайн-метрики по версиям и сегментам в pandas и отличать настоящую регрессию от смеси трафика
  • Трейсить вызовы LLM и инструментов, ставить регрессионный прогон в CI и замечать дрейф входных данных

Вы выпустили RAG-бота. Через неделю тимлид спрашивает: «Ну как он?» Без данных честный ответ — «не знаю». Вы не знаете, о чём на самом деле спрашивают пользователи. Не знаете, стал ли бот хуже после вчерашней правки промпта. Не знаете, почему вон тот ответ был ерундой: поиск не нашёл документ, модель его проигнорировала или документ устарел. Качество LLM-систем деградирует тихо: никто не получает исключение, просто ответы становятся хуже.

Два инструмента делают это видимым. разработка через эвалыразработка через эвалы (EDD)EN eval-driven developmentАналог разработки через тесты для LLM-систем. Сначала пример неудачи попадает в набор, потом делается правка, и она проверяется прогоном набора. — разработка через эвалы: любая правка начинается с примера неудачи в наборе и заканчивается прогоном набора, как в разработке через тесты (урок 0.1.3). НаблюдаемостьнаблюдаемостьEN observabilityСпособность по собранным данным (логи, метрики, трейсы) ответить, что и почему произошло в системе, не добавляя новый код. — способность по логам, метрикам и трейсам ответить «что и почему случилось» с конкретным запросом. В прошлом уроке вы научились сравнивать версии на готовом наборе. Теперь — откуда этот набор берётся, как следить за качеством на живом трафике и как ловить регрессию до пользователей.

Откуда берутся эвалы: от логов к тест-кейсам

Заголовок раздела «Откуда берутся эвалы: от логов к тест-кейсам»

Лучший источник тест-кейсов — не фантазия разработчика, а то, что реально происходит в продакшене.

flowchart TD
A["Логи и трейсы продакшена"] --> B["Выборка: случайная + подозрительная"]
B --> C["Чтение, разметка и категории ошибок"]
C --> D["Новые тест-кейсы в золотом наборе"]
D --> E["Правка промпта, поиска или модели"]
E --> F["Регрессионный прогон в CI"]
F --> G["Выпуск и онлайн-метрики"]
G --> A
Цикл eval-driven development: каждая неудача из продакшена становится тестом

Выборка. Из логов берут два потока, и путать их нельзя.

  • Случайная выборка — например, 50 запросов в неделю наугад. Только по ней можно оценить, какая доля ответов плохая на самом деле.
  • Прицельная выборка — дизлайки, отказы «в источниках нет ответа», очень длинные диалоги, ошибки, ответы с выдуманными ссылками. Здесь неудачи концентрируются, и их быстро находить. Но долю плохих ответов по такой выборке считать нельзя: она смещена в сторону плохого.

Разметка. Дальше — тот же разбор ошибок по категориям, что в уроке 8.4.1: читаем, пишем пометку, группируем, считаем. Каждый подтверждённый случай с понятным правильным поведением становится тест-кейсом золотого наборазолотой набор (тест-набор)EN golden datasetВерсионированный набор входов с эталонными ответами или способом проверки, на котором прогоняют каждую версию промпта или модели..

Два набора. В статье Anthropic «Demystifying evals for AI agents» эвалы делят на два вида с разной ролью:

Набор возможностейРегрессионный набор
Вопросчто система пока умеет плохо?не сломали ли мы то, что работало?
Ожидаемый результатнизкая доля успехов, растёт со временемпочти 100%
Что делать с проваломобычное дело, это цель работыстоп: разбираться до выпуска

Освоенные задачи «выпускаются» из первого набора во второй. Если набор возможностей проходится на 100%, он насыщен и больше не показывает прогресс: нужны новые трудные задачи. Для старта, по совету той же статьи, хватит 20–50 простых задач из реальных неудач. Ждать идеального набора из тысячи примеров не нужно.

Дешёвые проверки кодом, как assertутверждение (assert)EN assertionassert условие, "сообщение" — проверка, которая падает с AssertionError, если условие ложно. Отключается флагом python -O. в юнит-тестах. Для RAG-бота из ДЗ s8-hw01:

  • в ответе есть хотя бы одна ссылка [n], и все номера существуют — функция parse_citations из ДЗ;
  • если поиск ничего не нашёл, ответ — фраза «в источниках нет ответа», а не импровизация;
  • язык ответа совпадает с языком вопроса;
  • длина в разумных пределах, нет обрыва по finish_reason: length;
  • нет персональных данных и фрагментов системного промпта (урок 8.4.3).

Такие проверки работают за микросекунды. Поэтому их запускают на каждый запрос в продакшене, а не только на наборе. Это первая и самая надёжная часть онлайн-оценкионлайн-оценкаEN online evaluationОценка качества на живом трафике — доля отказов и дизлайков, проверки ассертами и выборочный LLM-судья. Дополняет офлайн-прогон золотого набора..

Для смысла — верности источникам, полноты, тона — нужен LLM-судьяLLM-судья (LLM-as-a-judge)EN LLM-as-a-judgeСильная модель оценивает ответы другой модели по рубрике или сравнивает пару ответов. Дёшево и быстро, но со смещениями. с рубрикой. Как его построить и как бороться с позиционным смещением, вы знаете из урока 7.3.2. Там же вы проверяли судью по людям через каппу Коэна. В продакшене нужен ещё один шаг: поправка оценки на ошибки судьи.

Пусть судья ставит каждому ответу «хорошо» или «плохо». Разметьте вручную 100 ответов и сравните с ним:

  • из 80 ответов, которые люди считают хорошими, судья назвал хорошими 76. Это доля верно найденных положительных, TPR (полнота, recall) =76/80=0.95= 76/80 = 0.95;
  • из 20 плохих судья поймал 14. Это специфичностьспецифичность (TNR)EN specificity (true negative rate)Доля отрицательных примеров, которые классификатор правильно назвал отрицательными. Для LLM-судьи — как часто он ловит плохой ответ., TNR =14/20=0.70= 14/20 = 0.70.

Судья снисходителен: 30% плохих ответов он пропускает. Значит, когда он говорит «80% ответов хорошие», настоящая доля ниже. Если истинная доля хороших ответов θ\theta, судья говорит «хорошо» с вероятностью

q=θ⋅TPR+(1−θ)(1−TNR).q = \theta \cdot \text{TPR} + (1 - \theta)(1 - \text{TNR}).

Словами: хорошие ответы он одобряет с вероятностью TPR, а плохие ошибочно одобряет с вероятностью 1−TNR1 - \text{TNR}. Выразим θ\theta через наблюдаемую долю qq:

θ=q+TNR−1TPR+TNR−1.\theta = \frac{q + \text{TNR} - 1}{\text{TPR} + \text{TNR} - 1}.

Это поправка Рогана — Гладена из эпидемиологии, где так поправляют долю больных по неидеальному тесту. Знаменатель J=TPR+TNR−1J = \text{TPR} + \text{TNR} - 1 называют индексом Юденаиндекс ЮденаEN Youden's index (J statistic)J = TPR + TNR − 1. Ноль — судья не лучше монетки, единица — безошибочный. На J делят при поправке наблюдаемой доли «хороших» ответов.: у судьи-монетки J=0J = 0, и поправка невозможна, у идеального судьи J=1J = 1.

def corrected_rate(observed, tpr, tnr):
"""Поправка доли «хороших» ответов по судье на его ошибки (формула Рогана — Гладена)."""
j = tpr + tnr - 1 # индекс Юдена: 0 — судья не лучше монетки
if j <= 0:
raise ValueError("судья не лучше случайного: поправка невозможна")
return min(1.0, max(0.0, (observed + tnr - 1) / j))
tpr, tnr = 76 / 80, 14 / 20 # по 100 ответам, размеченным людьми
for version, observed in [("v7", 0.80), ("v8", 0.86)]:
print(version, f"судья: {observed:.2f} → после поправки: {corrected_rate(observed, tpr, tnr):.3f}")
v7 судья: 0.80 → после поправки: 0.769
v8 судья: 0.86 → после поправки: 0.862

Проверим первую строку вручную: (0.80+0.70−1)/(0.95+0.70−1)=0.50/0.65≈0.769(0.80 + 0.70 - 1) / (0.95 + 0.70 - 1) = 0.50 / 0.65 \approx 0.769. Обратите внимание на разницу: по судье версии различаются на 6 п. п., после поправки — на 9. Снисходительный судья сжимает различия: делите на J=0.65J = 0.65, и разница растёт в 1/0.65≈1.51/0.65 \approx 1.5 раза.

Три практических правила:

  1. Закрепите версию судьи — модель, промпт, температуру — так же, как версию промпта в 8.4.1. Сменили судью — все прошлые числа несравнимы, калибруйте заново.
  2. TPR и TNR тоже оценки со своей погрешностью: 20 плохих примеров дают TNR с интервалом около ±1.960.7⋅0.3/20≈±0.20\pm 1.96\sqrt{0.7 \cdot 0.3 / 20} \approx \pm 0.20. Для уверенной поправки размечайте больше плохих примеров (прицельная выборка тут как раз полезна).
  3. Судья — на выборке, а не на всём трафике: 10–20% запросов обычно хватает для метрик и экономит деньги.

Выборочная ручная проверка — эталон, на котором держатся оба уровня выше. Раз в неделю 30–50 случайных диалогов читает человек, знающий предметную область, по письменной инструкции с примерами. Расхождения человека с судьёй разбирают: иногда ошибся судья, иногда — рубрика, иногда — сам человек. Главы о человеческой оценке и о судьях в Evaluation Guidebook Hugging Face — хорошее продолжение этого раздела.

УровеньГде работаетСтоимостьЧто ловит
Ассерты100% трафика и каждый прогон наборапочти нольформат, ссылки, отказы, длину, PII
LLM-судья10–20% трафика, ночные прогонытокены судьиверность источникам, полноту, тон
Люди30–50 диалогов в неделю, калибровка судьичасы специалиставсё, включая то, о чём вы не подумали

Что смотреть на живом трафике:

  • Сигналы качества: доля отказов («в источниках нет ответа»), доля дизлайков среди оценённых ответов, доля перегенераций и правок ответа пользователем, жалобы и передачи живому оператору, доля ответов с несуществующими ссылками, средняя оценка судьи на выборке.
  • Эксплуатация: доля ошибок и таймаутов, время до первого токенавремя до первого токена (TTFT)EN time to first token (TTFT)Сколько пользователь ждёт первое слово ответа: очередь + prefill всего промпта. Растёт с длиной промпта. и полное время ответа, токены на запрос, доля попаданий в кэш.
  • Деньги: стоимость запроса и, важнее, стоимость полезного ответа — сумма затрат, делённая на число настоящих ответов. Отказ тоже стоит денег, но пользу не приносит.

Задержки описывают хвостовыми перцентилямихвостовая задержка (p95, p99)EN tail latencyЗадержка, которую не превышают 95% или 99% запросов. Среднее скрывает медленные запросы, а пользователи помнят именно их., а не средним. Если 90 запросов из 100 отвечают за 2 секунды, а 10 — за 30, среднее (90⋅2+10⋅30)/100=4.8(90 \cdot 2 + 10 \cdot 30)/100 = 4.8 секунды выглядит терпимо. Но каждый десятый пользователь ждёт полминуты, и именно он пишет жалобу. p95 = 30 секунд это показывает: 95% запросов укладываются в 30 секунд, а в 2 секунды — только 90%.

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

Когда поднимать тревогу? Простейшая контрольная карта: возьмите долю отказов в спокойный период, p0p_0, и для каждого дня с nn запросами проверяйте, не вышла ли доля за верхнюю границу

p0+3p0(1−p0)n.p_0 + 3\sqrt{\frac{p_0(1 - p_0)}{n}}.

Три стандартные ошибки (а не 1.96) — чтобы ежедневная проверка не будила дежурного ложными тревогами. В логе из практики p0≈0.057p_0 \approx 0.057, в день около 100 успешных запросов, граница 0.057+30.057⋅0.943/100≈0.1270.057 + 3\sqrt{0.057 \cdot 0.943 / 100} \approx 0.127. Первые пять дней доля отказов от 0.02 до 0.11, в пределах нормы. 7 сентября, в первый полный день после выкатки нового промпта, — 0.158. Тревога. Как строить алерты без ложных тревог и ловить небольшие, но устойчивые сдвиги через CUSUM — в уроке 11.1.4.

Метрика говорит «отказов стало больше». Чтобы понять почему для конкретного запроса, нужен трейстрейсинг (трассировка запросов)EN tracingЗапись пути одного запроса через систему в виде дерева спанов — поиск, вызов LLM, инструменты — с временем и атрибутами каждого шага.: запись пути запроса через систему. Он состоит из спановспанEN spanОдин шаг трейса — операция с началом, концом, атрибутами (модель, токены) и ссылкой на родительский спан. — шагов с началом, концом и атрибутами, вложенных друг в друга:

flowchart TD
R["rag.request · 70 мс<br/>prompt_version=v8"] --> S1["retrieve · 20 мс<br/>k=5, doc_ids=[d03, d17]"]
R --> S2["llm.chat · 50 мс<br/>model, input_tokens=1830,<br/>output_tokens=96"]
S2 -.-> S3["tool: calculator<br/>(у агента)"]
Трейс одного запроса RAG-бота: дерево спанов с длительностями и атрибутами

Чтобы понять идею, вендор не нужен. Вот трейсер на 25 строк. Конструкцию with вы знаете по with open(...) из урока 0.2.4: она гарантирует, что код «после» выполнится, даже если внутри случилась ошибка. Декоратор @contextmanager делает такую конструкцию из функции с yield (генераторы — урок 0.2.2): код до yield выполняется при входе в блок, код в finally — при выходе. Наш span при входе запоминает родителя и время, а при выходе считает длительность и записывает спан в список.

import json
import time
import uuid
from contextlib import contextmanager
SPANS = [] # в жизни — файл JSONL или отправка в Langfuse/Phoenix
_stack = [] # стек открытых спанов: вершина — родитель для следующего
@contextmanager
def span(name, **attrs):
"""Записывает шаг трейса: имя, родителя, длительность, атрибуты и ошибку, если она случилась."""
s = {"trace_id": _stack[0]["trace_id"] if _stack else uuid.uuid4().hex[:8],
"span_id": uuid.uuid4().hex[:8],
"parent": _stack[-1]["span_id"] if _stack else None,
"name": name, "attrs": dict(attrs), "start": time.perf_counter()}
_stack.append(s)
try:
yield s["attrs"] # внутри блока можно дописать атрибуты: токены, ответ
except Exception as e:
s["attrs"]["error"] = repr(e)
raise
finally:
_stack.pop()
s["ms"] = round((time.perf_counter() - s.pop("start")) * 1000, 1)
SPANS.append(s)
def answer(question):
with span("rag.request", prompt_version="v8", question_len=len(question)):
with span("retrieve", k=5) as a:
time.sleep(0.02) # здесь был бы HybridRetriever из ДЗ s8-hw01
a["doc_ids"] = ["d03", "d17"]
with span("llm.chat", model="qwen3:8b") as a:
time.sleep(0.05) # здесь был бы вызов модели
a.update(input_tokens=1830, output_tokens=96, finish_reason="stop")
return "Лимит — 60 запросов в минуту [1]."
answer("Какой лимит запросов на бесплатном тарифе?")
for s in sorted(SPANS, key=lambda s: s["parent"] is not None):
print(json.dumps(s, ensure_ascii=False))
{"trace_id": "011e471e", "span_id": "46ead53b", "parent": null, "name": "rag.request", "attrs": {"prompt_version": "v8", "question_len": 42}, "ms": 70.4}
{"trace_id": "011e471e", "span_id": "0ead7828", "parent": "46ead53b", "name": "retrieve", "attrs": {"k": 5, "doc_ids": ["d03", "d17"]}, "ms": 20.1}
{"trace_id": "011e471e", "span_id": "6df33f9a", "parent": "46ead53b", "name": "llm.chat", "attrs": {"model": "qwen3:8b", "input_tokens": 1830, "output_tokens": 96, "finish_reason": "stop"}, "ms": 50.1}

Идентификаторы случайные (uuid4), миллисекунды зависят от машины, поэтому у вас они будут другими. По такому трейсу видно всё: какой версией промпта ответили, какие документы нашлись, сколько токенов ушло и где потеряно время. Если ответ плохой, а нужного документа нет в doc_ids, виноват поиск (урок 8.2.2), а не модель.

Стандарт вместо самоделки. В продакшене трейсы пишут через OpenTelemetry — открытый стандарт телеметрии, который понимают почти все системы мониторинга. Для LLM у него есть отдельные семантические конвенции GenAI: стандартные имена атрибутов вроде gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens и gen_ai.operation.name. Конвенции ещё развиваются, поэтому сверяйтесь с их репозиторием. Смысл стандарта — не привязываться к вендору: код размечен один раз, а трейсы можно отправить куда угодно.

Куда отправлять, если нужен интерфейс с деревом спанов, поиском и графиками:

ИнструментЛицензияЧто умеетКак запустить у себя
LangfuseMIT (кроме папок ee)трейсы, LLM-судья, датасеты, версии промптовdocker compose up из репозитория
Phoenix (Arize)Elastic License 2.0трейсы на OpenTelemetry, эвалы, экспериментыpip install arize-phoenix, phoenix serve, интерфейс на порту 6006

Оба можно поднять на своей машине, данные никуда не уйдут. Учтите разницу лицензий: Elastic License 2.0 не считается открытой лицензией в строгом смысле и запрещает, например, продавать Phoenix как облачный сервис.

Что писать в трейс. Версию промпта и модели, параметры, ID найденных документов, токены по видам, время каждого шага, finish_reason, ошибки, ID пользователя (псевдонимизированный) и связь с отзывом пользователя через request_id. Текст вопросов и ответов — осторожно: там бывают персональные данные. Как их маскировать — в уроке 8.4.3.

Непрерывная интеграциянепрерывная интеграция (CI)EN continuous integrationАвтоматический запуск проверок (тестов, эвалов) на каждое изменение кода, например в GitHub Actions. Изменение, которое проверки не прошло, не сливают. (CI) — сервис, который на каждое изменение кода в репозитории автоматически запускает проверки. Например, GitHub Actions: вы описываете в файле, какие команды выполнить, и при каждом pull request они выполняются на сервере. Если команда завершилась с ненулевым кодом выхода, изменение помечается красным и его не сливают.

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

import sys
MUST_PASS = {"inj-01", "pii-02", "noans-03"} # инъекция, утечка PII, «нет ответа»: провал = стоп
def gate(baseline, candidate, max_drop=0.02):
"""baseline, candidate: {id примера: прошёл ли}. Возвращает список причин отказа (пустой — можно сливать)."""
reasons = []
failed_must = sorted(i for i in MUST_PASS if not candidate.get(i, False))
if failed_must:
reasons.append(f"провалены обязательные примеры: {failed_must}")
ids = sorted(baseline.keys() & candidate.keys()) # сравниваем только общие примеры — парно
old = sum(baseline[i] for i in ids) / len(ids)
new = sum(candidate[i] for i in ids) / len(ids)
broken = [i for i in ids if baseline[i] and not candidate[i]]
if new < old - max_drop:
reasons.append(f"доля успехов упала: {old:.3f} → {new:.3f}; сломались {broken[:5]}")
return reasons
baseline = {f"case-{k:02d}": k % 10 != 0 for k in range(100)} | {i: True for i in MUST_PASS}
candidate = dict(baseline, **{"case-11": False, "case-12": False, "case-13": False, "pii-02": False})
problems = gate(baseline, candidate)
print("\n".join(problems) or "OK")
# sys.exit(1 if problems else 0) # в CI ненулевой код выхода блокирует слияние
провалены обязательные примеры: ['pii-02']
доля успехов упала: 0.903 → 0.864; сломались ['case-11', 'case-12', 'case-13', 'pii-02']

Две идеи в этом коде. Обязательные примеры — безопасность и «красные линии»: одного провала достаточно, чтобы остановить выпуск. Список сломавшихся — это несогласованные пары из урока 8.4.1, с которых начинается разбор. Порог в 2 п. п. на сотне примеров грубый: если важны мелкие изменения, используйте парный интервал из 8.4.1.

Что и когда запускать, чтобы не разориться:

  • на каждый pull request — ассерты и регрессионный набор (сотня-другая примеров, без судьи или с дешёвым судьёй);
  • по ночам — полный набор с LLM-судьёй и набор возможностей;
  • перед сменой модели — всё вышеперечисленное, затем теневой запусктеневой запускEN shadow deploymentКопия реальных запросов отправляется новой версии, но её ответы пользователю не показывают — только записывают и сравнивают. (копия реальных запросов идёт новой модели, ответы пишутся в лог, но пользователю не показываются) и канареечный выпускканареечный выпускEN canary releaseНовая версия сначала получает малую долю трафика (например, 5%); её онлайн-метрики сравнивают со старой, и только потом трафик переключают целиком. на 5–10% трафика с онлайн-метриками по версиям.

Как тот же гейт работает в масштабе компании, где одну модель делят десятки команд, — в разборе LLM-платформы из урока 11.2.5.

Даже если вы ничего не меняли, мир меняется. Три вида дрейфа:

  • Дрейф данныхдрейф данныхEN data driftИзменение распределения входов со временем — новый язык, новые темы, сезонность. Эвал-набор при этом перестаёт отражать реальный трафик. — меняется распределение входов: маркетинг привёл китайских клиентов, вышел новый продукт, начался сезон. Набор, собранный в прошлом месяце, перестаёт отражать трафик.
  • Дрейф понятий — меняются правильные ответы: цены поднялись, документ устарел, лимиты изменились. Ответ «по источникам» становится неверным, если источники старые.
  • Дрейф модели — провайдер обновил модель под тем же именем. Отсюда совет закреплять снимок модели (урок 8.4.1).

Дрейф данных по категориальным признакам (язык, тема) удобно мерить индексом стабильности популяциииндекс стабильности популяции (PSI)EN population stability indexМера сдвига распределения признака: сумма (aᵢ − eᵢ)·ln(aᵢ/eᵢ) по категориям или по бинам числового признака. Пороги — эмпирическое правило: меньше 0.1 — мало, больше 0.25 — заметный сдвиг.:

PSI=∑i(ai−ei)ln⁡aiei,\mathrm{PSI} = \sum_i (a_i - e_i)\ln\frac{a_i}{e_i},

где eie_i — доля категории ii в базовый период, а aia_i — в текущий. Каждое слагаемое неотрицательно: если доля выросла, оба множителя положительны, если упала — оба отрицательны. Распространённые пороги — эмпирическое правило, а не статистический тест: меньше 0.1 — сдвиг небольшой, 0.1–0.25 — умеренный, больше 0.25 — значительный. В логе из практики доли языков до и после 6 сентября: ru 0.825 → 0.660, en 0.063 → 0.049, zh 0.112 → 0.291. Посчитаем: (0.660−0.825)ln⁡0.6600.825+(0.049−0.063)ln⁡0.0490.063+(0.291−0.112)ln⁡0.2910.112≈0.037+0.004+0.170=0.21(0.660 - 0.825)\ln\frac{0.660}{0.825} + (0.049 - 0.063)\ln\frac{0.049}{0.063} + (0.291 - 0.112)\ln\frac{0.291}{0.112} \approx 0.037 + 0.004 + 0.170 = 0.21. Почти весь сдвиг дал китайский язык. PSI для числовых признаков (длина входа, задержка), тест Колмогорова — Смирнова и поиск момента, когда всё сломалось, — в уроке 11.1.4.

Для свободного текста категорий нет. Тогда кластеризуют эмбеддинги вопросов (урок 8.2.1) и следят за долей вопросов, далёких от всех известных кластеров. Реакция на дрейф всегда одна: новые случаи — в разбор ошибок и в набор, затем проверка, справляется ли система.

Перед вами лог RAG-бота «Панда-Облака» за 10 дней: 1000 запросов в формате JSONL. 6 сентября в полдень промпт v7 заменили на v8. Посчитайте метрики качества и стоимости по версиям, найдите, где именно случилась регрессия, и измерьте дрейф. Все вычисления — в pandas (урок 2.4.1): groupby, quantile, unstack.

Лог RAG-бота: метрики по версиям, регрессия и дрейфnumpy, pandas · нужен интернет7 тестов
"""Анализ лога RAG-бота: метрики по версиям промпта, поиск регрессии и дрейфа (pandas).

Бот поддержки «Панда-Облака» (вымышленный провайдер из ДЗ s8-hw01) пишет в лог по строке JSON на запрос.
6 сентября в 12:00 промпт v7 заменили на v8. Ваша задача — понять по логу, что изменилось.
Поля строки лога:
  request_id, ts (время, ISO-строка), prompt_version ("v7" / "v8"), lang ("ru" / "en" / "zh"), topic,
  status ("ok" / "error" / "timeout"), input_tokens, cached_tokens, output_tokens,
  answer_type ("answer" / "no_answer" — бот ответил или сказал «в источниках нет ответа»; у ошибок пусто),
  ttft_ms, latency_ms, feedback (+1 / −1 от пользователя, есть не у всех),
  judge_score (оценка LLM-судьи 0…1 по верности источникам, только у ~20% ответов).
"""
import io
import json

import numpy as np
import pandas as pd

PRICES = {"input": 0.30, "cached": 0.03, "output": 1.20}   # долл. за 1 млн токенов — условные, как в уроке 8.1.1


def make_log(n=1000, seed=11):
    """Синтетический лог RAG-бота «Панда-Облако» за 10 дней в формате JSONL (одна строка — один запрос).
    6 сентября в 12:00 промпт v7 заменили на v8. Числа выдуманы, но устроены как в настоящих логах."""
    rng = np.random.default_rng(seed)
    secs = np.sort(rng.integers(0, 10 * 24 * 3600, n))           # секунды от начала 1 сентября
    v8 = secs >= 5 * 24 * 3600 + 12 * 3600                       # 6 сентября, 12:00 — выкатили v8
    p_zh = 0.04 + 0.03 * secs / 86400                             # дрейф: китайских вопросов всё больше
    u = rng.random(n)
    lang = np.where(u < p_zh, "zh", np.where(u < p_zh + 0.06, "en", "ru"))
    topic = rng.choice(["тарифы", "лимиты", "ошибки", "ключи", "другое"], size=n, p=[0.3, 0.2, 0.25, 0.15, 0.1])
    u = rng.random(n)
    status = np.where(u < 0.97, "ok", np.where(u < 0.99, "error", "timeout"))
    system = np.where(v8, 1250, 900)                              # у v8 системный промпт длиннее
    inp = system + np.clip(rng.normal(1400, 300, n), 400, 2600).astype(int) + rng.integers(20, 80, n)
    cached = np.where(rng.random(n) < 0.85, system, 0)            # префикс-кэш срабатывает не всегда
    p_old = np.select([lang == "ru", lang == "en"], [0.06, 0.08], 0.10)
    p_new = np.select([lang == "ru", lang == "en"], [0.06, 0.09], 0.40)   # v8 часто отказывает на китайском
    p_ref = np.where(v8, p_new, p_old) + 0.10 * (topic == "другое")      # про «другое» в базе мало документов
    refused = rng.random(n) < p_ref
    out = np.where(refused, rng.integers(15, 40, n), np.clip(rng.lognormal(5.1, 0.35, n), 40, 600).astype(int))
    ttft = 120 + 0.08 * (inp - cached) + rng.normal(0, 25, n)
    latency = ttft + 22 * out + rng.normal(0, 60, n)
    has_fb = rng.random(n) < 0.15                                 # оценку ставит примерно каждый седьмой
    thumbs_down = rng.random(n) < np.where(refused, 0.6, 0.15)    # отказы раздражают
    judged = ~refused & (rng.random(n) < 0.2)                     # LLM-судья проверяет 20% ответов
    judge = np.clip(rng.normal(np.where(v8, 0.86, 0.80), 0.1), 0, 1).round(2)
    fail_ms = np.where(status == "timeout", 30000, rng.integers(200, 800, n))
    t0 = np.datetime64("2026-09-01T00:00:00")
    lines = []
    for i in range(n):
        row = {"request_id": f"r{i + 1:04d}", "ts": str(t0 + np.timedelta64(int(secs[i]), "s")),
               "prompt_version": "v8" if v8[i] else "v7", "lang": str(lang[i]), "topic": str(topic[i]),
               "status": str(status[i]), "input_tokens": int(inp[i]), "cached_tokens": int(cached[i])}
        if status[i] == "ok":
            row.update(answer_type="no_answer" if refused[i] else "answer", output_tokens=int(out[i]),
                       ttft_ms=round(float(ttft[i])), latency_ms=round(float(latency[i])))
            if has_fb[i]:
                row["feedback"] = -1 if thumbs_down[i] else 1
            if judged[i]:
                row["judge_score"] = float(judge[i])
        else:
            row.update(answer_type=None, output_tokens=0, ttft_ms=None, latency_ms=int(fail_ms[i]))
        lines.append(json.dumps(row, ensure_ascii=False))
    return "\n".join(lines)


def load_log(text):
    """JSONL-текст → DataFrame.

    pd.read_json(io.StringIO(text), lines=True); столбец ts перевести в datetime (pd.to_datetime);
    отсортировать по ts и сбросить индекс (reset_index(drop=True)).
    """
    # TODO
    raise NotImplementedError


def add_cost(df, prices=PRICES):
    """Вернуть КОПИЮ df с новым столбцом cost_usd.

    cost = ((input_tokens − cached_tokens)·prices["input"] + cached_tokens·prices["cached"]
            + output_tokens·prices["output"]) / 1 000 000
    """
    # TODO
    raise NotImplementedError


def version_report(df):
    """DataFrame: индекс — версии промпта ("v7", "v8"), столбцы:

    n                    — всего запросов версии;
    error_rate           — доля status != "ok";
    refusal_rate         — среди status == "ok" доля answer_type == "no_answer";
    thumbs_down_rate     — среди строк с непустым feedback доля feedback == −1;
    judge_mean           — среднее judge_score (пропуски не считаются);
    latency_p95, ttft_p95 — 95-й процентиль latency_ms и ttft_ms среди status == "ok" (Series.quantile(0.95));
    cost_per_1k          — средняя стоимость запроса × 1000 (сколько стоит тысяча запросов);
    cost_per_1k_answers  — сумма cost_usd / число answer_type == "answer" × 1000 (тысяча ПОЛЕЗНЫХ ответов).
    Если столбца cost_usd нет — сначала add_cost.
    """
    # TODO: for version, g in df.groupby("prompt_version"): ... ; pd.DataFrame(rows).T
    raise NotImplementedError


def segment_report(df, by="lang", base="v7", new="v8", z=1.96):
    """Доля отказов (answer_type == "no_answer" среди status == "ok") по значениям столбца by.

    Индекс — значения by, столбцы: n_base, n_new (число ok-запросов), rate_base, rate_new,
    delta = rate_new − rate_base, lo и hi = delta ∓ z·se, где se = √(p₁(1−p₁)/n₁ + p₂(1−p₂)/n₂),
    significant — интервал не содержит ноль. Строки отсортированы по delta по убыванию.
    """
    # TODO: groupby([by, "prompt_version"])["refused"].agg(["mean", "size"]).unstack("prompt_version")
    raise NotImplementedError


def daily_share(df, col="lang", value="zh"):
    """Series: для каждого дня (df["ts"].dt.floor("D")) — доля строк, где df[col] == value."""
    # TODO
    raise NotImplementedError


def demo():
    pd.set_option("display.width", 160)
    pd.set_option("display.max_columns", 12)
    df = add_cost(load_log(make_log()))
    print("Запросов:", len(df), "| с", df["ts"].min(), "по", df["ts"].max())
    print(version_report(df).round(4).T)
    print("\nОтказы по языкам:\n", segment_report(df, by="lang").round(3))
    print("\nДоля вопросов на китайском по дням:\n", daily_share(df).round(3))


try:
    demo()
except Exception as e:  # пока функции не дописаны
    print("Допишите функции:", type(e).__name__, e)

Что должно получиться и почему это важно. По сводке версий v8 выглядит противоречиво. Судья доволен больше (0.836 против 0.809). Но отказов почти втрое больше (0.158 против 0.057), дизлайков больше (0.26 против 0.16), тысяча запросов стоит столько же, а тысяча полезных ответов — на 13% дороже (0.85 против 0.75 долл.). Длинный системный промпт v8 добавил 30 мс к p95 TTFT.

Срез по языку показывает, в чём дело: рост отказов значим только у китайских вопросов (с 0.05 до 0.35), у русских и английских интервал разницы включает ноль. Интервал здесь непарный (SE12+SE22\sqrt{\mathrm{SE}_1^2 + \mathrm{SE}_2^2}, урок 1.3.4): до и после смены промпта бот отвечал на разные запросы, в отличие от A/B на одном наборе в уроке 8.4.1. Если бы вы сделали срез по темам, то увидели бы «значимую регрессию» в трёх темах из пяти. Но внутри одних только русских вопросов ни одна тема значимо не выросла: темы просто содержат разную долю китайских вопросов. Сначала ищите сегмент, который объясняет всё, потом углубляйтесь. Заодно помните урок 1.3.4: чем больше срезов вы проверяете, тем больше случайных «значимых» находок.

А вот дрейф: доля китайских вопросов выросла с 5% до 38% за 10 дней. Поэтому v8 опасен вдвойне: его слабое место растёт быстрее всего. Решение — не откатывать v8 целиком (судья им доволен), а разобрать китайские отказы, добавить их в набор и починить: скорее всего, новая инструкция плохо сочетается с русскоязычными документами.

Проверь себя6 вопросов
1 Вы хотите оценить, какая доля ответов бота плохая. Какую выборку из логов использовать?
2 Судья по ручной разметке: TPR = 0.9, TNR = 0.8. На продакшене он называет хорошими 74% ответов. Какова поправленная доля хороших ответов? Округлите до двух знаков.
3 Что верно о регрессионном наборе и наборе возможностей?Можно выбрать несколько ответов
4 После выкатки v8 общая доля отказов выросла с 6% до 16%. В то же время доля китайских вопросов выросла с 11% до 29%. Какой анализ отвечает на вопрос «виноват ли промпт»?
5 Распределение языков: в базовый период ru 0.8, zh 0.2; сейчас ru 0.6, zh 0.4. Чему равен PSI? Округлите до двух знаков.
6 Зачем размечать трейсы по стандарту OpenTelemetry, а не писать формат конкретного сервиса?
  • Эвалы рождаются из продакшена: случайная выборка для оценки доли проблем, прицельная — для поиска неудач, разбор ошибок по категориям, новые тест-кейсы. Набор возможностей меряет прогресс, регрессионный ловит поломки.
  • Три уровня проверок: ассерты на 100% трафика, LLM-судья на выборке с поправкой на его TPR и TNR (индекс Юдена), люди — для калибровки и того, о чём вы не подумали.
  • Онлайн-метрики: отказы, дизлайки, ошибки, p95 задержек, стоимость полезного ответа — по версиям и сегментам, с контрольными границами для тревог. Общая доля может скрывать регрессию в одном сегменте.
  • Трейс — дерево спанов с версией промпта, документами, токенами и временем. OpenTelemetry с конвенциями GenAI избавляет от привязки к вендору; Langfuse и Phoenix запускаются у себя.
  • В CI — эвал-шлюз с обязательными примерами. Перед сменой модели — полный прогон, теневой запуск и канарейка. Дрейф данных меряют PSI и превращают в новые тест-кейсы.

Дальше: урок 8.4.3 — упакуем RAG-бота в сервис на FastAPI со стримингом, лимитами, кэшем и защитой, соберём всё в docker compose и проверим под нагрузкой.