1. Главная
  2. Блог
  3. JSON Schema против реальности: как заставить российские LLM возвращать строгую структуру и не терять заявки в проде

JSON Schema против реальности: как заставить российские LLM возвращать строгую структуру и не терять заявки в проде

JSON Schema против реальности: как заставить российские LLM возвращать строгую структуру и не терять заявки в проде

Вечер пятницы, конец недели. Менеджер по продажам ставит задачу ИИ-агенту: разобрать за день входящие письма и разложить по CRM как карточки заявок. Агент возвращает ответ — и парсер падает. На одном письме модель добавила «Конечно, вот результат:» перед открывающей фигурной скобкой. На другом — лишнюю запятую перед закрывающей. На третьем — поле budget строкой "около 500 тыс" вместо числа. В CRM ушла едва ли половина карточек. Остальные осели в логах с пометкой JSONDecodeError.

Знакомая картина? Это типичная цена за то, чтобы «просто получить JSON от LLM». Расскажу, как мы у себя перестали терять заявки и довели парсинг практически до идеала на потоке из тысяч вызовов модели в сутки. По ходу разберу, почему разработка ИИ-агентов для компании — это не «подключил модель и поехали», а вполне себе инженерный проект.

Почему «верни JSON» — это не инструкция, а пожелание

Сначала про природу проблемы. Большая языковая модель — это не JSON-сериализатор. Она генерирует токены по вероятностям. Когда вы пишете в промпте «Верни ответ в JSON», модель честно старается, но на длинных контекстах и сложных схемах её начинает штормить: где-то добавит markdown-ограждение ```json, где-то закомментирует поле, где-то решит, что строка с большим количеством символов лучше выглядит в одинарных кавычках.

В англоязычных моделях с этим борются нативным structured output — там декодер принудительно ограничивают грамматикой. В российских LLM ситуация другая. GigaChat Pro поддерживает response_format с указанием JSON Schema — но не везде и не идеально. YandexGPT возвращает чистый текст. T-Lite и Saiga вообще без гарантий. Function calling — отдельная история, но он работает только когда вам нужно вызвать инструмент, а не получить структуру.

В реальном B2B-проекте у вас десятки сценариев, где нужна структура без вызова tool. Извлечение полей из письма. Разбор отзыва клиента на компоненты. Генерация плана действий с шагами и приоритетами. И тут начинается инженерная работа, о которой в туториалах обычно не пишут.

Кейс: разбор обращений в дилерский центр

Возьму живой пример из практики. Один из заказчиков — дилер автотехники. Входящий канал: почта, мессенджеры, веб-форма. За сутки набегают сотни сообщений, и человек физически не успевает разгребать. Задача ИИ-агента, которого мы поставили в компанию, — извлечь из каждого обращения структурированные поля:

{
  "client_intent": "purchase | service | info | complaint",
  "vehicle_class": "string | null",
  "budget_rub": "integer | null",
  "urgency_days": "integer 1..90 | null",
  "needs_call_back": "boolean",
  "extracted_phones": ["string"],
  "summary": "string max 200 chars"
}

Первая версия работала на «голом» промпте: «Верни JSON по схеме». Разбирала она заметно меньше того, что приходило. Остальное падало в ручную очередь — менеджеру приходилось открывать письмо, вычитывать и заводить карточку самому. У оператора на это уходило несколько часов в неделю, которые он предпочёл бы потратить на живых клиентов, а не на разгребание того, что не разобрал робот.

Мы пробовали три подхода. Первый провалился. Второй частично. Третий взлетел. Расскажу честно, что было не так с первыми двумя, — потому что именно там обычно застревают команды, которые пытаются сделать ИИ-агента на сайт или во внутренний контур своими силами.

Стратегия первая: жёсткий промпт и сторонний валидатор

Первая попытка — усилить промпт. Добавили примеры (few-shot), явный запрет на markdown-обёртку, пример невалидного и валидного JSON рядом. На выходе подключили pydantic для валидации.

Стало ощутимо лучше, но всё ещё далеко от «в проде можно спать спокойно». Главное — мы поняли: промпт не контролирует декодер. Модель в принципе может сгенерировать что угодно. Few-shot снижает вероятность отклонения, но не до нуля. И чем длиннее входное письмо, тем выше шанс, что модель «отвлечётся» и начнёт вести себя не так, как её просили.

Худший случай, который мы поймали, — длинная жалоба клиента на пять абзацев. Модель в ответе начинает пересказывать жалобу прозой и только под конец вспоминает про JSON. Парсер видит «Клиент недоволен качеством обслуживания, потому что...» — и падает. И это не единичный кейс: примерно у каждого длинного эмоционального письма был шанс закончиться так же.

Стратегия вторая: извлечение + recovery + retry с фидбэком

Что в итоге сработало — комбинация из трёх слоёв. Тут я перестал верить в «одну красивую функцию SDK» и начал строить конвейер.

Слой 1: tolerant extractor. Не доверяем модели вернуть чистый JSON. Из текста ответа достаём первый блок, похожий на JSON-объект, через скобочный счётчик. Простой парсер на 30 строк, который ищет первую {, считает баланс фигурных скобок с учётом кавычек и эскейпов, возвращает срез.

def extract_json_block(text: str) -> str | None:
    start = text.find('{')
    if start == -1:
        return None
    depth = 0
    in_string = False
    escape = False
    for i in range(start, len(text)):
        ch = text[i]
        if escape:
            escape = False
            continue
        if ch == '\\':
            escape = True
            continue
        if ch == '"':
            in_string = not in_string
            continue
        if in_string:
            continue
        if ch == '{':
            depth += 1
        elif ch == '}':
            depth -= 1
            if depth == 0:
                return text[start:i+1]
    return None

Этот шаг уже снимает почти все случаи с «Конечно, вот ответ:» и markdown-обёртками. Некрасиво, зато надёжно.

Слой 2: мягкая коррекция. Перед json.loads прогоняем ответ через несколько регулярок: убираем trailing comma, заменяем одинарные кавычки на двойные в строковых значениях, вырезаем комментарии //, которые модель иногда любит добавлять «для понятности». Это спорно с точки зрения чистоты, я сам сначала морщился. Но в проде это заметно поднимает долю успешных разборов, и я перестал морщиться.

Слой 3: retry с фидбэком об ошибке. Если после двух предыдущих слоёв pydantic-валидация всё равно падает — отправляем модели второй запрос. Не абстрактное «попробуй ещё раз», а конкретно: «Твой предыдущий ответ не прошёл валидацию по полю budget_rub: ожидалось целое число, получено "около 500 тыс". Исправь и верни только JSON». Модель обычно с первой попытки чинит именно ту проблему, на которую ей указали.

Важная деталь: на retry мы переключаем температуру с 0.3 на 0.0. Детерминизм против креативности — то, что нужно для исправления. На первом запросе немного температуры полезно, потому что модель лучше «понимает» разговорный текст письма. На втором она не должна ничего сочинять, только чинить.

Что получилось на выходе

Не буду приводить красивые проценты «было столько-то, стало столько-то» — в реальности цифры пляшут в зависимости от того, какие письма пришли сегодня. Скажу качественно.

После включения трёх слоёв в связке доля сообщений, которые долетают до CRM автоматически, стала близка к тому, что модель в принципе способна разобрать. Оставшиеся падения — это уже не баг парсера, а физическое ограничение модели: обращения вроде «Здарова, я Витёк» без контекста, которые и живой оператор не всегда поймёт. Их роутим в отдельную очередь, и это честно.

Время от входящего письма до карточки в CRM сократилось с нескольких часов до считанных секунд. Менеджер, который раньше залипал в разгребании остатков, теперь тратит на эту работу минуты в день, а не часы в неделю. Он же первым и сказал, что «стало наконец не стыдно перед клиентами» — потому что заявка с сайта, которая раньше могла провисеть до утра, теперь оказывается в его карточке пока клиент ещё не закрыл вкладку.

Отдельно замечу для тех, кто собирает n8n ИИ-агенты в качестве связки для входящего потока: описанные три слоя удобно раскладываются на отдельные ноды. Extractor + мягкая коррекция — в Function-ноде, валидация — через отдельный шаг, retry — обычной веткой If с обратной подачей ошибки в промпт. Это работает без магии и не привязывает вас к конкретному вендору LLM.

Накладные расходы, о которых не пишут в туториалах

Честно: retry стоит денег и времени. Дополнительный запрос — это дополнительные токены (контекст + предыдущий ответ + новое указание) и лишняя секунда с копейками латентности. У нас retry срабатывает нечасто, поэтому средняя добавка к счёту получается небольшой и вполне приемлемой. Но если бы retry вылезал в каждом втором запросе — это был бы сигнал не оптимизировать пайплайн, а переписывать основной промпт.

Чего точно делать не стоит — запускать retry в бесконечный цикл. Максимум одна попытка. Если после неё валидация снова падает — пишем в DLQ (dead letter queue), отдельную таблицу в Postgres с полями raw_input, llm_response, error, created_at. Раз в неделю просматриваем, что туда попадает. Это золотая жила для улучшения промптов: почти каждый разбор такой очереди подсказывает, что где-то в схеме не хватает ещё одного enum-значения или что модель системно путает два похожих поля.

Ещё один момент, о котором обычно узнают уже в проде: латентность прокси. Если ходите в модель через корпоративный прокси, у него часто стоит жёсткий лимит в 100 секунд на соединение. Длинное письмо + retry запросто в этот лимит упирается, и вы получаете таймаут вместо ответа. Мы решили это через стриминг ответа и раннее закрытие соединения, как только видим закрывающую фигурную скобку верхнего уровня. Кажется мелочью, пока не поймаете первый такой таймаут на боевом трафике.

Когда брать response_format от GigaChat Pro, а когда городить свой парсер

GigaChat Pro в новых версиях принимает response_format={"type": "json_object"}, а в некоторых сценариях — и JSON Schema. Где это работает — берите, не изобретайте велосипед. Доля сырых валидных ответов сразу растёт, и часть регулярок из второго слоя становится не нужна.

Но есть случаи, где собственный пайплайн всё равно нужен:

  • сложные вложенные схемы с условной обязательностью полей — нативный response_format их пока переваривает так себе;
  • задачи, где нужно поддержать несколько провайдеров: если ИИ-агент в компании должен уметь падать с GigaChat на YandexGPT для отказоустойчивости, то одна и та же логика разбора должна работать для обоих;
  • кейсы, где важна детерминированная нормализация — например, телефон всегда в формате +7XXXXXXXXXX, а LLM возвращает его то со скобками, то с восьмёркой, то через пробелы.

В этих случаях нативный response_format — первый слой, а tolerant extractor + валидация + retry остаются страховкой.

Тот же подход у нас переиспользуется, когда мы ставим ИИ-агент на компьютер сотрудника — например, локального ассистента, который разбирает почту прямо на рабочей станции. Модель может быть другая, транспорт другой, но три слоя обороны остаются те же: сначала аккуратно вынуть JSON, потом мягко починить, потом при неудаче — один осмысленный retry.

Вывод

Структурированный вывод от LLM в продакшене — это не одна функция SDK, а инженерный пайплайн из трёх-четырёх слоёв обороны. Промпт делает большую часть работы, валидация и retry добивают остальное. Тот, кто полагается только на промпт, теряет заметную долю данных и потом разгребает завалы в логах — а вместе с завалами копит недовольство менеджеров, которые видят «дырки» в CRM и перестают доверять всей системе.

Главный вопрос, который стоит задать своей команде, если вы уже запустили ИИ-агента в компанию: что у вас сейчас на проде — гордое «работает у меня локально» или измеренная доля успешных парсингов на реальном потоке? Если второй цифры нет — её стоит замерить до того, как клиент напишет: «А где моя заявка от вторника?»

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.