본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 27. 10:45

에이전트 시리즈 (6): 메모리 관리 — 에이전트에게 중요한 것을 기억하도록 가르치기

요약

LLM의 stateless 특성을 극복하기 위한 에이전트 메모리 관리 전략을 다룹니다. 인지 과학 모델을 기반으로 감각, 작업, 에피소드 메모리 등 네 가지 계층 구조와 LangGraph를 활용한 구현 방법을 설명합니다.

핵심 포인트

  • LLM은 설계상 상태가 없으므로 명시적인 메모리 관리가 필수적임
  • 메모리는 감각, 작업, 에피소드 등 계층적 구조로 분류 가능함
  • 대화 기록이 길어질 경우 토큰 비용 절감을 위한 요약 및 압축이 필요함
  • LangGraph의 checkpointer와 store를 통해 메모리 구현 가능

메모리: 에이전트를 "도구"에서 "비서"로 변화시키기

메모리가 없는 에이전트는 대화할 때마다 처음부터 다시 시작합니다. 당신이 이름, 직업, 선호하는 학습 스타일을 알려주어도 — 다음번에는 당신이 누구인지 전혀 알지 못합니다.

이것은 버그가 아닙니다. 누락된 아키텍처 계층 (architectural layer)의 문제입니다.

LLM (Large Language Models)은 설계상 상태가 없는 (stateless) 특성을 가집니다. 모든 호출은 독립적입니다. 만약 에이전트가 무언가를 기억하기를 원한다면, 아키텍처 수준에서 메모리를 명시적으로 저장, 관리 및 검색 (explicitly store, manage, and retrieve) 해야 합니다. 그것이 바로 메모리 관리 (memory management)가 해결하는 문제입니다.

이 글에서는 에이전트 메모리를 네 가지 차원으로 나눕니다: 메모리 유형의 분류 (taxonomy), 세 가지 컨텍스트 관리 (context management) 전략, LangGraph의 두 가지 메모리 프리미티브 (memory primitives: checkpointer 및 store), 그리고 임의로 긴 대화를 위한 자동 압축 (auto-compression) 체계입니다.

네 가지 메모리 유형: 인지 과학에서 엔지니어링까지

인간의 메모리에 대한 인지 과학 (cognitive science) 모델을 빌려오면, 에이전트 메모리는 자연스럽게 네 가지 계층으로 분해되며 — 각 계층은 그에 상응하는 LangGraph 구현을 가집니다:

┌──────────────────────────────────────────────────────────────┐
│                     Memory Hierarchy                          │
├──────────────────────┬───────────────────────────────────────┤
...

감각 메모리 (Sensory Memory): 현재의 턴 (Current Turn)

가장 일시적인 계층입니다. 하나의 LLM 호출에 대한 입력과 출력이며 — 사용 후 폐기됩니다:

q = "What is len([1, 2, 3])?"
answer = llm.invoke([HumanMessage(q)])
# answer.content → "len([1, 2, 3]) equals 3."
...

여기에는 "관리"할 것이 없습니다 — 감각 메모리는 그저 LLM 호출 자체입니다.

작업 메모리 (Working Memory): 제한된 대화 기록 (Bounded Conversation History)

대화의 마지막 몇 턴을 프롬프트 (prompt) 앞에 추가합니다. 그 효과는 즉각적이고 명확합니다:

history = [
    HumanMessage("My name is Li Lei, I'm a Python engineer"),
    AIMessage("Hello, Li Lei! Nice to meet you."),
...

측정된 출력:

히스토리 포함 시 → "네, 당신의 이름은 Li Lei이고, Python 엔지니어라고 말씀하셨죠..."
히스토리 미포함 시 → "죄송합니다. 이전에 말씀하신 이름을 기억할 수 없습니다. AI로서 저는 개인 데이터를 저장할 수 있는 지속적인 메모리 (persistent memory)를 가지고 있지 않기 때문입니다..."

그 차이는 극명합니다. 단점은 대화 길이에 따라 토큰 비용 (token cost)이 선형적으로 증가한다는 점이며, 따라서 이를 제한하기 위해 절단 (truncation) 또는 요약 (summarization)이 필요합니다.

에피소드 메모리 (Episodic Memory): 압축된 히스토리 스니펫

대화 히스토리가 길어지면 모든 내용을 프롬프트 (prompt)에 집어넣는 것은 비용이 많이 듭니다. 에피소드 메모리의 접근 방식은 다음과 같습니다: 먼저 압축한 다음, 저장한다.

long_history = history * 4  # 16개의 메시지
summary = llm.invoke([
    SystemMessage("다음 대화를 핵심 사실을 보존하면서 60단어 미만으로 압축하세요"),
...

16개의 메시지가 28단어로 압축되었습니다. 다음 턴 (turn)에서는 원본 히스토리 대신 요약본을 사용합니다. 이로 인해 토큰 비용이 극적으로 감소합니다.

시맨틱 메모리 (Semantic Memory): 세션 간 사용자 사실 정보

가장 지속적인 계층입니다. 대화를 넘나들며 생존하며, 특히 사용자에 대한 장기적인 사실(이름, 역할, 선호도 등)을 저장하기 위해 사용됩니다:

# KV 스토어에 사용자 프로필 저장 — 향후 어떤 세션에서도 읽을 수 있음
user_profile = {
    "name": "Li Lei",
...

세 가지 컨텍스트 관리 전략: 절단 (Truncation) / 요약 (Summarization) / 검색 (Retrieval)

대화 히스토리가 계속 늘어나 컨텍스트 윈도우 (context window)가 이를 감당할 수 없을 때, 세 가지 옵션이 있습니다:

전략 1: 절단 (Truncation)

가장 간단한 접근 방식입니다. 마지막 N개의 메시지만 유지하고 나머지는 버립니다:

# 마지막 4개의 메시지만 유지
truncated = history[-4:]
resp = llm.invoke(truncated + [HumanMessage(test_q)])

8개의 주제로 구성된 대화 히스토리(16개 메시지)를 대상으로 테스트를 진행했습니다. 마지막 4개로 절단한 후, "Python 리스트란 무엇인가요?"라고 질문했습니다:

절단 후 가장 먼저 보이는 메시지: "Python 데코레이터(decorators)를 설명해 주세요" (주제 5 — "리스트(lists)"가 주제 1이었음)
답변: "Python 리스트는 내장 데이터 구조입니다..." (LLM의 자체 지식으로부터 답변함)
⚠ 우리가 리스트를 "다루었다"는 사실을 상실함 — LLM이 일반적인 지식으로만 답변함

가장 적합한 경우: 과거의 연속성이 중요하지 않은 시나리오, 또는 과거 문맥이 무관한 순수 Q&A 에이전트(Agents).

전략 2: 요약 (Summarization)

LLM을 사용하여 긴 히스토리를 하나의 요약 문단으로 압축한 다음, 이후의 턴(turns)에서는 원본 히스토리 대신 요약본을 사용합니다:

summary_resp = llm.invoke([
    SystemMessage("대화 히스토리를 요약(80단어 이하)하여 압축하세요. 모든 주제 이름은 유지하세요."),
    HumanMessage("\n".join([f"{m.type}: {m.content}" for m in history])),
...

동일한 "Python 리스트란 무엇인가요?"라는 질문에 대해, 요약 방식은 단순히 일반적인 지식을 나열하는 것이 아니라 "우리가 이것에 대해 논의했다"는 인식을 가지고 답변합니다.

전략 비교:

전략토큰 비용유지되는 정보복잡도가장 적합한 경우
절단 (Truncation)가장 낮음최근 턴만 유지매우 낮음Q&A, 상태 비저장(stateless) 작업
...

전략 3: 검색 (Retrieval)

현재 질문과 의미론적으로 관련이 있는 히스토리 조각(snippets)만 가져옵니다 — 가장 효과적이면서도 가장 복잡한 접근 방식입니다:

# 단순화된 데모: 키워드 필터 (운영 환경에서는 벡터 유사도(vector similarity)를 사용함)
relevant = [m for m in history if "list" in m.content.lower()]
# 16개 메시지 → 관련 메시지 3개
...

가장 적합한 경우: 지식 베이스 에이전트(Knowledge-base Agents), 광범위한 사용자 히스토리를 가진 개인 비서.

LangGraph 체크포인터(checkpointer): 세션 내 상태 지속성 (Within-Session State Persistence)

LangGraph의 MemorySaver (체크포인터)는 thread_id를 사용하여 세션을 구분하고, 세션 내의 대화 히스토리를 자동으로 누적합니다:

from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent

...

실무에서의 턴 간 참조 (Cross-Turn Reference in Practice)

동일한 세션 내에서의 세 번의 연속적인 날씨 질의:

[Turn 1] 사용자: 오늘 베이징 날씨가 어때?
         에이전트: 오늘 베이징: 맑음, 25°C, 북동풍 레벨 3, 공기질 좋음.
         (상태 내 메시지 수: 4)
...

Turn 2와 Turn 3는 모두 이전 기록에 의존합니다. 체크포인터 (checkpointer)가 턴 간 문맥 (cross-turn context)을 자동으로 처리합니다.

세션 격리 (Session Isolation)

서로 다른 thread_id 값은 완전히 독립적입니다:

[새 세션 — thread_id: weather_002]
사용자: 내가 방금 어떤 도시에 대해 물어봤지?
에이전트: "어떤 도시"인지 물으셨지만, 특정 도시 이름은 제공되지 않았습니다.
...

세션 지속 (Session Continuation)

나중에 동일한 thread_id로 돌아오면 — 기록이 여전히 남아 있습니다:

[세션 A 지속 — 동일한 thread_id]
사용자: 그 두 도시를 선전(Shenzhen)과 비교해줘
에이전트: 오늘 선전: 소나기, 27°C, 남서풍 레벨 2, 뇌우 주의보.
...

MemorySaver는 인메모리 (in-memory) 방식이므로 — 프로세스가 재시작되면 데이터가 손실됩니다. 프로덕션 (production) 환경에서는 SqliteSaver (로컬 파일) 또는 PostgresSaver (데이터베이스)를 사용하세요.

LangGraph InMemoryStore: 세션 간 장기 메모리 (Cross-Session Long-Term Memory)

체크포인터 (checkpointer)는 단일 세션 내의 메모리를 처리합니다. 세션 간 장기 메모리를 위해서는 store가 필요합니다:

checkpointer  →  thread_id에 결합되며, 세션의 수명 동안 유효함
store         →  user_id에 결합되며, 모든 세션에 걸쳐 지속됨

핵심 API (Core API)

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()
...

실무에서의 세션 간 메모리 (Cross-Session Memory in Practice)

세션 A에서 에이전트는 대화로부터 사용자 정보를 자동으로 추출하여 저장합니다:

[세션 A] 사용자가 세 가지 사항을 말함 → 자동 추출 및 저장됨:
  • Li Lei, 백엔드 엔지니어
  • Python, Go, LangGraph, 에이전트 개발
...

완전히 새로운 세션 B (새로운 thread_id, 동일한 user_id)에서 "나를 알아?
"라고 물었을 때:

[세션 B — 완전히 새로운 thread_id]
사용자: 안녕, 나를 알아?
에이전트: 안녕하세요! 당신의 프로필에 기반하면, 네, 알고 있습니다. 당신은 백엔드 엔지니어인 Li Lei입니다.
...

서로 다른 user_id 값에 대한 데이터는 완전히 격리되어 있으며, 사용자 간에 유출될 수 없습니다.

checkpointer vs store

# 단기 메모리 (Short-term memory): checkpointer — thread_id에 종속되며 세션 내에서 유효함
app = graph.compile(checkpointer=MemorySaver())
result = app.invoke(input, config={"configurable": {"thread_id": "abc"}})
...

프로덕션 환경에서는 실제 영속성 (Persistence)을 확보하기 위해 InMemoryStorePostgresStore 또는 RedisStore로 교체하십시오. 아키텍처는 동일하게 유지됩니다.

자동 요약 (Auto-Summarization): RemoveMessage + Summary Rotation

메시지 수가 임계값 (Threshold)을 초과하면 자동 압축을 트리거합니다. 이것이 무한히 긴 대화 속에서도 에이전트 (Agent)가 일관성을 유지하게 만드는 핵심 메커니즘입니다.

그래프 구조 (Graph Structure)

[chat node]
    │
    ├─ message count ≤ threshold → END
...

compress 노드: RemoveMessage를 통한 오래된 메시지 삭제

def compress_node(state: SummaryState) -> dict:
    messages = state["messages"]
    to_compress = messages[:-2]   # 가장 최근의 2개는 유지하고 나머지는 압축
...

RemoveMessage는 LangGraph 전용 메시지 삭제 연산자 (Operator)입니다. add_messages 리듀서 (Reducer)가 이를 만나면, 메시지를 추가하는 대신 상태 (State)에서 일치하는 메시지 ID를 제거합니다.

측정 결과

11회의 대화 턴 (Turns), 압축 임계값 8개 메시지:

[Turns 1–4]  Message count:  2/4/6/8  | Summary: ○ none

  [Compression triggered] 10 messages → compress 8, keep 2
...

핵심 결과: 11회의 모든 턴에서 활성 메시지는 2~8개로 유지되었습니다 — 하지만 요약 체인 (Summary chain)을 통해 1번째 턴의 모든 지식 조각이 11번째 턴까지 보존되었습니다.

상태 설계 (State Design)

class SummaryState(TypedDict):
    messages: Annotated[list, add_messages]  # add_messages가 RemoveMessage를 처리함
    summary: Optional[str]                   # 누적된 이력 요약, 시스템 프롬프트 (System prompt)에 주입됨
...

메모리 관리 설계 체크리스트 (Memory Management Design Checklist)

완전한 에이전트 메모리 시스템을 구축할 때 고려해야 할 모든 사항:

단기 메모리 (Short-term memory, checkpointer)

  • 적절한 체크포인터 (checkpointer) 백엔드 선택 (개발 시에는 MemorySaver, 운영 환경에서는 SqliteSaver/PostgresSaver)
  • 각 사용자/세션에 고유한 thread_id 할당
  • 토큰의 무한한 증가를 방지하기 위해 히스토리 절단 임계값 (history truncation threshold) 설정

장기 메모리 (Long-term memory, store)

  • 네임스페이스 (namespace)별로 사용자 데이터 정리: (type, user_id) 예: ("user_facts", uid)
  • 추출 과정에서 신뢰도 필터링 (confidence filtering) 적용 — 의미 없는 노이즈를 저장하는 것을 방지
  • 운영 환경에서는 InMemoryStorePostgresStore / RedisStore로 교체

컨텍스트 압축 (Context compression)

  • 압축 임계값 결정 (8~20개의 메시지가 합리적인 시작 범위)
  • 요약 프롬프트 (summary prompt)에 무엇을 보존할지 명시적으로 작성 (주제 이름, 주요 결정 사항, 사용자 선호도 등)
  • 요약 체인 (summary chain) 테스트: N번째 턴의 요약이 1번째 턴의 정보를 여전히 담고 있는가?
  • 전체 메시지 리스트를 교체하는 대신 RemoveMessage를 사용 (후자의 방식은 체크포인터와 함께 사용할 때 오류를 발생시킴)

메모리 읽기/쓰기 타이밍 (Memory read/write timing)

  • 메모리 읽기: chat_node 시작 시점에 시스템 프롬프트 (system prompt)에 주입
  • 메모리 쓰기: chat_node 종료 시점에 새로운 사용자 정보 추출
  • 매 턴마다 쓰기가 발생하는 것을 방지 (신뢰도 또는 콘텐츠 길이 필터 설정)

요약 (Summary)

다섯 가지 핵심 사항:

  1. 네 가지 메모리 유형과 각각의 고유한 역할: 감각 메모리 (Sensory memory)는 LLM 호출 그 자체이며, 작업 메모리 (Working memory)는 대화 기록(Conversation history)이고, 일화 메모리 (Episodic memory)는 압축된 요약본이며, 의미 메모리 (Semantic memory)는 세션 간 공유되는 KV 스토어 (KV store)입니다.
  2. checkpointer는 세션을 관리하고, store는 사용자를 관리합니다: thread_id는 세션 차원이며, user_id는 사용자 차원입니다. 이 둘을 분리하여 유지하십시오.
  3. 요약 압축은 긴 대화의 핵심입니다: RemoveMessage와 요약 주입 (Summary injection)을 사용하면 이전의 모든 지식을 보존하면서 토큰 비용을 제한된 범위 내로 유지할 수 있습니다.
  4. 세션 격리는 타협할 수 없는 원칙입니다: 서로 다른 thread_id 기록은 절대 섞이지 않으며, 서로 다른 user_id의 장기 메모리 또한 절대 섞이지 않습니다.
  5. InMemoryStore에서 PostgresStore로의 전환은 단 한 줄의 코드 변경으로 가능합니다: 아키텍처는 일정하게 유지되며, 백엔드는 교체 가능 (Pluggable)합니다.

다음 단계: 지식 베이스 통합 (Knowledge Base Integration) — 도구로서의 RAG (RAG-as-a-tool)와 RAG 파이프라인 (RAG pipeline)의 실질적인 차이점, 멀티 지식 베이스 라우팅 (Multi-knowledge-base routing), 그리고 에이전트가 언제, 무엇을, 얼마나 자주 검색할지 결정하는 방법에 대해 다룹니다.

참고 문헌 (References)

저의 홈페이지에서 더 유용한 지식과 흥미로운 제품들을 찾아보세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0