Самокорректирующийся структурированный вывод в Spring AI 2.0

от автора

Большие языковые модели — это системы, работающие по принципу «текст на входе, текст на выходе»: их интерфейсом является естественный язык.

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

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

Spring AI поддерживает структурированный вывод с первого дня через ChatClient.call().entity(...). В Spring AI 2.0 к нему добавлены два новых механизма настройки — нативный структурированный вывод на стороне провайдера и самокорректирующаяся валидация схемы. Настройки по умолчанию не изменились, поэтому существующий код продолжает работать.

В этой публикации мы разберем структурированный вывод: начнем с простого рабочего примера, а затем шаг за шагом добавим элементы надежности.

Типизированный ответ

Определите Java record для структуры, которую хотите получить на выходе:

record ActorsFilms(String actor, List movies) {}

Попросите ChatClient заполнить его. Вместо того чтобы завершать вызов методом .content(), который возвращает необработанный текстовый ответ, завершите его методом .entity(...) и передайте целевой тип:

ActorsFilms films = chatClient.prompt()    .user("Generate the filmography for a random actor.")    .call()    .entity(ActorsFilms.class);

Вот и все. Результат — типизированный ActorsFilms, который можно передать в остальную часть кода:

System.out.println(films.actor());     // "Tom Hanks"System.out.println(films.movies());    // ["Forrest Gump", "Cast Away", ...]

.entity(...) доступен только для .call(). Для типизированного разбора требуется полный ответ, поэтому при потоковой обработке ответа он недоступен. Поэтому, например, .stream() возвращает текстовые фрагменты, а не типизированные объекты. Это относится ко всем вариантам, рассмотренным ниже: Class, ParameterizedTypeReference, пользовательский конвертер, с новыми механизмами настройки или без них.

За кулисами Spring AI выполнил три действия: генератор схем преобразовал ваш Java record в JSON-схему, эта схема была добавлена в системный промпт, а JSON-ответ модели затем был передан конвертеру типов, который разобрал его обратно в ваш record.

Это работает на каждой модели, поддерживаемой Spring AI: здесь нет ничего специфичного для конкретного провайдера.

Но гарантий это также не дает. Модель просят сгенерировать JSON, соответствующий схеме, а не принуждают к этому. В большинстве случаев она выполняет требование. Но иногда нет: возвращает лишнее поле, пропускает обязательное или оборачивает JSON в пояснительный текст. В таких случаях парсер выбрасывает исключение.

Следующие два раздела решают эту проблему — по одному подходу за раз.

Добавляем страховку: validateSchema()

Самый простой способ обработать некорректно сформированный вывод — обнаружить его и повторить попытку. В Spring AI 2.0 это делается автоматически с помощью одного переключателя:

ActorsFilms films = chatClient.prompt()    .user("Generate the filmography for a random actor.")    .call()    .entity(ActorsFilms.class, spec -> spec.validateSchema());

spec.validateSchema() включает самокорректирующийся цикл повторных попыток:

  1. Модель отвечает.

  2. Spring AI проверяет ответ по схеме для ActorsFilms.

  3. Если проверка проходит успешно, вы получаете обратно свой типизированный record.

  4. Если же проверка завершается ошибкой, сообщение об ошибке валидации («missing required field actor», «expected array, got string») добавляется к пользовательскому промпту, а вызов выполняется повторно — по умолчанию до 3 попыток.

Модель видит конкретную ошибку при каждой повторной попытке. Вторая попытка — это не слепой повтор: модель знает, что именно было неверно, и может это исправить.

Это обеспечивается StructuredOutputValidationAdvisor — рекурсивным эдвайзером, который автоматически регистрируется при вызове validateSchema(). Ничего настраивать вручную не нужно: этот переключатель и есть вся конфигурация.

Настройка эдвайзера

По умолчанию StructuredOutputValidationAdvisor использует 3 попытки повтора и стандартный JsonMapper из Spring AI. Чтобы изменить настройки — например, увеличить число попыток, передать заранее подготовленную схему или использовать другой mapper, — создайте собственный экземпляр и зарегистрируйте его в ChatClient. Явно зарегистрированный эдвайзер заменяет автоматически зарегистрированный:

var validationAdvisor = StructuredOutputValidationAdvisor.builder()    .outputType(ActorsFilms.class)    .maxRepeatAttempts(5)    .build();ChatClient chatClient = ChatClient.builder(chatModel)    .defaultAdvisors(validationAdvisor)    .build();

Добавляем гарантии на стороне провайдера: useProviderStructuredOutput()

validateSchema() — это страховка на стороне ответа: она обнаруживает некорректный вывод постфактум и повторяет запрос. Дополняющий подход — ограничение на стороне запроса: сообщить провайдеру модели на уровне API, что ответ должен соответствовать схеме. Большинство современных провайдеров это поддерживают: OpenAI Structured Outputs, расширение структурированного вывода Anthropic, responseSchema у Gemini, response_format у Mistral.

Spring AI предоставляет переносимый доступ к этой возможности через еще один переключатель в том же consumer:

ActorsFilms films = chatClient.prompt()    .user("Generate the filmography for a random actor.")    .call()    .entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());

Что меняется на уровне обмена данными:

  • Системный промпт больше не содержит инструкцию о JSON-формате: он становится чище и требует меньше токенов.

  • Схема отправляется провайдеру как поле уровня API.

  • Среда выполнения провайдера обеспечивает соответствие схеме: некорректные ответы вообще не могут быть выданы.

Поддерживаемые провайдеры на момент версии 2.0: OpenAI, Anthropic, Google GenAI, Mistral AI, Ollama (в зависимости от модели). Один и тот же вызов .useProviderStructuredOutput() работает независимо от того, какой провайдер подключен.

Spring AI определяет поддержку, проверяя, реализуют ли chat options модели интерфейс StructuredOutputChatOptions. Если нет, флаг молча игнорируется, а вызов возвращается к стандартному подходу на основе промпта.

Почему это отключено по умолчанию

Совместимость. Старые модели или модели без поддержки отклонили бы такой запрос, тогда как стандартный подход на основе промпта работает везде. Даже у поддерживаемых провайдеров есть несколько известных ограничений, о которых стоит упомянуть:

1. Частичная поддержка JSON Schema.

Нативная поддержка структурированного вывода часто неполная: даже у провайдеров, заявляющих такую возможность, набор принимаемых возможностей JSON Schema различается. $ref, глубоко вложенные массивы, allOf/anyOf/oneOf, regex-шаблоны и рекурсивные типы часто оказываются проблемными. Именно такие отклонения формы и помогает обнаруживать validateSchema() — об этом в следующем разделе.

2. Ollama с reasoning-моделями («thinking»)

Варианты вроде qwen могут выдавать свой внутренний reasoning как обычный текст вместо JSON, что приводит к ошибкам разбора. Используйте модель без reasoning-режима или сочетайте нативный вывод с validateSchema(), чтобы некорректные ответы повторялись.

OpenAI не принимает массивы верхнего уровня в своем API Structured Outputs. Перед нативным запросом оберните список в контейнерный record: record FilmographyList(List films) {}.

Комбинируем оба подхода

Два переключателя решают разные задачи и естественно сочетаются:

ActorsFilms films = chatClient.prompt()    .user("Generate the filmography for a random actor.")    .call()    .entity(ActorsFilms.class, spec -> spec        .useProviderStructuredOutput()        .validateSchema());

useProviderStructuredOutput() снижает вероятность некорректного вывода, ограничивая модель на уровне API. validateSchema() перехватывает оставшиеся случаи — пограничные особенности провайдеров, упомянутую выше специфику reasoning-моделей Ollama — и автоматически их исправляет.

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

Generic-типы: списки, мапы и не только

.entity(Class) предназначен для конкретных классов. Для generic-типов — List, Map<String, ActorsFilms> — используйте ParameterizedTypeReference:

List<ActorsFilms> films = chatClient.prompt()    .user("Generate filmographies for three random actors.")    .call()    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});

Тот же validateSchema() работает и здесь:

List<ActorsFilms> films = chatClient.prompt()    .user("Generate filmographies for three random actors.")    .call()    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {},            spec -> spec.validateSchema());

На один момент стоит обратить внимание: API Structured Outputs у OpenAI не принимает массив верхнего уровня. Если совместить List<...> с .useProviderStructuredOutput() на OpenAI, вызов завершится ошибкой. Решение — wrapper record в одну строку:

record FilmographyList(List<ActorsFilms> films) {}FilmographyList result = chatClient.prompt()    .user("Generate filmographies for three random actors.")    .call()    .entity(FilmographyList.class, spec -> spec.useProviderStructuredOutput());

В стандартном сценарии на основе промпта такого ограничения нет: массивы верхнего уровня без useProviderStructuredOutput() работают нормально.

Получение полного ответа

.entity(...) возвращает только разобранный объект. Если вам также нужен базовый ChatResponse — для статистики использования токенов, метаданных наблюдаемости или чего-либо за пределами entity — используйте .responseEntity(...):

ResponseEntity<ChatResponse, ActorsFilms> result = chatClient.prompt()    .user("Generate the filmography for a random actor.")    .call()    .responseEntity(ActorsFilms.class);ActorsFilms films = result.entity();ChatResponse raw = result.response();long totalTokens = raw.getMetadata().getUsage().getTotalTokens();

У него тот же набор перегрузок, что и у .entity(...): применимы Class и ParameterizedTypeReference.

Когда встроенных средств недостаточно

Встроенный BeanOutputConverter строг: он ожидает, что ответ модели будет пригодным для разбора JSON — и точка. Но модели часто оборачивают свой JSON в markdown-блоки кода:

Here's the filmography:{ "actor": "Tom Hanks", "movies": ["Forrest Gump", "Cast Away"] }

BeanOutputConverter выбросит исключение уже на первой букве H в слове «Here’s». Распространенное решение — пользовательский конвертер, который удаляет блоки кода и извлекает JSON, прежде чем передать его стандартному парсеру:

public class LenientJsonOutputConverter<T> implements StructuredOutputConverter<T> {    private static final Pattern FENCE = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");    private final BeanOutputConverter<T> delegate;    public LenientJsonOutputConverter(Class<T> targetType) {        this.delegate = new BeanOutputConverter<>(targetType);    }    @Override public String getFormat()     { return delegate.getFormat(); }    @Override public String getJsonSchema() { return delegate.getJsonSchema(); }    @Override    public T convert(String source) {        var matcher = FENCE.matcher(source);        String json = matcher.find() ? matcher.group(1).trim() : source.trim();        return delegate.convert(json);    }}

Передайте его в .entity(...) вместо Class:

ActorsFilms films = chatClient.prompt()    .user("Generate the filmography for a random actor.")    .call()    .entity(new LenientJsonOutputConverter<>(ActorsFilms.class));

Поскольку этот конвертер делегирует getJsonSchema() базовому BeanOutputConverter, оба новых механизма настройки по-прежнему работают: validateSchema() и useProviderStructuredOutput() используют ту же схему, что и стандартный конвертер. Вы получаете устойчивый разбор плюс самокоррекцию без дополнительной настройки.

Роль getJsonSchema()

Добавленный в версии 2.0 как default-метод в StructuredOutputConverte#getJsonSchema() служит мостом, который позволяет конвертеру участвовать в useProviderStructuredOutput() и validateSchema(). Реализуйте его так, чтобы он возвращал вашу схему — обычно через делегирование BeanOutputConverter, — и новые механизмы заработают; оставьте реализацию по умолчанию, и оба механизма для этого конвертера станут no-op.

Не-JSON-форматы

Для форматов за пределами возможностей JSON — YAML для генераторов конфигураций, CSV для извлечения данных — реализуйте StructuredOutputConverter с нуля: напишите собственный getFormat() для промпта и собственный парсер convert(...). Оставьте getJsonSchema() в реализации по умолчанию, и оба новых механизма не будут задействованы: путь на основе промпта будет работать так же, как и для встроенных конвертеров.

Шпаргалка

Что нужно

Что использовать

Стандартный вариант — работает у любого провайдера

.entity(Type.class)

Generic-типы вроде List<T>, Map<K,V>

.entity(new ParameterizedTypeReference<...>() {})

Не падать на некорректно сформированном выводе

.entity(Type.class, spec -> spec.validateSchema())

Более строгие гарантии на стороне провайдера

.entity(Type.class, spec -> spec.useProviderStructuredOutput())

Оба подхода — ограничение запроса + повтор ответа

.entity(Type.class, spec -> spec.useProviderStructuredOutput().validateSchema())

Статистика токенов / метаданные вместе с entity

.responseEntity(...) (те же перегрузки)

Модель оборачивает JSON в markdown-блоки или используется не-JSON-формат

Реализуйте StructuredOutputConverter<T> и передайте его в .entity(...)

Потоковые ответы

Не поддерживаются: .entity(...) доступен только для .call(); .stream() возвращает текстовые фрагменты, а не типизированные объекты

Итоги

Структурированный вывод в Spring AI 2.0 — это тот же вызов .entity(...), который вы уже знаете, но с двумя новыми переключателями: validateSchema() для самокоррекции на стороне ответа и useProviderStructuredOutput() для принудительного соблюдения схемы на стороне запроса. Каждый из них полезен сам по себе; вместе они ограничивают запрос и самокорректируют ответ. Существующий код продолжает работать без изменений; новый код может подключать эти возможности для каждого вызова отдельно, без перенастройки приложения.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

ссылка на оригинал статьи https://habr.com/ru/articles/1057472/