관리 메뉴

The Nirsa Way

[Spring AI] #3-3. ChatService 분석 – internalCall()로 요청과 응답 재구성 흐름 파악하기 본문

Programming/Spring AI

[Spring AI] #3-3. ChatService 분석 – internalCall()로 요청과 응답 재구성 흐름 파악하기

KoreaNirsa 2025. 7. 1. 16:54
반응형

 

※ 인프런 강의 Spring AI 실전 가이드: RAG 챗봇 만들기를 실습하는 내용입니다.
※ 해당 강의 코드를 코틀린 → 자바로 언어를 바꿔 진행하기 때문에 일부 코드 및 구현부가 다를 수 있습니다.
※ 실습이지만 코드를 직접 까보는 내용을 기록하는 포스팅이므로 강의 내용과 상이할 수 있습니다.

[Spring AI] #1. LLM 호출 실습 시작 – 구조 먼저 실행해보기
[Spring AI] #2. AiConfig 분석 – OpenAI API 연결 설정 방법
[Spring AI] #3-1. ChatService 분석 – 메시지 생성과 모델 호출 흐름
[Spring AI] #3-2. ChatService 분석 – buildRequestPrompt()로 요청 준비하기
[Spring AI] #3-3. ChatService 분석 – internalCall()로 요청과 응답 재구성 흐름 파악하기
[Spring AI] #4. ChatController 분석 – 요청 처리와 응답 흐름 정리 (포스팅 예정)

 

이전 포스팅에서의 내용

아래 코드에서 buildRequestPrompt(prompt)의 반환까지 확인하였습니다. 이번 포스팅에는 internalCall 메서드를 분석해보겠습니다.

※ 두번째 인자값의 null
이전 응답(ChatResponse) 객체를 넣을 수 있는 자리이며 여러번 LLM 호출 또는 하나의 세션 안에서 사용량 등을 사용할 때 지속적으로 이전 응답에서 사용한 사용량과 현재 응답에서 사용한 사용량을 비교하며 로그 등으로 기록하기 위함입니다.

chatModel.call(Prompt)

 


 

 

1. internalCall(requestPrompt, null) 파악하기 - request와 observationContext 객체

우선 처음 마주하는 코드는 아래와 같습니다. 첫번째 매개변수로 Prompt 객체를, 두번째 매개변수로 ChatResponse 객체를 받습니다. 두번째 매개변수는 응답에 관련되었고 null을 주었으므로 비어있는 객체로 받습니다.

아래의 코드는 LLM 호출하기 위한 요청 객체를 생성합니다. prompt 객체를 전달하여 시스템 메시지, 사용자 메시지 등을 전달합니다. 두번째 인자는 스트리밍의 여부를 확인하는 옵션입니다. ChatCompletionRequest 객체를 반환 받게 되는데, 이는 LLM API를 호출하기 위한 전용 객체라고 보시면 됩니다.

ChatCompletionRequest request = createRequest(prompt, false);

그 다음으로 나오는 ChatModelObservationContext는 LLM 호출에 대한 메타정보를 저장하고 관찰하는 역할을 수행합니다. 요청 전 시점을 기록하고 호출 후 응답 시간, 오류 여부등을 기록하여 모니터링에 연동하는 등 사용할 수 있도록 해주는 객체입니다.

ChatModelObservationContext observationContext = ChatModelObservationContext.builder()
    .prompt(prompt)
    .provider(OpenAiApiConstants.PROVIDER_NAME)
    .requestOptions(prompt.getOptions())
    .build();

 


 

2. internalCall(requestPrompt, null) 파악하기 -  ChatResponse 객체

다음으로 확인해볼 객체는 ChatResponse 입니다. OpenAI API에 요청하고 응답을 받는 역할을 수행하며 사용량 등을 추적하며 관찰하는 역할을 수행합니다.

ChatResponse response = ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
        this.observationRegistry)
.observe(() -> {

    ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
        .execute(ctx -> this.openAiApi.chatCompletionEntity(request, getAdditionalHttpHeaders(prompt)));

    var chatCompletion = completionEntity.getBody();

    if (chatCompletion == null) {
        logger.warn("No chat completion returned for prompt: {}", prompt);
        return new ChatResponse(List.of());
    }

    List<Choice> choices = chatCompletion.choices();
    if (choices == null) {
        logger.warn("No choices returned for prompt: {}", prompt);
        return new ChatResponse(List.of());
    }

    List<Generation> generations = choices.stream().map(choice -> {
// @formatter:off
        Map<String, Object> metadata = Map.of(
                "id", chatCompletion.id() != null ? chatCompletion.id() : "",
                "role", choice.message().role() != null ? choice.message().role().name() : "",
                "index", choice.index(),
                "finishReason", choice.finishReason() != null ? choice.finishReason().name() : "",
                "refusal", StringUtils.hasText(choice.message().refusal()) ? choice.message().refusal() : "");
        // @formatter:on
        return buildGeneration(choice, metadata, request);
    }).toList();

    RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);

    // Current usage
    OpenAiApi.Usage usage = completionEntity.getBody().usage();
    Usage currentChatResponseUsage = usage != null ? getDefaultUsage(usage) : new EmptyUsage();
    Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentChatResponseUsage, previousChatResponse);
    ChatResponse chatResponse = new ChatResponse(generations,
            from(completionEntity.getBody(), rateLimit, accumulatedUsage));

    observationContext.setResponse(chatResponse);

    return chatResponse;

});

 

CHAT_MODEL_OPERATION은 LLM 호출을 추적하며 위에서 구했던 observationContext를 넣어주게 되는데, 이는 관찰을 시작하는 시점(observe)에 해당 컨텍스트(observationContext) 를 사용하여 로그 등을 기록합니다.

ChatModelObservationDocumentation.CHAT_MODEL_OPERATION
  .observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext, this.observationRegistry)
  .observe(() -> {
     ...
  });

 


 

3. internalCall(requestPrompt, null) 파악하기 - observe 내부

코드를 조금씩 확인해보면, 아래의 코드가 먼저 보이게 되는데 이는 실패 시 retryTemplate을 사용하여 재시도를 하기 위한 코드 입니다. 이미 만들어 두었던 요청을 위한 객체(request)과 header 정보를 포함하여 LLM을 다시 호출합니다.

ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
    .execute(ctx -> this.openAiApi.chatCompletionEntity(request, getAdditionalHttpHeaders(prompt)));
※ RetryTemplate
실패 시 자동으로 재시도를 도와주는 Springframework의 템플릿입니다. 재시도 가능한 코드를 블록에 감싸면 됩니다. 

 

"this.openAiApi.chatCompletionEntity(request, getAdditionalHttpHeaders(prompt)" 에서 실제 요청이 이루어지며 요청 실패 시 retryTemplate에 의해 재시도를 합니다.

OpenAiApi.chatCompletionEntity()

 

응답이 왔다면 completionEntity에서 응답 본문(body)를 추출합니다. 만약 응답 본문을 추출했지만 null이 들어있다면 요청이 실패한것이므로 로그를 남기고 응답 객체(ChatResponse)를 생성하여 반환합니다.

var chatCompletion = completionEntity.getBody();

if (chatCompletion == null) {
    logger.warn("No chat completion returned for prompt: {}", prompt);
    return new ChatResponse(List.of());
}

 

다시 한번 chatCompletion 객체에서 choices() 메서드를 호출하여 하나 또는 여러개의 응답 리스트를 추출합니다. 모델이 생성한 메시지, 해당 응답의 인덱스, 토큰 생성 중단 이유 등이 담겨져 있습니다.

마찬가지로 null이라면 모델이 생성한 응답이 없다는 것이므로 로그를 남기고 응답 객체(ChatResponse)를 생성하여 반환합니다.

List<Choice> choices = chatCompletion.choices();
if (choices == null) {
    logger.warn("No choices returned for prompt: {}", prompt);
    return new ChatResponse(List.of());
}

 

이후 꺼낸 choices 리스트 객체에서 한 개의 응답씩 꺼내게 되는데 List<Choice> 리스트를 Spring AI에서 사용되는 List<Generation> 리스트로 변환하는 과정입니다.

각각의 값을 꺼내서 null이 아니라면 "id" 키를 가진 값에 응답받았던 id 데이터를 넣고, "role" 키를 가진 값에 응답 받았던 role 이름을 넣고 하는 과정을 거칩니다. 즉 null 인지 확인하여 null이면 빈 문자열, null이 아니라면 응답받은 데이터를 Map 형태로 저장한 후 buildGeneration()을 통해 List<generation> 형태로 변환합니다.

List<Generation> generations = choices.stream().map(choice -> {
// @formatter:off
    Map<String, Object> metadata = Map.of(
            "id", chatCompletion.id() != null ? chatCompletion.id() : "",
            "role", choice.message().role() != null ? choice.message().role().name() : "",
            "index", choice.index(),
            "finishReason", choice.finishReason() != null ? choice.finishReason().name() : "",
            "refusal", StringUtils.hasText(choice.message().refusal()) ? choice.message().refusal() : "");
    // @formatter:on
    return buildGeneration(choice, metadata, request);
}).toList();

 

OpenAI 응답의 HTTP Header에서 reateLimit을 추출하는 과정입니다.

RateLimit rateLimit = OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity);

아래와 같이 남은 호출 수, 사용 가능한 토큰 수, 남은 토큰 수, 리셋될때 까지 남은 시간 등을 추출하여 RateLimit 객체로 반환합니다.

OpenAiResponseHeaderExtractor.extractAiResponseHeaders(completionEntity)

 

주석명처럼 현재 사용량을 계산하는 코드입니다. 응답 받은 객체의 사용량(completionEntity.getBody().usage())을 꺼낸 후 usage가 존재한다면 사용량에 대한 정보를 DefaultUsage 객체로 반환, 존재하지 않는다면 EmptyUsage() 객체를 생성합니다. ( 참고로 DefaultUsage 객체로 반환하는데 변수의 타입이 Usage인 이유는 DefaultUsage가 상속받는 인터페이스가 Usage 입니다.)

// Current usage
OpenAiApi.Usage usage = completionEntity.getBody().usage();
Usage currentChatResponseUsage = usage != null ? getDefaultUsage(usage) : new EmptyUsage();

 

또한 UsageUtils.getCumulativeUsage를 통해 현재 응답 사용량과 이전 응답 사용량을 누적으로 계산하게 되는데, 현재 저의 코드는 이전 응답이 null 이므로 현재 사용량만을 반환합니다.

Usage accumulatedUsage = UsageUtils.getCumulativeUsage(currentChatResponseUsage, previousChatResponse);

UsageUtils.getCumulativeUsage()

 

생성자 인자에 모델의 응답 리스트(generations)가 들어가며 from() 메서드를 호출하여 ChatResponseMetadata로 변환 후 생성자에 전달하여 최종 응답 객체(ChatResponse)를 생성합니다.

ChatResponse chatResponse = new ChatResponse(generations,
        from(completionEntity.getBody(), rateLimit, accumulatedUsage));

OpenAiChatModel.from()

 

이제 observationContext 객체의 응답 필드(Response)에 최종 응답 객체(chatResponse)를 저장한 후 마지막으로 chatResponse를 반환하여 종료합니다.

observationContext.setResponse(chatResponse);

return chatResponse;

 


 

chatService 마무리

결과적으로 작성했던 chatService의 chatModel.call(promt)이 어떻게 동작하는지 확인해보기 위한 포스팅 이였습니다.

 

간단히 요약하자면 buildRequestPrompt()는 LLM API 호출을 하기 위해 필요한 정보들을 담는 과정이였고, internalCall()은 실제로 호출을 하는 부분이였습니다.

반응형