LLM ответил правильно, но уже не на тот вопрос: защита записи по версии
Модель подготовила подсказку по обращению, JSON прошёл проверки, срок задачи ещё не истёк. Но клиент успел изменить запрос — и старый worker записал уже ненужный совет. Покажу на Python и SQLite, как связать результат с версией исходных данных и отклонить устаревшую запись одним UPDATE.
Дедлайн ещё действует, а контекст уже другой
В комментариях к статье про очередь возник важный вопрос: что делать, если задача не просрочена, но само обращение изменилось? Это отдельная проверка. Срок полезности отвечает на вопрос «не поздно ли выполнять», версия — «к тем ли данным относится ответ».
Возьмём учебный сценарий, не историю производственного инцидента. В обращении написано: «Где моя доставка?». Бэкенд читает текст и revision=1, отправляет текст модели. Пока идёт генерация, клиент пишет: «Отмените заказ». Текст обновляется, revision становится 2. Первый ответ может быть совершенно разумным для старого вопроса, но сохранять его как актуальную подсказку уже нельзя.
Проблема не требует ошибки модели. Достаточно нормальной задержки между чтением и записью. Повторная генерация, более строгая JSON-схема или проверка дедлайна сами по себе эту гонку не закрывают.
Почему SELECT перед UPDATE недостаточно
Наивный вариант: прочитать текущую revision, сравнить её с сохранённой и затем отдельным запросом записать результат. Между сравнением и записью может пройти ещё одно изменение. Проверка была правильной в момент чтения — не в момент применения.
Я бы перенёс условие в саму запись: обновить подсказку только у нужного tenant и ticket, если revision всё ещё равна той, которую прочитала задача. При успехе увеличить revision. Сравнение и изменение выполняет база в одном UPDATE, а не Python в двух независимых шагах.
По документации SQLite, UPDATE меняет только строки, для которых истинно условие WHERE. Ноль подходящих строк — допустимый результат, не SQL-ошибка. Поэтому отсутствие исключения ещё не означает, что подсказка применена: надо проверить число изменённых строк.
Минимальный пример: старый ответ не попадает в карточку
Ниже самостоятельный учебный скрипт на стандартной библиотеке. LLM здесь не вызывается: строка «Old delivery advice» имитирует уже полученный и проверенный ответ. Метаданные Snapshot формирует бэкенд; модель не выбирает tenant, ticket или revision.
Результат запуска: False, затем ('Cancel the order', None). Старая подсказка не записалась. Если убрать условие по revision, тот же ответ попадёт рядом с новым текстом обращения — хотя синтаксически обе операции корректны.
В Python sqlite3 атрибут Cursor.rowcount показывает число изменённых строк для обычного UPDATE. В этом примере успех — ровно одна строка, причём функция возвращается после выхода из транзакционного блока. Ошибку SQL или commit нельзя превращать в False: False здесь означает только, что условная запись не нашла подходящую строку.
Версия работает только при общем правиле записи
Сам счётчик ничего не защищает, если часть системы обновляет значимые данные без его увеличения. Я бы закрепил контракт: любое изменение входного контекста и любое принятое обновление защищаемой подсказки увеличивает revision в той же транзакции. Ручная правка подсказки тоже должна участвовать в этом правиле.
Текст и версию нужно читать согласованно — в примере одним SELECT. Нельзя сначала забрать body, потом отдельным чтением получить уже новую revision и прикрепить её к старому тексту. Не нужно держать транзакцию или блокировку открытой на всё время обращения к модели: читаем снимок, завершаем чтение, генерируем, пробуем короткую условную запись.
При изменении текста старую подсказку в примере очищаем. Другой вариант — хранить вместе с ней версию источника и не показывать как актуальную при несовпадении. Просто отбрасывать будущие устаревшие ответы недостаточно, если предыдущий совет уже остался в интерфейсе.
Идентификатор обращения нельзя переиспользовать после удаления со сбросом revision: иначе старая задача может совпасть с новой записью. Для такой модели данных нужен отдельный неизменяемый идентификатор экземпляра записи. Счётчик также не должен сбрасываться или переполняться; ограничения типа и диапазона стоит закрепить в реальной схеме.
Что именно проверено локально
Для примера выполнены 10 unittest-тестов без запросов к LLM. Проверены успешная запись и увеличение версии, отказ после ручного изменения, два результата одного снимка, неверный tenant, отсутствующее обращение, одинаковый повторный текст ответа, изменение исходного текста с возвратом к прежнему, незащищённый UPDATE, откат при ошибке и конкуренция двух соединений.
В конкурентном тесте два потока открывают отдельные соединения с временной SQLite-базой, получают один исходный снимок и одновременно пытаются применить разные подсказки. Проверяем не имя победителя, а инвариант: один True, один False, итоговая revision=2. Это локальная проверка конкретного примера, не нагрузочный тест и не доказательство поведения любой СУБД или ORM.
Первый принятый ответ — не обязательно последний запрошенный
Здесь есть важное продуктовое ограничение. Две генерации могут стартовать с одной revision. Первым запишется тот результат, который первым успешно выполнил условный UPDATE. Второй будет отклонён. Это политика «одна принятая запись на снимок», а не «побеждает самая новая запрошенная генерация».
Если пользователь нажал «пересоздать», а продукт обязан дождаться именно новой попытки, нужна дополнительная версия генерации: например, generation_seq, которую бэкенд атомарно повышает при новом разрешённом запуске и затем проверяет вместе с версией контекста. Нельзя выдавать обычный compare-and-set по одной revision за реализацию такой политики.
Один общий счётчик проще проверить, но он может отклонять полезный результат даже после несущественной правки. Раздельные context_revision и suggestion_revision позволяют точнее определить конфликты, зато требуют явно перечислить зависимости. Я бы начинал с безопасного контракта и усложнял его только после разбора реальных причин отказов.
Конфликт — не команда немедленно заплатить за ещё один вызов
False в примере стоит трактовать как «не применено»: версия могла измениться, запись могла исчезнуть или перестать соответствовать области доступа. Для точного сообщения можно отдельно прочитать состояние, но это чтение уже не является разрешением на запись.
Я бы сохранял сам результат задачи и причину неприменения отдельно от актуальной подсказки. Автоматическая новая генерация — отдельное продуктовое решение с новым снимком, проверкой срока полезности и бюджета. Если обращение редактируют непрерывно, слепой цикл «конфликт → ещё один вызов» только умножит расходы. Можно дождаться паузы в правках или предложить явное действие «сформировать заново».
Эта защита касается локальной записи. Она не отменяет уже отправленное письмо и не откатывает действие во внешней CRM. Для внешних эффектов нужны собственные проверки, идемпотентность и сверка результата. Tenant в WHERE полезен для области записи, но не заменяет авторизацию: право пользователя на обращение проверяется отдельно.
Что проверить перед внедрением
- Какие изменения делают исходный ответ неактуальным и все ли писатели увеличивают соответствующую версию.
- Не разъезжаются ли body и revision при чтении снимка.
- Есть ли условие по версии непосредственно в UPDATE и проверяется ли результат после commit.
- Что видит пользователь после конфликта и что происходит со старой уже сохранённой подсказкой.
- Нужна ли политика «первый принятый» или «последний запрошенный» — это разные контракты.
Практический вывод: валидный ответ модели не получает право перезаписывать текущие данные автоматически. Это право нужно заново проверить на границе применения — против того состояния, к которому ответ был подготовлен.
Технические источники: SQLite: UPDATE и условие WHERE; Python sqlite3: Cursor.rowcount. Пример и локальные тесты подготовлены для этой статьи; это не описание готовой production-системы.
Какую политику вы выбираете для двух AI-ответов на один снимок: первый принятый, последний запрошенный или ручной выбор? И какие изменения контекста у вас обязательно отменяют право на запись?