Создание ДС с локальной LLM. Часть 2: Строгая структура данных (JSON Schema)
В первой части мы рассмотрели общую архитектуру решения для автоматической генерации Дополнительных Соглашений. Мы выяснили, что ключ к успеху — это не просто “поболтать” с моделью, а заставить её работать как часть жесткого алгоритма.
Сегодня мы углубимся в техническую реализацию этого “принуждения”. Как заставить творческую языковую модель (LLM) возвращать данные, которые можно безопасно вставить в базу данных или Word-документ, не опасаясь галлюцинаций или лишнего текста?
Ответ: JSON Schema и режим Structured Outputs.
Проблема: LLM любят поговорить
Обычный промпт: “Извлеки из договора номер и дату” часто приводит к ответу:
“Конечно! Я проанализировал текст. Номер договора — 123, а дата заключения — 1 января 2025 года.”
Для человека это понятно. Для кода — это катастрофа. Нам нужно писать регулярные выражения, чтобы вытащить данные из этого ответа. А если модель решит изменить формат ответа завтра? Весь пайплайн сломается.
Решение: Structured Outputs
Современные движки для запуска LLM (включая vLLM, llama.cpp и серверы, совместимые с OpenAI API) поддерживают параметр response_format. Мы можем передать схему данных, и движок будет гарантировать, что выход модели валиден относительно этой схемы.
В моем проекте я использую локальную модель Qwen, и для неё я определил строгую схему ADDENDUM_SCHEMA.
Реализация в коде
Вот как выглядит определение схемы на Python (фрагмент из generate_addendums.py):
ADDENDUM_SCHEMA = {
"type": "json_schema",
"json_schema": {
"name": "addendum_placeholders_ru",
"strict": True,
"schema": {
"type": "object",
"properties": {
# Основные реквизиты
"номер_ДС": {"type": "string"},
"дата_ДС": {"type": "string"},
"город_подписания": {"type": "string"},
"номер_договора": {"type": "string"},
"дата_договора": {"type": "string"},
# Стороны
"наименование_заказчика": {"type": "string"},
"наименование_исполнителя": {"type": "string"},
# Подписанты (обычные и для грамматики)
"подписант_заказчика": {"type": "string"},
"подписант_исполнителя": {"type": "string"},
"подписант_заказчика_в_родительном падеже": {"type": "string"},
"подписант_исполнителя_в_родительном падеже": {"type": "string"},
# Блок изменений (Предмет, Оплата, Сроки)
"тип_единицы_предмет": {"type": "string"}, # например, "Статья"
"номер_единицы_предмет": {"type": "string"}, # например, "3"
"последний_пункт_предмет": {"type": "string"}, # "3.4"
"добавляемый_пункт_предмет": {"type": "string"}, # "3.5"
# Грамматические конструкции для вставки в текст
"фраза_единицы_предмет_вин": {"type": "string"}, # "статью 3"
"фраза_пункт_предмет_твор": {"type": "string"}, # "пунктом 3.5"
"текст_цитаты_предмет": {"type": "string"}
},
"required": [
"номер_ДС", "дата_ДС", "город_подписания",
"номер_договора", "дата_договора",
"наименование_заказчика", "наименование_исполнителя",
"подписант_заказчика_в_родительном падеже",
# ... и все остальные поля обязательны!
]
}
}
} Разбор архитектурных решений в схеме
Почему схема именно такая? Обратите внимание на несколько неочевидных моментов.
1. Борьба с падежами
Русский язык сложен для автоматической генерации шаблонов. В преамбуле ДС мы пишем:
“…в лице Генерального директора Иванова И.И., действующего на основании…” (Родительный падеж).
В блоке подписей:
“Генеральный директор Иванов И.И.” (Именительный падеж).
Если просить модель вернуть просто “ФИО подписанта”, нам придется склонять его отдельной библиотекой (типа pymorphy2), которая часто ошибается с фамилиями.