AI-генератор коротких видео Shorts Maker v2 начинался как типичный прототип: несколько параллельных вызовов к разным AI-провайдерам, собранных в asyncio.gather(). Работало быстро, пока один из провайдеров не возвращал ошибку. В этот момент весь прогресс терялся, а деньги за уже выполненные API-вызовы списывались безвозвратно. Рефакторинг превратил прототип в production-решение с атомарными checkpoint'ами, явной машиной состояний и идемпотентностью на уровне бизнес-операций. Разберём ключевые инженерные решения, которые за этим стоят.
Материал построен вокруг конкретных архитектурных паттернов: агрегат Project в парадигме Domain-Driven Design, частичный статус для каждой сцены, конкурентная обработка с пост-сценным сохранением и заменяемые AI-провайдеры через структурные протоколы Python. Каждое решение закрывает определённый класс проблем, с которыми сталкиваются разработчики при построении пайплайнов, завязанных на дорогие внешние API. Если вы проектируете систему, где сбой одного компонента не должен обнулять всю цепочку обработки, этот разбор даст вам готовые паттерны для внедрения.
Похожий подход к оценке надёжности AI-систем мы разбирали в кейсе Motorway и AWS, где двухфазная стратегия и quality gates сократили долю ошибок с 12.5% до 2%. Архитектурные принципы из той статьи перекликаются с решениями в Shorts Maker v2, хотя задачи разные: там оценка агентов, здесь генерация видео.
Проблема: почему прототип на asyncio.gather() не стал production-решением
Исходная архитектура Shorts Maker v2 выглядела знакомо для любого, кто быстро прототипировал AI-фичи. Генерация видео разбивалась на сцены. Для каждой сцены требовалось вызвать минимум два AI-сервиса: генерацию изображения и озвучку текста. Вызовы собирались в asyncio.gather() и выполнялись параллельно. Код получался компактным, а время выполнения радовало на тестовых данных.
Три проблемы проявились при первых же реальных нагрузках. Первая: потеря всего прогресса при сбое одного провайдера. Если из десяти сцен восемь сгенерировались успешно, а на девятой API вернул 503, asyncio.gather() выбрасывал исключение, и все восемь успешных результатов исчезали. Денег за API-вызовы никто не возвращал. Вторая проблема: отсутствие возобновления. После сбоя пайплайн приходилось запускать заново с нуля, повторно генерируя уже готовые сцены. Третья: недетерминированное состояние. При частичном сбое невозможно было понять, какие сцены реально завершены, а какие находятся в подвешенном состоянии, потому что система не хранила промежуточных результатов.
Типичный сценарий потерь: проект из 12 сцен, средняя стоимость генерации одной сцены $0.15, сбой на девятой сцене. Потеря прогресса означает повторную оплату восьми успешных сцен, то есть $1.20 чистых убытков. На сотне проектов в день это $120, которые можно не тратить, если пайплайн умеет возобновляться с точки сбоя.
Целевая архитектура: DDD, агрегат Project и явная машина состояний
Рефакторинг начался с введения центральной сущности - агрегата Project. В терминах Domain-Driven Design агрегат инкапсулирует всю бизнес-логику генерации видео и выступает единственной точкой входа для изменения состояния. Project хранит список сцен, их статусы, метаданные и текущую фазу обработки. Никакой внешний код не может изменить состояние сцены в обход агрегата.
Явная машина состояний стала вторым ключевым решением. Вместо неявных флагов и проверок if project.scenes_done == total_scenes появились чётко определённые переходы: Created → GeneratingScenes → Completed и Created → GeneratingScenes → Failed. Каждый переход валидируется: нельзя пометить проект как Completed, если есть сцены в статусе InProgress или Failed. Машина состояний устранила класс багов, связанных с неконсистентным состоянием, когда часть данных говорит об успехе, а часть о незавершённости.
Такой подход прямо соотносится с ценностями проекта AI-MANUAL: структурированность и практичность. Вместо разрозненных проверок разработчик получает предсказуемую модель, которую легко тестировать и расширять. Если завтра добавится новый этап обработки, например постобработка видео, он просто встраивается как дополнительное состояние в машину, не ломая существующую логику.
Частичный статус сцен: отслеживаем прогресс каждой единицы работы
Каждая сцена внутри агрегата Project получила собственный жизненный цикл с четырьмя статусами: Pending, InProgress, Completed, Failed. Это позволило отслеживать прогресс гранулярно, на уровне отдельной сцены, а не проекта целиком. После сбоя система анализирует список сцен, находит первую со статусом не Completed и возобновляет генерацию с неё.
Структура данных агрегата выглядит примерно так:
{
"project_id": "proj_8a3f2",
"state": "GeneratingScenes",
"version": 5,
"scenes": [
{"scene_id": 0, "status": "Completed", "image_url": "...", "audio_url": "..."},
{"scene_id": 1, "status": "Completed", "image_url": "...", "audio_url": "..."},
{"scene_id": 2, "status": "InProgress", "image_url": null, "audio_url": null},
{"scene_id": 3, "status": "Pending", "image_url": null, "audio_url": null}
]
}
Поле version используется для optimistic locking, но об этом позже. Сейчас важно, что при сбое на сцене 2 система видит: сцены 0 и 1 завершены, сцена 2 не завершена, стартуем с неё. Никакой повторной генерации, никаких лишних затрат.
Отказоустойчивость: атомарные checkpoint'ы и возобновление после сбоев
Атомарный checkpoint - это сохранение полного состояния агрегата Project после завершения каждой сцены. Слово «атомарный» здесь означает, что операция сохранения либо выполняется целиком, либо не выполняется вовсе: нельзя сохранить половину сцены или обновить статус без сохранения URL сгенерированного изображения.
Реализация опирается на файловое хранилище с optimistic locking. После успешной генерации сцены агрегат обновляет её статус на Completed, инкрементирует свою версию и сохраняется. Если в момент сохранения версия в хранилище не совпадает с ожидаемой, операция отклоняется - это означает, что другой конкурентный процесс уже изменил агрегат. Подробнее механизм разберём в разделе о конкурентности.
Сценарий восстановления: проект из 5 сцен, сбой на сцене 3. В хранилище лежит состояние с двумя завершёнными сценами и версией 2. При перезапуске пайплайн загружает агрегат, видит, что сцены 0 и 1 Completed, сцена 2 InProgress (не успела за checkpoint'иться до сбоя), и начинает генерацию со сцены 2. Идемпотентность бизнес-операций гарантирует, что повторный вызов генерации для сцены, которая могла частично выполниться до сбоя, не создаст дубликатов и не приведёт к двойному списанию средств. Достигается это через проверку статуса перед вызовом API: если сцена уже Completed, операция пропускается.
Практическая ценность для читателя: внедрение checkpoint'ов сокращает прямые затраты на API-вызовы при сбоях на величину, пропорциональную доле успешно завершённой работы до сбоя. Для пайплайна из 20 сцен со сбоем на 15-й экономия составляет 70% стоимости генерации по сравнению с полным перезапуском.
Конкурентность без конфликтов: пост-сценное сохранение и optimistic locking
Генерация сцен выполняется конкурентно с помощью asyncio.TaskGroup (доступен в Python 3.11+). Каждая сцена запускается как отдельная задача, которая независимо вызывает AI-провайдеров для изображения и озвучки. Но сохранение состояния происходит строго последовательно: после завершения задачи она не пишет в хранилище напрямую, а помещает результат в очередь, из которой единственный поток сохранения применяет изменения к агрегату.
Такой подход называется пост-сценным сохранением. Он исключает гонки данных на уровне агрегата: две одновременно завершившиеся сцены не пытаются одновременно перезаписать файл состояния. Вместо этого поток сохранения обрабатывает их по очереди, инкрементируя версию агрегата после каждой.
Optimistic locking добавляет дополнительный уровень защиты. При сохранении агрегата в хранилище передаётся ожидаемая версия. Если другая реплика сервиса или фоновый процесс успели изменить агрегат, версия в хранилище будет отличаться от ожидаемой, и сохранение отклонится с ошибкой конфликта. Разрешение коллизии простое: перезагрузить актуальное состояние агрегата, применить к нему накопившиеся изменения и повторить сохранение.
Пример: сцены 4 и 5 завершаются почти одновременно. Поток сохранения берёт результат сцены 4, загружает агрегат версии 7, применяет изменения, сохраняет как версию 8. Затем берёт результат сцены 5, загружает агрегат - он уже версии 8, ожидаемая версия 7, optimistic locking отклоняет сохранение. Поток перезагружает агрегат версии 8, применяет изменения сцены 5 и сохраняет как версию 9. Конфликт разрешён, данные не потеряны.
Компромисс очевиден: последовательное сохранение добавляет небольшую задержку по сравнению с полностью параллельной записью, но устраняет целый класс трудноуловимых багов, связанных с конкурентным доступом к состоянию. Для AI-пайплайнов, где генерация одной сцены занимает секунды, а сохранение - миллисекунды, эта задержка незаметна.
Гибкость интеграций: заменяемые AI-провайдеры через структурные протоколы
Жёсткая привязка к конкретным AI-сервисам - частая причина боли при эволюции пайплайна. Сегодня вы используете OpenAI для генерации изображений, завтра выходит новая модель с лучшим соотношением цена/качество, но её API несовместим с вашим кодом. Shorts Maker v2 решает это через структурные протоколы Python (typing.Protocol).
Определяется интерфейс провайдера без привязки к реализации:
from typing import Protocol, runtime_checkable
@runtime_checkable
class TextToImageProvider(Protocol):
async def generate(self, prompt: str, size: tuple[int, int]) -> bytes:
...
def cost_per_call(self) -> float:
...
Конкретные реализации подключаются через dependency injection. Ядро пайплайна работает только с протоколом, не зная, кто именно генерирует изображение: DALL-E, Stable Diffusion или локальная модель:
class DalleProvider:
async def generate(self, prompt: str, size: tuple[int, int]) -> bytes:
# вызов OpenAI API
...
def cost_per_call(self) -> float:
return 0.04
class StableDiffusionProvider:
async def generate(self, prompt: str, size: tuple[int, int]) -> bytes:
# вызов локального инференса
...
def cost_per_call(self) -> float:
return 0.01
Замена провайдера сводится к изменению одной строки в конфигурации. Пайплайн не требует модификации. Тот же принцип применяется к провайдерам озвучки, распознавания речи и любым другим AI-сервисам, которые могут появиться в будущем. Это не абстрактная архитектурная рекомендация, а конкретный паттерн, который можно скопировать в свой проект и адаптировать под свои интерфейсы.
Если вы проектируете AI-агентов с нуля, аналогичный подход к абстрагированию внешних зависимостей мы разбирали в статье про архитектуру самописного AI-агента, где оркестрация LLM, память и инструменты вынесены за интерфейсы, что позволяет менять провайдеров без переписывания ядра.
Применимость и ограничения: когда стоит внедрять такие решения
DDD, машины состояний и атомарные checkpoint'ы не нужны для скрипта из 50 строк, который вызывается раз в день. Сложность решения должна быть пропорциональна цене ошибки. Критерии, при которых описанные паттерны оправданы: дорогие внешние API, где повторные вызовы стоят денег; длительные пайплайны, где потеря прогресса означает потерю часов работы; требования к надёжности, где сбой в production недопустим без возможности быстрого восстановления.
Для простых сценариев существуют альтернативы. Очереди задач с механизмом повторных попыток, например Celery с Redis, закрывают потребность в возобновлении для короткоживущих задач. Паттерн Saga с компенсирующими транзакциями решает проблему консистентности без полноценного DDD. Выбор зависит от масштаба: если у вас три сцены и один AI-провайдер, агрегат Project с машиной состояний будет оверинжинирингом.
Увеличение сложности кода - честная цена за надёжность. Явная машина состояний требует дисциплины: каждый новый разработчик должен понимать, что нельзя менять статус сцены в обход агрегата. Структурные протоколы добавляют слой абстракции, который усложняет навигацию по коду. Optimistic locking требует обработки конфликтов, что увеличивает количество ветвлений в логике сохранения. Для Shorts Maker v2 эти затраты окупились: количество инцидентов, связанных с потерей прогресса, сократилось до нуля, а добавление нового AI-провайдера теперь занимает часы, а не дни.
Главный вывод: архитектурные решения из этого разбора - не серебряная пуля, а набор инструментов для конкретного класса проблем. Если ваши боли совпадают с описанными (потеря прогресса, недетерминированное состояние, vendor lock-in на AI-провайдерах), паттерны из Shorts Maker v2 можно брать и адаптировать. Если нет - не усложняйте. Прозрачность в оценке применимости решений - один из принципов AI-MANUAL, и этот материал ему следует.
Для более широкого взгляда на архитектурные риски при внедрении AI рекомендую статью о незаменимости разработчика в эпоху AI, где разбираются скрытые расходы на AI-агентов и практические методики контроля качества. А если интересен процесс быстрого превращения прототипа в production-код, обратите внимание на разбор инструмента Pre2Prod, который за 40-60 минут поднимает покрытие тестами с 0% до 78% и устраняет уязвимости OWASP Top 10.