Що буде якщо дати двом AI протилежні переконання і змусити їх сперечатись на задану тему? Саме це питання стало відправною точкою для Agent Chat — експерименту де два агенти з різними характерами ведуть діалог в реальному часі, підкріплюючи аргументи реальними фактами з Wikipedia, Tavily, NewsAPI, ArXiv і Alpha Vantage.
⚠️ Важливо про архітектуру: Agent Chat — це експериментальний проєкт
зроблений навмисно просто щоб швидко перевірити як поводяться агенти з різними
характерами. Polling замість WebSocket, синхронне читання з БД у циклі, відсутність
черг — все це свідомі компроміси заради простоти запуску і читабельності коду.
Для production multi-agent системи архітектура потребує доопрацювання.
💡 Рекомендація: запускайте локально з Ollama — це повністю безкоштовно.
Жодних API ключів, жодних витрат. qwen3:8b достатньо щоб побачити живий діалог
агентів з реальними фактами з Wikipedia і ArXiv.
GitHub: github.com/VadimKharovyuk/Agent_Chat — MIT ліцензія, повний код, README з інструкціями запуску.
Зміст
- Ідея і як це виглядає в дії
- Стек: Spring Boot 4, Spring AI 2.0, Ollama і OpenRouter
- Архітектура за 5 хвилин — entity, шари, flow від запиту до діалогу
- AiProviderConfig: як перемикати Ollama і OpenRouter через @Profile
- AgentConversationService — сервісний шар: що тут і чому логіки діалогу тут немає
- generateTopic() — як агент сам придумує тему з реальних новин
- AgentConversationRunner — серце проєкту: @Async цикл, stop-фрази, HISTORY_SIZE
- ask() — як будується контекст і чому важливий порядок ролей
- П'ять tools: Wikipedia, Tavily, NewsAPI, Alpha Vantage, ArXiv
- Як написати промпт щоб агенти реально сперечались
- Деплой: Ollama локально і Railway у продакшні
- Висновки + GitHub
Ідея і як це виглядає в дії
Класична проблема AI-агентів — вони занадто ввічливі. Попроси GPT посперечатись — він погодиться через два повідомлення. Agent Chat вирішує це через системні промпти з жорсткими заборонами і чіткими переконаннями для кожного агента.
Флоу виглядає так:
Тема → Агент A відповідає → Агент B відповідає → Агент A ...
Користувач задає:
- Тему — наприклад «Чи варто регулювати AI державою?»
- Системний промпт для Агента A — роль, переконання, заборони, формат
- Системний промпт для Агента B — протилежна позиція
- Кількість раундів — 1 раунд = 2 повідомлення (A + B)
Агенти ведуть діалог автоматично. Кожне повідомлення зберігається в PostgreSQL. Розмову можна зупинити в будь-який момент. Агент також може самостійно завершити діалог — якщо його відповідь містить стоп-фразу типу «до свидания» або «farewell».
Найцікавіша частина: агенти можуть звертатись до реальних джерел щоб підкріпити
аргументи — Wikipedia, свіжі новини, наукові статті, ціни акцій. Це робить діалог
живішим і менш галюцинаційним.
Якщо у вас вже встановлені інші моделі в Ollama — можете спробувати їх. Але з
досвіду: не всі моделі добре слідують інструкціям системного промпту. Деякі
ігнорують заборони і починають погоджуватись з опонентом вже через 2-3 раунди,
інші не викликають tools взагалі. Детальніше які моделі реально працюють на
локальному залізі —
читайте у статті про Ollama на 8GB RAM.
Стек: Spring Boot 4, Spring AI 2.0, Ollama і OpenRouter
| Компонент |
Технологія |
Навіщо |
| Backend |
Java 21, Spring Boot 4.0.6 |
Основний фреймворк |
| AI Framework |
Spring AI 2.0.0-M5 |
Абстракція над LLM провайдерами |
| LLM (local) |
Ollama (qwen3:8b / llama3.1:8b) |
Локальна розробка без витрат |
| LLM (prod) |
OpenRouter (deepseek/deepseek-chat) |
Хмарний провайдер для Railway |
| Database |
PostgreSQL |
Зберігання розмов і повідомлень |
| Frontend |
Thymeleaf + Bootstrap 5 |
Простий UI без окремого SPA |
| Tools |
Wikipedia, Tavily, NewsAPI, Alpha Vantage, ArXiv |
Реальні факти для аргументів |
Ключове рішення стеку — Spring AI як абстракційний шар. Весь код у Runner і Service працює з інтерфейсом ChatModel — він не знає чи під капотом Ollama чи OpenRouter. Перемикання відбувається виключно через Spring Profile.
Архітектура за 5 хвилин — entity, шари, flow від запиту до діалогу
Структура проєкту класична для Spring Boot — чіткий розподіл відповідальності між шарами:
src/main/java/com/example/agent_chat/
├── config/
│ └── AiProviderConfig.java # Ollama / OpenRouter провайдери
├── controller/
│ ├── HomeController.java
│ └── AgentConversationController.java
├── entity/
│ ├── AgentConversation.java # Розмова: тема, промпти, статус
│ ├── AgentMessage.java # Повідомлення: sender, content, round
│ ├── AgentSender.java # Enum: AGENT_A / AGENT_B
│ └── ConversationStatus.java # Enum: RUNNING / STOPPED / FINISHED
├── repository/
│ ├── AgentConversationRepository.java
│ └── AgentMessageRepository.java
├── service/
│ ├── AgentConversationService.java # Тонкий сервісний шар
│ ├── AgentConversationRunner.java # @Async цикл діалогу
│ └── WikipediaSearchTool.java # + інші tools
└── dto/
├── StartConversationRequest.java
├── ConversationResponse.java
└── ExperimentMapper.java
Flow від запиту до першого повідомлення агента:
HTTP POST /start
↓
AgentConversationController
↓
AgentConversationService.start()
↓ зберігає AgentConversation в БД зі статусом RUNNING
↓
AgentConversationRunner.run() ← @Async (окремий thread)
↓
[цикл раундів]
↓
ask() → Spring AI → Ollama / OpenRouter → відповідь
↓
saveMessage() → AgentMessage в БД
↓
HTTP GET /conversation/{id} ← фронтенд polling
Важливо: controller повертає conversationId одразу, не чекаючи поки агенти завершать діалог. Фронтенд сам поллить стан розмови. Це стандартний патерн для @Async операцій.
AiProviderConfig: як перемикати Ollama і OpenRouter через @Profile
Одне з найелегантніших рішень у проєкті — конфігурація провайдерів через Spring Profiles. Весь код Runner і Service залежить від інтерфейсу ChatModel — конкретна реалізація підставляється через DI залежно від активного профілю:
@Configuration
public class AiProviderConfig {
// ── LOCAL: Ollama ─────────────────────────────────────────────
@Configuration
@Profile("local")
static class OllamaConfig {
@Bean
@Primary
public ChatModel primaryChatModel(OllamaChatModel ollamaChatModel) {
return ollamaChatModel;
}
@Bean("agentChatModel")
public ChatModel agentChatModel(OllamaChatModel ollamaChatModel) {
return ollamaChatModel;
}
}
// ── PROD: OpenRouter ──────────────────────────────────────────
@Configuration
@Profile("openai")
static class OpenAiConfig {
@Bean
@Primary
public ChatModel primaryChatModel(OpenAiChatModel openAiChatModel) {
return openAiChatModel;
}
@Bean("agentChatModel")
public ChatModel agentChatModel(OpenAiChatModel openAiChatModel) {
return openAiChatModel;
}
}
}
Зверніть увагу на два біни: primaryChatModel і agentChatModel. Це не дублювання — це дві різні ролі:
- primaryChatModel — використовується в
AgentConversationService.generateTopic() для генерації теми. Це швидкий запит без tools.
- agentChatModel — використовується в
AgentConversationRunner для основного діалогу. Саме він отримує @Qualifier("agentChatModel").
Переключення між профілями:
# Локально — application-local.properties
spring.ai.ollama.chat.model=qwen3:8b
# Продакшн — змінна середовища
SPRING_PROFILES_ACTIVE=openai
OPENAI_API_KEY=your_openrouter_key
Зверніть увагу на @ConditionalOnProperty(name = "app.agent.experiment.enabled", havingValue = "true") на Runner і Service. Це означає що весь агентський функціонал вимкнено за замовчуванням і вмикається явно. Корисно коли хочете деплоїти застосунок без агентів або додавати нові фічі поступово.
AgentConversationService — сервісний шар: що тут і чому логіки діалогу тут немає
Сервісний шар у цьому проєкті навмисно тонкий. Він не містить логіки діалогу — вона вся в Runner. Відповідальність AgentConversationService:
- Створити
AgentConversation в БД і делегувати запуск в Runner
- Зупинити розмову через зміну статусу
- Надати CRUD для читання розмов
- Генерувати тему через
generateTopic()
@Service
@ConditionalOnProperty(name = "app.agent.experiment.enabled",
havingValue = "true", matchIfMissing = false)
public class AgentConversationService {
private final AgentConversationRepository conversationRepository;
private final AgentMessageRepository messageRepository;
private final AgentConversationRunner runner;
private final ChatModel primaryChatModel;
private final NewsApiSearchTool newsApiSearchTool;
public Long start(StartConversationRequest request) {
// Зберігаємо розмову в БД
AgentConversation conversation = new AgentConversation();
conversation.setTopic(request.topic());
conversation.setSystemPromptA(request.systemPromptA());
conversation.setSystemPromptB(request.systemPromptB());
conversation.setTotalRounds(0);
conversationRepository.save(conversation);
int maxRounds = request.maxRounds() > 0 ? request.maxRounds() : 100;
// Делегуємо в Runner — він піде в @Async thread
runner.run(
conversation.getId(),
request.systemPromptA(),
request.systemPromptB(),
request.topic(),
maxRounds
);
// Повертаємо ID одразу — не чекаємо завершення
return conversation.getId();
}
public void stop(Long conversationId) {
AgentConversation conversation = conversationRepository
.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + conversationId));
conversation.setStatus(ConversationStatus.STOPPED);
conversation.setFinishedAt(LocalDateTime.now());
conversationRepository.save(conversation);
}
@Transactional
public void deleteById(Long id) {
messageRepository.deleteByConversationId(id);
conversationRepository.deleteById(id);
}
}
Зверніть на метод stop(): він просто змінює статус в БД на STOPPED. Не зупиняє thread напряму — Runner сам перевіряє статус на початку кожного раунду і між A і B. Це безпечніший підхід ніж переривання потоку.
Чому логіка циклу не в Service? Принцип єдиної відповідальності. Service керує станом розмови в БД і надає API для контролера. Runner виконує сам діалог. Якщо завтра потрібно додати WebSocket замість polling або змінити логіку чергування агентів — ці зміни торкнуться тільки Runner, не Service.
generateTopic() — як агент сам придумує тему з реальних новин
Окрема цікава фіча: якщо користувач не хоче вигадувати тему сам — система генерує її автоматично через реальні новини. Ось повний метод:
public String generateTopic() {
List<String> queries = List.of(
"technology AI society",
"economy inflation future",
"climate environment crisis",
"politics democracy freedom",
"science space exploration",
"healthcare medicine future",
"education technology students",
"cryptocurrency bitcoin finance"
);
// Вибираємо рандомну категорію
String randomQuery = queries.get(
(int) (Math.random() * queries.size())
);
// Отримуємо свіжі новини по категорії
String news = newsApiSearchTool.searchNews(randomQuery);
// Просимо LLM сформулювати провокаційну тему
String prompt = """
На основі цих новин придумай одну провокаційну тему
для філософської дискусії.
Тема має бути спірною — щоб два агенти з протилежними
поглядами могли сперечатись.
Відповідай ТІЛЬКИ темою — одне речення, без пояснень, без лапок.
Новини: %s
""".formatted(news);
return primaryChatModel.call(prompt).trim();
}
Три кроки: рандомна категорія → NewsAPI → LLM формулює тему. Якщо щось пішло не так (NewsAPI недоступний, LLM не відповів) — fallback на дефолтну тему: «Чи змінить штучний інтелект майбутнє людства?».
Важлива деталь: тут використовується primaryChatModel а не agentChatModel. Для генерації теми не потрібні tools і складний контекст — це простий text-in text-out запит. Розділення бінів виправдане.
AgentConversationRunner — серце проєкту: @Async цикл, stop-фрази, HISTORY_SIZE
AgentConversationRunner — це компонент що виконує сам діалог в окремому потоці. Розберемо ключові частини:
Основний цикл
@Async
public void run(Long conversationId, String systemPromptA,
String systemPromptB, String topic, int maxRounds) {
String message = topic; // Перше повідомлення — тема розмови
for (int round = 1; round <= maxRounds; round++) {
// Перевірка чи не зупинили вручну
AgentConversation conversation = conversationRepository
.findById(conversationId).orElseThrow();
if (conversation.getStatus() == ConversationStatus.STOPPED) return;
// Агент A відповідає
List<AgentMessage> historyA = messageRepository
.findByConversationIdOrderByRoundNumberAsc(conversationId);
String replyA = ask(systemPromptA, historyA, message, AgentSender.AGENT_A);
saveMessage(conversation, AgentSender.AGENT_A, replyA, round);
// Перевірка stop-фрази і ручної зупинки між A і B
if (containsStopPhrase(replyA)) { finish(conversation, round); return; }
conversation = conversationRepository.findById(conversationId).orElseThrow();
if (conversation.getStatus() == ConversationStatus.STOPPED) return;
// Агент B відповідає
List<AgentMessage> historyB = messageRepository
.findByConversationIdOrderByRoundNumberAsc(conversationId);
String replyB = ask(systemPromptB, historyB, replyA, AgentSender.AGENT_B);
saveMessage(conversation, AgentSender.AGENT_B, replyB, round);
if (containsStopPhrase(replyB)) { finish(conversation, round); return; }
message = replyB; // Відповідь B стає вхідним повідомленням для A
sleep(500); // Невелика пауза між раундами
}
finish(conversation, maxRounds);
}
Stop-фрази
private static final List<String> STOP_PHRASES = List.of(
"до свидания", "прощай", "на этом всё",
"goodbye", "farewell", "конец разговора"
);
Якщо агент вирішив завершити розмову природним чином — система це розпізнає і зупиняє цикл. Фрази перевіряються після кожної відповіді — і після A, і після B.
HISTORY_SIZE = 8
Не вся історія розмови передається в кожен запит — тільки останні 8 повідомлень. Це критично важливе обмеження: без нього контекстне вікно переповнюється на довгих розмовах, а вартість запиту зростає пропорційно до кількості раундів. 8 повідомлень = 4 раунди назад — достатньо для зв'язності діалогу.
Зверніть на подвійне читання з БД. Перед запитом Агента A і перед запитом Агента B — окремі запити в репозиторій для отримання свіжої історії. Це не баг — це свідоме рішення: між A і B відповідь A вже збережена в БД, тому B повинен бачити оновлену історію.
ask() — як будується контекст і чому важливий порядок ролей
Метод ask() — найтехнічніша частина проєкту. Розберемо детально:
private String ask(String systemPrompt, List<AgentMessage> history,
String lastMessage, AgentSender currentSender) {
List<Message> messages = new ArrayList<>();
// 1. Системний промпт — роль і переконання агента
messages.add(new SystemMessage(systemPrompt));
// 2. Останні HISTORY_SIZE повідомлень з правильними ролями
history.stream()
.skip(Math.max(0, history.size() - HISTORY_SIZE))
.forEach(m -> {
if (m.getSender() == currentSender) {
// Своя репліка → AssistantMessage
messages.add(new AssistantMessage(m.getContent()));
} else {
// Репліка опонента → UserMessage
messages.add(new UserMessage(m.getContent()));
}
});
// 3. Останнє повідомлення від опонента
messages.add(new UserMessage(lastMessage));
// 4. Запит з усіма 5 tools
ToolCallback[] tools = ToolCallbacks.from(
wikipediaSearchTool, tavilySearchTool,
alphaVantageTool, arxivSearchTool, newsApiSearchTool
);
return agentChatModel.call(
new Prompt(messages,
ToolCallingChatOptions.builder()
.toolCallbacks(tools)
.build()))
.getResult().getOutput().getText();
}
Ключовий момент — маппінг ролей в history. LLM очікує що AssistantMessage — це те що говорив він сам, а UserMessage — те що говорив користувач (в нашому випадку — опонент). Якщо переплутати — модель «забуде» свою позицію і почне погоджуватись з опонентом.
Тому для кожного агента одна і та ж БД-запис може бути або AssistantMessage або UserMessage — залежно від того який агент зараз відповідає.
Закоментований код removeThinkingBlock(). У репозиторії є закоментований варіант ask() з видаленням <think>...</think> блоків. Деякі моделі (qwen3 зокрема) повертають внутрішні міркування у тегах think — і якщо їх не прибрати, вони потраплять у відповідь. Для продакшн використання з qwen3 рекомендую розкоментувати цю логіку.
П'ять tools: Wikipedia, Tavily, NewsAPI, Alpha Vantage, ArXiv
Кожен tool — це Spring-компонент з методом позначеним @Tool. Spring AI автоматично реєструє їх і передає опис в LLM. Модель сама вирішує який tool викликати залежно від контексту питання.
WikipediaSearchTool — факти і визначення
@Tool(description = """
Шукає інформацію у Wikipedia.
Використовуй для визначень, фактів, історії, біографій.
Використовуй ТІЛЬКИ одне-два слова для пошуку.
""")
public String searchWikipedia(String query) {
// Скорочуємо запит до першого слова — Wikipedia погано
// обробляє довгі фрази
String shortQuery = query.trim().split("\\s+")[0];
WikiSearchResponse response = restClient.get()
.uri("https://ru.wikipedia.org/w/api.php", uriBuilder -> uriBuilder
.queryParam("action", "query")
.queryParam("list", "search")
.queryParam("srsearch", shortQuery)
.queryParam("format", "json")
.queryParam("srlimit", "1")
.build())
.retrieve()
.body(WikiSearchResponse.class);
String title = response.query().search().get(0).title();
String snippet = response.query().search().get(0).snippet()
.replaceAll("<[^>]+>", "").trim(); // Прибираємо HTML теги
return "Стаття: " + title + "\n" + snippet;
}
Важлива деталь: Wikipedia повертає snippet з HTML тегами (<span class="searchmatch"> тощо) — їх потрібно прибирати перед передачею в LLM.
AlphaVantageTool — ціни акцій для економічних дискусій
@Tool(description = """
Отримує поточну ціну акцій або фінансові дані компанії.
Запит — тікер акції: AAPL, GOOGL, TSLA, AMZN.
""")
public String getStockPrice(String symbol) {
Map response = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/query")
.queryParam("function", "GLOBAL_QUOTE")
.queryParam("symbol", symbol.toUpperCase())
.queryParam("apikey", apiKey)
.build())
.retrieve()
.body(Map.class);
Map<String, String> quote = (Map<String, String>) response.get("Global Quote");
return String.format("Акція %s: $%s | Зміна: %s | Макс: $%s | Мін: $%s",
symbol, quote.get("05. price"), quote.get("10. change percent"),
quote.get("03. high"), quote.get("04. low"));
}
Таблиця всіх tools
| Tool |
Використання |
Безкоштовний ліміт |
| Wikipedia |
Визначення, факти, біографії, наукові поняття |
✅ Необмежено |
| Tavily Search |
Актуальні новини, свіжа статистика, веб-пошук |
1,000 / місяць |
| NewsAPI |
Свіжі новини по темі як аргумент |
100 / день |
| Alpha Vantage |
Ціни акцій, фінансові дані для економічних дискусій |
25 / день |
| ArXiv |
Наукові статті та дослідження |
✅ Необмежено |
Практична порада по @Tool description: опис tool — це системний промпт для LLM що пояснює коли і як його використовувати. Чим точніший опис — тим рідше модель викликає tool недоречно або з неправильними параметрами. Зверніть на «Використовуй ТІЛЬКИ одне-два слова» у Wikipedia tool — без цього модель надсилала б довгі речення і отримувала порожні результати.
Як написати промпт щоб агенти реально сперечались
Якість діалогу майже повністю залежить від системного промпту. Ось структура що працює:
Обов'язкові елементи промпту:
- Роль — хто цей агент, його характер і переконання
- Позиція — що він відстоює і у що вірить
- Заборони — з чим він НІКОЛИ не погоджується (найважливіше!)
- Формат — коротко, з фактами, питання в кінці
- Мова — вкажіть явно
Приклад сильного промпту (Агент A — капіталіст):
Ти жорсткий капіталіст, мільярдер, власник корпорації.
Віриш що вільний ринок — єдиний шлях до процвітання.
Перед відповіддю шукай у Wikipedia факти про ВВП, рівень життя.
НІКОЛИ не погоджуйся з комуністичними ідеями.
Говори цифрами і фактами. Зневажаєш планову економіку.
Відповідай коротко — 2-3 речення.
Закінчуй провокаційним питанням.
Відповідай ТІЛЬКИ українською мовою.
Приклад слабкого промпту (так робити не треба):
Ти прихильник капіталізму. Відстоюй свою позицію.
Різниця між сильним і слабким промптом — в деталізації заборон. Без чіткого «НІКОЛИ не погоджуйся» модель знайде компроміс через 2-3 повідомлення і діалог стане нудним.
Поради для живого діалогу:
- Вказуйте конкретні джерела для пошуку: «шукай у Wikipedia», «перевір ціну акцій»
- Вимагайте питання в кінці кожної репліки — це провокує відповідь
- Обмежуйте довжину: 2-3 речення — оптимально, більше стає нудно
- Вказуйте мову явно — без цього модель може перемикатись
- Для qwen3:8b додайте: «не використовуй теги think у відповіді»
Деплой: Ollama локально і Railway у продакшні
Локальний запуск з Ollama
# 1. Встановлюємо і запускаємо модель
ollama pull qwen3:8b # якісне тестування (~5GB)
# або
ollama pull llama3.1:8b # швидке тестування (~4.7GB)
ollama serve
# 2. Створюємо БД
psql -c "CREATE DATABASE Agent_Chat;"
# 3. application-local.properties
spring.ai.ollama.chat.model=qwen3:8b
spring.datasource.url=jdbc:postgresql://localhost:5432/Agent_Chat
spring.datasource.username=postgres
spring.datasource.password=your_password
spring.profiles.active=local
# 4. Запуск
mvn spring-boot:run
Відкрити: http://localhost:1024
Порівняння моделей для локального тестування:
| Модель |
Час відповіді |
Якість tool calling |
RAM |
| qwen3:8b |
2+ хв |
⭐⭐⭐ Краще слідує інструкціям |
~8GB |
| llama3.1:8b |
20–30 сек |
⭐⭐ Швидше але слабше |
~6GB |
Продакшн на Railway через OpenRouter
# Змінні середовища на Railway
SPRING_PROFILES_ACTIVE=openai
OPENAI_API_KEY=your_openrouter_key # ключ OpenRouter
DB_URL=jdbc:postgresql://...
DB_USERNAME=postgres
DB_PASSWORD=your_password
APP_AGENT_EXPERIMENT_ENABLED=true # вмикаємо агентів
Чому Railway а не Heroku або Fly.io? Railway безкоштовно надає PostgreSQL і простий деплой через GitHub. Для експериментального проєкту це оптимально — не потрібно налаштовувати окремий БД сервіс. OpenRouter з deepseek/deepseek-chat коштує значно менше ніж прямий OpenAI — і для тестового проєкту це правильний вибір.
Висновки
Agent Chat — це не production-ready продукт, а живий експеримент що показує кілька важливих архітектурних підходів:
- @Async цикл з перевіркою стану в БД — простий і надійний спосіб керувати довготривалими фоновими операціями без черг повідомлень
- Spring Profiles для перемикання провайдерів — увесь код залежить від інтерфейсу, конкретна реалізація підставляється через DI
- Тонкий сервісний шар + окремий Runner — правильний розподіл відповідальності коли логіка складна і асинхронна
- @Tool з детальним description — якість опису tool напряму впливає на коректність виклику моделлю
- HISTORY_SIZE обмеження — обов'язковий елемент для контролю витрат і розміру контексту в довгих розмовах
Повний код доступний на GitHub — MIT ліцензія, можна використовувати як основу для власних агентних проєктів:
→ github.com/VadimKharovyuk/Agent_Chat
Читайте також:
→ Яку модель Ollama обрати для агента з tool calling: порівняння і бенчмарки — якщо хочете детальніше розібратись з вибором локальної моделі для tool calling.
→ GPT-Realtime-2 vs Gemini Live API: що обрати у 2026 році — якщо розглядаєте голосових агентів замість текстових.
Джерела: Agent Chat GitHub репозиторій, Spring AI Documentation, Ollama Model Library, OpenRouter API Docs