← Back to blog

Как собрать современный распределённый SaaS с нуля, полностью на ИИ-агентах

·12 min read·AI Agents

Начинать ещё один SaaS в 2026 году — странное решение. В дискуссиях пишут, что категория перенасыщена, что ИИ стёр все рвы, что строить осталось только обёртки вокруг фронтовой модели с красивым лендингом. Я читаю эти рассуждения и в основном согласен с диагнозом и полностью не согласен с выводом. Обёртки умирают первыми. Остаётся скучная, труднокопируемая инфраструктура — мультитенантность, биллинг, контроль расходов, работа с секретами, идемпотентность при повторной доставке — и эта работа не стала дешевле от того, что модель поумнела.

Это история постройки одной такой системы. Она называется Aulinq. Мультитенантная, мультиязычная платформа ИИ-агентов: отвечает клиенту в Telegram, WhatsApp или веб-чате, маршрутизирует разговор через самую дешёвую модель, которая реально с ним справится, списывает с арендатора за каждый токен доли цента и держит данные и секреты каждого арендатора изолированными от остальных. На этой основе — три продуктовых трека: white-label для агентств, продающих агентов своим клиентам, API для стартапов, встраивающих агентов в свой продукт, и простой вход для соло-фаундера, которому нужен рабочий бот без инфраструктурного PhD. Продакшн — несколько Go-сервисов, один Postgres, один Redis и шина NATS JetStream. Я — единственный инженер, и у меня есть основная работа.

Последнее — то, о чём люди на самом деле хотят услышать. Как выкатить распределённую платформу в одиночку, работая по найму? Честный ответ состоит из двух вещей, и ни одна из них не «я пишу код быстро». Первая: я перестал кодить в одиночку и начал оркестрировать команду ИИ-агентов — планировщика, исполнителя, ревьюера, верификатора, у каждого свой контекст и свои инструменты. Вторая: я перестал пытаться делать это часовыми кусками по будням. Часа вечером хватает, чтобы посмотреть дифф, утвердить план или понаблюдать за работой верификатора. Не хватает, чтобы исследовать стек-решение, набросать архитектуру или вытащить падающий сервис из разъехавшегося состояния. Для этого нужен непрерывный блок внимания, и для меня этот блок — суббота, иногда воскресенье, когда семьи нет дома.

Метод и режим работы зависят друг от друга. Выходной батч — это мыслительная работа: циклы исследования, итерации архитектуры, закрытие пробелов в библиотеках, правила для агентов. Будни — маленькие задачи «проверь и смержи». Если их перепутать, получишь наполовину исследованное стек-решение, принятое во вторник в 23:00, — ровно тот режим отказа, от которого этот метод и защищает.

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

Вопрос стека — это не «что популярно»

Первое, что сделает ИИ-ассистент по коду, когда вы спросите «какой стек взять для распределённого SaaS», — даст самый популярный ответ. Python. FastAPI. Postgres. Redis. Может, Kafka, если сказать «событийная архитектура». Это правильный ответ для туториала и часто неправильный — для системы, которую другие ИИ-агенты будут дорабатывать ближайшие два года.

Популярный стек популярен, потому что он понятен максимальному числу людей. Понятен людям — не то же самое, что понятен агентам. Агентам не нужна большая экосистема туториалов — они их уже прочитали. Им нужна система типов, которая отвергает их ошибки, рантайм, который громко падает, и артефакт деплоя с минимальным числом подвижных частей.

Я прогнал вопрос стека через глубокое исследование, а не через чат. Четыре-пять итераций: сначала широкий обзор, потом сужение до двух-трёх вариантов, которые выжили, потом глубокое чтение по каждому, потом перекрёстная проверка второй моделью, чтобы поймать смещение первой. Эта схема — для каждого важного решения в проекте, не только для стека. Это медленнее, чем спросить один раз. И быстрее, чем спросить один раз и пересобирать на четвёртом месяце.

Победил Go. Не потому, что он популярен в смысле ИИ-инструментов — нет, там доминирует Python, — а потому, что компилятор Go превращает целый класс ошибок агентов в ошибки сборки вместо стектрейсов в три часа ночи. Когда агент добавляет поле в структуру события и забывает обновить потребителя, сборка падает. В аналоге на Python потребитель молча десериализует в None, и ты узнаёшь об этом в проде.

Трейдофф реальный, и я хочу его назвать: я отказался от прямого доступа к экосистеме ML на Python. Каждый вызов модели в платформе идёт через HTTP API, а не через внутрипроцессную библиотеку. Для LLM-маршрутизатора это нормально — модели всё равно живут за провайдерами, — а вот команде, занимающейся локальным инференсом или тяжёлой предобработкой, это обошлось бы дорого. Полный разбор Go-против-Python напишу отдельно, включая случаи, где Python был бы правильным выбором.

Когда библиотеки нет — напиши её

После стека — тот же цикл исследования для каждого слоя. Шина событий: NATS JetStream вместо Kafka, потому что маршрутизация по subject-строкам — это строка, о которой агент может рассуждать, а маршрутизация по топикам со schema registry — шаг сборки, который агент сделает неправильно. Персистентность: один Postgres со схемой на домен, потому что «одна база, которую агент может прочитать целиком» бьёт «десять баз, у каждой свой инструмент миграций» по понятности для агента.

Дальше то, что удивляло людей, когда я об этом рассказывал. Несколько библиотек, которые мне были нужны, не существовали в форме, которой я хотел пользоваться. Типизированный JetStream-паблишер, оборачивающий каждое событие в конверт и выставляющий PublishInbound вместо сырого Publish(subject, []byte). Circuit breaker со скользящим окном отказов вместо наивного счётчика подряд идущих. Мультитенантное хранилище секретов с envelope-шифрованием, которое не светит плейнтекст в слой инструментов.

Реакция других инженеров обычно была: «просто возьми библиотеку X». Иногда это было правильно. Иногда существующая библиотека была тонкой обёрткой, которая заставила бы агента писать вокруг неё один и тот же boilerplate в каждом сервисе. Когда пробел настоящий, написать библиотеку — не самая большая проблема: это пара выходных с агентом, и ты получаешь ровно тот интерфейс, которого ждёт остальная система. Сейчас у платформы есть набор внутренних Go-библиотек (go-events, go-ai-providers, go-resilience, go-secrets и ещё десяток), которые импортирует каждый сервис. Каждая существует потому, что публичная альтернатива заставила бы агентов писать больше клея, а не меньше.

Брейкер — хороший пример формы. Публичные Go-библиотеки circuit breaker, которые я нашёл, считали подряд идущие отказы и сбрасывались по таймеру. Мне нужен был скользящий тайм-фрейм: считать отказы за последние шестьдесят секунд, открыться при пересечении порога, полуоткрыться для пробы, закрыться после первого успеха. Это шестьдесят строк Go:

func (cb *SlidingWindowCircuitBreaker) Call(fn func() error) error {
    cb.mu.Lock()
    switch cb.state {
    case cbOpen:
        if time.Since(cb.openedAt) >= cb.cooldown {
            cb.state = cbHalfOpen
        } else {
            cb.mu.Unlock()
            return ErrCircuitOpen
        }
    }
    cb.mu.Unlock()
 
    err := fn()
 
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if err != nil {
        cb.failures = append(cb.failures, time.Now())
        // отбрасываем отказы старше окна...
        if cb.state == cbHalfOpen || len(cb.failures) >= cb.maxFailures {
            cb.state = cbOpen
            cb.openedAt = time.Now()
        }
        return err
    }
    if cb.state == cbHalfOpen {
        cb.state = cbClosed
    }
    return nil
}

Первую версию этого агент написал за один проход. Я ревьюил, нашёл, что цикл отбрасывания старых отказов ошибается на единицу, и попросил агента починить. Суть в том, что у библиотеки теперь ровно тот интерфейс, который вызывает остальная система, без слоя адаптеров, и каждый сервис, который её импортирует, получает одинаковую семантику отказов. Оберни я популярную библиотеку — у каждого сервиса был бы свой ad-hoc враппер, и агентам, дорабатывающим эти сервисы, пришлось бы изучать каждый. Про сами библиотеки будет отдельная серия — паблишер событий, интерфейс AI-провайдеров, хранилище секретов, селектор RAG: каждый случай, где публичный вариант означал бы больше агентского клея, а не меньше.

План архитектуры итерациями

Перед разработкой я спланировал всю архитектуру вместе с агентами. Этот шаг большинство пропускает, и именно он сэкономил больше всего времени потом — хотя ощущался как самая медленная часть.

Главное, что я понял: агенту нужно отдать максимум исходного контекста, потому что дефолт агента — сделать как можно меньше. Попросите агента «спроектировать архитектуру мультитенантной ИИ-платформы» — получите трёхслойную диаграмму с боксом «AI Service». Попросите с ограничениями — вот мессенджеры, вот требование маршрутизации по ценовым уровням, вот правила тенанси, вот ожидаемый поток событий, вот чего я хочу избежать, — и получите то, что реально можно строить.

Я итерировал. Четыре, пять, шесть раундов. Получал план, читал, упирался в конкретное решение, просил пересмотреть. Агент защищал выбор, я возражал конкретным сценарием, на котором он ломался, агент переделывал. Скучно и раздражающе. Иногда я предпочёл бы кодить. Но план, переживший пять раундов «а что если так», — это план, который агент может реализовать, не выдумывая архитектуру на ходу.

Больше всего в этих итерациях я ценил обдумывание направлений, в которые ещё не иду. Текущий план должен покрывать только то, что я строю сейчас, но архитектура должна гнуться в сторону того, что я могу строить дальше. Пример: я спроектировал конфигурацию агента как неизменяемый шаблон плюс переопределение на арендатора, хотя тогда у меня был один арендатор и сплит не был строго нужен. Через полгода, когда пришёл второй арендатор и появился white-label кейс, модель переопределений уже всё обрабатывала. Построй я сначала простое «один конфиг на агента» — агенту, дорабатывающему систему, пришлось бы мигрировать модель данных под нагрузкой, а это задача, которую агенты делают плохо. Сами архитектурные решения — Go вместо Python, десять микросервисов вместо монолита, один Postgres вместо базы на сервис — получат отдельные тексты позже. Эта статья — про процесс, а не про защиту.

Цикл разработки

Когда план стал стабильным, разработка представляла собой один большой план верхнего уровня, разбитый на конкретные маленькие задачи. Не «собери сервис обмена сообщениями», а «добавь метод PublishInbound в библиотеку событий, вот структура, вот паттерн subject-а, напиши юнит-тест». Потом следующая маленькая задача. Потом следующая.

Порядок имел значение. Сначала библиотеки, с маленькими примерами, доказывающими, что каждая работает, и юнит-тестами, фиксирующими контракт. Потом ядро сервисов, с кросс-тестированием друг против друга, когда появились хотя бы два. Потом первоначальный UI — а это ещё один цикл исследования, на этот раз по фронтенд-фреймворкам, потому что первый ответ агента был React с кучей boilerplate, а мне нужно было самое маленькое, что компилируется в статику и тянет i18n без войны за шаг сборки.

Здесь живёт разделение ролей. Один агент читает кодовую базу и пишет план задачи. Второй реализует по плану. Третий читает дифф и ищет баги. Четвёртый собирает, перезапускает и гоняет тесты — и не имеет права назвать работу сделанной, пока не увидит систему здоровой своими глазами. Ни один агент не продвигает собственную работу. Исполнитель не ревьюит, ревьюер не верифицирует, верификатор не утверждает.

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

Правила для агентов: записаны, короткие

На середине сборки я заметил, что агенты расползаются. Сервис, который должен был маршрутизировать по tenant ID, маршрутизировал по account ID. Миграция добавляла колонку без выдачи привилегий, и новая роль не могла её читать. Мелочи, каждая чинится, но одна и та же форма ошибки возвращалась между сессиями, потому что агент, начинающий новую сессию, не знал правил.

Я их записал. CLAUDE.md в корне репозитория, намеренно короткий. Восемь необсуждаемых правил, каждое — одно предложение. Мультиязычность означает отсутствие языко-специфичного кода в бэкенде — LLM единственный авторитет по языку, поэтому в бэкенде только английские ключи. Чини тесты, которые трогаешь или ломаешь. Продакшн-код, никаких заглушек. Изменение схемы перезапускает локальный стек. После изменений кода — собери, перезапусти, проверь логи. Используй сабагентов для параллельной работы и циклы для верификации.

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

Правило, которое окупилось десятикратно: «LLM — единственный авторитет по языку». До него агенты добавляли в бэкенд эвристики подсчёта кириллицы и определение языка по ключевым словам — потому что это паттерн из каждого туториала по чат-ботам. Каждая такая эвристика ломалась на первом же русскоязычном арендаторе, который в середине разговора переключался на английский. После правила бэкенд вообще перестаёт пытаться определять язык: передаёт текст модели, и модель отвечает на языке, которым пишет пользователь. Одно предложение в файле правил — и целая категория багов перестала появляться.

Что бы я переделал

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

Ещё я бы написал файл правил для агентов до первого сервиса, а не на середине пути. Дрейф ранних сессий был небольшим, но каждое из тех ранних решений теперь вшито в код, который агенты дорабатывают, и дорабатывающие агенты не всегда знают, какие решения были осознанными, а какие — дрейфом. Файл правил с первого дня не поймал бы всё, но дал бы ранним агентам более ясную цель.

Эта статья — про метод. Следующая — про команду агентов в деталях: конкретные баги, которые поймали роли, и один случай, когда конвейер был избыточен. Потом архитектурные решения (Go против Python, микросервисы против монолита, NATS против Kafka, один Postgres против базы на сервис), потом библиотеки, которые пришлось писать, потом оптимизация моделей и ценовые уровни, и в конце — сторона go-to-market. Смысл записывать всё это в том, что единственный канал дистрибуции, который сработал для такого технического продукта, — инженеры, пересылающие реальную сборку другим инженерам.