В одном из проектов я строил систему оценки ответов с двумя контурами. Первый был скучным и предсказуемым: нормализовать результат и сравнить его с эталоном. Второй использовал LLM как судью и оценивал смысл ответа целиком.
Каппа Коэна между этими двумя проверками оказалась почти нулевой. Первая реакция была рефлекторной: менять модель, крутить промпт, добавлять размеченные примеры. В общем, нормальный способ потратить пару дней до того, как открыть таблицу расхождений. Таблица быстро отрезвила. Детерминированный код умел проверять только компактную каноническую форму, а LLM читала весь текст и рассуждала о смысловой эквивалентности. Оба контура работали честно — они просто отвечали на разные вопросы. Вспомним, Каппа Коэна считается так:
где (observed agreement) — наблюдаемая доля совпадений вердиктов, а
(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. Дальше я буду называть его арбитром. Это не обязательно парсер регулярных выражений: арбитром может быть любая проверка с однозначным контрактом, которой мы готовы доверить окончательное решение.
В системе три роли:
-
Детерминированный арбитр принимает или отклоняет всё, что умеет разобрать.
-
LLM-судья получает только неразобранный текст и пытается извлечь из него каноническое значение.
-
Ручная проверка принимает все случаи, где система не смогла безопасно сказать «да» или «нет».
Арбитр работает дважды:
-
До LLM. Стандартные числовые формы обрабатываются локально, без затрат на модель и без дополнительной задержки.
-
После LLM. Извлечённое моделью значение снова проходит ту же детерминированную проверку. Само заявление
equivalent: trueничего не решает.
В этой статье под повторной проверкой я понимаю именно сопоставление извлечённого значения с каноном. Это не «проверка по реальным данным» и не доказательство того, что модель правильно прочитала исходный текст. К этому ограничению я ещё вернусь.
У арбитра три свойства:
-
Детерминизм — всегда один и тот же результат на тех же данных.
-
Дешевизна — локальный код выполняется за доли миллисекунды.
-
Право вето — если арбитр выдал
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 |
Повторная проверка |
Итог |
|---|---|---|---|---|
|
|
совпадение |
не вызывается |
не нужна |
принять |
|
|
разобрано, но не совпало |
не вызывается |
не выполняется |
отклонить |
|
|
разобрать не удалось |
извлекает |
совпадение |
принять |
|
|
разобрать не удалось |
извлекает |
не совпало |
ручная проверка |
|
|
разобрать не удалось |
отказывается подтверждать |
не выполняется |
ручная проверка |
Ниже — схема ядра 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/