Перейти к содержимому
PD
AI-автоматизация 7 мин чтения

Не давайте агенту делать UPDATE: append-only запись для AI-автоматизации

Агент, который напрямую меняет базу, не поддается ни аудиту, ни отладке, ни откату. Рассказываю про паттерн ledger предложений: агент записывает намерения, а скучный коммиттер их применяет.

PD

Pavel Duglas

AI Automation & MVP Architect

Самая опасная строка в большинстве кодовых баз с агентами - это не prompt. Это tool с сигнатурой вроде update_record(id, fields). Как только LLM получает право напрямую менять production-состояние, вы теряете сразу три вещи: вы не можете объяснить, что произошло, не можете это воспроизвести и не можете аккуратно откатить. Я разобрал достаточно инцидентов в духе «агент за ночь поменял 400 карточек клиентов, и никто не понимает почему», чтобы выработать жесткое правило: агенты не пишут в мою базу. Они пишут в ledger. Реальную запись делает что-то скучное и детерминированное.

Ниже паттерн, которым я пользуюсь: схема, логика коммиттера и компромиссы.

Почему прямая запись ломается

Когда агент вызывает tool, который выполняет UPDATE deals SET stage = 'lost', после него остается только новое значение. Может быть, еще updated_at. А теперь вспомните вопросы, которые вам реально зададут в проде:

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

При прямой записи честный ответ на большинство этих вопросов звучит как «сейчас погрепаю логи и понадеюсь на лучшее». Логи не являются источником истины. Их сэмплируют, ротируют, обрезают, и они почти никогда не структурированы настолько, чтобы восстановить состояние «до» и «после».

Есть и вторая проблема. Прямая запись смешивает две совершенно разные задачи: решить, что нужно поменять, и безопасно это поменять. С первой LLM справляется хорошо, со второй ужасно. Модель не знает про конкурентные правки, повторяет вызов, если tool отвалился по таймауту, и иногда галлюцинирует ID, который по несчастливой случайности существует.

Паттерн: агент предлагает, коммиттер применяет

Решение - разрезать путь записи на две части.

  1. У агента остается ровно один пишущий tool: propose_change. Он добавляет строку в ledger предложений и никогда не трогает бизнес-таблицы.
  2. Отдельный процесс без LLM, коммиттер, читает ожидающие предложения, валидирует их, проверяет политики и применяет внутри транзакции. Результат он записывает обратно в ledger.

Ledger работает только на добавление. Строки не обновляются на месте, максимум меняется статус, и даже его я предпочитаю моделировать новой строкой-событием. Ничего не удаляется никогда.

Схема ledger

Вот упрощенная версия того, что у меня крутится в Postgres:

create table agent_proposals (
  id              bigserial primary key,
  run_id          uuid not null,
  agent_build     text not null,      -- версия prompt + модели + tools
  idempotency_key text not null unique,
  entity_type     text not null,      -- 'deal', 'ticket', 'contact'
  entity_id       text not null,
  action          text not null,      -- 'set_field', 'create', 'archive'
  payload         jsonb not null,     -- что поменять
  expected        jsonb,              -- что агент считал текущим состоянием
  reason          text not null,      -- короткое обоснование от агента
  evidence_ref    text,               -- ссылка на снимок контекста
  created_at      timestamptz not null default now()
);

create table agent_proposal_events (
  id           bigserial primary key,
  proposal_id  bigint not null references agent_proposals(id),
  status       text not null,  -- 'approved','applied','rejected','conflict','reverted'
  detail       jsonb,
  before_state jsonb,
  after_state  jsonb,
  actor        text not null,  -- 'committer', 'human:anna', 'policy'
  created_at   timestamptz not null default now()
);

Основную работу делают три колонки.

expected - это то, как агент видел текущее состояние в момент решения. Если агент прочитал, что сделка на этапе negotiation, и хочет перевести ее в lost, то в expected лежит {"stage": "negotiation"}. Перед применением коммиттер сверяет это с реальностью. Если менеджер пять минут назад перевел сделку в won, предложение получает статус conflict, а не молча затирает живую продажу. Это оптимистичная блокировка, и она ловит удивительно много плохих записей.

idempotency_key делает повторы безвредными. Я собираю его из ID запуска, сущности и действия. Если агент после таймаута вызовет tool второй раз, вставка упадет на unique-ограничении, tool вернет «уже предложено», и агент пойдет дальше.

evidence_ref указывает на сохраненный снимок того, что видел агент: найденные документы, ответы tools и нужный кусок диалога. Хранить ссылку вместо целого блоба выгоднее, ledger остается компактным. Сам снимок лежит в object storage с ключом по запуску.

Коммиттер

Коммиттер намеренно тупой. Для каждого ожидающего предложения он делает следующее:

  1. Загружает сущность в транзакции с блокировкой строки.
  2. Валидирует payload по строгой схеме для конкретной пары entity_type и action. Неизвестные поля отклоняются, а не игнорируются.
  3. Сравнивает expected с текущим состоянием. Расхождение означает conflict.
  4. Проверяет политику: применить сразу, отправить человеку на согласование или отклонить.
  5. Применяет, фиксирует before_state и after_state, пишет событие, коммитит.

Никаких вызовов LLM. Никаких креативных ретраев. Если что-то не так, он останавливается и записывает причину.

Уровни политик - вот где живет автономность

Коммиттер - естественное место, где решается, насколько вы доверяете агенту. И настраивать это можно по каждому действию, не трогая prompt.

Я использую три уровня:

  • Auto: низкий риск, изменение легко откатить. Поставить тег на тикет, заполнить пустое поле, добавить заметку. Применяется сразу.
  • Review: все, что касается денег, клиентов или внешних систем. Смена этапа сделки, отправка письма, возврат платежа. Попадает в очередь на согласование, причина и evidence в одном клике.
  • Deny: то, что агенту нельзя делать ни при каких обстоятельствах, что бы он ни предлагал. Удаление записей, смена владельца, изменения в тарифах и биллинге.

Приятно то, что действия можно «повышать». Если за две недели в очереди на «сменить приоритет тикета» 98 процентов предложений одобрены, переводим действие в auto. Это решение об автономности на основе данных, а не по ощущениям, и его всегда можно отыграть назад.

Еще я ограничиваю объем на один запуск. Если запуск предлагает больше, скажем, 50 изменений одного типа, коммиттер отправляет их все на ручную проверку. Зациклившийся агент превращается в алерт, а не в инцидент.

Что дает append-only

Настоящий аудит

У каждого изменения в бизнес-таблицах, которое пришло от агента, есть строка в ledger: какой build, какой запуск, что агент видел, почему так решил, кто или что одобрил, состояние до и после. Когда клиент спрашивает «почему система пометила этот лид как спам», я отвечаю за минуту и показываю реальные данные.

Точечный откат

Поскольку before_state фиксируется в момент применения, откат - это просто новое предложение со старыми значениями, которое применяет тот же коммиттер с той же проверкой конфликтов. Можно откатить один запуск, один build агента или все, что конкретный build натворил за конкретный день. Без восстановления бэкапа и без потери чужих ручных правок.

Replay и отладка с перемоткой времени

Эту штуку недооценивают сильнее всего. Когда у вас сохранены снимок контекста и версия build, можно перезапустить ровно то же решение с новым prompt или другой моделью и сравнить предложения. Вышла модель подешевле, и хочется переехать? Не нужно гадать, поведет ли она себя так же. Прогоните прошлонедельные запуски, сравните новые предложения с тем, что реально одобрили люди, и у вас готов eval-набор, собранный прямо из прода.

Детектор деградации

Доля отклонений и конфликтов по каждому build агента - это живая метрика качества. Если после тихого обновления модели у провайдера доля отклонений прыгнула с 3 до 15 процентов, вы увидите это на дашборде в тот же день.

Детали, которые больно кусают, если их пропустить

Делайте представление агента явным. Агент сможет корректно заполнить expected, только если его читающие tools возвращают те поля, которые он потом захочет поменять. Поэтому я проектирую read-tools так, чтобы они отдавали компактный объект текущего состояния по каждой сущности.

Поле reason должно быть коротким и обязательным. Одно-два предложения. Оно не для модели, а для человека, который будет разбирать очередь в девять утра. Длинные объяснения никто не читает.

Для внешних побочных эффектов нужен outbox. Отправленное письмо или вызов стороннего API не откатишь. Оформляйте их тоже как предложения, а коммиттер пусть пишет в outbox-таблицу, которую доставляет отдельный воркер. Тогда откат превращается в «отправить исправление», и это хотя бы осознанное решение.

Сообщайте агенту результат. Если агенту нужно реагировать на исход в том же запуске, propose_change должен возвращать статус: применено, ждет проверки или конфликт с актуальными значениями. С ответом «конфликт, текущий этап - won» агенты справляются куда лучше, чем с молчаливым успехом.

Не превращайте ledger во вторую базу. Бизнес-логика читает из бизнес-таблиц. Ledger нужен для истории, согласования и replay. Если вы поймали себя на том, что рендерите продуктовый UI из таблицы предложений, где-то свернули не туда.

Когда это избыточно

Если агент только читает данные и готовит черновик, который человек потом вручную переносит куда нужно, ledger не нужен. Коммиттером работает человек. То же самое для одноразовых внутренних скриптов, где весь датасет можно пересобрать с нуля.

Паттерн окупается в тот момент, когда агент начинает писать в состояние, на которое опираются люди и которое дорого восстанавливать: CRM, тикеты, остатки на складе, все, где крутятся деньги. В таких системах я закладываю ledger с первого дня, потому что если прикручивать его после первого инцидента, в истории уже будет дыра.

Чек-лист для старта

Если хотите внедрить это уже сегодня, начните с малого:

  1. Замените все пишущие tools агента одним propose_change.
  2. Создайте две таблицы из примера выше. Хранение evidence на первом этапе можно пропустить, но колонку оставьте.
  3. Напишите коммиттер с валидацией схемы и проверкой expected. Для начала отправьте все действия в review.
  4. Сделайте максимально простой экран согласования: причина, diff, кнопки «одобрить» и «отклонить».
  5. Через две недели посмотрите на долю одобрений и переведите рутинные действия в auto.

Для типичного MVP это примерно два дня работы. Взамен вы получаете агента, которого можно объяснить, воспроизвести и откатить. Именно в этом разница между демкой и системой, которую клиент реально готов оставить работать без присмотра.

Вопросы и ответы

Не слишком ли это замедляет агента, если каждое изменение идет через коммиттер?

Для действий уровня auto задержка измеряется миллисекундами: коммиттер может работать синхронно прямо внутри вызова propose_change и сразу вернуть статус. Реально замедляются только действия уровня review, но там задержка и есть смысл - это изменения, которые вы все равно не хотели бы применять без человека.

Можно ли использовать этот паттерн без Postgres, например в BAS или в связке с Google Sheets?

Да, суть не в конкретной базе, а в разделении «предложить» и «применить». В простых автоматизациях ledger может быть отдельным листом или таблицей в SQLite, куда скрипт только добавляет строки, а отдельный шаг проверяет expected и применяет изменения. Главное - уникальный ключ идемпотентности и сохранение состояния до изменения.

Что делать, если агент постоянно получает conflict?

Это почти всегда сигнал, что агент работает с устаревшими данными. Проверьте, что read-tools возвращают свежее состояние, а не кэш, и что между чтением и предложением не проходит слишком много времени. Если конфликты вызваны активной работой людей, это нормально: коммиттер как раз и защищает их правки, а агенту стоит перечитать сущность и принять решение заново.

Похожие статьи