Практическое руководство
Как сравнивать локальные LLM по надёжности структурированного вывода
Обычный текстовый бенчмарк показывает, насколько убедительно модель отвечает на вопросы, но почти ничего не говорит о том, сможет ли она сто раз подряд вернуть JSON, соответствующий реальной бизнес-схеме. Ниже — методика, которая измеряет именно эту надёжность.
Что именно мы проверяем
Большая языковая модель, LLM в прикладном конвейере часто работает не как собеседник, а как преобразователь неструктурированного текста в запись для CRM, очереди задач или внутреннего API. В таком режиме красивый ответ бесполезен, если парсер не может его прочитать, обязательное поле потеряно или число превращено в строку.
Полезно разделить качество на три независимых уровня:
- Синтаксическая валидность: ответ целиком разбирается как JSON.
- Соответствие схеме: присутствуют обязательные поля, типы и допустимые значения верны, лишние поля запрещены.
- Смысловая корректность: значения действительно следуют из входного текста.
Эта статья сосредоточена на первых двух уровнях и стоимости автоматического восстановления. Смысловую корректность следует оценивать отдельно по заранее размеченному набору. Валидный JSON не гарантирует истинность данных.
1. Зафиксируйте бизнес-схему
Не упрощайте схему до пары строк ради модели. Используйте уменьшенную, но репрезентативную версию будущего контракта: вложенные объекты, обязательные поля, перечисления, nullable-значения и запрет неожиданных ключей.
Следующий фрагмент — учебный пример заявки в службу поддержки, а не описание реальной компании:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["request_id", "category", "priority", "customer", "summary"],
"properties": {
"request_id": { "type": "string", "minLength": 1 },
"category": {
"type": "string",
"enum": ["billing", "access", "delivery", "other"]
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"customer": {
"type": "object",
"additionalProperties": false,
"required": ["name", "email"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"email": { "type": ["string", "null"], "format": "email" }
}
},
"summary": { "type": "string", "minLength": 1 },
"amount": { "type": ["number", "null"], "minimum": 0 }
}
}
Храните схему в системе контроля версий. Изменение схемы означает новую версию эксперимента: результаты разных контрактов напрямую несопоставимы.
2. Соберите набор входов без утечки данных
Набор должен покрывать не только удобные случаи. Добавьте короткие и длинные сообщения, пропущенные значения, неоднозначные формулировки, переносы строк, кавычки, Unicode, числа с разными разделителями и текст, похожий на инструкцию для модели.
Практический минимум — выделить категории случаев заранее и дать каждой категории стабильный идентификатор. Например:
{"case_id":"basic-001","text":"Пример: Анна не может войти в кабинет. Email не указан."}
{"case_id":"quotes-001","text":"Пример: клиент пишет: «Показывает ошибку \"Доступ закрыт\"»."}
{"case_id":"missing-001","text":"Пример: требуется связаться с клиентом, но имя и адрес отсутствуют."}
Это синтетические примеры, а не результаты теста. Для рабочего набора удалите персональные данные и секреты либо создайте синтетические записи с теми же структурными особенностями. Не отправляйте в модель пароли, токены, ключи API и содержимое закрытых обращений.
Заморозьте файл входов и запишите его контрольную сумму:
sha256sum cases.jsonl schema.json prompt.txt > checksums.txt
Команда только читает указанные файлы и создаёт локальный список контрольных сумм. Перед запуском убедитесь, что имена файлов соответствуют вашей рабочей папке.
3. Сделайте условия одинаковыми
Для каждой модели зафиксируйте:
- точный идентификатор модели и квантования;
- версию сервера вывода;
- шаблон чата и системную инструкцию;
- temperature, seed, лимит токенов и размер контекста;
- режим ограничения грамматикой или JSON Schema;
- оборудование, число потоков и параметры пакетной обработки.
Сравнивайте два режима отдельно: обычная генерация по инструкции и constrained decoding, если сервер и модель его поддерживают. Второй режим может почти устранить синтаксические ошибки, но не гарантирует заполнение полей правильными значениями.
Пример конфигурации эксперимента:
{
"experiment_id": "structured-output-v1",
"temperature": 0,
"seed": 42,
"max_output_tokens": 512,
"repetitions": 5,
"timeout_seconds": 60,
"schema_file": "schema.json",
"cases_file": "cases.jsonl",
"prompt_file": "prompt.txt"
}
Это шаблон, а не универсально оптимальные значения. Некоторые локальные серверы не обеспечивают детерминизм даже при фиксированном seed, поэтому повторения обязательны.
4. Используйте строгий промпт
Преобразуй входное сообщение в объект по предоставленной JSON Schema.
Правила:
1. Верни ровно один JSON-объект.
2. Не добавляй Markdown, комментарии и пояснения.
3. Не угадывай отсутствующие сведения.
4. Используй null только там, где это разрешено схемой.
5. Не добавляй поля, которых нет в схеме.
Вход:
{{INPUT}}
Схему передавайте одним и тем же способом всем моделям. Если одна модель получает нативный параметр schema, а другая видит схему только в промпте, это два разных режима и их нужно так и назвать в отчёте.
5. Сохраняйте сырой ответ до исправлений
На каждый запуск записывайте отдельную строку JSONL:
{
"model": "model-id",
"case_id": "basic-001",
"repetition": 1,
"raw_output": "{\"request_id\":\"basic-001\", ...}",
"latency_ms": 842,
"input_tokens": 173,
"output_tokens": 71,
"finish_reason": "stop"
}
Не перезаписывайте исходный ответ очищенной версией. Иначе невозможно будет проверить, сколько работы действительно потребовалось после генерации. Запускайте модели последовательно или чередуйте их в одинаковом порядке, если конкуренция за память и вычислительные ресурсы влияет на задержку.
6. Считайте метрики по ступеням
Валидность JSON
Доля ответов, которые стандартный парсер принимает без извлечения блока, удаления Markdown и замены кавычек:
json_valid_rate = json_valid_runs / all_runs
Соответствие схеме
Проверяйте распарсенный объект полноценным валидатором выбранного проекта JSON Schema. Отдельно считайте строгую долю успешных запусков и частоту ошибок по категориям: отсутствующее поле, неверный тип, неизвестное enum-значение, лишний ключ.
schema_valid_rate = schema_valid_runs / all_runs
Полнота
Для каждого ответа вычисляйте долю присутствующих обязательных путей. Вложенные поля учитывайте отдельно:
completeness =
present_required_paths / all_required_paths
Поле со значением null считается присутствующим, но его допустимость проверяет схема. Чтобы модель не получала высокий балл за выдуманные значения, дополнительно нужна смысловая разметка.
Стабильность
Повторите каждый случай несколько раз. Полезная метрика — доля случаев, прошедших схему во всех повторениях:
all_pass_rate =
cases_with_all_repetitions_valid / all_cases
Для производственного конвейера эта величина часто показательнее среднего успеха: один случайный сбой уже отправляет запись в обработчик ошибок.
7. Измерьте стоимость исправлений
Не объединяйте все способы восстановления в одно «починилось». Задайте фиксированную лестницу:
- 0 — без исправления: ответ сразу соответствует схеме.
- 1 — безопасное извлечение: найден единственный JSON-объект без изменения его значений.
- 2 — детерминированная нормализация: применено заранее разрешённое преобразование, например удаление окружающего Markdown-блока.
- 3 — повторная генерация: тот же запрос отправлен модели ещё раз с сообщением об ошибке.
- 4 — ручная обработка: автоматическое восстановление не помогло или могло изменить смысл.
Автоматические исправления должны быть белым списком, а не набором догадок. Нельзя молча превращать произвольную строку в число, подставлять обязательные значения или выбирать ближайший элемент enum: это создаёт валидную, но потенциально ложную запись.
Пример модели стоимости в условных единицах:
cost =
0 * direct_successes +
1 * extracted_outputs +
2 * normalized_outputs +
5 * retries +
50 * manual_reviews
Коэффициенты выше приведены только для иллюстрации. В своём проекте замените их измеренными затратами: машинным временем, числом дополнительных генераций и средним временем оператора. Публикуйте коэффициенты вместе с итоговым баллом.
8. Постройте честный итоговый отчёт
Не сводите всё к одному месту в рейтинге. Минимальная таблица должна выглядеть так:
| Модель и режим | JSON valid | Schema valid | Полнота | Все повторы успешны | Средняя стоимость исправления | p95 задержки |
|---|---|---|---|---|---|---|
| model-a / prompt-only | — | — | — | — | — | — |
| model-a / constrained | — | — | — | — | — | — |
Пустые значения намеренны: это шаблон отчёта, а не выдуманные результаты. Добавьте абсолютные числа рядом с процентами, размер набора и доверительные интервалы либо результаты повторных прогонов. Для редких ошибок одного процента без знаменателя недостаточно.
Проверка воспроизводимости
Перед публикацией результата выполните контрольный прогон и убедитесь, что:
- контрольные суммы схемы, промпта и набора не изменились;
- каждый
case_idимеет одинаковое число повторений для всех моделей; - нет пустых ответов, потерянных тайм-аутов и дублированных запусков;
- сырые ответы сохранены отдельно от исправленных;
- валидатор использует заявленную версию JSON Schema;
- ошибки сервера входят в знаменатель, а не исчезают из статистики;
- повторный запуск на части набора даёт сопоставимое распределение ошибок.
Полезно также вручную просмотреть случайную выборку успешных и неуспешных ответов. Это не заменяет автоматическую проверку, но помогает обнаружить ошибку в самой схеме или в расчёте путей полноты.
Типовые ошибки
- Извлечение текста между первой «{» и последней «}» считается успехом
- Это уже ремонт, а не валидный исходный ответ. Учитывайте его отдельной ступенью стоимости.
- Валидность JSON смешивается с соответствием схеме
{"status":"ok"}может быть корректным JSON и полностью нарушать бизнес-контракт.- Успешные ответы оцениваются, а тайм-ауты отбрасываются
- Тайм-аут — наблюдаемый отказ системы. Он должен оставаться в общем числе запусков.
- Моделям дают разные промпты после ручной «оптимизации»
- Так можно исследовать лучший рецепт для каждой модели, но нельзя утверждать, что сравнивался один и тот же режим. Публикуйте оба эксперимента отдельно.
- Используется один прогон
- Даже низкая temperature не гарантирует одинаковый результат на всех стеках локального вывода.
- Проверяется только удобный набор
- Добавьте случаи с отсутствующими данными, вложенными кавычками, длинным контекстом и конфликтующими формулировками.
- Ремонт меняет смысл незаметно
- Любое преобразование значений требует отдельной проверки. Безопаснее отправить запись на повтор или ручной разбор, чем получить правдоподобную ошибку.
Ограничения методики
Результат относится только к зафиксированной комбинации модели, квантования, сервера, шаблона чата, схемы, промпта и оборудования. Он не доказывает превосходство модели в рассуждении, работе с фактами или других задачах.
Строгая схема способна скрыть смысловые ошибки: генератор выберет допустимое enum-значение, хотя вход не даёт для этого оснований. Для полной оценки нужен размеченный эталон и метрики по значениям полей. Кроме того, синтетические входы безопаснее для публикации, но могут хуже отражать распределение реального трафика.
Наконец, constrained decoding переносит часть качества из модели в инфраструктуру. Это не недостаток: в бизнес-системе важна надёжность всего конвейера. Просто сравнивайте режимы явно и не приписывайте серверную гарантию одной модели.
Критерий выбора
Выбирайте не модель с самым высоким средним баллом, а конфигурацию, которая проходит ваш допустимый порог отказов при приемлемой задержке и стоимости восстановления. Хороший итог эксперимента — не абстрактный победитель, а проверяемое правило, например: «конфигурация допускается в пилот, если не менее заданной доли случаев проходит схему во всех повторениях, а ручной разбор остаётся ниже установленного лимита».
Порог задаётся владельцем процесса до просмотра результатов. Это уменьшает соблазн подогнать критерии под понравившуюся модель.