Astroq AI: как я собрал астрологического ИИ-агента на Temporal, и почему именно так
Есть жанр «ещё один чат-бот поверх LLM». Обычно это тонкая обёртка: фронт шлёт сообщение, бэкенд проксирует в модель, ответ возвращается. Работает — пока что-то не падает посреди вызова инструмента, пока пользователь не открыл два устройства, пока модель не отвечает 40 секунд, и пока вам не понадобилось видеть, что вообще происходит внутри.
Astroq AI (astroq.tech) — астрологический ассистент: натальные карты, гороскопы, совместимость и планетарные транзиты. Но интересна тут не астрология, а то, как он устроен под капотом. Я сознательно строил его не как «обёртку над LLM», а как надёжного агента с долговечной оркестрацией. Ниже — на чём всё написано и почему выбран именно такой стек.
Что умеет бот
- Считает натальную карту по дате/времени/городу рождения (реальные эфемериды, не выдумка модели).
- Даёт гороскоп, разбирает совместимость знаков и текущие планетарные транзиты.
- Стримит ответ по токенам (печатающийся текст, как в ChatGPT/Claude).
- Поддерживает вход через Google, несколько чатов на пользователя с историей и ссылками на чат (
/chat/:id), как у claude.ai. - А ещё умеет прислать котика, когда транзиты складываются мрачно. Мелочь, а приятно.
Стек в одной таблице
| Слой | Технология | Зачем |
|---|---|---|
| Оркестрация агента | Temporal | надёжность, возобновляемость, наблюдаемость |
| API | Fastify (Node + TS) | быстрый, лёгкий HTTP-слой |
| Стриминг | SSE + Redis pub/sub | токены из воркера → браузер, масштабируемо |
| Хранилище | PostgreSQL | пользователи, список чатов, история сообщений |
| LLM | YandexGPT | RU-доступность, RU-биллинг (OpenAI-совместимый) |
| Астро-расчёты | astronomia + luxon | эфемериды, натальная карта |
| Фронтенд | React + Vite + Tailwind | современный SPA (+ react-router) |
| Auth | @fastify/oauth2 + cookie | OAuth без вендора, подписанная cookie |
| Деплой | Docker Compose + Caddy | self-host на Yandex Cloud, авто-HTTPS, РФ-периметр |
А теперь — почему именно так.
Почему Temporal — это сердце проекта
Это главное архитектурное решение, и оно же самое неочевидное.
Обычный агент — это цикл: спросил модель → она попросила вызвать инструмент → вызвал → отдал результат обратно модели → она ответила или попросила следующий инструмент → повторил. На бумаге безобидно. На практике это последовательность сетевых вызовов, перемешанная с состоянием, и оба источника — проблема:
- Каждый шаг — это поход наружу (к LLM, к инструменту), и любой может упасть или зависнуть: модель отвалилась по таймауту, инструмент вернул 500, сеть моргнула.
- Между шагами надо помнить контекст: что уже спросили, что ответила модель, какой инструмент в процессе, с какими аргументами. Если этот цикл живёт просто в памяти процесса, то рестарт, деплой или краш посреди разговора = всё потеряно. Пользователь остаётся с зависшим «думаю…», который никогда не закончится.
Наивное лечение — обмазать всё ретраями, вручную складывать промежуточное состояние в Redis/БД, придумывать, как «доиграть» прерванный диалог после рестарта. Это быстро превращается в самописный движок надёжности, который никто не хотел писать.
Temporal переворачивает подход — он делит агента на две части. Вся логика разговора живёт в workflow: формально это обычная функция, но по правилам Temporal она детерминирована — сама не лезет в сеть, файлы, к системным часам или генератору случайных чисел. Её дело — только решать, что делать дальше, и поручать это активностям. А всё «грязное» — вызвать модель, запустить инструмент, записать в БД, отправить токены в стрим — это уже активности: их Temporal запускает сам, при сбоях ретраит и складывает результат каждой в историю.
Соль в этой истории. При любом сбое Temporal переигрывает workflow по ней: код функции прогоняется заново, но уже выполненные активности повторно не дёргаются — их результаты подставляются из записанной истории. Снаружи выглядит так, будто процесс вообще не падал. По сути состояние диалога — это и есть история workflow, а не переменные в памяти, которые умирают вместе с процессом.
Что это даёт бесплатно
- Надёжность. Упал воркер посреди вызова инструмента — Temporal переиграет историю workflow и продолжит ровно с того места. Пользователь ничего не заметит.
- Возобновляемость. Длинные диалоги не упираются в лимит истории: workflow делает
continue-as-new, сворачивая контекст. - Наблюдаемость. Каждый шаг агента виден в Temporal UI: какие активности запускались, что упало, сколько длилось. Когда у меня в проде падал вызов модели, я открыл историю workflow и увидел точную причину:
401 Unknown api key. Не гадал по логам — посмотрел event history. - Идемпотентность через детерминированный ID. Каждый чат = workflow с ID
astro-chat-<chatId>. Повторный сигнал не плодит дубли.
Грубо, поток такой:
Браузер ──POST /send-prompt──▶ Fastify ──signal──▶ Temporal Workflow
│ (детерминированный)
┌────────────────────┼────────────────────┐
▼ ▼ ▼
RunModelActivity RunToolActivity persistMessage
(вызов YandexGPT) (натальная карта) (запись в БД)
Workflow — дирижёр. Активности — музыканты, которые ходят во внешний мир. Если музыкант споткнулся, дирижёр не теряет партитуру.
Стриминг: было поллинг — стало SSE
Было — поллинг каждые 1.5 секунды.
В первой версии фронт опрашивал бэкенд по таймеру: раз в ~1.5 секунды дёргал /get-conversation-history, сравнивал ответ и подменял содержимое чата. Работало, но ощущалось плохо:
- ответ появлялся целиком, после того как workflow полностью отработал, — никакого «печатающегося» текста;
- между «отправил» и «увидел» зияла пауза в несколько секунд, заполненная пустотой;
- сотни клиентов = сотни лишних запросов в секунду на ровном месте;
- по сути это был «псевдо-реалтайм» поверх обычного REST.
Стало — SSE с токенами по мере генерации.
Теперь токены льются в реальном времени, как в ChatGPT/Claude. Браузер держит одно долгоживущее SSE-соединение (EventSource), а сервер шлёт события по мере того, как модель генерирует ответ: reset (начать новый сегмент), token (кусок текста), confirm (нужно подтверждение инструмента), idle (ход завершён). Поллинг ушёл совсем.
Разница на ощупь: вместо «отправил → подождал в пустоту → бах, весь ответ» стало «отправил → текст печатается сразу, инструменты подтверждаются на лету». Плюс ушла паразитная нагрузка от опроса.
Как это устроено
Токены модели генерируются внутри активности (в процессе воркера Temporal), а отдавать их надо в браузер через API. Это два разных процесса. Их надо как-то связать.
Решение: воркер публикует токены в Redis (PUBLISH agent:stream:<workflowId>), а API держит SSE-соединение с браузером и форвардит туда события из Redis. Получается чисто и децентрализованно.
Важная деталь масштабирования: наивная реализация открывает по Redis-подписчику на каждое SSE-соединение — и упирается в maxclients на тысячах юзеров. Поэтому сделан один pattern-подписчик на инстанс (psubscribe agent:stream:*) с раздачей по каналам в памяти. Подключение 10 000-го клиента добавляет запись в Map, а не Redis-коннект. Бэкенд после этого масштабируется горизонтально без боли.
Хранилище: Postgres как источник истины для истории
Сначала история жила только в Temporal (рабочая память агента) и в localStorage (кэш на устройстве). Но Temporal сворачивает старое в summary при continue-as-new, а localStorage — это одно устройство.
Поэтому Postgres стал источником истины для отображения истории:
- users — аккаунты из OAuth;
- chats — список чатов пользователя (title, время, архив);
- messages — сами сообщения (каждое пишется активностью
persistMessageпо мере появления).
Temporal при этом остаётся живым оркестратором (сигналы, стриминг, контекст для LLM), а Postgres — постоянной записью. Разделение ответственности: метаданные и история ↔ оркестрация. Открыл чат с другого устройства — история подтянулась из БД, а не «потерялась с localStorage».