Eval-driven development и наблюдаемость
Как эвалы рождаются из логов продакшена, как устроены три уровня проверок (ассерты, откалиброванный LLM-судья, люди), какие онлайн-метрики смотреть, как трейсить вызовы 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
Выборка. Из логов берут два потока, и путать их нельзя.
- Случайная выборка — например, 50 запросов в неделю наугад. Только по ней можно оценить, какая доля ответов плохая на самом деле.
- Прицельная выборка — дизлайки, отказы «в источниках нет ответа», очень длинные диалоги, ошибки, ответы с выдуманными ссылками. Здесь неудачи концентрируются, и их быстро находить. Но долю плохих ответов по такой выборке считать нельзя: она смещена в сторону плохого.
Разметка. Дальше — тот же разбор ошибок по категориям, что в уроке 8.4.1: читаем, пишем пометку, группируем, считаем. Каждый подтверждённый случай с понятным правильным поведением становится тест-кейсом золотого наборазолотой набор (тест-набор)EN golden datasetВерсионированный набор входов с эталонными ответами или способом проверки, на котором прогоняют каждую версию промпта или модели..
Два набора. В статье Anthropic «Demystifying evals for AI agents» эвалы делят на два вида с разной ролью:
| Набор возможностей | Регрессионный набор | |
|---|---|---|
| Вопрос | что система пока умеет плохо? | не сломали ли мы то, что работало? |
| Ожидаемый результат | низкая доля успехов, растёт со временем | почти 100% |
| Что делать с провалом | обычное дело, это цель работы | стоп: разбираться до выпуска |
Освоенные задачи «выпускаются» из первого набора во второй. Если набор возможностей проходится на 100%, он насыщен и больше не показывает прогресс: нужны новые трудные задачи. Для старта, по совету той же статьи, хватит 20–50 простых задач из реальных неудач. Ждать идеального набора из тысячи примеров не нужно.
Три уровня проверок
Заголовок раздела «Три уровня проверок»Уровень 1. Детерминированные ассерты
Заголовок раздела «Уровень 1. Детерминированные ассерты»Дешёвые проверки кодом, как assertутверждение (assert)EN assertionassert условие, "сообщение" — проверка, которая падает с AssertionError, если условие ложно. Отключается флагом python -O. в юнит-тестах. Для RAG-бота из ДЗ s8-hw01:
- в ответе есть хотя бы одна ссылка
[n], и все номера существуют — функцияparse_citationsиз ДЗ; - если поиск ничего не нашёл, ответ — фраза «в источниках нет ответа», а не импровизация;
- язык ответа совпадает с языком вопроса;
- длина в разумных пределах, нет обрыва по
finish_reason: length; - нет персональных данных и фрагментов системного промпта (урок 8.4.3).
Такие проверки работают за микросекунды. Поэтому их запускают на каждый запрос в продакшене, а не только на наборе. Это первая и самая надёжная часть онлайн-оценкионлайн-оценкаEN online evaluationОценка качества на живом трафике — доля отказов и дизлайков, проверки ассертами и выборочный LLM-судья. Дополняет офлайн-прогон золотого набора..
Уровень 2. LLM-судья и его калибровка
Заголовок раздела «Уровень 2. LLM-судья и его калибровка»Для смысла — верности источникам, полноты, тона — нужен LLM-судьяLLM-судья (LLM-as-a-judge)EN LLM-as-a-judgeСильная модель оценивает ответы другой модели по рубрике или сравнивает пару ответов. Дёшево и быстро, но со смещениями. с рубрикой. Как его построить и как бороться с позиционным смещением, вы знаете из урока 7.3.2. Там же вы проверяли судью по людям через каппу Коэна. В продакшене нужен ещё один шаг: поправка оценки на ошибки судьи.
Пусть судья ставит каждому ответу «хорошо» или «плохо». Разметьте вручную 100 ответов и сравните с ним:
- из 80 ответов, которые люди считают хорошими, судья назвал хорошими 76. Это доля верно найденных положительных, TPR (полнота, recall) ;
- из 20 плохих судья поймал 14. Это специфичностьспецифичность (TNR)EN specificity (true negative rate)Доля отрицательных примеров, которые классификатор правильно назвал отрицательными. Для LLM-судьи — как часто он ловит плохой ответ., TNR .
Судья снисходителен: 30% плохих ответов он пропускает. Значит, когда он говорит «80% ответов хорошие», настоящая доля ниже. Если истинная доля хороших ответов , судья говорит «хорошо» с вероятностью
Словами: хорошие ответы он одобряет с вероятностью TPR, а плохие ошибочно одобряет с вероятностью . Выразим через наблюдаемую долю :
Это поправка Рогана — Гладена из эпидемиологии, где так поправляют долю больных по неидеальному тесту. Знаменатель называют индексом Юденаиндекс ЮденаEN Youden's index (J statistic)J = TPR + TNR − 1. Ноль — судья не лучше монетки, единица — безошибочный. На J делят при поправке наблюдаемой доли «хороших» ответов.: у судьи-монетки , и поправка невозможна, у идеального судьи .
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.769v8 судья: 0.86 → после поправки: 0.862Проверим первую строку вручную: . Обратите внимание на разницу: по судье версии различаются на 6 п. п., после поправки — на 9. Снисходительный судья сжимает различия: делите на , и разница растёт в раза.
Три практических правила:
- Закрепите версию судьи — модель, промпт, температуру — так же, как версию промпта в 8.4.1. Сменили судью — все прошлые числа несравнимы, калибруйте заново.
- TPR и TNR тоже оценки со своей погрешностью: 20 плохих примеров дают TNR с интервалом около . Для уверенной поправки размечайте больше плохих примеров (прицельная выборка тут как раз полезна).
- Судья — на выборке, а не на всём трафике: 10–20% запросов обычно хватает для метрик и экономит деньги.
Уровень 3. Люди
Заголовок раздела «Уровень 3. Люди»Выборочная ручная проверка — эталон, на котором держатся оба уровня выше. Раз в неделю 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, среднее секунды выглядит терпимо. Но каждый десятый пользователь ждёт полминуты, и именно он пишет жалобу. p95 = 30 секунд это показывает: 95% запросов укладываются в 30 секунд, а в 2 секунды — только 90%.
Все метрики сегментируют: по версии промпта и модели, языку, теме, типу клиента. Средняя по больнице прячет проблемы, как вы увидите в практике.
Когда поднимать тревогу? Простейшая контрольная карта: возьмите долю отказов в спокойный период, , и для каждого дня с запросами проверяйте, не вышла ли доля за верхнюю границу
Три стандартные ошибки (а не 1.96) — чтобы ежедневная проверка не будила дежурного ложными тревогами. В логе из практики , в день около 100 успешных запросов, граница . Первые пять дней доля отказов от 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/>(у агента)"]
Чтобы понять идею, вендор не нужен. Вот трейсер на 25 строк. Конструкцию with вы знаете по with open(...) из
урока 0.2.4: она гарантирует, что код «после» выполнится, даже если
внутри случилась ошибка. Декоратор @contextmanager делает такую конструкцию из функции с yield (генераторы —
урок 0.2.2): код до yield выполняется при входе в блок, код в finally — при выходе. Наш span при входе
запоминает родителя и время, а при выходе считает длительность и записывает спан в список.
import jsonimport timeimport uuidfrom contextlib import contextmanager
SPANS = [] # в жизни — файл JSONL или отправка в Langfuse/Phoenix_stack = [] # стек открытых спанов: вершина — родитель для следующего
@contextmanagerdef 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. Конвенции ещё развиваются, поэтому сверяйтесь с их
репозиторием. Смысл стандарта — не привязываться
к вендору: код размечен один раз, а трейсы можно отправить куда угодно.
Куда отправлять, если нужен интерфейс с деревом спанов, поиском и графиками:
| Инструмент | Лицензия | Что умеет | Как запустить у себя |
|---|---|---|---|
| Langfuse | MIT (кроме папок 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
Заголовок раздела «Регрессионный прогон в CI»Непрерывная интеграциянепрерывная интеграция (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 — заметный сдвиг.:
где — доля категории в базовый период, а — в текущий. Каждое слагаемое неотрицательно: если доля выросла, оба множителя положительны, если упала — оба отрицательны. Распространённые пороги — эмпирическое правило, а не статистический тест: меньше 0.1 — сдвиг небольшой, 0.1–0.25 — умеренный, больше 0.25 — значительный. В логе из практики доли языков до и после 6 сентября: ru 0.825 → 0.660, en 0.063 → 0.049, zh 0.112 → 0.291. Посчитаем: . Почти весь сдвиг дал китайский язык. PSI для числовых признаков (длина входа, задержка), тест Колмогорова — Смирнова и поиск момента, когда всё сломалось, — в уроке 11.1.4.
Для свободного текста категорий нет. Тогда кластеризуют эмбеддинги вопросов (урок 8.2.1) и следят за долей вопросов, далёких от всех известных кластеров. Реакция на дрейф всегда одна: новые случаи — в разбор ошибок и в набор, затем проверка, справляется ли система.
Практика
Заголовок раздела «Практика»Перед вами лог RAG-бота «Панда-Облака» за 10 дней: 1000 запросов в формате JSONL. 6 сентября в полдень промпт
v7 заменили на v8. Посчитайте метрики качества и стоимости по версиям, найдите, где именно случилась регрессия,
и измерьте дрейф. Все вычисления — в pandas (урок 2.4.1): groupby, quantile, unstack.
"""Анализ лога 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), у русских и английских интервал разницы включает ноль. Интервал здесь непарный (, урок 1.3.4): до и после смены промпта бот отвечал на разные запросы, в отличие от A/B на одном наборе в уроке 8.4.1. Если бы вы сделали срез по темам, то увидели бы «значимую регрессию» в трёх темах из пяти. Но внутри одних только русских вопросов ни одна тема значимо не выросла: темы просто содержат разную долю китайских вопросов. Сначала ищите сегмент, который объясняет всё, потом углубляйтесь. Заодно помните урок 1.3.4: чем больше срезов вы проверяете, тем больше случайных «значимых» находок.
А вот дрейф: доля китайских вопросов выросла с 5% до 38% за 10 дней. Поэтому v8 опасен вдвойне: его слабое место растёт быстрее всего. Решение — не откатывать v8 целиком (судья им доволен), а разобрать китайские отказы, добавить их в набор и починить: скорее всего, новая инструкция плохо сочетается с русскоязычными документами.
Проверь себя
Заголовок раздела «Проверь себя»- Эвалы рождаются из продакшена: случайная выборка для оценки доли проблем, прицельная — для поиска неудач, разбор ошибок по категориям, новые тест-кейсы. Набор возможностей меряет прогресс, регрессионный ловит поломки.
- Три уровня проверок: ассерты на 100% трафика, LLM-судья на выборке с поправкой на его TPR и TNR (индекс Юдена), люди — для калибровки и того, о чём вы не подумали.
- Онлайн-метрики: отказы, дизлайки, ошибки, p95 задержек, стоимость полезного ответа — по версиям и сегментам, с контрольными границами для тревог. Общая доля может скрывать регрессию в одном сегменте.
- Трейс — дерево спанов с версией промпта, документами, токенами и временем. OpenTelemetry с конвенциями GenAI избавляет от привязки к вендору; Langfuse и Phoenix запускаются у себя.
- В CI — эвал-шлюз с обязательными примерами. Перед сменой модели — полный прогон, теневой запуск и канарейка. Дрейф данных меряют PSI и превращают в новые тест-кейсы.
Дальше: урок 8.4.3 — упакуем RAG-бота в сервис на FastAPI со стримингом, лимитами, кэшем и защитой, соберём всё в docker compose и проверим под нагрузкой.