LLM-судье нельзя верить на слово: как построить надёжный гейт и проверить сами тесты

от автора

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

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

\kappa = \frac{p_o - p_e}{1 - p_e}

где p_o (observed agreement) — наблюдаемая доля совпадений вердиктов, а p_e (expected agreement) — ожидаемая доля совпадений при случайном угадывании с теми же частотами классов. Метрика полезная, но должностную инструкцию разметчиков она не читает.

Роли проверяющих, в моём случае, выглядели так:

Контур

На какой вопрос он отвечает

Детерминированная проверка

Совпадает ли нормализованное значение с каноном?

LLM-судья

Эквивалентен ли смысл свободного ответа канону?

Человек

Корректен ли ответ с учётом текста, контекста и правил домена?

Человека в этом исходном сравнении не было. Поэтому низкая каппа не доказывала, что LLM-судья плохо оценивает ответы. Она сообщала только одно: два контура расходятся. Кто из них прав и одинаковые ли у них вообще инструкции, метрика не знает.

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

Почему нельзя просто отдать решение LLM

Здесь и дальше весь код — из маленького открытого репозитория, в который я вынес механику этой статьи. Он полностью синтетический и запускается офлайн; подробнее о нём чуть ниже, когда дойдём до контрактов.

Пусть канонический ответ — 3/4. Формы 0.75, 75% и 6/8 можно привести к одной дроби детерминированно. Значение 0.7 тоже отлично парсится — и столь же детерминированно не совпадает с каноном. LLM здесь не нужна. Вся «магия» нормализации — примерно 30 строк стандартной библиотеки Python. Класс Fraction из коробки берёт на себя эквивалентность 6/8 == 3/4 == 0.75 и избавляет от классической головной боли с точностью вещественных чисел.

def normalize(raw: str | None) -> Fraction | None:    """Число из короткого ответа, или None, если числа там нет."""    if raw is None:        return None    text = raw.strip()    if not text:        return None    text = text.replace(",", ".")          # десятичная запятая    text = _WHITESPACE_RE.sub("", text)    # "3 / 4" -> "3/4"    is_percent = text.endswith("%")    if is_percent:        text = text[:-1]        if not text:            return None    try:        value = Fraction(text)    except (ValueError, ZeroDivisionError):        return None    return value / 100 if is_percent else value

Ключевое свойство функции — у неё три исхода, а не два. Fraction("три четверти") выбрасывает ValueError, и это не ошибка системы. Такой ответ получает статус unparseable: «детерминированно разобрать не удалось». Это единственная дверь, через которую в систему допускается LLM.

Распределяем полномочия

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

В системе три роли:

  1. Детерминированный арбитр принимает или отклоняет всё, что умеет разобрать.

  2. LLM-судья получает только неразобранный текст и пытается извлечь из него каноническое значение.

  3. Ручная проверка принимает все случаи, где система не смогла безопасно сказать «да» или «нет».

Арбитр работает дважды:

  • До LLM. Стандартные числовые формы обрабатываются локально, без затрат на модель и без дополнительной задержки.

  • После LLM. Извлечённое моделью значение снова проходит ту же детерминированную проверку. Само заявление equivalent: true ничего не решает.

В этой статье под повторной проверкой я понимаю именно сопоставление извлечённого значения с каноном. Это не «проверка по реальным данным» и не доказательство того, что модель правильно прочитала исходный текст. К этому ограничению я ещё вернусь.

У арбитра три свойства:

  1. Детерминизм — всегда один и тот же результат на тех же данных.

  2. Дешевизна — локальный код выполняется за доли миллисекунды.

  3. Право вето — если арбитр выдал reject, LLM не имеет права его оспорить.

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

def check_authority(raw_answer: str, canonical: str,                    numeric_tolerance: float = 0.0) -> AuthorityResult:    """Нормализовать `raw_answer` и сравнить с `canonical`.    Эта функция никогда не обращается к LLM-судье. Её вердикт финален в обе стороны:    совпадение — accept, распарсенное-но-другое число — reject, и только текст,    который вообще не нормализуется, остаётся открытым (status=unparseable)    для ветки LLM-судьи.    """    canon_value = normalize(canonical)    if canon_value is None:        raise ValueError(f"canonical answer {canonical!r} must itself be parseable")    value = normalize(raw_answer)    if value is None:        return AuthorityResult(status=AUTHORITY_UNPARSEABLE, value=None)    if values_match(value, canon_value, numeric_tolerance):        return AuthorityResult(status=AUTHORITY_ACCEPT, value=value)    return AuthorityResult(status=AUTHORITY_REJECT, value=value)

Мелочь, которая на самом деле не мелочь: raise ValueError, если сам эталон не парсится. Арбитр отказывается работать, если его собственная точка отсчёта кривая, — падает громко на старте, а не выдаёт тихий мусор на каждом случае.

Центральный инвариант системы

Инвариант — это правило, которое обязано оставаться истинным при любом входе. Здесь оно такое:

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

До кода полезно пройти все основные маршруты на одном примере:

Вход

Первый проход арбитра

Действие LLM

Повторная проверка

Итог

3/4

совпадение

не вызывается

не нужна

принять

0.7

разобрано, но не совпало

не вызывается

не выполняется

отклонить

три четверти

разобрать не удалось

извлекает 3/4

совпадение

принять

примерно 0.7

разобрать не удалось

извлекает 0.7

не совпало

ручная проверка

не знаю

разобрать не удалось

отказывается подтверждать

не выполняется

ручная проверка

Ниже — схема ядра run_case. Я сократил создание CaseResult, но оставил все ветки, которые влияют на решение:

def run_case(case, authority_cfg, judge_enabled, judge_adapter):    auth = check_authority(case.answer, authority_cfg.canonical,                           authority_cfg.numeric_tolerance)    if auth.status == AUTHORITY_ACCEPT:        return CaseResult(..., VERDICT_ACCEPT, ROUTE_AUTHORITY, None, None, ...)    if auth.status == AUTHORITY_REJECT:        return CaseResult(..., VERDICT_REJECT, ROUTE_AUTHORITY, None, None, ...)    # auth.status == unparseable: единственная зона, куда пускают LLM-судью.    if not judge_enabled or judge_adapter is None:        return CaseResult(..., VERDICT_MANUAL_REVIEW, ROUTE_NONE, None,                          REASON_JUDGE_DISABLED, ...)    try:        response = judge_adapter.evaluate(case.id)    except JudgeError:        return CaseResult(..., VERDICT_MANUAL_REVIEW, ROUTE_JUDGE, None,                          REASON_JUDGE_ERROR, ...)    if response.timeout:        return CaseResult(..., VERDICT_MANUAL_REVIEW, ROUTE_JUDGE, None,                          REASON_TIMEOUT, ...)    if not response.equivalent:        return CaseResult(..., VERDICT_MANUAL_REVIEW, ROUTE_JUDGE, None,                          REASON_UNPARSEABLE, ...)    # LLM заявила эквивалентность — перепроверяем извлечённое значение.    grounding = check_authority(response.extracted or "", authority_cfg.canonical,                                authority_cfg.numeric_tolerance)    if grounding.status == AUTHORITY_ACCEPT:        return CaseResult(..., VERDICT_ACCEPT, ROUTE_JUDGE, response.extracted,                          None, ...)    return CaseResult(..., VERDICT_MANUAL_REVIEW, ROUTE_JUDGE, response.extracted,                      REASON_UNGROUNDED_RESCUE, ...)

В полном коде CaseResult — неизменяемый класс данных с семью полями; здесь часть аргументов заменена многоточиями. Главное в другом: LLM появляется ровно в одной ветке — после auth.status == unparseable. Её заявление equivalent: true само по себе не возвращает accept; между ним и принятием стоит второй вызов check_authority. Суть не в поставщике модели, а в распределении полномочий: детерминированный код стоит и на входе, и на выходе, а LLM зажата между ними.

Контракт проверяет не только результат, но и путь

Пора представить репозиторий, из которого весь код статьи, как следует: grounded-judge-gate написан с нуля: в нём нет кода и данных из закрытого проекта. Домен полностью синтетический, а ответы судьи записаны в JSON-фикстуру. После установки всё запускается без сети и API-ключей.

Это принципиальное ограничение демонстрации: записанный адаптер не является настоящим LLM-судьёй. Он доказывает, что маршрутизация и повторная проверка работают, но ничего не говорит о поведении новой модели на новых данных.

Обычного expect: accept для такой проверки мало. Один и тот же вердикт можно получить правильным и неправильным маршрутом. Поэтому сценарий фиксирует три поля:

- id: verbal-form  answer: 'три четверти'  expect: { verdict: accept, route: judge, grounded: '3/4' }- id: illegal-rescue-trap  answer: 'примерно 0.7'  expect: { verdict: needs_manual_review, route: judge, grounded: '0.7' }

Во втором случае записанный ответ намеренно содержит equivalent: true. Это не опечатка, а ловушка: извлечённое значение 0.7 не проходит арбитра против 3/4, поэтому система обязана отправить случай на ручную проверку.

Враждебные фикстуры

В публичной демонстрации модель не вызывается: я сам записал ответы, которые имитируют опасное поведение LLM-судьи. Здесь ошибка не случайна, а фикстура специально пытается продавить неправильное принятие:

{  "verbal-form": { "equivalent": true, "extracted": "3/4" },  "verbal-decimal": { "equivalent": true, "extracted": "0.75" },  "illegal-rescue-trap": { "equivalent": true, "extracted": "0.7" },  "illegal-rescue-trap-2": { "equivalent": true, "extracted": "0.8" },  "illegal-rescue-trap-3": { "equivalent": true, "extracted": "1/2" },  "unparseable-no-rescue": { "equivalent": false, "extracted": null }}

Третья ловушка — моя любимая. Ответ точно не три четверти семантически означает ровно противоположное канону, а фикстура заявляет equivalent: true и извлекает 1/2. Система, которая верит такому ответу на слово, может принять ошибочный результат. Повторная проверка сравнивает 1/2 с 3/4 и отправляет случай человеку.

Прогон сценария — один CLI-вызов:

uv syncuv run judge-gate run scenarios/short_answer.yaml --report report.md

report.md — это не «зелёная галочка», а таблица маршрутов. Видно не только что решили, но и кто решил:

Cases: 15  |  Passed: 15/15  |  route=authority: 9  route=judge: 2  manual_review: 4| id                    | answer                        | verdict            | route     | grounded | reason            ||-----------------------|-------------------------------|--------------------|-----------|----------|-------------------|| exact-fraction        | 3/4                           | accept             | authority |          |                   || percent-form          | 75%                           | accept             | authority |          |                   || mismatch-decimal      | 0.7                           | reject             | authority |          |                   || verbal-form           | три четверти                  | accept             | judge     | 3/4      |                   || illegal-rescue-trap   | примерно 0.7                  | needs_manual_review| judge     | 0.7      | ungrounded_rescue || illegal-rescue-trap-3 | точно не три четверти         | needs_manual_review| judge     | 1/2      | ungrounded_rescue || unparseable-no-rescue | не знаю                       | needs_manual_review| judge     |          | unparseable       |

(таблица сокращена, полные 15 строк — в репозитории)

Сравните две последние строки: обе ушли на ручную проверку, но по разным причинам. В одном случае фикстура имитирует ошибочное утверждение судьи, в другом — отказ подтвердить эквивалентность. Для человека это разные задачи, поэтому reason — часть результата.

На синтетическом наборе сейчас 15 случаев: 9 проходят через арбитр, 2 принимаются после извлечения и повторной проверки, 4 уходят человеку. Контракт совпадает для всех 15. Любое расхождение по verdict, route или grounded даёт код возврата 1, поэтому проверку можно поставить обычным шагом в CI.

Проверку тоже пришлось проверить

Первая версия репозитория умела уверенно доказать чуть больше, чем проверяла. На предрелизной проверке всплыли три дефекта, и каждый оказался полезнее ещё одного абзаца в README.

Первый был в сравнении контракта. Проверка grounded была односторонней:

# былоif expected.grounded is not None and actual.grounded != expected.grounded:    return False# сталоif actual.grounded != expected.grounded:    return False

Если контракт ожидал grounded: null, неожиданно извлечённое значение не роняло тест. Формально зелёный CI, фактически результат уже нарушал контракт. После исправления null снова означает «значения быть не должно», а не «мне всё равно».

Второй баг был ещё ироничнее: одна из illegal-rescue-ловушек возвращала equivalent: false. То есть записанный судья даже не пытался протащить ответ, а тест торжественно подтверждал, что повторная проверка его остановила. Фикстуры пришлось сделать по-настоящему враждебными: equivalent: true плюс неверное extracted.

Третий дефект жил в калибровочном скрипте. Маргинальные частоты показывают, сколько раз каждый разметчик выбрал каждый класс независимо от второго разметчика. Если оба на всех объектах использовали единственную метку, получается p_e == 1, а формула каппы делит на ноль. Первая реализация возвращала 1.0; корректный ответ здесь — N/A, потому что метрика не определена. Забавно писать статью об осторожной интерпретации каппы и одновременно слишком уверенно интерпретировать её в собственном скрипте. Теперь этот случай закрыт отдельным тестом.

Исправление — один тернарный оператор и честный тип возврата float | None:

def cohens_kappa(pairs):    """pairs: список (judge_label, human_label). Возвращает (po, pe, kappa, confusion).    kappa is None при pe == 1.0: оба разметчика использовали одну и ту же    единственную метку на всех объектах, случайное согласие съедает всю шкалу,    и (po - pe) / (1 - pe) математически не определено — а не равно 1.0.    """    n = len(pairs)    confusion = {a: {b: 0 for b in LABELS} for a in LABELS}    for judge_label, human_label in pairs:        confusion[judge_label][human_label] += 1    po = sum(confusion[label][label] for label in LABELS) / n    judge_totals = {label: sum(confusion[label].values()) for label in LABELS}    human_totals = {label: sum(confusion[j][label] for j in LABELS) for label in LABELS}    pe = sum((judge_totals[l] / n) * (human_totals[l] / n) for l in LABELS)    kappa = (po - pe) / (1 - pe) if pe < 1.0 else None    return po, pe, kappa, confusion

Разница принципиальная. 1.0 в отчёте читается как «идеальное согласие, всё отлично»; N/A читается как «на этих данных метрика не работает, иди смотри сам». Первое — тихая ложь ровно того сорта, про который вся статья.

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

Как проверяется инвариант

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

def test_every_accept_passed_authority_directly_or_via_grounding():    scenario = load_scenario(SCENARIO_PATH)    results = run_scenario(scenario)    accepted = [r for r in results if r.verdict == VERDICT_ACCEPT]    assert accepted, "gold set should contain at least one accept"    for r in accepted:        assert r.route in ("authority", "judge")        if r.route == "judge":            assert r.grounded is not None

Строчка assert accepted здесь не для красоты. Без неё тест остаётся зелёным на пустом списке — то есть, на сломанном раннере, который не принял вообще ничего, и проходит идеально. Тест, который зеленеет, когда система мертва, — это и есть «тесты зеленеют» из заголовка.

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

Ручная проверка — не авария, а штатный исход

needs_manual_review легко принять за недоделанный accept/reject. Для меня это отдельный штатный маршрут. Он сохраняет причину: unparseable, judge_disabled, ungrounded_rescue, judge_error или timeout.

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

Повторная проверка тоже не волшебная кнопка

У схемы остаётся неприятное ограничение. Арбитр проверяет, что извлечённое моделью значение совпало с каноном. Он не доказывает, что LLM честно получила это значение из исходного текста.

Если на ответ не знаю модель сфабрикует extracted="3/4", повторный арбитр увидит совпадение и примет его. На этом слое фабрикация, случайно попавшая в канон, неотличима от правильного извлечения.

Поэтому метрика grounding invariant violations = 0 в демонстрации означает только, что раннер не пропустил обязательную повторную проверку. Это структурная самопроверка, а не доказательство безопасности. Для контроля верности исходному тексту нужен следующий слой: проверка связи extracted с исходным ответом, более сильная разметка или человек. В версии 0.1 такого слоя нет, и README говорит об этом прямо.

Есть ещё два ограничения. В репозитории используется записанный адаптер, а не настоящая LLM, и весь набор состоит из 15 синтетических случаев одного числового домена. Этого достаточно, чтобы воспроизвести маршруты, но недостаточно, чтобы заявлять о полном покрытии ошибок LLM-судьи.

Где схема уместна

Она полезна там, где после вероятностного шага остаётся форма, которую можно проверить детерминированно:

  • короткие числовые и формульные ответы;

  • извлечение структурированных полей из документов;

  • утверждения в агентном процессе, для которых есть исполняемый контракт;

  • процессы с высокой ценой ошибки, где ручная проверка — нормальная ветка.

Для эссе, вкусовой оценки и непроверяемых утверждений детерминированный арбитр просто не из чего построить. Там эта схема не заменяет человеческую разметку и не делает LLM объективным.

Что я вынес из этой истории

Низкая каппа не обязана означать плохую модель. Сначала стоит проверить, сравниваются ли одинаковые объекты, роли и инструкции. В моём случае метрика подсветила конфликт контрактов раньше, чем качество LLM-судьи.

Детерминированное знание не стоит отдавать вероятностному арбитру. LLM оказалась полезнее в ограниченной роли: извлечь кандидата, вернуть его в детерминированный контур и принять отказ системы сказать «да» или «нет» там, где она не уверена.

И последнее: контракт системы с LLM должен проверять не только финальный вердикт. Кто принял решение, что было извлечено и почему случай ушёл человеку — такие же части результата, как accept или reject.

Первоисточник по метрике: Jacob Cohen, A Coefficient of Agreement for Nominal Scales, 1960, https://doi.org/10.1177/001316446002000104.

ссылка на оригинал статьи https://habr.com/ru/articles/1062614/