Spring AI ToolCallingAdvisor
Spring AI ToolCallingAdvisor
ToolCallingAdvisor 는 Spring AI 2.0에서 도구 실행 라이프사이클을 소유하는 재귀 advisor예요. DefaultChatClient 가 자동 등록하며, 모델이 도구 호출 없는 응답을 만들 때까지 요청/응답 루프를 구동합니다.
이 페이지는 빌더 API, 구성 옵션, 후크 메서드, 확장 패턴에 대한 레퍼런스입니다. 루프의 개념적 개요는 The Tool Calling Loop 를, 더 넓은 재귀 advisor 패턴은 Recursive Advisors 를 참고하세요.
개요
ToolCallingAdvisor 는 CallAdvisor 와 StreamAdvisor 를 모두 구현하고, ToolAdvisor 마커 인터페이스도 구현합니다. 마커 인터페이스는 DefaultChatClient 가 체인에 정확히 하나의 도구 advisor가 있도록 강제하는 데 쓰여요.
기본 ToolCallingAdvisor.DEFAULT_ORDER 는 Ordered.HIGHEST_PRECEDENCE + 300 입니다. 기본 MessageChatMemoryAdvisor 순서(HIGHEST_PRECEDENCE + 200)보다 높아서 메모리 advisor를 기본적으로 도구 루프 밖에 둡니다.
빌더
ToolCallingAdvisor.builder() 로 생성합니다:
var toolCallingAdvisor = ToolCallingAdvisor.builder()
.toolCallingManager(toolCallingManager)
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
.build();
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(toolCallingAdvisor)
.build();
대부분의 애플리케이션은 ToolCallingAdvisor 를 직접 만들지 않아요 — DefaultChatClient 가 합리적인 기본값으로 하나를 자동 등록합니다. 비기본 설정이 필요하거나 커스텀 서브클래스로 교체할 때만 직접 만드세요.
빌더 옵션
| 옵션 | 설명 | 기본값 |
|---|---|---|
toolCallingManager(ToolCallingManager) |
도구 호출 실행에 쓰이는 ToolCallingManager 인스턴스. |
자동 빌드 인스턴스 |
toolExecutionEligibilityChecker(ToolExecutionEligibilityChecker) |
모델 응답이 또 다른 도구 호출 반복을 트리거할지 결정하는 술어. 기본값은 chatResponse.hasToolCalls() 를 검사. 프로바이더 특화 중지 사유 로직(예: 도구 호출 존재 외에 finish-reason 필드 검사)을 적용하려면 오버라이드. |
chatResponse -> chatResponse != null && chatResponse.hasToolCalls() |
advisorOrder(int) |
체인에서 advisor가 적용되는 순서. BaseAdvisor.HIGHEST_PRECEDENCE 와 BaseAdvisor.LOWEST_PRECEDENCE 사이여야 함. 어떤 다른 advisor가 루프 안/밖에서 실행될지 결정. |
HIGHEST_PRECEDENCE + 300 |
conversationHistoryEnabled(boolean) |
advisor가 반복 간 대화 기록을 내부적으로 유지할지. true(기본)면 루프 안의 각 LLM 호출이 이전 도구 호출과 응답의 전체 기록을 advisor가 관리하며 받음. false 면 advisor가 최신 메시지만 전달 — 루프 안 MemoryAdvisor 가 기록 관리를 맡을 때 유용. |
true |
disableInternalConversationHistory() |
conversationHistoryEnabled(false) 의 단축. |
— |
ToolExecutionEligibilityChecker
ToolExecutionEligibilityChecker 는 함수형 인터페이스입니다:
@FunctionalInterface
public interface ToolExecutionEligibilityChecker {
boolean isToolCallResponse(@Nullable ChatResponse chatResponse);
}
기본 체커는 응답에 도구 호출이 있을 때마다 다음 반복을 발화합니다. 프로바이더 특화 동작을 위해 오버라이드하세요:
ToolExecutionEligibilityChecker strictChecker = response ->
response != null
&& response.hasToolCalls()
&& "tool_calls".equals(response.getMetadata().getFinishReason());
var advisor = ToolCallingAdvisor.builder()
.toolExecutionEligibilityChecker(strictChecker)
.build();
대화 기록 동작
기본적으로 ToolCallingAdvisor 는 완전한 대화 기록(사용자 메시지, 모델 응답, 도구 호출 요청, 도구 응답)을 루프 안에 유지합니다. 이후 각 반복은 모델에 완전한 기록을 보냅니다.
메모리가 루프 밖에 있을 때 이게 올바른 동작이에요. 외부 메모리 advisor는 최종 사용자/assistant 교환만 볼 뿐, 반복별 기록은 ToolCallingAdvisor 의 사적인 문제니까요.
다음 경우에 disableInternalConversationHistory() 를 설정하세요:
MemoryAdvisor를 루프 안에 두는 경우 (반복별 기록을 스스로 관리).- 루프를 직접 구동하고 최신 메시지만 전달하고 싶을 때.
var toolCallingAdvisor = ToolCallingAdvisor.builder()
.disableInternalConversationHistory() // memory advisor inside the loop handles history
.advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
.build();
var chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
.order(BaseAdvisor.HIGHEST_PRECEDENCE + 400) // inside the loop
.build();
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(chatMemoryAdvisor, toolCallingAdvisor)
.build();
자동 등록된 ToolCallingAdvisor 에서는 DefaultChatClient 가 루프 안에 놓인 어떤 MemoryAdvisor 든 감지해 내부 기록을 자동으로 비활성화합니다 — 직접 disableInternalConversationHistory() 를 호출할 필요가 없어요. 수동 호출은 ToolCallingAdvisor 를 직접 만들 때만 필요합니다.
후크 메서드
ToolCallingAdvisor 는 루프의 잘 정의된 지점에 protected 후크 메서드를 노출합니다. 서브클래스는 루프 자체를 재구현하지 않고 이런 후크를 오버라이드해 동작을 커스터마이즈합니다.
두 개의 평행 패밀리가 있습니다 — call(차단) 경로용과 stream(리액티브) 경로용. 커스텀 서브클래스는 두 모드를 모두 처리하려면 관련 쌍을 오버라이드해야 해요.
Call 경로 후크
protected ChatClientRequest doInitializeLoop(
ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain);
protected ChatClientRequest doBeforeCall(
ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain);
protected ChatClientResponse doAfterCall(
ChatClientResponse chatClientResponse, CallAdvisorChain callAdvisorChain);
protected ChatClientResponse doFinalizeLoop(
ChatClientResponse chatClientResponse, CallAdvisorChain callAdvisorChain);
protected List<Message> doGetNextInstructionsForToolCall(
ChatClientRequest chatClientRequest,
ChatClientResponse chatClientResponse,
ToolExecutionResult toolExecutionResult);
| 후크 | 언제, 무엇을 위해 |
|---|---|
doInitializeLoop |
첫 반복 전에 한 번. 세션 스코프 상태(인덱스, 캐시, 보강된 프롬프트) 설정용. |
doBeforeCall |
각 반복 전. 도구 주입/제거, 옵션 변경, 반복별 컨텍스트 추가에 사용. 반환된 요청이 모델로 보내지는 것. |
doAfterCall |
각 반복의 모델 응답 후. 반복별 observation 기록이나 루프가 계속할지 결정 전 응답 변환에 사용. |
doFinalizeLoop |
루프 종료 후 한 번. 집계 메트릭 발행, 세션 상태 정리, 최종 변환 부착에 사용. |
doGetNextInstructionsForToolCall |
다음 반복이 모델로 보낼 메시지를 결정. 기본 동작은 conversationHistoryEnabled 에 의존: true 면 전체 대화 기록 반환, false 면 시스템 메시지와 최신 도구 응답만 반환. 이는 체인의 나머지로 전달되는 것에만 영향 — 도구 호출 한도는 이 설정과 무관하게 항상 현재 턴의 완전한 기록에 대해 평가. |
Stream 경로 후크
protected ChatClientRequest doInitializeLoopStream(
ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain);
protected ChatClientRequest doBeforeStream(
ChatClientRequest chatClientRequest, StreamAdvisorChain streamAdvisorChain);
protected ChatClientResponse doAfterStream(
ChatClientResponse chatClientResponse, StreamAdvisorChain streamAdvisorChain);
protected Flux<ChatClientResponse> doFinalizeLoopStream(
Flux<ChatClientResponse> chatClientResponseFlux, StreamAdvisorChain streamAdvisorChain);
protected List<Message> doGetNextInstructionsForToolCallStream(
ChatClientRequest chatClientRequest,
ChatClientResponse chatClientResponse,
ToolExecutionResult toolExecutionResult);
stream 변형은 call 변형과 같은 의미를 따릅니다. doAfterStream 은 반복의 청크에 걸쳐 집계된 응답에 동작하고, doFinalizeLoopStream 은 전체 출력 Flux 를 변환할 수 있어요.
서브클래스 예시
ToolSearchToolCallingAdvisor 는 ToolCallingAdvisor 서브클래스의 구체적 예입니다. doInitializeLoop 와 doInitializeLoopStream 을 오버라이드해 세션 시작 시 도구 집합을 인덱싱하고 시스템 메시지를 보강하며, doBeforeCall 와 doBeforeStream 을 오버라이드해 각 반복마다 지금까지 발견된 도구만 주입합니다. 루프의 나머지는 기본 클래스에서 상속받아요.
public class MyAuditingToolCallingAdvisor extends ToolCallingAdvisor {
private final AuditService audit;
@Override
protected ChatClientResponse doAfterCall(
ChatClientResponse response, CallAdvisorChain chain) {
var toolCalls = response.chatResponse().getResult().getOutput().getToolCalls();
for (var call : toolCalls) {
audit.recordIntent(call.name(), call.arguments());
}
return response;
}
public static Builder<?> builder() {
return new Builder<>();
}
public static class Builder<T extends Builder<T>> extends ToolCallingAdvisor.Builder<T> {
private AuditService audit;
public T audit(AuditService audit) {
this.audit = audit;
return self();
}
@Override
public MyAuditingToolCallingAdvisor build() {
// Use the inherited fields from ToolCallingAdvisor.Builder via the protected getters.
return new MyAuditingToolCallingAdvisor(
getToolCallingManager(),
getToolExecutionEligibilityChecker(),
getAdvisorOrder(),
isConversationHistoryEnabled(),
audit);
}
}
}
자기 참조 제네릭 패턴(Builder<T extends Builder<T>>)은 서브클래스 빌더가 서브클래스 타입을 잃지 않고 상속된 setter를 연결하게 해 줍니다. DefaultChatClient 가 호출별 조정에 쓰는 복사 의미를 지원해야 한다면 newCopy() 와 copy() 를 오버라이드하세요.
단일 ToolAdvisor 불변식
ToolAdvisor 는 마커 인터페이스입니다. DefaultChatClient 는 이걸로 어떤 advisor 체인에도 정확히 하나의 도구 advisor만 있도록 강제합니다. 이 불변식은 두 개의 도구 호출 advisor를 쌓는 미묘한 이중 실행 버그를 방지해요.
실용적으로는:
- 자동 등록된
ToolCallingAdvisor가 그 하나로 간주됩니다. - 두 번째
ToolAdvisor구현 advisor(예: 커스텀 서브클래스)를 등록하면DefaultChatClient는 기본 등록을 건너뛰고 당신의 것을 사용합니다 — 불변식이 보존돼요. - 동시에 두 개의 커스텀
ToolAdvisor구현 advisor를 등록하면 체인 구성이 명확한 오류로 빠르게 실패합니다.
Spring Boot 애플리케이션에서 기본을 교체하려면 자동 구성을 통해 서브클래스를 등록하세요.
사용자 제어 스트리밍
사용자 제어 도구 실행은 블로킹 변형을 다루고, 이 섹션은 스트리밍을 다룹니다.
.stream() 으로 루프를 직접 구동할 때 각 반복은 청크 Flux 를 만듭니다. ChatClientMessageAggregator 로 청크를 집계해 도구 호출을 감지하면서, 원시 스트림을 다운스트림 구독자(예: SSE 엔드포인트)에 계속 전달합니다:
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?";
Prompt prompt = new Prompt(List.of(new UserMessage(question)), chatOptions);
AtomicReference<ChatClientResponse> ref = new AtomicReference<>();
new ChatClientMessageAggregator().aggregateChatClientResponse(
chatClient.prompt()
.messages(prompt.getInstructions())
.options(chatOptions)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.stream()
.chatClientResponse()
.doOnNext(chunk -> forwardToSse(chunk)), // side-channel emission
ref::set
).blockLast();
ChatClientResponse response = ref.get();
while (response.chatResponse() != null && response.chatResponse().hasToolCalls()) {
ToolExecutionResult result = toolCallingManager.executeToolCalls(prompt, response.chatResponse());
prompt = new Prompt(result.conversationHistory(), chatOptions);
AtomicReference<ChatClientResponse> nextRef = new AtomicReference<>();
new ChatClientMessageAggregator().aggregateChatClientResponse(
chatClient.prompt()
.messages(result.conversationHistory())
.options(chatOptions)
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.stream()
.chatClientResponse()
.doOnNext(chunk -> forwardToSse(chunk)),
nextRef::set
).blockLast();
response = nextRef.get();
}
이 패턴은 장황합니다. 대부분의 경우 루프 안에 커스텀 advisor 두기 를 선호하세요 — 프레임워크의 루프를 유지하면서 청크 스트림만 가로채면 되니까요.
도구 호출 루프 관찰
대부분의 관찰 사용 사례 — UI로의 스트리밍 중간 진행, 도구 호출 이벤트의 감사 로그 전달, 반복별 메트릭 기록 — 의 경우 자동 등록을 끄거나 루프를 직접 구동할 필요가 없어요. ToolCallingAdvisor.DEFAULT_ORDER 보다 큰 순서를 줘서 커스텀 advisor를 루프 안에 두세요:
public class ToolCallObservingAdvisor implements CallAdvisor, StreamAdvisor {
private final Consumer<ChatClientResponse> observer;
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 400; // inside ToolCallingAdvisor (order 300)
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
// Each iteration's request includes ToolResponseMessages from prior iterations
request.prompt().getInstructions().forEach(msg -> log.debug("Message: {}", msg));
ChatClientResponse response = chain.nextCall(request);
observer.accept(response);
return response;
}
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
// Observe every chunk including tool-call request chunks
return chain.nextStream(request).doOnNext(observer);
}
}
관찰 advisor를 자동 등록된 ToolCallingAdvisor 와 함께 등록하세요:
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new ToolCallObservingAdvisor(chunk -> forwardToSse(chunk)))
.build();
String response = chatClient.prompt()
.user("What is the weather in Amsterdam and Paris?")
.tools(new WeatherTools())
.call()
.content();
ToolCallObservingAdvisor 는 매 반복 실행되고, 메인 호출자는 ToolCallingAdvisor 가 반환된 스트림에서 도구 호출 청크를 걸러내므로 최종 답만 받습니다.
Return Direct
도구의 ToolMetadata 에 returnDirect = true 가 있으면 ToolCallingAdvisor 는:
- 도구 호출을 정상적으로 실행합니다.
ToolExecutionResult에서returnDirect플래그를 감지합니다.- 루프에서 빠져나옵니다.
- 도구 실행 결과를 호출자에게 직접
ChatResponse로 반환하는데, generation 콘텐츠가 도구의 출력이에요.
모델은 도구 결과를 절대 보지 않습니다 — 왕복이 생략됩니다. 도구 출력이 최종 답일 때(예: RAG 검색)나 도구가 에이전트의 추론 루프를 종료해야 할 때 유용해요.
모델이 단일 반복에서 여러 도구 호출을 요청하면 returnDirect 는 호출된 도구 전부가 returnDirect = true 일 때만 존중됩니다. 그렇지 않으면 결과가 모델로 다시 보내지고 루프가 계속돼요.
자동 등록 옵트아웃
ToolCallingAdvisor 자동 등록을 전역으로 끄려면 (자동 구성된 ChatClient 의 모든 호출):
spring.ai.chat.client.tool-calling.enabled=false
단일 호출만 끄려면:
chatClient.prompt("What day is tomorrow?")
.tools(new DateTimeTools())
.advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
.call()
.content();
자동 등록이 꺼지면 .tools(...) 로 전달된 도구는 모델로 전송되지만 응답의 도구 호출은 자동으로 실행되지 않습니다. 그러면 사용자 제어 모드가 됩니다.
더 보기
- Tool Calling — 개념적 개요
- Tool Calling: 메모리와 도구 루프 — 안/밖 순서
- Tool Calling: 루프 확장 — 커스텀 서브클래스용 자동 구성 확장 지점
- Tool Search Tool — 점진적 도구 노출을 구현하는 구체적 서브클래스
ToolSearchToolCallingAdvisor - Recursive Advisors — 기반이 되는 재귀 advisor 패턴
- Advisors — 더 넓은 advisor 시스템