~/khan

Spring AI 2.0 어드바이저 3종 — 툴 루프를 체인 위로 올리고, 툴을 검색하고, JSON 을 스스로 고치기

· 18 min read · by Khan
#Spring AI#LLM#Tool Calling#Advisor#Java

Spring AI 1.x 로 툴 호출을 붙여 놓고 나면, 늘 같은 자리에서 막혔습니다. "지금 몇 번째 툴 호출인지" 를 로그로 찍고 싶은데 찍을 자리가 없었습니다. 루프가 ChatModel 구현체 안쪽에 있었기 때문입니다. 2.0 은 그 루프를 어드바이저 체인 위로 끌어올립니다. 이번 글은 그 변화의 중심에 있는 어드바이저 세 개 — ToolCallingAdvisor, ToolSearchToolCallingAdvisor, StructuredOutputValidationAdvisor — 를 한 번에 정리한 메모입니다.

0. 먼저 베이스라인

항목내용
Spring AI 2.0.0 GA2026-06-12
베이스라인Spring Boot 4.0 / 4.1, Spring Framework 7.0
JSONJackson 3
Null SafetyJSpecify 전면 적용
Options빌더 패턴 + 불변 객체로 재정리

Spring Framework 7 / Boot 4 위에 올라가는 릴리즈라, Spring Framework 7 정리 글 에서 짚었던 Jackson 3 패키지 이동(com.fasterxml.jacksontools.jackson)이나 JSpecify 이야기가 그대로 따라옵니다. 즉 Spring AI 2.0 만 따로 올리는 선택지는 없습니다. Boot 4 를 먼저 밟고 와야 합니다.

1. 무엇이 근본적으로 바뀌었나 — 루프의 위치

이번 릴리즈의 핵심은 기능 추가가 아니라 자리 이동 입니다.

1.x — 루프가 모델 안쪽에 있었습니다

ChatClient

   ├─ Advisor 체인 (memory, logging, RAG ...)


ChatModel (OpenAiChatModel, AnthropicChatModel ...)

   └─ [ 여기 안에 툴 호출 루프가 각각 따로 구현되어 있음 ]
        while (응답에 toolCalls 가 있으면) { 툴 실행하고 다시 호출 }

모델 구현체마다 자기만의 루프를 들고 있었고 바깥에서 끼어들 훅이 없었습니다. 그래서 "툴 호출 사이에 승인 절차를 넣고 싶다", "반복 횟수를 관측하고 싶다" 같은 요구는 매번 모델을 직접 다루는 코드로 내려가야 했습니다.

2.0 — 루프가 어드바이저 체인 위로 올라왔습니다

ChatClient

   ├─ Advisor 체인
   │    ├─ MessageChatMemoryAdvisor      (order: HIGHEST + 200)
   │    ├─ ToolCallingAdvisor            (order: HIGHEST + 300)  ◀ 루프가 여기
   │    └─ ...

ChatModel  ← 이제 한 번의 호출만 담당합니다

ToolCallingAdvisor재귀 어드바이저(recursive advisor) 입니다. 응답에 툴 호출이 없어질 때까지 자기 아래 체인을 다시 타고 내려갑니다. 루프가 체인 위로 올라왔다는 건, 곧 다른 어드바이저와 조합할 수 있게 됐다 는 뜻입니다. 뒤에 나오는 메모리 위치 이야기가 바로 그 조합의 결과입니다.

2. ToolCallingAdvisor — 툴 호출 루프의 승격

2.1 아무것도 안 해도 이미 켜져 있습니다

DefaultChatClientToolCallingAdvisor정확히 하나 자동 등록합니다. 순서는 HIGHEST_PRECEDENCE + 300 입니다.

String response = ChatClient.create(chatModel)
    .prompt("암스테르담 날씨 어때? 맑으면 런던발 항공권도 예약해 줘.")
    .tools(new WeatherTools())
    .call()
    .content();

호출 코드는 1.x 와 거의 같은데, 이 한 줄 뒤에서 도는 루프의 주인이 바뀐 것 입니다. 블로킹(.call()) 과 스트리밍(.stream()) 둘 다 지원합니다.

툴 정의 자체는 1.x 와 동일하게 애노테이션으로 씁니다.

class WeatherTools {
 
    @Tool(description = "Get the current weather for a given city")
    public String getWeather(String city) {
        return weatherService.fetch(city);
    }
 
    @Tool(description = "Book a flight between two cities on a given date")
    public BookingConfirmation bookFlight(
            String origin,
            String destination,
            @ToolParam(description = "Date in YYYY-MM-DD format") String date) {
        return flightService.book(origin, destination, date);
    }
}

2.2 루프 한 바퀴

어드바이저 안에서 일어나는 일을 풀어 적으면 이렇게 됩니다.

1) 초기화 — 툴 정의(이름 / 설명 / 입력 스키마)를 컨텍스트에 주입


2) 반복 — 지금까지의 대화 이력을 합쳐 LLM 호출


3) 검사 — 응답에 toolCalls 가 있는가?

      ├─ 있다 → ToolCallingManager 가 실행 → 결과를 이력에 붙이고 2) 로

      └─ 없다 → 최종 응답을 사용자에게 반환 (루프 종료)

2.3 끼어들 수 있는 자리 — 훅

ToolCallingAdvisor 를 상속해서 protected 훅을 오버라이드하면 루프의 정해진 지점에 개입할 수 있습니다.

호출 시점
doInitializeLoop / doInitializeLoopStream첫 반복 전에 한 번
doBeforeCall / doBeforeStream매 반복 직전
doAfterCall / doAfterStream매 반복 직후
doFinalizeLoop / doFinalizeLoopStream루프 종료 후 한 번

이 훅이 장식이 아니라는 증거가 바로 다음에 나올 ToolSearchToolCallingAdvisor 입니다. 그 어드바이저는 doInitializeLoop 에서 툴을 색인하고 doBeforeCall 에서 검색된 툴을 하나씩 밀어 넣는 식으로 상속만으로 구현되어 있습니다. 프레임워크가 자기 확장 지점을 자기가 먼저 써 본 셈입니다.

2.4 루프를 직접 돌리고 싶을 때

외부 승인, 조건부 중단, 진행 상황 스트리밍처럼 루프를 손에 쥐어야 하는 경우가 있습니다. 이때는 호출 단위로 자동 등록을 끕니다.

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?";
 
ChatClientResponse response = chatClient.prompt()
    .user(question)
    .options(chatOptions)
    .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
    .call()
    .chatClientResponse();
 
Prompt prompt = new Prompt(List.of(new UserMessage(question)), chatOptions);
 
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();
}

1.x 의 internalToolExecutionEnabled 플래그가 하던 일을, 이제 AdvisorParams.toolCallingAdvisorAutoRegister(false) 가 대신합니다. 플래그가 어드바이저 등록 여부 라는 더 정직한 이름으로 바뀐 것입니다.

3. 메모리를 루프 안에 둘까, 밖에 둘까

루프가 체인 위로 올라오면서 새로 생긴 질문입니다. MessageChatMemoryAdvisor 의 order 가 ToolCallingAdvisor.DEFAULT_ORDER 보다 앞이냐 뒤냐에 따라 저장되는 내용이 달라집니다.

위치order저장되는 것
루프 바깥 (기본)HIGHEST + 200루프 전에 이력 한 번 로드, 최종 user/assistant 메시지만 저장. 툴 메시지는 저장 안 됨 (1.x 와 동일한 동작)
루프 안쪽> ToolCallingAdvisor.DEFAULT_ORDER매 반복마다 호출되어 툴 요청/응답 전체가 저장됨

루프 안쪽에 두면 다음 턴에 "지난번에 이 툴을 왜 불렀는지" 까지 컨텍스트로 들어옵니다. 대신 저장소가 툴 메시지를 감당해야 합니다. 2.0 기준으로 전체 툴 메시지를 지원하는 저장소는 InMemoryChatMemoryRepository, RedisChatMemoryRepository, Neo4jChatMemoryRepository 입니다. JDBC 기반은 커뮤니티 프로젝트(spring-ai-session)를 거쳐 2.1 에 들어올 예정입니다.

한 가지 함정이 있습니다. 메모리가 루프 안에 있으면 어드바이저 내부 이력과 저장소가 같은 내용을 두 번 쓰게 됩니다. 자동 등록을 쓰면 DefaultChatClient 가 이 상황을 감지해 내부 이력을 알아서 끕니다. 어드바이저를 직접 만들어 끼우는 경우에만 빌더에서 .disableInternalConversationHistory() 를 직접 불러 줘야 합니다.

4. ToolSearchToolCallingAdvisor — 툴이 수백 개일 때

4.1 문제

툴을 등록한다는 건 매 요청마다 모든 툴의 이름·설명·입력 스키마를 프롬프트에 실어 보낸다 는 뜻입니다. 툴이 다섯 개면 아무 문제가 없습니다. 그런데 MCP 서버를 두어 개 붙이는 순간 툴이 수십, 수백 개가 됩니다.

  • 컨텍스트가 툴 정의로 채워집니다.
  • 정작 이번 질문에 쓸 툴은 한두 개입니다.
  • 매 턴마다 같은 값을 다시 보냅니다.

4.2 해법 — 점진적 툴 공개 (progressive tool disclosure)

ToolSearchToolCallingAdvisor 는 전부 싣는 대신, 툴을 찾아 주는 툴 하나(toolSearchTool)만 먼저 노출합니다. 모델은 자연어 질의로 필요한 툴을 검색합니다. 검색된 것만 그 시점에 컨텍스트로 들어옵니다.

[1.x 방식]  요청 ──▶ 툴 정의 120개 전부 + 질문
[2.0 검색]  요청 ──▶ toolSearchTool 1개 + 질문

                       ▼ 모델: "항공권 예약 관련 툴 찾아줘"
                     검색 결과 툴 3개만 주입 ──▶ 실제 호출

4.3 설정

spring.ai.chat.client.tool-search-advisor.enabled=true
spring.ai.chat.client.tool-search-advisor.tool-index-type=vector

인덱스 전략은 셋 중에 고릅니다.

전략성격
regex가벼운 기본값
lucene키워드 검색, 별도 설치 없이 번들
vector임베딩 기반 의미 검색, VectorStore 빈 필요

4.4 세션 단위 색인

툴 인덱스는 세션 스코프입니다. 그래서 요청마다 세션 ID 가 필요하고 기본값은 ChatMemory.CONVERSATION_ID 를 읽습니다.

chatClient.prompt()
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-42-session"))
    .user("암스테르담 여행 계획 좀 짜 줘")
    .call()
    .content();

키 이름을 바꾸려면 spring.ai.chat.client.tool-search-advisor.session-id-key-name 을 씁니다.

4.5 효과

Spring 팀 벤치마크 기준 토큰 34~64% 절감 입니다. 단, 이건 툴이 대략 30개를 넘어갈 때의 이야기입니다. 툴이 열 개도 안 되는 프로젝트에서는 검색 왕복이 한 번 더 붙는 만큼 오히려 손해입니다. "툴이 몇 개부터인가" 가 이 어드바이저를 켜는 기준 입니다.

5. StructuredOutputValidationAdvisor — 틀린 JSON 을 스스로 고치기

5.1 native structured output 도 완벽하지 않습니다

Spring AI 는 처음부터 구조화 출력을 지원했고 요즘 모델들은 native structured output 도 제공합니다. 그런데도 스키마에 안 맞는 응답은 나옵니다. 필수 필드가 빠지거나, 배열이 와야 할 자리에 문자열이 오거나 하는 식입니다.

이걸 애플리케이션 코드에서 처리하려면 대개 이렇게 됩니다.

// 1.x 시절 흔했던 모양
for (int i = 0; i < 3; i++) {
    try {
        return chatClient.prompt(question).call().entity(ActorsFilms.class);
    } catch (Exception e) {
        question = question + "\n이전 응답이 스키마에 맞지 않았습니다. 다시 시도해 주세요.";
    }
}
throw new IllegalStateException("구조화 출력 실패");

5.2 2.0 의 동작

StructuredOutputValidationAdvisor 는 위 패턴을 어드바이저로 흡수한 재귀 어드바이저입니다.

응답 수신


기대 타입으로 생성한 JSON 스키마와 대조

   ├─ 통과 → 그대로 반환

   └─ 실패 → 검증 에러 문구를 프롬프트 뒤에 덧붙여 재호출 (기본 3회까지)

포인트는 "그냥 다시 물어본다" 가 아니라 틀린 이유를 그대로 붙여서 다시 물어본다 는 데 있습니다. missing required field actor, expected array, got string 같은 검증 메시지가 프롬프트에 들어갑니다. 모델 입장에서는 "다시 해 봐" 보다 "이 필드가 빠졌다" 가 훨씬 고치기 쉬운 지시입니다.

5.3 커스터마이즈

기본 시도 횟수는 3회이고 Spring AI 기본 JsonMapper 를 씁니다. 횟수를 늘리거나 스키마를 직접 넘기려면 인스턴스를 만들어 등록합니다.

var validationAdvisor = StructuredOutputValidationAdvisor.builder()
    .outputType(ActorsFilms.class)
    .maxRepeatAttempts(5)
    .build();
 
ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(validationAdvisor)
    .build();

5.4 켜기 전에 한 번 생각할 것

재시도는 공짜가 아닙니다. 시도 횟수를 5로 올린다는 건 최악의 경우 요청 비용과 지연이 5배 라는 뜻입니다. 검증이 자주 실패한다면 횟수를 늘리기 전에 스키마 쪽을 먼저 의심하는 편이 낫습니다. 필드 설명이 비어 있거나, 한 DTO 에 필드가 지나치게 많거나, enum 후보를 프롬프트에 안 알려 준 경우가 대부분이었습니다. 이 어드바이저는 가끔 나오는 실패를 덮는 안전망 이지, 스키마 설계를 대신해 주지는 않습니다.

6. 1.x → 2.0, 무엇이 깨지나

툴 호출 코드를 들고 있다면 다음 항목들을 먼저 훑어야 합니다.

1.x2.0
ToolCallAdvisorToolCallingAdvisor 로 리네임
toolNames("currentWeather")제거 — ToolCallback 빈을 명시적으로 넘김
SpringBeanToolCallbackResolver (Function 빈 해석)제거
internalToolExecutionEnabled제거 — AdvisorParams.toolCallingAdvisorAutoRegister(false)
streamToolCallResponses(...)제거 (툴 요청만 나가고 응답이 빠지던 동작이라 삭제)
ChatOptions#copy(), [*]Options#fromOptions()제거 — .mutate()

Function 빈 방식이 사라진 부분이 손이 제일 많이 갑니다.

// Before (1.x)
@Bean
@Description("Get the weather in location")
Function<WeatherRequest, WeatherResponse> currentWeather() {
    return weatherService::getWeather;
}
chatClient.prompt().toolNames("currentWeather");
 
// After (2.0)
@Bean
ToolCallback currentWeather() {
    return FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
        .description("Get the weather in location")
        .inputType(WeatherRequest.class)
        .build();
}
 
@Autowired ToolCallback currentWeather;
 
chatClient.prompt()
    .user("What's the weather in Copenhagen?")
    .tools(currentWeather)
    .call()
    .content();

문자열 이름으로 빈을 찾던 걸 타입으로 바꾼 것이라, 컴파일 타임에 오타가 잡힌다는 점에서는 이득입니다.

7. 마무리

세 어드바이저를 한 줄씩 정리하면 이렇게 됩니다.

  1. ToolCallingAdvisor — 모델 안에 숨어 있던 툴 루프를 체인 위로 꺼내 관측·확장 가능하게 만들었습니다. 자동 등록이라 기존 코드는 그대로 돌아갑니다.
  2. ToolSearchToolCallingAdvisor — 툴을 전부 싣는 대신 검색해서 씁니다. 툴 30개 이상에서 토큰 34~64% 절감.
  3. StructuredOutputValidationAdvisor — 스키마 검증 실패를 에러 문구째로 되먹여 재요청합니다. 기본 3회.

셋을 관통하는 방향은 하나로 보입니다. 애플리케이션 코드에 손으로 짜 넣던 while 문 세 개(툴 루프 / 툴 선별 / 검증 재시도)를 프레임워크가 회수한 것. 1.x 에서 저 셋을 직접 짜 본 적이 있다면, 2.0 마이그레이션은 코드가 늘어나는 작업이 아니라 지워지는 작업 이 됩니다.

다만 도입 순서는 갈라 두는 편이 안전했습니다. ToolCallingAdvisor 는 자동 등록이라 사실상 선택지가 아니고 나머지 둘은 켤 조건이 분명한 기능 입니다. 툴이 30개를 넘는가, 구조화 출력이 실제로 실패하는가. 그 조건이 아직 아니라면 켜지 않는 게 맞습니다.

참고