Проблема: как оркестратор выбирает агента

Допустим, у вас есть три агента: DataAgent (анализ данных), CodeAgent (написание кода) и SearchAgent (поиск в интернете). Пользователь пишет: «Построй линейный график по этому CSV».

Как оркестратор решает, кому передать задачу? Три варианта:

  1. Хардкод в if-else: «если в запросе слово "график" — DataAgent». Работает до первого синонима («диаграмма», «chart», «визуализация»).
  2. LLM без контекста: спрашиваем у модели, какой агент подходит. Но откуда LLM знает, что умеет каждый из них, если не объяснить?
  3. Роутинг по skills: каждый агент декларирует свои умения — оркестратор матчит задачу против этих деклараций.

Третий вариант — единственный, который масштабируется. Когда агентов 30, а не 3, ручной хардкод становится кошмаром поддержки. Skill-декларации позволяют добавлять нового агента без правок роутера: просто зарегистрировал skills — он уже участвует в маршрутизации.

💡 Skill ≠ инструмент. Инструмент (tool) — это функция, которую агент может вызвать внутри себя. Skill — это декларация для внешних наблюдателей: что этот агент умеет делать как единица системы. Инструменты — детали реализации; skills — публичный контракт.

Анатомия skill

Skill — это структурированный документ с несколькими ключевыми полями. Самое важное из них — description и examples: именно по ним роутер матчит входящую задачу.

Поля объекта AgentSkill
id
req
Уникальный машиночитаемый идентификатор. Используется для логирования и явного указания агента. Пример: "analyze-data", "write-sql"
name
req
Человекочитаемое название для UI и логов. Пример: "Анализ данных"
description
req
Самое важное поле. Описывает что делает skill, в каких ситуациях применяется, какие типы данных принимает. Именно по нему LLM и embedding-роутер понимают, подходит ли этот skill для задачи.
examples
req
Список конкретных запросов, которые этот skill обрабатывает. Критично для embedding-роутинга: чем больше примеров, тем точнее матч. Пример: ["Построй график продаж", "Покажи динамику по неделям"]
tags
opt
Категории для быстрой фильтрации перед семантическим поиском. Пример: ["data", "visualization", "analytics"]
input_modes
opt
Типы входных данных: text, file/csv, file/json, data/dataframe, image. Позволяет сразу отсечь агентов, которые не умеют работать с нужным форматом.
output_modes
opt
Типы результатов: text, chart, data/json, file/pdf. Роутер может отфильтровать агентов по нужному формату ответа.
constraints
opt
Ограничения: максимальный размер файла, поддерживаемые языки, требования к данным. Агент сам проверяет их перед принятием задачи.

Полная skill-декларация аналитического агента выглядит так:

from dataclasses import dataclass, field
from typing import List, Optional

@dataclass
class AgentSkill:
    id: str
    name: str
    description: str
    examples: List[str]
    tags: List[str] = field(default_factory=list)
    input_modes: List[str] = field(default_factory=lambda: ["text"])
    output_modes: List[str] = field(default_factory=lambda: ["text"])
    constraints: dict = field(default_factory=dict)

# Skill аналитического агента
analyze_data_skill = AgentSkill(
    id="analyze-data",
    name="Анализ и визуализация данных",
    description=(
        "Агент анализирует структурированные данные: CSV, JSON, таблицы. "
        "Строит графики (линейные, столбчатые, тепловые карты), вычисляет "
        "агрегаты (сумма, среднее, медиана), выявляет тренды и аномалии. "
        "Работает с временными рядами, категориальными и числовыми переменными."
    ),
    examples=[
        "Построй график продаж за последние 6 месяцев",
        "Найди аномалии в этом датасете",
        "Какой месяц был самым прибыльным?",
        "Сравни выручку по регионам",
        "Покажи тренд посещаемости по неделям",
        "Вычисли среднее и медиану по колонке revenue",
    ],
    tags=["data", "analytics", "visualization", "statistics"],
    input_modes=["text", "file/csv", "file/json", "data/dataframe"],
    output_modes=["text", "chart", "data/json"],
    constraints={
        "max_file_size_mb": 50,
        "max_rows": 1_000_000,
    }
)

Skill vs Tool: два уровня абстракции

Это различие ломает больше всего мозгов при первом знакомстве. Оба понятия описывают «что умеет агент», но на разных уровнях:

                ВНЕШНИЙ МИР                 |        ВНУТРИ АГЕНТА
                (для роутинга)               |        (для выполнения)
                                             |
  Оркестратор видит:                         |   Агент использует:
  ┌──────────────────────────────────┐       |   ┌──────────────────────────────────┐
  │  Skill: "Анализ данных"          │       |   │  Tool: load_csv(path)            │
  │  desc: "строит графики, ищет..." │       |   │  Tool: compute_stats(df)         │
  │  examples: [...]                 │       |   │  Tool: plot_chart(df, type)      │
  │  input_modes: [csv, json]        │       |   │  Tool: detect_anomalies(df)      │
  └──────────────────────────────────┘       |   └──────────────────────────────────┘
           ↑ публичный контракт              |            ↑ детали реализации
           ↑ для выбора агента               |            ↑ для выполнения задачи

Skill говорит «я умею анализировать данные». Какими именно инструментами — pandas, numpy, matplotlib, или DuckDB — это детали реализации, скрытые за skill. Один skill может использовать десятки tools внутри.

⚠️ Не путайте инструменты агента со skills. Если у агента есть tool search_web(query), это не значит, что его skill — «поиск в интернете». Skill — это то, зачем пользователь к нему обращается, а не как агент это делает. DataAgent тоже может вызвать search_web для получения данных, но его skill — «анализ данных», а не «поиск».

Три стратегии роутинга по skills

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

Keyword routing
Быстрый
МетодTF-IDF / совпадения слов
Скорость< 1 мс
Точность~60–70%
Стоимость$0
КогдаПрототип, строгий бюджет
LLM routing
Умный
МетодПромпт в LLM с описаниями
Скорость0.5–2 с
Точность~90–95%
Стоимость~$0.001 / запрос
КогдаНебольшой объём, нужна точность
Embedding routing
Семантика
МетодCosine similarity по векторам
Скорость1–5 мс (after index)
Точность~85–92%
Стоимость~$0.0001 / запрос
КогдаВысокая нагрузка, >10 агентов

Keyword Router

Самый простой вариант: разбиваем задачу и описания skills на слова, считаем пересечение. Быстро и предсказуемо, но плохо справляется с синонимами («построй» vs «сделай», «chart» vs «график»).

import re
from collections import Counter

class KeywordRouter:
    def __init__(self, registry: "SkillRegistry"):
        self.registry = registry
        self._build_index()

    def _tokenize(self, text: str) -> set[str]:
        # Простая токенизация: строчные слова от 3 символов
        return {w for w in re.findall(r'\b\w+\b', text.lower()) if len(w) >= 3}

    def _build_index(self):
        """Индексируем все skills заранее."""
        self._skill_tokens: dict[str, set[str]] = {}
        for agent_name, skills in self.registry.skills.items():
            for skill in skills:
                tokens = self._tokenize(skill.description)
                for ex in skill.examples:
                    tokens |= self._tokenize(ex)
                tokens |= {t.lower() for t in skill.tags}
                self._skill_tokens[f"{agent_name}::{skill.id}"] = tokens

    def route(self, task: str) -> tuple[str, str, float]:
        """Возвращает (agent_name, skill_id, score)."""
        task_tokens = self._tokenize(task)
        best_key, best_score = None, 0.0

        for key, skill_tokens in self._skill_tokens.items():
            intersection = task_tokens & skill_tokens
            union = task_tokens | skill_tokens
            score = len(intersection) / len(union) if union else 0.0
            if score > best_score:
                best_score = score
                best_key = key

        if best_key:
            agent_name, skill_id = best_key.split("::")
            return agent_name, skill_id, best_score
        return None, None, 0.0

LLM Router

Передаём LLM список skills со всеми описаниями и просим выбрать. Модель понимает синонимы, контекст, неоднозначность. Главный минус — latency и стоимость. Важно: передавай только описания skills, не все поля — экономит токены.

import anthropic
import json

class LLMRouter:
    def __init__(self, registry: "SkillRegistry", model: str = "claude-haiku-4-5-20251001"):
        self.registry = registry
        self.client = anthropic.Anthropic()
        self.model = model  # Используем быструю/дешёвую модель для роутинга

    def _build_skills_summary(self) -> str:
        lines = []
        for agent_name, skills in self.registry.skills.items():
            for skill in skills:
                lines.append(
                    f'- agent="{agent_name}", skill_id="{skill.id}": {skill.description}'
                )
        return "\n".join(lines)

    def route(self, task: str) -> tuple[str, str, float]:
        skills_summary = self._build_skills_summary()

        prompt = f"""Выбери наиболее подходящего агента и skill для задачи.

Доступные агенты и skills:
{skills_summary}

Задача пользователя: {task}

Ответь строго в формате JSON:
{{"agent": "имя_агента", "skill_id": "id_skill", "confidence": 0.0-1.0, "reason": "краткое объяснение"}}

Если ни один skill не подходит, верни {{"agent": null, "skill_id": null, "confidence": 0.0}}"""

        response = self.client.messages.create(
            model=self.model,
            max_tokens=200,
            messages=[{"role": "user", "content": prompt}]
        )

        result = json.loads(response.content[0].text)
        return result["agent"], result["skill_id"], result["confidence"]

Embedding Router

Семантический роутинг: преобразуем все skill-описания и примеры в векторы заранее (один раз). При каждом входящем запросе создаём его вектор и находим ближайший skill по cosine similarity. Быстро и точно — на практике лучший выбор для продакшена с высокой нагрузкой.

import numpy as np
import anthropic

class EmbeddingRouter:
    def __init__(self, registry: "SkillRegistry"):
        self.registry = registry
        self.client = anthropic.Anthropic()
        self._index: list[dict] = []  # [{agent, skill_id, vector}]
        self._build_index()

    def _embed(self, texts: list[str]) -> np.ndarray:
        """Получаем эмбеддинги через Voyage AI (встроен в Anthropic SDK)."""
        response = self.client.embeddings.create(
            model="voyage-3",
            input=texts,
        )
        return np.array([e.embedding for e in response.data])

    def _build_index(self):
        """Строим векторный индекс один раз при запуске."""
        texts_to_embed = []
        metadata = []

        for agent_name, skills in self.registry.skills.items():
            for skill in skills:
                # Конкатенируем description + примеры в один текст для эмбеддинга
                skill_text = skill.description + " " + " ".join(skill.examples)
                texts_to_embed.append(skill_text)
                metadata.append({"agent": agent_name, "skill_id": skill.id})

                # Каждый пример также индексируем отдельно для точности
                for example in skill.examples:
                    texts_to_embed.append(example)
                    metadata.append({"agent": agent_name, "skill_id": skill.id})

        vectors = self._embed(texts_to_embed)
        self._index = [
            {"agent": m["agent"], "skill_id": m["skill_id"], "vector": v}
            for m, v in zip(metadata, vectors)
        ]

    def route(self, task: str) -> tuple[str, str, float]:
        task_vec = self._embed([task])[0]

        best_agent, best_skill_id, best_score = None, None, -1.0
        scores: dict[tuple, float] = {}

        for entry in self._index:
            # Cosine similarity
            score = float(np.dot(task_vec, entry["vector"]) /
                         (np.linalg.norm(task_vec) * np.linalg.norm(entry["vector"]) + 1e-9))
            key = (entry["agent"], entry["skill_id"])
            # Берём максимальный score среди всех текстов skill
            scores[key] = max(scores.get(key, -1.0), score)

        if scores:
            best_key = max(scores, key=scores.__getitem__)
            return best_key[0], best_key[1], scores[best_key]

        return None, None, 0.0
💡 Пороговое значение (threshold). Всегда устанавливай минимальный score, ниже которого задача считается не подходящей ни одному агенту. Для keyword: 0.15+, для embedding: 0.65+, для LLM: confidence 0.5+. Лучше вернуть «не знаю, кому это отправить» чем отправить не тому агенту.

SkillRegistry: центральный каталог

Все агенты регистрируют свои skills в едином реестре. Это даёт оркестратору единую точку входа и позволяет динамически добавлять/удалять агентов.

from typing import Optional
import json

class SkillRegistry:
    def __init__(self):
        self.skills: dict[str, list[AgentSkill]] = {}  # agent_name → [skills]
        self._agents: dict[str, any] = {}  # agent_name → agent instance

    def register(self, agent_name: str, agent_instance, skills: list[AgentSkill]):
        """Регистрирует агента вместе с его skills."""
        self.skills[agent_name] = skills
        self._agents[agent_name] = agent_instance

    def unregister(self, agent_name: str):
        self.skills.pop(agent_name, None)
        self._agents.pop(agent_name, None)

    def get_agent(self, agent_name: str):
        return self._agents.get(agent_name)

    def find_by_tag(self, tag: str) -> list[tuple[str, AgentSkill]]:
        """Быстрая фильтрация по тегу перед семантическим поиском."""
        result = []
        for agent_name, skills in self.skills.items():
            for skill in skills:
                if tag in skill.tags:
                    result.append((agent_name, skill))
        return result

    def to_dict(self) -> dict:
        """Сериализация для кэширования и отладки."""
        return {
            agent: [
                {
                    "id": s.id, "name": s.name,
                    "description": s.description,
                    "examples": s.examples,
                    "tags": s.tags,
                }
                for s in skills
            ]
            for agent, skills in self.skills.items()
        }


# Инициализация системы
registry = SkillRegistry()

# Регистрируем агентов (каждый агент знает свои skills)
registry.register("DataAgent", data_agent_instance, [
    AgentSkill(
        id="analyze-data",
        name="Анализ и визуализация данных",
        description="Анализирует CSV/JSON данные, строит графики, вычисляет статистику.",
        examples=["Построй график продаж", "Найди аномалии в данных", "Какой месяц лучший?"],
        tags=["data", "analytics"],
    ),
    AgentSkill(
        id="forecast",
        name="Прогнозирование временных рядов",
        description="Строит прогнозы по историческим данным с помощью статистических моделей.",
        examples=["Спрогнозируй продажи на следующий квартал", "Какой будет трафик в пятницу?"],
        tags=["data", "forecasting"],
    ),
])

registry.register("CodeAgent", code_agent_instance, [
    AgentSkill(
        id="write-code",
        name="Написание кода",
        description="Пишет, рефакторит и отлаживает код на Python, JavaScript, SQL.",
        examples=["Напиши функцию для парсинга JSON", "Отрефакторь этот класс"],
        tags=["code", "development"],
    ),
])

# Инициализируем роутер
router = EmbeddingRouter(registry)

# Роутим задачу
agent_name, skill_id, score = router.route("Покажи динамику выручки за 2024 год")
if score >= 0.65:
    agent = registry.get_agent(agent_name)
    result = await agent.execute(task, skill_id=skill_id)
else:
    result = "Не могу найти подходящего агента для этой задачи"

Роутинг в действии: поток выполнения

Посмотрим как работает embedding-роутинг на конкретном примере. Запрос «Построй линейный график по этому CSV» — как система находит агента?

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ВХОДЯЩИЙ ЗАПРОС «Построй линейный график по этому CSV» embed(task) ОРКЕСТРАТОР · EmbeddingRouter skill_registry.find_best_match(task_vector) 0.92 0.31 0.19 ✓ DataAgent — ВЫБРАН Skills: analyze-data Анализ и визуализация данных forecast Прогнозирование временных рядов score: 0.92 ≥ threshold 0.65 CodeAgent Skills: write-code Написание и рефакторинг кода debug Отладка и анализ ошибок score: 0.31 < threshold 0.65 SearchAgent Skills: web-search Поиск в интернете summarize-docs Реферирование документов score: 0.19 < threshold 0.65 ✓ Маршрут: DataAgent → analyze-data

Cosine similarity scores для запроса «Построй линейный график по этому CSV»:

DataAgent::analyze-data
0.92
CodeAgent::write-code
0.31
SearchAgent::web-search
0.19
DataAgent::forecast
0.41

Гранулярность: не слишком мелко, не слишком крупно

Один из самых частых вопросов: «Сколько skills нужно у одного агента?» Слишком мало — роутер не понимает разницы между возможностями. Слишком много — explosion of skills, роутинг работает медленнее, а skill-описания начинают перекрываться.

❌ Слишком мелко
plot-bar-chart
plot-line-chart
plot-pie-chart
plot-heatmap
plot-scatter
compute-sum
compute-mean
compute-median
8 skills вместо 1. Роутер должен выбрать «bar» vs «line» — но пользователь часто не знает, что хочет. Описания пересекаются, роутинг деградирует.
⚠️ Слишком крупно
data-everything
1 skill «Всё, что связано с данными». Когда к DataAgent приходит задача на прогнозирование и задача на очистку данных — роутер не различает, нужен ли DataAgent или DataCleaningAgent.
✓ Правильно
analyze-visualize
forecast
data-cleaning
3 skill на уровне задачи. Каждый skill соответствует одному типу работы пользователя. Не деталям реализации (тип графика), а намерению («хочу понять данные»).

Эмпирическое правило: skill должен соответствовать тому, что пользователь напишет в одном предложении. Если для различия двух skills нужны технические детали — объедините их в один. Типичный агент: 2–5 skills. Больше 8 — сигнал для рефакторинга или разделения агента.

Как писать description, чтобы роутинг работал

Description — это то, что читает роутер. Пишите его не для человека, а для матчинга. Несколько правил:

Используйте синонимы и парафразы явно. LLM и embedding-модели понимают семантику, но конкретные слова из описания повышают точность матча. Если задача может быть сформулирована несколькими способами — включите их в description или examples.

# Плохо — одна формулировка
description = "Строит графики по данным"

# Хорошо — несколько формулировок + контекст
description = (
    "Визуализирует и анализирует данные: строит графики (charts, диаграммы, "
    "plots), вычисляет статистику (агрегаты, распределения), выявляет тренды. "
    "Работает с табличными данными — CSV, Excel, SQL-результатами, pandas DataFrame. "
    "Подходит когда нужно 'понять данные', 'посмотреть динамику', "
    "'сравнить показатели' или 'найти аномалии'."
)
⚠️

Пишите чего skill НЕ делает. Это критично для пограничных случаев. Если DataAgent не умеет собирать данные из интернета — скажите об этом явно, чтобы роутер не направлял такие запросы к нему.

description = (
    "Анализирует предоставленные данные (CSV, JSON, DataFrame). "
    "НЕ собирает данные из интернета — для этого используй SearchAgent. "
    "НЕ пишет код — для этого используй CodeAgent."
)

Гибридный роутинг: фильтрация + семантика

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

class HybridRouter:
    """
    Двухэтапный роутинг:
    1. Быстрая фильтрация по тегам (keyword-based)
    2. Семантический поиск среди прошедших фильтр
    """
    def __init__(self, registry: SkillRegistry, threshold: float = 0.65):
        self.registry = registry
        self.threshold = threshold
        self.embedding_router = EmbeddingRouter(registry)

    def _detect_tags(self, task: str) -> list[str]:
        """Быстрое определение домена по ключевым словам."""
        task_lower = task.lower()
        tags = []
        tag_keywords = {
            "data": ["график", "данные", "csv", "таблица", "статистика",
                     "chart", "plot", "анализ", "тренд"],
            "code": ["код", "функция", "класс", "баг", "ошибка",
                     "python", "javascript", "sql", "рефакторинг"],
            "search": ["найди", "поищи", "загрузи", "скачай",
                       "интернет", "сайт", "статья", "новости"],
        }
        for tag, keywords in tag_keywords.items():
            if any(kw in task_lower for kw in keywords):
                tags.append(tag)
        return tags

    async def route(self, task: str) -> tuple[str, str, float]:
        # Шаг 1: определяем теги
        detected_tags = self._detect_tags(task)

        # Шаг 2: если тег найден — ограничиваем пространство поиска
        if detected_tags:
            candidate_agents = set()
            for tag in detected_tags:
                for agent_name, skill in self.registry.find_by_tag(tag):
                    candidate_agents.add(agent_name)
        else:
            candidate_agents = set(self.registry.skills.keys())

        # Шаг 3: embedding-роутинг только среди кандидатов
        # (в реальной системе — отдельный индекс per tag group)
        agent_name, skill_id, score = self.embedding_router.route(task)

        if agent_name in candidate_agents and score >= self.threshold:
            return agent_name, skill_id, score

        # Fallback: LLM routing если embedding не уверен
        if score < self.threshold:
            llm_router = LLMRouter(self.registry)
            return llm_router.route(task)

        return None, None, 0.0

Типичные ошибки при описании skills

Description = название skill
id="analyze-data", description="Анализирует данные" — тавтология. Роутер не получает никакой дополнительной информации. По такому описанию embedding-матч будет работать случайным образом.
→ Описывайте как, что именно, в каких ситуациях. Минимум 2–3 предложения.
Нет примеров (examples)
Examples — это отдельные векторы в embedding-индексе. Без них вектор skill строится только по description, и пользовательские запросы «вблизи примеров» не попадают в нужную область пространства. На практике accuracy падает на 10–15% при отсутствии примеров.
→ Минимум 5–6 примеров на skill. Разнообразных — коротких и длинных, официальных и разговорных.
Дублирующиеся skills у разных агентов
DataAgent: "write-sql" и CodeAgent: "write-sql" — оба умеют писать SQL-запросы. Роутер выбирает случайным образом. Система становится непредсказуемой, а пользователи получают разные результаты на одинаковые запросы.
→ Один skill — один хозяин. Если оба агента умеют что-то, выберите «главного» или разграничьте по контексту (DataAgent для аналитики, CodeAgent для разработки).
Один огромный skill «делаю всё»
description="Я могу анализировать данные, писать код, искать в интернете, делать отчёты...". Вектор такого skill попадает в центр семантического пространства и конкурирует со всеми другими skills — и проигрывает специализированным.
→ Разбейте на 2–5 focused skills. Специализация повышает точность роутинга.
Отсутствие threshold — роутинг без fallback
Роутер всегда выбирает агента с максимальным score, даже если score = 0.12. В результате нерелевантные задачи отправляются случайному агенту, который возвращает мусор или вообще падает с ошибкой.
→ Всегда устанавливайте minimum threshold (0.65+ для embedding). Реализуйте явный fallback: «задача не может быть обработана».

Шпаргалка

📋

Skills:

  • Skill = публичный контракт агента для роутинга; Tool = внутренняя реализация
  • Обязательные поля: id, name, description, examples
  • Description пишите для роутера: синонимы, контекст, чего НЕ делает
  • Examples: минимум 5–6, разнообразных, на разных уровнях формальности
  • Гранулярность: 2–5 skills на агента, на уровне задачи пользователя

Роутинг:

  • Keyword: быстрый, простой, ~65% точность — для прототипов
  • LLM: умный, дорогой, ~93% точность — для небольших объёмов
  • Embedding: быстрый + точный, ~88% точность — для продакшена
  • Hybrid: tag filter → embedding — лучший баланс скорости и точности
  • Threshold: embedding ≥ 0.65, LLM confidence ≥ 0.5

SkillRegistry:

  • Единая точка регистрации всех агентов
  • Поддерживает динамическое добавление/удаление агентов
  • Embedding-индекс строится один раз при инициализации

Практика

Три задачи для закрепления:

  1. Спроектируй skills для HR-агента. Агент должен уметь: составлять описания вакансий, проверять резюме, отвечать на вопросы по трудовому законодательству, составлять офферы. Определи нужное количество skills, напиши для каждого description и минимум 4 examples. Затем протестируй с keyword-роутером — правильно ли он матчит запросы к разным skills?
  2. Сравни точность роутеров. Создай SkillRegistry с 3 агентами (по 2–3 skill каждый). Составь набор из 20 тестовых запросов с известными правильными ответами. Запусти KeywordRouter и EmbeddingRouter. Сравни F1-score, latency, и найди случаи где они расходятся — что победило и почему?
  3. Реализуй динамический реестр. Добавь в SkillRegistry поддержку персистентности: сохранение/загрузка в JSON-файл. Реализуй горячую замену агента (unregister + register) без перестройки всего embedding-индекса — только обновление векторов изменённого агента. Подсказка: храни индекс как dict, обновляй только ключи изменённого агента.