Практика оценки LLM-систем
Как превратить замечания LLM-судьи в детерминированные проверки
Если дорогая модель раз за разом находит отсутствующую цитату, сломанный JSON или утверждение, которого нет в источнике, это уже не задача для модели. Это спецификация будущего программного барьера.
Зачем выносить повторяющиеся ошибки из модельной оценки
LLM-судья полезен там, где требуется оценить смысл, полноту, стиль или качество рассуждения. Но он плохо подходит для инвариантов, которые можно выразить однозначно: существует ли процитированный фрагмент, соответствует ли объект схеме, встречается ли заявленный факт в разрешённом источнике.
Оставлять такие проверки модели невыгодно по трём причинам:
- каждый прогон расходует токены и увеличивает задержку;
- одинаковые ответы могут получить разные оценки;
- текстовое замечание трудно связать с конкретным нарушенным условием.
Цель не в том, чтобы полностью отказаться от судьи. Надёжная схема ставит дешёвые детерминированные барьеры раньше него. Судья получает только ответы, прошедшие синтаксические и проверяемые содержательные ограничения.
Что именно можно детерминировать
Хороший кандидат на перенос формулируется как функция с бинарным или конечным результатом:
check(answer, source, contract) -> pass | fail(reason, location)
В этой статье реализованы три класса проверок:
- Существование цитат: каждый процитированный фрагмент действительно присутствует в исходном материале после заранее определённой нормализации.
- Валидность структуры: ответ разбирается как JSON и соответствует зафиксированной JSON Schema.
- Соответствие источнику: проверяемые утверждения содержат ссылки на разрешённые фрагменты, а опорный текст этих фрагментов покрывает ключевые термины утверждения.
Третий барьер намеренно не доказывает истинность. Он выявляет отсутствие опоры и грубое рассогласование. Семантические противоречия, числа с изменённым смыслом и сложные выводы всё ещё могут требовать модели-судьи или специализированного анализатора.
Шаг 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
Сохраняйте идентификатор правила, расположение ошибки и версию контракта. Не сохраняйте только строку вроде «валидация не пройдена»: она непригодна для агрегации и отладки.
Как проверить результат
Для проверки механизма нужны не выдуманные метрики, а контролируемые изменения входа. Выполните четыре локальных прогона:
- Передайте корректный пример. Ожидаются
"passed": trueи код завершения0. - Замените
source_idна несуществующий. ОжидаетсяSOURCE_ID_UNKNOWNилиCLAIM_SOURCE_UNKNOWN. - Измените одно содержательное слово внутри цитаты. Ожидается
CITATION_NOT_FOUND. - Удалите обязательное поле
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 в примере не является универсальной рекомендацией. Лексическое покрытие зависит от языка, длины утверждений, морфологии и допустимого перефразирования. Настраивайте его на размеченной выборке из собственных входов:
- соберите реальные замечания судьи без персональных данных и секретов;
- разделите их на случаи «нет опоры» и «опора существует»;
- посчитайте результат правила на нескольких порогах;
- выберите допустимый баланс ложных отказов и пропусков;
- зафиксируйте выбор, версию выборки и назначение правила.
Если ложный отказ дорог, используйте лексическое правило как маршрутизатор: пограничные случаи отправляйте судье, а не отклоняйте окончательно. Например, оценки ниже нижней границы можно отклонять, выше верхней — пропускать, а промежуточную зону — оценивать моделью.
Типовые ошибки реализации
Слишком агрессивная нормализация цитат
Удаление пунктуации, чисел или отрицаний повышает число ложных совпадений. Нормализация должна устранять только различия представления: Unicode-форму, регистр и повторяющиеся пробелы. Любое более сильное преобразование следует оформлять отдельным правилом.
Проверка цитаты по всему корпусу
Цитата может существовать, но не в указанном документе. Ищите её внутри конкретного source_id, иначе неверная атрибуция пройдёт барьер.
Смешение структуры и содержания
JSON Schema проверяет форму, но не подтверждает факты. Поле с правильным типом может содержать выдуманное значение. Не называйте структурную валидацию проверкой достоверности.
Нестабильный токенизатор
Если правило зависит от сторонней морфологии или обновляемого списка стоп-слов, его результат может измениться после обновления окружения. Версионируйте зависимости и конфигурацию так же, как схему.
Один общий код ошибки
Разные причины требуют разных действий. Неизвестный источник указывает на нарушение ссылочной целостности, отсутствующая цитата — на неверное копирование, низкое покрытие — на возможное перефразирование или галлюцинацию.
Автоматический перенос каждого замечания
Фразы судьи вроде «ответ недостаточно ясен» или «пропущен важный аспект» нельзя честно свести к регулярному выражению. Попытка сделать это создаёт хрупкий суррогат метрики, который оптимизируется генератором, но не отражает качество.
Ограничения
- Подстрочный поиск не обнаруживает корректно перефразированную цитату и не должен использоваться, если формат допускает пересказ вместо дословного цитирования.
- Лексическое пересечение не понимает отрицание, причинность, единицы измерения и временной контекст.
- Наличие слов источника не означает, что утверждение логически следует из источника.
- JSON Schema не проверяет доступность внешнего документа и актуальность его содержимого.
- Короткие утверждения дают нестабильную долю пересечения; для них полезнее отдельные правила или ручная маршрутизация.
- Тексты с разными языками, транслитерацией и богатой морфологией требуют адаптированного анализа.
Поэтому детерминированные проверки следует рассматривать как барьеры для известных классов отказа, а не как универсальную оценку ответа.
Рабочая граница между кодом и судьёй
Оставляйте в коде то, для чего можно указать точный вход, стабильное преобразование и однозначное ожидаемое состояние. Оставляйте модели задачи, где требуется интерпретация: достаточность аргументации, качество синтеза нескольких источников, существенность пропуска или естественность формулировки.
Практический цикл выглядит так:
- судья обнаруживает новый класс ошибки;
- команда проверяет, можно ли выразить его как инвариант;
- инвариант получает идентификатор, контракт и диагностический вывод;
- правило прогоняется на сохранённых примерах;
- после калибровки оно перемещается перед судьёй;
- статистика отказов показывает, какие ошибки ещё остаются модельными.
Так модель-судья постепенно освобождается от механической работы, но продолжает оценивать то, ради чего она действительно нужна: смысл.
Контрольный список внедрения
- Каждое правило связано с повторяющимся наблюдением, а не с гипотетической ошибкой.
- У правила есть стабильный
rule_id. - Формат ответа закреплён схемой и версией.
- Нормализация текста описана явно.
- Цитата проверяется внутри указанного источника.
- Отчёт содержит расположение и причину нарушения.
- Отказ завершает шаг ненулевым кодом.
- Пограничные семантические случаи направляются модели, а не маскируются эвристикой.
- Пороговые значения откалиброваны на собственных размеченных данных.
- Версии кода, схемы и конфигурации сохраняются вместе с результатом.