Skip to content
Published on

Chatbot Memory Architecture: 장기 기억과 사용자 프로필 기반 개인화 대화 시스템 구축

Authors
  • Name
    Twitter
Chatbot Memory Architecture

들어가며: 왜 메모리가 중요한가

LLM 기반 챗봇의 가장 근본적인 한계 중 하나는 대화가 끝나면 모든 것을 잊어버린다는 점이다. 사용자가 어제 나눈 대화 내용, 선호하는 응답 스타일, 과거에 해결한 문제 등 모든 맥락이 세션이 종료되는 순간 사라진다. 이것은 마치 매번 새로운 상담원을 만나 처음부터 자기소개를 하고, 이전에 설명한 모든 배경을 다시 설명해야 하는 것과 같다.

인간의 기억 시스템은 감각 기억, 단기 기억(작업 기억), 장기 기억이라는 계층적 구조로 이루어져 있다. 감각 기억은 수 초 이내에 사라지고, 단기 기억은 약 7개 항목을 20-30초 동안 유지하며, 장기 기억은 사실상 무한한 용량으로 영구적으로 정보를 저장한다. 효과적인 챗봇 메모리 아키텍처는 바로 이 인간 기억 시스템의 계층적 특성을 모방해야 한다.

메모리 아키텍처가 제대로 설계되지 않으면 다음과 같은 문제가 발생한다. 첫째, 대화가 길어질수록 컨텍스트 윈도우를 초과해 이전 대화 내용이 잘려나간다. 둘째, 반복 방문하는 사용자에게도 매번 동일한 초기 경험을 제공하여 사용자 만족도가 하락한다. 셋째, 개인화된 추천이나 맞춤형 응답이 불가능해 챗봇의 가치가 크게 제한된다. 넷째, 과거 대화에서 해결된 문제를 다시 처음부터 해결해야 하는 비효율이 발생한다.

이 글에서는 대화 메모리의 유형을 체계적으로 분류하고, LangChain의 메모리 모듈을 실전에서 활용하는 방법, 장기 기억 저장소 설계 전략, 사용자 프로필 기반 개인화 시스템, MemGPT(Letta) 아키텍처 분석, 그리고 메모리 검색 최적화 기법까지 종합적으로 다룬다.

대화 메모리 유형 분류

챗봇의 메모리 시스템은 정보를 저장하고 검색하는 방식에 따라 크게 네 가지 유형으로 분류할 수 있다. 각 유형은 고유한 장단점을 가지며, 실전에서는 이들을 조합하여 사용하는 것이 일반적이다.

Buffer Memory (버퍼 메모리)

버퍼 메모리는 가장 단순한 형태의 대화 메모리다. 모든 대화 메시지를 원본 그대로 저장하고, 매 턴마다 전체 대화 이력을 LLM에 전달한다. 구현이 간단하고 정보 손실이 없다는 장점이 있지만, 대화가 길어지면 토큰 사용량이 급격히 증가하며 컨텍스트 윈도우 한계에 도달할 수 있다.

버퍼 메모리의 변형으로 Window Buffer Memory가 있다. 이는 최근 k개의 대화 턴만 유지하여 토큰 사용량을 제한한다. 가장 최근 맥락만 중요한 간단한 Q&A 챗봇에 적합하지만, 대화 초반의 중요한 정보가 유실될 수 있다.

Summary Memory (요약 메모리)

요약 메모리는 대화가 진행될수록 이전 대화 내용을 LLM을 사용하여 자동으로 요약한다. 전체 대화 이력 대신 압축된 요약본만 유지하므로 토큰 사용량이 훨씬 효율적이다. 대화가 아무리 길어져도 요약의 크기는 상대적으로 일정하게 유지된다.

단점은 요약 과정에서 세부 정보가 손실될 수 있다는 것이다. 또한 매 턴마다 요약을 생성하기 위한 추가 LLM 호출이 필요하므로 지연 시간과 비용이 증가한다. Summary Buffer Memory는 이 두 접근법의 하이브리드로, 최근 대화는 원본을 유지하고 오래된 대화만 요약하는 방식이다.

Vector Store Memory (벡터 저장소 메모리)

벡터 저장소 메모리는 대화 메시지를 임베딩 벡터로 변환하여 벡터 데이터베이스에 저장한다. 새로운 질문이 들어오면 의미적 유사도 검색을 통해 관련 있는 과거 대화만 선택적으로 가져온다. 시간 순서와 무관하게 의미적으로 관련된 대화를 효율적으로 검색할 수 있어, 장기 기억에 특히 적합하다.

단점은 대화의 시간적 순서가 보존되지 않을 수 있다는 점과, 임베딩 품질에 따라 검색 정확도가 달라진다는 것이다. 또한 벡터 DB 인프라를 별도로 관리해야 한다.

Knowledge Graph Memory (지식 그래프 메모리)

지식 그래프 메모리는 대화에서 추출한 엔티티와 관계를 그래프 구조로 저장한다. "김영주는 서울에 산다", "김영주는 Python을 좋아한다"와 같은 삼중항(triple)으로 정보를 구조화한다. 엔티티 간의 관계를 명시적으로 추론할 수 있어, 복잡한 맥락 파악이 필요한 상황에서 강력하다.

단점은 엔티티와 관계 추출의 정확도가 LLM 성능에 크게 의존한다는 것과, 구조화되지 않은 자유로운 대화에서는 효과가 제한적일 수 있다는 것이다.

LangChain 메모리 모듈 실전

LangChain은 다양한 메모리 모듈을 제공하여 챗봇 개발자가 필요에 맞는 메모리 전략을 쉽게 구현할 수 있도록 한다. 다음은 각 메모리 유형을 LangChain으로 구현하는 실전 코드다.

코드 예제 1: 다양한 메모리 유형 구현

from langchain.memory import (
    ConversationBufferMemory,
    ConversationBufferWindowMemory,
    ConversationSummaryMemory,
    ConversationSummaryBufferMemory,
    VectorStoreRetrieverMemory,
)
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.chains import ConversationChain

llm = ChatOpenAI(model="gpt-4o", temperature=0)

# 1. Buffer Memory - 전체 대화 이력 저장
buffer_memory = ConversationBufferMemory(
    return_messages=True,
    memory_key="history",
)

# 2. Window Buffer Memory - 최근 5턴만 유지
window_memory = ConversationBufferWindowMemory(
    k=5,
    return_messages=True,
    memory_key="history",
)

# 3. Summary Memory - 대화 요약 유지
summary_memory = ConversationSummaryMemory(
    llm=llm,
    return_messages=True,
    memory_key="history",
)

# 4. Summary Buffer Memory - 하이브리드 (최근 대화 원본 + 오래된 대화 요약)
summary_buffer_memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=1000,  # 이 토큰 수를 초과하면 요약 시작
    return_messages=True,
    memory_key="history",
)

# 5. Vector Store Memory - 의미 기반 검색
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
    collection_name="conversation_memory",
    embedding_function=embeddings,
    persist_directory="./memory_db",
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

vector_memory = VectorStoreRetrieverMemory(
    retriever=retriever,
    memory_key="history",
    input_key="input",
)

# 실제 대화 체인 구성
conversation = ConversationChain(
    llm=llm,
    memory=summary_buffer_memory,  # 선택한 메모리 타입 적용
    verbose=True,
)

# 대화 실행
response1 = conversation.predict(input="안녕하세요, 저는 김영주입니다. Python 개발자예요.")
response2 = conversation.predict(input="최근에 LangChain으로 RAG 시스템을 만들고 있어요.")
response3 = conversation.predict(input="제 이름이 뭐라고 했죠?")
print(response3)  # "김영주"라고 올바르게 응답

코드 예제 2: 커스텀 메모리 매니저 구현

import json
import hashlib
from datetime import datetime, timedelta
from typing import Any
from dataclasses import dataclass, field
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma


@dataclass
class MemoryEntry:
    """단일 메모리 항목을 나타내는 데이터 클래스"""
    content: str
    timestamp: datetime
    importance: float  # 0.0 ~ 1.0
    access_count: int = 0
    last_accessed: datetime = field(default_factory=datetime.now)
    memory_type: str = "episodic"  # episodic, semantic, procedural
    metadata: dict = field(default_factory=dict)

    @property
    def recency_score(self) -> float:
        """시간 경과에 따른 감쇠 점수 (망각 곡선 시뮬레이션)"""
        hours_elapsed = (datetime.now() - self.last_accessed).total_seconds() / 3600
        decay_rate = 0.1
        return max(0.0, 1.0 * (2.718 ** (-decay_rate * hours_elapsed)))

    @property
    def composite_score(self) -> float:
        """중요도, 최신성, 접근 빈도를 조합한 복합 점수"""
        frequency_score = min(1.0, self.access_count / 10)
        return (
            0.4 * self.importance +
            0.35 * self.recency_score +
            0.25 * frequency_score
        )


class HierarchicalMemoryManager:
    """계층적 메모리 관리자: 단기 / 작업 / 장기 메모리를 통합 관리"""

    def __init__(self, user_id: str):
        self.user_id = user_id
        self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

        # 단기 메모리: 현재 세션의 최근 대화
        self.short_term: list[BaseMessage] = []
        self.short_term_limit = 10

        # 작업 메모리: 현재 대화의 핵심 정보 (요약)
        self.working_memory: str = ""

        # 장기 메모리: 벡터 DB 기반
        self.long_term_store = Chroma(
            collection_name=f"long_term_{user_id}",
            embedding_function=self.embeddings,
            persist_directory=f"./memory/{user_id}",
        )

        # 메모리 인덱스 (메타데이터 관리)
        self.memory_index: dict[str, MemoryEntry] = {}

    def add_interaction(self, human_msg: str, ai_msg: str) -> None:
        """새로운 대화 상호작용을 메모리에 추가"""
        # 단기 메모리에 추가
        self.short_term.append(HumanMessage(content=human_msg))
        self.short_term.append(AIMessage(content=ai_msg))

        # 단기 메모리 한도 초과 시 오래된 대화를 장기 메모리로 이전
        if len(self.short_term) > self.short_term_limit * 2:
            self._consolidate_to_long_term()

        # 작업 메모리 업데이트
        self._update_working_memory(human_msg, ai_msg)

    def _consolidate_to_long_term(self) -> None:
        """단기 메모리의 오래된 대화를 요약하여 장기 메모리로 이전"""
        old_messages = self.short_term[:4]  # 가장 오래된 2턴
        self.short_term = self.short_term[4:]

        # 대화 내용 요약
        conversation_text = "\n".join(
            f"{'User' if isinstance(m, HumanMessage) else 'AI'}: {m.content}"
            for m in old_messages
        )
        summary_prompt = f"다음 대화에서 기억할 가치가 있는 핵심 정보를 추출하세요:\n{conversation_text}"
        summary = self.llm.invoke(summary_prompt).content

        # 중요도 평가
        importance = self._evaluate_importance(summary)

        # 장기 메모리에 저장
        memory_id = hashlib.md5(summary.encode()).hexdigest()
        self.long_term_store.add_texts(
            texts=[summary],
            metadatas=[{
                "memory_id": memory_id,
                "user_id": self.user_id,
                "timestamp": datetime.now().isoformat(),
                "importance": importance,
                "type": "conversation_summary",
            }],
            ids=[memory_id],
        )
        self.memory_index[memory_id] = MemoryEntry(
            content=summary,
            timestamp=datetime.now(),
            importance=importance,
        )

    def _evaluate_importance(self, content: str) -> float:
        """메모리 내용의 중요도를 LLM으로 평가 (0.0 ~ 1.0)"""
        prompt = (
            f"다음 정보의 중요도를 0.0에서 1.0 사이의 숫자로만 평가하세요. "
            f"사용자의 개인 정보, 선호도, 반복되는 패턴은 높은 점수를, "
            f"일반적인 인사말이나 사소한 대화는 낮은 점수를 주세요.\n"
            f"정보: {content}\n점수:"
        )
        response = self.llm.invoke(prompt).content.strip()
        try:
            return max(0.0, min(1.0, float(response)))
        except ValueError:
            return 0.5

    def _update_working_memory(self, human_msg: str, ai_msg: str) -> None:
        """작업 메모리(현재 대화 요약)를 업데이트"""
        prompt = (
            f"현재 대화 요약:\n{self.working_memory}\n\n"
            f"새로운 대화:\nUser: {human_msg}\nAI: {ai_msg}\n\n"
            f"위 내용을 반영하여 대화 요약을 업데이트하세요. 3-5문장으로 핵심만 포함하세요:"
        )
        self.working_memory = self.llm.invoke(prompt).content

    def retrieve_relevant_memories(self, query: str, k: int = 5) -> list[str]:
        """쿼리와 관련된 장기 메모리를 검색"""
        results = self.long_term_store.similarity_search_with_score(query, k=k)
        memories = []
        for doc, score in results:
            memory_id = doc.metadata.get("memory_id")
            if memory_id and memory_id in self.memory_index:
                self.memory_index[memory_id].access_count += 1
                self.memory_index[memory_id].last_accessed = datetime.now()
            memories.append(doc.page_content)
        return memories

    def build_context(self, current_query: str) -> str:
        """현재 쿼리에 대한 전체 컨텍스트를 구성"""
        relevant_memories = self.retrieve_relevant_memories(current_query)
        short_term_text = "\n".join(
            f"{'User' if isinstance(m, HumanMessage) else 'AI'}: {m.content}"
            for m in self.short_term[-6:]  # 최근 3턴
        )
        context = (
            f"## 사용자에 대한 장기 기억\n"
            + "\n".join(f"- {m}" for m in relevant_memories)
            + f"\n\n## 현재 대화 요약\n{self.working_memory}"
            + f"\n\n## 최근 대화\n{short_term_text}"
        )
        return context

장기 기억 저장소 설계: 벡터 DB + 관계형 DB 하이브리드

프로덕션 환경에서 챗봇의 장기 기억을 효과적으로 관리하려면 벡터 데이터베이스와 관계형 데이터베이스를 함께 사용하는 하이브리드 아키텍처가 필요하다. 벡터 DB는 의미 기반 검색에 탁월하지만 구조화된 데이터 관리, 정확한 필터링, 트랜잭션 처리에는 한계가 있다. 반면 관계형 DB는 정확한 조건 기반 조회와 데이터 무결성 보장에 강하지만 의미적 유사도 검색은 지원하지 않는다.

하이브리드 아키텍처에서 관계형 DB(PostgreSQL 등)는 사용자 프로필, 대화 세션 메타데이터, 메모리 항목의 구조화된 속성(중요도, 생성일, 접근 빈도 등)을 관리하고, 벡터 DB(Pinecone, Chroma, Qdrant 등)는 대화 내용과 메모리 요약의 임베딩을 저장하여 의미 기반 검색을 담당한다. 두 DB 간의 연결은 고유 ID를 통해 이루어진다.

이 구조의 핵심은 "관계형 DB에서 후보를 필터링하고, 벡터 DB에서 의미적으로 랭킹하는" 2단계 검색 파이프라인이다. 예를 들어, "최근 1주일 이내에 중요도 0.7 이상인 메모리 중 현재 질문과 관련 있는 것"을 찾을 때, 먼저 관계형 DB에서 시간과 중요도 조건을 만족하는 메모리 ID를 필터링한 후, 해당 ID들의 벡터만 대상으로 유사도 검색을 수행한다. 이 방식은 벡터 DB의 전체 검색보다 훨씬 효율적이며, 비즈니스 로직을 반영한 정교한 메모리 검색이 가능하다.

또한 관계형 DB에 저장된 메모리 메타데이터를 활용하면 메모리 가비지 컬렉션(오래되고 중요도가 낮으며 접근하지 않는 메모리를 자동 삭제), 메모리 사용 통계 분석, 사용자별 메모리 용량 관리 등 운영에 필요한 기능을 체계적으로 구현할 수 있다.

사용자 프로필 기반 개인화

사용자 프로필은 개인화된 대화 경험의 핵심이다. 대화에서 점진적으로 학습한 사용자 정보를 구조화하여 저장하고, 이를 대화 컨텍스트에 반영하면 챗봇은 사용자를 "알아가는" 느낌을 줄 수 있다.

코드 예제 3: 사용자 프로필 스키마 및 자동 업데이트 시스템

from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser


class UserPreferences(BaseModel):
    """사용자 선호도 프로필"""
    response_style: Optional[str] = Field(
        None, description="선호하는 응답 스타일 (간결/상세/코드중심)"
    )
    language_level: Optional[str] = Field(
        None, description="기술 수준 (초급/중급/고급)"
    )
    interests: list[str] = Field(
        default_factory=list, description="관심 분야 목록"
    )
    preferred_language: Optional[str] = Field(
        None, description="선호하는 프로그래밍 언어"
    )
    communication_tone: Optional[str] = Field(
        None, description="선호하는 대화 톤 (격식/반말/친근)"
    )


class UserProfile(BaseModel):
    """통합 사용자 프로필"""
    user_id: str
    name: Optional[str] = None
    occupation: Optional[str] = None
    company: Optional[str] = None
    location: Optional[str] = None
    preferences: UserPreferences = Field(default_factory=UserPreferences)
    known_facts: list[str] = Field(
        default_factory=list, description="사용자에 대해 알려진 사실들"
    )
    interaction_count: int = 0
    first_interaction: Optional[datetime] = None
    last_interaction: Optional[datetime] = None
    topics_discussed: list[str] = Field(default_factory=list)
    updated_at: Optional[datetime] = None


class ProfileUpdater:
    """대화에서 자동으로 사용자 프로필을 업데이트하는 시스템"""

    def __init__(self):
        self.llm = ChatOpenAI(model="gpt-4o", temperature=0)
        self.parser = JsonOutputParser()

    def extract_profile_updates(
        self, conversation: str, current_profile: UserProfile
    ) -> dict:
        """대화 내용에서 프로필 업데이트 정보를 추출"""
        prompt = ChatPromptTemplate.from_template(
            "당신은 대화에서 사용자 정보를 추출하는 전문가입니다.\n\n"
            "현재 사용자 프로필:\n{current_profile}\n\n"
            "최근 대화:\n{conversation}\n\n"
            "대화에서 새로 발견된 사용자 정보를 JSON으로 추출하세요.\n"
            "변경 없는 필드는 포함하지 마세요.\n"
            "추출 가능한 필드: name, occupation, company, location, "
            "interests (리스트에 추가), known_facts (리스트에 추가), "
            "response_style, language_level, preferred_language\n\n"
            "JSON 형식으로만 응답하세요:"
        )
        chain = prompt | self.llm | self.parser
        updates = chain.invoke({
            "current_profile": current_profile.model_dump_json(indent=2),
            "conversation": conversation,
        })
        return updates

    def apply_updates(
        self, profile: UserProfile, updates: dict
    ) -> UserProfile:
        """추출된 업데이트를 프로필에 적용"""
        if "name" in updates:
            profile.name = updates["name"]
        if "occupation" in updates:
            profile.occupation = updates["occupation"]
        if "company" in updates:
            profile.company = updates["company"]
        if "location" in updates:
            profile.location = updates["location"]
        if "interests" in updates:
            for interest in updates["interests"]:
                if interest not in profile.preferences.interests:
                    profile.preferences.interests.append(interest)
        if "known_facts" in updates:
            for fact in updates["known_facts"]:
                if fact not in profile.known_facts:
                    profile.known_facts.append(fact)
        if "response_style" in updates:
            profile.preferences.response_style = updates["response_style"]
        if "language_level" in updates:
            profile.preferences.language_level = updates["language_level"]
        if "preferred_language" in updates:
            profile.preferences.preferred_language = updates["preferred_language"]

        profile.interaction_count += 1
        profile.last_interaction = datetime.now()
        profile.updated_at = datetime.now()
        if not profile.first_interaction:
            profile.first_interaction = datetime.now()

        return profile


# 사용 예시
updater = ProfileUpdater()
profile = UserProfile(user_id="user_001")

conversation = """
User: 안녕하세요, 김영주입니다. 네이버에서 백엔드 개발하고 있어요.
AI: 반갑습니다 김영주님! 백엔드 개발자시군요.
User: 네, Python과 Go를 주로 사용합니다. 요즘 LangChain에 관심이 많아요.
AI: LangChain은 LLM 애플리케이션 개발에 정말 유용한 프레임워크죠!
"""

updates = updater.extract_profile_updates(conversation, profile)
# 결과: {"name": "김영주", "occupation": "백엔드 개발자", "company": "네이버",
#        "interests": ["LangChain"], "preferred_language": "Python"}
profile = updater.apply_updates(profile, updates)

사용자 프로필 기반 개인화 전략은 여러 수준에서 적용할 수 있다. 가장 기본적인 수준은 사용자의 이름을 기억하고 호칭을 사용하는 것이다. 다음 수준은 사용자의 기술 수준에 맞게 응답의 깊이를 조절하는 것이다. 초급 개발자에게는 기초 개념부터 설명하고, 고급 개발자에게는 바로 핵심 구현에 집중할 수 있다. 가장 고급 수준은 사용자의 과거 질문 패턴과 관심 분야를 분석하여 선제적으로 관련 정보를 제공하는 것이다.

개인화 시스템을 설계할 때 가장 중요한 원칙은 점진적 학습이다. 사용자에게 긴 설문지를 작성하게 하는 것이 아니라, 자연스러운 대화 흐름 속에서 정보를 하나씩 수집한다. 5번째 대화에서는 이름과 직업 정도만 알고 있지만, 50번째 대화에서는 선호하는 코딩 스타일, 자주 사용하는 라이브러리, 현재 진행 중인 프로젝트까지 파악하여 매우 맞춤화된 경험을 제공할 수 있다.

MemGPT 아키텍처 분석

MemGPT(현재 Letta로 리브랜딩)는 LLM을 운영체제처럼 활용하여 자체적으로 메모리를 관리하는 혁신적인 아키텍처다. 전통적인 접근법에서는 개발자가 메모리 관리 로직을 명시적으로 코딩하지만, MemGPT에서는 LLM 자체가 메모리 관리자 역할을 수행한다.

MemGPT의 핵심 개념은 가상 컨텍스트 관리(Virtual Context Management)다. 물리적인 컨텍스트 윈도우는 제한되어 있지만, LLM이 필요에 따라 정보를 메모리 계층 간에 이동시킴으로써 사실상 무한한 컨텍스트를 활용할 수 있다. 이는 운영체제의 가상 메모리 개념과 정확히 대응한다. 물리적 RAM(컨텍스트 윈도우)이 부족하면 디스크(외부 저장소)에서 필요한 데이터를 페이지 인/아웃하는 것이다.

MemGPT의 3계층 메모리 구조

Core Memory(코어 메모리): 항상 컨텍스트 윈도우에 포함되는 압축된 핵심 정보. 사용자의 이름, 주요 선호도, 현재 대화의 핵심 맥락 등이 포함된다. LLM은 core_memory_append, core_memory_replace 같은 도구를 사용하여 이 메모리를 능동적으로 편집한다.

Recall Memory(리콜 메모리): 과거 대화 이력을 검색할 수 있는 데이터베이스. LLM이 conversation_search 도구를 호출하여 특정 키워드나 시간 범위로 과거 대화를 검색한다. 전체 대화 이력이 저장되어 있어 세부 정보까지 복원할 수 있다.

Archival Memory(아카이브 메모리): 무한 용량의 장기 저장소. 벡터 데이터베이스를 기반으로 하며, LLM이 archival_memory_insert, archival_memory_search 도구를 통해 중요한 정보를 저장하고 검색한다. 현재 대화에서는 당장 필요하지 않지만 나중에 유용할 수 있는 정보를 보관한다.

코드 예제 4: MemGPT 스타일 메모리 관리 시뮬레이션

from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.tools import tool


@dataclass
class CoreMemory:
    """항상 컨텍스트에 포함되는 핵심 메모리"""
    persona: str = "당신은 친절한 AI 어시스턴트입니다."
    user_info: str = "아직 사용자에 대해 알려진 정보가 없습니다."
    max_chars: int = 2000

    def update_persona(self, new_content: str) -> str:
        if len(new_content) > self.max_chars:
            return "Error: 코어 메모리 용량 초과"
        self.persona = new_content
        return f"Persona 업데이트 완료: {new_content[:50]}..."

    def update_user_info(self, new_content: str) -> str:
        if len(new_content) > self.max_chars:
            return "Error: 코어 메모리 용량 초과"
        self.user_info = new_content
        return f"User info 업데이트 완료: {new_content[:50]}..."

    def append_user_info(self, additional_info: str) -> str:
        updated = f"{self.user_info}\n- {additional_info}"
        if len(updated) > self.max_chars:
            return "Error: 코어 메모리 용량 초과. 아카이브로 이전하세요."
        self.user_info = updated
        return f"User info에 추가 완료: {additional_info}"


class MemGPTStyleAgent:
    """MemGPT 아키텍처를 시뮬레이션하는 에이전트"""

    def __init__(self, user_id: str):
        self.user_id = user_id
        self.llm = ChatOpenAI(model="gpt-4o", temperature=0.1)
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

        # 3계층 메모리 초기화
        self.core_memory = CoreMemory()
        self.recall_memory: list[dict] = []  # 전체 대화 이력
        self.archival_memory = Chroma(
            collection_name=f"archival_{user_id}",
            embedding_function=self.embeddings,
            persist_directory=f"./archival/{user_id}",
        )

        # 메모리 관리 도구 정의
        self.tools = self._define_tools()

    def _define_tools(self) -> list:
        core = self.core_memory
        archival = self.archival_memory
        recall = self.recall_memory

        @tool
        def core_memory_append(info: str) -> str:
            """코어 메모리의 사용자 정보에 새 항목을 추가합니다."""
            return core.append_user_info(info)

        @tool
        def core_memory_replace(old_text: str, new_text: str) -> str:
            """코어 메모리의 특정 텍스트를 새 텍스트로 교체합니다."""
            if old_text in core.user_info:
                core.user_info = core.user_info.replace(old_text, new_text)
                return f"교체 완료: '{old_text}' -> '{new_text}'"
            return f"Error: '{old_text}'를 코어 메모리에서 찾을 수 없습니다."

        @tool
        def archival_memory_insert(content: str) -> str:
            """장기 아카이브 메모리에 정보를 저장합니다."""
            archival.add_texts(
                texts=[content],
                metadatas=[{
                    "timestamp": datetime.now().isoformat(),
                    "user_id": self.user_id,
                }],
            )
            return f"아카이브 저장 완료: {content[:50]}..."

        @tool
        def archival_memory_search(query: str, k: int = 3) -> str:
            """아카이브 메모리에서 관련 정보를 검색합니다."""
            results = archival.similarity_search(query, k=k)
            if not results:
                return "아카이브에서 관련 정보를 찾을 수 없습니다."
            return "\n".join(
                f"[{i+1}] {doc.page_content}" for i, doc in enumerate(results)
            )

        @tool
        def conversation_search(query: str) -> str:
            """과거 대화 이력에서 키워드로 검색합니다."""
            matches = [
                entry for entry in recall
                if query.lower() in entry["content"].lower()
            ]
            if not matches:
                return "관련 대화를 찾을 수 없습니다."
            return "\n".join(
                f"[{entry['timestamp']}] {entry['role']}: {entry['content']}"
                for entry in matches[-5:]
            )

        return [
            core_memory_append,
            core_memory_replace,
            archival_memory_insert,
            archival_memory_search,
            conversation_search,
        ]

    def build_system_prompt(self) -> str:
        """시스템 프롬프트에 코어 메모리를 포함"""
        return (
            f"# 시스템 지침\n{self.core_memory.persona}\n\n"
            f"# 사용자 정보 (코어 메모리)\n{self.core_memory.user_info}\n\n"
            f"# 메모리 관리 지침\n"
            f"- 사용자에 대한 새로운 정보를 발견하면 core_memory_append를 사용하세요.\n"
            f"- 기존 정보가 변경되면 core_memory_replace를 사용하세요.\n"
            f"- 상세한 기술 정보나 긴 내용은 archival_memory_insert로 저장하세요.\n"
            f"- 과거 대화를 참조해야 하면 conversation_search를 사용하세요.\n"
        )

    def chat(self, user_message: str) -> str:
        """사용자 메시지를 처리하고 응답을 생성"""
        # 리콜 메모리에 사용자 메시지 기록
        self.recall_memory.append({
            "role": "user",
            "content": user_message,
            "timestamp": datetime.now().isoformat(),
        })

        # LLM에 코어 메모리 + 도구와 함께 요청
        response = self.llm.bind_tools(self.tools).invoke([
            {"role": "system", "content": self.build_system_prompt()},
            {"role": "user", "content": user_message},
        ])

        # 도구 호출이 있으면 실행 (실제 구현에서는 에이전트 루프)
        ai_response = response.content or "메모리를 업데이트했습니다."

        # 리콜 메모리에 AI 응답 기록
        self.recall_memory.append({
            "role": "assistant",
            "content": ai_response,
            "timestamp": datetime.now().isoformat(),
        })

        return ai_response

MemGPT 아키텍처의 핵심 강점은 메모리 관리가 선언적이 아니라 자율적이라는 점이다. 개발자가 "언제 요약하고 언제 삭제할지"를 규칙으로 정하는 대신, LLM 스스로가 대화 맥락을 보고 "이 정보는 코어 메모리에 저장해야 한다", "이 세부 내용은 아카이브로 옮기자"와 같은 판단을 한다. 이렇게 하면 메모리 관리 로직이 자연어의 풍부한 의미를 활용할 수 있어, 규칙 기반 시스템으로는 달성하기 어려운 수준의 지능적 메모리 관리가 가능하다.

메모리 검색 최적화: 하이브리드 검색과 Reranking

메모리에 저장된 정보가 아무리 풍부해도, 적절한 시점에 적절한 정보를 검색하지 못하면 무용지물이다. 메모리 검색 최적화는 챗봇 메모리 시스템의 성능을 결정하는 핵심 요소다.

하이브리드 검색 전략

단순 벡터 유사도 검색만으로는 최적의 결과를 얻기 어렵다. 의미적 유사도는 높지만 실제로는 관련 없는 결과가 포함되거나, 키워드가 정확히 일치하는 중요한 결과가 누락될 수 있다. 하이브리드 검색은 밀집 벡터(dense vector) 검색과 희소 벡터(sparse vector, BM25) 검색을 결합하여 두 방식의 장점을 취한다.

밀집 벡터 검색은 의미적 유사도를 포착하여 "Python 코딩"과 "파이썬 프로그래밍"처럼 다른 표현이지만 같은 의미의 쿼리를 잘 처리한다. 반면 BM25 같은 희소 벡터 검색은 정확한 키워드 매칭에 강하며, 고유명사나 특정 용어를 포함한 검색에서 우수하다.

Reranking

초기 검색 결과를 더 정교한 모델로 재정렬하는 리랭킹(reranking)은 검색 품질을 크게 향상시킨다. 1단계에서 빠른 검색으로 후보를 넓게 가져오고(top-20), 2단계에서 교차 인코더(cross-encoder) 모델을 사용하여 쿼리와 각 후보의 관련성을 정밀하게 평가하여 상위 5개를 선별한다.

코드 예제 5: 하이브리드 검색 및 리랭킹 메모리 검색 파이프라인

import numpy as np
from typing import Optional
from dataclasses import dataclass
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder


@dataclass
class SearchResult:
    content: str
    score: float
    source: str  # "dense", "sparse", "hybrid"
    metadata: dict


class HybridMemoryRetriever:
    """하이브리드 검색 + 리랭킹 기반 메모리 검색 파이프라인"""

    def __init__(self, collection_name: str):
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        self.vectorstore = Chroma(
            collection_name=collection_name,
            embedding_function=self.embeddings,
        )
        # BM25를 위한 문서 코퍼스
        self.documents: list[str] = []
        self.doc_metadata: list[dict] = []
        self.bm25: Optional[BM25Okapi] = None

        # Cross-encoder 리랭커
        self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

    def add_memory(self, content: str, metadata: dict) -> None:
        """메모리를 벡터 DB와 BM25 인덱스 모두에 추가"""
        # 벡터 DB에 추가
        self.vectorstore.add_texts(
            texts=[content], metadatas=[metadata]
        )
        # BM25 인덱스에 추가
        self.documents.append(content)
        self.doc_metadata.append(metadata)
        # BM25 인덱스 재구축
        tokenized_docs = [doc.split() for doc in self.documents]
        self.bm25 = BM25Okapi(tokenized_docs)

    def _dense_search(self, query: str, k: int = 20) -> list[SearchResult]:
        """밀집 벡터 검색 (의미적 유사도)"""
        results = self.vectorstore.similarity_search_with_score(query, k=k)
        return [
            SearchResult(
                content=doc.page_content,
                score=1.0 / (1.0 + score),  # 거리를 유사도로 변환
                source="dense",
                metadata=doc.metadata,
            )
            for doc, score in results
        ]

    def _sparse_search(self, query: str, k: int = 20) -> list[SearchResult]:
        """희소 벡터 검색 (BM25 키워드 매칭)"""
        if self.bm25 is None:
            return []
        tokenized_query = query.split()
        scores = self.bm25.get_scores(tokenized_query)
        top_indices = np.argsort(scores)[-k:][::-1]
        return [
            SearchResult(
                content=self.documents[i],
                score=float(scores[i]),
                source="sparse",
                metadata=self.doc_metadata[i],
            )
            for i in top_indices if scores[i] > 0
        ]

    def _reciprocal_rank_fusion(
        self,
        dense_results: list[SearchResult],
        sparse_results: list[SearchResult],
        k: int = 60,
        dense_weight: float = 0.6,
        sparse_weight: float = 0.4,
    ) -> list[SearchResult]:
        """RRF(Reciprocal Rank Fusion)로 두 검색 결과를 결합"""
        doc_scores: dict[str, float] = {}
        doc_map: dict[str, SearchResult] = {}

        for rank, result in enumerate(dense_results):
            rrf_score = dense_weight / (k + rank + 1)
            doc_scores[result.content] = doc_scores.get(result.content, 0) + rrf_score
            doc_map[result.content] = result

        for rank, result in enumerate(sparse_results):
            rrf_score = sparse_weight / (k + rank + 1)
            doc_scores[result.content] = doc_scores.get(result.content, 0) + rrf_score
            if result.content not in doc_map:
                doc_map[result.content] = result

        sorted_docs = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
        return [
            SearchResult(
                content=content,
                score=score,
                source="hybrid",
                metadata=doc_map[content].metadata,
            )
            for content, score in sorted_docs
        ]

    def _rerank(
        self, query: str, candidates: list[SearchResult], top_k: int = 5
    ) -> list[SearchResult]:
        """Cross-encoder로 후보를 재정렬"""
        if not candidates:
            return []
        pairs = [(query, r.content) for r in candidates]
        scores = self.reranker.predict(pairs)
        for i, score in enumerate(scores):
            candidates[i].score = float(score)
        candidates.sort(key=lambda x: x.score, reverse=True)
        return candidates[:top_k]

    def search(
        self,
        query: str,
        top_k: int = 5,
        use_reranking: bool = True,
    ) -> list[SearchResult]:
        """전체 하이브리드 검색 + 리랭킹 파이프라인"""
        # 1단계: 밀집 + 희소 검색
        dense_results = self._dense_search(query, k=20)
        sparse_results = self._sparse_search(query, k=20)

        # 2단계: RRF 결합
        fused_results = self._reciprocal_rank_fusion(
            dense_results, sparse_results
        )

        # 3단계: 리랭킹 (선택적)
        if use_reranking and fused_results:
            return self._rerank(query, fused_results[:15], top_k=top_k)
        return fused_results[:top_k]


# 사용 예시
retriever = HybridMemoryRetriever("user_memories")
retriever.add_memory(
    "사용자는 Python 백엔드 개발자로 FastAPI를 주로 사용한다.",
    {"type": "profile", "importance": 0.9}
)
retriever.add_memory(
    "지난주 LangChain의 LCEL 파이프라인에 대해 질문했다.",
    {"type": "conversation", "importance": 0.7}
)
results = retriever.search("FastAPI 관련 이전 대화")

메모리 유형별 비교표

각 메모리 유형의 특성을 종합적으로 비교하면 다음과 같다. 프로젝트의 요구사항에 맞는 메모리 전략을 선택하는 데 참고할 수 있다.

특성Buffer MemoryWindow BufferSummary MemorySummary BufferVector StoreKnowledge Graph
구현 복잡도매우 낮음낮음중간중간높음매우 높음
토큰 효율성매우 낮음중간높음높음높음중간
정보 보존도완벽최근만요약 수준최근 완벽 + 요약검색 의존구조화된 사실
장기 대화 적합성부적합부적합적합적합매우 적합적합
세션 간 지속성불가불가가능(저장 시)가능(저장 시)가능가능
검색 방식전체 전달최근 k턴요약 전달하이브리드의미 검색그래프 탐색
추가 LLM 호출없음없음매 턴임계값 초과 시없음매 턴
추가 인프라없음없음없음없음벡터 DB그래프 DB
추천 사용 사례단순 Q&A짧은 상담일반 대화범용개인화 어시스턴트도메인 전문가
대표 구현LangChain BufferLangChain WindowLangChain SummaryLangChain SummaryBufferPinecone + LangChainNeo4j + LangChain

메모리 유형 선택 가이드라인

단순한 고객 지원 챗봇에서는 Window Buffer Memory가 적합하다. 최근 몇 턴의 맥락만 있으면 대부분의 질문에 답할 수 있고, 구현 비용이 낮다. 개인 비서형 챗봇에서는 Vector Store Memory를 중심으로 Summary Buffer Memory를 결합하는 것이 좋다. 장기적인 사용자 정보를 벡터 DB에 저장하고, 현재 세션의 대화는 요약 버퍼로 관리한다. 도메인 전문 상담 챗봇(의료, 법률 등)에서는 Knowledge Graph Memory가 유용하다. 전문 용어 간의 관계를 그래프로 구조화하면 정확한 맥락 파악이 가능하다.

프라이버시와 데이터 보호

챗봇 메모리 시스템은 사용자의 개인정보를 대량으로 수집하고 저장하므로, 프라이버시와 데이터 보호는 설계 초기부터 반드시 고려해야 하는 핵심 요소다.

데이터 최소화 원칙

서비스 제공에 필요한 최소한의 정보만 수집하고 저장해야 한다. "나중에 쓸모 있을 수도 있다"는 이유로 모든 대화 내용을 무기한 보관하는 것은 위험하다. 메모리에 저장할 정보의 카테고리를 명확히 정의하고, 정의된 카테고리에 해당하지 않는 민감 정보(주민번호, 신용카드 번호, 의료 기록 등)는 자동으로 필터링하여 저장하지 않아야 한다.

사용자 통제권 보장

GDPR, CCPA 등 글로벌 개인정보보호법은 사용자에게 자신의 데이터에 대한 접근, 수정, 삭제 권한을 보장하도록 요구한다. 챗봇 메모리 시스템에서도 다음을 지원해야 한다. 첫째, 사용자가 챗봇이 자신에 대해 기억하고 있는 정보를 확인할 수 있어야 한다. 둘째, 잘못된 정보를 수정하거나 특정 메모리를 삭제할 수 있어야 한다. 셋째, 메모리 기능 자체를 비활성화하는 옵트아웃 옵션을 제공해야 한다.

데이터 보안

저장된 메모리 데이터는 암호화(at-rest 및 in-transit)가 필수다. 특히 벡터 DB에 저장된 임베딩은 원본 텍스트를 복원할 수 없다고 생각하기 쉽지만, 최근 연구에서 임베딩 역전 공격(embedding inversion attack)을 통해 원본 텍스트를 상당 부분 복원할 수 있음이 밝혀졌다. 따라서 벡터 DB에 대한 접근 제어와 암호화도 텍스트 DB와 동일한 수준으로 적용해야 한다.

메모리 보존 정책

메모리의 보존 기간을 명확히 정의해야 한다. 30일 이상 접근하지 않은 메모리는 자동 삭제하거나, 최소한 식별 정보를 제거한 익명화 처리를 해야 한다. 사용자가 서비스를 탈퇴하면 해당 사용자의 모든 메모리를 완전히 삭제하는 프로세스가 반드시 존재해야 한다.

실패 사례와 복구 전략

메모리 시스템은 다양한 방식으로 실패할 수 있다. 미리 예상 가능한 실패 시나리오와 복구 전략을 준비해두는 것이 프로덕션 안정성의 핵심이다.

실패 사례 1: 메모리 오염 (Memory Pollution)

사용자가 의도적이든 비의도적이든 잘못된 정보를 제공하면, 그 정보가 메모리에 저장되어 이후 대화를 왜곡할 수 있다. 예를 들어 "저는 의사입니다"라고 한 사용자가 나중에 "저는 개발자입니다"라고 하면, 두 정보가 모두 메모리에 남아 모순이 발생한다.

복구 전략: 모순 탐지 로직을 구현한다. 새로운 정보가 기존 메모리와 충돌하면, 사용자에게 확인을 요청하거나 최신 정보로 갱신한다. 중요도가 높은 프로필 정보(직업, 위치 등)의 변경은 별도로 기록하여 감사 추적(audit trail)을 유지한다.

실패 사례 2: 컨텍스트 윈도우 오버플로우

메모리에서 가져온 정보와 현재 대화를 합치면 컨텍스트 윈도우를 초과하는 경우가 발생할 수 있다. 특히 장기간 사용한 사용자의 프로필이 방대한 경우에 빈번하다.

복구 전략: 메모리 검색 결과의 총 토큰 수를 사전에 제한한다. 컨텍스트 윈도우의 60%를 현재 대화용으로, 30%를 메모리용으로, 10%를 시스템 프롬프트용으로 할당하는 예산 기반 접근법을 사용한다. 예산 초과 시 중요도가 낮은 메모리부터 제거하는 우선순위 큐를 활용한다.

실패 사례 3: 벡터 DB 장애

벡터 DB가 다운되거나 응답이 지연되면 메모리 검색이 불가능해진다. 이 경우 대화가 완전히 실패하면 안 된다.

복구 전략: 그레이스풀 디그레이데이션(graceful degradation) 패턴을 적용한다. 벡터 DB 장애 시 메모리 없이도 기본적인 대화가 가능하도록 폴백(fallback) 로직을 구현한다. 최근 N턴의 대화만으로 응답하되, 사용자에게 "과거 대화를 참조하는 데 일시적 문제가 있다"고 투명하게 알린다.

실패 사례 4: 요약 품질 저하

Summary Memory에서 LLM의 요약 품질이 낮으면, 중요한 정보가 누락되거나 왜곡된 요약이 생성될 수 있다. 이는 시간이 지남에 따라 누적되어 대화 품질을 점진적으로 악화시킨다.

복구 전략: 요약의 품질을 주기적으로 검증하는 파이프라인을 구축한다. 원본 대화와 요약을 비교하여 핵심 정보의 보존 여부를 자동으로 검사하고, 품질이 기준 이하인 요약은 재생성한다. 또한 중요도가 높은 정보(사용자 이름, 핵심 요구사항 등)는 요약과 별도로 구조화된 형태로 보존한다.

실패 사례 5: 개인정보 유출

메모리에 저장된 한 사용자의 정보가 다른 사용자에게 노출되는 사고가 발생할 수 있다. 이는 user_id 기반 격리가 제대로 이루어지지 않을 때 발생한다.

복구 전략: 메모리 저장소를 사용자별로 물리적으로 분리하거나, 모든 쿼리에 user_id 필터를 강제 적용하는 미들웨어를 도입한다. 정기적인 보안 감사를 통해 교차 사용자 데이터 접근이 불가능함을 검증한다.

운영 시 주의사항 체크리스트

프로덕션 환경에서 챗봇 메모리 시스템을 운영할 때 반드시 확인해야 할 항목들이다.

인프라 관련

  • 벡터 DB의 고가용성(HA) 설정이 되어 있는가
  • 메모리 저장소의 백업 및 복원 절차가 수립되어 있는가
  • 임베딩 모델 버전 변경 시 기존 벡터 재인덱싱 계획이 있는가
  • 메모리 저장소의 용량 모니터링과 자동 스케일링이 구성되어 있는가

성능 관련

  • 메모리 검색 지연 시간(p95)이 SLA 이내인가
  • 대량 사용자 환경에서의 동시 접근 성능이 검증되었는가
  • 메모리 가비지 컬렉션이 정기적으로 실행되는가
  • 임베딩 생성 비용이 예산 이내인가

보안/프라이버시 관련

  • 저장 데이터의 암호화(AES-256 이상)가 적용되어 있는가
  • 사용자별 메모리 격리가 검증되었는가
  • 개인정보 자동 필터링(PII detection)이 동작하는가
  • 데이터 보존 정책(retention policy)이 정의되고 자동화되어 있는가
  • 사용자의 데이터 삭제 요청을 처리하는 API가 존재하는가

품질 관련

  • 메모리 검색 정확도를 정기적으로 평가하고 있는가
  • 요약 품질의 드리프트(drift)를 모니터링하고 있는가
  • 사용자 프로필 정보의 정확성을 검증하는 메커니즘이 있는가
  • 메모리 시스템 장애 시 폴백 시나리오가 테스트되었는가

참고자료

메모리 아키텍처 설계와 구현에 유용한 주요 참고자료를 정리한다.

  1. MemGPT / Letta 공식 문서 - MemGPT 아키텍처의 공식 개념 설명과 구현 가이드
  2. LangChain Conversational Memory - Pinecone - LangChain 메모리 모듈의 종류와 활용법에 대한 상세 튜토리얼
  3. Mem0 - Universal Memory Layer for AI Agents - AI 에이전트를 위한 범용 메모리 레이어 오픈소스 프로젝트
  4. Design Patterns for Long-Term Memory in LLM-Powered Architectures - Serokell - LLM 기반 시스템의 장기 기억 설계 패턴 종합 분석
  5. Agent Memory Paper List (GitHub) - AI 에이전트 메모리 관련 학술 논문 큐레이션 목록
  6. LangChain ConversationBufferMemory 공식 문서 - LangChain 메모리 API 레퍼런스
  7. Stateful AI Agents: A Deep Dive into Letta Memory Models - Letta/MemGPT 메모리 모델에 대한 심층 분석 글