Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
В этой статье расскажу, почему eval‑набор для LLM‑функции бывает зелёным не потому, что функция хорошая, а потому, что плохо устроен сам измеритель, и как выстроить тестирование LLM‑функций так, чтобы его результатам можно было верить.
Знакомая картина.
В продукт добавили AI‑ассистента поддержки. Тестировщик получил задачу «покрыть автотестами», собрал набор кейсов, подключил LLM‑судью, общий балл 96%, релиз прошёл.
Через неделю в чат приходит скриншот: ассистент уверенно пообещал клиенту возврат, которого нет в правилах компании.
Повторный запуск того же вопроса даёт правильный ответ, и баг закрывают как «не воспроизводится».
Проблема здесь не в модели и не в качестве работы конкретного тестировщика. LLM‑функцию проверили наивным подходом к эндпоинту:
-
Один запрос.
-
Один ожидаемый ответ.
-
Одна общая цифра.
Для функции с чётким детерминированным результатом такой тест часто достаточен. Для генеративного результата он проверяет слишком узкое свойство — совпадение одной конкретной формулировки.
Сразу о границах.
Под 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 показано, как я раскладываю данные, чтобы этого не происходило.
Главная мысль схемы: данные, на которых команда искала ошибки и правила промпт, не годятся для финальной оценки. Нужна отложенная выборка, которая не использовалась при разработке и настройке. В репозитории после правок промпта рабочий набор показывает 98%, а отложенная выборка — 85%. Разница в 13 пунктов — это подгонка, а не качество.
Как исправить. Развести роли данных: разведочная выборка для поиска ошибок, рабочий набор для правок, замороженный регрессионный набор для CI, holdout для приёмки. Разведку повторять после каждого заметного изменения: вместе с промптом меняются и ошибки.
Ошибка 6. Граница между моделью и кодом проведена не там
Симптом. Любое падение начинается с вопроса «это модель или наш код?», и ответ ищут часами. Или наоборот: всё вокруг модели считают детерминированным и не проверяют, какое действие модель выбрала.
Почему возникает. LLM‑функцию видят одним чёрным ящиком, а она состоит из частей разной природы. Сборка промпта, проверка схемы, проверка прав, исполнение вызова — обычный код. А выбор инструмента и его аргументов делает модель, и это часть её поведения, а не обвязки.
Чем грозит. Сломанный разбор JSON списывают на «модель капризничает», и баг в коде живёт месяцами. А неверное действие, выбранное моделью, не проверяется вовсе, потому что считается «обвязкой».
На Рис. 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 |
|
Граница модели и кода |
Часы на «модель или код» |
Стоит ли проверка прав между выбором модели и действием |
|
Не всё окружение зафиксировано |
«Мы ничего не трогали» |
Версионируются ли база знаний, поиск и судья |
Чек‑лист на полчаса
-
Найдите тесты, которые проверяют текст подстрокой или целиком, и спросите: какое решение здесь на самом деле проверяется и кто его принимает.
-
Выпишите требования, которые не допускают ни одного нарушения: уберите у них процентный порог и проверьте, что в приложении есть защита помимо eval.
-
Разложите текущий общий балл по категориям вопросов.
-
Если используете LLM‑судью, посчитайте его recall по опасному классу на ручной разметке.
-
Проверьте, есть ли выборка, которая не использовалась при правке промпта.
-
Найдите, где модель выбирает инструмент, и что стоит между этим выбором и действием.
-
Перечислите всё, от чего зависит ответ, и проверьте, что каждое из этого версионируется.
Вывод
Все семь ошибок — про один навык: умение строить измеритель, которому можно верить. LLM‑функция требует двух классов проверок.
Вероятностные метрики качества — по категориям, с повторами, с проверенным судьёй и отложенной выборкой. И жёсткие инварианты, которые eval ищет, а код приложения гарантирует.
Если смешать эти классы, eval становится зелёным не потому, что система хорошая, а потому, что прибор настроен её не замечать.
Я бы предпочёл начинать со второго пункта чек‑листа: выписать жёсткие инварианты, убрать у них процентный порог и проверить, что их держит код, а не только тест. Это небольшая инженерная задача по сравнению с ценой разбора первого инцидента.
А как у вас: кто в команде отвечает за eval AI‑функций — QA, разработчики или ML‑инженеры? И есть ли у вас требования, которые проверяются с нулевым допуском?
Буду рад обсудить в комментариях.
Начать стоит с expected.txt: там все четыре отчёта, на которые опирается статья. Как они считаются, видно в eval.py, исходные ответы и разметка лежат в data/runs.jsonl и data/judge_sample.jsonl, а гейт с нулевым допуском запускается командой python3 eval.py --strict.
ИСТОЧНИКИ
-
Thinking Machines Lab, Defeating Nondeterminism in LLM Inference, 10.09.2025
-
CBC News, Air Canada found liable for chatbot’s bad advice on plane tickets, 15.02.2024
-
Zheng et al., Judging LLM‑as‑a‑Judge with MT‑Bench and Chatbot Arena (2023)
-
Прощаев С., Оценка AI‑агентов: 7 ошибок, из‑за которых тесты врут (Хабр, 25.09.2026)

Проверка качества LLM‑систем не заканчивается на успешном прохождении eval‑набора. Важно понимать, какие ошибки скрываются за метриками и как отслеживать поведение моделей на практике.
Эти и смежные вопросы можно изучить на бесплатных открытых уроках OTUS:
-
15 октября, 20:00. «ИИ в управлении рисками: Предиктивная аналитика для тестировщика». Записаться
-
27 октября, 18:00. «Langfuse в AgentOps: наблюдаемость и оценка LLM‑агентов». Записаться
Больше вебинаров по тестированию, разработке и ИИ — в октябрьском дайджесте OTUS.
Автор: sproshchaev


