티스토리 뷰

이전 글을 작성한 이후로 소스코드가 많이 변경 됐습니다. 특히 아래 구문(이전 글에서 작성했던 내용)에 대해 수정이 있었는데요.

그리고 number_of_messages_to_keep는 꼭 0으로 넣어주세요. 이 환경변수는 대화내역을 N개 저장하고 있겠다는 설정인데요. 대화내역을 저장하는 경우 ChatGPT와 대화할 때 상호작용이 되는 것처럼 보일 겁니다. 이전 대화내용을 기억하고 있으니까요. 아주 훌륭한 옵션이죠. 다만, 이 설정을 위해서는 Redis가 필요합니다. Google Cloud에서 Memorystore(Redis)를 올려서 사용하는 것도 좋지만 비용 문제로 다루지 않겠습니다. 만약 이미 Redis가 있다면 앞에 Hello-ChatGPT에 환경변수로 REDIS_HOST를 넣어서 사용해 주시면 됩니다.

 

완전한 서버리스로 사용할 수 있게 하기 위해서 Redis와의 의존성을 없앴습니다. 사이드 프로젝트를 운영하기에 클라우드에서 매니지드 Redis는 가격이 꽤 비싼 편입니다. 특히나 서비스를 Cloud Run으로 선택해서 서버리스로 운영하는 입장에서는 더욱더 그렇죠. 이번 글에서는 슬랙에서 제공하는 API로 Redis를 대체한 이야기를 다뤄봅니다. 

 

# Redis를 사용하려고 했던 이유

사용자가 봇과 대화를 하고 있다는 느낌을 주기 위해서입니다. 이전 대화 내용을 기억하고 있어야 그게 가능하니까요. 예를 들면 아래와 같습니다. 봇이 대화 내용을 기억하지 못하고 있다면 이상한 답변을 했을 겁니다.

이전 대화를 기억하도록 한다

 

이렇게 대화가 가능하려면 ChatGPT API로 메시지를 전송할 때 이전 대화 내역까지 함께 전송해야 합니다. 아래 예제를 봐주세요.

# https://platform.openai.com/docs/guides/gpt/chat-completions-api
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Who won the world series in 2020?"},
        {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
        {"role": "user", "content": "Where was it played?"}
    ]
)

 

이전 대화를 기억하는 가장 쉬운 방법으로 Redis를 사용했던 겁니다. 애플리케이션에 메모리로 들고 있을 수 없는 이유는 여러 대의 서버에서 API가 수행되기 때문입니다. 아무튼, 슬랙에 모든 메시지에는 고유한 ts(timestamp)가 존재하는데요. 그 값을 기준으로 대화 내역을 Redis에 기록하는 겁니다. 고유한 ts 밑에 달린 메시지가 스레드가 되는데 결국 스레드별로 메시지를 저장하고 있어야 합니다. 그래야 서로 다른 스레드에서의 대화가 섞이지 않을 테니까요. Redis에는 ts를 키로 갖는 리스트를 만들어서 메시지를 관리하면 됩니다. 그런데 이렇게 하는 경우 Redis 키가 무한정 많아질 수 있으므로 expire 옵션을 사용해줘야 합니다. 일정기간 지난 메시지는 파기함으로써 관리하지 않는 겁니다. 

사실 위에 방식대로 해도 잘 동작합니다. 그런데 개선하고 싶은 부분이 몇 가지가 눈에 밟힙니다.

 

# 개선하고자 하는 부분

1. 앞서 언급한 대로 비용 문제가 있습니다. Cloud Run의 경우 완전 관리형 서버리스 제품이기 때문에 사용하지 않을 때는 비용이 발생하지 않습니다. 하지만 Redis의 경우는 그렇지 않죠. 

2. Redis에서 키가 expire 되거나, 캐시가 날아가는 경우 대화 맥락이 끊깁니다. 봇은 더 이상 과거 대화를 기억하지 못하는 상태가 되는 거죠. 

3. 사람들끼리 대화하던 공간에 봇을 소환하는 경우 봇은 이전 대화 스토리를 모르기 때문에 제대로 끼어들지 못합니다. 아래 같은 상황을 상상해 보세요. Redis에 앞선 대화들이 저장되어 있지 않기 때문에 봇은 답변하지 못합니다.

맥락없이 틀린 부분을 질문했으므로 봇은 이상한 답변을 하게 된다

 

# 개선 방법

Redis 대신 (너무 당연한 이야기지만) 슬랙 API를 사용합니다. 슬랙 API 중에는 채널에 메시지 내역을 가져오는 것이 존재합니다. 즉, 스레드에 달린 메시지 전체를 가져올 수 있는 거죠. 바로 conversations.replies 메소드 입니다. 호출 시에 response는 아래와 같습니다. 우리에게 필요한 건 text 부분입니다.

{
    "messages": [
        {
            "type": "message",
            "user": "U061F7AUR",
            "text": "island",
            "thread_ts": "1482960137.003543",
            "reply_count": 3,
            "subscribed": true,
            "last_read": "1484678597.521003",
            "unread_count": 0,
            "ts": "1482960137.003543"
        },
        {
            "type": "message",
            "user": "U061F7AUR",
            "text": "one island",
            "thread_ts": "1482960137.003543",
            "parent_user_id": "U061F7AUR",
            "ts": "1483037603.017503"
        },
        {
            "type": "message",
            "user": "U061F7AUR",
            "text": "two island",
            "thread_ts": "1482960137.003543",
            "parent_user_id": "U061F7AUR",
            "ts": "1483051909.018632"
        },
        {
            "type": "message",
            "user": "U061F7AUR",
            "text": "three for the land",
            "thread_ts": "1482960137.003543",
            "parent_user_id": "U061F7AUR",
            "ts": "1483125339.020269"
        }
    ],
    "has_more": true,
    "ok": true,
    "response_metadata": {
        "next_cursor": "bmV4dF90czoxNDg0Njc4MjkwNTE3MDkx"
    }
}

 

text 부분을 가져오는 코드는 아래처럼 구현됩니다.

# Get past chat history and fit it into the ChatGPT format.
conversations_replies = client.conversations_replies(channel=channel, ts=thread_ts)
chat_history = conversations_replies.data.get("messages")[-1 * number_of_messages_to_keep:]
messages = []
for history in chat_history:
    if "app_id" in history:
        messages.append({role="assistant", content=history.get("text")})
    else:
        messages.append({role="user", content=history.get("text"))})

conversations.replies를 가져와서 그중에 마지막 몇 개 메시지(number_of_messages_to_keep)만 사용하는 겁니다. 이렇게 하는 이유는 모든 메시지를 ChatGPT에서 보내는 경우 토큰 제약이 발생하기 때문입니다. 그리고 메시지에 app_id 존재 유무에 따라서 사용자인지 assistant 인지 지정해 줍니다. 쉽죠? 전체 코드는 여기서 보실 수 있습니다. 이제 아래를 봐주세요. 봇이 대화 흐름에 자연스럽게 끼어들 수 있게 됐습니다. 그리고 완전한 서버리스로 서비스가 가능해졌습니다.

대화 중간에 끼어들 수 있게 됐다

 

잠깐! 권한 scopes에 channels:history, groups:history, mpim:history 세 가지가 필요합니다. 각각은 아래 목적 때문에 필요 합니다.

- channels:history : 슬랙 앱이 추가된 공개 채널에서 메시지 및 기타 콘텐츠 보기
- groups:history : 슬랙 앱이 추가된 비공개 채널에서 메시지 및 기타 콘텐츠 보기
- mpim:history : 슬랙 앱이 추가된 그룹 쪽지의 메시지 및 기타 콘텐츠 보기

 

# 모든 상황에서 사용은 안됩니다. 제약사항.

슬랙 conversations.replies는 tier 3의 Rate Limits이 존재하므로 주의가 필요합니다. 비싼 API인 거죠. 최대 50+ per minute 호출이 가능합니다. 슬랙에서 빈번하게 봇을 호출하는 경우에는 사용할 수 없습니다. 그렇게 많이/자주 호출된다면 애초에 Cloud Run 대신 작은 컴퓨팅 인스턴스를 사용하는 게 더 나을 겁니다. 거기에 Redis를 설치해서 사용하면 될 테니까요. 분명 그게 더 저렴할 겁니다. :) 

 

# 마무리

"슬랙에 ChatGPT 납치하기" 시리즈는 이제 마무리 지으려고 합니다. 3편 이후 꽤 오랜만에 글을 작성했는데요. 공백기간 동안 코드도 많이 변경이 됐습니다. 중간에 넣었다가 삭제한 기능들도 꽤 있네요. 예를 들면 슬랙에서 이미지를 첨부하고 질의하는 경우 Google Vision API를 통해서 오브젝트를 탐색하고 ChatGPT에서 넘겨준다거나, DALL·E로 이미지를 생성해 주는 기능 등이 있습니다. 그런데 현재 레포지토리는 다른 건 다 제거했고 슬랙에서 ChatGPT를 사용할 수 있는 용도로만 다이어트시켰습니다. 그리고 제일 큰 변화는 레포 두 개(ChatGPT, Slack Bot)를 합쳐서 한 개로만 운영 가능하도록 했습니다. 좀 더 많은 사람들이 쉽게 접근하실 수 있도록이요. 가벼운 경험들이지만 이런 공유가 누군가에게는 도움이 되길 바랍니다.

 

댓글
최근에 올라온 글
최근에 달린 댓글
글 보관함
Total
Today
Yesterday