Зимой я разбил две машины с интервалом в пару недель. Никто не пострадал, кроме моего кошелька — и в результате этого курьёзного случая мне очень понадобились деньги.

В январе 2026 я вышел на рынок труда искать своего идеального работодателя. Все тестовые задания для SEO‑специалиста одинаковы до банальности: провести аудит сайта, написать ТЗ, дать рекомендации. После первого же тестового я понял, что не готов сделать ещё сотню таких. И решил, что помощника проще написать, чем искать, учить, терпеть и оплачивать.

9 января 2026 появилась первая строка кода. Сегодня это Оракул — оркестратор ИИ‑агентов: 465 коммитов, ~20 000 строк Python, четыре LLM‑провайдера, планировщик, MCP‑сервер и первые клиенты. Загрузка по основной работе никуда не делась, так что весь проект — это примерно 4 часа в день.

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

Требование 0: помощник, который не врёт

Главный критерий был один и очень жёсткий: я должен смело отсылать результат его работы клиенту, не перепроверяя.

Обычный чат с LLM этот критерий не проходит. Спросите у модели «какая скорость загрузки у сайта X» — и получите правдоподобное число, которое она придумала. Для черновика это терпимо. Для отчёта, под которым стоит моя фамилия, — нет.

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

Сейчас в шаге агента можно выбрать один из семнадцати инструментов:

TOOL_CHOICES = [    
  ('none', 'Только текст'),    
  ('fetch_url_content', 'Парсинг сайта'),   
  ('google_search', 'Поиск в Google (Gemini)'),    
  ('check_pagespeed', 'Google PageSpeed'),    
  ('ai_readiness_audit', 'AI-готовность (роботы/рендер/структура)'),    
  ('get_rapid_seo_metrics', 'Анализ ссылок'),    
  ('fetch_url_selenium', 'Парсинг Selenium'),    
  ('get_keyword_ideas', 'Подбор ключевых слов (Google Ads)'),    
  ('get_credential', 'Авторизация (Vault)'),    
  ('fetch_github_repo', 'Анализ GitHub-репозитория'),   
  ('fetch_accessibility_map', 'RAW Data'),    
  ('analyze_website_with_gemini', 'AI Accessibility Map'),    
  ('call_subagent', 'Вызвать субагента (рекурсия)'),    
  ('get_gsc_data', 'Google Search Console'),    
  ('get_yandex_metrika_data', 'Яндекс.Метрика'),    
  ('generate_image', 'Генерация изображения'),    
  ('generate_voice', 'Генерация голоса (TTS)'),    
  ('analyze_youtube_channel', 'Анализ YouTube-канала'),
]

PageSpeed возвращает реальные Core Web Vitals из Google API. Search Console — реальные запросы и позиции по OAuth. Метрика — реальный трафик. Модель получает готовые числа и пишет по ним текст. Придумать ей нечего.

Это скучное решение, но именно оно превращает игрушку в инструмент, результат которого не стыдно отдать.

Требование 1: не терять контекст на сложных задачах

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

Решение — агенты‑роутеры: сложная задача разбита на самостоятельные шаги, у каждого своя инструкция и свой инструмент.

class AgentStep(models.Model):
    agent = models.ForeignKey(AgentPersona, related_name='steps', ...)
    order = models.PositiveIntegerField("Порядок", default=0)
    instruction = models.TextField("Инструкция шага")
    tool_name = models.CharField("Инструмент", choices=TOOL_CHOICES, default='none')

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

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

Пример автоматически сформированного коммерческого предложения на основе текущих метрик сайта
Пример автоматически сформированного коммерческого предложения на основе текущих метрик сайта

Подряд идущие шаги‑инструменты выполняются параллельно через asyncio.gather: PageSpeed, ссылочный профиль и парсинг контента незачем ждать по очереди.

Баг, который это принесло. Каждый параллельный шаг писал свой результат в общий AgentTaskState.collected_data (JSON‑поле). Классический read‑modify‑write: корутина читала состояние, правила свой ключ в памяти и сохраняла весь объект целиком. Побеждал тот, кто сохранился последним, ключи остальных молча терялись. Симптом выглядел мистически — пустой collected_data у прогонов, которые внешне отработали успешно. Лечится одним локом на task_id:

_task_state_locks: dict[str, asyncio.Lock] = {}

async def save_subagent_result(task_id, key, value):
    async with _get_task_state_lock(task_id):
        state = await AgentTaskState.objects.aget(task_id=task_id)
        state.collected_data[key] = value
        await state.asave()
        

Требование 2: не зависеть от одного провайдера

С разными типами задач разные модели справляются по‑разному. Дешёвая модель отлично классифицирует намерение пользователя и разваливается на длинном аналитическом тексте. Дорогая пишет прекрасный отчёт, но платить ей за «определи, о чём вопрос» — глупо.

Провайдер выбирается на уровне агента, а не проекта:

class LLMProvider(models.TextChoices):
    GEMINI = "gemini", "Gemini"
    OPENAI = "openai", "OpenAI"
    CLAUDE = "claude", "Claude"
    OPENROUTER = "openrouter", "OpenRouter"
    

Четвёртый пункт здесь важнее первых трёх: OpenRouter — это доступ к сотням моделей одним ключом и одним счётом, включая те, ради которых иначе пришлось бы заводить отдельного провайдера и отдельную оплату. Модель задаётся слагом (anthropic/claude-sonnet-5, google/gemini-3.5-flash) в настройках и меняется за пару секунд без деплоя.

Внутри для каждого провайдера свой цикл вызова инструментов — openaichat_with_tools, claudechat_with_tools, openrouterchat_with_tools — потому что формат tool calling у всех троих разный, а у Gemini ещё и своя логика automatic function calling. Это самая скучная и самая полезная часть проекта: набор инструментов один, а провайдера агенту можно поменять как перчатки.

Заодно каждый вызов логируется с токенами и стоимостью — цены лежат в отдельной таблице и правятся из админки:

DEFAULT_PRICES = {    "gemini-2.5-flash": ("0.30", "2.50"),    "gpt-5-mini":       ("0.25", "2.00"),    "claude-sonnet-5":  ("3.00", "15.00"),    ...}

Без этого невозможно ответить на простой вопрос: а сколько вообще стоит один аудит?

Требование 3: выполнять повторяющиеся задачи самостоятельно

Помощник, которого надо просить, — это всё ещё работа. Настоящая экономия начинается там, где он делает что‑то сам.

Планировщик на APScheduler с хранением расписания в БД (django-apscheduler), два типа триггеров: CRON для регулярных задач и ONCE для одноразовых.

Первое, что я на него повесил, — еженедельная сводка SEO‑новостей от Search Engine Journal. Утром в понедельник агент идёт на сайт, читает свежие материалы, отбирает то, что относится к моим проектам, и присылает выжимку в Telegram. Раньше я тратил на это час и всё равно пропускал половину.

Автоматическая еженедельная сводка SEO-новостей от Search Engine Journal
Автоматическая еженедельная сводка SEO‑новостей от Search Engine Journal

Баг, который стоил мне денег. Задачи типа ONCE после выполнения должны удаляться. У меня в коде сначала стоял только is_completed = True без фактического удаления, а позже нашлась вещь похуже: если доставка результата в Telegram падала (пользователь заблокировал бота, сеть моргнула), исключение прерывало код до пометки о завершении. Задача оставалась активной и через минуту запускала весь дорогой конвейер заново. Навсегда.

Мораль дороже бага: в планировщике завершение задачи и доставка результата должны быть развязаны. Доставка — часть задачи, а не условие её закрытия.

Требование 4: смотреть на сайты глазами ИИ‑ботов

Это уже профессиональная деформация. Мне нужно видеть страницу не так, как её видит человек, и не так, как её видит краулер из 2015 года, а так, как её видит LLM‑бот: что он вообще получит, если придёт на этот URL.

Модуль на Playwright открывает страницу в Chromium и снимает три вещи: метаданные, семантический DOM и дерево доступности (accessibility tree) — то самое, по которому ассистивные технологии и ИИ‑агенты понимают структуру страницы. Опционально — реальный скриншот, который дальше уходит в Gemini как картинка вместе с текстовым контекстом.

Сверху это обёрнуто в MCP‑сервер, так что теми же двумя инструментами может пользоваться любой MCP‑клиент, не только Оракул:

mcp = FastMCP("ai-web-vision")
@mcp.tool()
async def inspect_website_for_ai(url: str, include_screenshot: bool = False, ...) -> dict:    """Opens a public website in Chromium and returns an AI-friendly snapshot:    metadata, semantic DOM, accessibility info, optional screenshot."""

Два момента, которые стоили отдельной отладки.

SSRF. Инструмент, который ходит по произвольному URL от лица сервера, — это дыра по умолчанию. Нормализация URL с проверкой на приватные диапазоны обязательна: allow_private_hosts=False в публичном MCP‑сервере не опция, а условие его существования.

Зомби‑процессы. Внутренние шаги Playwright ограничены таймаутами, а сам chromium.launch() — ничем. Если памяти не хватает из‑за зомби‑процессов от прошлых неудачных запусков, запуск браузера висит бесконечно, и вместе с ним висит запрос. Пришлось ставить жёсткий потолок на всю последовательность:

OVERALL_TIMEOUT_S = 45

Требование 5: читать сайты за WAF

Половина интересных сайтов закрыта Cloudflare и подобными системами. Обычный Python‑клиент они отсекают мгновенно — и не по User‑Agent, а по TLS/JA3-отпечатку: набор шифров и расширений в TLS‑рукопожатии у requests/httpx не похож на браузерный.

Лестница выглядит так:

  1. curl_cffi с имперсонацией отпечатка реального Chrome — закрывает большинство случаев и стоит десятки миллисекунд;

  2. если прилетает 403/503 или маркер JS‑челленджа («just a moment», “attention required”) — undetected-chromedriver, то есть настоящий браузер;

  3. если и он не справился — честно сказать пользователю, что сайт закрыт, а не подсунуть модели страницу с капчей вместо контента.

Третий пункт важнее первых двух. Худшее, что может сделать парсер, — вернуть текст страницы‑заглушки, по которому модель бодро напишет аудит несуществующего сайта.

Подробно этот механизм я разбирал в отдельной статье: как читать сайты за ClaudFlare.

Отдельная радость — зависимости этой части. В requirements.txt у меня теперь висит комментарий на пять строк о том, что устаревший setuptools ломает импорт undetected-chromedriver тихо, без явной ошибки: сервер молча откатывался на обычный Selenium и терял устойчивость к антиботу, пока я не полез читать логи старта процесса.

Требование 6: не упираться в таймауты

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

Решение простое: тяжёлые агенты выполняются в фоне. Флаг на карточке агента, и каждое обращение сразу ставится в очередь планировщика как ONCE‑задача:

run_in_background = models.BooleanField(
    "Всегда выполнять в фоне",
    help_text="Каждое обращение к агенту сразу ставится в очередь планировщика "
              "(ONCE-задача) вместо синхронного ответа в чате — для тяжёлых "
              "агентов, где ответ занимает дольше, чем уместно ждать в чате.",
)

Пользователь получает мгновенное подтверждение, а результат прилетает отдельным сообщением, когда будет готов: в Telegram — сообщением от бота, в веб‑панели — через поллинг статуса задачи. Тот же механизм, две доставки.

Фоновая задача
Фоновая задача

Требование 7: избавиться от гугл‑таблиц, не потеряв состояние

До Оракула состояние по клиенту жило в гугл‑таблице. Это работает ровно до тех пор, пока таблицу заполняет один человек.

Теперь результаты всех агентов по одному сайту складываются в одно место и достаются по ключу — URL сайта. С нормализацией, потому что example.com, https://www.example.com/ и https://example.com/?utm\_source=... — это один клиент, а не три:

def _normalize_cache_url(url: str) -> str:
    """Сводит URL к ключу кеша: без схемы/www/хвостового слэша и query."""
    

Побочный, но приятный эффект: результаты шагов роутера кешируются на 24 часа по паре (URL, роль). Второй запрос по тому же сайту в тот же день не запускает сбор данных заново.

Баг, который тут прятался. В кеше вместе с контекстом сохранялась ведущая строка «ИСХОДНАЯ ЗАДАЧА: …» из первого прогона. При попадании в кеш последний шаг видел формулировку старого запроса вместо текущего. Наружу это выглядело так: коммерческое предложение «для руководителя» никогда не готовилось для руководителя — уточнение молча терялось, копирайтер выбирал схему по умолчанию. Одна регулярка на удаление этой строки — и функция, которая существует только чтобы объяснить будущему себе, почему она существует.

А дальше из этого хранилища вырос неожиданный сценарий. Раз система помнит, что и кому рекомендовала, она может спросить о результате:

track_outcomes = models.BooleanField("Отслеживать результат", ...)
outcome_followup_delay_hours = models.PositiveIntegerField(
    "Через сколько часов спросить о результате", default=24)

Через заданное время бот сам пишет в Telegram: рекомендации по такому‑то сайту — сделали? Ответ копится в истории по паре (агент, URL). Так Оракул стал ещё и аккаунт‑менеджером, который пинает меня, а не наоборот.

Сводка текущего состояния по проекту
Сводка текущего состояния по проекту

Требование 8: мелочи, которые рождались из задач

Ни одну из этих функций я не планировал. Каждая появилась потому, что в конкретный вторник понадобилась.

  • Голос. Голосовое сообщение в Telegram транскрибируется и уходит агенту как обычный запрос; ответ при желании возвращается озвученным (TTS, десяток голосов на выбор). Удобно, когда за рулём. Иронично, учитывая предысторию.

  • Генерация изображений. Понадобилось нарисовать открытку с днём рождения для коллеги. Теперь это шаг конвейера, который умеет вставлять картинки в лендинги.

  • PDF‑отчёты. WeasyPrint + брендированные шаблоны: у отчёта клиента должен быть логотип клиента, а не мой.

  • Архивы. Когда агент генерирует пачку файлов, они приезжают одним zip.

  • Хранилище доступов. Отдельная шифрованная таблица: агент получает пароль от админки клиента ровно на том шаге, где он нужен, и только если этот шаг ему разрешён.

Все они — по сотне‑двести строк каждая. Их можно было не делать. Но именно из них складывается ощущение, что помощник — не демка, а рабочий инструмент.

Личный репетитор по английскому. Урок - голосом. Задает домашку и напоминает о ней.
Личный репетитор по английскому. Урок — голосом. Задает домашку и напоминает о ней.

Чего Оракул не умеет

Чтобы не было впечатления, что всё описанное — серебряная пуля.

  • Он не подбирает модель сам. Классификатор выбирает специалиста под задачу, но какая модель стоит за специалистом — решает владелец кабинета руками. Живого аукциона цен между провайдерами нет, и я не уверен, что он нужен.

  • Он не думает за пределами конвейера. Роутер — это заданная человеком последовательность шагов. Если задача не ложится на неё, надо садиться и собирать нового агента.

  • Он всё ещё зависит от качества промпта. Инструменты гарантируют, что цифры настоящие. Они не гарантируют, что выводы умные.

  • Антибот — это гонка. Всё, что написано выше про JA3 и Cloudflare, верно на сегодня и потребует правок через полгода.

Сколько это стоило

Первая строка — 9 января 2026, готовый продукт — к лету. 465 коммитов, ~20 000 строк Python, 52 миграции, 2 200 строк тестов, 3 800 строк шаблонов. Примерно по 4 часа в день, не бросая основную работу.

Честно говоря, сам по себе код здесь — не самая интересная цифра. Интереснее другое: сайт, контент, маркетинговая проработка, лендинг, документация на двух языках — всё это просто не по силам одному человеку, будь он хоть семи пядей во лбу. И тем не менее оно есть.

Потому что Оракул написал сам себя больше чем наполовину. Эту цифру я посчитал.

Методика: git blame по всем живым исходникам (.py, .html, .js, .css, без миграций и venv) — 30 007 строк. Дальше каждая строка относится к коммиту, а коммит — к одной из двух корзин: с трейлером Co-Authored-By от ИИ и без него.

всего живых строк:              30 007
из ИИ-коммитов:                 17 643   → 58,8%

По слоям: бэкенд на Django — 55,0% (10 789 из 19 616 строк), шаблоны панели — 55,9%, лендинг — 77,1%.

Это нижняя граница, и вот почему: трейлер появился в коммитах только 4 июля 2026, а проект начался 9 января. Из полугода до этой даты в текущем коде уцелело 7 699 строк, и они посчитаны как «человеческие», хотя писались ровно так же. Если считать честно, доля ближе к 80%.

Это и есть лучшая реклама Оракула: продукт, который сам себя собирает, и есть доказательство того, что командная работа ИИ‑агентов даёт другой порядок скорости, а не «на 20% быстрее».

Мои расходы на LLM при полной загрузке — около $60 в месяц. Ставка джуна, которого этот стек заменяет в моей рутине, — 60–80 тысяч рублей.

Он работает уже не только у меня, но и у моих первых клиентов. Низкий им поклон.

Зачем всё это

Сейчас я вижу задачу шире, чем в январе, когда просто не хотел делать сто одинаковых аудитов.

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

И главное — сместить парадигму восприятия ИИ. С чата, который советует, на сотрудника, который выдаёт готовый продукт.

  • Стек: #Python 3, #Django 6, #PostgreSQL, APScheduler, python‑telegram‑bot, Playwright, Selenium + undetected‑chromedriver, curl_cffi, WeasyPrint, MCP (FastMCP), gunicorn, systemd.

  • LLM‑провайдеры: #Google Gemini, #OpenAI, #Anthropic Claude, #OpenRouter.

  • Интеграции: Google Search Console, Google Ads, PageSpeed Insights, Яндекс.Метрика, YouTube Data API, Битрикс24, RapidAPI.

Если у вас есть похожая рутина, которую вы делаете руками сто раз подряд, — расскажите в комментариях, какая. Мне интересно, где ещё эта схема ложится.