ChatGPT를 활용한 Node.js API 개발

Model - GPT 3.5 Turbo

대기열에 등록해서 바로 지금 실사용하기 힘든 GPT4를 제외한다면, OpenAI에서 제공하는 GPT3.5 모델을 이용하는 것이 가장 무난한 선택이었다. GPT 3.5 Turbo 모델으로 문자열 입력, 문자열 출력을 하는 대부분의 로직을 "적절한" 프롬프트만 있다면 만들어낼 수 있다. 특히, JSON 포맷으로 출력해달라는 프롬프트를 추가하는 경우 ChatGPT가 생성한 결과를 가져다 쓰기도 간편하다. GPT 3.5 Turbo 모델을 말고도 text-davinci-002, text-davinci-003 등의 모델이 있으나, 생성한 결과를 JSON으로 포맷팅하기 힘듦, GPT 3.5 Turbo보다 비쌈, 출력 속도가 조금 더 느림, 귀찮음 그리고 이런 저런 추가적인 리소스를 감내할 정도로 출력 결과 퀄리티가 충분히 차이나지 않음의 이유로 GPT 3.5 Turbo 모델을 사용하기로 했다.

Pricing

토큰 당 과금하는 방식이다. What are tokens and how to count them? 문서에서 확인할 수 있듯, 언어별로 다르며, 대략 알파벳으로 4글자 정도가 1토큰으로 책정된다. API를 호출하지 않고 내 문장이 몇 토큰인지 확인하려면 다음 라이브러리를 사용한다. GPT-3-Encoder, tiktoken.

Npm Package - openai

OpenAI 사에서 개발하고 관리하는 npm package이다. browser와 node.js에서 사용할 수 있다. 내부적으로 http 요청을 처리하기 위해 axios를 사용한다. 그리고 이 axios가 browser에서는 stream response type을 지원하지 않는다. 따라서, OpenAI의 문서대로 responseType을 stream으로 지정하더라도, browser에서는 응답을 스트리밍 받을 수 없다. 물론, api key & org id의 노출, 프롬프트의 노출 등의 이유로 브라우저에서 직접 openai에 API 호출을 요청하는 것은 그리 똑똑한 생각은 아니기에 큰 문제는 없지만, 혹시라도 프론트엔드에서 스트리밍을 하고 싶다면 openai package를 사용하지 않고 직접 http 요청을 보내야한다.

사용하려는 Model에 따라 createCompletion, createChatCompletion 를 골라서 요청하면 된다. GPT 3.5 Turbo의 경우 후자를 사용한다. ChatGPT에게 보낼 프롬프트 문자열이 변수 prompt 에 할당되있다고 한다면,

const foo = async ... => {
  const res = await openai.createChatCompletion({
    model: 'gpt-3.5-turbo',
    messages: [
      {
        role: 'system',
        content: prompt,
      },
    ],
  });

  const gptOutput = res.data.choices[0].message.content;  
  ...
};

위와 같이 요청을 보내서 응답을 받을 수 있다.

이제 우리는 적절한 프롬프트를 작성하고, ChatGPT에게 요청한 뒤에, 응답이 유효한지 확인(validate)해서 사용하면 된다.

Prompt

openai tokenizer에서 직접 테스트해보면,

안녕하세요 좋은 아침입니다 -> 32 tokens
안녕 좋은 아침 -> 17 tokens
hello good morning -> 3 tokens

영어보다 한글이 토큰을 훨씬 많이 먹는다는 점, 그리고 ChatGPT가 생성한 결과물이 은근한 직역투 (Say goodbye to st.의 직역투인 ~와는 작별하세요! 등...)가 있는 점을 미루어보아 ChatGPT의 제 1언어는 영어라고 가정하고, 프롬프트를 영어로 작성하기로 했다.

내가 구현한 API는 사용자로부터 입력값을 받아, 프롬프트에 과거 성과가 좋았던 데이터를 넣어서 새로운 광고 제목을 추천하는 것이었다. 그 프롬프트의 아웃라인은 다음과 같다.

You are an API server for suggesting ad title for given content.
Since you are an API server, you should response with whitespace-free JSON, without ```.
(A)
The ad title should be written in (B).

(C)

Now, make an API response with whitespace-free JSON, without ```, escaped " with ', at least 5 suggestion contained, contains suggestions as string array (e.g. ["abc", "def"], without any wrapping object).:

(A)에는 유저의 입력값을 넣고, (B)에는 "Korean"과 같은 타겟 언어를 넣었다. (C)에는 해당 유저와 관련된, 그리고 ChatGPT가 광고 제목을 생성하는 데에 참고할 수 있는 과거 데이터를 넣었다. (A)와 (B)까지는 api key 등의 노출 이슈를 제외하면 프론트엔드만으로도 달성할 수 있다. 하지만, 과거 데이터 (C)를 참고하려면 데이터베이스의 접속이 필요해지기 때문에 백엔드가 필수적이다.

관절, 통증을 광고하는 광고주에 (A)로 박청호가 반한 터키 아이스크림 맛은? 를 입력하면 다음과 같은 광고 타이틀을 얻을 수 있다.

내 몸을 20대로 되돌리다! '터키 아이스크림'으로 관절 통증 태워라!
박청호도 극찬한 '터키 아이스크림'! 내 몸에도 쏙쏙 적용해봐요!
무릎, 허리, 관절 통증 이젠 이만한 '터키 아이스크림'이 있다!
비싼 수술 필요 없다! '터키 아이스크림'으로 신개념 관절 관리하자!
평생 깨알같이 '관절 통증' 고민하던 당신, 이제 부러우리들한테 자랑할 '터키 아이스크림'을 만나세요!

하지만 ChatGPT로 받은 응답은 위와 같은 형식이 아닌, 다음과 같은 stringified JSON 문자열 배열이다.

["내 몸을 20대로 되돌리다! '터키 아이스크림'으로 관절 통증 태워라!","박청호도 극찬한 '터키 아이스크림'! 내 몸에도 쏙쏙 적용해봐요!","무릎, 허리, 관절 통증 이젠 이만한 '터키 아이스크림'이 있다!","비싼 수술 필요 없다! '터키 아이스크림'으로 신개념 관절 관리하자!","평생 깨알같이 '관절 통증' 고민하던 당신, 이제 부러우리들한테 자랑할 '터키 아이스크림'을 만나세요!"]

또한 ChatGPT는 순수하지 않기 때문에, 같은 입력에도 다른 출력을 한다. 이전에 받은 출력과는 다르게 한국어가 아닌 영어로 광고 제목을 추천해주거나, JSON으로 출력해달라고 프롬프트에 명시해놓았으나 그것을 깜빡하는 경우도 있으며, 내가 원하는 JSON 포맷이 아닌 경우도 있다. 따라서, 우리는 ChatGPT로부터 응답받은 결과값을 검증해야한다.

Validation

검증은 두 가지로 분류할 수 있다. 형식(Format)에 대한 검증, 그리고 내용(Content)에 대한 검증. 전자부터 설명한다.

import _ from 'lodash-es';

const validateFormat = (gptOutput: string): boolean => {
  try {
    const suggestions = JSON.parse(gptOutput);
    if (!_.isArray(suggestions)) return false;
    if (suggestions.length < 5) return false;
    const isAllStrings = suggestions.every(_.isString);
    if (!isAllStrings) return false;
    return true;
  } catch (error) {
    // TODO: log
    return false;
  }
};

내가 요청한 형식대로 ChatGPT가 응답했는지를 검증해야한다. 프롬프트에 문자열 배열로 반환하라고 여러번 설명을 했는데도 불구하고, 문자열 배열이 아닌 {"suggestions":["a", "b", "c"]} 와 같이 응답한 경우도 있고, ol > li 의 형식으로 응답하기도 한다. 따라서, 위 코드와 같이 JSON 파싱에 실패하거나, 문자열의 길이가 5 이하이거나 (프롬프트에서 최소 5개 이상의 제안을 포함함을 명시했다), 배열의 모든 요소가 문자열이 아니면 invalid gptOutput으로 간주한다.

이제는 후자, 내용(Content)에 대한 검증으로 들어가보자.

type ValidateContentOptions = {
  targetLanguage?: string;
  ...
};

const validateContent = async (suggestion: string, options: ValidateContentOptions = { ... }): Promise<...> => {
  ...
};

내용에 대한 검증은 비즈니스 요구사항에 따라 문자열의 길이 등 여러가지가 있겠지만, 그 중 하나는 생성된 내용이 원하는 타겟 언어로 잘 생성되었는지에 대한 확인이다. 이는, franc 또는 AWS Comprehend DetectDominantLanguage 등을 이용해서 검증할 수 있다. AWS.Comprehend의 경우 5개 문자열의 언어 감지 요금으로 약 2~5원 정도가 청구된다. 다른 라이브러리 languagedetect의 경우 마지막 커밋이 2020년 7월이기도 한데, 한국어를 탐지하지 못한다. franc의 경우 ESModule이라 기존 프로젝트의 설정에 따라 쉽게 import하지 못할 수도 있다.

Retry

함수를 이렇게 잘게 나눈 이유는 (설명의 편의도 있지만) 재시도가 필요하기 때문이다. 재시도를 할 때마다 프롬프트를 새로 생성한다면 어떨까? 프롬프트 문자열을 생성하는 함수에서 과거 데이터를 조회하는 경우, 재시도를 할 때마다 데이터베이스에 접근해야한다. 이보단 이미 생성된 프롬프트가 있다면 그것을 재사용하는 것이 낫지 않을까?

포맷이 맞지 않는 경우는 ChatGPT로부터 받은 모든 결과값을 사용하지 않고 폐기하면 되지만, 응답받은 5개의 광고 제목 중 4개가 한국어, 1개가 영어인 경우는? 재시도해서 두 번째로 받은 결과값에서 1개가 한국어, 나머지 4개가 영어인 경우는? 이런 경우, 세 번째 재시도를 하기 보다는 (= 응답의 전부가 한국어일때까지 시도하기 보다는), 첫 번째 결과와 두 번째 결과에서 한국어만 병합해서 응답하는게 낫지 않을까?

따라서 이를 JavaScript로 러프하게 구현하면 다음과 같다.

const getContentTitleSuggestions = async ({ prompt, targetLanguage }, result = [], retry = 0) => {
  if (result.length >= 5) return result;
  if (retry > 5) throw new Error(`...`);

  const res = await openai.createChatCompletetion(...);
  const gptOutput = ...;
  if (!validateFormat(gptOutput)) return getContentTitleSuggestions({ prompt, targetLanguage }, result, retry + 1);
  const validSuggestions = ...;
  return getContentTitleSuggestions({ prompt, targetLangauge }, [...validSuggestions, ...result], retry + 1);
};

임의의 재시도 횟수 5를 가지고 재귀적으로 구현한 위의 getContentTitleSuggestion()는 6번의 재시도 후에도 원하는 결과를 얻지 못하면 에러를 던진다.

Future Works

  1. JSON 타입으로 반환하는 모델을 원해 GPT 3.5 Turbo만을 이용했지만, text-davinci-003의 ? replacement 기능 등을 이용해 다른 모델을 사용할 수도 있을 것이다.

  2. (현재 waitlist에서 대기 중인) 이미지를 이해한다는 GPT 4를 사용할 수 있게 된다면, 다양한 입력을 넣을 수 있을 것이다.

  3. 이미지를 만들어주는 DALL.E 서비스를 활용한다면 광고 텍스트가 아닌 광고 썸네일 제안에도 사용할 수 있을 것이다.

  4. 생성된 결과물의 정량적인 평가는 어떻게 하면 좋을까? 프롬프트 A와 프롬프트 B를 평가하기 위한 방법으로 A/B 테스트 말고 다른 방법은 없을까?

  5. 생성 결과와 사용자가 선택한/선택하지 않은 (factual / counterfactual) 제안 등을 로그로 남기고, 이를 다음 프롬프트에 녹이는 것은 어떨까?