Основи 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
- документація з виклику інструментів