Все статьи
Как это сделаноОктябрь 2026

Как мы построили очередь capture-задач для AI Control

Автор: Даниил Шастовский·· 12 мин чтения

Кнопка «Проверить», которая обещает больше, чем кажется

Представьте простую кнопку в интерфейсе: «Проверить, что ChatGPT, Perplexity и Gemini говорят о моём бренде». Пользователь нажимает её и мысленно готовится ждать секунду-другую — примерно как при обычном поиске в Google. На первый взгляд это действительно один вызов API: отправили промпт, получили ответ, показали на экране.

Именно так мы и думали об этом, когда в AI Control появилась первая версия проверки видимости бренда в ответах AI-систем. Казалось, что вся сложность — в том, чтобы правильно сформировать промпт и аккуратно распарсить ответ. Реальность оказалась куда прозаичнее — и почти не имела отношения к парсингу ответов.

AI Control не делает один запрос — он делает пачку запросов. Пользователь обычно проверяет не один промпт, а сразу несколько (мы писали о том, как их выбирать, в статье про мониторинг промптов) — и хочет увидеть результат сразу по нескольким реальным AI-системам: ChatGPT, Perplexity, Copilot, Gemini, Google AI. Каждая из них — отдельный внешний сервис, который не отвечает мгновенно: модели нужно сгенерировать ответ, иногда сходить в поиск за источниками, иногда — и то, и другое. По нашим наблюдениям один такой запрос к одной системе может занять от нескольких секунд до пары минут — в зависимости от конкретной системы, длины промпта и её текущей загруженности.

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

И это не абстрактная инженерная прихоть. На основе результатов этих проверок потом строится историческая аналитика: видимость бренда во времени, Share of Voice относительно конкурентов, список источников, которые AI-системы реально цитируют. Если проверка время от времени «теряется» где-то посередине, это не просто неприятный UX — это дыра в данных, которую задним числом уже не восстановить.

Почему «просто подождать» не работает

Наивный вариант выглядит так: пользователь нажимает «Проверить», браузер отправляет запрос, сервер синхронно опрашивает все AI-системы — по очереди или параллельно, — собирает ответы и в конце одним большим ответом отдаёт их обратно. Работает отлично на демо с одним промптом. На практике, при нескольких промптах и нескольких системах, ломается сразу в трёх местах.

  • Долгий запрос привязан к вкладке браузера. Закрыли вкладку, заблокировали экран телефона, на секунду оборвался Wi-Fi — соединение разорвано. Вся уже проделанная работа пропадает, проверку нужно запускать заново.
  • Состояние задачи хранится только в памяти сервера. Даже если сделать всё асинхронным и показывать прогресс-бар, но держать «что происходит с задачей №482» исключительно в оперативной памяти процесса, рестарт сервера — деплой, масштабирование, падение — бесследно стирает все задачи, которые были в работе. Пользователь видит прогресс-бар, навсегда замерший на 40%.
  • Нет ограничения на параллелизм. Если запускать вызов к AI-системе сразу на каждый промпт, как только пришёл запрос, несколько пользователей, стартовавших проверки одновременно, быстро породят десятки параллельных вызовов к одним и тем же внешним системам. Это именно то, от чего предостерегают сами провайдеры: слишком много одновременных запросов повышает шанс получить замедленные или отклонённые ответы — причём не только у того, кто это запустил, а у всех, кто в этот момент пользуется сервисом.

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

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

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

Идея в основе решения: сначала записать, потом делать

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

Мы изменили порядок действий на один, на первый взгляд, скромный шаг: прежде чем начать любую реальную работу, backend записывает в базу строку — job — со статусом «в очереди». Только после этого начинается собственно обращение к AI-системе. Это меняет всё: теперь единица работы существует независимо от того, открыта ли ещё вкладка браузера, жив ли тот конкретный процесс сервера, который принял исходный запрос, и вообще — подключён ли в этот момент пользователь к интернету.

Дальше в дело вступает небольшой фиксированный пул фоновых воркеров — не отдельных серверов, а процессов внутри того же backend, которые постоянно опрашивают базу на предмет задач в статусе «в очереди». Как только воркер находит такую задачу, он атомарно помечает её как «захвачена» — именно атомарно, чтобы два воркера не схватили одну и ту же задачу одновременно, — и только после этого идёт делать реальный внешний вызов. Количество одновременно работающих воркеров сознательно ограничено: условно, речь о небольшой фиксированной горстке задач одновременно, а не о «сколько запросов пришло, столько и обрабатываем».

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

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

Разберём на условном примере. Пользователь запускает проверку по 8 промптам в 4 AI-системах — это 32 отдельные задачи. Если бы всё шло строго последовательно и синхронно, при среднем времени ответа в районе 30–40 секунд на запрос вся проверка могла бы растянуться на 15–20 минут непрерывного HTTP-соединения. С очередью и несколькими воркерами то же суммарное время работы выполняется параллельно: часть задач уже готова, пока другие ещё обрабатываются, и пользователь видит первые результаты почти сразу, а не ждёт ответа по самому медленному промпту, прежде чем увидеть хоть что-то.

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

Архитектура по слоям

Если собрать это в единую картинку, у механизма проверки получается пять слоёв — от входящего запроса до реального обращения к внешней AI-системе.

API-эндпоинтпринимает запрос на проверку и сразу отвечает, не дожидаясь результата
Запись в базу данныхjob создаётся со статусом «в очереди» раньше, чем начинается любая работа
Очередь и пул воркеровограниченный буфер задач, которые забирает небольшое фиксированное число фоновых воркеров
Внешняя AI-системареальный вызов к ChatGPT, Perplexity, Copilot, Gemini или Google AI

Важно, что слои 1 и 2 — это единственное, что обязано отработать синхронно и быстро: принять запрос и записать строку в базу. Всё, что происходит дальше — очередь, воркеры, сам вызов к AI-системе, — уже не блокирует ни пользователя, ни тот HTTP-запрос, который всё это инициировал.

Жизненный цикл одной задачи

Если смотреть не на архитектуру целиком, а на судьбу одной конкретной задачи — скажем, «проверить промпт X в Perplexity», — она проходит через несколько состояний.

В очереди
Захвачена воркером
Выполняется
Готово
Ошибка → повтор

Состояния «Готово» и «Ошибка» не всегда финальны. Если вызов к AI-системе не удался по временной причине — например, внешний сервис ответил с ошибкой, которая выглядит как кратковременная перегрузка, а не как «этот промпт в принципе нельзя обработать», — задача возвращается в статус «в очереди» с увеличенным счётчиком попыток, а не сразу помечается провалившейся окончательно. У повторов есть предел: после нескольких неудачных попыток задача помечается как окончательно упавшая, и это видно в интерфейсе, а не теряется молча.

Как это выглядит в коде

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

Псевдокод: запись job и цикл воркера
# упрощённый псевдокод, не реальный продакшен-код

def enqueue_check(prompt, provider):
    job = db.insert("capture_jobs", {
        "status": "pending",
        "prompt": prompt,
        "provider": provider,
        "attempts": 0,
        "created_at": now(),
    })
    return job.id

def worker_loop():
    while True:
        job = db.claim_one(
            "UPDATE capture_jobs SET status='claimed', claimed_at=now() "
            "WHERE id = (SELECT id FROM capture_jobs WHERE status='pending' "
            "ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED) "
            "RETURNING *"
        )
        if job is None:
            sleep(1)
            continue

        try:
            db.update(job.id, status="running")
            result = call_ai_system(job.provider, job.prompt)
            db.update(job.id, status="done", result=result)
        except TransientError:
            db.update(job.id, status="pending", attempts=job.attempts + 1)
        except FatalError as e:
            db.update(job.id, status="failed", error=str(e))

def on_startup():
    # всё, что осталось в статусе "running" после рестарта, считается прерванным
    db.execute(
        "UPDATE capture_jobs SET status='pending' WHERE status='running'"
    )

Две детали здесь важнее, чем может показаться. Во-первых, захват задачи («claim») — это один атомарный SQL-запрос, а не «сначала прочитать, потом записать» в два шага: именно это не даёт двум воркерам забрать одну и ту же задачу в редком, но неизбежном случае гонки. Во-вторых, обработчик старта (on_startup) — это и есть вся логика восстановления после рестарта: ничего специального сверх «найти всё зависшее и вернуть в очередь» не требуется.

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

Есть и побочный эффект, который мы не планировали заранее, но оценили постфактум: фиксированный пул воркеров собрал в одном месте всю логику поведения при ошибках внешних систем. Раньше код вида «что делать, если AI-система ответила ошибкой перегрузки» был бы размазан по всем местам, откуда теоретически можно вызвать внешний API. Теперь он живёт ровно в одном цикле воркера и ведёт себя одинаково независимо от того, какой именно экран или кнопка инициировали проверку — обновлять его приходится тоже в одном месте, а не искать по всей кодовой базе.

Наивный подход vs надёжная очередь: что происходит в краевых случаях

На словах разница между «синхронно подождать ответ» и «записать задачу и обработать её в фоне» звучит как внутренняя техническая деталь. На практике она становится заметна именно в краевых случаях — и именно в них наивная версия подводит чаще всего.

СитуацияНаивный синхронный вызовНадёжная очередь задач
Пользователь закрыл вкладкуСоединение разрывается, ответ потерян, проверку нужно начинать зановоJob уже сохранён в базе — работа продолжается в фоне, прогресс виден при следующем заходе
Сервер перезапускается во время проверкиЗадача, которую сервер держал в памяти, исчезает бесследноПри старте backend находит незавершённые job и возвращает их в очередь
Два похожих запроса приходят почти одновременноОба запускают дублирующие вызовы к AI-системам — лишний расход и гонкаИдемпотентность на уровне job распознаёт дубль и не плодит лишнюю работу
Внешняя AI-система не отвечает вовремяВся HTTP-цепочка виснет вместе с пользовательским запросомJob помечается как истёкшая или уходит на повтор, а остальная очередь продолжает работать

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

Вопросы, которые нам задают про такую архитектуру

Почему не взять готовую очередь вроде Celery или RabbitMQ?

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

Что если воркер сам упадёт во время обработки job?

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

Как пользователь узнаёт, что проверка идёт, если всё происходит в фоне?

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

Не проще ли было просто разрешить больше параллельных запросов к AI-системам?

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

Чек-лист: как построить свою очередь фоновых задач

Ничего из описанного выше не специфично для проверки AI-систем — это общий паттерн для любой задачи, которая делает что-то медленное и внешнее: отправляет письма, генерирует отчёты, дёргает чужой API, обрабатывает загруженный файл. Если вы делаете что-то похожее сами — даже если изрядную часть кода пишете с помощью ИИ, как в вайбкодинге для SEO-специалистов, — вот с чего стоит начать.

  1. Записывайте состояние задачи до того, как начнёте её выполнять, а не после — источником истины должна быть база данных, а не память процесса.
  2. Ограничивайте параллелизм сознательно: небольшой фиксированный пул воркеров защищает и ваш сервис, и внешние системы, с которыми он общается.
  3. Проектируйте restart-safety с самого начала, а не добавляйте её после первого инцидента: при старте ищите задачи, застрявшие в промежуточном статусе, и возвращайте их в очередь.
  4. Делайте повторы идемпотентными — обработка одной и той же задачи дважды не должна приводить к двойному результату или двойному списанию.
  5. Чистите завершённые задачи — иначе таблица очереди растёт бесконечно и сама становится узким местом.
  6. Делайте прогресс наблюдаемым извне процесса: если пользователь или дашборд не могут посмотреть статус без доступа к памяти сервера, восстановление после сбоя будет болезненным.
Читайте также

Если тема «проверить, что ИИ говорит о бренде» для вас в новинку — начните с гайда по мониторингу видимости в AI-поиске, а про то, как устроен сбор самих промптов, — в статье о мониторинге промптов.

Хотите проверить это на своём рынке?

AI Control регулярно собирает ответы AI-систем, позиции брендов, конкурентов и цитируемые источники по вашим промптам.

Открыть презентацию AI Control

А ваш контент готов к историям вроде этих?

Используйте 50 приветственных лимитов для проверки AI Readiness — retrieval, extractability, сигналы schema.org и приоритизированное ТЗ на правки.

Получить 50 лимитов

Не только читайте про ИИ-поиск — проверьте по нему свои страницы.

Те же сигналы AEO/GEO, что описаны выше — разметка, retrieval, прямые ответы, цитирования — AI Readiness оценивает на любой странице, которую вы ему дадите. Новому аккаунту доступны 50 общих лимитов.