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 и подставляется в промпт как стабильный префикс.
Три правила, без которых это не работает:
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. Подводные камни и надёжность в эксплуатации
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 вопросов и передаёт менеджеру заинтересованных клиентов вместе с перепиской — с лимитами на аккаунт, часами отправки и паузами, которые задаёте вы.
Нанимать академика для сортировки конвертов на почте разорительно: под ежедневный конвейер бери компактный собственный инструмент, заточенный ровно под одну операцию.