Основы Spring AI: ChatClient, стриминг, память и инструменты с Ollama
Запускаем AI-приложение на Spring Boot со Spring AI и локальной моделью Ollama: ChatClient, шаблоны промптов, стриминг, структурированный вывод, память чата и вызов инструментов.
Представьте AI-функцию, которая сегодня работает на OpenAI. В следующем квартале растёт счёт, команда хочет попробовать Ollama на ноутбуках, или компания стандартизируется на Azure OpenAI, Bedrock или Gemini. Без слоя абстракции смена провайдера может прокатиться волной по всему коду: настройка клиента, формат запросов, код стриминга, эмбеддинги, описания инструментов и разбор ответов. Spring AI даёт разработчикам на Spring Boot единый набор API для разных провайдеров, и эта волна остаётся маленькой.
Здесь речь о практических основах: что даёт фреймворк, настройка проекта, конфигурация Ollama, ChatClient, шаблоны промптов, стриминг, структурированный вывод, память чата и вызов инструментов. Поиск, векторные хранилища, ETL, фильтрация, наблюдаемость, оценка, мультимодальный ввод, MCP и советы для продакшена оставлены на следующую тему.
Все примеры работают на Ollama, так что API-ключ для повторения никому не нужен. Пример проекта собран на Spring Boot 3.5.5 и Spring AI 1.0.9. Существует и стабильная линейка Spring AI 2.0.x, но она рассчитана на Spring Boot 4.0.x и 4.1.x, поэтому смешивать два набора версий в одной сборке не стоит.
Словарь до кода
Новичкам терминология может даваться тяжелее кода. Вот термины, которые встретятся ниже.
- LLM (большая языковая модель): модель, которая генерирует текст. Сюда относятся GPT, Claude, Gemini и локальные модели вроде Llama. На вход подаётся текст, а модель предсказывает полезный текст на выходе.
- Модель: конкретный движок, который вызывается. Здесь его локально запускает Ollama. В облаке это может быть OpenAI, Anthropic, Azure OpenAI, Amazon Bedrock или Google Vertex AI.
- Промпт: инструкция или вопрос, отправляемый модели, например «Explain Spring AI in one paragraph» (объясни Spring AI в одном абзаце). Чёткие, конкретные промпты с достаточным контекстом дают ответы лучше.
- Токен: небольшая единица текста — слово, часть слова, знак препинания или пробел. Цены, размер контекста и длина ответа обычно считаются в токенах.
- Контекстное окно: максимальный объём текста, который модель может учесть за раз. В него входят промпт, история чата, найденные документы, результаты инструментов и сам ответ.
- ChatClient: fluent API Spring AI для чат-моделей. Вместо того чтобы вручную собирать HTTP-вызовы под конкретного провайдера, код читается как chatClient.prompt().user(...).call().content().
- Шаблон промпта: многоразовый промпт с плейсхолдерами. «Explain {topic} in {language}» во время выполнения превращается в «Explain RAG in Java».
- Структурированный вывод: запрос данных у модели в предсказуемой форме и их отображение на Java-record или класс. Полезно, когда приложению нужны поля, а не проза.
- Память чата: история разговора, которой управляет приложение. По умолчанию модели не хранят состояние, поэтому прошлые сообщения нужно отправлять заново всякий раз, когда модель должна их помнить.
- Advisor: компонент Spring AI, который меняет или дополняет запрос до того, как он попадёт к модели. Например, memory advisor добавляет прошлые сообщения, а RAG advisor, о котором позже, добавил бы найденные документы.
- Вызов инструментов: модель просит приложение выполнить Java-метод. Сама она к базе данных или API не обращается. Она запрашивает инструмент, приложение его выполняет, и результат возвращается модели.
Что входит в Spring AI
Фреймворк — не просто обёртка над chat completions. Для Java-команд это полноценный прикладной фреймворк для AI-нагрузок. Основные части:
- переносимые API моделей для чата, эмбеддингов, изображений, распознавания и синтеза речи и модерации
- интеграции с OpenAI, Anthropic, Azure OpenAI, Amazon Bedrock, Google Vertex AI и Gemini, Mistral, Ollama и другими
- ChatClient — fluent API в духе WebClient и RestClient
- шаблоны промптов и типы сообщений для системного, пользовательского, ассистентского и медиаконтента
- отображение ответов модели на Java-record или классы
- стриминг через Project Reactor
- advisor’ы для повторяющихся паттернов вроде памяти и RAG
- вызов инструментов, чтобы модель могла запрашивать Java-методы
- модели эмбеддингов за абстракциями векторных хранилищ
- дополнения для продакшена: ETL, наблюдаемость, оценка и MCP
Поверхность широкая, поэтому разбор ниже держится основного потока приложения.
Настройка проекта
Создайте Maven-проект на Java 21 со Spring Boot 3.5.x, Spring Web и стартером Spring AI для Ollama. Bill of materials держит все модули Spring AI на совместимых версиях.
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.9</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Для локального демо с Ollama добавьте веб-стартер вместе со стартером Ollama.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
Кто предпочитает OpenAI, может заменить стартер модели.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
Одно предупреждение: держите в classpath только один стартер чат-модели, если не настроен основной бин ChatModel. Когда там есть и OpenAI, и Ollama, Spring находит двух кандидатов и не может собрать ChatClient.Builder по умолчанию, и приложение не стартует.
Запуск локальной модели
Установите Ollama и скачайте чат-модель.
ollama pull llama3.2
Затем укажите её в application.properties.
spring.application.name=spring-boot-ollama
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=llama3.2
Запустите приложение.
mvn spring-boot:run
Оно слушает здесь:
http://localhost:8080
Простой чат через ChatClient
ChatClient — высокоуровневый API, с которого стоит начинать большинству Spring-приложений.
@RestController
public class SpringAiDemoController {
private final ChatClient chatClient;
public SpringAiDemoController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/api/demo/simple")
public String simpleChat(@RequestParam(defaultValue = "Tell me a joke") String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
}
Пробуем из терминала:
curl "http://localhost:8080/api/demo/simple?message=Explain%20Spring%20AI%20in%20one%20paragraph"
Вызов раскладывается на четыре шага. prompt() открывает новый запрос, user(...) прикрепляет сообщение, call() выбирает блокирующее выполнение, а content() возвращает сгенерированный текст.
Шаблоны промптов
Захардкоженные промпты тяжело поддерживать. Шаблон хранит многоразовый текст с параметрами.
@GetMapping("/api/demo/template")
public String templateChat(
@RequestParam(defaultValue = "Microservices") String topic,
@RequestParam(defaultValue = "Java") String language) {
PromptTemplate template = new PromptTemplate(
"Explain the concept of {topic} using examples in {language}. Keep it brief.");
String renderedPrompt = template.render(Map.of(
"topic", topic,
"language", language
));
return chatClient.prompt()
.user(renderedPrompt)
.call()
.content();
}
Вызываем эндпоинт с темой и языком:
curl "http://localhost:8080/api/demo/template?topic=Retrieval-Augmented%20Generation&language=Java"
Шаблоны лучше всего подходят, когда промпт — часть поведения приложения, а не разовая строка.
Стриминг
Длинные ответы стоит стримить, чтобы пользователь сразу видел вывод, а не ждал весь текст.
@GetMapping(value = "/api/demo/stream", produces = "text/event-stream")
public Flux<String> streamChat(
@RequestParam(defaultValue = "Write a short story about a brave robot.") String message) {return chatClient.prompt()
.user(message)
.stream()
.content();
}
Проверьте в браузере или через curl в режиме без буферизации.
curl -N "http://localhost:8080/api/demo/stream?message=Write%20a%20short%20Spring%20AI%20intro"
Spring MVC отдаёт Flux строк как Server-Sent Events.
Структурированный вывод
Текст хорош для чата, но бизнес-софту обычно нужны структурированные данные. Spring AI умеет запросить ответ, который отображается на Java-тип. С локальными моделями лучше работает явное описание формата JSON.
public record MovieReview(String title, double rating, String shortSummary) {
}
@GetMapping("/api/demo/structured")
public MovieReview structuredOutput(@RequestParam(defaultValue = "Inception") String movie) {
return chatClient.prompt()
.user(u -> u.text("Review the movie {movie}.")
.param("movie", movie))
.call()
.entity(MovieReview.class);
}
Пробуем:
curl "http://localhost:8080/api/demo/structured?movie=The%20Matrix"
Если вызвать entity с классом record, Spring AI добавит в промпт инструкции по формату и сразу преобразует полученный JSON в record. Это аккуратнее и надёжнее, чем разбирать JSON-строки вручную.
Память разговора
Модели не помнят прошлые запросы, поэтому историю передаёт приложение. Spring AI решает это через абстракции памяти и advisor’ы, которые регистрируются по умолчанию в билдере клиента.
this.chatClient = chatClientBuilder
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder().build()
).build()
)
.build();
После этого каждый запрос несёт идентификатор разговора.
@GetMapping("/api/demo/memory")
public String chatWithMemory(
@RequestParam String message,
@RequestParam(defaultValue = "chat_session_1") String chatId) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, chatId))
.call()
.content();
}
Эффект видно на двух вызовах с одним id: сначала назовите имя, потом спросите, каким оно было.
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=My%20name%20is%20Yash"
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=What%20is%20my%20name?"
В продакшене замените хранение в памяти на постоянный репозиторий: JDBC, Redis, Cassandra, MongoDB или Neo4j.
Вызов инструментов
Инструменты позволяют модели запрашивать работу, определённую приложением. Прямого доступа к базе данных или API у модели нет. Она просит, код приложения запускает инструмент, и результат возвращается модели.
Инструмент — это обычный метод с описаниями.
public static class WeatherTools {
@Tool(description = "Get the current weather for a city")
public WeatherResponse getCurrentWeather(
@ToolParam(description = "City name") String city) {
return new WeatherResponse(city, 22.5, "Sunny");
}
}
public record WeatherResponse(
String location,
double temperatureCelsius,
String condition) {
}
Он публикуется как бин.
@Bean
public WeatherTools weatherTools() {
return new WeatherTools();
}
Затем запрос его подключает.
@GetMapping("/api/demo/function")
public String functionCalling(
@RequestParam(defaultValue = "What is the weather in Paris?") String message) {
return chatClient.prompt()
.user(message)
.tools(weatherTools)
.call()
.content();
}
Вопрос о погоде в городе прогоняет весь цикл целиком.
curl "http://localhost:8080/api/demo/function?message=What%20is%20the%20weather%20in%20Paris?"
Хорошие примеры: проверить остатки на складе, получить погоду, найти клиента, открыть тикет в поддержку или запустить процесс. С разрушительными действиями нужна строгость: добавьте авторизацию, валидацию, аудит и подтверждение пользователем, где это необходимо.
Запуск всего демо
Снова запустите приложение.
mvn spring-boot:run
Потом по очереди проверьте эндпоинты.
curl "http://localhost:8080/api/demo/simple?message=Explain%20Spring%20AI%20in%20one%20paragraph"
curl "http://localhost:8080/api/demo/template?topic=Retrieval-Augmented%20Generation&language=Java"
curl -N "http://localhost:8080/api/demo/stream?message=Write%20a%20short%20Spring%20AI%20intro"
curl "http://localhost:8080/api/demo/structured?movie=Inception"
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=My%20name%20is%20Yash"
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=What%20is%20my%20name?"
curl "http://localhost:8080/api/demo/function?message=What%20is%20the%20weather%20in%20Paris?"
Частые ошибки
Если добавить несколько стартеров моделей и не выбрать один, при запуске будет такая ошибка:
No qualifying bean of type 'ChatModel' available: expected single matching bean but found 2
Оставьте в демо один стартер или явно укажите основную модель.
Если пользоваться памятью чата без идентификатора разговора, каждый вызов будет выглядеть как новый чат. Передавайте id как параметр advisor’а.
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, chatId))
Третья ловушка — слишком свободно открывать инструменты. Инструменты — это реальные действия приложения, поэтому валидируйте их входные данные, проверяйте права и избегайте разрушительных операций, если нет процесса согласования.
Что имеем на данный момент
Фундамент Spring AI-приложения готов: чат, промпты, стриминг, структурированный вывод, память и инструменты. Этих частей уже хватает на множество полезных сценариев: чат-ассистенты, суммаризаторы, эндпоинты для структурированного извлечения данных и контролируемые сценарии с вызовом инструментов. После чата идёт слой знаний на эмбеддингах и векторных хранилищах, а поверх него — RAG, ETL-конвейеры, фильтры по метаданным, наблюдаемость, оценка, мультимодальный ввод, MCP и подготовка к продакшену.
Полный демо-проект можно клонировать с GitHub.
git clone https://github.com/Yash2462/blog-demos.git
После клонирования откройте папку spring-ai-demo и следуйте README в ней.
Дополнительные материалы
- справочная документация Spring AI
- руководство по началу работы со Spring AI
- страница о чат-модели Ollama в документации Spring AI
- документация по ChatClient
- документация по вызову инструментов