재귀 어드바이저

재귀 어드바이저 (Recursive Advisors)

Spring AI의 재귀 어드바이저는 다운스트림 어드바이저 체인을 여러 번 반복해서 돌 수 있는 특별한 어드바이저예요. 특정 조건이 충족될 때까지 LLM을 반복 호출해야 하는 상황(예: 툴 호출을 루프로 실행하거나, 구조화된 출력 검증 실패 시 재시도)에서 아주 유용하죠. 이 글에서는 재귀 어드바이저의 개념과 Spring AI가 기본 제공하는 ToolCallingAdvisor, StructuredOutputValidationAdvisor 두 가지를 살펴보고, 커스텀 재귀 어드바이저에서 토큰 사용량을 정확히 누적하는 방법까지 알아볼게요.

출처: 문서

본문

What is a Recursive Advisor?

재귀 어드바이저(recursive advisor)는 다운스트림 어드바이저 체인을 여러 번 반복해서 도는 특별한 종류의 어드바이저예요. 이 패턴은 특정 조건이 충족될 때까지 LLM을 반복적으로 호출해야 할 때 유용한데, 예를 들면 이런 경우가 있어요:

  • 더 이상 호출할 툴이 없을 때까지 툴 호출을 루프로 실행하는 경우

  • 구조화된 출력을 검증하고, 검증에 실패하면 재시도하는 경우

  • 요청을 수정하면서 평가(evaluation) 로직을 구현하는 경우

  • 요청을 수정하면서 재시도 로직을 구현하는 경우

CallAdvisorChain.copy(CallAdvisor after) 메서드가 재귀 어드바이저 패턴을 가능하게 하는 핵심 유틸리티예요. 이 메서드는 원래 체인에서 지정한 어드바이저 뒤에 오는 어드바이저만 포함하는 새 어드바이저 체인을 만들어서, 재귀 어드바이저가 필요할 때마다 이 하위 체인을 호출할 수 있게 해줘요. 이 접근 방식은 다음을 보장해요:

  • 재귀 어드바이저가 체인에 남아 있는 어드바이저들을 반복해서 돌 수 있어요

  • 체인에 있는 다른 어드바이저들이 각 반복iteration을 관찰하고 가로챌 수 있어요

  • 어드바이저 체인이 올바른 순서와 관측 가능성(observability)을 유지해요

  • 재귀 어드바이저는 자신보다 앞에 있던 어드바이저를 다시 실행하지 않아요

Built-in Recursive Advisors

Spring AI는 이 패턴을 보여주는 두 가지 기본 제공 재귀 어드바이저를 포함하고 있어요.

ToolCallingAdvisor

ToolCallingAdvisor는 개별 ChatModel 내부 실행에 의존하지 않고 어드바이저 체인의 일부로 툴 호출 루프를 구현해요. 툴이 존재할 때마다 DefaultChatClient에 의해 자동 등록되며, ChatClient가 툴로 강화된 대화를 구동하는 기본 메커니즘이에요.

주요 특징:

  • ToolExecutionEligibilityChecker가 더 이상 호출할 툴이 없다고 보고할 때까지 어드바이저 체인을 반복해요.

  • callAdvisorChain.copy(this)를 사용해 재귀 호출용 하위 체인을 만들어요 — 다른 어드바이저들이 매 반복iteration을 관찰하고 가로챌 수 있어요.

  • return-direct를 지원해요: 툴 결과의 returnDirect = true면 어드바이저가 루프를 끊고, 툴 결과를 LLM에 다시 보내지 않고 호출자에게 그대로 반환해요.

  • ToolAdvisor 마커 인터페이스를 구현하는데, DefaultChatClient는 이 인터페이스를 사용해 단일 툴 어드바이저 불변식(single-tool-advisor invariant)을 강제해요 — 커스텀 하위 클래스는 대체(replacement)로 투명하게 등록돼요.

  • 루프의 매 반복iteration마다 토큰 사용량을 누적해서, 최종 ChatResponse가 마지막 호출의 사용량뿐 아니라 모든 모델 호출의 누적 사용량을 보고해요 (자세한 내용은 Cumulative Usage Across Multi-Step Flows 참고).

간단한 예시:

var toolCallingAdvisor = ToolCallingAdvisor.builder()
    .toolCallingManager(toolCallingManager)
    .advisorOrder(BaseAdvisor.HIGHEST_PRECEDENCE + 300)
    .build();

var chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(toolCallingAdvisor)
    .build();

전체 빌더 API, 훅 메서드, 설정 옵션, 메모리 어드바이저 순서 상호작용, 사용자 제어 실행 패턴, 커스텀 하위 클래스 확장 패턴은 ToolCallingAdvisor 문서를 확인해주세요.

툴 루프가 더 넓은 툴 호출 아키텍처에 어떻게 들어맞는지에 대한 개념적 개요는 Tool Calling: The Tool Calling Loop에서 볼 수 있어요.

ToolCallingAdvisor의 확장 훅을 사용해 점진적 툴 공개(progressive tool disclosure)를 구현하는 구체적인 하위 클래스는 Tool Search Tool를 참고해주세요.

StructuredOutputValidationAdvisor

StructuredOutputValidationAdvisor는 구조화된 JSON 출력을 JSON 스키마에 대해 검증하고, 검증에 실패하면 설정 가능한 횟수만큼 호출을 재시도해요.

주요 특징:

  • 예상 출력 타입에서 JSON 스키마를 도출하거나, 미리 제공된 스키마 문자열을 받아요.

  • LLM 응답을 해당 스키마에 대해 검증해요.

  • 검증 실패 시 호출을 재시도해요 (기본값: 최대 3회).

  • 재시도 시 프롬프트에 검증 오류 메시지를 추가해서 모델이 스스로 수정하도록 도와줘요.

  • callAdvisorChain.copy(this)를 사용해 재귀 호출용 하위 체인을 만들어요.

  • 매 검증 시도마다 토큰 사용량을 누적해서, 반환된 ChatResponse가 마지막 시도뿐 아니라 모든 재시도의 누적 사용량을 보고해요 (자세한 내용은 Cumulative Usage Across Multi-Step Flows 참고).

  • 선택적으로 커스텀 JsonMapper를 지원해요.

이 어드바이저는 outputType(스키마를 자동 도출) 또는 outputJsonSchema(미리 제공된 스키마 문자열)로 설정할 수 있는데, 두 옵션은 상호 배타적이에요.

outputType을 사용한 예시:

var validationAdvisor = StructuredOutputValidationAdvisor.builder()
    .outputType(MyResponseType.class)
    .maxRepeatAttempts(3)
    .build();

var chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(validationAdvisor)
    .build();

미리 제공된 JSON 스키마를 사용한 예시:

var validationAdvisor = StructuredOutputValidationAdvisor.builder()
    .outputJsonSchema(myConverter.getJsonSchema())
    .build();

또는 어드바이저를 수동으로 설정하지 않고 entity() 호출에서 EntityParamSpec 컨슈머를 통해 직접 스키마 검증을 활성화할 수도 있어요:

ActorFilms actorFilms = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorFilms.class, spec -> spec.validateSchema());

높은 수준의 사용법은 Schema Validation & Self-Correction에서 확인할 수 있어요.

Accumulating Token Usage in Custom Recursive Advisors

재귀 어드바이저는 호출 한 번에 모델을 여러 번 호출하기 때문에, 정확한 누적 합계를 보고하려면 모든 호출의 토큰 사용량을 누적해야 해요. 그렇지 않으면 호출자에게 마지막 호출의 사용량만 보이게 되거든요. Spring AI는 이를 간단하게 처리할 수 있도록 org.springframework.ai.chat.client.advisor.UsageAccumulator를 제공해요. adviseCall 호출마다 (또는 스트림 구독의 경우, 구독별로 유지되도록 Flux.defer 안에서) 인스턴스를 하나 만들고, 각 라운드의 응답을 addRoundResponse(…​)로 접고, applyAccumulatedUsage(…​)로 최종 응답에 누적 합계를 찍어주면 돼요:

UsageAccumulator usage = new UsageAccumulator();
ChatClientResponse response;
do {
    response = callAdvisorChain.copy(this).nextCall(request);
    usage.addRoundResponse(response.chatResponse());
    // ... decide whether to loop again ...
}
while (loopAgain);
return usage.applyAccumulatedUsage(response);

기저의 토큰 계산은 org.springframework.ai.support.UsageCalculator(accumulateResponseUsage와 withUsage)에 있으며, UsageAccumulator가 이를 감싸요(wraps).

더 알아보기 (Learn more)