Lovable выпустила Chats и раскрыла архитектуру траекторий и входящих сообщений для мультиагентного взаимодействия
Оригинальный заголовок: Inside Chats: How Lovable's Agents Work Together
Lovable выпустила Chats — агента, который работает на уровне рабочего пространства, может вести диалог между проектами и запускать сборку. После подтверждения изменений он передаёт задачу builder-агенту внутри проекта и возвращает ход выполнения обратно в диалог.
Lovable раскрыла трёхуровневую архитектуру Chats: траектории, входящие сообщения и активация. Её можно перенести на собственную оркестрацию мультиагентных систем.
Создавать агентный продукт становится заметно интереснее, когда из одного диалога нужно запустить работу где-то ещё.
Агент делегирует задачу другому агенту. У этого агента своя история, и он, возможно, уже что-то делает. Новые инструкции приходят до того, как задача завершена. А результаты должны вернуться в тот диалог, который всё это запустил. Как связать эти части, не потеряв контекст, не перепутав зоны ответственности и не превратив каждое взаимодействие в отдельный случай?
Это лишь часть инженерных вопросов, стоящих за Chats — нашим новым способом обсуждать идеи, принимать решения и доводить дело до конца, держа в поле зрения ваши проекты в Lovable. Вы начинаете с диалога и, когда готовы, подтверждаете сборку, которая превращает идею в изменения в приложении.
Chats опирается на инфраструктуру, которую мы изначально разработали для совместной работы агентов и субагентов. В обычный будний день наша Trajectory System добавляет около полумиллиарда событий, охватывая примерно 2.6 миллиона пользовательских ходов.
В этом посте мы разберём инженерную сторону такого масштаба, компромиссы, на которые пошли, и то, как Chats использует эту основу, чтобы связать диалоги с работой над вашими приложениями.
Если коротко
Наша архитектура разделяет три вещи, которые легко перепутать: что произошло, что агенту нужно знать и когда ему следует запуститься.
То, что произошло, мы записываем в историю, доступную только для добавления и поддерживающую форки, — траекторию. Контекст модели каждого агента строится на основе этой истории, а не сама история используется как промпт. Агенты общаются через надёжные входящие ящики, а активации обеспечивают их запуск и получение новой работы.
В Chats именно это связывает обсуждение идеи с её реализацией. Как только вы подтверждаете изменение, Chats передаёт работу агенту, который собирает ваше приложение, и возвращает его обновления обратно в диалог.
Вот как мы построили эту связь.
Траектории: журнал событий по образцу Git
Каждый диалог агента в Lovable — это журнал событий. Мы фиксируем всё, что выдаёт LLM, на уровне гранулярности Lovable Agent Framework: UserMessage, AgentStart и AgentDone обрамляют ответ агента, IterationStart и IterationEnd — каждый вызов LLM, вывод модели с рассуждениями, содержимым и tool_call, а также полный жизненный цикл инструмента — ToolParametersValidated, ToolExecutionStart, ToolExecutionEnd и ToolApprovalRequired, когда нужно участие человека. Каждый вызов инструмента сопоставляется со своим результатом. Ничего не выбрасывается: события неизменяемы и только добавляются в конец, и даже откат фиксируется как событие Revert, а не удалением чего-либо.
Дизайн повторяет коммиты и историю Git. У каждого события один указатель на родителя. Пройдите по цепочке родителей назад — и получите полный журнал. Именованные heads указывают на вершину каждой линии истории, как ветки в Git.
Это единственное свойство бесплатно даёт нам кое-что мощное: форк можно сделать на любой границе итерации. Из любого IterationEnd или AgentDone в диалоге мы можем создать новый head, указывающий на это событие. Новая ветка разделяет всю историю до этой точки — точно так же, как ветвление в Git, — а форк почти ничего не стоит, потому что события не копируются: первое событие на форке, ThreadForkConfig, просто указывает на точку форка как на своего родителя.
Одно намеренное отступление от Git: у события ровно один родитель. Мы никогда не сливаем одну траекторию с другой, потому что семантика слияния двух историй агентов неясна: что вообще должно означать чередование двух независимых потоков мыслей, вызовов инструментов и результатов? Когда агентам нужно поделиться результатами, они не сливаются, а отправляют друг другу сообщения через Плоскость управления агентами, к которой мы вернёмся позже в этой статье. Траектории только ветвятся.
Контекст — это проекция траектории
Траектория — источник истины, но мы передаём модели не её саму. В начале каждой итерации, сразу после записи IterationStart, конструктор промпта идёт от головы агента назад и строит промпт по траектории. Журнал событий фиксирует всё, что произошло; какую часть этого увидит модель и в каком виде — отдельное решение, принимаемое для каждого вызова.
Уточним: контекст всегда собирается из траектории — выигрыш не в том, чтобы избежать этой работы, а в гибкости собрать его под конкретную ситуацию. Самый наглядный пример — компактирование, и оно записывается в журнал, как и всё остальное. Когда агент решает выполнить компактирование, он записывает PromptCompactionStart в свою траекторию и продолжает работу. Суммаризатор работает в фоне как небольшой собственный цикл агента, на боковой траектории, которая ответвляется от истории агента. Его результат никогда не попадает на траекторию агента напрямую. Вместо этого боковая траектория работает почти как входящие: на следующей границе итерации агент решает принять готовую сводку и записывает её как PromptCompactionEnd в свою траекторию. Построение следующего промпта превращается в обратный проход: встретив End в паре с его Start, конструктор берёт сводку вместо более старых событий и отображает всё после точки компактирования как обычную историю.
Такое разделение журнала событий и контекста LLM открывает целый ряд паттернов:
- Асинхронное компактирование. Суммаризатор работает, пока агент продолжает итерации. Агент никогда не останавливается ради него; при построении следующего промпта он просто находит готовое компактирование и использует его.
- Фоновые агенты с общей историей. Конструктор промпта форкнутого агента проходит по событиям родителя как по своей собственной истории — форк бесплатно видит ровно ту же историю, что и оригинал, и строит по ней свой промпт.
- Промпты под конкретные сценарии. Конструктор, субагенты и другие агенты строят промпты из одних и тех же типов событий, но с разными предустановками: какие уведомления включать, подтягивать ли контекст кодовой базы, как подавать унаследованную историю.
Стриминг: частичные события и дельты полей
События неизменяемы и целостны, но ответ LLM приходит потоком небольших фрагментов, и пользователи хотят видеть, как он появляется. Если ждать, пока событие с содержимым будет готово целиком, прежде чем что-то показать, агент будет казаться зависшим. Поэтому в Системе траекторий есть побочный канал для событий, которых ещё нет: частичные события.
Когда агент начинает получать, скажем, блок размышлений от модели, он открывает частичное событие: PartialOpened, несущее начальные поля события и уникальный идентификатор. По мере поступления фрагментов он выдаёт PartialDelta — небольшие правки одного поля этого открытого частичного события, обычно «дописать этот текст». Каждая дельта сразу отправляется в браузер, который применяет её к своей текущей копии и отрисовывает текст слово за словом. Когда модель заканчивает блок, агент дописывает в траекторию настоящее, полное событие размышления с тем же уникальным идентификатором. Это добавление закрывает частичное событие: все, кто следил за дельтами, теперь знают, в какое сохранённое событие они складываются, и заменяют свою текущую копию на достоверную.
Частичные события — не события. У них нет позиции в траектории, нет родителя, и они никогда не сохраняются; сохраняются только окончательные события. Это упрощает путь записи — когда поток заканчивается, нечего отбрасывать или уплотнять — и делает живое чтение честным. Браузер, подключившийся в середине ответа, получает уже сохранённые события плюс каждое открытое частичное событие, материализованное один раз, с уже применёнными дельтами, а дальше следит за живым потоком. Переподключения, обновления страницы и несколько вкладок сходятся к одной и той же траектории, потому что траектория — единственное, что вообще записывалось.
Два журнала на каждого агента: входящие и траектория агента
Вот деталь, которая оказалась очень важной: у каждого агента на самом деле два журнала событий.
- Входящие. Сюда попадает всё, что отправлено агенту: пользовательские UserMessage и ExternalAgentNotification от других агентов или из управляющей плоскости — завершение подагента, сообщение от другого агента, запланированное пробуждение.
- Траектория агента. Собственно история работы агента: что он думал, говорил и делал.
Каждый раз при запуске агент смотрит во входящие и переносит всё ещё не обработанное в свою траекторию. Это происходит в начале запуска и затем на каждой границе итерации, так что сообщение, пришедшее в середине ответа, подхватывается как реплика в скобках, а не ждёт завершения всего ответа.
Такое разделение отвязывает внешнее поступление от выполнения. Сообщения могут накапливаться во входящих в любое время и откуда угодно — другие участники пишут только во входящие, никогда в траекторию агента — но агент вносит их в свою историю по собственному расписанию, в чётко определённых точках. Траектория остаётся чистой упорядоченной записью того, что агент действительно обработал.
Управляющая плоскость агента
Система Trajectory даёт каждому агенту долговечную историю, которую можно форкать. Но она ничего не говорит о том, когда агент запускается, где он работает и как результаты одного агента попадают к другому. Как только агентов становится больше одного, все эти вопросы встают одновременно. Строитель порождает вспомогательных подагентов, и ему нужно получить их результаты обратно. Фоновый форк должен запускаться, когда сессия затихает. Один агент хочет передать работу нескольким другим и получать от них отчёты о ходе дела. Запланированная задача должна разбудить агента, с которым никто не разговаривал несколько дней. Каждый из этих случаев — своя схема оркестрации: родитель и потомок, разветвление и сбор, «выстрелил и забыл», таймеры. И ни одна из них не должна жить внутри самого цикла агента.
Этим занимается Agent Control Plane, или ACP: слой оркестрации поверх системы Trajectory. Он знает, какие агенты существуют и на какой траектории работает каждый из них. Он создаёт новых агентов — либо с нуля на новой траектории, либо как форк существующего. Он доставляет сообщения между агентами. И он решает, когда агент должен работать, выдавая активации: сигналы пробуждения, которые может подхватить любой узел нашего кластера, чтобы запустить агента вместе с его траекторией.
ACP вынесен в отдельный слой потому, что он сводит все эти схемы к одному примитиву. Любое взаимодействие между агентами — порождение, результат, отчёт о ходе работы, передача работы от одного агента другому, запланированное пробуждение — это одни и те же два шага: добавить сообщение во входящие получателя, затем отправить активацию. Агенты не вызывают друг друга, не держат соединений друг с другом и не знают, на какой машине находится другой. Они пишут во входящие, а остальное делает ACP. Соответственно и поверхность у него небольшая: SpawnAgent, ForkAndSendMessage, SendMessage, NotifyParents, StopAgent и несколько операций чтения.
Вот одна из таких схем целиком — подагент отчитывается строителю, который его породил:
- Подагент доходит до AgentDone.
- Его конверт завершения вызывает NotifyParents, который формирует ExternalAgentNotification с результатом и терминальным статусом (Completed, Failed или Cancelled).
- SendMessage добавляет его во входящие родителя. Именно эта запись и есть долговечная часть: теперь сообщение в безопасности, что бы ни случилось дальше.
- ACP отправляет активацию. Если родитель спит, узел подхватывает активацию и запускает прогон, который находит уведомление во входящих. Если родитель уже работает, уведомление подхватывает сам на следующей границе итерации, и активация не запускает второй прогон: прогон забирает свою активацию, а у каждой траектории есть единственный эксклюзивный писатель, так что дубликат просто подтверждается и отбрасывается — сообщение уже лежит во входящих.
Смысл в развязке. Отправитель добавляет сообщение во входящие и на этом заканчивает; получатель читает свои входящие, когда в следующий раз запускается. Пробуждение — это сигнал, а не полезная нагрузка, и входящие — источник истины. Поэтому сообщение занятому агенту и сообщение бездействующему — одна и та же запись, и ни одной из сторон не приходится согласовывать свои действия с другой.
Приостановка и возобновление: агенты, переживающие развёртывания
Одно из самых удобных следствий такой архитектуры — то, как легко приостанавливать и возобновлять работу агентов.
На любой границе итерации — в момент после записи IterationEnd — история событий траектории содержит всё, что нужно следующей итерации. Поэтому на этой границе цикл может просто остановиться: запуск завершается как приостановленный, AgentDone не записывается, а всё ещё открытый блок агента сам по себе служит сигналом, что работу нужно продолжить. После этого ACP отправляет новую активацию с причиной возобновления. Любой узел флота может её подхватить, проверить входящие и запустить следующую итерацию, как будто ничего не произошло.
Это отвязывает долгоиграющих агентов от инфраструктуры развёртывания. Когда флот перезапускается, каждый узел перестаёт принимать новые активации и даёт текущим запускам завершиться: большинство ходов просто доигрывается. Ход, который всё ещё выполняется, вместо этого приостанавливается на ближайшей границе и возобновляется уже на новом узле. Тот же путь срабатывает, когда песочница умирает посреди хода: упавший вызов инструмента завершает итерацию, и запуск приостанавливается на этой границе, а не повторяет попытки вслепую. Мы выпускаем обновления весь день и при этом ни разу не убиваем долгоиграющий запуск.
Инфраструктура песочниц делает это ещё чище. Песочницы работают отдельно от флота агентов, а долговечное состояние проекта — это его репозиторий, а не сама песочница. Возобновлённый агент заново подключается к своей песочнице или собирает её заново из репозитория. Процесс агента действительно одноразовый; работа живёт в траектории и в git.
Собираем всё вместе: Chats
Lovable всегда был местом, куда заходят в проект, чтобы поговорить с агентом-строителем этого проекта. С Chats мы запускаем нечто другое: агента, с которым можно свободно общаться вне какого-либо одного проекта.
Чат-агент, стоящий за Chats, работает на уровне рабочего пространства, и его траектория привязана к рабочему пространству, а не к проекту. Он использует другую конфигурацию модели, настроенную на быстрый, свободный разговор, а не на сборку, и имеет доступ ко всем вашим проектам. Вы можете попросить его зайти в проект и что-то там собрать или обновить несколько проектов параллельно.
Именно здесь Chats опираются на примитивы, о которых мы рассказали: они почти целиком построены из траекторий, входящих и активаций. Когда чат-агент решает, что проект должен выполнить какую-то работу, он вызывает инструмент send_message_to_project. Под капотом это просто SendMessage: ExternalAgentNotification с инструкциями и вложениями добавляется во входящие этого проекта, а активация будит строителя, который продолжает свою траекторию ровно с того места, где остановился, — или, если он уже занят, встраивает сообщение на ближайшей границе следующей итерации. Чат-агент отвечает за то, чтобы сообщение было самодостаточным: он может читать чат и состояние проекта, чтобы решить, что сказать, но строитель видит только отправленное ему сообщение. Поскольку обе траектории живут в одной системе, возможно и обратное: агент проекта мог бы прочитать чат-ветку ради дополнительного контекста. Сегодня мы оставляем эту ответственность на стороне чата.
Прогресс идёт обратно по тому же примитиву. Когда билдер публикует обновление о ходе работы посреди своего хода, ACP уведомляет родителя: обновление попадает во входящие чат-агента как ExternalAgentNotification. Когда ход билдера завершается, ещё одно уведомление несёт терминальный статус и сводку результата — потраченные кредиты, изменённые файлы, статус сборки. Это тот же примитив NotifyParents, которым субагент отчитывается перед породившим его агентом, только направленный вверх по цепочке. Отчёт о прогрессе — не отдельный канал, а ещё одно сообщение, приземляющееся во входящих.
Почему мы сделали именно так
Почему журнал событий, а не снимки состояния?
Снимок привязывает вас к одному представлению «разговора». Журнал позволяет каждому потребителю построить своё: промпт билдера, промпт чат-агента, отрисовку в браузере, воспроизведение для evals — всё из одних и тех же событий. Компакция, откаты и вставки становятся добавлениями, а не перезаписями, так что всегда можно ответить на вопрос «что агент на самом деле видел, когда делал этот вызов». Отладке и оценке нужна именно эта сырая запись; стоит сделать снимок — и детали, объясняющие неудачный ход, исчезают. Цена — чтение превращается в проход, а не в поиск. Мы отыгрываем это за счёт раскладки хранения, кэширования и компакции как проекции.
Почему два журнала — входящие и траектория агента — а не один?
У пишущих и у агента разные потребности. Другим участникам нужно место, куда можно дописать в любой момент, не конкурируя с работающим агентом; агенту нужна история, которую пишет только он и в том порядке, который он контролирует. На каждой границе итерации агент копирует ожидающие сообщения из входящих в свою траекторию, прежде чем заново собрать промпт. Заодно траектория агента остаётся воспроизводимой: любой промпт, который видела модель, можно восстановить из этого одного журнала, потому что внешние данные попадают в него только в чётко определённых точках. И это развязывает пробуждение от полезной нагрузки. Потерять или продублировать активацию не страшно, потому что источник истины — входящие; именно это делает восстановление после сбоя и повторные запуски безопасными.
Почему мы моделируем это по образцу Git, а не простого линейного журнала?
Указатель на родителя у каждого события делает форки бесплатными: форк — это новая голова, указывающая на существующее событие, без копирования, и сборщик промпта форкнутого агента просто идёт по истории родителя. Именованные головы дают каждой линии работы свою идентичность — история агента, входящие, субагенты и форки — всё это просто головы в одном хранилище, так что один слой хранения и один путь чтения обслуживают любой вид агента. А ментальная модель оказалась достойна заимствования. Инженеры и так рассуждают в терминах коммитов, веток и голов; нам не пришлось изобретать словарь.
Под капотом
Пара деталей реализации для любопытных.
Хранение. Траектории лежат в Bigtable как последовательность событий. Ключи строк устроены так, что события одной траектории оказываются рядом, и чтение истории — а это ровно то, из чего собирается контекст — превращается в быстрое сканирование, а не в переход на каждое событие. Большие полезные нагрузки вроде файлов знаний и изменений файлов вынесены в блоб-хранилище с адресацией по содержимому, чтобы сами события оставались небольшими.
Активации. Каждое пробуждение записывается в журнал активаций и публикуется через Pub/Sub с упорядочиванием по агентам, так что любой узел в парке может его подхватить, загрузить агента вместе с его траекторией и запустить его. Периодический реконсилятор повторно запускает активации, которые были опубликованы, но так и не завершились, поэтому потерянное пробуждение — это задержка, а не потерянное сообщение.
О самом цикле работы агента и о том, как вызовы базовой LLM маршрутизируются между провайдерами, читайте в нашем предыдущем посте Маршрутизация миллиардов токенов в минуту.
Подводя итоги
В следующий раз, когда вы будете использовать Chats, чтобы превратить идею в изменение вашего приложения, вы яснее представите, что происходит под капотом: как агенты передают работу друг другу, отслеживают контекст и возвращают результаты обратно в ваш диалог.
Если вы сами разрабатываете агентный продукт, эти проектные решения можно взять на вооружение. Ваши агенты будут выполнять разные задачи, но перед вами встанут многие из тех же вопросов: что им необходимо знать и как они взаимодействуют. Мы поделились нашим подходом, чтобы у вас была конкретная основа для дальнейшей работы.
Попробуйте Chats и увидите его в действии.
Источник: Lovable · Blog · lovable.dev