От одного ко многим, или рождение многомодельности
Всё началось с банальной житейской хитрости. Заметил, что разные модели хороши для разных задач: deepseek-v3.1:671b отлично пишет код, gpt-oss:120b хорош для рерайта, а qwen3-coder:480b — настоящий мастер разъяснения сложных концепций простым языком.
Переключаться между ними вручную, копируя контекст, — удовольствие ниже среднего. Да и просто интересно стало: а что, если отправить один и тот же запрос сразу нескольким моделям и сравнить результаты? Так родилась идея многомодельности.
Переосмысление архитектуры данных
Но просто так взять и послать запрос нескольким моделям нельзя. Нужно было ответить на главный вопрос: как хранить диалог, в котором участвует несколько ассистентов?
Старая структура с одной сессией (sessionId) и массивом сообщений messages больше не подходила. Ведь у нас появилось как минимум два уровня:
- Родительская сессия (Parent Session). Объединяет все диалоги по одной теме. Представьте это как комнату в чате.
- Дочерняя сессия (Child Session). Отдельный диалог для каждой конкретной модели внутри этой комнаты.
Модель данных в MongoDB претерпела серьёзные изменения. Взгляните на новый ChatHistory.java:
java
// backend/src/main/java/ru/akademit/ai/ollama/entity/ChatHistory.java
package ru.akademit.ai.ollama.entity;
// ... imports ...
import java.util.concurrent.ConcurrentHashMap;
@Data
@NoArgsConstructor
@Document(collection = "chat_sessions")
public class ChatHistory {
@Id
private String id;
@Indexed(unique = true)
@Field("parent_session_id") // <-- Уникальный идентификатор «комнаты»
private String parentSessionId;
@Field("model_histories")
private ConcurrentHashMap<String, ModelHistory> modelHistories = new ConcurrentHashMap<>();
@Field("created_at")
private LocalDateTime createdAt;
@Field("last_updated_at")
private LocalDateTime lastUpdatedAt;
// ... вложенные классы ChatMessage и ModelHistory ...
@Data
@NoArgsConstructor
public static class ModelHistory {
@Field("model_name")
private String modelName;
@Field("messages")
private List<ChatMessage> messages = new ArrayList<>();
@Field("created_at")
private LocalDateTime createdAt;
@Field("last_updated_at")
private LocalDateTime lastUpdatedAt;
public ModelHistory(String modelName) { /* ... */ }
public void addUserMessage(String content) { /* ... */ }
public void addAssistantMessage(String content) { /* ... */ }
public List<ChatMessage> getLastMessages(int limit) { /* ... */ }
}
// Методы для работы с конкретной моделью
public ModelHistory getOrCreateModelHistory(String model) { /* ... */ }
public void addUserMessage(String model, String content) { /* ... */ }
public void addAssistantMessage(String model, String content) { /* ... */ }
public List<ChatMessage> getLastMessages(String model, int limit) { /* ... */ }
}
Ключевое изменение — появление ConcurrentHashMap<String, ModelHistory>. Ключ — это имя модели, а значение — отдельная история переписки именно с этой моделью. Самое важное здесь — то, что история пользователя (user role) теперь хранится в каждой ModelHistory отдельно. Это гарантирует, что каждая модель получает полный контекст диалога.
Как это работает на бэкенде
Теперь в запросе от фронтенда не одна модель, а их список (List<String> models). Посмотрим на обновлённый ChatService.java:
java
// backend/src/main/java/ru/akademit/ai/ollama/service/ChatService.java (фрагмент)
public void processAndStreamChat(ChatMessagePayload payload) {
log.info("Processing multi-model chat request for parent session: {}, models: {}",
payload.getParentSessionId(), payload.getModels());
String parentSessionId = payload.getParentSessionId();
// 1. Получаем или создаём родительскую сессию
ChatHistory parentSession = getOrCreateParentSession(parentSessionId);
// 2. Сохраняем сообщение пользователя для КАЖДОЙ выбранной модели
for (String model : payload.getModels()) {
parentSession.addUserMessage(model, payload.getMessage());
}
historyRepository.save(parentSession);
// 3. Запускаем обработку для каждой модели в отдельном потоке
List<CompletableFuture<Void>> futures = payload.getModels().stream()
.map(model -> CompletableFuture.runAsync(() -> processSingleModel
(parentSessionId, model, payload.getTemperature())))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenRun(() -> log.info("All models processed for parent session: {}", parentSessionId));
}
private void processSingleModel(String parentSessionId, String model, Double temperature) {
String destination = "/topic/chat/" + parentSessionId + "/" + model; // <-- Уникальный топик для каждой модели
try {
ChatHistory parentSession = getParentSession(parentSessionId);
// 4. Получаем историю ТОЛЬКО для этой модели
List<ChatHistory.ChatMessage> historyMessages = parentSession.getLastMessages(model, MAX_HISTORY_MESSAGES);
// ... формируем запрос к Ollama, добавляя сообщения из historyMessages ...
// ... стримим ответ в destination ...
// 5. Сохраняем ответ ассистента для этой модели
saveAssistantMessageToSession(parentSessionId, model, fullResponse.toString());
// ...
} catch (Exception e) { /* обработка ошибок */ }
}
Алгоритм прост и элегантен:
- Получаем «комнату» (
parentSessionId). - Сохраняем вопрос пользователя в историю каждой выбранной модели.
- Параллельно запускаем обработку для каждой модели.
- Каждая модель читает только свою историю, общается с Ollama Cloud и стримит ответ в свой уникальный вебсокет-топик (
/topic/chat/{parentSessionId}/{model}). - Ответ модели сохраняется в её личную историю.
А что на фронтенде?
Самое интересное, конечно, происходит в интерфейсе. Концепция одной вкладки (ChatTab) ушла в прошлое. Теперь у нас есть массив вкладок.
typescript
// frontend/src/components/Chat.tsx (фрагмент)
interface ChatTab {
model: string; // Модель, например deepseek-v3.1:671b-cloud
displayName: string; // Отображаемое имя (без -cloud)
sessionId: string; // Уникальный ID дочерней сессии
messages: Message[]; // Массив сообщений для этой вкладки
isLoading: boolean; // Флаг загрузки для этой модели
}
const Chat: React.FC = () => {
const [tabs, setTabs] = useState<ChatTab[]>([]);
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
// ... остальные стейты
}
Пользователь выбирает модели в мультиселекте. Для каждой выбранной модели создаётся своя вкладка. Отправляя сообщение, мы видим, как одновременно несколько вкладок заполняются ответами от разных ассистентов. Можно переключаться между ними, сравнивать результаты, анализировать, какая модель лучше справилась с задачей.
Вкладки с индикаторами загрузки и счётчиками сообщений делают этот процесс наглядным и управляемым. Это уже не просто чат, а настоящая тестовая лаборатория.
Копирование истории, или «научи другого тому, что знаешь сам»
Работая с несколькими моделями, я быстро понял, что часто лучшие результаты даёт тандем. Например, пусть одна модель (sourceModel) генерирует отличный код, а вторая (targetModel) — пишет к нему документацию. Но как познакомить вторую модель с контекстом, если она не участвовала в генерации кода? Скопировать историю.
На бэкенде появился простой, но полезный эндпоинт:
java
// backend/src/main/java/ru/akademit/ai/ollama/controller/ChatController.java
@PostMapping("/history/copy/{targetParentSessionId}/{sourceModel}/{targetModel}")
public ResponseEntity<Void> copyHistory(
@PathVariable("targetParentSessionId") String targetParentSessionId,
@PathVariable("sourceModel") String sourceModel,
@PathVariable("targetModel") String targetModel) {
chatService.copyModelHistory(targetParentSessionId, sourceModel, targetModel);
return ResponseEntity.ok().build();
}
И его реализация в сервисе:
java
// backend/src/main/java/ru/akademit/ai/ollama/service/ChatService.java
public void copyModelHistory(String targetParentSessionId, String sourceModel, String targetModel) {
log.info("Copying history from {} to {} for parent session: {}", sourceModel, targetModel, targetParentSessionId);
try {
ChatHistory parentSession = getParentSession(targetParentSessionId);
ChatHistory.ModelHistory sourceHistory = parentSession.getModelHistories().get(sourceModel);
if (sourceHistory == null) {
log.warn("Source model {} not found. Nothing to copy.", sourceModel);
return;
}
// Создаём новую историю для целевой модели и копируем все сообщения
ChatHistory.ModelHistory targetHistory = new ChatHistory.ModelHistory(targetModel);
for (ChatHistory.ChatMessage msg : sourceHistory.getMessages()) {
targetHistory.getMessages().add(new ChatMessage(msg.getRole(), msg.getContent()));
}
targetHistory.setCreatedAt(sourceHistory.getCreatedAt());
targetHistory.setLastUpdatedAt(LocalDateTime.now());
parentSession.getModelHistories().put(targetModel, targetHistory);
parentSession.setLastUpdatedAt(LocalDateTime.now());
historyRepository.save(parentSession);
log.info("Successfully copied history from {} to {}", sourceModel, targetModel);
} catch (Exception e) {
log.error("Failed to copy model history", e);
}
}
Мы просто берём массив сообщений из sourceModel и вставляем его в новую ModelHistory для targetModel. Это позволяет передать контекст любой новой модели, используя опыт предыдущих диалогов.
Цивилизованные миграции с помощью Flyway
И вот тут мы подходим к самому важному инфраструктурному моменту. Когда я менял структуру документа в MongoDB, передо мной встал вопрос: а как обновлять уже существующие базы данных пользователей?
Просто удалить всё и создать заново — это не вариант. Нужны были инструменты для версионирования схемы данных, как это делается в PostgreSQL с помощью Liquibase или Flyway.
К моему удивлению, Flyway (да-да, тот самый Flyway для SQL) отлично поддерживает MongoDB. Как это работает:
- Мы подключаем зависимость
flyway-database-mongodb. - Создаём JS-скрипты миграций в
resources/db/migration/. - Flyway применяет их по порядку (V1, V2, V3…) при запуске приложения.
Взгляните на мою вторую миграцию (V2__Update_chat_sessions_structure.js), которая как раз и преобразует старые документы в новую структуру.
javascript
// backend/src/main/resources/db/migration/V2__Update_chat_sessions_structure.js
// Миграция для обновления структуры коллекции chat_sessions
db = db.getSiblingDB('chatdb');
print('Running migration V2: Update chat_sessions structure');
const sessions = db.chat_sessions.find({});
let migratedCount = 0;
sessions.forEach(session => {
let needsUpdate = false;
const updateDoc = {};
// 1. Переименовываем session_id в parent_session_id
if (session.session_id && !session.parent_session_id) {
updateDoc.parent_session_id = session.session_id;
needsUpdate = true;
}
// 2. Конвертируем старую структуру (одна модель) в новую (model_histories)
if (session.model && !session.model_histories) {
// Заменяем точки в имени модели на подчёркивания, т. к. точки в ключах MongoDB — не очень хорошая практика
const safeModelKey = session.model.replace(/./g, '_');
updateDoc.model_histories = {};
updateDoc.model_histories[safeModelKey] = {
model_name: session.model,
messages: session.messages || [],
created_at: session.created_at,
last_updated_at: session.last_updated_at || session.created_at
};
needsUpdate = true;
}
// 3. Удаляем старые поля
if (needsUpdate) {
const unsetFields = {};
if (session.session_id) unsetFields.session_id = "";
if (session.model) unsetFields.model = "";
if (session.messages) unsetFields.messages = "";
db.chat_sessions.updateOne(
{ _id: session._id },
{
$set: updateDoc,
$unset: unsetFields
}
);
migratedCount++;
print(`Updated session: ${session._id}`);
}
});
print(`V2 migration completed. Updated ${migratedCount} documents.`);
Этот скрипт пробегает по всем существующим документам и аккуратно преобразует их. Старые поля (session_id, model, messages) удаляются, новые (parent_session_id, model_histories) — добавляются. И всё это под контролем Flyway, который запомнит, что версия V2 уже применена, и не будет выполнять её повторно.
Как подключить Flyway к MongoDB в Spring Boot
В pom.xml добавляем зависимость (она находится не в центральном репозитории Maven, поэтому я положил JAR в папку lib проекта и подключил как системную):
xml
<!-- backend/pom.xml -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-mongodb</artifactId>
<version>12.0.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/flyway-database-mongodb-12.0.0.jar</systemPath>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
И конфигурация в application.yml:
yaml
# backend/src/main/resources/application.yml
spring:
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
# URL для MongoDB в формате JDBC (да, Flyway это понимает!)
url: ${spring.data.mongodb.uri:mongodb://${spring.data.mongodb.username}
:${spring.data.mongodb.password}@${spring.data.mongodb.host}:${spring.data.mongodb.port}
/${spring.data.mongodb.database}?authSource=${spring.data.mongodb.authentication-database}}
После этого при каждом запуске приложения Flyway будет проверять, все ли миграции применены, и если нет — выполнит их. Это делает обновление приложения безопасным и предсказуемым.
Итоги
- Многомодельность превратила чат из одиночки в команду ассистентов. Теперь можно наглядно сравнивать ответы разных нейросетей на один запрос.
- Копирование истории — простой, но мощный инструмент, позволяющий делиться контекстом между разными моделями.
- Flyway для MongoDB решил проблему эволюции схемы данных. Больше никаких ручных манипуляций с базой. Миграции версионированы, автоматизированы и безопасны.
Проект продолжает расти. В следующей серии, возможно, расскажу о системных промтах и более тонкой настройке каждой модели прямо из интерфейса.
Заходите на GitVerse в ветке multiple-models, ставьте звёздочки, предлагайте идеи. Вместе мы сделаем лучший клиент для облачных LLM.