Веб-разработка долгие годы молилась на stateless-архитектуру. REST-паттерны диктуют суровое правило: сервер обязан забыть клиента в ту же миллисекунду, когда закрывается TCP-соединение. Балансировщики нагрузки тасуют запросы между десятками серверов, PHP-скрипты умирают после отдачи ответа, Node.js-воркеры перерабатывают контексты. Это прекрасно работало для CRUD-приложений, где всё состояние жестко зафиксировано в базе данных. Но для ИИ-разработки мир без состояний обернулся настоящим архитектурным проклятием.
Пользователь пишет в Telegram-бот: «А как эту функцию переписать на Go?». Вебхук бьет в ваш бэкенд. В теле запроса (payload) лежит только этот крошечный текст и ID пользователя. Большие языковые модели (LLM) вроде GPT-4 или Claude 3.5 не хранят историю чатов. Они представляют собой чистый лист при каждом новом запросе. Если вы отправите этот вопрос напрямую в LLM, модель либо начнет галлюцинировать, либо вежливо уточнит: «О какой функции идет речь?».
Проблема потери памяти между HTTP-запросами — главный вызов при создании коммерческих AI-ботов. Вы не просто маршрутизируете запросы, вы берете на себя роль операционной системы, управляющей памятью для процессора, лишенного долгосрочного хранилища.
Иллюзия непрерывности
Когда мы общаемся в мессенджере, диалог выглядит как единая неразрывная лента. Технически же каждый webhook — это изолированный старт приложения. Чтобы модель понимала контекст, мы вынуждены искусственно склеивать фрагменты прошлого и отправлять их заново.
В RouterAPI и других шлюзах, реализующих спецификацию OpenAI, этот механизм опирается на массив messages. Вы несете полную ответственность за сборку этого массива при каждом HTTP-вызове.
Казалось бы, решение банально: достаем из базы всю историю переписки и шлем в API. На практике этот подход умирает на второй день работы продакшена. Во-первых, вы упираетесь в лимит контекстного окна (например, 128k токенов). Во-вторых, вы начинаете сжигать деньги. Провайдеры тарифицируют каждый переданный токен. Отправляя 10 000 слов истории ради одного нового вопроса «ок, спасибо», вы оплачиваете чтение всех 10 000 слов заново.
Проектирование персистентного состояния
Архитектура памяти требует жесткой схемы данных. Использование Redis для хранения истории чатов — частая ошибка новичков. Redis вытесняет старые ключи (eviction) и теряет данные при перезапусках. История диалогов должна лежать в реляционной БД или специализированном NoSQL-хранилище, где вы гарантируете ACID (или хотя бы надежную персистентность).
Базовая структура в PostgreSQL/MySQL включает три сущности:
users(кто пишет).dialogues/sessions(контекстные ветки, так как один юзер может вести несколько параллельных чатов).messages(реплики).
Но как только вы начинаете сохранять сообщения, вы сталкиваетесь с первой суровой реальностью асинхронного мира: состоянием гонки (Race Conditions).
Пользователь отправляет три сообщения подряд с интервалом в полсекунды. Мессенджер выстреливает три вебхука. Балансировщик раскидывает их на три параллельных воркера. Воркер А читает историю, отправляет запрос в RouterAPI (это занимает 5 секунд). Воркер B читает ту же историю без учета первого сообщения, отправляет запрос. Воркер С делает то же самое. Итог: база данных замусорена дублями, бот отвечает три раза невпопад, контекст сломан.
Чтобы предотвратить это, таблица dialogues должна иметь поле lock_expires_at или механизм распределенных блокировок (например, через Redis). Бэкенд обязан захватить блокировку чата. Если блокировка висит, новые вебхуки либо складываются в очередь (Queue), либо отклоняются, заставляя пользователя подождать. Никаких параллельных вызовов к LLM в рамках одного диалога.
Сборка Payload и транзакционные границы
Еще одна ловушка кроется в транзакциях баз данных. Распространенный антипаттерн:
DB::beginTransaction;
try {
$msg = Message::create(['content' => $userText, 'role' => 'user']);
// ОШИБКА: Долгий HTTP-запрос внутри открытой транзакции
$response = Http::post('https://routerapi.net/v1/chat/completions', $payload);
Message::create(['content' => $response['choices'][0]['message']['content'], 'role' => 'assistant']);
DB::commit;
} catch (Exception $e) {
DB::rollBack;
}
Выполнение HTTP-запроса к нейросети внутри открытой транзакции БД уничтожит ваш пул соединений. Ответ от модели может генерироваться 10, 20 или 60 секунд. Все это время коннект к базе будет заблокирован. При 100 активных пользователях ваша база рухнет из-за исчерпания лимита подключений.
Правильный паттерн работы с состоянием:
- Сохранить сообщение пользователя и зафиксировать транзакцию.
- Поставить отметку в БД (или кэше), что бот «печатает».
- Выполнить длительный запрос к RouterAPI.
- Получить ответ.
- Открыть новую транзакцию, сохранить ответ ассистента, снять блокировку.
Стратегии усечения памяти (Truncation)
Поскольку бесконечно скармливать историю нейросети невозможно, инженерам приходится применять алгоритмы сжатия контекста перед отправкой в RouterAPI.
1. Скользящее окно (Sliding Window) Самый примитивный подход: LIMIT 20 ORDER BY created_at DESC. Бот помнит только последние 20 реплик. Работает для коротких сессий поддержки. Ломается, когда пользователь ссылается на факт, упомянутый 21 реплику назад.
2. Асинхронная суммаризация Когда размер диалога достигает определенного порога токенов, бэкенд запускает фонового воркера. Воркер берет старые сообщения (например, с 1 по 50) и отправляет их в дешевую, быструю модель (например, google/gemini-2.5-flash через RouterAPI) с промптом: «Составь техническую выжимку диалога. Сохрани ключевые факты, имена, даты и достигнутые договоренности». Результат сжатия сохраняется в базе, а старые сообщения помечаются как «архивные». В следующий payload для LLM подмешивается одно системное сообщение: [Summary: Пользователь разрабатывает парсер на Python, использует библиотеку BeautifulSoup, столкнулся с блокировками по IP]. Это экономит тысячи токенов на каждом запросе.
3. Инъекция фактов (RAG) Вместо отправки линейной истории, реплики пользователя векторизуются. При новом вопросе бэкенд ищет семантически похожие прошлые утверждения и добавляет их в контекст. Это сложно в реализации, но позволяет боту вспомнить, как зовут собаку пользователя, упомянутую полгода назад, не пересылая модели полгода чатов.
Интеграция с RouterAPI
Код сборки состояния для шлюза (на примере PHP/Laravel) выглядит как слоистый пирог. Сначала системный промпт, затем возможная выжимка памяти, затем последние N сообщений.
$recentMessages = Message::where('dialogue_id', $dialogue->id)
->where('is_archived', false)
->latest('id')
->take(15)
->get
->reverse;
$payloadMessages = [
['role' => 'system', 'content' => 'Ты технический эксперт. Отвечай кратко и без воды.']
];
if ($dialogue->summary_text) {
$payloadMessages[] = [
'role' => 'system',
'content' => 'Контекст прошлых бесед: ' . $dialogue->summary_text
];
}
foreach ($recentMessages as $msg) {
$payloadMessages[] = [
'role' => $msg->role,
'content' => $msg->content
];
}
$response = Http::withToken(config('RouterAPI.key'))
->timeout(60)
->post('https://routerapi.net/v1/chat/completions', [
'model' => 'anthropic/claude-3.5-sonnet',
'messages' => $payloadMessages,
'temperature' => 0.7
]);
Важно учитывать отказоустойчивость. RouterAPI агрегирует десятки провайдеров, но сеть всегда ненадежна. Если API возвращает 403, 502 или отваливается по таймауту, механизм состояния обязан это корректно переварить. Сообщение пользователя не должно помечаться как «обработанное», иначе оно выпадет из диалога. Бэкенд должен либо выполнить автоматический ретрай (retry), либо вернуть пользователю текстовую ошибку, оставив его сообщение в базе последним, чтобы при следующем запросе контекст восстановился корректно.
Разработка AI-ботов заставляет нас осознать простую истину. LLM — это просто мощный, но абсолютно беспамятный процессор. База данных и логика бэкенда — это оперативная память и жесткий диск. Собирая эти компоненты воедино через REST API, мы фактически с нуля пишем операционную систему для диалогов. Волшебного API для памяти не существует. Вы либо жестко контролируете состояние на своей стороне, либо ваш бот страдает хронической амнезией.