Перейти к содержимому
GPT из Java без шаманства

GPT из Java без шаманства

19 июня 2026 г.·
Влад Карандашов

GPT из Java без шаманства
GPT из Java без шаманства

Подключить GPT-модель к Java-приложению технически несложно. Сложнее не превратить это в метод на двести строк, где prompt склеивается из строк, ответ парсится на удачу, а в базу потом улетает текст, который модель придумала с очень уверенным видом. Здесь будет практический разбор именно backend-интеграции: Java, Spring Boot, openai-java, немного Spring AI, Responses API, web search и JSON на выходе.

Почему не только Spring AI

Когда приложение уже на Spring Boot, рука сама тянется к Spring AI. Это нормальный первый выбор. У Spring AI есть ChatClient, properties, prompt templates, advisors, converters и привычная Spring-интеграция. Через properties можно задать base-url, ключ, модель, retry-настройки, response format, headers и extra-body для OpenAI-compatible серверов. Это описано в документации.

Но у абстракций есть цена. Как только нужен не просто chat, а конкретные возможности Responses API, hosted web_search и точный контроль над request body, официальный SDK оказывается проще. Он ближе к реальному API. Не надо гадать, как общая модель Spring AI переложит provider-specific фичу в HTTP-запрос.

Есть еще одна неприятная особенность: в OpenAI Chat-настройках Spring AI tools описаны как function tools. Это не то же самое, что hosted tools Responses API, например web_search. В README openai-java Responses API назван основным API для работы с моделями, а Chat Completions — предыдущим стандартом, который по-прежнему поддерживается.

Не хочу разводить холивар на тему «Spring AI или официальный SDK», для себя я сформулировал простое правило. Если требуется построить систему похожую на полноценный чат (ai-бот с поддержкой диалога, RAG, агентность) — берем Spring AI с кучей готовых решений. Если же требуется более тонкая настройка или задача состоит в точечном вызове без поддержи диалога — лучше взять официальный SDK.

Клиент как обычный Spring bean

Обычно достаточно такого набора зависимостей (проверьте актуальные версии в maven central):

build.gradle.kts
dependencies {
    implementation("com.openai:openai-java:4.39.1")
    implementation("org.springframework.ai:spring-ai-model:2.0.0")
}

openai-java нужен для транспорта и моделей API. spring-ai-model дает несколько полезных классов вокруг structured output.

OpenAI SDK умеет настраивать baseUrl, apiKey, timeout, retries и другие параметры. Поэтому начинаем с конфигов:

AiProperties.java
@ConfigurationProperties(prefix = "ai")
public record AiProperties(
        String baseUrl,
        String apiKey,
        String model,
        Duration timeout
) {
}

И теперь можем построить bean OpenAIClient используя данные из application.yml:

AiConfiguration.java
@Configuration
@EnableConfigurationProperties(AiProperties.class)
class AiConfiguration {

    @Bean
    OpenAIClient openAiClient(AiProperties properties) {
        return OpenAIOkHttpClient.builder()
                .baseUrl(properties.baseUrl())
                .apiKey(properties.apiKey())
                .timeout(properties.timeout())
                .maxRetries(1)
                .build();
    }
}

Отдельная мелочь, про которую легко забыть: client не стоит создавать на каждый запрос. В README SDK есть прямое предупреждение на этот счет: внутри есть connection pool и thread pools, ими лучше пользоваться повторно.

Модель не надо хардкодить. Сегодня вы смотрите на качество, завтра на цену, послезавтра выясняется, что web search доступен только у соседнего варианта. Выносите в конфиг как в примере выше.

А вот retry я предпочитаю не доверять llm-клиенту: может приводить к лишнему потреблению токенов, даже когда это не сильно нужно. Выставляю maxRetries(1) и организую нужную retry-политику кодом.

Timeweb AI Gateway

В рамках РФ прямой доступ к языковым моделям затруднен из-за санкций, это ни для кого не секрет. Все это прекрасно обходилось через vpn, пока одно доблестное ведомство не решило ввести уже свои санкции против собственных граждан. Отечественные “аналоги” мало того что стоят в три раза дороже, так еще и сильно уступают в качестве. Поэтому в качестве условно законного варианта хочу предложить Timeweb AI Gateway. Это не реклама, просто это объективно достаточно удобный сервис для интеграции своих приложений с иностранными языковыми моделями без лишних препонов.

Timeweb AI Gateway — это прямой API к языковым моделям. Не агент, не RAG-платформа, не MCP-хаб. По сути прокси к различным моделям. Самое прекрасное — позапросная тарификация. Вам может казаться, что подписка выгоднее, но в случае приложений это совсем не так. Сегодня вашим приложением никто не пользуется, а вы продолжаете платить подписку. Завтра случилась черная пятница и лимиты исчерпались за час. Позапросная тарификация сильно выгодней для сервисов.

Минимальный сценарий такой:

  1. В панели Timeweb Cloud открыть «AI-агенты» и вкладку «AI Gateway»
    • у timeweb есть свои надстройки над моделями, которые они называют “агенты”, но вам нужен именно «AI Gateway»
  2. Создать API-ключ и выбрать модель
    • ориентируйтесь на необходимое качество, доступные tools и цену
    • на момент написания статьи самая дешевая — openai/gpt-5-nano
  3. В приложении указать baseUrl=https://api.timeweb.ai/v1, ключ и имя модели

Серьезно: обращайте внимание на поддерживаемые tools. OpenAI-compatible означает совместимую форму API, а не гарантию, что любая модель поддержит responses, native structured output и web search одновременно. Это надо проверять интеграционным тестом или хотя бы отдельным ручным вызовом. Заглядывайте в документацию конкретной модели.

Request: не мешаем инструкцию и данные

Пример плохого prompt:

prompt.txt
Вот данные пользователя: ...
Сделай с ними ... и верни JSON.

Такое часто даже работает. До первого странного пользовательского текста, смены модели или попытки покрыть это тестами. Проблема в том, что здесь смешаны задача, данные, формат ответа и бизнес-ограничения.

Я стараюсь раскладывать request на несколько частей:

  • instructions — стабильные правила поведения модели;
  • input — данные конкретного запроса;
  • schema или native structured output — контракт ответа;
  • allowlist — значения, которые модель может выбрать, но не может расширять сама;
  • tools — внешние инструменты, которые модели разрешено вызвать.

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

Пример входного dto:

EnrichmentRequest.java
public record EnrichmentRequest(
        // примерное название от пользователя
        String approximateTitle,
        // штрихкод
        String barcode,
        // какие категории есть в нашей бд
        Set<String> allowedLabels  
) {
}

Пример ответа:

EnrichmentResponse.java
public record EnrichmentResponse(
        String title,
        String summary,
        Set<String> labels
) {
}

input лучше собирать как конструктор, в зависимости от того, что нужно в конкретной ситуации.

PromptBuilder.java
private String buildInput(EnrichmentRequest request) {
    return """
            Ты помогаешь найти в интернете лекарство по штрихкоду. Найди информацию о лекарстве,
            отвечай на русском языке и верни только название, описание и подходящие теги.
            Краткое описание должно понятно объяснять, что делает лекарство без сложных терминов.
            Выбирай теги исключительно из переданного списка, не придумывай новые.
            
            Штрихкод:
            %s
            
            Предполагаемое название:
            %s

            Допустимые теги:
            %s

            Правила:
            - верни только теги из списка допустимых;
            - если данных недостаточно, не выдумывай факты;
            - summary должен быть коротким и проверяемым;
            - ответ должен соответствовать JSON-схеме.
            """.formatted(
                request.barcode(),
                request.approximateTitle(),
                String.join(", ", request.allowedLabels()
            )
    );
}

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

Structured output: сначала native

Если endpoint и модель поддерживают native structured output, начинать надо с него. Схема передается в API отдельным параметром, а не просьбой внутри prompt’а. Это сильнее, чем «верни валидный JSON, пожалуйста».

В openai-java для Responses API это делается через ResponseCreateParams.Builder.text(Class<T>). SDK строит JSON Schema по Java-классу, локально проверяет ее на совместимость с требованиями OpenAI и возвращает StructuredResponse<T>.

Responses API request
StructuredResponseCreateParams<EnrichmentResponse> params =
        ResponseCreateParams.builder()
                .model(properties.model())
                .instructions(
                        buildInput(request)
                )
                .input(buildInput(request))
                .addTool(WebSearchTool.builder()
                        .type(WebSearchTool.Type.WEB_SEARCH)
                        .build())
                .toolChoice(ToolChoiceOptions.REQUIRED)
                .text(EnrichmentResponse.class)
                .build();

Spring AI тоже считает native structured output более надежным вариантом для моделей, которые умеют работать с JSON Schema напрямую (Spring AI Structured Output).

Тогда зачем вообще вспоминать schema-in-prompt? Из-за совместимости. В OpenAI-compatible мире один gateway принимает text.format, другой отклоняет параметр, третья модель поддерживает только Chat Completions, а web search может быть доступен отдельно от structured output. В таких случаях BeanOutputConverter<T> из Spring AI остается нормальным запасным вариантом:

Fallback structured output
private final BeanOutputConverter<EnrichmentResponse> outputConverter =
        new BeanOutputConverter<>(EnrichmentResponse.class);

String input = buildInput(request) + "\n\n" + outputConverter.getFormat();

Но это именно запасной путь. Если native structured output работает у вашей модели через ваш gateway, лучше использовать его.

Responses API и web search

Теперь полный вызов. Web search подключается как hosted tool. Если поиск обязателен, можно выставить toolChoice(REQUIRED). Если это дорогой или редкий сценарий, лучше оставить автоматический выбор.

EnrichmentService.java
public EnrichmentResponse enrich(EnrichmentRequest request) {
    StructuredResponseCreateParams<EnrichmentResponse> params = ...;

    StructuredResponse<EnrichmentResponse> response = client.responses().create(params);
    EnrichmentResponse result = extractStructuredOutput(response);

    return validateAndNormalize(result, request.allowedLabels());
}

В документации OpenAI web search описан именно как tool для Responses API. Там же есть раздел про sources: если модель и API возвращают источники, их можно смотреть отдельно.

Даже при structured output ответ Responses API остается деревом output items. Там могут быть сообщения, tool calls, reasoning items и другие элементы. Поэтому извлечение typed result лучше спрятать в helper.

EnrichmentService.java
private EnrichmentResponse extractStructuredOutput(
        StructuredResponse<EnrichmentResponse> response
) {
    return response.output().stream()
            .filter(StructuredResponseOutputItem::isMessage)
            .map(StructuredResponseOutputItem::asMessage)
            .flatMap(message -> message.content().stream())
            .filter(StructuredResponseOutputMessage.Content::isOutputText)
            .map(StructuredResponseOutputMessage.Content::asOutputText)
            .findFirst()
            .orElseThrow(() -> new IllegalStateException("AI response has no structured output"));
}

StructuredResponse<T> при этом не выбрасывает исходный ответ. Raw response доступен, а значит можно смотреть status, usage, tool calls, sources и служебную информацию. В production рядом с этим helper’ом придется обработать refusal, пустой output, incomplete status, timeout и ошибки gateway. Иначе «модель не ответила» быстро превращается в NullPointerException где-нибудь в контроллере.

Ответ модели не становится правдой

Даже валидный JSON может быть неправильным. Модель могла выбрать несуществующую метку, растянуть summary на полстраницы, вернуть HTML, markdown или уверенно записать факт, которого нет.

Минимальная защита выглядит так:

  1. Получили structured output.
  2. Прогнали валидацию.
  3. Нормализовали строки.
  4. Отфильтровали значения по доступным (например из бд).
  5. Проверили бизнес-инварианты.

Я бы относился к LLM-ответу как к ответу внешнего API, причем API с богатым воображением. Схема помогает. Валидация помогает сильнее. Allowlist решает больше, чем еще одна фраза в prompt’е.

Задержки, кэш и другие вещи, о которых вспоминают поздно

Задержки

Вызов модели почти никогда не ведет себя как быстрый запрос к соседнему сервису. Даже обычный ответ может занимать десятки секунд. Web search, reasoning и большой context добавляют еще. Если это закладывать в архитектуру после релиза, будет больно.

Сначала стоит решить, может ли пользователь ждать синхронно. Для коротких сценариев request-response еще приемлемо. Хотя даже в этом случае нужно подумать, чтобы случайно не сделать вызов ai внутри database-транзакции.

Лучше всего конечно, делать асинхронно:

  • приняли запрос, сохранили, вернули id;
  • обработали по мере возможностей, можно использовать kafka для очереди;
  • результат отдали через polling или уведомление (SSE, WebSocket и тп).

Кэш

Кэш нужен не только ради скорости. В случае AI — он буквально экономит деньги. Декомпозируйте задачу и кжшируйте все, что можно кэшировать, чтобы не делать лишних ai-вызовов. Значение кэша лучше хранить уже после валидации и нормализации.

TTL зависит от задачи: результат с web search по актуальным данным стареет быстрее, чем простая нормализация текста. В некоторых случаях TTL может быть не нужен вовсе. Из хорошего — вам не нужен Redis: задержки llm настолько велики, что запрос к основной БД работает на порядки быстрее. Если хранение долговременное — используйте основную БД.

Retry

С retries тоже легко перестараться. Повторить timeout или 5xx один раз с backoff’ом — нормально. Три раза подряд переспрашивать модель, которая стабильно возвращает невалидный JSON, — просто способ заплатить втрое больше. Нужны лимиты: max input size, max output tokens, timeout, rate limit, ограничение tool calls и circuit breaker на внешний gateway.

Дедубликация

Более того, на входе обязательно нужно организовать дедубликацию! Нельзя переплачивать за то, что пользователь нажал два раза кнопку.

Observability

И отдельно observability. Помимо стандартных latency, timeout, доля fallback’ов и тд — нужно отслеживаться количество потраченных токенов. Разделяйте input и output токены, их цена отличается. Выделяйте лимиты на пользователя и алерты по системе в целом.

Что тестировать

Prompt руками не рефакторится безопасно. Нужны тесты, которые фиксируют форму запроса и поведение на плохих ответах.

Проверяем:

  • какой JSON уходит в ai: model, instructions, input, tools, tool_choice;
  • request содержит native structured output config;
  • web search включен только там, где он нужен;
  • невалидный response не проходит в бизнес-логику;
  • выдуманные поля и неизвестные labels отбрасываются;
  • API-ключ не попадает в логи.

Для HTTP-уровня хорошо подходит WireMock. Он позволяет зафиксировать request body и вернуть response, похожий на реальный Responses API. Это особенно полезно для web search: гонять настоящую модель ради проверки сериализации дорого и медленно.

Где провести границы

Spring AI можно брать там, где он снимает рутину, но не там, где прячет важную provider-specific деталь. Для сложных интеграций лучше использовать собственный SDK.

Timeweb AI Gateway — входная точка, а не магическая замена OpenAI: endpoint совместим по форме, но поддержку конкретной модели, tools и structured output все равно надо проверять.

А приложение делает самую неблагодарную работу: не верит модели на слово. Проверяет схему. Проверяет значения. Режет лишнее. Кэширует валидный результат. И заранее учитывает, что нейросеть может отвечать долго, дорого и иногда неправильно.

Это чуть больше кода, чем «спросить GPT и распарсить строку». Зато такой код можно поддерживать, тестировать и переносить между провайдерами без переписывания половины сервиса.