Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

Spring AI: Dynamic Tool Calling

Tung Bui Thanh (TungDaDev)12 mins read

Spring ai dynamic tool calling.webp

Agent doanh nghiệp có vài chục, vài trăm tool mà lần gọi LLM nào cũng nạp hết JSON Schema thì token tăng vọt, độ trễ dài ra và LLM dễ chọn nhầm tool hơn. Cách xử lý là Dynamic Tool Calling: dùng Vector Store kết hợp Advisor Pattern trong Spring AI.

# quá tải Context Window và chi phí Token

Doanh nghiệp thường nối nhiều hệ thống (ERP, CRM, Core Banking, Kho hàng, MCP Servers), nên số hàm công cụ (Tools / Function Calling) dễ lên tới 50 đến hơn 200 tools:

# tính chi phí token overhead

Mỗi định nghĩa công cụ gửi tới LLM gồm tên hàm, mô tả (description), tham số (properties), kiểu dữ liệu (types) và danh sách trường bắt buộc (required). Một schema công cụ thường tốn vài trăm token (gọi là T_schema, tạm lấy khoảng 200 - 350), tuỳ mô tả dài hay ngắn và có bao nhiêu tham số.

Giả sử hệ thống có M = 100 công cụ và nhận 500,000 lượt chat mỗi tháng:

Token dư thừa mỗi request = M × T_schema = 100 × 250 = 25,000 tokens

Tổng token lãng phí hàng tháng = 500,000 × 25,000 = 12.5 Tỷ tokens

Giả sử giá 2.5 / 1M input tokens, doanh nghiệp trả **31,250 mỗi tháng** chỉ để mô tả những công cụ mà phần lớn không hề được dùng trong request đó.

Con số này là trường hợp xấu nhất, khi không bật prompt caching. Danh sách tool nằm ở đầu prompt và gần như không đổi giữa các request, nên với provider có prompt caching (OpenAI, Anthropic, Gemini đều có) phần schema được tính theo giá token đã cache, rẻ hơn nhiều lần. Ngược lại, chọn tool động làm danh sách tool đổi theo từng câu hỏi, prefix của prompt đổi theo, nên cache hay bị trượt. Trước khi làm Dynamic Tool Calling, nên tính lại chi phí với giá cache của provider mình đang dùng.

# ba hệ quả

  1. Tốn tiền: hơn 90% chi phí đầu vào là để gửi các schema không liên quan.
  2. Time-to-First-Token (TTFT) dài hơn: LLM phải xử lý (prefill) hàng chục nghìn tokens ngữ cảnh trước khi ra token đầu tiên, nên độ trễ tăng thêm đáng kể, có thể tới vài giây tuỳ model và hạ tầng.
  3. Ảo giác công cụ (Tool Hallucination & Confusion): khi có nhiều công cụ có chữ ký giống nhau (ví dụ cancelBooking, cancelOrder, cancelTicket) nằm chung trong prompt, model dễ gọi nhầm tool hoặc điền sai tham số.

# kiến trúc Dynamic Tool Calling với Spring AI Advisor

Cách xử lý là áp dụng RAG cho Tool Schemas. Thay vì đưa toàn bộ công cụ vào ChatClient, hệ thống chỉ truy vấn và nạp động Top-K (K = 3 - 5) công cụ giống câu hỏi của người dùng nhất về ngữ nghĩa.

Trong Spring AI 1.x, cách gọn nhất để chen vào vòng đời của request mà không đụng tới code nghiệp vụ là viết một CallAdvisor. Các bản milestone trước 1.0 dùng CallAroundAdvisor / AdvisedRequest; từ 1.0.0 GA hai kiểu này đã bị bỏ, thay bằng CallAdvisor / ChatClientRequest, nên code cũ trên mạng nhiều khi không còn biên dịch được.


# code triển khai

# 1. tự động lập chỉ mục các Bean có gắn @Tool

Khi ứng dụng khởi động, hệ thống quét toàn bộ Spring Beans, lấy metadata và nạp vào VectorStore:

package com.tungdadev.agent.tools;
 
import org.springframework.ai.document.Document;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
 
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
 
@Component
public class DynamicToolRegistry {
 
    private final ApplicationContext applicationContext;
    private final VectorStore vectorStore;
    private final Map<String, ToolCallback> callbackMap = new ConcurrentHashMap<>();
 
    public DynamicToolRegistry(ApplicationContext applicationContext, VectorStore vectorStore) {
        this.applicationContext = applicationContext;
        this.vectorStore = vectorStore;
    }
 
    @EventListener(ApplicationReadyEvent.class)
    public void indexAllTools() {
        List<Document> toolDocuments = new ArrayList<>();
 
        for (String beanName : applicationContext.getBeanDefinitionNames()) {
            // Lọc theo kiểu trước khi getBean(): ToolCallbacks.from() ném IllegalStateException
            // nếu object không có method @Tool nào, còn getBean() mọi bean sẽ khởi tạo cả bean lazy
            Class<?> beanType = applicationContext.getType(beanName);
            if (beanType == null || !hasToolMethod(beanType)) {
                continue;
            }
 
            Object bean = applicationContext.getBean(beanName);
            for (ToolCallback callback : ToolCallbacks.from(bean)) {
                String toolName = callback.getToolDefinition().name();
                String description = callback.getToolDefinition().description();
 
                callbackMap.put(toolName, callback);
 
                // ID cố định theo tên tool: restart thì ghi đè document cũ thay vì thêm bản trùng
                String id = UUID.nameUUIDFromBytes(toolName.getBytes(StandardCharsets.UTF_8)).toString();
                toolDocuments.add(new Document(id, description, Map.of(
                        "toolName", toolName,
                        "type", "agent_tool"
                )));
            }
        }
 
        if (!toolDocuments.isEmpty()) {
            vectorStore.add(toolDocuments);
        }
    }
 
    private static boolean hasToolMethod(Class<?> beanType) {
        return Arrays.stream(ReflectionUtils.getDeclaredMethods(ClassUtils.getUserClass(beanType)))
                .anyMatch(method -> method.isAnnotationPresent(Tool.class));
    }
 
    public ToolCallback getCallback(String toolName) {
        return callbackMap.get(toolName);
    }
}

# 2. thiết kế CallAdvisor nạp công cụ động

Advisor này là interceptor chạy trước khi request tới LLM: tìm kiếm tương đồng rồi gắn các ToolCallback tương ứng vào ChatOptions của prompt. Tool được thực thi bên trong ChatModel dựa trên chính danh sách callback trong options, nên chỉ cần thay danh sách ở đây là đủ:

package com.tungdadev.agent.advisor;
 
import com.tungdadev.agent.tools.DynamicToolRegistry;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.document.Document;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.Ordered;
 
import java.util.*;
 
public class DynamicToolSelectionAdvisor implements CallAdvisor {
 
    private final VectorStore vectorStore;
    private final DynamicToolRegistry toolRegistry;
    private final int topK;
    private final double similarityThreshold;
    private final List<ToolCallback> alwaysOnTools;
 
    public DynamicToolSelectionAdvisor(VectorStore vectorStore,
                                       DynamicToolRegistry toolRegistry,
                                       int topK,
                                       double similarityThreshold,
                                       List<ToolCallback> alwaysOnTools) {
        this.vectorStore = vectorStore;
        this.toolRegistry = toolRegistry;
        this.topK = topK;
        this.similarityThreshold = similarityThreshold;
        this.alwaysOnTools = alwaysOnTools != null ? alwaysOnTools : List.of();
    }
 
    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        // Model không hỗ trợ tool calling thì để nguyên request
        if (!(request.prompt().getOptions() instanceof ToolCallingChatOptions options)) {
            return chain.nextCall(request);
        }
 
        String userPrompt = request.prompt().getUserMessage().getText();
 
        // 1. Tìm kiếm ngữ nghĩa trong Vector Store, chỉ trong các document mô tả tool
        SearchRequest searchRequest = SearchRequest.builder()
                .query(userPrompt)
                .topK(topK)
                .similarityThreshold(similarityThreshold)
                .filterExpression("type == 'agent_tool'")
                .build();
 
        List<Document> matchedDocs = vectorStore.similaritySearch(searchRequest);
 
        // 2. Chuyển đổi tên tool thành ToolCallback thực thi (giữ thứ tự, không trùng tên)
        Map<String, ToolCallback> selectedTools = new LinkedHashMap<>();
        for (ToolCallback tool : alwaysOnTools) {
            selectedTools.put(tool.getToolDefinition().name(), tool);
        }
        for (Document doc : matchedDocs) {
            String toolName = (String) doc.getMetadata().get("toolName");
            ToolCallback callback = toolRegistry.getCallback(toolName);
            if (callback != null) {
                selectedTools.putIfAbsent(toolName, callback);
            }
        }
 
        // 3. Sao chép ChatOptions, chỉ giữ các tool đã chọn, rồi gắn vào Prompt mới
        ToolCallingChatOptions newOptions = options.copy();
        newOptions.setToolCallbacks(new ArrayList<>(selectedTools.values()));
 
        ChatClientRequest modifiedRequest = request.mutate()
                .prompt(request.prompt().mutate().chatOptions(newOptions).build())
                .build();
 
        // 4. Tiếp tục chuỗi xử lý
        return chain.nextCall(modifiedRequest);
    }
 
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 100;
    }
 
    @Override
    public String getName() {
        return "DynamicToolSelectionAdvisor";
    }
}

Code trên viết cho Spring AI 1.0/1.1. Lên Spring AI 2.0, ChatOptions thành immutable: thay cặp options.copy() + setToolCallbacks(...) bằng options.mutate().toolCallbacks(...).build(). Bản 2.0 cũng đã đưa Tool Search Tool vào core: thay vì advisor tự chọn tool trước, model chỉ thấy một tool tìm kiếm và tự hỏi xem cần tool nào. Nếu đang ở 2.0 thì nên thử cách có sẵn này trước khi tự viết.

Vài chỗ dễ vấp khi đưa advisor này lên production:

  • Câu hỏi nối tiếp kiểu "huỷ nó đi" gần như không có ngữ nghĩa để search. Advisor trên chỉ dùng tin nhắn user cuối cùng; hệ thống thật nên ghép thêm vài lượt hội thoại gần nhất vào query, hoặc giữ lại các tool vừa dùng ở lượt trước.
  • Ngưỡng similarityThreshold phụ thuộc model embedding. Cùng một câu, điểm cosine của model này có thể cao hơn hẳn model kia, nên con số 0.72 bên dưới chỉ là điểm khởi đầu, phải đo lại trên bộ câu hỏi thật.
  • Chất lượng tìm kiếm phụ thuộc hoàn toàn vào description của @Tool. Mô tả một dòng chung chung thì search trượt, model không thấy tool cần dùng và sẽ trả lời bừa hoặc báo không làm được.
  • Tool bị xoá khỏi code vẫn còn document trong Vector Store. getCallback() trả null nên advisor bỏ qua, không lỗi, nhưng nó chiếm một suất trong Top-K; nên dọn các document mồ côi khi khởi động.

# 3. cấu hình ChatClient trong Spring Configuration

Đăng ký Advisor vào ChatClient.Builder để mọi lời gọi agent đều dùng cơ chế truy xuất động:

package com.tungdadev.agent.config;
 
import com.tungdadev.agent.advisor.DynamicToolSelectionAdvisor;
import com.tungdadev.agent.tools.DynamicToolRegistry;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
import java.util.List;
 
@Configuration
public class AgentChatConfiguration {
 
    @Bean
    public ChatClient enterpriseAgentChatClient(ChatClient.Builder builder,
                                                VectorStore vectorStore,
                                                DynamicToolRegistry toolRegistry) {
        return builder
                .defaultAdvisors(
                    new DynamicToolSelectionAdvisor(
                        vectorStore,
                        toolRegistry,
                        3,       // Top-3 tools liên quan nhất
                        0.72,    // Ngưỡng Cosine Similarity tối thiểu
                        List.of() // Always-on tools nếu có
                    )
                )
                .build();
    }
}

# chiến lược kết hợp: Hybrid Tool Retrieval và RBAC

Ở môi trường doanh nghiệp thật, chỉ tìm kiếm ngữ nghĩa thôi thì chưa đủ. Bạn nên thêm hai lớp bảo vệ:

  1. Always-On Tools: một số công cụ kiểm tra quan trọng (như ghi nhật ký kiểm toán, kiểm tra quyền truy cập) luôn nằm trong danh sách alwaysOnTools, không phụ thuộc kết quả vector search.
  2. Lọc tool theo vai trò (Role-Based Tool Filtering):
    • Trước khi kết quả vector search tới LLM, hệ thống lọc danh sách công cụ theo quyền của người dùng hiện tại (Authentication / GrantedAuthorities).
    • Nhân viên hỗ trợ cấp 1 mà hỏi chuyện hoàn tiền thì issueRefundTool bị loại khỏi danh sách ngay ở bước lọc metadata, nên Prompt Injection không thể khiến model gọi một tool mà nó không hề được thấy.

Lọc ở bước chọn tool chỉ là lớp ngoài. Bản thân tool (hoặc service bên dưới) vẫn phải kiểm tra quyền khi thực thi, vì danh sách tool có thể bị cấu hình sai, và tool đã được phép thì vẫn có thể bị lừa điền tham số của người khác (ví dụ hoàn tiền cho đơn không thuộc khách đang chat).


# đo hiệu năng

Bảng dưới là số liệu minh hoạ cho một trợ lý ảo với 60 công cụ nghiệp vụ. Kết quả thật phụ thuộc nhiều vào model, độ dài mô tả tool, model embedding và có bật prompt caching hay không, nên hãy coi đây là hướng tham khảo và tự đo trên hệ thống của mình. Để đối chiếu, benchmark sơ bộ của Spring AI cho Tool Search Tool với 28 tools ghi nhận mức tiết kiệm token khoảng 34% đến 64% tuỳ model.

Tiêu chí kỹ thuậtNạp tĩnh toàn bộ (Static)Dynamic Tool RetrievalCải thiện thực tế
Dung lượng Input Tokens trung bình~15,000 tokens~2,000 tokens📉 Tiết kiệm ~85%
Thời gian phản hồi token đầu tiên (TTFT)~1.6 giây~0.7 giây⚡ Nhanh hơn ~2 lần
Tỷ lệ chọn đúng công cụ (Accuracy)~85%~93%🎯 Ít chọn nhầm tool hơn
Giới hạn số lượng công cụ tối đaChạm giới hạn của providerMở rộng hàng nghìn tools🚀 Khả năng mở rộng cao

Mấy con số này lấy từ đâu thì bạn có thể tự nhẩm: mỗi tool tốn khoảng 200–300 token cho tên, mô tả và JSON schema, 60 tools là khoảng 15 nghìn token. Bản dynamic chỉ gửi system prompt, câu hỏi và top 5 tools liên quan nhất, rơi vào khoảng 2 nghìn token. Prompt ngắn thì giai đoạn prefill ngắn, TTFT giảm, nhưng không giảm tuyến tính vì còn phải cộng thêm một lượt gọi embedding và truy vấn vector store (thường vài chục tới vài trăm mili-giây).

Tỷ lệ chọn đúng tăng vì model chỉ phải phân biệt 5 tool thay vì 60 tool na ná nhau. Có điều độ chính xác giờ bị chặn trên bởi bước retrieval: tool đúng không lọt vào top 5 thì model không có cách nào gọi được nó. Vì vậy khi đo, bạn nên đo riêng recall của bước retrieval (tool cần dùng có nằm trong top-k không) trước khi đo độ chính xác của cả hệ thống. Bật prompt caching thì chi phí của bản static giảm đáng kể, khoảng cách giữa hai cách cũng thu hẹp lại.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.