Spring AI 도구 호출
Spring AI 도구 호출 (Tool Calling)
도구 호출(Tool calling) — AI 모델이 애플리케이션이 정의한 함수를 호출하고 그 결과에 따라 행동하는 능력 — 은 에이전틱 AI 시스템의 핵심 구성 요소예요. 텍스트만 생성할 수 있는 모델은 챗봇이고, 정보를 발견하고 행동을 취하며 목표에 도달할 때까지 루프를 돌 수 있는 모델은 에이전트입니다.
도구는 크게 두 가지 목적으로 쓰여요:
- 정보 검색 (Information retrieval) — 데이터베이스, 웹 서비스, 파일 시스템, 검색 엔진의 데이터로 모델의 지식을 보강합니다. 예: 현재 날씨 가져오기, 고객 레코드 조회, 최신 뉴스 검색.
- 행동 취하기 (Taking action) — 시스템에서 연산을 실행합니다. 예: 이메일 보내기, 항공편 예약, 레코드 갱신, 워크플로 트리거.
"도구 호출"을 모델의 능력이라고 말하지만, 실제로 도구 호출 로직을 소유하는 건 애플리케이션이에요. 모델은 도구 호출을 요청하고 입력 인자를 제공할 수 있으며, 애플리케이션이 도구를 실행하고 결과를 반환할 책임을 집니다. 모델은 당신의 도구 뒤에 있는 API에 직접 접근하지 못해요 — 이건 중요한 보안 고려사항입니다.
아키텍처 개요
Spring AI 2.0은 도구 호출 루프를 ChatClient 의 advisor 체인의 일급 구성 요소로 만들었습니다. 흐름을 한눈에 보면:
- 도구를 정의하고 (
Defining Tools)ChatClient에 넘깁니다. ChatClient가 루프를 구동하는ToolCallingAdvisor를 자동 등록합니다.- 모델이 어떤 도구를 호출할지 결정하고,
ToolCallingManager가 실행하며, 모델이 도구 호출 없는 응답을 만들 때까지 루프가 계속됩니다. - 체인의 다른 advisor(메모리, 관측 가능성, 재시도, 커스텀 로직)가 advisor 순서라는 단일 다이얼로 루프와 결합됩니다.
Spring AI 1.x의 per-ChatModel 도구 실행 루프를 대체하는 아키텍처입니다. 저수준 용도로 ChatModel 직접 호출은 여전히 지원됩니다 — ChatModel Tool Calling 참고.
빠른 시작
@Tool 로 메서드에 애노테이션을 달아 도구를 정의합니다:
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.i18n.LocaleContextHolder;
class DateTimeTools {
@Tool(description = "Get the current date and time in the user's timezone")
String getCurrentDateTime() {
return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
}
@Tool(description = "Set a user alarm for the given time, provided in ISO-8601 format")
void setAlarm(String time) {
LocalDateTime alarmTime = LocalDateTime.parse(time, DateTimeFormatter.ISO_DATE_TIME);
System.out.println("Alarm set for " + alarmTime);
}
}
.tools() 로 도구를 ChatClient 에 넘기고 도구가 필요한 질문을 하세요:
ChatModel chatModel = ...
String response = ChatClient.create(chatModel)
.prompt("Can you set an alarm 10 minutes from now?")
.tools(new DateTimeTools())
.call()
.content();
모델은 getCurrentDateTime() 을 호출한 뒤 계산된 시간으로 setAlarm(...) 을 호출하기로 결정합니다. Spring AI가 전체 왕복을 자동으로 처리해요.
도구 정의
Spring AI는 최고 수준부터 가장 명시적인 것까지 세 가지 방식으로 도구를 정의할 수 있게 해 줍니다:
| 스타일 | 언제 쓰는가 | 섹션 |
|---|---|---|
선언적 @Tool |
메서드를 소유하고 최소한의 장황함을 원할 때 | Declarative |
MethodToolCallback |
메서드 기반 도구를 프로그래밍 방식으로 제어해야 할 때 | Programmatic: Method |
FunctionToolCallback |
Function / Supplier / Consumer 람다나 메서드 참조를 노출하려 할 때 |
Programmatic: Function |
세 방식 모두 동일한 루프를 통과하는 ToolCallback 인스턴스를 생성합니다. 스타일을 자유롭게 섞을 수 있어요.
선언적: @Tool
@Tool 로 public, 패키지-프라이빗, private, static, 인스턴스 등 어떤 메서드든 애노테이션을 답니다:
class WeatherTools {
@Tool(description = "Get the current weather for a given city")
public String getWeather(String city) {
return weatherService.fetch(city);
}
}
@Tool 애노테이션은 다음을 지원합니다:
name— 도구 이름. 기본은 메서드 이름. 모델에 제공되는 도구 집합 내에서 고유해야 합니다.description— 도구가 무엇을 하고 언제 쓰는지. 강력히 권장됩니다. 없으면 모델이 언제 호출할지에 대한 안내가 없어요.returnDirect— 결과를 모델에 다시 피드백하지 않고 호출자에게 직접 반환. Return Direct 참고.resultConverter— 사용할ToolCallResultConverter. Result Conversion 참고.
AOT 컴파일(GraalVM 네이티브 이미지)의 경우 @Tool 메서드를 포함한 클래스는 Spring 빈이어야 합니다 (예: @Component). 그렇지 않으면 @RegisterReflection(memberCategories = MemberCategory.INVOKE_DECLARED_METHODS) 로 애노테이션하세요.
개별 파라미터를 설명하려면 @ToolParam 을 추가합니다:
class WeatherTools {
@Tool(description = "Get the weather for a city at a specific time")
public String getWeather(
@ToolParam(description = "City name") String city,
@ToolParam(description = "Time in ISO-8601 format", required = false) String at) {
return weatherService.fetch(city, at);
}
}
기본적으로 모든 파라미터는 필수입니다. @ToolParam(required = false) 또는 @Nullable 로 파라미터를 선택사항으로 만듭니다. 전체 스키마 커스터마이즈 옵션은 JSON Schema 참고.
프로그래밍 방식: MethodToolCallback
동적 도구 등록 — 소스 클래스를 제어할 수 없거나 런타임에 도구 정의를 만들고 싶을 때 — 은 MethodToolCallback 을 사용합니다:
Method method = ReflectionUtils.findMethod(WeatherTools.class, "getWeather", String.class);
MethodToolCallback callback = MethodToolCallback.builder()
.toolDefinition(ToolDefinitions.builder(method)
.description("Get the current weather for a given city")
.build())
.toolMethod(method)
.toolObject(new WeatherTools())
.build();
toolObject 는 인스턴스 메서드에 필수이고 static 메서드에는 선택사항입니다. 전체 빌더 API는 도구 명세 참고 참고.
프로그래밍 방식: FunctionToolCallback
Function, Supplier, Consumer, BiFunction — 람다와 메서드 참조를 포함 — 으로 뒷받침되는 도구는 FunctionToolCallback 을 사용합니다:
FunctionToolCallback callback = FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
팩토리는 이름과 함수 참조를 받습니다. 빌더는 설명과 입력 타입(JSON 스키마 생성에 사용)을 설정해요.
ToolCallback 빈
Spring AI는 애플리케이션 컨텍스트의 ToolCallback 빈을 자동 발견하고 ToolCallbackResolver 를 통해 이름 기반 조회로 노출합니다. 도구를 한 번 @Bean 으로 정의하고 필요할 때 주입하세요:
@Configuration(proxyBeanMethods = false)
class WeatherToolsConfig {
@Bean
ToolCallback currentWeather(WeatherService weatherService) {
return FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("Get the weather in location")
.inputType(WeatherRequest.class)
.build();
}
}
빈을 주입해 ChatClient 에 넘깁니다:
@Autowired ToolCallback currentWeather;
ChatClient.create(chatModel)
.prompt("What's the weather in Copenhagen?")
.tools(currentWeather)
.call()
.content();
Spring AI 2.0은 toolNames(...) 로 Function 빈을 이름으로 해석하던 SpringBeanToolCallbackResolver 패턴을 제거했습니다. 도구는 이제 명시적인 ToolCallback 빈으로 등록해야 합니다.
ChatClient에 도구 전달
ChatClient 는 도구를 제공하는 두 가지 메서드를 노출합니다:
// Per call — tools available only for this single request
chatClient.prompt(...)
.tools(new WeatherTools(), currentWeather)
.call();
// As defaults — tools available on every request built from this client
ChatClient client = ChatClient.builder(chatModel)
.defaultTools(new WeatherTools(), currentWeather)
.build();
두 메서드 모두 이기종(heterogeneous) 입니다 — @Tool 애노테이션 POJO 인스턴스, ToolCallback 인스턴스, ToolCallbackProvider 인스턴스, 그리고 이들의 배열이나 컬렉션을 받습니다. 한 호출에서 스타일을 섞을 수 있어요.
호출별 .tools(...) 는 클라이언트의 기본값에 추가됩니다 — 대체하지 않습니다. 모델로 보내지는 최종 도구 목록은 .defaultTools(...) 와 호출 지점에 추가된 .tools(...) 의 합집합입니다.
기본 도구는 같은
ChatClient.Builder에서 빌드한 모든 요청에서 공유됩니다. 항상 사용 가능해야 하는 도구에는 유용하지만, 부주의하게 쓰면 위험할 수 있어요 — 위험 등급과 파괴적 도구는 기본값이 아니라 호출별로 추가하는 게 일반적입니다.
도구 호출 루프
ChatClient 요청에서 .call() 이나 .stream() 을 호출하면 요청이 advisor 체인을 통과합니다. DefaultChatClient 가 자동 등록하는 ToolCallingAdvisor 가 도구 실행 라이프사이클을 소유합니다:
- advisor가 등록된 모든 도구 정의를 포함해 요청을 모델에 보냅니다.
- 모델이 도구를 호출할지 결정하고 응답을 반환합니다.
- 응답에 도구 호출이 있으면 advisor는:
a. 그것들을
ToolCallingManager에 넘겨 일치하는ToolCallback을 찾아 실행합니다. b. 도구 결과를 대화 기록에 추가합니다. c. 갱신된 기록을 모델에 다시 보냅니다. - 도구 호출 없는 응답을 모델이 만들 때까지 2~3단계를 반복하고, 그 응답을 호출자에게 반환합니다.
차단(.call())과 스트리밍(.stream()) 모드 모두 완전히 지원됩니다.
ToolCallingAdvisor 는 재귀 advisor 입니다 — 루프의 각 반복에서 다운스트림 advisor 체인을 다시 진입합니다. 같은 메커니즘이 구조화 출력 검증 재시도와 다른 루프형 advisor 패턴도 구동합니다.
DefaultChatClient 는 체인에 정확히 하나의 ToolAdvisor 가 있도록 강제합니다. 두 번째를 등록하려 하면 명시적 오류로 실패해요.
전체 ToolCallingAdvisor 빌더 API와 후크 메서드, 구성 옵션은 ToolCallingAdvisor 를 참고하세요.
메모리와 도구 루프
MessageChatMemoryAdvisor 를 ToolCallingAdvisor(기본 순서 HIGHEST_PRECEDENCE + 300)에 대해 어디에 두느냐에 따라 메모리 저장소가 캡처하는 대화 컨텍스트의 양이 결정됩니다.
루프 밖 (기본)
MessageChatMemoryAdvisor 의 기본 순서는 HIGHEST_PRECEDENCE + 200 — ToolCallingAdvisor 보다 낮아서 루프 밖에 위치합니다. 메모리 advisor는:
- 루프가 시작되기 전에 기록을 한 번 로드합니다.
- 루프 완료 후 최종 사용자 메시지와 최종 assistant 메시지만 영속화합니다.
- 도구 호출 요청이나 도구 응답 메시지를 절대 보지 못합니다.
이것이 안전한 기본값이며 모든 ChatMemoryRepository 구현에서 동작합니다. Spring AI 1.x의 동작과도 일치합니다 (거기선 도구 루프가 각 ChatModel 내부에 있어 메모리가 도구 메시지를 관찰할 수 없었죠).
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // outside the loop by default
.build();
루프 안
모델이 이후 턴에서 이미 무엇을 시도했는지, 어떤 도구가 호출됐는지, 무엇을 반환했는지 추론할 수 있도록 전체 도구 기록을 주고 싶다면, ToolCallingAdvisor.DEFAULT_ORDER 보다 큰 순서를 줘서 메모리 advisor를 루프 안에 두세요:
var chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
.order(BaseAdvisor.HIGHEST_PRECEDENCE + 400) // inside (after) ToolCallingAdvisor
.build();
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(chatMemoryAdvisor)
.build();
중복 쓰기를 피하려면 메모리 advisor가 루프 안에 있을 때 ToolCallingAdvisor 의 내부 대화 기록을 비활성화해야 합니다. 자동 등록된 ToolCallingAdvisor 를 쓰면 이건 자동입니다 — DefaultChatClient 가 루프 안의 MemoryAdvisor 를 감지해 추가 구성 없이 내부 기록을 비활성화해요. ToolCallingAdvisor 를 직접 만들면 빌더에서 .disableInternalConversationHistory() 를 직접 호출하세요.
백엔드 호환성
모든 ChatMemoryRepository 가 도구 메시지를 영속화할 수 있는 건 아니에요. 저장소는 ToolResponseMessage 와 도구 호출 요청 메시지를 일반 사용자·assistant 턴과 함께 직렬화하는 방법을 알아야 하며, 대부분의 현재 구현은 후자만 모델링합니다.
2.0 기준 내장 저장소 중 전체 메시지 집합을 지원하는 것은:
InMemoryChatMemoryRepositoryRedisChatMemoryRepositoryNeo4jChatMemoryRepository
이 중 어느 것이든 루프 안에서 안전합니다.
수백 개 도구로 확장
기본 ToolCallingAdvisor 는 모든 요청에서 등록된 모든 도구 정의를 모델에 보냅니다. 소규모 도구 라이브러리라면 괜찮지만, 30개 이상이 되거나 세션이 수백 개의 도구 정의를 모을 수 있는 멀티 서버 MCP 설정에서는 컨텍스트 부풀림, 정확도 저하, 불필요한 토큰 비용이 생깁니다.
ToolSearchToolCallingAdvisor 는 점진적 도구 노출(progressive tool disclosure) 패턴을 구현하는 드롭인 대체품입니다. 세션당 전체 도구 집합을 한 번 인덱싱하고, 모델이 자연어 쿼리로 관련 도구를 검색하는 데 쓰는 내장 toolSearchTool 만 주입합니다. 발견된 도구만 이후 요청에 포함돼요.
단일 프로퍼티로 활성화합니다:
spring.ai.chat.client.tool-search-advisor.enabled=true
MCP 도구
모델 컨텍스트 프로토콜(MCP)은 AI 애플리케이션이 원격 서버의 도구, 리소스, 프롬프트를 소비하는 표준화된 방식입니다. Spring AI는 양방향으로 MCP를 통합합니다. 애플리케이션이 MCP 서버의 도구를 소비할 수도, 자신의 도구를 MCP 클라이언트에 노출할 수도 있어요.
MCP 서버 도구 소비
MCP 클라이언트 스타터를 추가하고 하나 이상의 MCP 서버 연결을 구성하세요:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
spring.ai.mcp.client.stdio.connections.my-server.command=npx
spring.ai.mcp.client.stdio.connections.my-server.args=-y,@modelcontextprotocol/server-everything
자동 구성은 구성된 모든 서버에 연결해 도구를 발견하고 SyncMcpToolCallbackProvider 빈(비동기 클라이언트 타입이면 AsyncMcpToolCallbackProvider)으로 노출합니다.
MCP 프로바이더는 고의로 ChatClient 에 자동 등록되지 않습니다 — 그들은 ToolCallbackProvider 를 구현하지만, 도구를 즉시 나열하면 시작 시 모든 연결된 MCP 서버에 네트워크 왕복을 강제하기 때문이에요. 프로바이더를 주입해 명시적으로 배선하세요:
@Autowired SyncMcpToolCallbackProvider mcpTools;
// Once, as default tools for every request
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(mcpTools)
.build();
// Or per call
chatClient.prompt()
.user("Search the web for the latest Spring AI release notes")
.tools(mcpTools)
.call()
.content();
Spring 도구를 MCP 서버로 노출
반대 방향 — Spring 빈을 MCP 도구로 노출 — 은 @Tool 대신 @McpTool 을 쓰고 MCP 서버 스타터를 추가하면 됩니다:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
@Component
public class WeatherTools {
@McpTool(description = "Get the current weather for a given city")
public String getWeather(
@McpToolParam(description = "City name") String city) {
return weatherService.fetch(city);
}
}
로컬과 MCP 도구 결합
로컬 @Tool 메서드와 원격 MCP 도구는 같은 ToolCallback 인터페이스를 공유합니다 — 모델과 ToolCallingAdvisor 는 둘을 구분하지 않아요. .tools(...) 와 .defaultTools(...) 는 한 호출에서 두 타입 모두 받으므로 자유롭게 섞을 수 있습니다:
chatClient.prompt()
.tools(new LocalTools(), mcpTools)
.call()
.content();
하이브리드 설정에서 알아둘 점:
- 이름 충돌은 MCP 쪽에서만 처리됩니다.
DefaultMcpToolNamePrefixGenerator가 MCP 서버 간 중복 이름에 접두사를 붙이지만, 로컬@Tool메서드는 알지 못해요. 로컬 도구와 원격 MCP 도구가 이름을 공유하면 직접 하나를 이름 바꾸거나,McpToolFilter로 원격 것을 제거해야 합니다. - 노출되는 것을 제한하세요. MCP 도구는 표면을 완전히 제어할 수 없는 외부 소스에서 옵니다.
McpToolFilter빈으로 서버 정체성, 도구 이름, 설명을 기준으로 어떤 도구가 네임스페이스에 들어갈지 고를 수 있어요 — 수다스럽거나 신뢰할 수 없는 MCP 서버의 폭발 반경을 제한하는 데 유용합니다.
도구 인자 보강 (Tool Argument Augmentation)
Spring AI는 추가 인자로 도구 입력 스키마를 동적으로 보강하는 유틸리티를 제공합니다. 이를 통해 기반 도구 구현을 수정하지 않고도 추론, 신뢰도, 메타데이터 같은 추가 정보를 모델에서 캡처할 수 있어요. 모델은 보강된 스키마를 보고 추가 필드를 채우며, 코드는 컨슈머로 그것을 받고, 원래 도구는 예상 인자만 — 변경 없이 — 받습니다.
대표적인 사용 사례:
- 내부 사고/추론 — 도구 실행 전 모델의 단계별 추론 캡처.
- 메모리 강화 — 장기 메모리에 저장할 통찰 추출.
- 분석/추적 — 메타데이터, 사용자 의도, 사용 패턴 수집.
- 멀티 에이전트 조정 — 에이전트 식별자나 조정 신호 전달.
빠른 시작
보강된 인자를 Java record로 정의합니다:
public record AgentThinking(
@ToolParam(description = "Your reasoning for calling this tool", required = true)
String innerThought,
@ToolParam(description = "Confidence level (low, medium, high)", required = false)
String confidence
) {}
도구를 AugmentedToolCallbackProvider 로 감쌉니다:
AugmentedToolCallbackProvider<AgentThinking> provider = AugmentedToolCallbackProvider
.<AgentThinking>builder()
.toolObject(new WeatherTools()) // wrap the original tools
.argumentType(AgentThinking.class) // augmentation schema type
.argumentConsumer(event -> { // optional consumer of augmented content
AgentThinking thinking = event.arguments();
log.info("Tool: {} | Reasoning: {}", event.toolDefinition().name(), thinking.innerThought());
})
.removeExtraArgumentsAfterProcessing(true)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultTools(provider)
.build();
LLM은 추가 필드가 있는 보강된 스키마를 봅니다. 컨슈머는 AgentThinking record를 받고, 원래 도구는 예상 인자만 받아요.
핵심 구성 요소
AugmentedToolCallbackProvider<T>— 도구 객체나 프로바이더를 감싸 지정된 record 타입으로 모든 도구를 보강합니다.AugmentedToolCallback<T>— 개별ToolCallback인스턴스를 감쌉니다.AugmentedArgumentEvent<T>— 컨슈머용toolDefinition(),rawInput(),arguments()를 포함합니다.ToolInputSchemaAugmenter— 스키마 조작용 저수준 유틸리티.
removeExtraArgumentsAfterProcessing 옵션(기본 true)은 보강된 인자를 원래 도구에 넘길지 제어합니다. 도구가 추가 필드를 무시할 수 있고 입력에 보존하고 싶다면 false 로 설정하세요.
사용자 제어 도구 실행
자동 등록된 루프는 대부분의 경우를 다루지만, 어떤 시나리오는 각 반복을 직접 소유해야 합니다:
- 도구 실행을 외부 승인 단계에서 게이트하기.
- 중간 진행을 SSE나 WebSocket 엔드포인트로 전달.
- 턴 사이에 조건부 로직 적용.
- 부수 채널 신호로 루프 중지.
요청에 AdvisorParams.toolCallingAdvisorAutoRegister(false) 를 설정해 호출별로 옵트아웃하세요. ChatResponse 에서 도구 호출을 감지하고 ToolCallingManager 로 실행하는 책임을 직접 지게 됩니다:
ChatClient chatClient = ...
ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
ToolCallback[] tools = ToolCallbacks.from(new WeatherTools());
ChatOptions chatOptions = ToolCallingChatOptions.builder().toolCallbacks(tools).build();
String question = "What is the weather in Amsterdam and Paris?";
// ToolCallingAdvisor disabled — no tool loop runs automatically
ChatClientResponse response = chatClient.prompt()
.user(question)
.options(chatOptions)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.chatClientResponse();
Prompt prompt = new Prompt(List.of(new UserMessage(question)), chatOptions);
// Drive the loop yourself — each iteration is observable and interruptible
while (response.chatResponse() != null && response.chatResponse().hasToolCalls()) {
ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response.chatResponse());
prompt = new Prompt(result.conversationHistory(), chatOptions);
response = chatClient.prompt()
.messages(result.conversationHistory())
.options(chatOptions)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.chatClientResponse();
}
같은 패턴이 스트리밍 API에도 적용됩니다. 각 반복의 청크 Flux 를 ChatClientMessageAggregator 로 집계하면서 구독자에게 전달할 수 있어요. 전체 스트리밍 변형은 User-Controlled Streaming 참고.
자동 등록을 전역으로 끄려면:
spring.ai.chat.client.tool-calling.enabled=false
프레임워크 밖에서 루프를 직접 구동하면 관측 가능성, advisor 구성, 단일 ToolAdvisor 불변식도 우회합니다. 대부분의 요구(UI로의 스트리밍 중간 진행 포함)에는 전체 옵트아웃보다 커스텀 advisor를 루프 안에 두는 게 더 간단하고 구성 가능해요.
루프 확장: 커스텀 ToolAdvisor
도구 루프는 블랙박스가 아닙니다. ToolCallingAdvisor 는 잘 정의된 지점에서 후크 메서드를 노출하고, DefaultChatClient 의 자동 구성은 커스텀 구현을 투명하게 받아들입니다. ToolSearchToolCallingAdvisor 가 점진적 도구 노출을 구현할 때 쓰는 것과 동일한 확장 지점이에요.
ToolAdvisor 는 마커 인터페이스입니다. 커스텀 도구 호출 advisor는 DefaultChatClient 가 그것을 인식하고, 단일 advisor 제약을 강제하며, 기본 대신 등록하도록 반드시 구현해야 합니다.
후크 메서드
기본 ToolCallingAdvisor 는 네 쌍의 protected 후크 메서드(call과 stream 경로 각각 하나)를 노출합니다:
| 후크 | 언제 발화 |
|---|---|
doInitializeLoop / doInitializeLoopStream |
첫 반복 전에 한 번 |
doBeforeCall / doBeforeStream |
각 반복 전 |
doAfterCall / doAfterStream |
각 반복 후 |
doFinalizeLoop / doFinalizeLoopStream |
루프 종료 후 한 번 |
ToolSearchToolCallingAdvisor 는 doInitializeLoop 로 도구 집합을 인덱싱하고 시스템 메시지를 보강하며, doBeforeCall 로 지금까지 발견된 도구만 주입합니다. 커스텀 서브클래스도 같은 패턴을 따릅니다.
커스텀 루프의 대표적인 사례:
- 승인 게이트 — 파괴적 도구 실행 전에 멈추고 사람의 확인을 기다립니다.
- 관측 가능성 — 각 도구 호출에 대한 구조화 이벤트를 부수 채널로 발행.
- 예산 강제 — 세션당 토큰이나 LLM 호출을 세고 한도를 초과하면 중단.
- 커스텀 도구 해석 — 시작 시 사용할 수 없는 동적 소스에서 도구 해석.
자동 구성 통합
커스텀 ToolAdvisor 구현은 수동 ChatClient 배선 없이 자동 구성 시스템에 꽂힙니다. 확장 지점은 ToolCallingAdvisor.Builder<?> 빈이에요.
ChatClientAutoConfiguration 은 @ConditionalOnMissingBean 으로 가드를 건 기본 ToolCallingAdvisor.Builder<?> 빈을 선언합니다. 기본형 ToolCallingAdvisor.Builder<?> 로 타이핑된 자신의 빈을 ChatClientAutoConfiguration 보다 먼저 실행되는 자동 구성에 등록해 교체하세요:
@AutoConfiguration(beforeName = "org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration")
@ConditionalOnProperty(prefix = "my.advisor", name = "enabled", havingValue = "true")
public class MyToolAdvisorAutoConfiguration {
@Bean
@ConditionalOnMissingBean
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(ToolCallingManager toolCallingManager) {
return MyCustomToolCallingAdvisor.builder()
.toolCallingManager(toolCallingManager);
}
}
전체 ToolCallingAdvisor 빌더 API와 후크 시그니처는 ToolCallingAdvisor 를 참고하세요.
도구 명세 레퍼런스
ToolCallback
ToolCallback 인터페이스는 도구를 모델링합니다 — 모델이 보는 정의와 모델이 호출할 때 발동되는 실행 로직을 담아요.
public interface ToolCallback {
/** Definition used by the AI model to decide when and how to call the tool. */
ToolDefinition getToolDefinition();
/** Metadata controlling how the tool is handled (e.g. return-direct). */
ToolMetadata getToolMetadata();
/** Execute with the given JSON input and return the result. */
String call(String toolInput);
/** Execute with the given input and tool context. */
String call(String toolInput, ToolContext toolContext);
}
Spring AI는 MethodToolCallback 과 FunctionToolCallback 을 내장 구현으로 제공합니다. 원격 도구 소스를 프록시해야 할 때(예: MCP 통합)처럼 완전한 제어가 필요하면 ToolCallback 을 직접 구현하세요.
ToolDefinition
ToolDefinition 은 모델이 보는 계약입니다: 이름, 설명, 입력 스키마.
public interface ToolDefinition {
/** Unique tool name within the tool set provided to the model. */
String name();
/** Description used by the model to decide when to call the tool. */
String description();
/** JSON schema of the tool's input parameters. */
String inputSchema();
}
ToolDefinition.builder() 로 만듭니다:
ToolDefinition toolDefinition = ToolDefinition.builder()
.name("currentWeather")
.description("Get the weather in location")
.inputSchema("""
{
"type": "object",
"properties": {
"location": { "type": "string" },
"unit": { "type": "string", "enum": ["C", "F"] }
},
"required": ["location", "unit"]
}
""")
.build();
메서드 기반 도구의 경우 ToolDefinitions.from(method) 가 정의를 자동 생성합니다. 클래스가 @Tool 로 애노테이션되어 있으면 그 이름과 설명이 메서드명 기본값을 덮어써요.
JSON 스키마
입력 스키마는 모델에게 도구를 어떻게 호출할지 알려줍니다. Spring AI의 JsonSchemaGenerator 는 메서드나 함수의 파라미터 목록에서 스키마를 생성하며 여러 커스터마이즈 애노테이션을 지원합니다.
파라미터 설명 — 다음 중 아무거나 사용합니다 (Spring AI 애노테이션이 최우선순위):
@ToolParam(description = "...")— Spring AI@JsonClassDescription(description = "...")— Jackson@JsonPropertyDescription(description = "...")— Jackson@Schema(description = "...")— Swagger
class CustomerTools {
@Tool(description = "Update customer information")
void updateCustomerInfo(
Long id,
String name,
@ToolParam(description = "Email address, RFC 5322 format", required = false) String email) {
// ...
}
}
필수 vs 선택 — 기본적으로 모든 파라미터는 필수입니다. 파라미터를 선택사항으로 만들려면(우선순위 순):
@ToolParam(required = false)— Spring AI@JsonProperty(required = false)— Jackson@Schema(required = false)— Swagger@Nullable— Spring Framework / JSpecify
올바른 필수 상태를 정의하는 것은 환각을 줄이는 데 중요합니다. 필수로 표시된 파라미터는 모델이 값을 제공해야 하며, 선택 파라미터는 생략할 수 있어요. 파라미터를 필수로 표시했는데 모델이 그 값을 정할 방법이 없다면 모델은 값을 지어냅니다.
도구 컨텍스트
ToolContext 로 비모델 데이터(테넌트 ID, 사용자 ID, 요청 스코프)를 도구 메서드에 전달할 수 있습니다. 그 데이터는 모델로 전송되지 않습니다 — 순전히 도구 내부용이에요.
채팅 요청은 도구 정의와 도구 컨텍스트를 함께 전달하며, Spring AI는 도구 정의만 모델에 보냅니다 (1). 모델은 도구 호출을 결정하고 요청을 Spring AI에 반환하며 (2), 이게 일치하는 도구로 디스패치됩니다 (3). 도구 컨텍스트는 호출 시점에 도구에 직접 전달되어 (3") 모델을 통과하지 않고 도구에 도달합니다. 도구는 결과를 Spring AI에 반환하고 (4), Spring AI는 그 결과를 모델에 보내며 (5), 모델의 최종 답이 채팅 응답으로 반환됩니다 (6).
아래 예시에서 .toolContext() 에 넘긴 Map.of("tenantId", "acme") 가 (1)단계의 도구 컨텍스트이고, getCustomerInfo 는 (3")단계의 ToolContext 파라미터로 그것을 읽습니다 — tenantId 가 모델에 노출되지 않고 도구에 도달해요.
class CustomerTools {
@Tool(description = "Retrieve customer information")
Customer getCustomerInfo(Long id, ToolContext toolContext) {
return customerRepository.findById(id, toolContext.getContext().get("tenantId"));
}
}
String response = ChatClient.create(chatModel)
.prompt("Tell me more about the customer with ID 42")
.tools(new CustomerTools())
.toolContext(Map.of("tenantId", "acme"))
.call()
.content();
toolContext 를 기본값과 호출 지점 양쪽에 설정하면 두 map이 병합됩니다. 런타임 값이 기본값보다 우선해요.
Return Direct
기본적으로 도구 호출 결과는 모델로 다시 보내져 대화를 계속합니다. returnDirect = true 면 결과가 모델을 우회해 호출자에게 직접 반환됩니다 — 도구 출력이 최종 답일 때(예: RAG 검색)와 추가 왕복이 가치 없이 지연만 더할 때 유용해요.
선언적 도구의 경우:
@Tool(description = "Retrieve customer information", returnDirect = true)
Customer getCustomerInfo(Long id) { ... }
프로그래밍 방식 도구는 ToolMetadata 로 returnDirect 를 설정합니다:
ToolMetadata toolMetadata = ToolMetadata.builder()
.returnDirect(true)
.build();
모델이 한 라운드에서 여러 도구 호출을 요청하면 returnDirect 는 호출된 도구 전부가 returnDirect = true 일 때만 존중됩니다. 그렇지 않으면 결과가 모델로 다시 보내져요.
결과 변환
도구 결과는 ToolCallResultConverter 에 의해 String(모델이 기대하는 형식)으로 변환됩니다:
@FunctionalInterface
public interface ToolCallResultConverter {
String convert(Object result, Type returnType);
}
기본 DefaultToolCallResultConverter 는 Jackson으로 결과를 직렬화합니다. @Tool 애노테이션(resultConverter) 또는 ToolMetadata 로 커스텀 컨버터를 구성하세요.
메서드 도구 한계
MethodToolCallback 사용 시 메서드 파라미터나 반환 값으로 지원되지 않는 타입이 있습니다:
Optional— 대신@Nullable또는@ToolParam(required = false)사용.- 비동기 타입 (
CompletableFuture,Future). - 리액티브 타입 (
Flow,Mono,Flux). - 함수형 타입 (
Function,Supplier,Consumer).
이런 타입은 모델로 직렬화할 수 없고 모델이 해석할 수 있는 스키마 표현도 없어요.
예외 처리
도구가 예외를 던지면 ToolExecutionException 으로 감싸져 ToolExecutionExceptionProcessor 에 전달됩니다:
@FunctionalInterface
public interface ToolExecutionExceptionProcessor {
String process(ToolExecutionException exception);
}
프로세서는 두 선택지가 있습니다: 프레임워크가 모델에 다시 피드백하는 오류 메시지를 만들어(모델이 회복하거나 사과하도록), 또는 호출자가 처리하도록 예외를 다시 던집니다.
기본 DefaultToolExecutionExceptionProcessor:
RuntimeException의 메시지를 모델로 다시 보냅니다.- 체크 예외와
Error는 항상 다시 던집니다.
프로퍼티로 구성:
| 프로퍼티 | 설명 | 기본값 |
|---|---|---|
spring.ai.tools.throw-exception-on-error |
true 면 모든 도구 오류를 호출자가 처리하도록 던짐. false 면 오류를 메시지로 모델에 다시 보냄. |
false |
또는 빈을 교체:
@Bean
ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return new DefaultToolExecutionExceptionProcessor(true);
}
자신만의 ToolCallback 을 구현한다면 실행 실패 시 call() 에서 ToolExecutionException 을 던지세요 — 프로세서가 기대하는 형태입니다.
도구 호출 한도
DefaultToolCallingManager 는 턴 내에서 도구가 호출될 수 있는 횟수를 제한해 폭주 루프를 방지합니다:
ToolCallingManager toolCallingManager = ToolCallingManager.builder()
.maxCallsPerTool(40) // per-tool default
.maxCallsPerTool("search", 10) // override for one tool
.excludeToolFromLimit("getCurrentWeather") // no limit for this tool
.maxTotalToolCalls(150) // cap across all tools combined
.onLimitExceeded(ToolCallLimitBehavior.THROW) // or RETURN_ERROR_RESPONSE
.build();
이 기본값들은(DefaultToolCallingManager.DEFAULT_MAX_CALLS_PER_TOOL / DEFAULT_MAX_TOTAL_TOOL_CALLS) 이 메서드를 호출하지 않아도 적용됩니다. 한도를 완전히 끄려면 unlimitedCallsPerTool() / unlimitedTotalToolCalls() 를 쓰세요.
카운팅 — 카운트는 현재 턴에만 적용됩니다. 마지막 UserMessage 이후의 ToolResponseMessage 항목만 집계되므로, 루프 밖 ChatMemory advisor가 재생하는 기록은 새 턴의 카운트를 부풀리지 않아요. 매니저 자체에는 캐시되지 않습니다 — 카운트는 매 호출마다 prompt.getInstructions() 에서 다시 계산되므로 단일 ToolCallingManager 인스턴스가 동시 요청 간 공유돼도 안전합니다. 이는 ToolCallingAdvisor 의 내부 대화 기록을 비활성화해도(루프 안 참고) 한도 카운팅에는 영향이 없다는 뜻이기도 해요 — 체인의 나머지에 무엇이 잘렸든 항상 완전한 턴 기록을 봅니다.
한도 초과 시 동작 — DefaultToolCallingManager 는 ToolCallLimitExceededException 을 던집니다. 이 예외는 도구 이름(총 한도 위반이면 null), 걸린 한도, 현재 배치에서 이미 실행된 부분 ToolExecutionResult 를 담아 완료된 작업이 버려지지 않게 해요. ToolCallingAdvisor 는 그것을 잡아 한도 위반을 최종 응답(단일 Generation, ex.buildGeneration() 경유)으로 반환합니다 — 도구 호출당 하나씩이 아니라요. 그래서 병렬 배치 앞부분의 성공한 호출이 첫 결과만 읽는 호출자에게 위반을 숨기지 못합니다. 종료 사유는 ToolCallLimitExceededException.FINISH_REASON 이고, 배치에서 이미 성공한 도구 호출은 버려지는 대신 METADATA_PARTIAL_TOOL_RESPONSES 메타데이터 키 아래 보존됩니다.
onLimitExceeded(ToolCallLimitBehavior.RETURN_ERROR_RESPONSE) 로 설정하면 매니저는 호출을 건너뛰고 오류 ToolResponse 를 합성합니다 — ToolExecutionException 에 쓰는 것과 같은 메커니즘이에요. 그래서 모델은 한도에 도달했다는 알림을 받고 대화가 종료 대신 계속됩니다. ToolCallingAdvisor 를 거치지 않고 ToolCallingManager.executeToolCalls(...) 를 직접 호출한다면 이것도 선택지입니다.
프로퍼티로 구성:
| 프로퍼티 | 설명 | 기본값 |
|---|---|---|
spring.ai.tools.limits.max-calls-per-tool-default |
턴당 한 도구 최대 호출 수. -1 은 비활성화. |
40 |
spring.ai.tools.limits.max-calls-per-tool.<name> |
도구별 오버라이드; -1 은 이 도구 면제. |
— |
spring.ai.tools.limits.excluded-tools |
도구별 한도에서 면제되는 도구 이름. | — |
spring.ai.tools.limits.max-total-tool-calls |
턴당 모든 도구의 최대 호출 수. -1 은 비활성화. |
150 |
spring.ai.tools.limits.on-limit-exceeded |
THROW 또는 RETURN_ERROR_RESPONSE. |
THROW |
도구 해석 (Tool Resolution)
대부분은 도구를 .tools(...) 와 .defaultTools(...) 로 명시적으로 전달합니다. 런타임 이름 기반 해석 같은 더 동적인 시나리오에는 Spring AI가 ToolCallbackResolver 를 사용합니다:
public interface ToolCallbackResolver {
@Nullable
ToolCallback resolve(String toolName);
}
기본적으로 StaticToolCallbackResolver 는 애플리케이션 컨텍스트의 모든 ToolCallback 빈과 ToolCallbackProvider 빈이 만든 도구로 자동 구성됩니다 (MCP 프로바이더는 지연 조회를 피하려 제외됩니다).
다른 전략(데이터베이스 기반, 클래스패스 스캔, 원격)이 필요하면 커스텀 빈으로 교체하세요:
@Bean
ToolCallbackResolver toolCallbackResolver(List<ToolCallback> toolCallbacks) {
return new StaticToolCallbackResolver(toolCallbacks);
}
해석기는 ToolCallingManager가 advisor 제어 실행과 사용자 제어 실행을 모두 지원하는 데 내부적으로 사용됩니다.
요청에 붙지 않았지만 해석기가 아는 도구를 이름으로 해석해 실행하는 폴백 동작을 켜려면:
spring.ai.tools.resolution.fallback.enabled=true
| 프로퍼티 | 설명 | 기본값 |
|---|---|---|
spring.ai.tools.resolution.fallback.enabled |
true 면 요청의 도구 콜백에 없는 요청된 도구를 ToolCallbackResolver 로 이름으로 해석해 실행할 수 있음. false 면 요청에 붙은 도구만 실행 가능. |
false |
ToolCallingManager 를 직접 만들 때는 빌더의 .resolutionFallbackEnabled(true) (기본 false)로 같은 스위치를 쓸 수 있습니다.
해석 폴백을 켜면 해석기가 노출하는 모든 도구가 모델이 이름을 대면 실행 가능해집니다 — 요청이 무엇을 붙였든 간에, 위험 등급과 파괴적 도구를 포함해서요. 해석기가 노출하는 도구를 완전히 제어할 때만 활성화하세요.
관측 가능성 (Observability)
Spring AI는 spring.ai.tool 이름으로 도구 호출에 대한 Micrometer observation을 발행합니다. 각 observation은 다음을 캡처합니다:
- 도구 이름과 정의 메타데이터.
- 실행 지속 시간.
- 추적 컨텍스트 (
Tracer사용 가능 시).
도구 호출 인자와 결과는 선택적으로 span 속성으로 내보낼 수 있는데, 민감성 때문에 기본적으로 비활성화돼 있습니다.
로깅
모든 주요 연산은 DEBUG 레벨로 로그됩니다. 활성화하려면:
logging.level.org.springframework.ai=DEBUG
ChatModel 도구 호출
ChatClient 와 ToolCallingAdvisor 없이 ChatModel 을 직접 구동하고 싶다면, Spring AI는 저수준 도구 호출 경로를 지원합니다. 요청/응답 주기를 완전히 제어하고 advisor 체인의 구성 기능이 필요 없을 때 적합한 선택이에요. ChatModel Tool Calling 참고.
Spring AI 1.x의 per-ChatModel 내부 도구 실행 루프는 제거되었습니다. 도구와 함께 ChatModel 을 호출하면 도구 정의가 모델로 보내지고 모델 응답이 반환되지만, 응답의 도구 호출은 자동으로 실행되지 않습니다. 루프를 직접 구동하거나, ChatClient 를 사용하세요 (도구 호출 advisor를 자동 등록해 주니까요).