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