Tool RAG: що робити коли у агента забагато інструментів

Actualizado:
Tool RAG: що робити коли у агента забагато інструментів

У вас 5 tools — все чудово. У вас 15 tools — починаються проблеми. У вас 50 tools — агент деградує. Але є рішення яке вирішує проблему масштабу елегантно — і ви вже знаєте як воно працює, бо використовуєте його для документів.

Ця стаття — частина серії про AI агентів на Spring Boot. Якщо ви ще не читали про механіку вибору tool — почніть з Як LLM вирішує коли викликати tool. Про що робити після того як tool відповів — Grounding і довіра до джерел.

Зміст

Коли Tool RAG потрібен — decision tree

Перш ніж читати далі — визначте чи потрібна вам ця стаття взагалі. 90% проектів не потребують Tool RAG. Але знати про нього варто всім бо проблема масштабу приходить непомітно.

Скільки tools у вашого агента?
          ↓
       до 10
          ↓
Хороший description + системний промпт
→ Достатньо. Tool RAG не потрібен.
→ Читайте про написання descriptions.

      від 10 до 20
          ↓
Чи tools тематично різні?
├── ТАК → Tool categories і routing (секція 7)
│         Простіше ніж Tool RAG, вирішує проблему
└── НІ → Покращіть descriptions, розмежуйте відповідальності

      від 20 до 50
          ↓
Чи бачите деградацію вибору в логах?
├── ТАК → Tool RAG (ця стаття)
└── НІ → Categories + routing, моніторинг (секція 9)

      50+
          ↓
Tool RAG обов'язковий.
Без нього агент деградує до ~13% точності вибору.
Важливо: Tool RAG — це не срібна куля і не must-have для кожного проекту. Це інструмент для конкретної проблеми. Якщо у вас менше 20 tools — зупиніться на секції 7 (categories і routing) і поверніться до цієї статті коли реєстр виросте.

Проблема масштабу: цифри які вас здивують

Ви додаєте tools поступово. Спочатку 5, потім 10, потім 20. Кожен новий tool вирішує реальну задачу. Але в певний момент якість роботи агента починає падати — і ви не розумієте чому. Помилок в коді немає. Descriptions написані правильно. Але агент все частіше вибирає не той tool або не викликає жодного.

Це не проблема вашого коду. Це системна проблема яку дослідники назвали "choice paralysis" — і вона підтверджена кількома незалежними дослідженнями 2025-2026 років.

Що показують дослідження 2025-2026

RAG-MCP (Anthropic, травень 2025) — дослідження на реальних MCP серверах показало катастрофічну нелінійну деградацію:

Кількість tools Токенів на descriptions Точність вибору (baseline) Точність з Tool RAG
~10 tools ~2K 78% ~90%
~50 tools 8K 84-95% ~95%
~200 tools 32K 41-83% ~85%
100+ tools ~20K 13.62% 43.13%
~740 tools 120K 0-20% ~60%

Ключовий результат: Tool RAG більш ніж утричі підвищив точність (з 13.62% до 43.13%) при великому реєстрі tools і скоротив розмір промпту більш ніж на 50%.

Зверніть на нелінійність деградації. При 50 tools все ще прийнятно — 84-95%. При 200 tools вже критично — падіння до 41%. При 740 tools агент фактично вибирає навмання — 0-20%. Це не поступове погіршення, це обрив.

Як виглядає деградація в логах

Якщо ви логуєте tool calls в Agent Chat або AskYourDocs — ось як виглядає проблема в реальності:

// Агент з 5 tools — нормальна поведінка:
INFO: Round 1 AGENT_A — Tavily search: 'vibe coding productivity'
INFO: Tavily found 3 results
INFO: Round 1 AGENT_A reply: "GitHub Copilot збільшує продуктивність на 51%..."

// Той самий агент але з 30 tools — деградація:
INFO: Round 1 AGENT_A — [жодного tool call]
INFO: Round 1 AGENT_A reply: "За загальними даними, vibe coding..."
// stop_reason: "end_turn" без жодного tool_use
// Агент "вирішив" відповісти з пам'яті бо загубився у виборі tools

// Або інший варіант деградації:
INFO: Round 1 AGENT_A — Wikipedia search: 'productivity'
// Вибрав Wikipedia замість Tavily — хоча Tavily більше підходить для актуальної статистики
// При 30 tools модель не розрізняє тонкі відмінності між описами

Ефект "Lost in the Middle" для tools

Окрема і менш відома проблема — позиційний bias. Дослідження BiasBusters (2025) показало: tools в середині довгого списку вибираються значно рідше ніж tools на початку або в кінці. При 741 tool:

  • Tools на початку і в кінці списку — 31-32% точності
  • Tools в середині (позиції 40-60%) — лише 22-52% точності

Чому так відбувається технічно: трансформерні моделі використовують Rotary Position Embedding (RoPE) який має ефект "long-term decay" — токени на початку і в кінці контексту отримують більшу увагу ніж токени в середині. Це архітектурний bias який присутній в більшості сучасних LLM незалежно від provider.

Практичний наслідок: якщо ваш найкращий tool для запиту випадково опинився в середині списку з 50+ tools — шанс що модель його вибере суттєво нижчий ніж якби він був першим. Tool RAG вирішує це автоматично — inject тільки 3-5 tools, всі вони на початку контексту, позиційний bias мінімальний.

Ще одна причина деградації: токени

Кожен tool description займає токени. При 50 tools з детальними descriptions — це 5,000-15,000 токенів тільки на опис інструментів, ще до того як починається контекст розмови і history. Дослідження Modarressi et al. (2025) показало:

  • Збільшення контексту на 1,000 токенів → зниження точності на 16 відсоткових пунктів
  • При перевищенні 8,000 токенів → падіння до 50 п.п.

Детально про те як токени впливають на якість і вартість відповідей — в статті Контекстне вікно LLM: чому AI забуває і скільки це коштує. Там же — конкретні цифри вартості для різних провайдерів.

// Математика токенів для Agent Chat з 5 tools (поточний стан):
// 5 tools × ~200 токенів = 1,000 токенів — прийнятно ✅

// Якщо масштабувати до 30 tools:
// 30 tools × ~200 токенів = 6,000 токенів — починається деградація ⚠️

// 50 tools:
// 50 tools × ~200 токенів = 10,000 токенів — суттєва деградація ❌

// Tool RAG — незалежно від розміру реєстру:
// inject 3 tools × ~200 токенів = 600 токенів — завжди прийнятно ✅
// навіть якщо в реєстрі 500 tools
"Prompt Bloat" і смерть MCP якої не було: в кінці 2025 з'явились статті з заголовками "MCP is Dead After Just One Year". InfiniFlow (грудень 2025) розібрав ситуацію точно: проблема не в протоколі MCP — проблема в підході "завантажити всі tool descriptions в контекст одразу". При 4,400+ MCP серверів на mcp.so (квітень 2025) і сотнях tools в enterprise системах — "choice paralysis" стає неминучим. Tool RAG вирішує саме цю проблему не змінюючи протокол.

Tool RAG концепція — та сама ідея що і RAG для документів

Якщо ви будували RAG систему — Tool RAG зрозумієте за хвилину. Якщо ні — ось аналогія: уявіть бібліотеку з 10,000 книг. Коли вам потрібна відповідь на питання — ви не читаєте всі книги підряд. Ви йдете до каталогу, знаходите 3-5 релевантних книг, і читаєте тільки їх.

Класичний RAG робить те саме з документами. Tool RAG робить те саме з інструментами агента.

Порівняння: що змінюється в промпті

Найнаочніший спосіб зрозуміти Tool RAG — побачити як виглядає промпт до і після:

// ❌ БЕЗ Tool RAG — всі 30 tools в кожному запиті:
{
  "tools": [
    { "name": "searchWikipedia", "description": "Шукає у Wikipedia..." },
    { "name": "searchWeb", "description": "Шукає в інтернеті..." },
    { "name": "getStockPrice", "description": "Отримує ціну акцій..." },
    { "name": "searchNews", "description": "Шукає новини..." },
    { "name": "searchPapers", "description": "Шукає наукові статті..." },
    { "name": "getWeather", "description": "Отримує погоду..." },
    { "name": "translateText", "description": "Перекладає текст..." },
    { "name": "summarizeDoc", "description": "Резюмує документ..." },
    // ... ще 22 tools
  ],
  "messages": [{ "role": "user", "content": "яка ціна акцій AAPL?" }]
}
// Розмір промпту: ~8,000 токенів тільки на tools
// Модель бачить 30 варіантів і може загубитись

// ✅ З Tool RAG — тільки 2 релевантних tools:
{
  "tools": [
    { "name": "getStockPrice", "description": "Отримує ціну акцій..." },
    { "name": "searchWeb", "description": "Шукає актуальні фін. дані..." }
  ],
  "messages": [{ "role": "user", "content": "яка ціна акцій AAPL?" }]
}
// Розмір промпту: ~400 токенів на tools
// Модель бачить 2 очевидних варіанти — вибір точний

Таблиця аналогій: RAG для документів vs Tool RAG

Класичний RAG Tool RAG
Що зберігається у векторній БД Чанки документів Descriptions tools
Що індексуємо (embed) Текст документу Description + тригерні сценарії tool
Що шукаємо Релевантні фрагменти тексту Релевантні інструменти
Що inject в LLM Топ-K фрагментів як контекст Топ-K tools як доступні інструменти
Технологія pgvector, Qdrant Та сама pgvector, Qdrant
Embedding модель text-embedding-3-small Та сама модель
Що вирішує Галюцинації через брак знань Деградацію через надлишок вибору

Ключова перевага: якщо у вас вже є RAG інфраструктура — Tool RAG додається до неї з мінімальними зусиллями. Та сама pgvector, той самий embedding model, той самий підхід. Якщо ви використовуєте AskYourDocs або будь-яку іншу RAG систему на pgvector — Tool RAG це фактично ще одна таблиця в тій самій БД.

Якщо ви ще не будували RAG систему: перш ніж впроваджувати Tool RAG — рекомендую ознайомитись з базовою концепцією. Детально про те як RAG працює зсередини і як будувати його на Spring AI + pgvector — RAG з Ollama: від пайплайну до продакшну. Tool RAG буде зрозумілий одразу після цього.
Tool RAG: що робити коли у агента забагато інструментів

Flow Tool RAG: від запиту до inject

Весь процес Tool RAG складається з шести кроків. Перші два відбуваються до того як LLM отримує запит — це і є головна відмінність від класичного підходу.

Запит користувача: "знайди актуальну ціну акцій AAPL"
          ↓
[1] Embedding запиту
    embeddingModel.embed("знайди актуальну ціну акцій AAPL")
    → vector[1536]
    // Перетворюємо запит на числовий вектор.
    // Та сама embedding модель що використовується для документів у RAG.
    // Важливо: embedding моделі для tool descriptions і для запитів
    // мають бути однаковими — інакше векторний пошук не працює коректно.
          ↓
[2] Векторний пошук по реєстру tool descriptions
    SELECT tool_name, bean_name, 1 - (embedding <=> query_vector) as score
    FROM tool_registry
    WHERE is_active = TRUE
    ORDER BY embedding <=> query_vector
    LIMIT 5
    → [AlphaVantageTool: 0.91, TavilySearchTool: 0.73, 
       NewsApiTool: 0.61, WikipediaSearchTool: 0.44, ArxivTool: 0.31]
    // pgvector повертає всі tools відсортовані за релевантністю.
    // Навіть WikipediaSearchTool потрапив до результатів —
    // але з низьким score 0.44. Його відфільтруємо на наступному кроці.
          ↓
[3] Фільтрація за порогом релевантності
    MIN_RELEVANCE_THRESHOLD = 0.60
    → залишаємо: [AlphaVantageTool: 0.91, TavilySearchTool: 0.73]
    // Відкидаємо tools з score нижче порогу.
    // Це критичний крок — без нього inject може потрапити нерелевантний tool.
    // NewsApiTool (0.61) на межі — залежить від вашого threshold.
          ↓
[4] Завантаження Spring beans для знайдених tools
    List<ToolCallback> tools = loadTools(["alphaVantageTool", "tavilySearchTool"])
    // Завантажуємо реальні Spring beans по bean_name збереженому в реєстрі.
    // Не рядки — справжні об'єкти з анотацією @Tool.
          ↓
[5] LLM запит з 2 tools замість 30+
    agentChatModel.call(prompt, tools)
    // Модель бачить тільки 2 релевантних tools.
    // ~400 токенів на descriptions замість 6,000-15,000.
    // Вибір очевидний — AlphaVantageTool для ціни акцій.
          ↓
[6] LLM викликає AlphaVantageTool
    → getStockPrice("AAPL")
          ↓
[7] Відповідь користувачу
    "Акція AAPL: $213.50 | Зміна: +1.2% | Макс: $215.20 | Мін: $212.80"

Замість передачі 30+ tool descriptions (~6,000 токенів) — передаємо 2 найрелевантніших (~400 токенів). Економія токенів: 93%. Точність вибору: значно вища. Latency: +50-100ms на embedding запит, але зекономлені токени компенсують це швидшою обробкою коротшого промпту.

Два кроки які варто розуміти детально

Крок 1 — Embedding запиту: перетворення тексту на числовий вектор — основа всього Tool RAG. Від якості embedding моделі залежить наскільки точно система знайде релевантний tool. Детально про те як обрати embedding модель для вашого стеку — Embedding моделі для RAG у 2026: як обрати, порівняння провайдерів. Якщо хочете зрозуміти як embedding працює зсередини — Embeddings простими словами: як AI розуміє сенс а не просто слова.

Крок 3 — Поріг релевантності: це найважливіший параметр який потрібно підібрати під ваш реєстр. Занадто високий поріг (0.85+) — агент часто не знайде жодного tool і відповідатиме без пошуку. Занадто низький (0.40-) — inject потраплять нерелевантні tools і деградація повернеться. Рекомендований стартовий поріг: 0.60-0.65. Коригуйте на основі моніторингу (секція 9).

Що робити якщо жодного tool не знайдено вище порогу? Два варіанти: (1) відповідати без tools — безпечно якщо запит не потребує актуальних даних; (2) знизити поріг і inject найкращий результат навіть якщо score низький. В Agent Chat ми використовуємо варіант 1 як дефолт — агент відповідає з власних знань якщо Tool RAG нічого не знайшов.

Реалізація: pgvector для реєстру tools на Spring AI

Схема БД для реєстру tools

-- Реєстр tools з embeddings descriptions
CREATE TABLE tool_registry (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tool_name       VARCHAR(200) NOT NULL UNIQUE,  -- ім'я Java класу або метода
    display_name    VARCHAR(200) NOT NULL,          -- людська назва
    description     TEXT NOT NULL,                  -- повний description для embedding
    category        VARCHAR(100),                   -- категорія для routing
    bean_name       VARCHAR(200) NOT NULL,          -- Spring bean name для inject
    is_active       BOOLEAN DEFAULT TRUE,
    version         INTEGER DEFAULT 1,              -- для versioning
    embedding       vector(1536),                   -- pgvector
    created_at      TIMESTAMP DEFAULT NOW(),
    updated_at      TIMESTAMP DEFAULT NOW()
);

-- Індекс для швидкого векторного пошуку
CREATE INDEX tool_registry_embedding_idx
ON tool_registry
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 10);  -- 10 списків для невеликого реєстру (до 1000 tools)

-- Індекс для пошуку по категорії
CREATE INDEX tool_registry_category_idx ON tool_registry(category, is_active);

Сервіс реєстрації tools

@Service
@RequiredArgsConstructor
@Slf4j
public class ToolRegistryService {

    private final JdbcTemplate jdbcTemplate;
    private final EmbeddingModel embeddingModel;

    /**
     * Реєструємо tool в реєстрі.
     * Викликається при старті або при додаванні нового tool.
     */
    public void registerTool(ToolRegistration registration) {
        // Генеруємо embedding з description
        float[] embedding = embeddingModel.embed(registration.getDescription());

        jdbcTemplate.update("""
            INSERT INTO tool_registry
                (tool_name, display_name, description, category, bean_name, embedding)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT (tool_name) DO UPDATE SET
                description = EXCLUDED.description,
                category = EXCLUDED.category,
                embedding = EXCLUDED.embedding,
                version = tool_registry.version + 1,
                updated_at = NOW()
            """,
            registration.getToolName(),
            registration.getDisplayName(),
            registration.getDescription(),
            registration.getCategory(),
            registration.getBeanName(),
            embedding
        );

        log.info("Tool registered: {} (category: {})",
            registration.getToolName(), registration.getCategory());
    }

    /**
     * Семантичний пошук релевантних tools для запиту
     */
    public List<ToolMatch> findRelevantTools(String userQuery, int topK) {
        float[] queryEmbedding = embeddingModel.embed(userQuery);

        return jdbcTemplate.query("""
            SELECT tool_name, display_name, bean_name, category,
                   1 - (embedding <=> ?) as relevance_score
            FROM tool_registry
            WHERE is_active = TRUE
            ORDER BY embedding <=> ?
            LIMIT ?
            """,
            (rs, rowNum) -> ToolMatch.builder()
                .toolName(rs.getString("tool_name"))
                .displayName(rs.getString("display_name"))
                .beanName(rs.getString("bean_name"))
                .category(rs.getString("category"))
                .relevanceScore(rs.getDouble("relevance_score"))
                .build(),
            embedding, embedding, topK
        );
    }
}

@Value
@Builder
public class ToolMatch {
    String toolName;
    String displayName;
    String beanName;
    String category;
    double relevanceScore;
}

@Value
@Builder
public class ToolRegistration {
    String toolName;
    String displayName;
    String description;   // повний текст для embedding — чим детальніший, тим краще
    String category;
    String beanName;
}

Реєстрація всіх tools при старті

@Component
@RequiredArgsConstructor
@Slf4j
public class ToolRegistryInitializer implements ApplicationRunner {

    private final ToolRegistryService registryService;

    @Override
    public void run(ApplicationArguments args) {
        log.info("Initializing tool registry...");

        List<ToolRegistration> tools = List.of(
            ToolRegistration.builder()
                .toolName("AlphaVantageTool.getStockPrice")
                .displayName("Stock Price Lookup")
                .description("""
                    Отримує поточну ціну акцій на біржі.
                    Використовуй для запитів про: ціну акцій, капіталізацію компаній,
                    фінансові показники, динаміку ринку.
                    Підтримує тікери: AAPL, GOOGL, TSLA, AMZN, MSFT та інші.
                    НЕ використовуй для: новин, прогнозів, загальної інформації про компанії.
                    """)
                .category("FINANCE")
                .beanName("alphaVantageTool")
                .build(),

            ToolRegistration.builder()
                .toolName("TavilySearchTool.searchWeb")
                .displayName("Web Search")
                .description("""
                    Шукає актуальну інформацію в інтернеті через Tavily.
                    Використовуй для: свіжих новин, актуальної статистики,
                    подій 2024-2025 року, даних яких немає у Wikipedia.
                    НЕ використовуй для: стабільних фактів, визначень, біографій.
                    """)
                .category("SEARCH")
                .beanName("tavilySearchTool")
                .build(),

            ToolRegistration.builder()
                .toolName("WikipediaSearchTool.searchWikipedia")
                .displayName("Wikipedia Search")
                .description("""
                    Шукає стабільну фактичну інформацію у Wikipedia.
                    Використовуй для: визначень понять, біографій, наукових фактів,
                    исторических подій, географічної інформації.
                    НЕ використовуй для: актуальних новин, цін, поточних подій.
                    """)
                .category("SEARCH")
                .beanName("wikipediaSearchTool")
                .build(),

            ToolRegistration.builder()
                .toolName("ArxivSearchTool.searchPapers")
                .displayName("ArXiv Scientific Papers")
                .description("""
                    Шукає наукові статті та дослідження на ArXiv.
                    Використовуй для: наукових досліджень, академічних публікацій,
                    технічних статей з AI/ML, фізики, математики, CS.
                    Запити мають бути англійською.
                    """)
                .category("RESEARCH")
                .beanName("arxivSearchTool")
                .build(),

            ToolRegistration.builder()
                .toolName("NewsApiSearchTool.searchNews")
                .displayName("News Search")
                .description("""
                    Шукає свіжі новини по темі через NewsAPI.
                    Використовуй для: останніх новин, поточних подій,
                    корпоративних новин, новин ринку.
                    Обмеження: 100 запитів на день.
                    """)
                .category("NEWS")
                .beanName("newsApiSearchTool")
                .build()
        );

        tools.forEach(registryService::registerTool);
        log.info("Tool registry initialized: {} tools registered", tools.size());
    }
}

Dynamic tool injection у Spring AI

Найцікавіша частина — як inject тільки релевантні tools в запит до LLM замість усіх одразу. Ключова ідея: ToolCallingChatOptions в Spring AI приймає масив ToolCallback[] динамічно — тобто різні запити можуть отримувати різні набори tools без зміни коду.

@Service
@RequiredArgsConstructor
@Slf4j
public class ToolRagAgentService {

    private final ToolRegistryService registryService;
    private final ApplicationContext applicationContext;
    private final ChatModel chatModel;

    // Скільки tools максимум inject в один запит
    private static final int TOP_K_TOOLS = 3;
    // Мінімальний поріг релевантності — підбирайте під свій реєстр
    private static final double MIN_RELEVANCE = 0.60;

    public String askWithToolRag(String systemPrompt, String userQuery) {
        long startTime = System.currentTimeMillis();

        // 1. Знаходимо релевантні tools через векторний пошук
        List<ToolMatch> relevantMatches = registryService
            .findRelevantTools(userQuery, TOP_K_TOOLS);

        // 2. Фільтруємо за мінімальним порогом релевантності
        List<ToolMatch> filteredTools = relevantMatches.stream()
            .filter(m -> m.getRelevanceScore() >= MIN_RELEVANCE)
            .toList();

        long ragLatency = System.currentTimeMillis() - startTime;

        log.info("Tool RAG: query='{}' found={}/{} tools threshold={} latency={}ms",
            userQuery.length() > 50 ? userQuery.substring(0, 50) + "..." : userQuery,
            filteredTools.size(),
            relevantMatches.size(),
            MIN_RELEVANCE,
            ragLatency);

        filteredTools.forEach(t ->
            log.info("  → {} score={:.3f}", t.getToolName(), t.getRelevanceScore()));

        // 3. Будуємо повідомлення
        List<Message> messages = List.of(
            new SystemMessage(systemPrompt),
            new UserMessage(userQuery)
        );

        // 4. Fallback якщо жодного релевантного tool не знайдено
        if (filteredTools.isEmpty()) {
            log.warn("Tool RAG: no tools above threshold={} for query='{}' — answering without tools",
                MIN_RELEVANCE, userQuery);
            return chatModel.call(new Prompt(messages))
                .getResult().getOutput().getText();
        }

        // 5. Завантажуємо Spring beans і робимо запит
        ToolCallback[] tools = loadToolCallbacks(filteredTools);

        return chatModel.call(
            new Prompt(messages,
                ToolCallingChatOptions.builder()
                    .toolCallbacks(tools)
                    .build()))
            .getResult().getOutput().getText();
    }

    /**
     * Завантажуємо ToolCallback через Spring ApplicationContext
     * по bean name збереженому в реєстрі.
     *
     * Thread-safe: ApplicationContext.getBean() є потокобезпечним —
     * Spring повертає singleton beans без блокувань.
     */
    private ToolCallback[] loadToolCallbacks(List<ToolMatch> matches) {
        return matches.stream()
            .map(match -> {
                try {
                    Object bean = applicationContext.getBean(match.getBeanName());
                    return ToolCallbacks.from(bean);
                } catch (NoSuchBeanDefinitionException e) {
                    // Bean не знайдено — можливо tool видалено з коду
                    // але залишився в реєстрі БД
                    log.error("Tool bean not found: '{}' — " +
                              "деактивуйте tool в реєстрі або перезапустіть застосунок",
                        match.getBeanName());
                    return new ToolCallback[0];
                } catch (BeansException e) {
                    log.error("Failed to load tool bean '{}': {}",
                        match.getBeanName(), e.getMessage());
                    return new ToolCallback[0];
                }
            })
            .flatMap(Arrays::stream)
            .toArray(ToolCallback[]::new);
    }
}

Кешування embeddings для зниження latency

Tool RAG додає один embedding запит перед кожним LLM викликом. Якщо однакові або схожі запити повторюються — кешування дозволяє уникнути зайвих embedding запитів:

@Service
@RequiredArgsConstructor
@Slf4j
public class CachedToolRegistryService {

    private final ToolRegistryService registryService;

    // Простий in-memory кеш — для production використовуйте Redis
    // ConcurrentHashMap є thread-safe
    private final Map<String, CachedResult> cache = new ConcurrentHashMap<>();

    private static final Duration CACHE_TTL = Duration.ofMinutes(5);
    private static final int MAX_CACHE_SIZE = 500;

    public List<ToolMatch> findRelevantToolsCached(String userQuery, int topK) {

        // Нормалізуємо запит для кращого cache hit rate
        String cacheKey = userQuery.toLowerCase().trim();

        CachedResult cached = cache.get(cacheKey);
        if (cached != null && !cached.isExpired()) {
            log.debug("Tool RAG cache HIT for query: '{}'", cacheKey);
            return cached.tools();
        }

        // Cache miss — виконуємо реальний пошук
        log.debug("Tool RAG cache MISS for query: '{}'", cacheKey);
        List<ToolMatch> tools = registryService.findRelevantTools(userQuery, topK);

        // Зберігаємо в кеш якщо не перевищено ліміт
        if (cache.size() < MAX_CACHE_SIZE) {
            cache.put(cacheKey, new CachedResult(tools, Instant.now()));
        }

        return tools;
    }

    /**
     * Очищаємо кеш при оновленні реєстру tools
     * (викликати після registerTool або updateToolDescription)
     */
    public void invalidateCache() {
        int size = cache.size();
        cache.clear();
        log.info("Tool RAG cache invalidated: {} entries cleared", size);
    }

    record CachedResult(List<ToolMatch> tools, Instant cachedAt) {
        boolean isExpired() {
            return Instant.now().isAfter(cachedAt.plus(CACHE_TTL));
        }
    }
}

Інтеграція в AgentConversationRunner

Ось як виглядає міграція Agent Chat з статичного списку tools на динамічний Tool RAG — мінімальні зміни в існуючому коді:

// В AgentConversationRunner.ask()

// ❌ Було — всі 5 tools в кожному раунді незалежно від теми:
ToolCallback[] tools = ToolCallbacks.from(
    wikipediaSearchTool,   // завжди inject
    tavilySearchTool,      // завжди inject
    alphaVantageTool,      // завжди inject — навіть якщо говоримо про архітектуру
    arxivSearchTool,       // завжди inject
    newsApiSearchTool      // завжди inject
);
// ~1,000 токенів на tools кожен раунд

// ✅ Стало — тільки релевантні tools для поточного повідомлення:
List<ToolMatch> relevantTools = cachedToolRegistryService
    .findRelevantToolsCached(lastMessage, 3);

ToolCallback[] tools = loadToolCallbacks(relevantTools);

log.info("Tool RAG round={} injected={} tools: [{}]",
    round,
    tools.length,
    relevantTools.stream()
        .map(t -> t.getToolName() + ":" + String.format("%.2f", t.getRelevanceScore()))
        .collect(joining(", ")));

// Приклад логів при діалозі про vibe coding:
// Tool RAG round=1 injected=2 tools: [TavilySearchTool:0.87, WikipediaSearchTool:0.71]
// Tool RAG round=2 injected=2 tools: [TavilySearchTool:0.83, NewsApiSearchTool:0.68]
// Tool RAG round=3 injected=1 tools: [WikipediaSearchTool:0.79]
// AlphaVantageTool і ArxivSearchTool не inject — не релевантні для теми
Підводний камінь — десинхронізація реєстру і коду: якщо ви видалили або перейменували Spring bean але не оновили реєстр БД — loadToolCallbacks() кине NoSuchBeanDefinitionException. Щоб уникнути: додайте валідацію реєстру при старті застосунку (метод validateRegistry() з секції 8) і деактивуйте застарілі записи через deactivateTool() замість видалення з БД.

Tool categories і routing — спрощена альтернатива

Якщо у вас 10-30 tools і вони тематично різні — categories і keyword routing простіше і швидше ніж повноцінний Tool RAG. Це проміжне рішення яке вирішує 80% проблем масштабу без векторної БД і без embedding запитів.

Головна відмінність від Tool RAG: routing визначає категорію через пошук ключових слів у запиті — це CPU операція за мікросекунди, а не embedding запит за 50-100ms. Для систем де latency критична — це суттєва перевага.

Підхід: keyword routing

@Service
@RequiredArgsConstructor
@Slf4j
public class ToolCategoryRouter {

    // Tools інжектуються напряму — без зайвих полів
    private final AlphaVantageTool alphaVantageTool;
    private final TavilySearchTool tavilySearchTool;
    private final WikipediaSearchTool wikipediaSearchTool;
    private final ArxivSearchTool arxivSearchTool;
    private final NewsApiSearchTool newsApiSearchTool;

    // Ключові слова для кожної категорії
    // Важливо: слова мають бути достатньо специфічними щоб не давати
    // хибних спрацювань. "news" може зустрітись у будь-якому запиті —
    // тому краще "breaking news", "latest news", "новини сьогодні"
    private static final Map<String, List<String>> CATEGORY_KEYWORDS = Map.of(
        "FINANCE",  List.of("акція", "ціна акцій", "капіталізація",
                            "stock price", "market cap", "AAPL", "TSLA", "GOOGL"),
        "NEWS",     List.of("новини", "останні події", "сьогодні відбулось",
                            "breaking news", "latest news", "поточні події"),
        "RESEARCH", List.of("дослідження", "наукова стаття", "arxiv",
                            "research paper", "academic study", "peer-reviewed"),
        "FACTS",    List.of("що таке", "хто такий", "визначення", "wikipedia",
                            "what is", "who is", "definition of", "history of")
    );

    // Mapping категорій до tools — визначаємо один раз
    // LinkedHashSet зберігає порядок і запобігає дублікатам
    private Map<String, List<Object>> buildCategoryTools() {
        return Map.of(
            "FINANCE",  List.of(alphaVantageTool, tavilySearchTool),
            "NEWS",     List.of(newsApiSearchTool, tavilySearchTool),
            "RESEARCH", List.of(arxivSearchTool, wikipediaSearchTool),
            "FACTS",    List.of(wikipediaSearchTool, tavilySearchTool)
        );
    }

    /**
     * Визначаємо категорію запиту і повертаємо відповідні tools.
     * Дедуплікує tools якщо запит підпадає під кілька категорій.
     */
    public ToolCallback[] routeTools(String userQuery) {
        String queryLower = userQuery.toLowerCase();

        Set<String> matchedCategories = CATEGORY_KEYWORDS.entrySet().stream()
            .filter(entry -> entry.getValue().stream()
                .anyMatch(queryLower::contains))
            .map(Map.Entry::getKey)
            .collect(Collectors.toSet());

        log.info("Tool routing: query='{}' → categories={}",
            userQuery.length() > 60 ? userQuery.substring(0, 60) + "..." : userQuery,
            matchedCategories.isEmpty() ? "[DEFAULT]" : matchedCategories);

        if (matchedCategories.isEmpty()) {
            // Дефолт: базовий пошук для будь-якого запиту
            log.info("Tool routing: no category matched → using default [Tavily, Wikipedia]");
            return ToolCallbacks.from(tavilySearchTool, wikipediaSearchTool);
        }

        Map<String, List<Object>> categoryTools = buildCategoryTools();

        // LinkedHashSet для дедуплікації — tavilySearchTool не додасться двічі
        // якщо запит відповідає FINANCE і NEWS одночасно
        Set<Object> selectedTools = new LinkedHashSet<>();
        matchedCategories.forEach(category ->
            selectedTools.addAll(categoryTools.getOrDefault(category, List.of()))
        );

        log.info("Tool routing: selected {} tools: {}",
            selectedTools.size(),
            selectedTools.stream()
                .map(t -> t.getClass().getSimpleName())
                .collect(Collectors.joining(", ")));

        return ToolCallbacks.from(selectedTools.toArray());
    }
}

Коли routing спрацьовує добре — і коли ламається

// ✅ Routing спрацьовує добре:
"яка ціна акцій AAPL?"
→ FINANCE → [AlphaVantageTool, TavilySearchTool] ✓

"що таке vibe coding?"
→ FACTS → [WikipediaSearchTool, TavilySearchTool] ✓

"останні новини про Tesla stock price"
→ NEWS + FINANCE → [NewsApiSearchTool, TavilySearchTool, AlphaVantageTool] ✓
  (дедуплікація працює — TavilySearchTool один раз)

// ❌ Routing ламається:
"розкажи про компанію яка змінила ринок"
→ [] → DEFAULT → [TavilySearchTool, WikipediaSearchTool]
  (жодне ключове слово не спрацювало — але Tavily все одно підходить)

"яка погода в Києві і який курс долара?"
→ [] → DEFAULT
  (немає категорій WEATHER і CURRENCY — routing не знає що робити)
  // З Tool RAG: embedding знайшов би WeatherTool і CurrencyTool автоматично

"дослідження показують що ціни акцій зростають"
→ RESEARCH + FINANCE → занадто багато tools
  (слово "дослідження" є але запит не потребує ArXiv)
  // Це і є момент коли routing стає крихким

Порівняння: routing vs Tool RAG

Keyword routing Tool RAG
Overhead на запит ~0ms (CPU) ~50-100ms (embedding)
Точність вибору Добра для простих запитів Висока для будь-яких запитів
Підтримка коду Ручне оновлення keywords Оновлення description в БД
Мультимовність Окремі keywords для кожної мови Автоматично через semantic search
Кількість tools До 30 Необмежено
Складність впровадження Низька — один клас Середня — БД + embedding
Інфраструктура Нічого додаткового pgvector + embedding model
Три сигнали що час переходити від routing до Tool RAG: 1. Список keywords розростається — ви постійно додаєте нові ключові слова бо routing пропускає запити. Це ознака що semantic search впорається краще. 2. Запити часто належать до 2-3 категорій одночасно — inject стає непередбачуваним, агент отримує забагато tools. 3. Додаєте нову мову інтерфейсу — keywords потрібно дублювати для кожної мови, а Tool RAG підтримує мультимовність автоматично через semantic search.

Tool versioning — як оновлювати реєстр

Практична проблема якої немає в жодному туторіалі: що робити коли tool змінився? Нова функціональність, новий опис, нові обмеження. Або ще складніше — ви змінили embedding модель і всі старі вектори стали несумісними.

Ключовий принцип: ніколи не видаляйте записи з реєстру — деактивуйте. Це зберігає історію змін і дозволяє відкотитись якщо щось пішло не так.

Чотири сценарії оновлення

@Service
@RequiredArgsConstructor
@Slf4j
public class ToolVersioningService {

    private final ToolRegistryService registryService;
    private final CachedToolRegistryService cachedRegistryService;
    private final JdbcTemplate jdbcTemplate;
    private final EmbeddingModel embeddingModel;

    /**
     * Сценарій 1: Змінився тільки опис (найчастіше)
     * Перегенеровуємо тільки один embedding — решта полів без змін
     */
    @Transactional
    public void updateToolDescription(String toolName, String newDescription) {

        // Спочатку перевіряємо що tool існує
        int exists = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM tool_registry WHERE tool_name = ? AND is_active = TRUE",
            Integer.class, toolName);

        if (exists == 0) {
            throw new IllegalArgumentException(
                "Tool not found or inactive: " + toolName);
        }

        // Генеруємо новий embedding тільки для оновленого опису
        float[] newEmbedding = embeddingModel.embed(newDescription);

        jdbcTemplate.update("""
            UPDATE tool_registry
            SET description = ?,
                embedding = ?,
                version = version + 1,
                updated_at = NOW()
            WHERE tool_name = ?
            """,
            newDescription, newEmbedding, toolName
        );

        // Обов'язково інвалідуємо кеш після оновлення
        cachedRegistryService.invalidateCache();

        log.info("Tool updated: {} — new embedding generated, cache invalidated",
            toolName);
    }

    /**
     * Сценарій 2: Tool деактивований (застарів або видалений з коду)
     * НЕ видаляємо — деактивуємо, зберігаємо historію
     */
    @Transactional
    public void deactivateTool(String toolName) {
        int updated = jdbcTemplate.update("""
            UPDATE tool_registry
            SET is_active = FALSE,
                updated_at = NOW()
            WHERE tool_name = ?
            """,
            toolName
        );

        if (updated == 0) {
            log.warn("Tool not found for deactivation: {}", toolName);
            return;
        }

        cachedRegistryService.invalidateCache();
        log.info("Tool deactivated: {} — removed from active registry", toolName);
    }

    /**
     * Сценарій 3: Масове оновлення після рефакторингу descriptions
     * Перегенеровуємо всі embeddings — може зайняти кілька хвилин
     * при великому реєстрі
     */
    @Transactional
    public RebuildResult rebuildAllEmbeddings() {
        log.info("Starting full tool registry rebuild...");
        long startTime = System.currentTimeMillis();

        List<Map<String, Object>> tools = jdbcTemplate.queryForList(
            "SELECT tool_name, description FROM tool_registry WHERE is_active = TRUE"
        );

        int success = 0;
        int failed = 0;

        for (Map<String, Object> tool : tools) {
            String toolName = (String) tool.get("tool_name");
            String description = (String) tool.get("description");

            try {
                float[] newEmbedding = embeddingModel.embed(description);

                jdbcTemplate.update("""
                    UPDATE tool_registry
                    SET embedding = ?,
                        version = version + 1,
                        updated_at = NOW()
                    WHERE tool_name = ?
                    """,
                    newEmbedding, toolName
                );
                success++;

            } catch (Exception e) {
                log.error("Failed to rebuild embedding for tool '{}': {}",
                    toolName, e.getMessage());
                failed++;
            }
        }

        cachedRegistryService.invalidateCache();

        long elapsed = System.currentTimeMillis() - startTime;
        log.info("Tool registry rebuild complete: {}/{} tools updated in {}ms",
            success, tools.size(), elapsed);

        return new RebuildResult(success, failed, elapsed);
    }

    /**
     * Сценарій 4: Зміна embedding моделі — найскладніший кейс
     * Всі старі вектори несумісні з новою моделлю —
     * потрібно перебудувати весь реєстр і оновити розмірність
     */
    public void migrateEmbeddingModel(int newDimensions) {
        log.warn("EMBEDDING MODEL MIGRATION STARTED — " +
                 "all existing vectors will be invalidated!");

        // 1. Перевіряємо чи нова розмірність відрізняється
        // (якщо однакова — просто rebuildAllEmbeddings)
        if (newDimensions != getCurrentDimensions()) {
            log.info("Updating vector dimensions: {} → {}",
                getCurrentDimensions(), newDimensions);

            // 2. Змінюємо розмірність колонки в pgvector
            // УВАГА: це DROP і CREATE — всі старі вектори видаляються
            jdbcTemplate.execute(
                "ALTER TABLE tool_registry ALTER COLUMN embedding TYPE vector(" +
                newDimensions + ")");
        }

        // 3. Перегенеровуємо всі embeddings з новою моделлю
        RebuildResult result = rebuildAllEmbeddings();

        log.info("Embedding model migration complete: {}", result);
    }

    private int getCurrentDimensions() {
        // Отримуємо поточну розмірність з першого запису
        return jdbcTemplate.queryForObject("""
            SELECT vector_dims(embedding)
            FROM tool_registry
            WHERE embedding IS NOT NULL
            LIMIT 1
            """, Integer.class);
    }

    record RebuildResult(int success, int failed, long elapsedMs) {}
}

Валідація реєстру при старті — health check

Додайте валідацію в ApplicationRunner щоб одразу після старту бачити розбіжності між кодом і реєстром:

@Component
@RequiredArgsConstructor
@Slf4j
public class ToolRegistryValidator implements ApplicationRunner {

    private final ToolVersioningService versioningService;
    private final ApplicationContext applicationContext;
    private final JdbcTemplate jdbcTemplate;

    @Override
    public void run(ApplicationArguments args) {
        log.info("Validating tool registry consistency...");

        // Отримуємо всі активні bean names з реєстру
        List<String> registeredBeans = jdbcTemplate.queryForList(
            "SELECT bean_name FROM tool_registry WHERE is_active = TRUE",
            String.class
        );

        List<String> issues = new ArrayList<>();

        // Перевіряємо чи існують beans в Spring контексті
        for (String beanName : registeredBeans) {
            if (!applicationContext.containsBean(beanName)) {
                issues.add("ORPHANED: bean '" + beanName +
                           "' in registry but NOT in Spring context");
            }
        }

        // Перевіряємо чи є tools з null embedding
        List<String> noEmbedding = jdbcTemplate.queryForList("""
            SELECT tool_name FROM tool_registry
            WHERE is_active = TRUE AND embedding IS NULL
            """, String.class);

        if (!noEmbedding.isEmpty()) {
            issues.add("NO EMBEDDING: tools without embedding: " + noEmbedding);
        }

        // Виводимо результат
        if (issues.isEmpty()) {
            log.info("Tool registry OK: {} active tools, all consistent",
                registeredBeans.size());
        } else {
            log.warn("Tool registry has {} issues:", issues.size());
            issues.forEach(issue -> log.warn("  ⚠️ {}", issue));
            log.warn("Run ToolVersioningService.rebuildAllEmbeddings() " +
                     "or deactivateTool() to fix");
        }
    }
}
Підводний камінь — кеш після оновлення: кожен метод оновлення реєстру викликає cachedRegistryService.invalidateCache(). Якщо забути про це — агент продовжуватиме використовувати старі результати пошуку ще 5 хвилин (TTL кешу). Особливо критично при деактивації tool: агент може спробувати викликати деактивований tool якщо він ще в кеші. Підводний камінь — міграція embedding моделі: це незворотня операція яка видаляє всі старі вектори. Завжди робіть backup таблиці перед міграцією: CREATE TABLE tool_registry_backup AS SELECT * FROM tool_registry;

Моніторинг і метрики реєстру

Реєстр tools — це живий компонент. Без моніторингу ви не знаєте: які tools реально використовуються, які можна видалити, які descriptions варто переписати. І головне — не знаєте коли Tool RAG починає давати збої.

Схема таблиці аналітики

-- Логування кожного tool selection з Tool RAG
CREATE TABLE tool_usage_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tool_name       VARCHAR(200) NOT NULL,
    query_snippet   VARCHAR(500),      -- перші 500 символів запиту
    relevance_score DOUBLE PRECISION,
    was_called      BOOLEAN DEFAULT FALSE, -- чи LLM реально викликав tool після inject
    session_id      VARCHAR(100),      -- ID сесії для correlation
    conversation_id BIGINT,            -- для Agent Chat
    created_at      TIMESTAMP DEFAULT NOW()
);

-- Індекс для швидких запитів по часу і tool_name
CREATE INDEX tool_usage_log_time_idx ON tool_usage_log(created_at DESC);
CREATE INDEX tool_usage_log_tool_idx ON tool_usage_log(tool_name, created_at DESC);

-- Агрегована статистика за останні 30 днів
CREATE VIEW tool_usage_stats AS
SELECT
    tool_name,
    COUNT(*)                                                    as injected_count,
    SUM(CASE WHEN was_called THEN 1 ELSE 0 END)                as called_count,
    ROUND(
        SUM(CASE WHEN was_called THEN 1 ELSE 0 END)::numeric /
        NULLIF(COUNT(*), 0) * 100, 1
    )                                                           as call_rate_pct,
    ROUND(AVG(relevance_score)::numeric, 3)                    as avg_relevance,
    ROUND(MIN(relevance_score)::numeric, 3)                    as min_relevance,
    MAX(created_at)                                            as last_injected,
    MAX(CASE WHEN was_called THEN created_at END)              as last_called
FROM tool_usage_log
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY tool_name
ORDER BY called_count DESC;

ToolUsageMonitor — сервіс моніторингу

@Service
@RequiredArgsConstructor
@Slf4j
public class ToolUsageMonitor {

    private final JdbcTemplate jdbcTemplate;

    /**
     * Batch insert — один запит замість N окремих
     */
    public void logInjection(List<ToolMatch> injectedTools,
                              String query,
                              String sessionId,
                              Long conversationId) {
        if (injectedTools.isEmpty()) return;

        String querySnippet = query.length() > 500
            ? query.substring(0, 500) : query;

        // Batch insert для всіх tools одразу
        jdbcTemplate.batchUpdate("""
            INSERT INTO tool_usage_log
                (tool_name, query_snippet, relevance_score,
                 session_id, conversation_id)
            VALUES (?, ?, ?, ?, ?)
            """,
            injectedTools,
            injectedTools.size(),
            (ps, tool) -> {
                ps.setString(1, tool.getToolName());
                ps.setString(2, querySnippet);
                ps.setDouble(3, tool.getRelevanceScore());
                ps.setString(4, sessionId);
                ps.setObject(5, conversationId);
            }
        );
    }

    /**
     * Оновлюємо was_called=TRUE після реального виклику tool.
     * Використовуємо session_id а не час — надійніше.
     */
    public void markToolCalled(String toolName, String sessionId) {
        int updated = jdbcTemplate.update("""
            UPDATE tool_usage_log
            SET was_called = TRUE
            WHERE tool_name = ?
              AND session_id = ?
              AND was_called = FALSE
            """,
            toolName, sessionId
        );

        if (updated == 0) {
            log.warn("markToolCalled: no record found for tool='{}' session='{}'",
                toolName, sessionId);
        }
    }

    /**
     * "Мертві" tools — inject часто але викликають рідко.
     * callRateThreshold: 0.10 = менше 10% викликів після inject
     */
    public List<DeadToolReport> findDeadTools(double callRateThreshold) {
        return jdbcTemplate.query("""
            SELECT tool_name, injected_count, called_count,
                   call_rate_pct, avg_relevance
            FROM tool_usage_stats
            WHERE injected_count >= 10
              AND call_rate_pct < ?
            ORDER BY injected_count DESC
            """,
            (rs, rowNum) -> new DeadToolReport(
                rs.getString("tool_name"),
                rs.getInt("injected_count"),
                rs.getInt("called_count"),
                rs.getDouble("call_rate_pct"),
                rs.getDouble("avg_relevance")
            ),
            callRateThreshold * 100  // конвертуємо 0.10 → 10 для порівняння з call_rate_pct
        );
    }

    /**
     * Tools які не використовувались 30+ днів
     */
    public List<String> findUnusedTools() {
        return jdbcTemplate.queryForList("""
            SELECT tr.tool_name
            FROM tool_registry tr
            LEFT JOIN tool_usage_log tul
                ON tr.tool_name = tul.tool_name
                AND tul.created_at > NOW() - INTERVAL '30 days'
            WHERE tr.is_active = TRUE
              AND tul.tool_name IS NULL
            ORDER BY tr.tool_name
            """,
            String.class
        );
    }

    /**
     * Tools з постійно низьким relevance score —
     * сигнал що description погано відповідає реальним запитам
     */
    public List<String> findLowRelevanceTools(double scoreThreshold) {
        return jdbcTemplate.queryForList("""
            SELECT tool_name
            FROM tool_usage_stats
            WHERE injected_count >= 5
              AND avg_relevance < ?
            ORDER BY avg_relevance ASC
            """,
            String.class,
            scoreThreshold  // наприклад 0.65
        );
    }

    /**
     * Повний health report реєстру
     */
    public RegistryHealthReport generateHealthReport() {
        List<DeadToolReport> deadTools = findDeadTools(0.10);
        List<String> unusedTools = findUnusedTools();
        List<String> lowRelevanceTools = findLowRelevanceTools(0.65);

        int totalActive = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM tool_registry WHERE is_active = TRUE",
            Integer.class);

        RegistryHealthReport report = new RegistryHealthReport(
            totalActive, deadTools, unusedTools, lowRelevanceTools);

        if (report.hasIssues()) {
            log.warn("Tool registry health report:\n{}", report.summary());
        } else {
            log.info("Tool registry healthy: {} active tools, no issues", totalActive);
        }

        return report;
    }

    record DeadToolReport(
        String toolName,
        int injectedCount,
        int calledCount,
        double callRatePct,
        double avgRelevance
    ) {}

    record RegistryHealthReport(
        int totalActiveTools,
        List<DeadToolReport> deadTools,
        List<String> unusedTools,
        List<String> lowRelevanceTools
    ) {
        boolean hasIssues() {
            return !deadTools.isEmpty()
                || !unusedTools.isEmpty()
                || !lowRelevanceTools.isEmpty();
        }

        String summary() {
            return String.format("""
                Active tools: %d
                Dead tools (low call rate): %s
                Unused tools (30+ days): %s
                Low relevance tools: %s
                """,
                totalActiveTools,
                deadTools.stream().map(DeadToolReport::toolName).toList(),
                unusedTools,
                lowRelevanceTools
            );
        }
    }
}

Scheduled моніторинг — автоматичний звіт

@Component
@RequiredArgsConstructor
@Slf4j
public class ToolRegistryHealthScheduler {

    private final ToolUsageMonitor monitor;
    private final ToolVersioningService versioningService;

    /**
     * Щотижневий health check — кожного понеділка о 9:00
     */
    @Scheduled(cron = "0 0 9 * * MON")
    public void weeklyHealthCheck() {
        log.info("=== Weekly Tool Registry Health Check ===");
        RegistryHealthReport report = monitor.generateHealthReport();

        if (report.hasIssues()) {
            // В production: відправити в Slack/email/Grafana
            // notificationService.sendAlert("Tool Registry Issues", report.summary());
            log.warn("Action required — review tool registry");
        }
    }

    /**
     * Щоденна перевірка консистентності при старті
     */
    @Scheduled(cron = "0 0 8 * * *")
    public void dailyConsistencyCheck() {
        // Перевіряємо чи немає tools без embeddings
        // (могли з'явитись після збою під час registerTool)
        List<String> noEmbedding = versioningService.findToolsWithoutEmbeddings();

        if (!noEmbedding.isEmpty()) {
            log.warn("Tools without embeddings found: {} — rebuilding",
                noEmbedding);
            versioningService.rebuildEmbeddingsForTools(noEmbedding);
        }
    }
}

Що робити з результатами моніторингу

Симптом Метрика Причина Дія
Tool inject часто, викликають рідко call_rate_pct < 10% Description занадто широкий Додати anti-use-cases в description, звузити тригери
Tool не з'являється в inject injected_count = 0 Description не відповідає запитам користувачів Переписати description мовою запитів з логів
Tool не використовувався 30+ днів last_called IS NULL Tool застарів або покритий іншим tool Деактивувати через deactivateTool()
avg_relevance постійно низький avg_relevance < 0.65 Слабка семантична відповідність Збагатити description синонімами і прикладами запитів
call_rate_pct = 100% called_count = injected_count Tool inject занадто рідко — поріг завищений Знизити MIN_RELEVANCE або збагатити description
Практична порада: перший тиждень після впровадження Tool RAG — логуйте всі query_snippet для inject з низьким score (<0.70). Ці запити показують де семантичний пошук не знаходить правильний tool. Додайте ці формулювання прямо в description як приклади — і точність пошуку зросте без будь-яких змін в коді.

Порівняльна таблиця підходів

Три підходи не виключають один одного — в production часто використовують комбінацію: routing для швидкого першого фільтру і Tool RAG для точного вибору всередині категорії.

Підхід Оптимально для Latency overhead Точність вибору Інфраструктура Складність
Всі tools в запиті до 10 tools 0ms 78-95% Нічого Мінімальна
Categories + keyword routing 10-30 tools, тематично різні ~1ms (CPU) 75-90% Нічого Низька
Tool RAG (векторний пошук) 30+ tools, семантично схожі ~50-150ms 85-95% pgvector + embedding model Середня
Tool RAG + кешування 30+ tools, повторювані запити ~10-30ms 85-95% pgvector + Redis/ConcurrentHashMap Середня+
Hybrid: routing → Tool RAG 50+ tools, змішані категорії ~20-80ms 90-97% pgvector + категорії в БД Висока

Hybrid підхід — як він працює

Для великих реєстрів (50+ tools) найефективніша комбінація:

Запит користувача
      ↓
[1] Keyword routing → визначаємо категорію (FINANCE / NEWS / RESEARCH)
    ~1ms, CPU операція
      ↓
[2] Tool RAG тільки всередині категорії
    Шукаємо по 10-15 tools замість 50+
    ~30-50ms замість 100-150ms
      ↓
[3] Inject Top-3 tools з категорії
    Точність вища бо пошук у меншому просторі

// Приклад:
// Запит: "яка ціна акцій AAPL і що пишуть новини про Apple?"
// Routing: FINANCE + NEWS → шукаємо тільки в 15 фінансових і 10 новинних tools
// Tool RAG: AlphaVantageTool (0.91) + NewsApiTool (0.87) + TavilySearchTool (0.74)
// Inject: 3 tools замість 50

Яку стратегію обрати — швидке рішення

tools <= 10         → Всі tools в запиті. Не ускладнюйте.
tools 10-30         → Categories + routing. Один клас, жодної інфраструктури.
tools 30-50         → Tool RAG з pgvector. Якщо є RAG — додати легко.
tools 50+           → Hybrid: routing → Tool RAG. Максимальна точність.
tools 100+          → Hybrid обов'язково. Без нього точність < 14%.
Реальні цифри з RAG-MCP (Anthropic, 2025): при 100+ tools базовий підхід дає 13.62% точності — агент фактично вибирає навмання. Tool RAG дає 43.13% — більш ніж утричі краще. Розмір промпту скорочується більш ніж на 50%. Для порівняння в токенах: 100 tools × ~200 токенів = 20,000 токенів на descriptions в кожному запиті. Tool RAG inject 3 tools → 600 токенів. Економія: 97% токенів на tools при вищій точності вибору. Якщо ви платите за tokens — Tool RAG окупається дуже швидко.

Висновки

Коли я вперше зіткнувся з проблемою масштабу tools в Agent Chat — не розумів чому агент починає "тупити" при додаванні нових інструментів. Код правильний, descriptions написані — але вибір стає гіршим. Виявилось що це не баг а архітектурна проблема яку вирішує Tool RAG.

Хороша новина: якщо у вас вже є pgvector і Spring AI — додати Tool RAG займає день роботи, не тиждень. Це та сама інфраструктура що і для документів, просто застосована до descriptions інструментів.

Що я виніс з впровадження:

  • До 10 tools — хороший description і системний промпт. Tool RAG не потрібен. Не ускладнюйте раніше часу
  • 10-30 tools — categories і keyword routing. Один клас, жодної інфраструктури, вирішує 80% проблем масштабу
  • 30+ tools — Tool RAG обов'язковий. Без нього точність деградує нелінійно — і ви не одразу це помітите
  • "Prompt bloat" — реальна і підступна проблема — агент деградує поступово з кожним новим tool. При 100+ tools точність падає до 13% — агент фактично вибирає навмання
  • Моніторинг обов'язковий з першого дня — без логів ви не знаєте які tools реально викликаються, які inject але ігноруються, і які можна безпечно деактивувати
  • Description — це не документація а промпт — чим точніше description відповідає реальним запитам користувачів, тим вищий relevance score і тим рідше потрібен re-query. Перший тиждень після впровадження — аналізуйте логи і збагачуйте descriptions
Наступний крок у серії: Tool RAG вирішує проблему вибору інструментів. Але є ще одна проблема — агент "забуває" контекст між сесіями. Відповів на питання, наступного дня той самий користувач запитує знову — і агент не пам'ятає попередньої розмови. Про чотири типи пам'яті агента і коли який використовувати — MEM-1: Пам'ять AI агента — in-context, RAG, episodic і semantic.

Читайте також у серії:

TU-1: Tool Use vs Function Calling — базова механіка перш ніж масштабувати.

Як LLM вирішує коли викликати tool — як писати descriptions щоб модель вибирала правильно.

Grounding і довіра до джерел — що робити після того як tool відповів.

Джерела: RAG-MCP: Mitigating Prompt Bloat in LLM Tool Selection (2025), vLLM Semantic Tool Selection (2025), Red Hat: Tool RAG — The Next Breakthrough (2026), BiasBusters: Tool Selection Bias in LLMs (2025), RAGFlow: From RAG to Context — 2025 Review, Spring AI Documentation

Останні статті

Читайте більше цікавих матеріалів

Tool RAG: що робити коли у агента забагато інструментів

Tool RAG: що робити коли у агента забагато інструментів

У вас 5 tools — все чудово. У вас 15 tools — починаються проблеми. У вас 50 tools — агент деградує. Але є рішення яке вирішує проблему масштабу елегантно — і ви вже знаєте як воно працює, бо використовуєте його для документів. Ця стаття — частина серії про AI агентів на Spring Boot. Якщо...

Grounding в AI агентах: що робити коли tool call повернув не те

Grounding в AI агентах: що робити коли tool call повернув не те

Уявіть: ваш AI агент отримав запит «яка ціна на Enterprise план?». Він викликав tool. Tool відповів. Агент сформулював відповідь — впевнено, зв'язно, з конкретною цифрою. Клієнт отримав відповідь і пішов задоволений. Проблема в тому що tool повернув порожній результат — документ не...

Я змусив два AI посперечатись про vibe coding — ось що вийшло

Я змусив два AI посперечатись про vibe coding — ось що вийшло

Я очікував що AI здасться через 3 раунди. Він не здався через 8. І це змінило моє розуміння того як працюють мовні моделі. Як виникла ідея Класична проблема AI-агентів — вони занадто ввічливі. Попроси ChatGPT посперечатись — він погодиться через два повідомлення. Мене це дратувало. Я...

Agent Chat: два AI агенти що сперечаються — Spring Boot 4 + Spring AI + Ollama / OpenRouter

Agent Chat: два AI агенти що сперечаються — Spring Boot 4 + Spring AI + Ollama / OpenRouter

Що буде якщо дати двом AI протилежні переконання і змусити їх сперечатись на задану тему? Саме це питання стало відправною точкою для Agent Chat — експерименту де два агенти з різними характерами ведуть діалог в реальному часі, підкріплюючи аргументи реальними фактами з Wikipedia, Tavily,...

GPT-Realtime-2 vs Gemini Live API: що обрати для голосового агента у 2026 році

GPT-Realtime-2 vs Gemini Live API: що обрати для голосового агента у 2026 році

Два флагмани real-time голосового AI вийшли практично одночасно. OpenAI випустила GPT-Realtime-2 7 травня 2026 року. Google запустила Gemini 3.1 Flash Live 26 березня 2026 року. Обидві — speech-to-speech моделі з reasoning всередині. Обидві — для голосових агентів у продакшн. Але під капотом...

GPT-5.5 в Codex: що змінилось для розробників у 2026

GPT-5.5 в Codex: що змінилось для розробників у 2026

23 квітня 2026 OpenAI випустила GPT-5.5 — і одразу зробила її дефолтною моделлю в Codex. Але не кожен апдейт насправді щось змінює у щоденній роботі. Цей — змінює. Три речі, які важливі для розробника: менше токенів на ті ж задачі, та сама швидкість що й GPT-5.4, і якісно новий...