Structured outputs: как заставить модель вернуть схему, а не прозу
Модель прочитала документ правильно. Она нашла адрес, площадь, регистрационный номер. А потом вернула их под ключами location, area и reg_no — и ваш конвейер, который искал legal_address, area_sqm и registration_number, отрисовал три пустых поля.
Так выглядит большинство сбоев извлечения на LLM. Не понимание — формат. Модель знала ответ и описала его своими словами, а ваш код не может догадаться, что под location имелся в виду legal_address.
Ниже — три уровня защиты от этого, по возрастанию стоимости, и то, где каждый перестаёт себя окупать.
Три способа сломать формат
Выдуманные имена ключей. Самый частый с большим отрывом. Ваш промпт описывает поля прозой — «извлеките юридический адрес, площадь в квадратных метрах», — и модель сама подбирает правдоподобные ключи JSON. Дважды одинаковыми они не бывают и с именами, которых ждёт ваш шаблон, не совпадают никогда.
Почти-JSON. Лишняя запятая, случайный markdown-забор, комментарий, оборванный объект, потому что кончился бюджет ответа. Парсер падает, а ретрай выдаёт почти-JSON с другим дефектом.
Бесшумно пропавшие поля. Модель опускает ключ вместо того, чтобы сообщить, что значения в документе нет. Отсутствующий ключ и пустое значение ниже по конвейеру выглядят одинаково, и никто не отличит настоящий пропуск от честной пустоты.
Все три — проблемы формата, и решения у них форматные.
Уровень 1: пиньте имена ключей в промпте
Самая дешёвая и самая доходная правка. Перестаньте описывать поля прозой и начните перечислять точные ключи.
Инструкция, которая сработала у нас, дописывает явный контракт ключей к тому, что промпт уже говорит:
Верните объект JSON, использующий РОВНО эти имена ключей
(не переименовывайте, не переводите и не сокращайте их):
legal_address, area_sqm, registration_number, cadastral_value.
Пишите каждый ключ ровно как в списке, даже если в документе значение
подписано иначе (используйте ключ из списка, а не синоним). Опускайте ключ
только тогда, когда в документе действительно нет значения для него.
Три детали важнее, чем кажутся.
«Не переводите». Извлечение работает по документам на одном языке и отчитывается в JSON, и модель, читающая русскую форму, охотно выдаст русские имена ключей, если ей не сказать иначе.
«Даже если в документе значение подписано иначе». Это та фраза, которая прекращает изобретение синонимов. Без неё модель резонно заключает, что поле, подписанное в форме «Местоположение», должно называться location, — ведь она читает документ, а не вашу схему.
«Опускайте ключ только при реальном отсутствии значения». Это превращает бесшумный пропуск в честное отсутствие и делает эти два случая различимыми на валидации.
Генерируйте этот список из своей схемы, а не руками. У нас он строится из тех же field_definitions, на которые потом отображается результат, а значит, промпт не может разойтись с тем, чего ждёт конвейер, как бы ни была сформулирована написанная человеком часть.
Для полей-массивов пиньте и ключи элементов, иначе повторяющиеся строки заполняются неровно:
Поля-массивы:
- "items" — массив; каждый элемент должен использовать РОВНО эти ключи:
description, quantity, unit_price, total_price
Уровень 2: структурированный вывод на стороне провайдера
Большинство провайдеров сегодня принимают JSON-схему вместе с запросом и ограничивают декодирование так, что ответ ей соответствует. Там, где это доступно, класс «почти-JSON» исчезает целиком — не потому, что вы валидируете после, а потому, что некорректный вывод становится недостижимым.
Поддержка неровная, и её стоит проверять, а не предполагать. В наших замерах OpenAI и Anthropic честно соблюдают схему; у DeepSeek поддержка слабее. Если вы извлекаете из сканов, полезно знать: ограничения схемы работают рядом с картинками на входе, то есть vision-извлечение из этого уровня не выпадает.
Не типизируйте значения, если вы не уверены, что хотите именно этого.
Здесь и лежит ловушка, и чтобы разглядеть её ясно, у нас ушёл вдумчивый вечер. Наши собственные определения полей объявляют unit_price и total_price как number. Наш промпт требует их строками — "49.0000", явно не 49.0, — потому что в таможенной декларации хвостовые нули являются частью значения, а число в JSON бесшумно их отбрасывает. 49.0000 становится 49.0, и сумма, которая обязана совпадать с бумажной формой до четвёртого знака, перестаёт совпадать.
Тот же конфликт всплыл на quantity: объявлен скаляром, а промпт требует массив строк, включая дубликаты, потому что графа формы вполне может содержать три строки.
Поэтому наша схема пиннит имена ключей и форму объектов, но не типизирует значения. Все скаляры уходят по проводу строками. Это держат два теста: нигде в сгенерированной схеме нет числового типа, и каждая скалярная позиция допускает также массив строк. Ослабить любой из них — прямой путь к порче сумм.
Общее правило: схема — это контракт формата, а не уровень валидации. Пользуйтесь ею, чтобы гарантировать форму. Значения валидируйте сами, там, где можете решить, что делать с плохим.
Уровень 3: переспросите про поля, которые не получились
Даже с двумя уровнями часть документов возвращается с пропущенным или некорректным полем. Рефлекс — повторить извлечение целиком. Это неверный ход и по стоимости, и по качеству: вы платите за весь документ заново, а слепой ретрай на рассуждающей модели обычно бьётся в ту же стену, что и в первый раз.
Лучше так: провалидируйте результат, соберите конкретные не получившиеся поля и спросите только про них.
В предыдущем ответе следующие обязательные поля отсутствовали или были
некорректны:
- export_date (обязательное, отсутствует)
- total_value (присутствует, но не строка)
Посмотрите документ ещё раз и верните объект JSON, содержащий ТОЛЬКО эти
ключи. Если в документе действительно нет значения для поля, верните для
него null, а не догадку.
Последнее предложение обязательно. Переспрос без него — приглашение выдумывать: вы сообщили модели, что она ошиблась, и путь наименьшего сопротивления — придумать что-то правдоподобное. Явное разрешение ответить «нет значения» и делает второй проход безопасным.
Затем сливайте результаты по правилу, которое вы записали до прогона. Наше: слияние принимается, только если число неверных ответов не растёт, а число пропущенных уменьшается. Второй проход, обменивающий один пропуск на одну ошибку, — не улучшение, а без записанного правила очень легко убедить себя в обратном.
Мерьте, прежде чем включать
Мы построили эту петлю, потом померили — и замер сказал: пока нет.
На единственном достижимом документе базовая линия уже давала 85 верных значений из 85 — оба кода компании, трёхстрочная графа количества с дубликатом, все цены с хвостовыми нулями. Проверочный проход дал ровно те же 85. Он пометил одно поле, export_date, и соответствующая графа на форме честно пуста. Петля задала свой единственный вопрос, получила правильное молчание и не применила ничего. Один лишний вызов, ноль изменений.
По нашему же правилу слияния это плечо не проходит. Поэтому код написан, покрыт тестами и выключен флагом до тех пор, пока не появятся документы с настоящими пропусками, на которых его можно померить.
Из этого стоит забрать две вещи.
Установление базовой линии потребовало четырёх кругов подсчёта, и каждое расхождение оказывалось ошибкой в нашем эталоне, а не в извлечении. Мы неверно сопоставили, какой графе формы соответствует поле; не заметили, что код стоит второй строкой в той же ячейке; проглядели печатный префикс. Проверяйте свою мерку так же строго, как измеряемое.
Счёт валидатора — это верхняя граница, а не оценка. Наш сообщает о пропусках в 10 из 27 сохранённых документов, но без исходных файлов настоящий промах неотличим от пустой графы на форме. Единственный случай, который удалось сверить с исходником, оказался пустой графой.
Главное
- Извлечение ломается на формате, а не на понимании. Модель обычно нашла значение — просто положила его под именем, которого ваш код не знает.
- Пиньте точные имена ключей в промпте, генерируя их из схемы. Самый дешёвый уровень, снимающий самый крупный класс сбоев.
- Схемы провайдера используйте для формы, а не для типов. Тип
numberбесшумно съест хвостовые нули, из-за которых таможенная сумма совпадает с бумажной формой. - Переспрашивайте только про не получившиеся поля и явно разрешайте ответ «нет значения». Слепой полный ретрай дороже и провоцирует выдумывание.
- Запишите правило слияния до замера и сначала проверьте эталон. Первые четыре наших расхождения были ошибками эталона, а не извлечения.
FAQ
Делает ли JSON-схема ненужным пиннинг ключей в промпте?
Нет. Держите оба. Схемы поддерживаются не везде, качество поддержки разное, а инструкция в промпте почти бесплатна. Когда есть оба, они усиливают друг друга; когда схема недоступна, промпт — это всё, что у вас есть.
Почему не типизировать числовые поля как числа?
Потому что число в JSON отбрасывает форматирование, которое документ считает значимым. 49.0000 становится 49.0, и сумма, обязанная совпадать с формой до четвёртого знака, перестаёт совпадать. Возите значения строками и валидируйте их сами.
Что делать с полем, которого нет в ответе?
Сначала выясните, нет ли его в документе. Это разные факты, и дефект только один из них. Инструктируйте модель возвращать null при настоящем отсутствии, чтобы ваш валидатор мог их различать.
Стоит ли проверочный проход лишнего вызова?
Только если вы померили, что он помогает. Наш дал ноль исправлений на документе, где базовая линия и так была безупречной, поэтому он выключен. Мерьте на документах с настоящими пропусками — и убедитесь, что это пропуски, а не пустые графы.
Как тестировать извлечение, не платя за каждый прогон?
Сохраняйте на диск те же изображения страниц и тот же промпт и гоняйте плечи оттуда, а не через живой API. Наш харнес складывает ровно то, что отправил бы боевой вызов, — это делает сравнение воспроизводимым и оставляет платными только те несколько вызовов, которые действительно что-то устанавливают.
Заключение
Структурированное извлечение — это в основном задача о контракте. Модель обычно читает документ правильно; ломается рукопожатие между тем, что она возвращает, и тем, чего ждёт ваш код.
Чините это рукопожатие в порядке стоимости уровней: закрепите ключи из схемы, добавьте нативную схему для формы там, где провайдер её поддерживает, и добавьте точечный переспрос только после того, как померите, что он восстанавливает что-то настоящее. И что бы вы ни строили, сначала честно установите базовую линию: самый дорогой дефект извлечения, который мы разбирали, четыре раза подряд оказывался ошибкой в том, как мы считали.
KTTC извлекает поля из таможенных деклараций, выписок из реестров и актов гражданского состояния, где потерянный код — это отклонённый документ. Попробуйте KTTC и посмотрите, что вернётся с ваших форм.
