Агентные системы ломаются не на сложных задачах и не на плохих моделях. Главная причина — недетерминизм LLM: температура, апдейты моделей, дрейф мира. Как отлаживать то, что не воспроизводится? Как перезапустить упавший пайплайн не с нуля? Как вообще понять поведение системы, если каждый запуск чуть-чуть другой?

Event Sourcing — паттерн, где состояние не снапшот, а иммутабельный лог событий. Недетерминизм он не убирает, но даёт инструменты для работы с ним.

Немного теории

Сразу замечу — идея не моя и совсем не новая. Event Sourcing как паттерн систематизировал Фаулер в 2005-м, связку ES + CQRS закрепил Грег Янг в докладе 2014 года. Паттерн в основном живёт в финансах и распределённых системах — далеко от LLM, но с ровно теми же тремя свойствами лога. Ближайшая родня, шагнувшая к агентам ещё раньше, — durable execution engines (Temporal, Restate): то же «храним историю событий, реплеим детерминированно», только в словаре workflow/activity вместо stream/tool_call.

Прямое применение ES к LLM-агентам — это уже 2026 год: ESAA (arXiv:2602.23193, та самая работа, под впечатлением которой писался zymi), OpenKedge (arXiv:2604.08601, формализует governance поверх event-sourced state).

Эта статья — не литературный обзор, а practitioner-essay поверх перечисленного. Если что-то из тезисов захочется копнуть глубже — ESAA и OpenKedge за формализацией, FAIR 2025 за аксиомами аудируемости, CoALA и Generative Agents за связью с памятью, Temporal и Restate за «как это решают вне агентского контекста».

Как устроена ESA

ESA — это event-driven архитектура с одним добавленным инвариантом: лог неизменяем, разрешена только запись. То есть шина событий тут не просто транспорт между сервисами, а ещё и единственное место истины — всё, что произошло в системе, лежит в нём и никогда не редактируется. Агенты на этой шине играют роль обычных подписчиков: каждый ждёт появления в логе нужных ему типов событий, забирает их в работу, а свои результаты пишет туда же.

Агенты - обычные подписчики: читают из лога, пишут в лог
Агенты - обычные подписчики: читают из лога, пишут в лог

Этот иммутабельный лог событий и есть самая ценная архитектурная идея подхода — он даёт сразу несколько преимуществ — правда, при условии что задача укладывается в события: перезапуск пайплайнов, единый источник правды, иммутабельный и аудируемый журнал. Эти три — на самом деле одно и то же свойство лога, рассмотренное с трёх сторон. И именно поэтому, когда любое из трёх перестаёт быть достижимым (перезапуск не воспроизводит реальность, source-of-truth не успевает за событиями, логи надо «удалять» по запросу) — ломается сразу всё, а не одно из трёх.

Лично для меня паттерн стал не столько готовой спецификацией, сколько удачной формулировкой базового инварианта: состояние агентной системы должно выводиться из журнала событий. Дальше в zymi начались уже более прикладные вопросы: как ветвить выполнение, как не повторять внешние эффекты, как собирать контекст в виде проекции и как сделать human-in-the-loop частью того же журнала.

Перезапуск пайплайна с любого места

ESA позволяет нам не просто перезапустить пайплайн, а запустить его с любого произвольного шага — допустим, у вас есть свой агент-исследователь, который сначала собирает информацию, а потом готовит отчёт на её основе. И если у вас есть вопросы к моменту подготовки отчёта, вы можете просто внести правки в этот шаг и перезапустить пайплайн с этого момента, не собирая данные заново.

ESA позволяет перезапускать пайплайн с любого места
ESA позволяет перезапускать пайплайн с любого места

Здесь как и у классического EDA есть свои проблемы — если в EDA была проблема с внешними событиями, например, списание со счёта нельзя провести ещё раз при перезапуске — то тут мы сталкиваемся ещё и с недетерминизмом LLM. Fork-resume даёт нам возможность хотя бы снизить его влияние на конечный результат — не нужно заново вызывать тех агентов, результаты которых нас устраивают.

Пример из zymi

В zymi это работает примерно так:

zymi resume <stream_id> --from-step writer

создаёт новый стрим и физически копирует туда событийный префикс исследователя — его LLM-вызовы, результаты поиска в сети, всё. Писатель перезапускается с актуальным промптом из agents/writer.yml, остальное замораживается.

Внешние эффекты внутри переисполняемой части закрываются отдельным флагом — если у инструмента стоит no_resume: true (например, send_email), при resume он не вызывается: в журнал пишется ToolCallCompleted { replayed: true } с честным плейсхолдером, и агент в следующем ходе видит «этот email уже был отправлен раньше, повторно не делал».

Лог как единый источник правды

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

Все взаимодействия в системе строятся через лог
Все взаимодействия в системе строятся через лог

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

Пример из zymi

В zymi есть команда, которая проверяет целостность хэш-цепочки лога:

zymi verify 

Когда я её добавлял, ожидал работы с рантаймом: где-то прокинуть хуки, не сломать пайплайн-исполнитель, не задеть подписчиков. По факту вышел файл на 76 строк, который не импортирует рантайм вообще: открывает тот же SQLite, перебирает стримы, валидирует SHA-256. Всё. Ровно по тому же пути живут zymi observe (TUI с живой графой пайплайна) и zymi runs (CLI-листинг прогонов) — каждая команда это самостоятельный потребитель поверх одного лога, и в ядро никто из них не лезет.

Выгода единого источника заметна сразу: новый потребитель — это новый файл рядом, а не новый интерфейс в рантайме.

Есть в едином источнике правды ещё один плюс — контекст это больше не мутабельный набор данных в памяти, это проекция из лога, которой мы можем крутить как хотим. Размер контекста, порог его сжатия, маскирование определённых результатов (это вообще отдельная большая тема, сильно экономящая токены — в планах расписать подробней в отдельной статье), любые манипуляции — у нас совершенно развязаны руки, т.к. исходники в целости и сохранности лежат в логе. В zymi это ContextBuilder поверх того же SQLite — поднять окно с 10 ходов до 30, замаскировать старые tool_result'ы плейсхолдерами, пересобрать контекст под другого агента — всё это операции над проекцией, исходники в логе не трогаются.

При этом проекции лога, само собой, очень сложно масштабировать на миллионы событий в секунду, а лог событий как единственная точка правды — не лучшая идея для координации тысяч параллельных агентов; здесь ESA бессильна и только добавит головной боли.

Иммутабельный и аудируемый журнал

Третья грань этого же лога — иммутабельность. Раз мы договорились, что лог — единственный источник правды и все потребители читают из него, то достаточно один раз потребовать «события только дописываются, никогда не редактируются» — и журнал перестаёт быть просто хранилищем, становится инвариантом, который можно проверить.

Связи между событиями лежат в самой записи - отдельной системы аудита не нужно
Связи между событиями лежат в самой записи - отдельной системы аудита не нужно

Следствий тут сразу несколько. Поверх неизменяемого лога естественно ложится хэш-цепочка: каждая запись содержит SHA-256(event_id + data + prev_hash), и отдельная команда пробегает стрим и говорит, не было ли подмены — журнал становится не просто иммутабельным по дисциплине, а математически верифицируемым. И самое приятное — аудит-трейл выпадает как побочный эффект из любого нового события, которое ты кладёшь на шину; ничего отдельного для этого делать не нужно.

Отдельно стоит выделить, что у каждого события понятно, откуда оно взялось и что его вызвало. Прямо в конверте лежат три поля: correlation_id (одна пользовательская заявка от начала до конца), causation_id (какое событие породило это) и source («cli», «telegram», «scheduler», «agent»). На любой факт в логе можно ответить «кто, когда, в ответ на что» — без отдельной системы аудита, просто потому что эти поля и так есть в конверте. Для агентов это убийца извечного вопроса «почему модель так решила?» — он превращается в «покажи цепочку событий, приведших к этому LlmCallStarted», и она там лежит целиком, от исходного user_message до конкретного tool_result, который попал в окно контекста.

Пример из zymi

Согласования действий «человеком в петле» сначала жили в HashMap внутри HTTP-обработчика — обычное in-memory-состояние. Перезапустил процесс — забыл, кто что одобрил полчаса назад. После перевода согласования на шину (ApprovalRequestedApprovalGranted с полем decided_by: "slack:@alice"), починилось сразу несколько вещей: согласования стали видны из TUI, переживают рестарт, и — побочным эффектом — появился постоянный аудит-трейл «кто, что и когда одобрил». Никто отдельно не строил систему аудита; она выпала из факта, что одобрения теперь живут в том же логе, что и всё остальное.

Цена у этого свойства тоже вполне ощутимая. Эволюция модели данных — лог за полгода накопил миллионы записей в старом формате, ты добавляешь новое поле, и встаёт вопрос «что делать со старыми событиями?». Право на забвение из GDPR — неизменяемый лог по определению не умеет «забыть» одну строку, а просто стереть её = сломать хэш-цепочку. На обе проблемы есть рабочие ответы:

  • Со схемой спасает связкой дефолтов для новых полей и явной пометкой пониженной точности.

  • С GDPR хитрее: формально иммутабельный журнал и право на забвение противоречат друг другу. Решений два: crypto-shredding — каждое событие с персональными данными шифруется своим ключом, и при запросе на удаление мы просто выбрасываем ключ, событие физически остается на месте, но расшифровать его уже никто не сможет; tombstone-события — специальная запись поверх, которая для всех проекций означает «забудьте про предыдущие данные по этому субъекту».

Платить за это приходится сложностью. Проекциям нужно уметь жить со старыми версиями схемы. А шифрование payload'ов — это уже целая инфраструктура управления ключами, которую тоже надо где-то хранить и не терять.

Ограничения архитектуры

Кроме разобранных выше ограничений есть и менее очевидная цена: ESA меняет жанр дебага. Привычная картина «упало → стек-трейс → строка кода» не работает — её место занимает «упало → найди correlation_id → пройди по цепочке событий → восстанови состояние на момент сбоя». Это та же по сложности работа, но в другом наборе примитивов — порядок, причинность, проекция состояния. Команде, которая приходит из мира функциональных вызовов и стек-трейсов, нужно время на смену оптики; это не дороже, чем дебажить распределённую систему, но и не дешевле — просто другое.

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

Хорошая система, основанная на событийном подходе, знает свои границы и явно делает шаг в сторону там, где они достигнуты, а не пытается «event-source'ить всё» через силу.

Заключение

Если коротко вернуться к тому, с чего начали: иммутабельный лог даёт три свойства — детерминированный перезапуск, единый источник правды, аудируемость. И ключевое здесь — это не три разных бонуса, которые можно набирать по отдельности. Это одно и то же свойство лога, повёрнутое к нам тремя гранями. Поэтому компромисс по любой из них рушит остальные две: если перезапуск не воспроизводит реальность — значит, лог не источник правды; если в логе можно подправить запись — значит, аудиту нельзя верить; если потребители ходят мимо лога — значит, fork-resume не воспроизведёт картинку, которую они видели.


Все практические кейсы в этой статье получены при разработке опенсорс-фреймворка для агентов — zymi-core. Писал о нём в своей предыдущей статье — Что если собирать агентов как dbt-проект? Проект сейчас двигается в сторону MCP сервера — бэкенд для агентов, который можно подключить к вашему любимому Claude Code / Codex / OpenClaw и прочему.

Про агентов, Event Sourcing и смежные темы пишу также в телеграме.

Комментарии (6)


  1. DrMayDay
    29.05.2026 07:34

    Как отлаживать то, что не воспроизводится? Как перезапустить упавший пайплайн не с нуля? Как вообще понять поведение системы, если каждый запуск чуть-чуть другой?

    Seed модели задайте руками, и будет вам полный детерминизм, одно и то же каждый запуск на одни те же данные. Не благодарите.


    1. ArtTrek Автор
      29.05.2026 07:34

      К сожалению seed не дает полного детерминизма - OpenAI пишет «mostly deterministic».

      Есть на эту тему исследование от Thinking Machine.

      Ну и сама нулевая температура, есть кейсы где она актуальна (классификация, json собирать), но что если нужен живой диалог? Написание кода? Исследование?

      Ну и в целом, ESA не про это - он не про воспроизводимость именно конкретного ответа, он про воспроизводимость всей системы.


      1. DrMayDay
        29.05.2026 07:34

        С LLM'ками нельзя всё сваливать в кучу. Если у вашего(я так понимаю самописного) агента начинаются проблемы типа "петель" на вызове инструментов, то тут уже не место "диалогам" - проблему нужно логировать и жёстко разгребать на конкретном seed'е. В той чудной статье 2023го года объясняют, что "mostly deterministic" имеется ввиду, что нельзя в своих государственно-сертифицированных криптоприложениях рассчитывать на идентичность, радиация там, распределённость вычислений, ненадёжность float, всё такое... Для целей отладки агентов детерминизм там полный(при условии одинаковых seed/temp/top_p/...).

        И вообще агентов надо на локальных моделях типа GPT-OSS 20B тестировать(если openai-compatible) именно из-за их "несильной умности", так как они не будут пытаться сами баги вашего агента как-то сглаживать, а сразу начнут косячить и глючить, что хорошо в случае отладки. Если вы сможете сделать агента у которого Gemma4 26B заработает без "петель", в этого агента потом можете что угодно засовывать(с таким форматом потока) - он точно отработает)


        1. ArtTrek Автор
          29.05.2026 07:34

          Вот тут соглашусь, отладка на локальных моделях отличный подход.

          Но главное с чем соглашусь - "проблему нужно логировать и жёстко разгребать". ESA как раз и создана для этого - всё, что происходит в системе уже в логе. Нужен перезапуск с другими параметрами? Да сколько влезет, причём нет нужды прогонять заново вообще все шаги, их можно спокойно переиспользовать.


  1. Ariless
    29.05.2026 07:34

    Самое ценное в такой архитектуре с точки зрения тестируемости это связка causation_id + immutable log.

    У меня в RAG-пайплайне был кейс, где тест падал с 422 (неправильная специализация). Проблема была не в модели, а в том что retrieval layer формировал некорректный контекст.

    Ключевой вопрос в отладке был не "ошибся ли LLM", а "какие именно данные попали в контекст на этом шаге". Без иммутабельного лога это требовало ручной реконструкции: проверка retrieval scores через node -e, отдельный режим AI_MOCK_RESPONSE=true для исключения LLM и изоляции retrieval-слоя. В результате выяснилось, что релевантный контекст не попадал в prompt на этапе сборки.

    При наличии event log с причинной связностью контекст становится воспроизводимой проекцией состояния системы. Это переводит отладку из восстановления фактов в навигацию по correlation_id. Практический эффект - тест начинает проверять не только выход системы, но и входное состояние, в котором этот выход был сгенерирован.


    1. ArtTrek Автор
      29.05.2026 07:34

      Прямо в точку. Про проверку входного состояния - идеальная формулировка, лучше моей.