Skip to content

Spring AI Agent Architecture, Implementation, and Dynamic Tool Discovery

The previous articles covered how an agent is driven by an agent loop, context engineering, which designs the context passed to the model on each turn of the loop, and how to control the tool loop with recursive advisors. This article puts these parts together to build an agent that actually works.

You do not have to design everything from scratch. Widely used agent products such as Claude Code describe their internal structure in public documentation, and several agents publish their code as open source. The book presents a "Spring AI agent architecture" that maps the structure these products share onto Spring AI modules, and the example repository implements it as the SpringAIAgent class.

The second half of the article deals with the problem that arises as the number of tools grows. The approach there is dynamic tool discovery, which searches for only the tools that are needed and passes those to the model instead of sending every registered tool definition each time.

Spring AI agent architecture

The agent products that lead the market have three things in common. They have a single agent loop, every capability connects through a single tool interface, and external connections are standardized on MCP. Spring AI already has the parts that fit this structure. The work is less about learning a new framework and more about rearranging the parts you have learned so far from an agent's point of view.

  • Root agent: ChatClient and the advisor chain form the center, and ToolCallingAdvisor, a recursive advisor, controls the tool loop. The ChatModel abstraction handles model calls, so you are not tied to a particular vendor.
  • Context and memory: The persona and behavioral guidelines go in the system prompt, and MessageChatMemoryAdvisor manages the conversation history.
  • Tool execution area: @Tool and ToolCallback are the single tool interface. Whether it is a simple lookup or complex business logic, it is registered with the agent in the same shape.
  • External extension: The MCP client makes the tools of external servers visible to the model. It is the path for connecting external agents, and the mechanism for getting human approval during tool execution also runs on MCP Elicitation.
  • Specialized agent tools: Tools for planning, asking questions, skills, and delegation come from spring-ai-agent-utils, a community library. The library brings implementation patterns inspired by Claude Code to Spring AI.

Spring AI agent architecture

Spring AI agent architecture

The components in the figure fall into two groups by where they come from. Loop control, the tool interface, and MCP communication are in Spring AI core. Elicitation for human approval (@McpElicitation) and ToolSearchToolCallingAdvisor, which you use when there are many tools, also belong to core. By contrast, TodoWriteTool (task planning), AskUserQuestionTool (clarifying questions), SkillsTool (skills), and TaskTool (delegating to subagents) live in a community-led incubating library.

Implementing SpringAIAgent: ChatClient and the advisor chain

You can build a single agent with the core parts alone. ChatClient, ToolCallingAdvisor, @Tool, the MCP client, and chat memory all come with the basic starters used in Chapters 2 through 5, so there is no need to add more libraries. No new tools are needed either. The example reuses DateTimeTools, CalculatorTools, and InventoryTools, built in Chapter 4.

The example repository builds both this core agent and the enhanced agent, which later adds community tools, from a single SpringAIAgent class. The only differences between the two are the system prompt, the tool list, and the tool loop advisor, so the constructor takes these three.

SpringAIAgent.java
public class SpringAIAgent {

    private final ChatClient chatClient;
    // 툴 루프 어드바이저는 요청마다 새로 생성(안전 가드의 카운터를 요청 사이에 공유하지 않으려고)
    private final Supplier<ToolCallingAdvisor> loopAdvisorFactory;

    public SpringAIAgent(ChatClient.Builder builder,
                         String systemPrompt,                              // 시스템 프롬프트(코어/강화가 다름)
                         List<ToolCallback> tools,                         // 등록할 툴(로컬, 커뮤니티, MCP, 모두 ToolCallback으로 통일)
                         Supplier<ToolCallingAdvisor> loopAdvisorFactory,  // 툴 루프 어드바이저 팩토리(요청마다 새로)
                         ChatMemory chatMemory,
                         MeterRegistry meterRegistry) {
        this.loopAdvisorFactory = loopAdvisorFactory;
        this.chatClient = builder.clone()
                .defaultSystem(systemPrompt)
                .defaultTools(tools.toArray())
                .defaultAdvisors(                                  // 루프 어드바이저는 여기 말고 run()에서 요청마다 끼움
                        MessageChatMemoryAdvisor.builder(chatMemory)   // 루프 바깥, 1회
                                .order(BaseAdvisor.HIGHEST_PRECEDENCE + 200).build(),
                        new ToolCallTraceAdvisor(),                    // 호출된 툴과 결과를 CLI에 표시(+350, 루프 안)
                        new ThinkTraceAdvisor(),                       // 모델 사고를 CLI에 표시(+360, 루프 안)
                        new SimpleLoggerAdvisor(),                     // 반복별 원문 로깅(0)
                        new ToolLoopMetricsAdvisor(meterRegistry))     // 루프-레벨 지표(+400, 관측 6.6.7)
                .build();
    }

    public Flux<String> run(String userMessage, String conversationId) {
        return chatClient.prompt()
                .advisors(loopAdvisorFactory.get())   // 요청마다 새 툴 루프 어드바이저(+300, 안전 가드도 새 카운터)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .user(userMessage)
                .stream().content();
    }
}
View full code

There is no while or for loop anywhere in the code. When the model requests a tool call, ToolCallingAdvisor runs the tool inside the advisor chain, adds the result to the conversation, and calls the model again. This repeats until the model produces a final answer. Spring AI 2.0 runs the tool loop internally on its own. Even so, the example registers the loop advisor explicitly, to make its execution order relative to the memory advisor visible in the code and to set up a safety guard that limits the number of tool execution rounds. The loop advisor comes in as a Supplier and is created anew for every request, so the guard's counter does not carry over from one request to another.

The order values reveal the structure. MessageChatMemoryAdvisor, at HIGHEST_PRECEDENCE + 200, sits outside the loop and runs once per request, while the loop advisor attached in run(), at +300, sits downstream of it. ThinkTraceAdvisor and ToolCallTraceAdvisor run inside the loop and show the model's thinking and the tools it called on the console at each iteration. If you also want to keep the tool call history in the chat memory repository, you can move the memory advisor inside the loop and turn off the internal conversation history of ToolCallingAdvisor. The memory advisor then handles history management in one place.

On the channel side, the code only needs to pass the user message and a conversation ID to run().

Ch6Step1_SpringAIAgent.java
@Component
@Profile("!ops & !knowledge")
@ConditionalOnProperty(prefix = "spring.ai.cli", name = "step", havingValue = "ch6-step1")
class Ch6Step1_SpringAIAgent implements CommandLineRunner {

    private final SpringAIAgent agent;

    Ch6Step1_SpringAIAgent(@Qualifier("coreAgent") SpringAIAgent agent) {
        this.agent = agent;
    }

    @Override
    public void run(String... args) {
        System.out.println("=== [Ch6] Step1: 코어 메인 에이전트 (로컬 툴만, 단독 실행) ===");
        String conversationId = UUID.randomUUID().toString();
        ask("지금 한국 시간을 알려주고, SKU-200 재고를 확인한 뒤 2개 예약해줘", conversationId);
        ask("방금 예약한 상품 이름이 뭐였지?", conversationId);   // 메모리 확인
    }
View full code

The Step 1 runner gets the coreAgent bean injected and asks two questions with the same conversation ID. The first request asks for the current time, a stock check, and a reservation all at once. The second asks for the name of the product just reserved, to confirm that chat memory works. To run it without an MCP server, pass the arguments --spring.ai.cli.step=ch6-step1 --spring.ai.mcp.client.enabled=false.

A single tool interface: local, community, and remote

Wherever a capability comes from, it becomes a ToolCallback when it enters the agent. The coreAgent bean in AgentConfig handles this assembly.

AgentConfig.java
    /** 코어 메인 에이전트. 로컬 도메인 툴 + 동적 툴 발견(선택) + 안전 가드 + 코어 페르소나. */
    @Bean
    SpringAIAgent coreAgent(ChatClient.Builder builder,
                            List<ToolCallback> localTools,
                            ObjectProvider<SyncMcpToolCallbackProvider> mcpToolsProvider,
                            ChatMemory chatMemory,
                            MeterRegistry meterRegistry,
                            ObjectProvider<VectorStore> vectorStoreProvider,
                            @Value("${spring.ai.cli.agent.core.system-prompt}") String systemPrompt,
                            @Value("${spring.ai.cli.tool-search.min-tools:10}") int toolSearchThreshold) {
        SyncMcpToolCallbackProvider mcpTools = mcpToolsProvider.getIfAvailable();
        VectorStore vectorStore = vectorStoreProvider.getIfAvailable();
        int remoteCount = mcpTools != null ? mcpTools.getToolCallbacks().length : 0;
        int toolCount = localTools.size() + remoteCount;   // 동적 발견 판단 기준: 도메인 툴 수(로컬 + 원격)

        boolean useToolSearch = shouldUseToolSearch(toolCount, toolSearchThreshold, vectorStore);
        // 어떤 어드바이저로 갈렸는지 시작 시 한 줄로 남김(동적 툴 발견 자동 전환 확인용)
        System.out.printf("[SpringAIAgent] 등록 툴 %d개(로컬 %d + 원격 %d), 임계값 %d → %s%n",
                toolCount, localTools.size(), remoteCount, toolSearchThreshold,
                useToolSearch ? "동적 툴 발견(ToolSearchToolCallingAdvisor, VectorToolIndex)"
                              : "전체 툴 직접 로드(ToolCallingAdvisor)");
        Supplier<ToolCallingAdvisor> loopAdvisorFactory = loopAdvisorFactory(useToolSearch, vectorStore);

        // 등록할 툴: 로컬 도메인 툴 +(연결됐다면) MCP 서버 툴(Provider를 ToolCallback으로 펼쳐 통일)
        List<ToolCallback> tools = new ArrayList<>(localTools);
        if (mcpTools != null) {
            tools.addAll(List.of(mcpTools.getToolCallbacks()));
        }
        return new SpringAIAgent(builder, systemPrompt, tools, loopAdvisorFactory, chatMemory, meterRegistry);
    }
View full code

The local tools are a List<ToolCallback> that the localTools bean builds by converting three tool objects with ToolCallbacks.from(). The MCP servers' tools come from SyncMcpToolCallbackProvider, and once expanded with getToolCallbacks(), they join the same list as the local tools. If the MCP client is turned off and this bean does not exist, getIfAvailable() returns null, and the agent is built with local tools only. Whether it is a method inside the JVM or a server across the network, to the model it is the same tool call.

Community tools come in the same way. For SkillsTool and TaskTool, the builder produces a ToolCallback directly. TodoWriteTool and AskUserQuestionTool are @Tool objects, so they are converted with ToolCallbacks.from() and added to the list. Adding capabilities does not change SpringAIAgent. Only the list that the configuration class passes in changes.

Dynamic tool discovery: a tool that finds tools

Adding tools comes at a cost. The name, description, and JSON schema of every registered tool are sent to the model with each request and take up context. According to a source the book cites, in a common setup with several MCP servers connected, the definitions of more than 50 tools alone can consume over 55,000 tokens before the conversation really gets going. As tools with similar names and functions multiply, the model's accuracy in picking the right tool also drops noticeably.

The tool search pattern solves this problem with search. At first, the model sees only one tool, a tool that finds tools (Tool Search Tool, TST). When the model needs some capability during a task, it passes a search query to this tool, and only the definitions of the tools that match are added to the context. Since even the job of managing tools is handled through a tool call, the pattern stays true to the principle that "every capability is connected as a tool."

Anthropic first introduced this pattern as a Claude-specific feature, and Spring AI implemented the same idea as a recursive advisor. The implementation is ToolSearchToolCallingAdvisor, a subclass of ToolCallingAdvisor. Because it intercepts the tool loop at the framework level, it works the same way with any model, whether OpenAI, Anthropic, Gemini, or Ollama.

A tool that finds tools

A tool that finds tools (Source: Smart Tool Selection: Achieving 34-64% Token Savings with Spring AI's Dynamic Tool Discovery)

Following the numbers in the figure, the flow has seven steps.

  1. All registered tools are indexed in ToolIndex.
  2. The first request carries only the search tool's definition instead of every tool.
  3. When the model decides it needs a capability, it calls the search tool with a search query.
  4. ToolIndex finds the tools that match the query and adds their definitions to the context of the next request.
  5. The model calls the actual tool that has just become visible to it.
  6. The application runs that tool and returns the result to the model.
  7. Once it has gathered the information it needs, the model gives the user a final answer.

The search method is abstracted behind the ToolIndex interface. You can choose among the keyword-based LuceneToolIndex, VectorToolIndex, which compares meaning through embeddings, and the regex-based RegexToolIndex, or plug in your own implementation. The dependency is spring-ai-starter-tool-search-advisor. This advisor accumulates the tool definitions found through search per session, so it needs a conversation ID when called. SpringAIAgent.run() passes this value with every request, so there is nothing extra to take care of.

The example's core agent picks one of two advisors based on the number of tools. In the coreAgent code above, it turns on dynamic tool discovery when the local and remote tools together number at least spring.ai.cli.tool-search.min-tools (10 if not set) and a vector store is available. The part that creates the advisor looks like this.

AgentConfig.java
    private static Supplier<ToolCallingAdvisor> loopAdvisorFactory(boolean useToolSearch, VectorStore vectorStore) {
        if (useToolSearch) {
            ToolIndex toolIndex = new VectorToolIndex(vectorStore);   // 1회 생성, 공유
            return () -> withSafetyGuard(
                    ToolSearchToolCallingAdvisor.builder().toolIndex(toolIndex).maxResults(TOOL_SEARCH_MAX_RESULTS));
        }
        return () -> withSafetyGuard(ToolCallingAdvisor.builder());
    }

    /**
     * 빌더에 두 어드바이저의 공통 설정(요청별 안전 가드 + 순서)을 더해 루프 어드바이저를 완성한다.
     * 팩토리가 요청마다 호출하므로 매 호출이 새 안전 가드(요청별 카운터)를 끼운다. 공통 꼬리를 한곳에 모아 중복을 없앤다.
     */
    private static ToolCallingAdvisor withSafetyGuard(ToolCallingAdvisor.Builder<?> builder) {
        return builder
                .toolExecutionEligibilityChecker(AgentSafety.maxToolRounds(MAX_TOOL_ROUNDS))
                .advisorOrder(LOOP_ADVISOR_ORDER)
                .build();
    }
View full code

With dynamic tool discovery on, VectorToolIndex is created only once, and the ToolSearchToolCallingAdvisor that is created fresh for each request shares that index. The index tracks per-session embeddings and cleans them up, so creating a new index for every request would pile up duplicates in the vector store. maxResults is set to 5. The store the index uses is the SimpleVectorStore from the toolVectorStore bean, and the embedding model is bge-m3, set in spring.ai.ollama.embedding.model in application.yml.

Counting the tools shows when the switch happens. There are 7 local tools in total: 2 for dates, 2 for calculation, and 3 for inventory. Adding the 2 tools of the operations MCP server still makes only 9, so every tool is sent as is. Search takes over only once there are 10 or more tools. The enhanced agent, used from Step 4 on, does not follow this rule. Instead, it uses OrchestrationToolCallingAdvisor, which always shows the meta-tools and searches only the domain tools. The enterprise agent CLI article covers this setup.

Published preliminary measurements give a sense of the effect. In a test that had models solve the same task with 28 tools, 3 relevant and 25 unrelated, applying Lucene search reduced total token usage by 60% for Gemini, 34% for OpenAI, and 64% for Anthropic. Most of the savings come from the tool definitions that used to be sent with every request, and because a search step is added, LLM calls went up by one or two on average. Vector search brought similar reductions of 47-63%. Just as RAG searches for documents and adds them to the prompt, this pattern searches for tool definitions and supplies only the capabilities that are needed, so you can think of it as "RAG for tools."

Where this fits in the 4-tier architecture

SpringAIAgent and the advisor chain belong to T2 Orchestration. As this tier runs the loop, it calls tools in the T3 Capability tier and hands model calls to ChatModel in T4 Foundation. The package names in the example, channel, orchestration, and capability, also follow the tiers. Dynamic tool discovery works at the boundary between T2 and T3, because the Orchestration tier uses search to decide which capabilities to show the model in each round. Now that the agent can call remote tools as well, the next article looks at how to get human approval before operations that cannot be undone: Human-in-the-Loop (HITL): An Approval Gate with MCP Elicitation

More in the book

Book sections 6.4.3, 6.4.5

  • Table 6.12, the Spring AI agent development stack: the role of each component, the split between core and community, and module artifacts
  • Example 6.17, a basic agent built with Spring AI core alone: a setup that places the memory advisor inside the loop and turns off the internal conversation history
  • A registration example that picks one of LuceneToolIndex, VectorToolIndex, and RegexToolIndex and plugs it into ToolSearchToolCallingAdvisor
  • Table 6.13, token usage per model before and after applying Tool Search

About the book Buy the book (Korean)

References