Зимой я разбил две машины с интервалом в пару недель. Никто не пострадал, кроме моего кошелька — и в результате этого курьёзного случая мне очень понадобились деньги.
В январе 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. Раньше я тратил на это час и всё равно пропускал половину.
Баг, который стоил мне денег. Задачи типа 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 не похож на браузерный.
Лестница выглядит так:
-
curl_cffiс имперсонацией отпечатка реального Chrome — закрывает большинство случаев и стоит десятки миллисекунд; -
если прилетает 403/503 или маркер JS-челленджа («just a moment», «attention required») —
undetected-chromedriver, то есть настоящий браузер; -
если и он не справился — честно сказать пользователю, что сайт закрыт, а не подсунуть модели страницу с капчей вместо контента.
Третий пункт важнее первых двух. Худшее, что может сделать парсер, — вернуть текст страницы-заглушки, по которому модель бодро напишет аудит несуществующего сайта.
Подробно этот механизм я разбирал в отдельной статье: как читать сайты за 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.
-
Сайт: orakul.digital
-
Демо-стенд: demo.orakul.digital — интерфейс Оракула без реальных обращений к LLM
Если у вас есть похожая рутина, которую вы делаете руками сто раз подряд, — расскажите в комментариях, какая. Мне интересно, где ещё эта схема ложится.
ссылка на оригинал статьи https://habr.com/ru/articles/1079820/