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

Сначала сделайте автоматизацию идемпотентной, потом - умной

Большинство сломанных AI-автоматизаций ломаются не из-за модели, а потому что каждый retry делает работу дважды. Разбираю контракт идемпотентности: схемы, правила хеширования и чек-лист аудита.

PD

Pavel Duglas

AI Automation & MVP Architect

Самый дорогой баг, который я выкатил в проде, не имел никакого отношения к AI. У клиента воркфлоу выставления счетов упал по таймауту на последнем шаге, платформа сделала retry - и 340 счетов ушли клиентам по второму разу. Модель работала нормально. Промпт был нормальный. Проблема была в том, что у пайплайна не было понятия «я это уже делал».

С тех пор идемпотентность для меня - решение, которое принимается в первый день, до первого написанного промпта. Особенно сейчас, когда половина пайплайна - это LLM, которая на каждый вызов отвечает чуть по-разному. Если вы делаете агентов, парсеры, Telegram-ботов или n8n-флоу, которые трогают реальные системы, - это самая скучная и самая рентабельная вещь, которую можно починить на этой неделе.

Идемпотентность - это контракт, а не колонка в базе

Многие разработчики слышат «идемпотентность» и думают: «добавлю колонку idempotency_key с уникальным индексом». Это механизм, а не контракт. В контракте три части, и если вы не проговорили все три явно - у вас нет идемпотентности, у вас есть race condition с уникальным индексом сверху.

1. Идентичность. Что именно считается «той же работой»? Та же доставка вебхука? Тот же заказ? Тот же заказ в том же состоянии? Это бизнесовый вопрос, а не технический. Ошибка здесь - это либо дубли счетов, либо молча съеденный второй легитимный заказ от того же клиента.

2. Окно. Сколько вы это помните? Всегда? 24 часа? До следующей успешной синхронизации? Кеш дедупликации с TTL пять минут отлично гасит шторм вебхуков и абсолютно бесполезен для ночной джобы, которая перезапустилась после двухдневного простоя.

3. Ответ. Что происходит на дубликате? Возвращаете оригинальный результат, отдаёте ошибку или тихо ничего не делаете? Агенту, который вызывает ваш tool, важно различать «создано» и «уже существовало, вот оригинал» - иначе он сам себе придумает цикл ретраев.

Запишите эти три ответа комментарием над хендлером. Я серьёзно. Половина споров на клиентских проектах у меня заканчивалась в момент, когда идентичность была сформулирована одним предложением.

Шаг 1: детерминированные work_id вместо случайных UUID

Если вызывающая сторона генерирует новый UUID на каждую попытку - это бесполезно. ID должен выводиться из самой работы, чтобы две попытки одной работы давали один и тот же ID.

Две стратегии, и обычно нужны обе.

Бизнес-ключ - когда исходная система даёт стабильный идентификатор:

work_id = sha256("invoice:v1:" + shopify_order_id)

Хеш содержимого - когда не даёт (спарсенные страницы, заявки с форм, входящие письма):

import { createHash } from "node:crypto";

function workId(kind, version, payload) {
  const canonical = JSON.stringify(payload, Object.keys(payload).sort());
  return createHash("sha256")
    .update(`${kind}:${version}:${canonical}`)
    .digest("hex");
}

Три правила, которые я выучил на своих же граблях:

  • Канонизируйте перед хешированием. Сортируйте ключи, тримьте строки, нормализуйте числа и таймстемпы. Иначе {a:1,b:2} и {b:2,a:1} - «разная работа».
  • Выкидывайте волатильные поля. received_at, request_id, trace_id уничтожают любой хеш. Хешируйте только то, что определяет работу по смыслу.
  • Версионируйте префикс. Когда логика изменилась и вы хотите переобработать всё - бампаете v1 → v2. Это решение сэкономило мне больше миграционных скриптов, чем я могу вспомнить.

Шаг 2: журнал эффектов, а не set увиденных ID

Множество seen_ids отвечает на вопрос «я это начинал?». Оно не отвечает на «я это закончил и что получилось?». А нужен именно второй ответ, потому что опасное окно - это ровно тот момент, когда джоба умерла на полпути.

create table work_log (
  work_id      text primary key,
  kind         text not null,
  state        text not null check (state in ('running','done','failed')),
  attempt      int  not null default 1,
  result       jsonb,
  external_ref text,          -- id, который вернула внешняя система
  locked_until timestamptz,
  created_at   timestamptz not null default now(),
  updated_at   timestamptz not null default now()
);

Захват работы - одним атомарным запросом, без read-then-write и без блокировок на уровне приложения:

insert into work_log (work_id, kind, state, locked_until)
values ($1, $2, 'running', now() + interval '10 minutes')
on conflict (work_id) do update
  set state = 'running',
      attempt = work_log.attempt + 1,
      locked_until = now() + interval '10 minutes',
      updated_at = now()
  where work_log.state = 'failed'
     or work_log.locked_until < now()
returning *;

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

Самая недооценённая колонка здесь - external_ref. Это ваше доказательство, что эффект приземлился в Stripe, Google Sheets или CRM, и именно она позволяет сверочной джобе чинить расхождения, а не гадать.

Шаг 3: с LLM отделяйте решение от эффекта

Вот где у AI-пайплайнов появляется своя разновидность болезни. Модель недетерминирована. Перезапустили шаг - получили другую классификацию, другую извлечённую сумму, другой tool call. А если retry случился после сайд-эффекта, у вас теперь два разных эффекта от «одной и той же» работы.

Поэтому я разбиваю каждый LLM-шаг на две фазы.

Фаза 1 - решить и закешировать решение. Ключ кеша - хеш от всего, что влияет на вывод: имя модели, версия шаблона промпта, temperature, схема tools, входные данные.

decision_key = sha256(model + prompt_v + str(temperature) + tools_hash + input_hash)

Сохраняете распарсенный вывод. На повторе вы воспроизводите то же решение, а не кидаете кубик заново. Бонусом это самая дешёвая оптимизация расходов на LLM и лучший способ дебажить пайплайн: видно, что именно модель сказала на первой попытке.

Фаза 2 - применить, идемпотентно. Шаг применения берёт закешированное решение плюс work_id и делает эффект через журнал из предыдущего раздела. Вывод модели предлагает, коммитит только apply.

Заодно вы бесплатно получаете kill switch: план, который можно посмотреть до исполнения. Когда клиент спрашивает «а можно согласовывать действия агента перед отправкой» - ответ уже «да», потому что решение и так лежит отдельным артефактом.

Шаг 4: внешний мир - тоже ваша проблема

Ваш журнал защищает вашу базу. Он не защищает сторонний API, который вы вызываете. Три уровня, в порядке предпочтения.

Уровень 1 - API поддерживает idempotency keys. Stripe и всё больше остальных. Отправляйте свой work_id в качестве ключа. Всё. Никогда не генерируйте случайный ключ на каждую попытку - это убивает весь смысл.

Уровень 2 - есть естественное уникальное поле, которым вы управляете. External reference, slug, SKU, кастомное поле. Тогда «создать» превращается в «upsert по этому полю». Большинство CRM и биллингов это умеют, если поискать в документации.

Уровень 3 - ничего нет. Telegram sendMessage, большинство вебхуков, любой легаси-эндпоинт. Здесь остаётся check-then-act с признанием, что это неидеально: ищете существующую запись по отпечатку, который сами же зашили (короткий хеш в поле заметки, невидимая строка в сообщении), и создаёте только если не нашли. Дальше сужаете окно гонки: делаете этот вызов последним в последовательности и сразу же пишете external_ref.

Отдельно про исходящие сообщения: держите таблицу sent_messages с ключом (chat_id, content_hash) и TTL. При network partition это не спасёт идеально, но остановит классику жанра - когда переотправка из очереди в три ночи спамит 400 пользователей.

Шаг 5: ретраи, окна и dead letter, который вы реально читаете

Исходите из того, что везде at-least-once доставка. Ваша очередь, провайдер вебхуков, error-ветка в n8n, крон, который наезжает сам на себя - рано или поздно всё это выстрелит дважды.

  • Ограничьте попытки. Экспоненциальный бэкофф с джиттером, максимум 5 попыток, дальше dead letter. Бесконечные ретраи на «отравленном» пейлоаде к утру сожгут вашу квоту API.
  • Сделайте dead letter видимым. Telegram-канал с work_id, kind, последней ошибкой и командой реплея в один клик. Если её никто не смотрит - это не DLQ, это фича по потере данных.
  • Защита от наложения по расписанию. Крон раз в 5 минут, который иногда работает 7, нуждается в локе, а не в надежде. locked_until в журнале уже даёт вам этот лок.
  • Сверка важнее профилактики. Раз в сутки сравнивайте журнал с целевой системой и репортите расхождения. Это единственное, что ловит гонки третьего уровня, и обычно это строк сорок кода.

Парсеры и BAS: тот же диагноз, другой костюм

Длинные браузерные автоматизации болеют этим же, только выглядит иначе. BAS-скрипт, который умер на элементе 8 400 из 10 000 и стартанул с нуля, - это не просто медленно. Это повторные отправки форм, повторные сообщения, заново сожжённый трафик прокси и лимиты аккаунтов.

Лечится той же формой: детерминированный ID на каждый элемент (обычно нормализованный URL или ID листинга - снять трекинговые параметры, отсортировать query string), список обработанных ID хранится вне рана, курсор чекпоинтится по ходу. При рестарте забираете только необработанное. И держите run_id отдельно от work_id - новый запуск не означает новую работу.

Ещё одно правило для парсеров: нормализуйте до хеширования. Я видел, как дедупликация не работала вообще, потому что варианты ?utm_source= делали каждый URL уникальным. Одна страница, пять «разных» задач, пять дублей в базе.

Аудит на 30 минут, который можно сделать сегодня

Откройте свою самую критичную для бизнеса автоматизацию и ответьте вслух:

  1. Если это выполнится дважды с тем же входом - что сломается? Назовите конкретный сайд-эффект.
  2. Откуда берётся детерминированный ID? Если ответ «execution ID платформы» - идемпотентности у вас нет.
  3. Если процесс упал между вызовом LLM и записью - что будет на ретрае? Промпт пойдёт заново?
  4. У каких внешних вызовов есть настоящие idempotency keys, у каких - естественные уникальные поля, а какие держатся на честном слове?
  5. После пяти неудач куда уходит пейлоад и кто это видит?
  6. Можете переиграть один упавший элемент, не перезапуская весь батч?

Каждое «не знаю» - это инцидент в проде с уже назначенной датой, вы её просто пока не прочитали.

Что я осознанно не делаю

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

Правило простое: если дубликат касается денег, инбокса клиента или состояния внешней системы - полный контракт обязателен. Всё остальное получает retry и пожатие плечами. Умение понимать, где это не нужно, - единственное, что не даёт паттерну превратиться в бюрократию.

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

Достаточно ли уникального индекса в базе, чтобы считать пайплайн идемпотентным?

Нет. Уникальный индекс защищает от второй вставки, но не отвечает на вопросы «что вернуть на дубликате» и «что делать, если процесс упал между вставкой и внешним вызовом». Нужен журнал эффектов со состоянием (running/done/failed), сохранённым результатом и external_ref внешней системы - только он позволяет корректно возобновить работу после краша и переиграть один упавший элемент.

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

Разделите шаг на решение и применение. На фазе решения кешируйте распарсенный вывод по ключу от модели, версии промпта, temperature, схемы tools и хеша входа - на ретрае вы воспроизводите то же решение, а не генерируете новое. На фазе применения используете закешированное решение и work_id, и коммитите эффект через журнал. Побочный эффект такого разделения - падение расходов на токены и возможность согласовывать действия агента до исполнения.

Что делать, если у внешнего API нет поддержки idempotency keys?

Сначала ищите естественное уникальное поле, которым вы управляете (external reference, slug, SKU, кастомное поле) и превращайте create в upsert по нему. Если и этого нет - зашивайте в объект собственный отпечаток (короткий хеш в заметке или скрытая строка в сообщении), делайте поиск перед созданием, ставьте этот вызов последним в последовательности и сразу пишите external_ref. Гонку это не убирает полностью, поэтому добавьте ежедневную сверку журнала с целевой системой - она и ловит остаточные дубли.

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