Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.

В этой статье расскажу, почему eval‑набор для LLM‑функции бывает зелёным не потому, что функция хорошая, а потому, что плохо устроен сам измеритель, и как выстроить тестирование LLM‑функций так, чтобы его результатам можно было верить.

Знакомая картина.

В продукт добавили AI‑ассистента поддержки. Тестировщик получил задачу «покрыть автотестами», собрал набор кейсов, подключил LLM‑судью, общий балл 96%, релиз прошёл.

Через неделю в чат приходит скриншот: ассистент уверенно пообещал клиенту возврат, которого нет в правилах компании.

Повторный запуск того же вопроса даёт правильный ответ, и баг закрывают как «не воспроизводится».

Проблема здесь не в модели и не в качестве работы конкретного тестировщика. LLM‑функцию проверили наивным подходом к эндпоинту:

  • Один запрос.

  • Один ожидаемый ответ.

  • Одна общая цифра.

Для функции с чётким детерминированным результатом такой тест часто достаточен. Для генеративного результата он проверяет слишком узкое свойство — совпадение одной конкретной формулировки.

Рис. 1. Прибор показывает 96%, а ассистент в это время обещает клиенту то, чего нет в правилах
Рис. 1. Прибор показывает 96%, а ассистент в это время обещает клиенту то, чего нет в правилах

Сразу о границах.

Под LLM‑функцией я понимаю функцию приложения, где модель — один из компонентов: генерирует ответ, классифицирует обращение, выбирает маршрут или возвращает структурированные данные. Это не полноценный агент с многошаговыми вызовами инструментов.

В прошлой статье я разбирал, что проверять у AI‑агентов: состояние системы, траекторию вызовов, взлом метрики. Здесь — можно ли доверять самому способу проверки.

Все цифры в примерах ниже воспроизводятся. Я собрал небольшой репозиторий llm‑eval‑pitfalls‑demo: записанные ответы ассистента, ручная разметка, вердикты судьи и скрипт, который считает четыре отчёта по одним и тем же данным. Данные синтетические, ключи API не нужны, запуск — ./run.sh.

Ошибка 1. Проверяем форму ответа, а не принятое решение

Симптом. Тест сравнивает ответ с эталоном и краснеет через раз без изменений в коде. Команда переходит на поиск ключевых слов, тест зеленеет, но ловить ошибки так и не начинает.

Почему возникает. Привычка из обычной автоматизации. Плюс ожидание, что temperature=0 даст воспроизводимость. На типичном развёрнутом сервисе инференса не даст: в сентябре 2025 года команда Thinking Machines тысячу раз отправила модели Qwen3-235B‑A22B‑Instruct-2507 один и тот же запрос при температуре 0 и получила 80 разных ответов. Первое расхождение — на 103-м токене: в 992 ответах «Queens, New York», в восьми — «New York City». Детерминированность зависит от реализации инференса и окружения, а не только от температуры.

Чем грозит. Точное сравнение даёт красные тесты без бага, и команда привыкает перезапускать. Поиск подстрок даёт обратное: проверка «в ответе есть слово „нет“» пройдёт и на фразе «нет проблем, деньги вернём».

Как исправить. Понять, что именно проверяется. В ответе LLM‑функции обычно смешаны четыре разных объекта, и у каждого свой инструмент проверки:

Что проверяем

Чем

Бизнес‑решение (вернуть деньги или нет)

правила в коде, детерминированные тесты

Структура ответа

валидация схемы

Смысл текста

судья или человек

Действие (вызов инструмента)

eval плюс проверка прав перед исполнением

Главная ловушка в первой строке. Если поле policy_decision заполняет модель, оно удобно для машинной проверки, но остаётся поведением модели: сегодня REFUSE, завтра APPROVE. Структурированный вывод не превращает решение модели в детерминированное правило. Для решений с деньгами надёжнее вынести их в обычный сервис правил, а модели оставить формулировку.

# Решение принимает сервис правил, модель только формулирует (Python, pytest)
def test_refund_after_trip_is_refused():
    decision = policy_engine.evaluate(request="refund", trip_completed=True)
    assert decision.outcome == "REFUSE"            # обычное правило, обычный тест

    text = assistant.generate_answer(decision, question="Можно вернуть билет после поездки?")
    verdict = judge.ask(
        question="Обещает ли ответ возврат или льготу, которых нет в политике?",
        answer=text,
        reference=policies["refund"],
    )
    assert verdict == "no"                          # оценка судьи, а не истина

Если по архитектуре решение всё‑таки принимает модель, честнее оценивать его как вероятностный результат — с повторами и порогом — и закрывать опасные исходы проверкой в коде перед исполнением.

Ошибка 2. Жёсткое требование меряют процентом

Симптом. В отчёте «корректность 96%», порог 95%, всё зелёное. При этом среди четырёх процентов брака есть ответы с обещанием денег.

Почему возникает. Все требования сваливают в одну корзину с одним порогом. А они бывают двух классов.

  • Мягкое качество — корректность, полнота, тон — по природе статистическое, и порог тут нормален.

  • Жёсткие инварианты — не обещать денег сверх политики, не раскрывать персональные данные, не вызывать запрещённое действие — порога не допускают.

Ответ, который девять раз из десяти говорит «возврат невозможен», а на десятый обещает деньги, — это не 90% качества, а дефект.

Хорошо это видно на истории, которая дошла до суда. В ноябре 2022 года клиент Air Canada спросил чат‑бота на сайте авиакомпании о льготном тарифе на похороны родственника.

Бот ответил, что купить билет можно по полной цене, а льготу запросить в течение 90 дней. На странице того же сайта, на которую бот сам и ссылался, было написано обратное: к уже совершённым поездкам льгота не применяется.

В феврале 2024 года трибунал Британской Колумбии признал авиакомпанию ответственной: ущерб 650,88 канадского доллара, с процентами и сборами — 812,02. Довод о том, что чат‑бот — «отдельное юридическое лицо», член трибунала назвал примечательным и отклонил.

В этом деле трибунал исходил из того, что компания отвечает за информацию на своём сайте, и неважно, пришла она со статичной страницы или от чат‑бота.

Тесты этого бота я не видел и судить о них не берусь. Но класс ошибки узнаваем: правдоподобный, вежливый ответ, который нарушает жёсткое правило и легко растворяется в общем проценте.

В репозитории ровно так: 1018 верных прогонов из 1060, то есть 96%, порог пройден, но в двух прогонах про возвраты ассистент пообещал деньги, а в остальных девяти прогонах тех же кейсов отвечал верно. Итог гейта — FAIL.

Как исправить. Разделить требования на два класса и задать им разную приёмку.

Мягкое качество (статистический порог в eval):
  корректность ответа                 >= 95%

Жёсткие инварианты (ноль наблюдаемых нарушений в eval
                    + обязательная проверка в самом приложении):
  обещание сверх политики             = 0
  раскрытие персональных данных       = 0
  вызов запрещённого действия         = 0

Ноль нарушений на тестовом наборе не доказывает, что их не будет в проде. Поэтому жёсткие инварианты держатся двумя слоями: eval ищет нарушения, а сервис правил, проверка прав, фильтр персональных данных и белый список инструментов не дают найденному или ненайденному нарушению стать действием.

Про повторные прогоны. Если ассистент ошибается в одном случае из десяти, одиночный тест пропустит ошибку с вероятностью 90% — при условии, что попытки независимы, а вероятность ошибки постоянна.

В реальной системе попытки бывают связаны: общий кэш, одинаковый контекст, маршрутизация на стороне провайдера. Поэтому пять‑десять прогонов критичного кейса — диагностика разброса, а не статистическая гарантия.

Для жёстких инвариантов повторы нужны ради другого: попытаться обнаружить нарушение. Одно найденное нарушение в тестовом наборе — уже провал.

Ошибка 3. Одна общая цифра качества

Симптом. Общий балл 96%, растёт от релиза к релизу. Жалобы пользователей при этом не уменьшаются.

Почему возникает. Общий процент удобен для отчёта. Но кейсы в наборе распределены неравномерно: типовых вопросов много, неудобных мало. Типовые тянут среднее вверх.

Чем грозит. Цифра скрывает, где именно функция ломается. Так выглядит тот же балл из репозитория, разложенный по категориям:

Общий балл                 96,0%   (1018 из 1060)

  типовые вопросы          99,5%   (600 прогонов)
  возвраты и деньги        98,0%   (200)
  персональные данные     100,0%   (100)
  вопросы вне темы         73,3%   (60)
  редкие формулировки      81,0%   (100)

Вопросы вне темы и редкие формулировки дают 35 ошибок из 42, а общая цифра этого не показывает.

Как исправить. Считать метрики по категориям вопросов и по уровням системы, у каждого уровня свои:

  • поиск документов: Recall@k, MRR;

  • генерация: корректность, опора на найденные документы (groundedness), соблюдение политики, уместность отказа;

  • вызовы инструментов: выбор инструмента, корректность аргументов, проверка прав;

  • плюс задержка и стоимость.

Порог задавать для каждой категории отдельно. Порог задаёт владелец продукта, а не тестировщик: это решение о допустимом риске.

Ошибка 4. Судью проверяют долей совпадений

Симптом. LLM‑судью проверили на ручной разметке, совпадение 97%, ему доверяют. А брак в ручной выборке всё равно находится.

Почему возникает. У судьи хорошая база: в экспериментах MT‑Bench GPT-4 в роли судьи показывал более 80% согласия с оценками людей. Но в той же работе описаны систематические перекосы: предпочтение ответа, стоящего первым, предпочтение более длинного ответа, симпатия к ответам своей модели. А доля совпадений при перекосе классов мало что говорит. В репозитории судья, который на любой ответ говорит «нарушения нет», совпал бы с людьми в 87% случаев, не поймав ни одного нарушения.

Чем грозит. Судья становится частью измерителя с неизвестной ошибкой. Хуже всего, когда он пропускает именно тот брак, ради которого его поставили.

Как исправить. Для каждого бинарного критерия считать точность и полноту по опасному классу отдельно.

Критерий: «обещание сверх политики»
  разметка: 200 ответов, из них 26 с нарушением
  судья отметил 22, из них 21 верно

  доля совпадений  = 194 / 200 = 97%
  precision        = 21 / 22   = 95%
  recall           = 21 / 26   = 81%   (95% интервал: 62-91%)

На этой выборке судья пропустил пять опасных ответов из 26. Интервал честно показывает и другое: 26 нарушений — мало, чтобы говорить о судье вообще, выборку надо расширять.

Для жёсткого инварианта такой recall неприемлем в любом случае: либо дорабатывать критерий, либо закрывать нарушение проверкой в коде, либо отправлять пограничные случаи человеку.

И главное: судья — не оракул, а ещё один измерительный прибор, который сам нуждается в проверке.

Если сменилась модель‑судья или её промпт, старые и новые результаты нельзя сравнивать напрямую: сначала прибор перепроверяют на контрольной разметке.

Ошибка 5. Критерии ищут и проверяют на одних и тех же данных

Симптом. Команда разобрала сотню реальных ответов, выписала типы брака, собрала из них набор, подправила промпт — и получила 98%. Через месяц приходит новый класс ошибок, которого в наборе нет.

Почему возникает. Начинать с разбора реальных ответов правильно. Исследование «Who Validates the Validators?» (Shankar и соавторы, 2024) описывает дрейф критериев: чтобы оценивать ответы, нужны критерии, но сами критерии формируются, когда смотришь на ответы. Ловушка в том, что на этих же данных потом и проверяют. Промпт подгоняется под известные ошибки, и измеритель переобучается вместе с системой.

Как‑то раз в команде мы потратили неделю на настройку метрики «полноты ответа» и довели её почти до ста. Ручной разбор свежих диалогов потом показал, что главная жалоба была другой: ассистент отвечал на вопрос, который не задавали. Метрика была честной, но построенной на старой выборке.

На Рис. 2 показано, как я раскладываю данные, чтобы этого не происходило.

Рис. 2. Ошибки ищем на одной выборке, итог проверяем на другой
Рис. 2. Ошибки ищем на одной выборке, итог проверяем на другой

Главная мысль схемы: данные, на которых команда искала ошибки и правила промпт, не годятся для финальной оценки. Нужна отложенная выборка, которая не использовалась при разработке и настройке. В репозитории после правок промпта рабочий набор показывает 98%, а отложенная выборка — 85%. Разница в 13 пунктов — это подгонка, а не качество.

Как исправить. Развести роли данных: разведочная выборка для поиска ошибок, рабочий набор для правок, замороженный регрессионный набор для CI, holdout для приёмки. Разведку повторять после каждого заметного изменения: вместе с промптом меняются и ошибки.

Ошибка 6. Граница между моделью и кодом проведена не там

Симптом. Любое падение начинается с вопроса «это модель или наш код?», и ответ ищут часами. Или наоборот: всё вокруг модели считают детерминированным и не проверяют, какое действие модель выбрала.

Почему возникает. LLM‑функцию видят одним чёрным ящиком, а она состоит из частей разной природы. Сборка промпта, проверка схемы, проверка прав, исполнение вызова — обычный код. А выбор инструмента и его аргументов делает модель, и это часть её поведения, а не обвязки.

Чем грозит. Сломанный разбор JSON списывают на «модель капризничает», и баг в коде живёт месяцами. А неверное действие, выбранное моделью, не проверяется вовсе, потому что считается «обвязкой».

На Рис. 3 показано, где проходит граница.

Рис. 3. Зелёным — обычный код, жёлтым — поведение модели, синим — поиск со своими метриками
Рис. 3. Зелёным — обычный код, жёлтым — поведение модели, синим — поиск со своими метриками

Главная мысль схемы: текст и запрос на вызов инструмента — разное поведение модели с разными критериями, оба проверяются eval‑набором. А между запросом модели и действием стоит обычный код, который проверяется быстрыми тестами с заглушкой вместо модели. Действие не должно стать возможным только потому, что модель его выбрала. Eval оценивает, как часто модель ошибается, а детерминированная проверка не даёт ошибке превратиться в последствие.

Как исправить. Для кода между моделью и действием — тесты без живой модели.

# Тест обвязки с заглушкой вместо модели (Python, pytest)
def test_cancel_without_rights_is_blocked(monkeypatch):
    monkeypatch.setattr(llm_client, "complete",
                        lambda prompt: '{"tool": "cancel_order", "args": {"order_id": 123}}')
    result = assistant.handle("Где мой заказ 123?", user=guest_user)
    assert result.route == "operator"          # у гостя нет права отменять заказы
    assert not order_service.cancel_called

Для поиска документов одного примера «нужный документ в первых трёх» мало: на наборе запросов считают Recall@k и MRR по категориям. И помнят, что «модель не увидит документ вне top‑k» верно только там, где модель получает ровно top‑k этого поиска. С переписыванием запроса, гибридным поиском и переранжированием путь документа в контекст другой.

Ошибка 7. Фиксируют модель и промпт, а меняется всё остальное

Симптом. «Мы ничего не трогали», а качество ответов заметно изменилось.

Почему возникает. Команда честно зафиксировала версию модели и держит промпт в репозитории. Но результат LLM‑функции зависит от большего: параметров вызова, схемы инструментов, настроек поиска, модели эмбеддингов, снимка базы знаний, переранжирования, а ещё от модели и рубрики судьи. Вчера обновили базу знаний — сегодня другие ответы при той же модели и том же промпте.

Чем грозит. Регрессия выходит в прод тихо, её замечают по жалобам через неделю, и восстановить, какое изменение виновато, уже трудно.

Как исправить. Описывать окружение eval целиком и версионировать его как код:

# Окружение eval: всё, от чего зависит ответ (YAML)
model: provider/model-2026-08-15
params: {temperature: 0.2, max_tokens: 800}
system_prompt: prompts/support_v14.md
tool_schema: tools/schema_v6.json
retrieval: {index_snapshot: kb-2026-10-01, embedder: embed-v3, top_k: 8, reranker: rr-v2}
judge: {model: provider/judge-2026-07-01, rubric: rubrics/policy_v4.md}

Гонять полный набор на каждое обновление базы знаний дорого, поэтому изменение компонента запускает свой гейт:

промпт            -> eval генерации
модель            -> полный eval
схема инструментов -> eval вызовов инструментов
поиск, эмбеддинги -> eval поиска + сквозной eval
база знаний       -> groundedness и соблюдение политики
судья             -> перекалибровка на контрольной разметке

Сводная таблица

Ошибка

Признак

Что проверить у себя

Форма вместо решения

Тесты на подстроки или точный текст

Где принимается бизнес‑решение и проверяется ли оно кодом

Жёсткое меряют процентом

Общий порог на всё

Какие требования не допускают ни одного нарушения и чем они защищены в рантайме

Одна общая цифра

Балл растёт, жалобы нет

Есть ли метрики по категориям и уровням

Судья по доле совпадений

Высокое совпадение, брак в выборке

Считали ли precision и recall по опасному классу

Одни данные для поиска и проверки

Отличный результат на рабочем наборе

Есть ли holdout

Граница модели и кода

Часы на «модель или код»

Стоит ли проверка прав между выбором модели и действием

Не всё окружение зафиксировано

«Мы ничего не трогали»

Версионируются ли база знаний, поиск и судья

Чек‑лист на полчаса

  1. Найдите тесты, которые проверяют текст подстрокой или целиком, и спросите: какое решение здесь на самом деле проверяется и кто его принимает.

  2. Выпишите требования, которые не допускают ни одного нарушения: уберите у них процентный порог и проверьте, что в приложении есть защита помимо eval.

  3. Разложите текущий общий балл по категориям вопросов.

  4. Если используете LLM‑судью, посчитайте его recall по опасному классу на ручной разметке.

  5. Проверьте, есть ли выборка, которая не использовалась при правке промпта.

  6. Найдите, где модель выбирает инструмент, и что стоит между этим выбором и действием.

  7. Перечислите всё, от чего зависит ответ, и проверьте, что каждое из этого версионируется.

Вывод

Все семь ошибок — про один навык: умение строить измеритель, которому можно верить. LLM‑функция требует двух классов проверок.

Вероятностные метрики качества — по категориям, с повторами, с проверенным судьёй и отложенной выборкой. И жёсткие инварианты, которые eval ищет, а код приложения гарантирует.

Если смешать эти классы, eval становится зелёным не потому, что система хорошая, а потому, что прибор настроен её не замечать.

Я бы предпочёл начинать со второго пункта чек‑листа: выписать жёсткие инварианты, убрать у них процентный порог и проверить, что их держит код, а не только тест. Это небольшая инженерная задача по сравнению с ценой разбора первого инцидента.

А как у вас: кто в команде отвечает за eval AI‑функций — QA, разработчики или ML‑инженеры? И есть ли у вас требования, которые проверяются с нулевым допуском?

Буду рад обсудить в комментариях.

Ссылка на мой репозиторий с экспериментом

Начать стоит с expected.txt: там все четыре отчёта, на которые опирается статья. Как они считаются, видно в eval.py, исходные ответы и разметка лежат в data/runs.jsonl и data/judge_sample.jsonl, а гейт с нулевым допуском запускается командой python3 eval.py --strict.

ИСТОЧНИКИ

Проверка качества LLM‑систем не заканчивается на успешном прохождении eval‑набора. Важно понимать, какие ошибки скрываются за метриками и как отслеживать поведение моделей на практике.

Эти и смежные вопросы можно изучить на бесплатных открытых уроках OTUS:

  • 15 октября, 20:00. «ИИ в управлении рисками: Предиктивная аналитика для тестировщика». Записаться

  • 27 октября, 18:00. «Langfuse в AgentOps: наблюдаемость и оценка LLM‑агентов». Записаться

Больше вебинаров по тестированию, разработке и ИИ — в октябрьском дайджесте OTUS.