Агент «Исследовать» в Алисе AI может работать до 20 минут. За это время он успевает обойти десятки сайтов, запустить модели, вызвать инструменты — и сделать всё это параллельно на нескольких хостах. И если в середине цепочки что-то упадёт (а практика показывает, что если может упасть — когда-нибудь упадёт: релизы, сети, «луна не в той фазе»), агент должен уметь продолжить работу с того же места, а не начать всё заново, сжигая часы и LLM-токены. Ещё год назад никакой инфраструктуры для этого у нас не было.

Меня зовут Алексей Логинов, я ведущий разработчик в команде, которая отвечает за инфраструктуру нашего ассистента. В этой статье я покажу, какой путь мы прошли от наивного SDK до полноценной платформы Agent Transport System (ATS) — и как при этом упирались в различные ограничения и преодолевали их.

Нулевая итерация: SDK поверх OpenAI Agents

Задача изначально звучала амбициозно: придумать и реализовать работу агентского режима в Алисе — и по-хорошему так, чтобы это решение потом можно было использовать для других агентов в будущем по всему Яндексу (если уж делать навороченный велосипед своё решение — то один раз).

В нулевой итерации мы взяли OpenAI Agents SDK, написали над ним тонкую обёртку и запустили первых агентов. И с одной стороны, такое решение «просто работало», а с другой — оно слишком просто работало. Идеальный сетап такой системы: все агенты и тулы в одном процессе. Для локальной разработки терпимо, для платформы с десятками независимых сервисов — нет:

  • Стейт выполнения хранится локально. Процесс упал — начинаем заново.

  • Сбой в сервисе убивает всю ветку вызовов. В цепочке Агент1 → Агент2 → Агент3 → Агент4, где каждый агент — отдельный сервис, если Агент2 выходит из строя, например, релиз надо прокатить, сеть оборвалась или просто крашнулся луна не в той фазе :) — теряем всё ниже по дереву: результаты, промежуточные данные, прогресс. А если до этого агенты два часа гоняли тяжёлые запросы к LLM — ретрай получается «затратный» во всех смыслах.

В теории это можно было «залатать» на месте: прикрутить базу, написать ретраи, мониторинг, алерты, доступы, квоты… и повторить N раз для каждого сервиса. Но тогда:

  • Каждый агентский сервис начинает делать слишком много инфраструктурных вещей.

  • Time-to-first-run для агентов растёт: «сначала получи доступы, потом подними БД, потом всё настрой..., а уже потом, возможно...».

  • Если понадобится поддержать второй язык — эту же логику придётся всю переписать ещё раз.

Так сформировались требования к платформе:

  1. Восстановление состояния: если что-то упало — возвращаемся в состояние до падения и продолжаем работу.

  2. Распределённое выполнение по умолчанию: один агент на одном хосте, другой — на другом, тула на третьем и они спокойно взаимодействуют.

  3. Тонкий SDK/код агентов: агент реализует протокол, а сложная логика написана один раз — в платформе.

Итого: нужен центральный сервер (под кодовым названием Agent Transport System (ATS)), который умеет общаться с агентами/тулами/моделями по специализированному gRPC-протоколу. Осталось сделать это «всего лишь» безотказным и самовосстанавливающимся :)

И тут мы узнали о нём…

Temporal: чужое плечо для надёжности

Temporal — фреймворк для построения отказоустойчивых систем с довольно дерзким слоганом:

What if your code never failed? Failures happen. Temporal makes them irrelevant. Build applications that never lose state, even when everything else fails.

Если описывать Temporal максимально прикладным образом, он оперирует двумя типами сущностей:

  • Workflow — объект с состоянием, который описывает последовательность шагов. Код workflow должен быть детерминированным и без сайд-эффектов: нельзя «сходить в сеть», открыть файл, да и с рандомом всё строго.

  • Activity — функции, которые вызываются из workflow для недетерминированной работы с сайд-эффектами (сеть, БД, модели, инструменты и т. д.).

И вот почему это работает:

  • Temporal персистит историю выполнения (event history): решения workflow и результаты завершённых activity фиксируются в хранилище.

  • Если воркер с workflow‑кодом падает, Temporal восстановит выполнение: SDK «проиграет» историю (replay) и вернёт workflow в то же состояние. Уже завершённые activity при этом не вызываются повторно — их результаты берутся из history. Поэтому workflow должен быть детерминированным: при повторном прогоне он должен вызвать те же activity в том же порядке и с теми же самыми аргументами.

  • Но в то же время: activity — это at-least-once. Если activity не успела зафиксировать completion в history, Temporal может запустить её повторно, поэтому сайд-эффекты должны быть идемпотентными (или с дедупликацией).

Таким образом, если что-то где-то падает, то activity автоматически ретраятся (если настроено), а workflow при падении воркера восстанавливается через replay истории и аккуратно доводится «за ручку» до нужного состояния перед падением. Получается такой своеобразный граф выполнения с надёжностью и гарантиями. Более глубоко на том, как работает Temporal, останавливаться не буду — есть отличная статья от Яндекс Еды о том, как они его используют.

Temporal даже имеет интеграцию с OpenAI Agents SDK, и теоретически можно писать агентов прямо на Temporal. Мы так делать не стали:

  • Агентский код жёстко привязывается к Temporal SDK (потом «переехать» будет больно).

  • У нас много кода на C++, а официального Temporal SDK под C++ нет.

  • Temporal больше подходит для условной стейт-машины или графа-выполнения сложного запроса: запустили workflow, он прошёл множество фаз и выдал финальный ответ. А мы хотим уметь по мере выполнения отдавать ответы пользователю, например, стримить ответ кусками, но в Temporal стриминга из коробки нет.

Поэтому мы решили: Temporal — отличная основа для надёжности, но что, если поместить его внутри сервера платформы, а не внутри каждого сервиса-агента?

ATS: центральный сервер на Temporal с gRPC-протоколом

Итак, протокол есть, дизайн есть, Temporal поможет с надёжностью, приступаем к разработке? Соберём идею воедино:

Agent Transport System (ATS) — центральный сервер платформы, который:

  • с одной стороны — говорит с Temporal на его языке (workflow/activity, ретраи, таймауты, история);

  • с другой — предоставляет агентам простой и стабильный контракт, чтобы они вообще не знали, что где-то там под капотом крутится Temporal.

Ключевая идея: агентам не нужно знать о Temporal. Они реализуют протокол ATS и остаются относительно простыми. Если грубо, ATS берёт на себя всё то, что мы не хотим размазывать по каждому агентскому сервису:

  • Оркестрация: кто кого вызывает, когда стартовать модель/тулу/дочернего агента, какие таймауты и ретраи применять.

  • Персистентность состояния выполнения: чтобы «упали — продолжили», а не «упали — начинай сначала».

  • Единый протокол взаимодействия: агент/тула реализует gRPC-интерфейс ATS — и на этом его инфраструктурные страдания заканчиваются.

  • Транспорт данных и событий между распределёнными сервисами (агенты/тулы/модели могут быть на разных хостах).

Это всё позволяет агентскому коду оставаться тонким: агент умеет рассуждать и просить «вызови тулу/модель», а инфраструктурную магию делает ATS.

Базовый поток выполнения выглядит так:

  1. Клиент отправляет запрос в ATS.

  2. ATS делает запрос в Temporal на запуск workflow. Temporal запускает workflow.

  3. Workflow делает запрос в Temporal на запуск activity корневого агента. Temporal запускает activity корневого агента.

  4. Activity корневого агента поднимает двунаправленный gRPC-стрим к сервису агента.

  5. Если агенту нужно вызвать модель / инструмент / дочернего агента — он просит ATS, ATS сообщает workflow о необходимости запустить activity (signal/update).

  6. Workflow запускает соответствующую activity.

  7. Activity поднимает двунаправленный gRPC-стрим к сервису.

  8. Завершённые activity возвращают результаты workflow — Temporal сохраняет их.

На бумаге всё хорошо, всё отлично запускается, но… ответы-то как получить? :)

Сервер на Temporal с in-memory стримингом

Как и говорилось выше, в Temporal нет из коробки стриминга, а нам он точно нужен: агент «Исследовать» может работать 20 минут, и пользователь не должен всё это время смотреть в пустой экран.

Наиболее близкие механизмы в Temporal для произвольных точечных вызовов между activity и workflow:

  • Signal — односторонний вызов без ожидания ответа.

  • Update — двусторонний с ожиданием результата, но тяжеловесный и с ограничениями (в том числе по количеству активных запросов).

Но оба механизма имеют жёсткие лимиты на RPS, суммарное количество вызовов и другие ограничения.

Решение нашлось на форумах Temporal: если заставить все activity одного workflow запускаться на одном хосте, то activity могут обмениваться данными через in-memory очереди. Тогда можно прокинуть чанки от model / tool activity обратно в agent activity, а дальше — во внешний стрим пользователю.

Обновлённая схема выглядит вот так:

  1. Клиент отправляет запрос в ATS.

  2. ATS делает запрос в Temporal на запуск workflow. Temporal запускает workflow.

  3. Workflow делает запрос в Temporal на запуск activity корневого агента. Temporal запускает activity корневого агента.

  4. Activity корневого агента поднимает двунаправленный gRPC-стрим к сервису агента.

  5. Если агенту нужно вызвать модель / инструмент / дочернего агента — он просит ATS, ATS сообщает workflow о необходимости запустить activity (signal/update).

  6. Workflow запускает соответствующую activity.

  7. Activity поднимает двунаправленный gRPC-стрим к сервису.

  8. Все activity одного workflow общаются между собой через in-memory очереди от дочернего активити к родительскому — так чанки данных передаются в реальном времени.

  9. Корневой агент пишет свои чанки во внешний стриминговый сервис — пользователь видит ответ по мере выполнения.

  10. Завершённые activity возвращают результаты workflow — Temporal сохраняет их.

На этом этапе у нас появился рабочий MVP, который реально стримил прогресс и переживал падения. Казалось бы — можно катить в прод. Но как только мы подключили настоящих агентов с настоящими данными, реальность напомнила о себе.

Удар о реальность: лимит 2 МБ

Из коробки Temporal SDK делает кучу магии, в результате которой мы можем объявить activity, принимающую некоторый набор входных аргументов, а затем делать вызов этой activity с этими аргументами, не задумываясь о том, как эти объекты будут сериализовываться, передаваться по сети, десериализовываться и сохраняться — это всё очень умело скрыто. Условно так:

from temporalio import activity, workflow

@activity.defn
async def my_function(arg_1: str, arg_2: SomeProtobuf): …

@workflow.defn
class MyWorkflow:
  
    @workflow.run
    async def run(self):
       return await workflow.execute_activity(my_function, "some_str", SomeProtobuf(), ...)

Но всё, что умело спрятано от глаз пользователя, всё равно имеет ограничения. Например, Temporal ограничивает размер входных и выходных данных activity до 2 МБ. Но ATS, в свою очередь, не может гарантировать сам или выставлять для агентов такое ограничение: например, тот же агент «Исследовать» может изучить десятки сайтов, написать код, сгенерировать промежуточные данные, да и про всевозможные «технические» данные не забываем — и не факт, что это всё влезет в 2 МБ.

Официальная рекомендация Temporal с форума — в подобных случаях стоит большие данные хранить во внешнем хранилище, а через Temporal передавать только идентификаторы, так как Temporal не рассчитан на подобное использование. В целом этого стоило ожидать...

Мы так и сделали:

  • Входные/выходные аргументы activity вынесли во внешнее хранилище (в нашем случае Redis).

  • В Temporal прокидываем только ID.

Заодно это решило ещё одну проблему: in-memory стриминг требовал «все activity на одном хосте», а это плохо масштабируется. Мы перевели стриминг туда же — в Redis, и выполнение workflow стало по-настоящему распределённым: activity могут жить на разных хостах и всё равно обмениваться чанками данных. 

Обновлённая схема выглядит так:

Так что там с ретраями?

Внимательный читатель справедливо спросит:

Хорошо, Temporal ретраит activity. Но сервисы агентов/инструментов у вас stateless. При ретрае агент «забывает», что делал. Не получится ли, что мы всё равно начинаем заново?

Это важный момент. Тут нам пришлось сделать собственный механизм восстановления состояния, который мы назвали FastForward. Работает он следующим образом:

  • На ретрае ATS приходит к агенту и начинает взаимодействие заново (у агента-то внутреннего состояния нет).

  • Дальше агент в какой-то момент просит вызвать инструмент my_tool (или модель, или дочернего агента).

  • ATS уже видел этот вызов до падения и фиксировал его в Temporal/Redis. Поэтому ATS делает проверку:

    • Нет ли уже активной activity по этой задаче (возможно, она продолжала работать, или Temporal её уже перезапустил)? Если нет, то:

    • Нет ли уже готового результата в хранилище (activity могла завершиться, пока агент/соединение лежали)?

Если результат есть — ATS отдаёт его агенту «из кеша». Так агент шаг за шагом «перематывается вперёд» до состояния, в котором он был до сбоя, без повторных вызовов тяжёлых LLM и инструментов.

Это похоже на логику Temporal (детерминированность + история), но адаптированную к нашему протоколу и к тому, что агент — отдельный сервис, а не код внутри workflow.

Результаты

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

Будущее

Текущая схема хорошо работает в продакшене, но у неё есть два неприятных системных ограничения:

  1. Всё завязано на активных открытых соединениях. Если коннект рвётся (даже если оба хоста живы), накопленное состояние на стороне агента может потеряться. FastForward минимизирует ущерб, но при больших объёмах трафика и тысячах параллельных запросов цена «держать десятки коннектов на запрос» становится заметной.

  2. ATS — центральное связующее звено. Он гоняет через себя весь трафик между сервисами, чего в целом можно избежать или минимизировать, если сделать систему чуть более децентрализованной.

Поэтому мы уже создаём ATS 2.0 с более децентрализованной схемой и собственной системой персистентности/доставки событий, чтобы меньше зависеть от долгоживущих коннектов и снизить роль центрального хаба.

Но это уже совсем другая история — расскажем в следующий раз.