Создание ДС с локальной 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`), которая часто ошибается с фамилиями.

Решение: Я запрашиваю у LLM сразу готовые формы: `подписант_заказчика` (для реквизитов) `подписант_заказчика_в_родительном падеже` (для преамбулы)

Это перекладывает задачу склонения на языковую модель, которая "чувствует" контекст лучше, чем алгоритмические морфолизаторы.

2. Конструктор фраз изменений

Для внесения изменений в договор формулировки могут быть разными: "Дополнить Статью 5.." или "Дополнить Раздел 2...". Чтобы шаблон Word был универсальным, я прошу модель вернуть готовые грамматические формы: `фраза_единицы_предмет_вин`: "статью 5" (Винительный падеж — дополнить кого/что?) `фраза_пункт_предмет_твор`: "пунктом 5.1" (Творительный падеж — дополнить кем/чем?)

Это позволяет в шаблоне DOCX написать просто: `{фраза_единицы_предмет_вин}` дополнить `{фраза_пункт_предмет_твор}` следующего содержания:

И это будет работать корректно и для "статью 5", и для "раздел 2".

3. Атомарность данных

Я разделил логические части изменений на отдельные поля: `последний_пункт` `добавляемый_пункт`

Это позволяет на этапе пост-обработки (уже в Python) проверить логику модели. Если `добавляемый_пункт` не следует за `последним` (например, после 3.4 идет 3.6), я могу залогировать это как предупреждение или даже перезапустить генерацию.

Как это работает в API

При вызове модели мы передаем эту схему в параметре `response_format`. Вот фрагмент функции `call_llm`:

payload = { "model": "qwen3-vl-30b-a3b-thinking", "messages": [ {"role": "system", "content": "Ты возвращаешь строго валидный JSON по русской схеме..."}, {"role": "user", "content": "..."} ], "temperature": 0.0, # Максимальная детерминированность "response_format": ADDENDUM_SCHEMA # Магия здесь }

Благодаря `temperature: 0.0` и `response_format`, мы получаем скучный, предсказуемый, но идеально валидный JSON, который можно сразу отправлять в генератор документов.

Что дальше?

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

Кому, как и мне, интересно автоматизировать юридические процессы, присоединяйтесь ко мне в телеграме, там я пишу "человеческим языком", а не вот это вот всё)

Начать дискуссию