Практика оценки LLM-систем

Как превратить замечания LLM-судьи в детерминированные проверки

Если дорогая модель раз за разом находит отсутствующую цитату, сломанный JSON или утверждение, которого нет в источнике, это уже не задача для модели. Это спецификация будущего программного барьера.

Продвинутый уровень До 12 минут Результат: три быстрых проверочных барьера

Зачем выносить повторяющиеся ошибки из модельной оценки

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

Оставлять такие проверки модели невыгодно по трём причинам:

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

Цель не в том, чтобы полностью отказаться от судьи. Надёжная схема ставит дешёвые детерминированные барьеры раньше него. Судья получает только ответы, прошедшие синтаксические и проверяемые содержательные ограничения.

Что именно можно детерминировать

Хороший кандидат на перенос формулируется как функция с бинарным или конечным результатом:

check(answer, source, contract) -> pass | fail(reason, location)

В этой статье реализованы три класса проверок:

  1. Существование цитат: каждый процитированный фрагмент действительно присутствует в исходном материале после заранее определённой нормализации.
  2. Валидность структуры: ответ разбирается как JSON и соответствует зафиксированной JSON Schema.
  3. Соответствие источнику: проверяемые утверждения содержат ссылки на разрешённые фрагменты, а опорный текст этих фрагментов покрывает ключевые термины утверждения.

Третий барьер намеренно не доказывает истинность. Он выявляет отсутствие опоры и грубое рассогласование. Семантические противоречия, числа с изменённым смыслом и сложные выводы всё ещё могут требовать модели-судьи или специализированного анализатора.

Шаг 1. Превратите замечания судьи в каталог правил

Не начинайте с кода. Сначала сгруппируйте уже полученные замечания по наблюдаемой причине. Для каждого повторяющегося класса заполните короткую карточку:

rule_id: CITATION_NOT_FOUND
input: answer.citations[*].quote, source.blocks[*].text
normalization: Unicode NFKC, пробелы, регистр
pass: нормализованная цитата является подстрокой нормализованного блока
failure: номер цитаты и source_id
judge_needed_after_failure: no

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

Сохраняйте исходное замечание судьи рядом с идентификатором правила, но не используйте свободный текст замечания как исполняемую спецификацию. Текст меняется; идентификатор и контракт должны быть стабильными.

Шаг 2. Зафиксируйте контракт ответа

Ниже приведён учебный пример формата. Это не описание реального клиента или производственной системы.

{
  "summary": "Краткий ответ",
  "claims": [
    {
      "text": "Проверяемое утверждение",
      "source_ids": ["s1"]
    }
  ],
  "citations": [
    {
      "source_id": "s1",
      "quote": "Дословный фрагмент источника"
    }
  ]
}

Для него можно использовать следующую схему:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": ["summary", "claims", "citations"],
  "properties": {
    "summary": {
      "type": "string",
      "minLength": 1
    },
    "claims": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["text", "source_ids"],
        "properties": {
          "text": {
            "type": "string",
            "minLength": 1
          },
          "source_ids": {
            "type": "array",
            "minItems": 1,
            "uniqueItems": true,
            "items": {
              "type": "string",
              "minLength": 1
            }
          }
        }
      }
    },
    "citations": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["source_id", "quote"],
        "properties": {
          "source_id": {
            "type": "string",
            "minLength": 1
          },
          "quote": {
            "type": "string",
            "minLength": 8
          }
        }
      }
    }
  }
}

additionalProperties: false важен: без него опечатка в имени поля может тихо пройти валидацию. Версию схемы следует хранить вместе с результатом генерации. Иначе после обновления контракта старые ответы начнут падать без понятной причины.

Шаг 3. Реализуйте единый проверяющий модуль

Пример ниже использует Python и пакет jsonschema. Код рассчитан на локальные JSON-файлы и ничего не отправляет по сети.

from __future__ import annotations

import json
import re
import sys
import unicodedata
from pathlib import Path
from typing import Any

from jsonschema import Draft202012Validator


WORD_RE = re.compile(r"[^\W_]+", re.UNICODE)


def normalize(text: str) -> str:
    text = unicodedata.normalize("NFKC", text)
    return " ".join(text.casefold().split())


def words(text: str) -> set[str]:
    return {
        token.casefold()
        for token in WORD_RE.findall(text)
        if len(token) >= 4
    }


def load_json(path: Path) -> Any:
    with path.open("r", encoding="utf-8") as stream:
        return json.load(stream)


def check_schema(answer: Any, schema: dict[str, Any]) -> list[dict]:
    validator = Draft202012Validator(schema)
    failures = []

    for error in sorted(validator.iter_errors(answer), key=lambda item: list(item.path)):
        location = "$"
        if error.path:
            location += "." + ".".join(map(str, error.path))
        failures.append({
            "rule_id": "STRUCTURE_INVALID",
            "location": location,
            "message": error.message,
        })

    return failures


def index_sources(source: dict[str, Any]) -> dict[str, str]:
    result = {}
    for block in source.get("blocks", []):
        source_id = block.get("id")
        text = block.get("text")
        if isinstance(source_id, str) and isinstance(text, str):
            result[source_id] = text
    return result


def check_citations(answer: dict, source_index: dict[str, str]) -> list[dict]:
    failures = []

    for index, citation in enumerate(answer.get("citations", [])):
        source_id = citation["source_id"]
        quote = citation["quote"]
        source_text = source_index.get(source_id)

        if source_text is None:
            failures.append({
                "rule_id": "SOURCE_ID_UNKNOWN",
                "location": f"$.citations.{index}.source_id",
                "message": f"Unknown source_id: {source_id}",
            })
            continue

        if normalize(quote) not in normalize(source_text):
            failures.append({
                "rule_id": "CITATION_NOT_FOUND",
                "location": f"$.citations.{index}.quote",
                "message": f"Quote is absent from source {source_id}",
            })

    return failures


def check_claim_support(
    answer: dict,
    source_index: dict[str, str],
    minimum_overlap: float = 0.55,
) -> list[dict]:
    failures = []

    for index, claim in enumerate(answer.get("claims", [])):
        claim_words = words(claim["text"])
        source_ids = claim["source_ids"]

        known_texts = [
            source_index[source_id]
            for source_id in source_ids
            if source_id in source_index
        ]

        unknown_ids = [
            source_id
            for source_id in source_ids
            if source_id not in source_index
        ]

        if unknown_ids:
            failures.append({
                "rule_id": "CLAIM_SOURCE_UNKNOWN",
                "location": f"$.claims.{index}.source_ids",
                "message": f"Unknown source ids: {unknown_ids}",
            })
            continue

        if not claim_words:
            failures.append({
                "rule_id": "CLAIM_NOT_CHECKABLE",
                "location": f"$.claims.{index}.text",
                "message": "Claim has no checkable terms",
            })
            continue

        support_words = words(" ".join(known_texts))
        overlap = len(claim_words & support_words) / len(claim_words)

        if overlap < minimum_overlap:
            failures.append({
                "rule_id": "CLAIM_LOW_LEXICAL_SUPPORT",
                "location": f"$.claims.{index}",
                "message": (
                    f"Lexical overlap {overlap:.2f} is below "
                    f"{minimum_overlap:.2f}"
                ),
            })

    return failures


def main() -> int:
    if len(sys.argv) != 4:
        print(
            "usage: python validate_answer.py "
            "answer.json source.json answer.schema.json",
            file=sys.stderr,
        )
        return 2

    answer = load_json(Path(sys.argv[1]))
    source = load_json(Path(sys.argv[2]))
    schema = load_json(Path(sys.argv[3]))

    failures = check_schema(answer, schema)

    if not failures:
        source_index = index_sources(source)
        failures.extend(check_citations(answer, source_index))
        failures.extend(check_claim_support(answer, source_index))

    report = {
        "passed": not failures,
        "failure_count": len(failures),
        "failures": failures,
    }
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0 if not failures else 1


if __name__ == "__main__":
    raise SystemExit(main())

Порядок проверок существенен. Сначала валидируется структура, и только затем код обращается к обязательным полям. Это предотвращает вторичные исключения, маскирующие настоящую ошибку контракта.

Шаг 4. Подготовьте воспроизводимый локальный прогон

Зафиксируйте версию зависимости в отдельном виртуальном окружении. Команды ниже не удаляют файлы, не используют секреты и не обращаются к пользовательским каталогам за пределами текущего проекта:

python3 -m venv .venv
.venv/bin/python -m pip install "jsonschema==4.23.0"
.venv/bin/python validate_answer.py \
  fixtures/answer.json \
  fixtures/source.json \
  answer.schema.json

Указанная версия здесь является частью воспроизводимого примера, а не заявлением о самой новой версии пакета. В рабочем репозитории зависимость должна проходить принятый в команде процесс обновления и проверки.

Пример исходного материала:

{
  "blocks": [
    {
      "id": "s1",
      "text": "Повторяющиеся структурные ошибки следует проверять до запуска модели-судьи."
    }
  ]
}

Пример корректного ответа:

{
  "summary": "Структурные проверки выполняются раньше модельной оценки.",
  "claims": [
    {
      "text": "Структурные ошибки проверяются до запуска модели-судьи.",
      "source_ids": ["s1"]
    }
  ],
  "citations": [
    {
      "source_id": "s1",
      "quote": "структурные ошибки следует проверять до запуска модели-судьи"
    }
  ]
}

Шаг 5. Встройте барьеры перед моделью-судьёй

Проверяющий модуль должен завершаться ненулевым кодом при нарушении правил. Тогда его можно поставить отдельным шагом в локальный сценарий, CI или очередь обработки:

set -eu

.venv/bin/python validate_answer.py \
  artifacts/answer.json \
  artifacts/source.json \
  answer.schema.json

.venv/bin/python run_model_judge.py \
  artifacts/answer.json \
  artifacts/source.json

При ошибке первая команда остановит сценарий, и вызова судьи не будет. Имя run_model_judge.py обозначает границу интеграции в примере; сама реализация модельного вызова здесь намеренно не приводится.

В сервисной архитектуре используйте ту же последовательность состояний:

generated
  -> structure_valid
  -> citations_valid
  -> support_gate_passed
  -> judge_pending
  -> accepted | rejected

Сохраняйте идентификатор правила, расположение ошибки и версию контракта. Не сохраняйте только строку вроде «валидация не пройдена»: она непригодна для агрегации и отладки.

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

Для проверки механизма нужны не выдуманные метрики, а контролируемые изменения входа. Выполните четыре локальных прогона:

  1. Передайте корректный пример. Ожидаются "passed": true и код завершения 0.
  2. Замените source_id на несуществующий. Ожидается SOURCE_ID_UNKNOWN или CLAIM_SOURCE_UNKNOWN.
  3. Измените одно содержательное слово внутри цитаты. Ожидается CITATION_NOT_FOUND.
  4. Удалите обязательное поле claims. Ожидается STRUCTURE_INVALID, а содержательные проверки не должны запускаться.

Код завершения можно увидеть безопасной командой:

.venv/bin/python validate_answer.py \
  fixtures/answer.json \
  fixtures/source.json \
  answer.schema.json
status=$?
printf 'exit_code=%s\n' "$status"

В CI дополнительно проверьте, что шаг модельного судьи не стартует после отказа детерминированного барьера. Это свойство конвейера, а не только валидатора.

Как выбрать порог соответствия источнику

Порог 0.55 в примере не является универсальной рекомендацией. Лексическое покрытие зависит от языка, длины утверждений, морфологии и допустимого перефразирования. Настраивайте его на размеченной выборке из собственных входов:

  1. соберите реальные замечания судьи без персональных данных и секретов;
  2. разделите их на случаи «нет опоры» и «опора существует»;
  3. посчитайте результат правила на нескольких порогах;
  4. выберите допустимый баланс ложных отказов и пропусков;
  5. зафиксируйте выбор, версию выборки и назначение правила.

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

Типовые ошибки реализации

Слишком агрессивная нормализация цитат

Удаление пунктуации, чисел или отрицаний повышает число ложных совпадений. Нормализация должна устранять только различия представления: Unicode-форму, регистр и повторяющиеся пробелы. Любое более сильное преобразование следует оформлять отдельным правилом.

Проверка цитаты по всему корпусу

Цитата может существовать, но не в указанном документе. Ищите её внутри конкретного source_id, иначе неверная атрибуция пройдёт барьер.

Смешение структуры и содержания

JSON Schema проверяет форму, но не подтверждает факты. Поле с правильным типом может содержать выдуманное значение. Не называйте структурную валидацию проверкой достоверности.

Нестабильный токенизатор

Если правило зависит от сторонней морфологии или обновляемого списка стоп-слов, его результат может измениться после обновления окружения. Версионируйте зависимости и конфигурацию так же, как схему.

Один общий код ошибки

Разные причины требуют разных действий. Неизвестный источник указывает на нарушение ссылочной целостности, отсутствующая цитата — на неверное копирование, низкое покрытие — на возможное перефразирование или галлюцинацию.

Автоматический перенос каждого замечания

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

Ограничения

  • Подстрочный поиск не обнаруживает корректно перефразированную цитату и не должен использоваться, если формат допускает пересказ вместо дословного цитирования.
  • Лексическое пересечение не понимает отрицание, причинность, единицы измерения и временной контекст.
  • Наличие слов источника не означает, что утверждение логически следует из источника.
  • JSON Schema не проверяет доступность внешнего документа и актуальность его содержимого.
  • Короткие утверждения дают нестабильную долю пересечения; для них полезнее отдельные правила или ручная маршрутизация.
  • Тексты с разными языками, транслитерацией и богатой морфологией требуют адаптированного анализа.

Поэтому детерминированные проверки следует рассматривать как барьеры для известных классов отказа, а не как универсальную оценку ответа.

Рабочая граница между кодом и судьёй

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

Практический цикл выглядит так:

  1. судья обнаруживает новый класс ошибки;
  2. команда проверяет, можно ли выразить его как инвариант;
  3. инвариант получает идентификатор, контракт и диагностический вывод;
  4. правило прогоняется на сохранённых примерах;
  5. после калибровки оно перемещается перед судьёй;
  6. статистика отказов показывает, какие ошибки ещё остаются модельными.

Так модель-судья постепенно освобождается от механической работы, но продолжает оценивать то, ради чего она действительно нужна: смысл.

Контрольный список внедрения

  • Каждое правило связано с повторяющимся наблюдением, а не с гипотетической ошибкой.
  • У правила есть стабильный rule_id.
  • Формат ответа закреплён схемой и версией.
  • Нормализация текста описана явно.
  • Цитата проверяется внутри указанного источника.
  • Отчёт содержит расположение и причину нарушения.
  • Отказ завершает шаг ненулевым кодом.
  • Пограничные семантические случаи направляются модели, а не маскируются эвристикой.
  • Пороговые значения откалиброваны на собственных размеченных данных.
  • Версии кода, схемы и конфигурации сохраняются вместе с результатом.