챗봇의 메모리를 수정하는 데 일주일을 보냈습니다 — 성공적이었던 방법들
요약
챗봇의 컨텍스트 윈도우 제한 문제를 해결하기 위해 시도한 다양한 메모리 관리 전략을 다룹니다. 단순 메시지 추가, 절단(Truncation), 요약(Summarization) 방식의 한계를 분석하고, 비용과 성능을 최적화할 수 있는 하이브리드 벡터 메모리 접근 방식을 제안합니다.
핵심 포인트
- 단순 메시지 추가 및 절단 방식은 컨텍스트 손실과 금붕어 문제를 야기함
- 요약 방식은 핵심 정보를 유지하나 지연 시간과 API 비용이 증가함
- RAG 개념을 응용한 벡터 메모리 방식이 효율적인 대안이 될 수 있음
- 다회차 대화의 일관성을 유지하기 위한 전략적 메모리 설계가 필수적임
두 달 전, 저는 제 SaaS 제품을 위한 고객 지원 챗봇 (customer support chatbot)을 출시했습니다. 처음 세 개의 메시지까지는 아주 잘 작동했습니다. 하지만 그 이후부터 사용자가 이전에 말한 내용을 잊어버리고, 같은 말을 반복하며, 모순된 조언을 하기 시작했습니다. 사용자들도 이를 눈치챘습니다. 한 사용자는 이렇게 적었습니다: "당신의 봇은 금붕어의 기억력을 가졌군요."
저는 전형적인 LLM 컨텍스트 윈도우 (context window)의 벽에 부딪혔습니다. 저의 초기 구현 방식은 단순히 전체 대화 기록을 프롬프트 (prompt)에 집어넣는 것이었습니다. 이 방식은 대화가 4k 토큰 (tokens)을 넘어설 때까지는 작동했습니다. 그 후에는 절단 (truncation) 방식을 시도했지만, 이는 중요한 컨텍스트 (context)를 손실시켰습니다. 더 큰 컨텍스트 윈도우를 사용하기 위해 막대한 비용을 지불하거나 정보를 잃는 것 외에는 해결 방법이 없어 보였습니다.
제가 무엇을 시도했고, 무엇이 실패했으며, API 비용을 폭증시키지 않으면서도 챗봇이 일관된 다회차 대화 (multi-turn conversations)를 유지할 수 있게 해준 접근 방식이 무엇인지 소개하겠습니다.
순진한 접근 방식: 단순히 메시지를 계속 추가하기
저의 첫 번째 시도는 부끄러울 정도로 단순했습니다:
messages = []
while True:
user_input = input()
...
이 방식은 4096 토큰 제한에 도달하기 전까지 약 10~15회 정도의 대화 턴 (turns) 동안은 작동합니다. 그 이후에는 API가 에러를 발생시킵니다. 그래서 저는 절단 (truncation) 방식을 추가했습니다: 마지막 N개의 메시지만 유지하는 방식입니다.
절단 (Truncation): 기억의 구멍
저는 max_messages=20으로 설정하고 가장 오래된 메시지들을 삭제했습니다:
def trim_messages(messages, max_messages=20):
return messages[-max_messages:]
이제 API는 한계에 도달하지 않았지만, 봇은 대화 초반의 모든 내용을 잊어버렸습니다. 사용자가 "제 이메일은 test@example.com이라고 이미 말씀드렸어요"라고 말하면, 봇은 다시 이메일을 물어보곤 했습니다. 더 나쁜 것은, 사용자가 이전에 스스로를 수정했을 경우, 그 수정 사항이 화면에서 밀려나면 봇이 다시 잘못된 정보로 돌아간다는 점이었습니다.
이것이 바로 금붕어 문제입니다. 절단 (truncation)은 간단하지만 잔혹합니다.
요약 (Summarization): 더 낫지만 비용이 많이 드는 방식
다음으로 저는 몇 번의 대화 턴마다 대화 기록을 요약 (summarizing)하고, 그 요약본을 시스템 메시지 (system message)로 주입하는 방식을 시도했습니다:
import openai
def summarize_history(messages):
...
저는 이 방식을 10턴마다 호출하여, 이전 대화 기록을 요약본과 마지막 몇 개의 원문 메시지(raw messages)로 교체했습니다. 이 방법은 더 효과적이었습니다. 봇이 이름이나 선호도와 같은 핵심 사실들을 기억했기 때문입니다. 하지만 지연 시간(latency, 요약당 추가적인 API 호출 발생)과 비용이 증가했습니다. 또한, 요약 과정에서 때때로 뉘앙스가 손실되기도 했습니다. 사용자의 좌절 섞인 말투나 미묘한 수정 사항이 평이하게 뭉개질 수 있었습니다.
마침내 성공한 하이브리드 접근 방식: 벡터 메모리 (vector memory)
문서에 대해 RAG (Retrieval Augmented Generation, 검색 증강 생성)가 어떻게 작동하는지 읽은 후, 저는 동일한 아이디어를 대화 기록에 적용할 수 있다는 것을 깨달았습니다. 모든 것을 유지하거나 모든 것을 요약하는 대신, 각 메시지를 임베딩 (embedding)으로 저장하고 응답을 생성할 때 가장 관련성이 높은 과거 메시지만 검색하는 방식입니다.
아키텍처는 다음과 같습니다:
- 모든 사용자 메시지와 어시스턴트 응답은 임베딩되어 벡터 데이터베이스 (vector database)에 저장됩니다.
- 새로운 사용자 메시지가 들어오면, 이를 임베딩하고 가장 유사한 상위 K개의 과거 메시지를 찾습니다.
- 검색된 메시지들(타임스탬프 포함)을 즉각적인 문맥을 위한 마지막 몇 개의 메시지와 함께 프롬프트 (prompt)에 주입합니다.
저는 임베딩을 위해 sentence-transformers를 사용했고, 간단한 인메모리 (in-memory) 벡터 저장소를 사용했습니다 (프로덕션 환경이라면 Pinecone이나 Chroma를 사용할 것입니다). 코드는 다음과 같습니다:
from sentence_transformers import SentenceTransformer
import numpy as np
...
그다음 메인 루프에서는 다음과 같습니다:
memory = VectorMemory()
recent_window = []
...
이 접근 방식에는 몇 가지 주요 장점이 있습니다:
- 프롬프트 크기가 작게 유지됩니다 (검색된 5개 메시지 + 마지막 3개 = 총 8개 메시지, 토큰 제한보다 훨씬 낮음).
- 메시지가 의미론적 (semantically)으로 관련이 있다면, 50턴 전의 구체적인 세부 사항도 회상할 수 있습니다.
- 요약을 위한 추가적인 API 호출이 없습니다. 임베딩은 빠르고 저렴합니다.
트레이드오프 (Trade-offs) 및 사용하지 말아야 할 때
완벽하지는 않습니다. 제가 발견한 단점들은 다음과 같습니다:
시맨틱 검색 (Semantic retrieval)이 항상 정확한 것은 아닙니다. 사용자가 "내 주문 번호가 뭐였지?"라고 물으면, 검색 시스템이 주문 번호가 포함된 이전 메시지를 정확히 찾아낼 수 있습니다. 하지만 사용자가 "그거 다시 말해줄래?"라고 묻는다면, 임베딩 (Embedding)이 이전 문맥과 일치하지 않을 수 있습니다. 그래서 저는 폴백 (Fallback) 메커니즘을 추가했습니다. 검색 결과의 유사도 점수 (Similarity scores)가 낮으면, 마지막 10개의 메시지를 가공되지 않은 상태 (Raw)로 포함하도록 했습니다.
콜드 스타트 문제 (Cold start problem). 처음 몇 개의 메시지 동안에는 검색할 데이터가 없습니다. 이 부분은 괜찮습니다. 최근 대화창 (Recent window)이 이를 처리해주기 때문입니다.
무제한으로 늘어나는 메모리. 저는 정리 프로세스를 구현해야 했습니다. 24시간이 지났거나 최대 개수(10,000개 메시지)를 초과하는 임베딩은 삭제하도록 했습니다. 실제 서비스 (Production) 환경에서는 TTL (Time To Live) 기능이 있는 적절한 벡터 데이터베이스 (Vector database)를 사용하세요.
임베딩 비용. 각 메시지마다 임베딩 호출이 필요합니다. all-MiniLM-L6-v2와 같은 로컬 모델을 사용하면 무료이며 빠릅니다 (CPU 추론 시 약 10ms).
만약 API 기반의 임베딩 서비스를 사용한다면 비용이 계속 쌓이게 됩니다. 저는 로컬 방식을 고수했습니다.
이 방식을 사용하지 말아야 할 때: 대화가 항상 짧다면 (<10턴), 텍스트 절단 (Truncation) 방식이 더 간단하고 잘 작동합니다. 만약 봇이 시맨틱 검색 (Semantic recall)보다는 엄격한 시간 순서에 따른 회상 (Chronological recall, 예: "내가 방금 뭐라고 했지?")이 필요하다면, 요약 (Summarization)을 결합한 슬라이딩 윈도우 (Sliding window) 방식이 더 나을 수 있습니다.
다음에 다시 한다면 다르게 할 점
저는 첫날부터 벡터 메모리 (Vector memory) 접근 방식을 사용했을 것입니다. 단순한 절단 (Naive truncation) 방식 때문에 일주일 동안 사용자 불만을 처리하고 리팩토링 (Refactoring)을 해야 했습니다. 또한, 배포하기 전에 재현율 정확도 (Recall accuracy)를 측정할 수 있도록 예상 답변이 포함된 50개의 테스트 대화로 구성된 작은 평가 세트 (Evaluation set)를 구축했을 것입니다.
또한, 단기 메모리와 장기 메모리를 분리했을 것입니다. 즉각적인 문맥을 위해 마지막 5개의 메시지는 항상 프롬프트 (Prompt)에 포함되어야 하며, 검색 (Retrieval)은 오래된 기록에서만 가져와야 합니다. 이러한 하이브리드 (Hybrid) 방식은 봇이 사용자가 방금 말한 마지막 내용을 놓치는 것을 방지합니다.
진짜 교훈
컨텍스트 관리 (Context management)는 똑똑하게 느껴지는 대화형 AI (Conversational AI)를 구축하는 데 있어 가장 어려운 부분입니다. 이는 모델의 문제가 아닙니다. GPT-3.5만으로도 충분히 역량이 있습니다. 핵심은 적절한 시점에 적절한 정보를 모델에 제공하는 것입니다. 벡터 기반 검색 (Vector-based retrieval)은 저의 금붕어 같던 모델을 코끼리처럼 만들었습니다.
만약 챗봇을 만들다가 컨텍스트의 한계 (Context wall)에 부딪힌다면, 더 큰 모델을 찾지 마세요. 더 나은 메모리 시스템 (Memory system)을 찾으세요.
긴 대화를 처리하는 여러분만의 방식은 무엇인가요? 여러분에게 효과적이었던 방법들을 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기