현재 프로젝트의 목표를 간단하게 정리하면 서비스하고 있는 웹툰들을 크롤링한 후에 플랫폼 간의 장르 키워드를 통일하고 통일한 키워드를 기준으로 비슷한 장르의 웹툰을 추천해주는 프로젝트이다.
이번에는 openai의 업데이트와 함께 npm의 openai 라이브러리가 최근 업데이트가 되었기 때문에 내 프로젝트의 openai 라이브러리도 최신버전으로 업그레이드 후에 코드도 리팩토링 해볼려고 한다.
$ npm install --save openai
먼저 openai변수를 선언해주고 API_KEY를 configService로 불러온 뒤, OpenAI를 생성한다.
private openai: OpenAI;
constructor(
private readonly configService: ConfigService,
@Inject(CACHE_MANAGER)
private readonly cacheManager: Cache,
) {
const API_KEY: string = configService.get<string>("OPENAI_API_KEY");
const configuration = { apiKey: API_KEY };
this.openai = new OpenAI(configuration);
}
openAI의 API_KEY는 유출을 조심해야 한다. 깃허브에 올리는 등으로 유출이 될 경우 openAI측에서 감지하여 API_KEY를 정지시키는 조치를 취하면 다행이지만 만약 그렇지 못한다면 매우 안타까운 상황이 발생한다.
openAI API를 이용해서 chatGPT에게 요청을 보낼때 3.5버전 이상의 모델과 그 밑의 모델의 prompt는 형태가 다르다. 공식문서에서는 이 예시를 자세히 보여준다.
3.5이상
{"messages": [{"role": "system", "content": "Marv is a factual chatbot that is also sarcastic."}, {"role": "user", "content": "What's the capital of France?"}, {"role": "assistant", "content": "Paris, as if everyone doesn't know that already."}]}
{"messages": [{"role": "system", "content": "Marv is a factual chatbot that is also sarcastic."}, {"role": "user", "content": "Who wrote 'Romeo and Juliet'?"}, {"role": "assistant", "content": "Oh, just some guy named William Shakespeare. Ever heard of him?"}]}
{"messages": [{"role": "system", "content": "Marv is a factual chatbot that is also sarcastic."}, {"role": "user", "content": "How far is the Moon from Earth?"}, {"role": "assistant", "content": "Around 384,400 kilometers. Give or take a few, like that really matters."}]}
3.5미만
{"prompt": "<prompt text>", "completion": "<ideal generated text>"}
{"prompt": "<prompt text>", "completion": "<ideal generated text>"}
{"prompt": "<prompt text>", "completion": "<ideal generated text>"}
위와 같이 prompt의 형태가 다르기 때문에 당연히 우리의 openai 서비스에서도 코드를 작성할때 prompt에 따른 두가지 함수를 작성해준다.
먼저 3.5버전 이상이다.
async create_3_5_Completion(
model: string,
prompt: ChatCompletionMessageParam[],
temperature: number,
maxTokens: number,
): Promise<any> {
try {
const params: OpenAI.Chat.ChatCompletionCreateParams = {
model,
messages: prompt,
temperature,
max_tokens: maxTokens,
};
const completion = await this.openai.chat.completions.create(params);
return completion.choices[0].message.content;
} catch (err) {
if (err instanceof OpenAI.APIError) {
console.log(err.status);
console.log(err.name);
console.log(err.headers);
} else {
throw err;
}
}
}
다음은 3.5버전 미만이다.
async createCompletion(
model: string,
prompt: string,
temperature: number,
max_tokens: number,
): Promise<string> {
try {
const params: OpenAI.CompletionCreateParams = {
model,
prompt,
temperature,
max_tokens,
};
const completion = await this.openai.completions.create(params);
return completion.choices[0].text;
} catch (err) {
if (err instanceof OpenAI.APIError) {
console.log(err.status);
console.log(err.name);
console.log(err.headers);
} else {
throw err;
}
}
}
위와 같이 openAI 모델의 버전마다 params의 타입이나 메서드가 다르다.
나는 단어의 유사도 판단과 장르의 유사도를 통한 추천을 할때 embedding을 통해 진행할 것이다.
openai에서 embedding을 지원하는 모델은 여러가지가 있지만 공식문서에서 거의 모든 상황에서 text-embedding-ada-002 모델을 사용하는 것이 좋다고 나와있고 가격적인 측면에서도 가장 합리적이기 때문에 이를 사용하기로 결정했다.
async createEmbedding(model: string, input: string): Promise<number[]> {
try {
const response = await this.openai.embeddings.create({
model: OPENAI_EMBEDDING_MODEL,
input,
});
const embedding = response.data[0]?.embedding;
return embedding;
} catch (err) {
if (err instanceof OpenAI.APIError) {
console.log(err.status);
console.log(err.name);
console.log(err.headers);
} else {
throw err;
}
}
}
이번 프로젝트에서는 미세조정이 필요없어 보이지만 네이버, 카카오 웹툰들 중에서는 장르 키워드의 개수가 너무 작거나 아예 없는 경우가 있다. 그렇기에 그런 웹툰들은 직접 장르 키워드를 지정해줘야 하는데 문제는 너무 오랜 시간이 소요된다.
그렇기에 여기서 기초적인 장르 키워드를 ai를 통해 지정해주는 것이다. 기존의 openAI에 존재하는 모델을 사용해도 되지만 이러한 정해진 형식의 요청을 받고 답을 내며 특정 역할을 수행하는 작업을 할 때 미세조정을 해주는 것이 훨씬 더 좋은 결과를 내기때문에 미세조정을 하기로 결정했다.
먼저 미세조정에 필요한 파일을 업로드한다.
async createFileUpload(path: string): Promise<FileObject> {
if (path.split(".").pop() !== "jsonl") {
throw new BadRequestException("Upload File's type must be jsonl..");
}
const file= fs.createReadStream(path);
const upload = await this.openai.files.create(
{
file,
purpose: "fine-tune"
}
);
return upload;
}
이때 파일의 형식은 jsonl이 필수이다.
다음으로 업로드된 파일을 미세조정한다.
async createFineTuneModel(fileId: string, model ?: string): Promise<FineTuningJob> {
try {
const trainingFile = fileId;
const trainingModel = model ? model : OPENAI_FINETUNE_BABBAGE_MODEL;
const fineTune = await this.openai.fineTuning.jobs.create(
{
training_file: trainingFile,
model: trainingModel,
hyperparameters: { n_epochs: 4 }
}
)
return fineTune;
} catch (e) {
if (e instanceof OpenAI.APIError) {
console.log(e.status);
console.log(e.name);
console.log(e.headers);
console.log(e);
} else {
throw e;
}
}
}
이때 hyperparameters에 n_epochs가 들어가 있는데 이는 해당 파일을 몇번 반복해서 학습할지를 의미하는 옵션으로 이외에도 여러가지 옵션이 있다.
앞에서는 작업을 요청했지만 이번에는 업로드한 파일이나 미세조정중인 작업의 목록을 확인해볼 것이다.
// 업로드 파일 목록 불러오기
async getUploadFileList(): Promise<FileObject[]> {
const uploadFileData = await this.openai.files.list();
const uploadFileList = uploadFileData.data;
return uploadFileList;
}
async getFineTuningList(): Promise<FineTuningJob[]> {
const fineTuningData = await this.openai.fineTuning.jobs.list();
const fineTuningList = fineTuningData.data;
return fineTuningList;
}
async getFineTuneList(): Promise<FineTune[]> {
const fineTuneData = await this.openai.fineTunes.list();
const fineTuneList = fineTuneData.data;
return fineTuneList;
}
파일을 잘못 올렸거나 동일한 파일을 여러개 올려야 할때 파일을 삭제해야 할 것이고, 미세조정을 잘못해서 도중에 멈춰야할 때 취소하는 작업이 필요하다.
async deleteUploadFile(id: string): Promise<void> {
try {
await this.openai.files.del(id);
} catch (e) {
if (e instanceof OpenAI.APIError) {
console.log(e.status);
console.log(e.name);
console.log(e.headers);
} else {
throw e;
}
}
}
async cancleFineTuning(id: string): Promise<void> {
try {
await this.openai.fineTuning.jobs.cancel(id);
} catch (e) {
if (e instanceof OpenAI.APIError) {
console.log(e.status);
console.log(e.name);
console.log(e.headers);
} else {
throw e;
}
}
}
나는 이번 프로젝트에서 미세조정에 gpt-3.5-turbo를 사용했고 예상 가격을 미리 계산해서 적당하고 생각하는 만큼 epoch를 지정해주었다. 하지만 openai api를 처음 이용하는 사람들은 얼마가 결제되는지를 잘 모르고 이용하는 경우가 있다. 따라서 다음과 같은 계산법으로 미리 계산을 하고 미세조정을 시작하자.
(1000 토큰당 모델 가격) X (내 파일 토큰 총량 / 1000) X epoch = 총 가격
물론 이는 정확하지 않을 수 있지만 내 경험상으로는 결과값이 거의 비슷했다. 내 파일의 토큰 총량은 openai에서 지원하는 tokenizer에서 확인할 수 있다.
나뿐만 아니라 많은 사람들이 json은 들어보았지만 jsonl은 많이 접하지 못하였다. openai api를 통해 파일을 업로드하고 미세조정 작업을 할려면 json을 jsonl형식으로 바꾸는 작업이 필요하다. 따라서 다음과 같이 변환하는 함수를 작성해주었다.
transformToJsonl(filePath: string): void {
let jsonlData: string = "";
const arr: any[] = require(filePath);
arr.forEach((data) => {
jsonlData += JSON.stringify(data) + "\n";
});
fs.writeFileSync(
OPENAI_JSONL_FOLDER_PATH + path.basename(filePath, ".json") + ".jsonl",
jsonlData,
{ encoding: "utf-8" }
);
}