Хрупкость формата: Как заставить нейросеть всегда отдавать валидный JSON

16.06.2026 17:00

Интеграция больших языковых моделей (LLM) в production-системы неизбежно сталкивается с фундаментальной проблемой типизации. Ваш бэкенд ожидает строгий, детерминированный JSON, соответствующий заранее определенному контракту. Нейросеть же по своей природе — это генератор текста, работающий на основе вероятностей токенов. Эта разница парадигм приводит к тому, что на каждый миллион запросов приходятся тысячи исключений SyntaxError: Unexpected token или JSON_ERROR_SYNTAX. Парсеры ломаются из-за забытой запятой, неэкранированной кавычки внутри строкового значения или непрошеного markdown-блока. Процессы останавливаются, очереди забиваются failed-задачами, а разработчики тратят часы на отладку. В этой статье мы детально разберем механику проблемы и инженерные методы жесткой фиксации формата при работе через гейтвей RouterAPI.

Иллюзия понимания формата и синтаксические галлюцинации

Распространенное заблуждение инженеров состоит в том, что языковые модели понимают структуру графов, деревьев или вложенных объектов. В реальности LLM оперируют исключительно плоскими строками токенов. Когда вы просите модель вернуть JSON, она не строит абстрактное синтаксическое дерево (AST) в оперативной памяти и не сериализует его в строку. Она просто подбирает слова, которые статистически часто встречаются рядом с фигурными скобками в обучающей выборке.

В идеальных условиях ответ выглядит корректно:

{
 "status": "success",
 "confidence": 0.95
}

Но в боевой среде разработчик регулярно получает мусорный выхлоп:

Конечно, я проанализировал ваш запрос. Вот результат:

{ "status": "success", "confidence": 0.95, "reasoning": "Система решила, что "это" правильный вариант ответа", }

В этом коротком ответе содержатся сразу четыре фатальные ошибки: префиксная фраза, markdown-разметка, неэкранированные внутренние кавычки в текстовом поле и висячая (trailing) запятая перед закрывающей скобкой. Стандартные функции `json_decode` в PHP или `JSON.parse` в JavaScript немедленно выбросят исключение. Бизнес-логика прервется.

## Эволюция костылей: От регулярок до Recovery-циклов

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

if (preg_match('/\{.*\}/s', $response, $matches)) { $jsonString = $matches[0]; }

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

Следующая стадия эволюции — внедрение recovery-циклов (циклов восстановления). Типичный recovery-цикл выглядит как блок try-catch, внутри которого обращение к API обернуто в цикл `while ($attempts < 3)`. Если парсинг возвращает `null` (или выбрасывает `JsonException`), бэкенд формирует новый запрос к нейросети: *"Твой предыдущий ответ вызвал ошибку парсинга: JSON_ERROR_SYNTAX. Верни только исправленный JSON без дополнительных комментариев. Исходный ответ: [ответ]"*.

Этот подход действительно работает, но цена его использования неприемлема. Он удваивает или утраивает время ответа (latency) и пропорционально увеличивает затраты на токены. Для highload-систем задержка в 5-10 секунд на обработку фоллбэка разрушает пользовательский опыт. Нам нужен детерминированный, валидный результат с первого запроса.

## Управление вероятностями: Аппаратный JSON Mode

Чтобы навсегда избавиться от текстового мусора вокруг JSON, необходимо вмешаться в процесс сэмплирования токенов на уровне движка вывода (inference engine). Современные провайдеры реализовали параметр `response_format`.

В инфраструктуре (при маршрутизации через RouterAPI) включение JSON mode выглядит так:

$response = $client->chat->create([ 'model' => 'openai/gpt-4o', 'messages' => [ ['role' => 'system', 'content' => 'Выводи ответ строго в формате JSON. Ключи: action (string), score (float).'], ['role' => 'user', 'content' => 'Оцени текст: "Запрос на отмену транзакции"'] ], 'response_format' => ['type' => 'json_object'] ]);

Флаг `json_object` модифицирует маскинг (logit bias) токенов "под капотом". Логика работает на уровне словаря модели: на шаге предсказания первого токена алгоритм сканирует все вероятности. Токенам, соответствующим буквам текста (например, "Вот", "Конечно", "```"), принудительно присваивается вероятность минус бесконечность (-inf). Единственными "выжившими" кандидатами остаются токены, кодирующие `{` или `[`. Модель физически лишается возможности вывести префикс.

## Структурные галлюцинации и внедрение JSON Schema

Режим `json_object` гарантирует валидность синтаксиса, но он не гарантирует соблюдение бизнес-контракта. Нейросеть выдаст корректный JSON, но вместо ожидаемого ключа `score` может сгенерировать `rating`, а вместо числа с плавающей точкой отдать строку `"0.9"`.

Кроме того, модели склонны добавлять поля вроде `explanation` или `metadata`, потому что обучающая выборка изобилует подробными объяснениями решений. Если ваш бэкенд использует строгую десериализацию (например, через Symfony Serializer в PHP или Go structs без тега `omitempty`), наличие лишних полей немедленно вызовет исключение нарушения контракта.

Ультимативным решением проблемы типизации становятся Structured Outputs. Вместо того чтобы полагаться на способность модели следовать словесной инструкции, разработчик передает строгую JSON Schema непосредственно в API-запрос. Движок вывода компилирует эту схему в контекстно-свободную грамматику (CFG) или конечный автомат (FSM). На каждом шаге предсказания доступен только тот набор токенов, который переводит автомат в валидное состояние.

Пример строгой типизации запроса в экосистеме RouterAPI:

{ "model": "anthropic/claude-3-5-sonnet", "messages": [..], "response_format": { "type": "json_schema", "json_schema": { "name": "moderation_result", "strict": true, "schema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["approve", "reject", "escalate"] }, "score": { "type": "number" }, "flags": { "type": "array", "items": { "type": "string" } } }, "required": ["action", "score", "flags"], "additionalProperties": false } } } }

Установка флага `"strict": true` запрещает любые отклонения от схемы. Время декодирования первых токенов может незначительно увеличиться из-за накладных расходов на проверку грамматики, но это полностью исключает ошибки формата. Отпадает необходимость в написании recovery-циклов и сложной логики валидации DTO на стороне бэкенда. Вы десериализуете ответ сразу в типизированный объект предметной области.

## Практики отказоустойчивости в многомодельных шлюзах

При работе с гейтвеями вроде разработчик сталкивается с "зоопарком" моделей от десятков вендоров. Далеко не все опенсорсные модели одинаково хорошо поддерживают `json_schema`. Менее интеллектуальные (или не прошедшие fine-tuning на системные вызовы) модели могут застрять в бесконечном цикле генерации, пытаясь подобрать токен, соответствующий жесткой маске схемы, что приводит к таймаутам.

Поэтому правильная архитектура отказоустойчивости должна строиться по каскадному принципу:

1. **Основной путь (Happy Path):** Вызов мощной модели (GPT-4o, Claude 3.5) с параметром `type: "json_schema"`. Ожидаем идеальный результат. Устанавливаем `temperature: 0.0`. Высокая креативность разрушает предсказуемость ключей.
2. **Деградация до JSON Object:** Если провайдер возвращает ошибку 400 (сообщая, что схема не поддерживается) или происходит таймаут, шлюз прозрачно переключает запрос на `type: "json_object"`, а саму схему сериализует в текст и добавляет в конец `system` промпта.
3. **Санитаризация ответа:** При получении строки, даже в режиме JSON mode, прогоняем ответ через легковесный фильтр, вырезающий теги ```json и ``` (некоторые модели умудряются просовывать разметку даже при заблокированных текстовых токенах).
4. **Аварийный Recovery-цикл:** Исключительно как последний рубеж обороны. Отправка ответа на быстрый retry с модифицированным промптом и повышенной штрафной функцией (frequency_penalty).

## Резюме

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

Единственный инженерно верный путь к предсказуемому бэкенду — смещение ответственности за формат на сам движок генерации. Используйте аппаратные механизмы провайдеров, такие как JSON mode и Structured Outputs, и маршрутизируйте запросы через надежные API-шлюзы. Когда модель физически не может выдать невалидный синтаксис, ваш код становится чище, а системы — отказоустойчивее.

Теги

Ещё по теме