Alexander Bond • 02.11.2026 • 14 мин чтения мультиагентные системыAI-оркестрация в B2Bдетерминированная маршрутизация

Рой агентов за $410 за ночь: роутер, арбитр и unit-экономика AI в B2B

Как детерминированная маршрутизация и Supervisor с лимитом в 3 попытки превращают вечный спор агентов в управляемый пайплайн за 1,7 цента на диалог.

1. Проблема и исторический нарратив: $410 за ночь вежливых споров

Когда мы в First Touch собирали пайплайн первого касания — ИИ-бот пишет по вашей базе с подключённых Telegram-аккаунтов, отвечает на вопросы о продукте, задаёт 3–5 уточняющих вопросов и передаёт заинтересованных менеджеру вместе с перепиской — первая версия была монолитной. Один системный промпт на 2 400 токенов: описание продукта, тон, правила квалификации, запрет на юридические обещания и инструкция «если клиент сомневается — предложи демо». Работало это ровно до первого живого диалога с человеком, который задал вопрос не из списка.

Но самый наглядный пример того, что происходит с мультиагентной системой без арбитра, я получил не на продакшене. Он случился в субботу вечером, когда разработчик перед уходом из офиса оставил работать пару автономных агентов: Кодера и Ревьюера.

Хронология по часам.

Суббота, 19:40. Задача: починить падающий тест. Кодеру — доступ к репозиторию и инструкция «сделай, чтобы тест проходил». Ревьюеру — доступ на чтение и инструкция «не пропускай устаревшие импорты». Цикл: Кодер правит → Ревьюер проверяет → при отказе возвращает замечание. Условие выхода — «пока Ревьюер не согласится». Разработчик ушёл домой.

Ночь, 03:00. Напарники упираются в конфликт версий библиотеки. Ревьюер требует убрать устаревший импорт. Кодер послушно убирает. Тест падает — этот импорт подтягивал транзитивную зависимость. Кодер возвращает строчку назад и вежливо просит проверить снова. Ревьюер снова видит устаревший импорт и снова требует убрать. Оба формально правы, оба идеально выполняют свою инструкцию. Цикл замкнулся.

Понедельник, 09:15. Утренняя сводка: 1 900 сообщений, в каждом — полный код проекта, потому что ни один агент не передавал дельту. С карты списано 410 долларов. Тест по-прежнему падает. Продвижения — ноль.

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

Переломной точкой стала надстройка Supervisor: жёсткий лимит в 3 попытки на задачу, сверка хэша изменений между итерациями и мгновенная остановка цикла при повторе ошибки. Тот же пайплайн, который за ночь не сдвинулся ни на шаг, после этого закрыл задачу за 14 минут и 0,09 доллара. Изменился не интеллект исполнителей. Изменилось наличие того, кто имеет право остановить качели на третьем круге.

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

2. Архитектура решения и технический стек

Принцип, который снимает 80% проблем: интеллект — в воркерах, решения — в роутере, право остановки — в арбитре.

Слои пайплайна:

1. Ingress и идемпотентность. Каждое входящее сообщение оборачивается в TaskEnvelope с dialog_id, account_id, step и ключом идемпотентности. Повторный вебхук не создаёт вторую задачу.

2. Router Agent. Детерминированная таблица правил + классификатор интентов. Классификатор вызывается только если правило не сработало, при temperature=0, фиксированном seed и версионированном промпте (router@v3.2). Решение логируется как reason_code. Вместо этого как свободный текст.

3. Worker pool. Узкие агенты: faq_agent, qualify_agent, handoff_agent, refuse_agent. У каждого системный промпт не длиннее 400 токенов и своя JSON-схема ответа через constrained decoding.

4. Supervisor. Проверяет три вещи: число попыток (жёсткий максимум 3), хэш дельты между итерациями и бюджет задачи в долларах. Любой повтор → escalate. Вместо этого retry.

5. Cost governor. Единый счётчик расходов на диалог, аккаунт и тенант. При превышении — не «предупреждение в лог», а отказ в вызове.

6. Telemetry. OpenTelemetry-трейс на каждый вызов: route, agent, attempt, tokens_in, tokens_cached, cost_usd, latency_ms, outcome.

Контракт между сервисами — обычные Pydantic-модели в JSON-RPC:

{
  "task_id": "8f3c1a...",
  "dialog_id": "tg:8123456",
  "step": 2,
  "route": "qualify",
  "agent": "qualify_agent",
  "confidence": 0.93,
  "reason_code": "RULE_INTEGRATION_QUESTION",
  "schema_version": "v3",
  "budget_usd": 0.02,
  "deadline_ms": 8000
}

Кэширование префиксов — не оптимизация «на потом», а часть контракта. Порядок контекста фиксирован: [статический системный промпт] → [машиночитаемое описание продукта] → [последние 2–3 реплики диалога]. Меняется только хвост, поэтому префикс попадает в radix-кэш vLLM (prefix caching) и переиспользуется между вызовами. Без этого порядка любой новый вопрос инвалидирует кэш, и вы заново платите за 2 000 токенов, которые вообще не менялись.

3. Машиночитаемый стандарт базы знаний

RAG «на глазок» — главный источник дрейфа поведения. Мы держим описание продукта в открытом файле по спецификации llms.txt: плоский текст с заголовками, который одинаково читается человеком и агентом, версионируется в git и подставляется в промпт как стабильный префикс.

Три правила, без которых это не работает:

  • **Один источник.** llms.txt — не «ещё одна вики», а единственный канон фактов о продукте: что делает, чего не делает, какие лимиты, чего не обещаем.
  • **Хэш в промпте.** В системный промпт подставляется `llms_hash`. Смена факта меняет хэш, а значит инвалидирует кэш префикса осознанно. Вместо этого случайно, и видна в трейсе.
  • **Запрет на вольные факты.** Если ответа нет в llms.txt, воркер обязан вернуть `route=handoff`. Не «примерно», не «обычно у нас». Это единственный способ не продать клиенту функцию, которой нет.
  • 4. Реализация: рабочий класс оркестратора

    from __future__ import annotations
    
    import asyncio
    import hashlib
    import time
    import uuid
    from enum import Enum
    from typing import Literal, Protocol
    
    from pydantic import BaseModel, ConfigDict, Field
    
    
    class Route(str, Enum):
        FAQ = "faq"
        QUALIFY = "qualify"
        HANDOFF = "handoff"
        REFUSE = "refuse"
    
    
    class BudgetExceeded(RuntimeError):
        pass
    
    
    class TaskEnvelope(BaseModel):
        model_config = ConfigDict(frozen=True)
    
        task_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
        dialog_id: str
        account_id: str
        step: int = 0
        intent_hint: str | None = None
        budget_usd: float = 0.02
        deadline_ms: int = 8_000
        attempts: int = Field(default=0, ge=0)
    
    
    class RouteDecision(BaseModel):
        route: Route
        agent: str
        confidence: float = Field(ge=0, le=1)
        reason_code: str
        schema_version: Literal["v3"] = "v3"
    
    
    class Verdict(BaseModel):
        status: Literal["accept", "retry", "escalate"]
        diff_hash: str
        reason: str
        cost_usd: float
    
    
    class AgentCallable(Protocol):
        name: str
    
        async def __call__(self, task: TaskEnvelope, context: str) -> tuple[str, float]:
            ...
    
    
    class MultiAgentOrchestrator:
        """Детерминированный роутер + Supervisor с жёстким лимитом попыток."""
    
        MAX_ATTEMPTS = 3
        RULES: dict[str, tuple[Route, str]] = {
            "price": (Route.FAQ, "faq_agent"),
            "how_it_works": (Route.FAQ, "faq_agent"),
            "integrate": (Route.QUALIFY, "qualify_agent"),
            "refund": (Route.HANDOFF, "handoff_agent"),
            "complaint": (Route.HANDOFF, "handoff_agent"),
        }
    
        def __init__(self, agents: list[AgentCallable], budget_usd: float = 0.50) -> None:
            self._agents = {a.name: a for a in agents}
            self._spent = 0.0
            self._budget = budget_usd
            self._seen: dict[str, set[str]] = {}
            self._lock = asyncio.Lock()
    
        def route(self, task: TaskEnvelope) -> RouteDecision:
            key = (task.intent_hint or "").strip().lower()
            if key in self.RULES:
                route, agent = self.RULES[key]
                return RouteDecision(
                    route=route, agent=agent, confidence=1.0,
                    reason_code=f"RULE_{key.upper()}",
                )
            # fallback: классификатор, temperature=0, seed фиксирован, версия промпта в трейсе
            return RouteDecision(
                route=Route.FAQ, agent="faq_agent", confidence=0.5,
                reason_code="FALLBACK_UNKNOWN_INTENT",
            )
    
        def _gate(self, task: TaskEnvelope, verdict: Verdict) -> Verdict:
            seen = self._seen.setdefault(task.dialog_id, set())
            if verdict.diff_hash in seen:
                return verdict.model_copy(
                    update={"status": "escalate", "reason": "loop_detected"}
                )
            seen.add(verdict.diff_hash)
            if task.attempts + 1 >= self.MAX_ATTEMPTS:
                return verdict.model_copy(
                    update={"status": "escalate", "reason": "attempts_exhausted"}
                )
            return verdict
    
        async def run(self, task: TaskEnvelope, context: str) -> dict:
            async with self._lock:
                if self._spent >= self._budget:
                    raise BudgetExceeded(f"budget {self._budget} USD exhausted")
    
            decision = self.route(task)
            agent = self._agents[decision.agent]
            started = time.monotonic()
    
            for attempt in range(self.MAX_ATTEMPTS):
                left = task.deadline_ms / 1000 - (time.monotonic() - started)
                if left <= 0:
                    return {"status": "timeout", "route": decision.route.value}
    
                answer, cost = await asyncio.wait_for(
                    agent(task, context), timeout=left
                )
                self._spent += cost
    
                digest = hashlib.sha256(
                    f"{decision.agent}|{answer[:512]}|{cost:.6f}".encode()
                ).hexdigest()[:16]
                verdict = self._gate(
                    task, Verdict(status="accept", diff_hash=digest,
                                  reason="ok", cost_usd=cost)
                )
                if verdict.status == "accept":
                    return {
                        "status": "done",
                        "route": decision.route.value,
                        "agent": decision.agent,
                        "answer": answer,
                        "cost_usd": round(cost, 6),
                        "spent_usd": round(self._spent, 6),
                    }
    
                task = task.model_copy(update={"attempts": attempt + 1})
    
            return {"status": "escalate", "route": decision.route.value,
                    "reason": "attempts_exhausted", "spent_usd": round(self._spent, 6)}

    Ключевые решения: TaskEnvelope неизменяем (frozen=True), поэтому попытки считаются через model_copy. Вместо этого через мутацию. _gate не спрашивает у модели «ты уверен?» — он сравнивает хэши. BudgetExceeded — исключение. Вместо этого мягкий флаг, потому что мягкий флаг обязательно кто-нибудь проигнорирует.

    5. Практический кейс [First Touch](https://aifirsttouch.ru): метрики пайплайна

    Внутренние замеры нашего пайплайна до и после разделения монолита на роутер и воркеров.

    До. Один промпт 2 400 токенов. Средний диалог — 6,2 реплики. Итого ~41 000 prompt-токенов на диалог, стоимость 4,1 цента, p50 латентности 4,1 секунды. Кэш префиксов фактически не работал: история диалога вставлялась в середину промпта, и каждый новый вопрос инвалидировал всё, что после него.

    После. Роутер — 380 токенов и таблица правил. Воркер — около 900 токенов, из которых 640 попадают в кэш. Hit rate префикса 74%, стоимость диалога 1,7 цента (−58%), p50 латентности 1,6 секунды. Supervisor остановил 6% задач на третьей попытке — раньше это были диалоги, которые тянулись до ручного вмешательства менеджера.

    Отдельный слой — детерминированные ограничители канала, которые живут вне LLM и не обсуждаются с моделью: лимит новых диалогов на аккаунт в день (у нового аккаунта в первые дни — до 3 в сутки), заданные пользователем часы отправки и паузы, инвайтинг — не больше 3 успешных приглашений на аккаунт за 24 часа, отметки в Stories — 3–5 человек на одну Stories и от 3 до 250 отметок в сутки. Это тот же принцип, что и MAX_ATTEMPTS = 3: ограничение живёт в коде. Вместо этого в надежде на благоразумие агента.

    Доля эскалаций к менеджеру — 12%. Это не потери пайплайна, а его продукт: ИИ-бот доводит диалог до состояния, когда человеку остаётся закрыть сделку, и передаёт переписку с контекстом. Пробный период — 10 дней или 250 сообщений через подключённые аккаунты, что закончится раньше.

    6. Подводные камни и надёжность в эксплуатации

  • **Retry-шторм под видом вежливости.** «Проверь ещё раз» — самый дорогой текст в системе. Лечится только внешним лимитом попыток и хэшем дельты.
  • **PII в префиксе.** Имя клиента в системной части промпта убивает hit rate кэша до нуля и заодно размазывает персональные данные по логам. Персональное — только в хвосте, только в пределах сессии.
  • **Дрейф версии промпта.** Роутер и воркер должны ссылаться на одну версию llms.txt. Разошедшиеся хэши дают классику: роутер отправляет в FAQ, а FAQ отвечает фактами из прошлого релиза.
  • **Роутер как единая точка отказа.** Нет правила → fallback → и вот уже 100% трафика идёт в `faq_agent` и тот медленно сходит с ума. Алерт на долю `FALLBACK_UNKNOWN_INTENT` обязателен.
  • **Галлюцинации в handoff-резюме.** Резюме проверяется схемой и обязательными цитатами из диалога. Резюме без цитат менеджеру бесполезно.
  • **Бюджет без разбивки по тенантам.** Общий лимит на всех означает, что один шумный аккаунт съедает квоту остальных.
  • **Недетерминизм в тестах.** Golden set из 200 размеченных диалогов, прогон трижды, сравнение `reason_code`. Вместо этого свободного текста ответа.
  • 7. Дорожная карта внедрения за 4 спринта

    Спринт 1 — «Видимость». OpenTelemetry на все вызовы, счётчик стоимости на диалог, инвентаризация промптов. Критерий выхода: стоимость диалога известна с точностью до цента, есть дашборд p50/p95.

    Спринт 2 — «Роутер». Таблица правил, классификатор-fallback с temperature=0, JSON-схемы ответов, golden set на 200 диалогов. Критерий выхода: доля неразмеченных интентов ниже 3%.

    Спринт 3 — «Арбитр и деньги». Supervisor с лимитом 3 попытки, детект повтора по хэшу, cost governor с per-tenant лимитом, prefix caching, llms.txt как единственный канон фактов. Критерий выхода: ни один диалог не превышает бюджет, hit rate кэша выше 60%.

    Спринт 4 — «Надёжность». Chaos-тесты: отключение воркера, таймауты, дубли вебхуков, деградация провайдера модели. Канареечный релиз 5% → 50% → 100%. Критерий выхода: p95 ниже 4 секунд, ноль задач без терминального статуса за сутки.

    8. ГЛАВНЫЙ ВЫВОД ИЗ ИСТОРИИ И CTA

    Главный жизненный и бизнес-вывод из этой истории

    Два усердных исполнителя с противоречивыми инструкциями способны спорить вечно за твой счёт. Над любой рабочей парой нужен старший с правом остановить качели на третьем круге.

    История с Кодером и Ревьюером доказывает вещь, которую в бизнесе упорно игнорируют: проблема была не в качестве исполнителей. Оба агента работали безупречно — именно поэтому счёт вырос до 410 долларов. Стоимость создаёт не ошибка, а согласованность усилий при отсутствии арбитра. То же самое происходит в компаниях: продажи обещают интеграцию, которой нет в роадмапе; поддержка компенсирует; продукт узнаёт об этом через месяц. Каждый отдел выполняет свой KPI идеально. Спор идёт за счёт клиента и за счёт бюджета.

    Применить это можно уже сегодня, тремя конкретными действиями.

    Первое: назначьте владельца остановки. Не «комитет», не «созвон по пятницам», а конкретную роль с правом прервать процесс на третьей итерации — в агентной системе это Supervisor, в компании это руководитель, который закрывает конфликт требования. Вместо этого тот, кто «согласовывает».

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

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

    Арбитр не делает исполнителей умнее. Он делает систему управляемой — и превращает ночь за 410 долларов в 14 минут за 9 центов.

    Если вы строите первое касание с клиентом в Telegram и не хотите, чтобы ИИ-переписка превращалась в вежливый спор с вашим же бюджетом, посмотрите, как это устроено в First Touch: ИИ-бот пишет по вашей базе с подключённых аккаунтов, отвечает на вопросы о продукте по вашей инструкции, задаёт 3–5 вопросов и передаёт менеджеру заинтересованных клиентов вместе с перепиской — с лимитами на аккаунт, часами отправки и паузами, которые задаёте вы.

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

    Автоматизируйте привлечение B2B-клиентов

    Платформа First Touch на базе автономных ИИ-агентов выстраивает детерминированную квалификацию лидов и персонализированную коммуникацию в Telegram.

    Попробовать First Touch