본문 바로가기
부트캠프/프로젝트(헬핏)

chatGPT open api (Spring) 2가지 방법.

by 티코딩 2023. 5. 11.

chatGPT api를 spring 환경에서 사용해봤다.

첫번째방법

첫번째방법은 다른 블로그와, gpt에게 물어 보며 공부한 방법이다. 밑에 출처를 남겼으니 그 블로그를 봐주세요.

1. 먼저, api key를 발급받아야 한다.

https://platform.openai.com/account/api-keys

 

OpenAI API

An API for accessing new AI models developed by OpenAI

platform.openai.com

발급받은 후에, 의존성 주입을 해준다.

implementation 'io.github.flashvayne:chatgpt-spring-boot-starter:1.0.4'

2. yml 파일에 발급받은 api-key를 넣어준다.

chatgpt:
  api-key: #your chatgpt-api-key

3. 간단하게 controller와 service를 만들어준다.

package com.th.chatGptprac.chatGpt.controller;

import com.th.chatGptprac.chatGpt.service.ChatService;
import io.github.flashvayne.chatgpt.service.ChatgptService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/chat")
public class ChatController {

    private final ChatService chatService;
    private final ChatgptService chatgptService;

    public ChatController(ChatService chatService, ChatgptService chatgptService) {
        this.chatService = chatService;
        this.chatgptService = chatgptService;
    }
    @PostMapping("")
    public String chat(@RequestBody String question){
        return chatService.getChatResponse(question);
    }
}
package com.th.chatGptprac.chatGpt.service;

import io.github.flashvayne.chatgpt.service.ChatgptService;
import org.springframework.stereotype.Service;

@Service
public class ChatService {

    public ChatService(ChatgptService chatgptService) {
        this.chatgptService = chatgptService;
    }
    private final ChatgptService chatgptService;

    public String getChatResponse(String prompt) {
        return chatgptService.sendMessage(prompt);
    }
}

4. 테스트 해본다.

https://yjkim-dev.tistory.com/56

 

Springboot + chatGPT API 연동해보기.

springboot 에서 chatGPT API를 연동해보겠습니다. 생각보다 어렵지 않으니 순서대로 따라오시면 됩니다. 우선 chatGPT API 를 이용하기 위해 API-KEY가 필요합니다. https://platform.openai.com/account/api-keys OpenAI A

yjkim-dev.tistory.com

여기 블로그에서 자세히 나와있으니 여기를 참고하세욤.

 

두번째 방법

다음으로 우리 프로젝트에서 사용한 예시를 보자.

우리팀 에이스 킹지원님 께서 짜신 chatGpt 디렉토리의 구성을 보자.

생각보다 간단해보인다!

한번 파헤쳐보자.

먼저, 위의 방법

 

위에서부터 차례대로 코드를 뜯어보자. 환경 설정을 위한 클래스인 ChatGptConfig 클래스다.

1. ChatGptConfig

@Component
public class ChatGptConfig {
    public static String SECRET_KEY;
    public static final String CHAT_URL = "https://api.openai.com/v1/chat/completions";
    public static final String COMPLETIONS_URL = "https://api.openai.com/v1/completions";
    public static final String MEDIA_TYPE = "application/json; charset=UTF-8";
    public static final String AUTHORIZATION = "Authorization";
    public static final String BEARER = "Bearer ";
    public static final String CHAT_MODEL = "gpt-3.5-turbo";
    public static final String COMPLETIONS_MODEL = "text-davinci-003";
    public static final Double TOP_P = 1.0;
    public static final Double TEMPERATURE = 0.2;  // 0 ~ 2
    public static final Integer MAX_TOKENS = 25;   // default: 16
    public static final Boolean STREAM = true;     // default: false

    @Value("${chat-gpt.secret-key}")
    public void setSecretKey(String value) {
       SECRET_KEY = value;
   }
}

다양한 상수, 변수들을 정의해둔 모습이다.

위에서부터 알아보자.

  • SECRET_KEY: OpenAI API를 사용하기 위한 인증키
  • CHAT_URL: 대화형 모드에 사용되는 API endpoint URL
  • COMPLETIONS_URL: 일반적인 GPT-3 API endpoint URL
  • MEDIA_TYPE: 요청 및 응답에서 사용되는 미디어 타입
  • AUTHORIZATION: HTTP 헤더에서 인증을 위해 사용되는 필드 이름
  • BEARER: HTTP 헤더에서 Bearer 토큰의 접두어로 사용되는 문자열
  • CHAT_MODEL: 대화형 모드에서 사용되는 모델 이름
  • COMPLETIONS_MODEL: 일반적인 GPT-3 API에서 사용되는 모델 이름
  • TOP_P: Top-p 샘플링에서 사용되는 값으로, 0에서 1 사이의 실수값을 가짐
  • TEMPERATURE: 생성된 텍스트의 다양성을 조절하는 값으로, 0에서 2 사이의 실수값을 가짐. (2에 가까울 수록 장황해진다.)
  • MAX_TOKENS: 생성된 텍스트의 최대 길이를 제한하는 값으로, 기본값은 16이지만 25로 설정됨
  • STREAM: 일부 API에서 생성된 텍스트를 스트리밍으로 반환하는지 여부를 결정하는 값으로, 기본값은 false이지만 true 설정됨

2.Controller

다음으로 Controller를 살펴보자.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/ai")
@Validated
public class ChatGptController {
    private final ChatGptService chatGptService;

    @GetMapping("/test/sse")
    public SseEmitter streamSseMvc() {
        SseEmitter emitter = new SseEmitter();
        ExecutorService sseMvcExecutor = Executors.newSingleThreadExecutor();
        sseMvcExecutor.execute(() -> {
            try {
                for (int i = 0; true; i++) {
                    SseEmitter.SseEventBuilder event = SseEmitter.event()
                                                           .data("SSE MVC - " + LocalTime.now().toString())
                                                           .id(String.valueOf(i))
                                                           .name("sse event - mvc");
                    emitter.send(event);
                    Thread.sleep(1000);
                }
            } catch (Exception ex) {
                emitter.completeWithError(ex);
            }
        });

        return emitter;
    }

    /*
     * # ChatGPT 챗
     *
     */
    @PostMapping("/question")
    public SseEmitter sendQuestion(@Valid @RequestBody ChatGptDto.Question requestBody) {
        SseEmitter emitter = new SseEmitter();

        Flux<ServerSentEvent<ChatGptDto.ResponseQuestion>> fluxResponseQuestion =
            chatGptService.askQuestion(requestBody.getQuestion());

        fluxResponseQuestion
            .map(event -> Optional.ofNullable(event.data()))
            .subscribe(
                data -> {
                    try {
                        assert data.isPresent();
                        emitter.send(data);
                    } catch (IOException e) {
                        emitter.completeWithError(e);
                    }
                },
                emitter::completeWithError,
                emitter::complete
            );

        return emitter;
    }

    /*
     * # ChatGPT 다빈치
     *
     */
    @PostMapping("/completions")
    public ResponseEntity<?> sendCompletions(@RequestBody ChatGptDto.Completions requestBody) {
        ChatGptDto.ResponseCompletions responseCompletions = chatGptService.askCompletions(requestBody.getPrompt());

        return ResponseEntity.ok().body(ApiResponse.ok("data", responseCompletions));
    }
}

먼저 엔드포인트의 기본 경로를 'api/v1/ai'로 설정했고, 맨윗줄에 ChatGptService 인스턴스를 주입받고 있다. 서비스는 ChatGPT API 사용하여 요청을 처리한다. 프론트에서 보낼수 있지만, 좀 더 사용하기 편한 api를 제공하기 위해서 sse를 수신하기 위해 webflux를 도입했다고 하신다. 우리는 챗 모델과 다빈치 모델 두가지를 썼다. 하지만 실제로 다빈치모델을 사용하진 않는다고 한다. (다빈치모델은 응답시간이 너무 오래걸린다는 단점때문에 사용성이 너무 떨어진다)

OpenAI api strem 방식

1. 첫번째 메서드 streamSseMvc 를 알아보자.

streamSseMvc() 메서드는 Server-Sent Events (SSE)를 사용했다. 이는 주기적으로 클라이언트에게 데이터를 전송하는 엔드포인트 이다. SSE를 사용하기 위해서 OpenAi API 에서 제공하는 Stream 방식을 채택했다고 하셨다. Stream 방식에대한 설명을 위에 첨부했다. 참고바랍니다.

SSE는 클라이언트와 서버 간에 지속적인 연결을 유지하면서 서버에서 이벤트를 생성하고 클라이언트에게 이벤트를 전송하는 단방향 비동기 기술이다. 클라이언트는 이벤트 스트림을 구독하고 서버가 이벤트를 생성하면 해당 이벤트를 수신한다.

streamSseMvc() 메서드는 SseEmitter 객체를 생성하여 SSE 이벤트를 클라이언트로 보내고, 이벤트 생성을 위한 ExecutorService를 생성한다. ExecutorService는 이벤트 생성을 위한 스레드를 하나 생성하고, 이 스레드에서는 1초마다 "SSE MVC - 현재 시각"과 같은 데이터를 생성하여 SseEmitter 객체에 전송합니다.

따라서 클라이언트가 SSE 스트림을 구독하면, streamSseMvc() 메서드에서 생성되는 SSE 이벤트 스트림을 받아서 실시간으로 데이터를 수신할 있습니다.

2. 다음 sendQuestion() 메서드를 알아보자.

sendQuestion() 메서드는 ChatGptDto.Question 타입의 request body를 받아 ChatGptService를 통해 GPT-3 API에 질문을 보내고, 그에 대한 답변을 SSE(Server-Sent Event)로 클라이언트에게 전송하는 REST API 엔드포인트 이다.

sendQuestion() 메서드의 동작은 다음과 같다.

  1. ChatGptDto.Question 타입의 request body를 받는다.
  2. ChatGptService를 통해 GPT-3 API에 질문을 보낸다.
  3. ChatGptService에서 반환한 Flux<ServerSentEvent<ChatGptDto.ResponseQuestion>> 객체를 생성한다.
  4. Flux<ServerSentEvent<ChatGptDto.ResponseQuestion>> 객체를 구독(subscribe)하고, 이벤트 스트림을 구독자(emitter)에게 전달한다.
  5. 구독자(emitter)가 SSE(Server-Sent Event) 스트림으로 클라이언트에게 이벤트를 전송한다.

따라서 메서드는 SSE(Server-Sent Event) 사용하여 실시간으로 클라이언트와 상호작용하는 ChatGpt API 구현하는 사용된다.

3. sendCompletions() 메서드를 알아보자.

sendCompletions() 메서드는 POST 요청을 받아 ChatGptService를 통해 OpenAI의 Davinci Codex 모델로부터 입력 프롬프트에 대한 자연어 생성을 요청한다. 요청 바디의 JSON 객체에는 prompt 필드가 있으며, 이 필드는 생성할 자연어의 프롬프트다.

ChatGptService에서는 ChatGpt 클래스를 사용하여 OpenAI API에 요청을 보내고, 그 결과를 ResponseCompletions 객체로 매핑하여 반환함. 이 객체는 요청에 대한 응답으로 생성된 자연어 텍스트와 함께 전송된다.

마지막으로, ResponseEntity.ok() 사용하여 200 OK 응답과 함께 ResponseCompletions 객체를 반환함. ApiResponse 사용하여 반환하며, 응답 JSON 객체의 data 필드에 ResponseCompletions 객체를 넣는다.

 

3. Dto

간단할줄 알았는데 생각보다 길었다.

public class ChatGptDto {
    @Getter
    @NoArgsConstructor
    public static class Question implements Serializable {
        @NotBlank
        @NotNull(message = "Null 값은 입력할 수 없습니다.")
        private String question;

        @Builder
        public Question(String question) {
            this.question = question;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class Completions implements Serializable {
        @NotBlank
        @NotNull(message = "Null 값은 입력할 수 없습니다.")
        private String prompt;

        @Builder
        public Completions(String question) {
            this.prompt = question;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class RequestQuestion implements Serializable {
        private String model;
        private List<Map<String, String>> messages;
        private Double temperature;
        @JsonProperty("top_p")
        private Double topP;
        private Boolean stream;

        @Builder
        public RequestQuestion(String model, List<Map<String, String>> messages, Double temperature, Double topP, Boolean stream) {
            this.model = model;
            this.messages = messages;
            this.temperature = temperature;
            this.topP = topP;
            this.stream = stream;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class RequestCompletions implements Serializable {
        private String model;
        private String prompt;
        @JsonProperty("max_tokens")
        private Integer maxTokens;
        private Double temperature;
        @JsonProperty("top_p")
        private Double topP;

        @Builder
        public RequestCompletions(String model, String prompt, Integer maxTokens, Double temperature, Double topP) {
            this.model = model;
            this.prompt = prompt;
            this.maxTokens = maxTokens;
            this.temperature = temperature;
            this.topP = topP;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class ResponseQuestion implements Serializable {
        private String id;
        private String object;
        private String model;
        private Long created;
        private List<ResponseQuestionChoice> choices;

        @Builder
        public ResponseQuestion(String id, String object, String model, Long created, List<ResponseQuestionChoice> choices) {
            this.id = id;
            this.object = object;
            this.model = model;
            this.created = created;
            this.choices = choices;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class ResponseCompletions implements Serializable {
        private String id;
        private String object;
        private String model;
        private Long created;
        private List<ResponseCompletionsChoice> choices;
        private Map<String, String> usage;

        @Builder
        public ResponseCompletions(String id, String object, String model, Long created, List<ResponseCompletionsChoice> choices, Map<String, String> usage) {
            this.id = id;
            this.object = object;
            this.model = model;
            this.created = created;
            this.choices = choices;
            this.usage = usage;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class ResponseQuestionChoice implements Serializable {
        private Map<String, String> delta;
        private Integer index;

        @JsonProperty("finish_reason")
        private String finishReason;

        @Builder
        public ResponseQuestionChoice(Map<String, String> delta, Integer index, String finishReason) {
            this.delta = delta;
            this.index = index;
            this.finishReason = finishReason;
        }
    }

    @Getter
    @NoArgsConstructor
    public static class ResponseCompletionsChoice implements Serializable {
        private String text;
        private Integer index;
        private String logprobs;
        @JsonProperty("finish_reason")
        private String finishReason;


        @Builder
        public ResponseCompletionsChoice(String text, Integer index, String logprobs, String finishReason) {
            this.text = text;
            this.index = index;
            this.logprobs = logprobs;
            this.finishReason = finishReason;
        }
    }

    private static String convertTimestamp(Long created) {
        Instant instant = Instant.ofEpochSecond(created);
        LocalDateTime datetime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());

       return String.format("%tF %<tT", datetime);
    }
}

 

먼저, Question 클래스는 질문 데이터를 담기 위한 클래스임.

  • @NotBlank와 @NotNull 어노테이션을 사용하여 유효성 검사를 함.
  • question 필드는 질문 문자열을 저장한다.
  • @Builder 어노테이션을 사용하여 빌더 패턴을 적용하여 객체를 생성할 수 있음.

다음으로, Completions 클래스는 입력에 대한 자동완성 데이터를 담기 위한 클래스임.

  • @NotBlank와 @NotNull 어노테이션을 사용하여 유효성 검사를 함.
  • prompt 필드는 입력 문자열을 저장한다.
  • @Builder 어노테이션을 사용하여 빌더 패턴을 적용하여 객체를 생성할 수 있음.

RequestQuestion 클래스는 ChatGpt API에 질문을 요청하는 데 필요한 데이터를 담기 위한 클래스다.

  • model 필드는 사용할 모델의 이름을 저장한다.
  • messages 필드는 이전 대화에서의 메시지를 저장하는 Map 객체의 리스트입니다. 이전 대화가 없는 경우 비어있는 Map 객체를 전달한다.
  • temperature 필드는 모델 출력의 다양성을 제어하는 데 사용된다.
  • topP 필드는 생성된 텍스트의 예측 가능성을 제어하는 데 사용된다.
  • stream 필드는 대화의 실시간 스트리밍을 사용할지 여부를 저장함.
  • @Builder 어노테이션을 사용하여 빌더 패턴을 적용하여 객체를 생성할 수 있음.

RequestCompletions 클래스는 입력 문자열에 대한 자동완성을 요청하기 위한 데이터를 담기 위한 클래스.

  • model 필드는 사용할 모델의 이름을 저장합니다.
  • prompt 필드는 입력 문자열을 저장합니다.
  • maxTokens 필드는 생성할 최대 토큰 수를 저장합니다.
  • temperature 필드는 모델 출력의 다양성을 제어하는 데 사용됩니다.
  • topP 필드는 생성된 텍스트의 예측 가능성을 제어하는 데 사용됩니다.
  • @Builder 어노테이션을 사용하여 빌더 패턴을 적용하여 객체를 생성할 있습니다.

ResponseQuestionChoice 클래스는 ChatGPT API로부터 받은 답변의 선택지에 대한 정보를 담고 있습니다.

  • delta 필드는 선택지가 추가로 생성되는 경우 이전 선택지와의 차이를 나타내며, index 필드는 해당 선택지의 인덱스 번호를 나타냅니다. finishReason 필드는 선택지 생성이 완료되었는지 여부를 나타내는데, 만약 생성이 완료되었다면 "stop"으로 설정됩니다.
  • ResponseCompletionsChoice 클래스는 ChatGPT API로부터 받은 문장 생성 결과에 대한 정보를 담고 있습니다.
  • text 필드는 생성된 문장의 내용을 나타냅니다. index 필드는 생성된 문장의 인덱스 번호를 나타냅니다. logprobs 필드는 생성된 문장의 확률값을 나타내는데, 해당 필드는 현재는 사용하지 않고 있습니다. finishReason 필드는 문장 생성이 완료되었는지 여부를 나타내는데, 만약 생성이 완료되었다면 "stop"으로 설정됩니다.

convertTimestamp 메소드는 ChatGPT API로부터 받은 생성 시각 정보를 포맷팅하여 반환하는 역할을 합니다.

  • Instant 클래스와 LocalDateTime 클래스를 사용하여 입력된 시각 정보를 yyyy-MM-dd HH:mm:ss 형식의 문자열로 변환합니다. 이 메소드는 ChatGPT API로부터 받은 응답 객체의 생성 시각 정보를 포맷팅하여 반환하는데 사용됩니다.

4.Service

@Service
public class ChatGptService {
    WebClient webClient = WebClient.create();
    private static final RestTemplate restTemplate = new RestTemplate();

    private <T> HttpEntity<T> buildRequest(T chatGptRequest) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.parseMediaType(ChatGptConfig.MEDIA_TYPE));
        headers.add(ChatGptConfig.AUTHORIZATION, ChatGptConfig.BEARER + SECRET_KEY);

        return new HttpEntity<>(chatGptRequest, headers);
    }

    public Flux<ServerSentEvent<ChatGptDto.ResponseQuestion>> askQuestion(String question) {
        ObjectMapper objectMapper = new ObjectMapper();

        try {
            ChatGptDto.RequestQuestion build =
                ChatGptDto.RequestQuestion.builder()
                    .model(ChatGptConfig.CHAT_MODEL)
                    .messages(List.of(
                        new HashMap<>() {{
                            put("role", "user");
                            put("content", question);
                        }}
                    ))
                    .temperature(ChatGptConfig.TEMPERATURE)
                    .topP(ChatGptConfig.TOP_P)
                    .stream(ChatGptConfig.STREAM)
                    .build();

            return webClient.post()
                       .uri(ChatGptConfig.CHAT_URL)
                       .contentType(MediaType.APPLICATION_JSON)
                       .header(ChatGptConfig.AUTHORIZATION, ChatGptConfig.BEARER + SECRET_KEY)
                       .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
                       .body(BodyInserters.fromValue(objectMapper.writeValueAsString(build)))
                       .retrieve()
                       .bodyToFlux(ChatGptDto.ResponseQuestion.class)
                       .subscribeOn(Schedulers.boundedElastic())
                       .onErrorComplete()
                       .map(message -> ServerSentEvent.<ChatGptDto.ResponseQuestion>builder()
                                           .event("message")
                                           .data(message)
                                           .build()
                       );
        } catch (Exception e) {
            throw new BusinessLogicException(ExceptionCode.INTERNAL_SERVER_ERROR);
        }
    }

    public ChatGptDto.ResponseCompletions askCompletions(String prompt) {
        ResponseEntity<?> response = restTemplate.postForEntity(
            ChatGptConfig.COMPLETIONS_URL,
            buildRequest(
                new ChatGptDto.RequestCompletions(
                    ChatGptConfig.COMPLETIONS_MODEL,
                    prompt,
                    ChatGptConfig.MAX_TOKENS,
                    ChatGptConfig.TEMPERATURE,
                    ChatGptConfig.TOP_P
                )
            ),
            ChatGptDto.ResponseCompletions.class
        );

        return (ChatGptDto.ResponseCompletions) response.getBody();
    }
}

 

askQuestion(String question):

인자로 받은 question 값을 질문으로 간주해, ChatGptConfig에서 설정된 URL, 모델, 파라미터들을 이용하여 WebClient 사용하여 요청을 보낸다.해당 요청은 Event Stream 이용하여 응답을 받으며, 받은 응답을 Flux 형태로 리턴함. 받은 Flux ServerSentEvent 형태로 변환하여 전달한다.

askCompletions(String prompt):

인자로 받은 prompt 값을 input으로 간주해, ChatGptConfig에서 설정된 URL, 모델, 파라미터들을 이용하여 RestTemplate를 사용하여 요청을 보낸다.

해당 요청은 HTTP 요청으로 응답을 받고, 받은 응답을 ChatGptDto.ResponseCompletions 형태로 리턴한다.

우리 프로젝트에선 이렇게 잘 작동이 된다.