LLM-судье нельзя верить на слово: как построить надёжный гейт и проверить сами тесты. llm.. llm. llmops.. llm. llmops. python.. llm. llmops. python. архитектура систем.. llm. llmops. python. архитектура систем. валидация моделей.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект. каппа Коэна.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект. каппа Коэна. качество данных.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект. каппа Коэна. качество данных. Машинное обучение.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект. каппа Коэна. качество данных. Машинное обучение. Программирование.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект. каппа Коэна. качество данных. Машинное обучение. Программирование. тестирование.. llm. llmops. python. архитектура систем. валидация моделей. искусственный интеллект. каппа Коэна. качество данных. Машинное обучение. Программирование. тестирование. Тестирование IT-систем.

В одном из проектов я строил систему оценки ответов с двумя контурами. Первый был скучным и предсказуемым: нормализовать результат и сравнить его с эталоном. Второй использовал 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 sync
uv 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.

Автор: b_ernis

Источник