Spring AI ToolCallingAdvisor

Spring AI ToolCallingAdvisor

ToolCallingAdvisor 는 Spring AI 2.0에서 도구 실행 라이프사이클을 소유하는 재귀 advisor예요. DefaultChatClient 가 자동 등록하며, 모델이 도구 호출 없는 응답을 만들 때까지 요청/응답 루프를 구동합니다.

이 페이지는 빌더 API, 구성 옵션, 후크 메서드, 확장 패턴에 대한 레퍼런스입니다. 루프의 개념적 개요는 The Tool Calling Loop 를, 더 넓은 재귀 advisor 패턴은 Recursive Advisors 를 참고하세요.

개요

ToolCallingAdvisorCallAdvisorStreamAdvisor 를 모두 구현하고, ToolAdvisor 마커 인터페이스도 구현합니다. 마커 인터페이스는 DefaultChatClient 가 체인에 정확히 하나의 도구 advisor가 있도록 강제하는 데 쓰여요.

기본 ToolCallingAdvisor.DEFAULT_ORDEROrdered.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_PRECEDENCEBaseAdvisor.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 를 변환할 수 있어요.

서브클래스 예시

ToolSearchToolCallingAdvisorToolCallingAdvisor 서브클래스의 구체적 예입니다. doInitializeLoopdoInitializeLoopStream 을 오버라이드해 세션 시작 시 도구 집합을 인덱싱하고 시스템 메시지를 보강하며, doBeforeCalldoBeforeStream 을 오버라이드해 각 반복마다 지금까지 발견된 도구만 주입합니다. 루프의 나머지는 기본 클래스에서 상속받아요.

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

도구의 ToolMetadatareturnDirect = true 가 있으면 ToolCallingAdvisor 는:

  1. 도구 호출을 정상적으로 실행합니다.
  2. ToolExecutionResult 에서 returnDirect 플래그를 감지합니다.
  3. 루프에서 빠져나옵니다.
  4. 도구 실행 결과를 호출자에게 직접 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(...) 로 전달된 도구는 모델로 전송되지만 응답의 도구 호출은 자동으로 실행되지 않습니다. 그러면 사용자 제어 모드가 됩니다.

더 보기