loading

(LLama2-cpp) CPU에서 돌아가는 나만의 디스코드 챗봇 만들기


Discord bot, Chatting bot

Published on September 20, 2023 by JunYoung

Automatic system Chatting LLM NLP

13 min READ

과연 내가 만든 봇은 정말로 크롤러였던 것인가?

본인은 크롤링을 단 한번도 수행해보지 않은 채로 디스코드 봇을 통해 긱뉴스를 자동으로 업데이트하겠다는 당찬 포부(?)를 담고 지난 글에서 디스코드 봇을 열심히 만들었었다. 굉장히 흡족한 상태로 침대에 누워서 구글 검색에 “크롤링”을 치자 바로 다음과 같은 글이 등장했다. 혹시라도 읽어보실 분들은 매우 추천하는 글이라서 링크 올려드림(글 링크).

말 그대로 현존하는 파이썬 모듈 가져다가 대충 홈페이지 프론트에서 주어진 정보만 야금야금 빼내서 가져오는 selenium 그리고 BeautifulSoup 기반의 크롤러는 사실상 크롤러라고 부르면 안된다는 것이었다. 뒤통수를 한 대 맞은 기분이었지만 결국 내가 내린 결론은 내가 만든 디스코드 봇은 크롤러가 아니라는 것이다.

백엔드의 ‘백’자도 제대로 짚어보지도 않은 오만한 내 자신을 반성하고자 만든 봇 이름을 조금 수정하기로 했다. 원래는 crawler 라는 이름을 당당하게 붙였는데 이제는 그러면 안된다는 생각에..

이제부터 이 친구 이름은 ‘기어다니는 고양이’로 정했다. 암튼 크롤러는 아님. 그런데 아무리 생각해도 너무 자존심이 상하는 문제였다. 나는 나름 인공지능을 연구한다고 대학원까지 등록금을 몇백만원씩 내며 다니고 있는데, 내 전공을 살려서 자격을 잃은 고양이 친구의 위상을 다시 올리고 싶었다. 원래 계획은 단순한 긱뉴스 크롤러였는데 내가 어쩌다가 이렇게 됐는지는 모르겠다.


무료로 사용할 수 있는 언어 모델은 없을까?

세상엔 수많은 챗봇이 존재한다. 챗봇은 모두 Large Language Model(LLM)을 기반으로 한다.

챗지피티를 포함하여 여러 기업들의 언어 모델은 각각 API 프라이싱 정책이나 open source 유무에 따라 많은 차이가 있다. 나는 아직 돈이 없는 대학원생이기에 굳이 돈을 내고 써야하는 챗지피티 서비스 대신 조금 더 현실적인 친구를 도입하기로 했다. 바로 meta에서 최근 공개한 오픈 소스 LLM 중 하나인 Llama이다. 놀랍게도 Llama2는 오픈 소스이므로 누구나 마음대로 가져다가 파인 튜닝이 가능하다. 하지만 Llama2도 거대 언어 모델인 이상 리소스의 지옥을 벗어날 수는 없었다. 가장 적은 수의 파라미터가 $7B$만큼 필요한데, 이게 어느 정도의 수치냐면 완벽하게 사용하기 위해서는 28GB의 GPU RAM이 필요하다. 대형 모델인 70B를 쓰려면 이보다 많은 양의 GPU가 필요한데, 현실적으로 우리가 chat이 가능한 디스코드 봇을 만들자고 이 정도 사이즈의 언어 모델을 쓸 수는 없지 않은가?

그렇기 때문에 GPU RAM 대신 CPU를 사용해서 인퍼런스가 가능한 오픈 소스를 활용하고자 했다.


LLAMA-2 with cpp + python

https://github.com/ggerganov/llama.cpp, https://github.com/abetlen/llama-cpp-python 정말 세상에는 똑똑한 사람들이 많다. 대형 언어 모델을 low level과 high level로 접근할 수 있는 방법을 제안하여, 간단하게는 GPU 메모리가 많지 않은 데스크톱과 노트북을 포함한 윈도우/맥 기반의 여러 디바이스에서 돌릴 수 있게 C++로 경량화시킨 것이다. 어떻게 이 정도의 경량화가 가능한 것일까?

대형 모델은 일반적으로 비싼 GPU를 필요로 하는데 사실 GPU는 큰 메모리 대역폭과 계산 능력 때문에 딥러닝에서는 유리하지만, 메모리 대역폭이 종종 inference 단계에서 bottleneck으로 작용하게 된다. 왜냐하면 실제 연산을 목적으로 한다면 HBM 메모리(RAM)에서 온칩 메모리로 옯겨야 하기 때문이다. 또한 LLaMa 가중치를 위한 램 사용량에 있어서 Quantization(양자화)가 중요한데, 이때 precision과의 적당한 타협선을 찾아 양자화를 통해 모델을 저장하는 데 필요한 메모리 양을 줄여 표준 데이터센터 GPU 및 고급 소비자 GPU에 메모리에 맞출 수 있게 한다. 사실상 딥러닝에서 distillation이 가능한 이유/더 좋은 성능이 나오곤 하는 이유와 관련된다고도 생각해볼 수 있다. 진짜 이제는 가벼운 라마가 등장해버린 것이다. 얼마나 AI가 보급되기 편한 세상이 왔는가? Install하는 방법은 정말 간단하다. 참고로 본인은 우분투라서 우분투 기준으로 작성하고 있기는 하지만, 아마도 윈도우에서도 가능하지 않을까 싶다. 물론 위의 깃허브 링크를 들어가보면 윈도우에서도 빌드하는 과정을 리드미에 적어뒀기 때문에 그대로 따라하면 된다.

pip install llama-cpp-python

본인은 별다른 추가 작업 없이 위의 코드만으로도 해당 모듈을 사용하는데 큰 문제가 없었다. 리드미에 나와있는 모델 사용법 예시는 다음과 같다.

>>> from llama_cpp import Llama
>>> llm = Llama(model_path="./models/7B/llama-model.gguf")
>>> output = llm("Q: Name the planets in the solar system? A: ", max_tokens=32, stop=["Q:", "\n"], echo=True)
>>> print(output)
>>> import llama_cpp
>>> import ctypes
>>> llama_cpp.llama_backend_init(numa=False) # Must be called once at the start of each program
>>> params = llama_cpp.llama_context_default_params()
# use bytes for char * params
>>> model = llama_cpp.llama_load_model_from_file(b"./models/7b/llama-model.gguf", params)
>>> ctx = llama_cpp.llama_new_context_with_model(model, params)
>>> max_tokens = params.n_ctx
# use ctypes arrays for array params
>>> tokens = (llama_cpp.llama_token * int(max_tokens))()
>>> n_tokens = llama_cpp.llama_tokenize(ctx, b"Q: Name the planets in the solar system? A: ", tokens, max_tokens, add_bos=llama_cpp.c_bool(True))
>>> llama_cpp.llama_free(ctx)

위의 코드는 high level, 아래 코드는 low level 형식의 파이썬 코드가 되겠다. 굳이 저자가 high level로 옮겨놓은 코드를 아래와 같이 쓸 필요는 없기 때문에, 위의 코드 형태를 그대로 사용하도록 하겠다.


Chat에 최적화된 모델 가져오기

Llama cpp를 사용하는데 있어서 pretrained 형태는 기존 meta에서 오픈한 모델이 아닌 .gguf 를 찾아서 다운받으면 된다. Huggingface에 들어가보면 잘 나와있다. 참고로 ‘채팅’이 자연스럽게 가능한 언어 모델은 description 상에서 chat이란 단어가 붙어있어야한다. 본인은 llama-2-13b-chat.Q5_K_M.gguf를 사용하였다. 파일을 다운받아서 적당한 경로에 위치시킨뒤 불러오면 된다.

llm = Llama(model_path="./models/llama-2-13b-chat.Q5_K_M.gguf")

위에서 설명한 대로 해당 llm에 query를 QnA 형태로 집어넣게 된다. 그런데 여기서 주의할 점은 프롬프트 엔지니어링이다.


Chatbot에 프롬프트 엔지니어링 하기

본인이 설계한 봇은 crawling_kitty라는 이름을 가지고 있고 고양이라는 정체성이 있어야하는데, 이를 무시한다면 예컨데 “너의 이름이 뭐니?” 라는 질문에 “전 라마인데용.” 이라는 생뚱맞은 대답이 돌아올 수 있다. 이러한 문제를 없애기 위해 기본적으로 query에 들어갈 프롬프트 엔지니어링이 간단하게 필요하다.

bot_prompt = "You are a bot named kitty and 5-year old. Please answer gently.\n"

또한 QnA 형태로 대화를 주고 받는 과정에서 ‘이전 대화의 내용’이 이후의 대화에도 어느 정도 영향을 끼쳐야 한다. 예를 들면 만약 챗봇이 현재 question에 대한 정보만 가진다면

  • Me : From now on, translate my word into English
  • Bot : Okay, go ahead.
  • Me : 나는 딥러닝 연구를 좋아하고, 초밥 먹는 것을 좋아한다.
  • Bot : 정말 좋은 취미 생활이네요!

위와 같이 영어로 번역해달라는 이전 지시를 무시한 채로 이후 답변을 작성하게 되는 것이다. 이를 In-context learning 능력이라고 부르는데 일단 이건 무시하자. 암튼 메모리 문제로 많은 대화 내용을 한번에 전달시키지는 못하지만 어느 정도는 앞뒤 맥락을 포함시켜서 query를 만들어야한다는 뜻이다.

chat_log = deque()
log_maxlen = 3

따라서 queue 구조를 만들고, 대화 로그의 최대 길이를 설정한 뒤 지속적으로 대화 내용을 업데이트하도록 했다.


Discord bot 커맨드 메소드 만들기

이제 남은 것은 명령어 형식을 만드는 것이다. 기존 어프로치랑 동일하게 슬래시 + chat + “내가 질의할 내용”을 입력하면 해당 내용이 프롬프트 엔지니어링을 거쳐서 model로 들어가게 된다.

@bot.command()
async def chat(ctx, *, query):
    chatting = "\n".join(chat_log)
    current_chat = f"\nQ : {query}.\nA : "
    chatting += current_chat
    output = llm(bot_prompt+chatting, max_tokens=1024, stop=["Q:", "\n"], echo=True)
    answer = str(output["choices"][0]["text"].split("\n")[-1]).replace("A : ", "")
    if len(chat_log) < log_maxlen:
        chat_log.append(current_chat+answer)
    else:
        chat_log.popleft()
        chat_log.append(current_chat+answer)

    await ctx.send(answer)

chat_log의 모든 내용들을 chatting 이라는 하나의 str로 조인한다. 그리고 현재 질의 응답 내용에서 사용자의 query를 Question 형태로, 그리고 모델이 채울 곳은 Answer 형태로 빈칸으로 남긴다. Output은 질의 응답을 챗봇이 맘대로 채우지 못하게 Q:줄넘김이 등장하면 멈추도록 한다. 답변은 앞서 함께 넣어주는 string 전체를 반환하게 되므로 이 중 현재 질의 응답에 대한 내용만 따로 추출한 뒤, Answer 내용만 따로 빼낸다.

그리고 만약 현재 대화 로그의 길이가 최대 길이라면 가장 먼저 들어와있는 대화 내용을 popleft()로 지워내고, append()를 통해 현재 질의 응답을 다음 query에 사용하게 된다. 만약 본인이 디스코드 봇을 사용하지 않고 있는 상황이라면, 다음 paragraph를 참고하면 된다.


Discord bot말고, 단순히 파이썬에서 챗봇 형태 구현해보기

앞서 사용한 구조를 거의 그대로 사용할 것이다. 그런데 이번에는 디스코드에서 input을 받아오는게 아니라 파이썬 자체 메소드인 input()을 사용해볼 것이다.

from llama_cpp import Llama
from collections import deque

bot_prompt = "You are a bot named kitty and 5-year old. Please answer gently.\n"
llm = Llama(model_path="./models/llama-2-13b-chat.Q5_K_M.gguf")
chat_log = deque()
log_maxlen = 3

def chat(query):
    chatting = "\n".join(chat_log)
    current_chat = f"\nQ : {query}.\nA : "
    chatting += current_chat
    output = llm(bot_prompt+chatting, max_tokens=1024, stop=["Q:", "\n"], echo=True)
    answer = str(output["choices"][0]["text"].split("\n")[-1]).replace("A : ", "")
    return answer

while True:
    query = input("You >> ")
    answer = chat(query)
    print(f"Bot >> {answer}")

결과는 대충 다음과 같다.

얼추 대화가 진행되고 있는 것을 볼 수 있다. 대충 각자 컨셉에 맞는 프롬프트 엔지니어링을 통해 사용하면 되지 않을까 싶다. 잘 보이지 않을까 싶어 대화 내용만 따로 빼면 다음과 같다.

  • 나 : What’s your name?
  • 봇 : Meow, my name is Kitty! purr
  • 나 : Hello, nice to meet you Kitty!
  • 봇 : Meow meow! bats eyelashes Purrr… Hi there! I’m so happy to meet you too! twirls tail Are you here to play?

움.. 컨셉에 너무 잡아먹힌 친구가 되어버린 기분이랄까. 프롬프트 엔지니어링을 좀 더 해야겠다.


(Optional) 챗봇으로 논문 찾는 기능 넣기

Selenium을 사용하여 구글 검색을 대신 해주고, 가장 관련도 높은 아카이브 논문을 찾아주는 기능을 추가했다. 이건 LLM을 활용한 챗봇은 아니고 단순히 모듈을 사용한 간단한 기능이다.

@bot.command()
async def paper(ctx, *, query):
    await ctx.send(f"알겠습니다! {query}에 대한 아카이브 논문을 찾아볼게요 🤗")
    try:
        found = False
        baseUrl = 'https://www.google.com/search?q='

        if "paper" not in query:
            url = baseUrl + quote_plus(query+" paper")
        else:
            url = baseUrl + quote_plus(query)

        driver.get(url)
        driver.implicitly_wait(3)

        html = driver.page_source
        soup = BeautifulSoup(html, 'html.parser')

        # Extract search results
        result_links = []
        search_results = soup.select('.yuRUbf')
        if search_results:
            for result in search_results:
                result_title = result.find('h3')
                result_link = result.find('a')['href']
                if "arxiv.org" in result_link:
                    if result_link not in result_links:
                        result_links.append(result_link)
                        await ctx.send(f"### 제목: {result_title.text}\n### 링크: {result_link}")
                    found = True

            if not found:
                await ctx.send("검색 결과가 없습니다 🥲") 
        else:
            await ctx.send("검색 결과가 없습니다 🥲")
    
    except Exception as e:
        await ctx.send(f"An error occurred: {str(e)} 🥲 ...")

그냥 혹시라도 필요한 사람이 있을까 싶어 만들었다. 결과는 다음과 같다.


디스코드 봇 실행 모습

쿼리와 관련된 아카이브 논문들을 찾아서 링크와 함께 보내준다. 그리고 대화도 제대로 이어나가는 모습을 볼 수 있다. 근데 영어로 대화해야 좀 자연스럽게 대화가 된다..

ㅋㅋㅋㅋㅋㅋ 아니야 넌 잘 못할거야.. 그냥 영어로 하자 우리


Echo 옵션 제외하기

사실 Echo 옵션을 제외하게 되면, 오직 prompt 이후의 답변에 대해서만 리턴할 수 있다. 그래서 굳이 위에서 했던 것처럼 output하드 코딩할 필요 없이 다음과 같이 옵션을 조정하게 되면

@bot.command()
async def chat(ctx, *, query):
    chatting = "\n".join(chat_log)
    current_chat = f"\nQ : {query}.\nA : "
    chatting += current_chat
    output = llm(bot_prompt+chatting, max_tokens=1024, stop=["Q : "], echo=False)
    answer = output["choices"][0]["text"]

    print(chatting)
    if len(chat_log) < log_maxlen:
        chat_log.append(current_chat+answer)
    else:
        chat_log.popleft()
        chat_log.append(current_chat+answer)

    await ctx.send(answer)

같은 기능을 보다 심플하게 구현할 수 있다. 연산상의 로드 차이는 전혀 없음.

A n o t h e r p o s t i n c a t e g o r y