재귀 어드바이저
재귀 어드바이저 (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).