spring-ai/03.Advisors

01. Advisor API

은서파 2024. 12. 9. 14:56

이번 포스트에서는 Spring AI의 Advisor API에 대해 살펴보자.

 

Advisors API

 

개요

Advisors API스프링 애플리케이션에서 AI 기반의 상호 작용을 가로채고, 수정하고 향상시킬 수 있는 유연하고 강력한 방법을 제공한다. 개발자는 어드바이저 API를 활용해서 보다 정교하고 재사용 가능하고 유지 관리가 가능한 AI 구성 요소를 만들 수 있다. 

Advisors API의 주요 이점은 반복되는 생성 AI 패턴 캡슐화, LLM과 주고받는 데이터의 변환, 다양한 모델 및 사용 사례에 걸쳐 이식성을 제공하는데 있다.

이런 Advisor는 ChatClient를 만들 때 defaultAdvisors를 통해 설정할 수 있으며 runtime에 파라미터를 수정할 수 있다.

// ChatClient에 Advisor 추가하기
var chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(
        new MessageChatMemoryAdvisor(chatMemory), // chat-memory advisor
        new QuestionAnswerAdvisor(vectorStore, SearchRequest.defaults()) // RAG advisor
    )
    .build();

String response = this.chatClient.prompt()
    // 런타임에 Advisor의 파라미터 수정하기
    .advisors(advisor -> advisor.param("chat_memory_conversation_id", "678")
            .param("chat_memory_response_size", 100))
    .user(userText)
    .call()
	.content();

 

Core Components

Advisors API는 스트리밍 시나리오의 경우는 StreamAdvisor와 StreamAdvisorChain으로 구성되고 비스트리밍 시나리오의 경우는 CallAdvisor와 CallAdvisorChain으로 구성된다. 또한 프롬프트 요청을 나타내는 ChatClientRequest, 채팅 완료 응답을 위한 ChatClientResponse도 포함된다. 둘 다 어드바이저 체인 전체에서 상태를 공유하기 위한 어드바이즈 컨텍스트를 보유한다.

context로 chain간 요청/응답의 상태를 공유한다.

다음은 비 스트리밍 형태인 CallAdvisor의 상속 관계이다.

Ordered#getOrder()는 Advisor의 우선순위를 나타내는데 숫자가 낮을 수록 높은 우선순위(먼저 실행)를 갖는다. 즉 Orderd.HIGHEST_PRECEDENCE = Integer.MIN_VALUE이다. 만약 동일한 우선순위의 Advisor가 여럿 있다면 이들의 동작 순서는 보장되지 않는다.

일반적으로 필요한 것은 XXAdvisor를 구현해서 aroundCall 또는 aroundStream을 재정의하면서 사용자 입력 프롬프트 데이터의 검사, 프롬프트 데이터에 대한 증강 및 커스터마이징, 선택적으로 요청 차단, 모델 응답 검사, 처리 오류 시 예외 던지기 등을 수행할 수 있다. 

CallAdvisorChain 등의 nextCall 은 다음 CallAdvisor를 호출하는 역할을 수행한다.

 

동작 절차

스프링 AI 프레임워크에 의해 생성된 어드바이저 체인을 사용하면 getOrder()값에 따라 정렬된 여러 어드바이저를 순차적으로 호출하는데 낮은 값이 먼저 실행된다. 먼저 실행된 Advisor는 처리 결과를 다음 Advisor에게 전달하기 때문에 순서의 지정은 매우 중요할 수 있다. 아무튼 가장 마지막에 실행된 Advisor가 요청을 Chat Model에 보내게 된다.

Advisors API의 동작 과정

  1. Spring AI 프레임워크는 사용자 프롬프트로 ChatClientRequest를 생성하는데 여기는 비어있는 context가 존재한자.
  2. 체인의 각 어드바이저는 요청을 처리하여 잠재적으로 수정할 수 있다. 또한 다음 엔티티를 호출하지 않아서 요청을 차단할 수도 있는데 이 경우는 해당 어드바이저가 응답을 작성 할 책임이 있다.
  3. 프레임워크에서 제공하는 최종 어드바이저가 요청을 채팅 모델로 보낸다.
  4. 채팅 모델의 응답은 다시 어드바이저 체인으로 전달되서 ChatClientResponse로 변환되고 여기에는 Contxt가 저장된다.
  5. 각 어드바이저들은 응답을 처리하거나 수정할 수 있다.
  6. 최종 ChatClientResponse에서 ChatCompletion을 추출해서 ChatResponse 형태로 클라이언트에게 전달된다.

 

SimpleLoggerAdvisor 적용

 

SimpleLoggerAdvisor

우리가 이미 사용하고 있는 SimpleLoggerAdvisorChatClient의 요청 및 응답 데이터를 기록하는 advisor이다. 이를 통해 AI 상호 작용을 디버깅하고 모니터링할 수 있다. 이 어드바이저는 Spring AI에서 미리 만들어두었기 때문에 핵심적인 소스 코드만 살짝 살펴보자.

public class SimpleLoggerAdvisor implements CallAdvisor, StreamAdvisor {
  public SimpleLoggerAdvisor(int order) {
    this(DEFAULT_REQUEST_TO_STRING, DEFAULT_RESPONSE_TO_STRING, order);
  }
  
  @Override
  public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, 
                                       CallAdvisorChain callAdvisorChain) {
    logRequest(chatClientRequest);                                // 다음 체인 호출 전 로깅
    // 다음 체인 호출
    ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(chatClientRequest); 
    logResponse(chatClientResponse);                              // 다음 체인 호출 후 로깅
    return chatClientResponse;
  }
  
  private void logRequest(ChatClientRequest request) {
    logger.debug("request: {}", this.requestToString.apply(request));
  }
  
  private void logResponse(ChatClientResponse chatClientResponse) {
    logger.debug("response: {}", this.responseToString.apply(chatClientResponse.chatResponse()));
  }
}

코드를 보면 참 별것 없다 단지 다음 Advisor를 호출하기 전/후에 필요한 동작(로깅)을 할 뿐이다.

 

사용자 정의 Advisor 작성

 

ReReadingAdvisor

대규모 언어 모델에서 추론 능력을 향상시키는 재읽기 기법이 사용되기도 한다. 이를 이용하기 위해 ReReadingAdvisor를 만들고 적용해보자. 여기서는 비동기 방식에 대해서는 고려하지 않으므로 CallAroundAdvisor만 구현해보자.

public class ReReadingAdvisor implements CallAdvisor {

    private int order;

    public ReReadingAdvisor(int order) {
        this.order = order;
    }

    @Override
    public String getName() {
        return this.getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, 
                                         CallAdvisorChain callAdvisorChain) {
        return callAdvisorChain.nextCall(rereading(chatClientRequest));
    }

    private ChatClientRequest rereading(ChatClientRequest req) {
        Prompt prompt = req.prompt().copy(); // 가급적 copy()를 사용하여 원본을 변경하지 않도록 한다.
        String userPrompt = req.prompt().getUserMessage().getText();
        if (userPrompt.length() > 100000 || userPrompt.contains("복잡한")) {
            Prompt enhanced = prompt.augmentUserMessage(userPrompt 
                                   + "\n다시 한번 이 문장을 찬찬히 읽어봐.\n" + userPrompt);
            return ChatClientRequest.builder().prompt(enhanced).context(req.context()).build();
        } else {
            return req;
        }
    }
}

 

적용 및 동작 확인

이제 SimpleLoggerAdvisor와 함께 ReReadingAdvisor를 적용해보자. ReReadingAdvisor의 우선순위는 0이고 SimpleLoggerAdvisor의 우선순위는 Integer.MAX_VALUE-1인 상황을 감안하고 동작 결과를 생각해보자.

public Object simpleGeneration(String userInput) {
        CallResponseSpec spec = ollamaGemma3ChatClient.prompt()
                .user(userInput) // user message 구성
                .options(OllamaOptions.builder().temperature(0.7).build())
                .advisors(new ReReadingAdvisor(0))
                .call(); // 실제 모델 호출

        return spec.content();
}

이제 상황에 따라 ReReadingAdvisor를 호출하도록 user message를 조절해보자.

@Test
void rereadingTest() throws Exception {
    String userInput = """
          솔로는 과학 교과서 4페이지, 사회 교과서 20페이지, 역사 교과서 7페이지, 지리 교과서 8페이지를 읽어야 한다.
          솔로는 월요일에 15페이지를 이미 읽었다.
          만약 그가 모든 읽기를 끝내기 위해 4일이 더 있다면, 하루 평균 몇 페이지를 읽어야 할까?
          답은 단답식으로 해줘.
          """;
        // 정답은 6
    Object result = aiService.simpleGeneration(userInput);
    log.debug("simple: {}", result);
    result = aiService.simpleGeneration("복잡한 문제야. " + userInput);
    log.debug("rereading: {}", result);
}

출력은 상당히 흥미롭다. ReReading이 의미가 있었다. (있을 때도 있었다가 더 정확한가?)

22:13:29 [DEBUG] c.q.s.SimpleGenerationTest.rereadingTest.58 simple: 4페이지 + 20페이지 + 7페이지 + 8페이지 = 40페이지.
총 읽어야 할 페이지 수는 40페이지입니다.
4일 동안 읽어야 하므로, 하루 평균 10페이지를 읽어야 합니다.

22:13:33 [DEBUG] c.q.s.SimpleGenerationTest.rereadingTest.60 rereading: 총 페이지 수는 4 + 20 + 7 + 8 = 39페이지야.
솔로는 이미 15페이지를 읽었으니, 남은 페이지는 39 - 15 = 24페이지야.
4일 동안 읽어야 하니까, 하루 평균 24 / 4 = 6페이지를 읽어야 해.