Разбор · Temporal-агент

Astroq AI: как я собрал астрологического ИИ-агента на Temporal, и почему именно так

Обновлено 29 июня 2026, 19:14 astroq.tech ↗ app.astroq.tech ↗

Есть жанр «ещё один чат-бот поверх LLM». Обычно это тонкая обёртка: фронт шлёт сообщение, бэкенд проксирует в модель, ответ возвращается. Работает — пока что-то не падает посреди вызова инструмента, пока пользователь не открыл два устройства, пока модель не отвечает 40 секунд, и пока вам не понадобилось видеть, что вообще происходит внутри.

Astroq AI (astroq.tech) — астрологический ассистент: натальные карты, гороскопы, совместимость и планетарные транзиты. Но интересна тут не астрология, а то, как он устроен под капотом. Я сознательно строил его не как «обёртку над LLM», а как надёжного агента с долговечной оркестрацией. Ниже — на чём всё написано и почему выбран именно такой стек.

Что умеет бот

Стек в одной таблице

СлойТехнологияЗачем
Оркестрация агентаTemporalнадёжность, возобновляемость, наблюдаемость
APIFastify (Node + TS)быстрый, лёгкий HTTP-слой
СтримингSSE + Redis pub/subтокены из воркера → браузер, масштабируемо
ХранилищеPostgreSQLпользователи, список чатов, история сообщений
LLMYandexGPTRU-доступность, RU-биллинг (OpenAI-совместимый)
Астро-расчётыastronomia + luxonэфемериды, натальная карта
ФронтендReact + Vite + Tailwindсовременный SPA (+ react-router)
Auth@fastify/oauth2 + cookieOAuth без вендора, подписанная cookie
ДеплойDocker Compose + Caddyself-host на Yandex Cloud, авто-HTTPS, РФ-периметр

А теперь — почему именно так.

Почему Temporal — это сердце проекта

Это главное архитектурное решение, и оно же самое неочевидное.

Обычный агент — это цикл: спросил модель → она попросила вызвать инструмент → вызвал → отдал результат обратно модели → она ответила или попросила следующий инструмент → повторил. На бумаге безобидно. На практике это последовательность сетевых вызовов, перемешанная с состоянием, и оба источника — проблема:

Наивное лечение — обмазать всё ретраями, вручную складывать промежуточное состояние в Redis/БД, придумывать, как «доиграть» прерванный диалог после рестарта. Это быстро превращается в самописный движок надёжности, который никто не хотел писать.

Temporal переворачивает подход — он делит агента на две части. Вся логика разговора живёт в workflow: формально это обычная функция, но по правилам Temporal она детерминирована — сама не лезет в сеть, файлы, к системным часам или генератору случайных чисел. Её дело — только решать, что делать дальше, и поручать это активностям. А всё «грязное» — вызвать модель, запустить инструмент, записать в БД, отправить токены в стрим — это уже активности: их Temporal запускает сам, при сбоях ретраит и складывает результат каждой в историю.

Соль в этой истории. При любом сбое Temporal переигрывает workflow по ней: код функции прогоняется заново, но уже выполненные активности повторно не дёргаются — их результаты подставляются из записанной истории. Снаружи выглядит так, будто процесс вообще не падал. По сути состояние диалога — это и есть история workflow, а не переменные в памяти, которые умирают вместе с процессом.

Что это даёт бесплатно

Грубо, поток такой:

Браузер ──POST /send-prompt──▶ Fastify ──signal──▶ Temporal Workflow
                                                     │  (детерминированный)
                                ┌────────────────────┼────────────────────┐
                                ▼                    ▼                     ▼
                        RunModelActivity      RunToolActivity       persistMessage
                        (вызов YandexGPT)     (натальная карта)     (запись в БД)

Workflow — дирижёр. Активности — музыканты, которые ходят во внешний мир. Если музыкант споткнулся, дирижёр не теряет партитуру.

Стриминг: было поллинг — стало SSE

Было — поллинг каждые 1.5 секунды.

В первой версии фронт опрашивал бэкенд по таймеру: раз в ~1.5 секунды дёргал /get-conversation-history, сравнивал ответ и подменял содержимое чата. Работало, но ощущалось плохо:

Стало — 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 стал источником истины для отображения истории:

Temporal при этом остаётся живым оркестратором (сигналы, стриминг, контекст для LLM), а Postgres — постоянной записью. Разделение ответственности: метаданные и история ↔ оркестрация. Открыл чат с другого устройства — история подтянулась из БД, а не «потерялась с localStorage».