LangGraph: Agent With Memory

체크포인터와 persistent memory로 멀티턴 챗봇 구현하기

LangGraph에서 체크포인터(checkpointer)를 사용해 에이전트에 영속적 메모리(persistent memory)를 추가하는 방법을 설명한다. ToolNodetools_condition을 활용한 도구 호출 흐름, MemorySaver 기반의 체크포인트 저장 및 thread_id로 세션을 구분하여 멀티턴 대화를 이어가는 예시를 포함한다. 프로덕션 환경에서는 SqliteSaverPostgresSaver 등 외부 저장소로 대체하는 방식을 소개한다.

Agent
LangGraph
AI
저자

Kwangmin Kim

공개

2025년 07월 15일

1 Agent 에 메모리(memory) 추가

  • 현재 챗봇은 과거 상호작용을 스스로 기억할 수 없어 일관된 다중 턴 대화를 진행하는 데 제한이 있다.
  • memory는 멀티턴 대화를 구현하기 위해 꼭 필요한 기능이다.
  • 이를 해결하기 위해 memory 를 추가한다.

참고

이번에는 pre-built 되어있는 ToolNodetools_condition 을 활용한다.

  1. ToolNode: 도구 호출을 위한 노드
  2. tools_condition: 도구 호출 여부에 따른 조건 분기

1.1 Persistent Checkpointing

이 챗봇은 이제 도구를 사용하여 사용자 질문에 답할 수 있지만, 이전 상호작용의 context를 기억하지 못한다. 이는 멀티턴(multi-turn) 대화를 진행하는 능력을 제한한다.

LangGraphpersistent checkpointing 을 통해 이 문제를 해결한다.

그래프를 컴파일할 때 checkpointer를 제공하고 그래프를 호출할 때 thread_id를 제공하면, LangGraph는 각 단계 후 상태를 자동으로 저장 한다. 동일한 thread_id를 사용하여 그래프를 다시 호출하면, 그래프는 저장된 상태를 로드하여 챗봇이 이전에 중단한 지점에서 대화를 이어갈 수 있게 한다. - thread_id로 대화 맥락을 구분한다. (대화 내용을 매번 초기화할 필요없이 thread_id만 바꾸면 새 대화로 전향 가능)

checkpointing 는 LangChain 의 메모리 기능보다 훨씬 강력하다. (아마 이 튜토리얼을 완수하면 자연스럽게 이를 확인할 수 있다.) - LangChain의 메모리는 주로 대화 기록을 저장하는 데 사용되는 반면, - LangGraph의 체크포인트는 그래프의 전체 상태를 저장한다. 이는 단순한 대화 기록뿐만 아니라, 그래프의 현재 노드, 변수 값, 도구 호출 결과 등 모든 상태 정보를 포함한다.

# API 키를 환경변수로 관리하기 위한 설정 파일
from dotenv import load_dotenv

# API 키 정보 로드
load_dotenv()
# LangSmith 추적을 설정합니다. https://smith.langchain.com
# !pip install -qU langchain-teddynote
from langchain_teddynote import logging

# 프로젝트 이름을 입력합니다.
logging.langsmith("CH17-LangGraph-Modules")
  • 멀티턴(multi-turn) 대화를 가능하게 하기 위해 checkpointing을 추가한다.
  • MemorySaver checkpointer를 생성한다.
from langgraph.checkpoint.memory import MemorySaver

# 메모리 저장소 생성
memory = MemorySaver()

참고

  • 이번 튜토리얼에서는 in-memory checkpointer 를 사용한다.
  • MemorySaver는 메모리에 체크포인트를 저장하는 간단한 구현이다.
  • 프로덕션 환경에서는 SqliteSaver 또는 PostgresSaver와 같은 외부 저장소로 대체하여 영속성을 보장할 수 있다.
from typing import Annotated
from typing_extensions import TypedDict
from langchain_openai import ChatOpenAI
from langchain_teddynote.tools.tavily import TavilySearch
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition


########## 1. 상태 정의 ##########
# 상태 정의
class State(TypedDict):
    # 메시지 목록 주석 추가
    messages: Annotated[list, add_messages]


########## 2. 도구 정의 및 바인딩 ##########
# 도구 초기화
tool = TavilySearch(max_results=3)
tools = [tool]

# LLM 초기화
llm = ChatOpenAI(model="gpt-4o-mini")

# 도구와 LLM 결합
llm_with_tools = llm.bind_tools(tools)


########## 3. 노드 추가 ##########
# 챗봇 함수 정의
def chatbot(state: State):
    # 메시지 호출 및 반환
    return {"messages": [llm_with_tools.invoke(state["messages"])]}


# 상태 그래프 생성
graph_builder = StateGraph(State)

# 챗봇 노드 추가
graph_builder.add_node("chatbot", chatbot)

# 도구 노드 생성 및 추가
tool_node = ToolNode(tools=[tool])

# 도구 노드 추가
graph_builder.add_node("tools", tool_node)

# 조건부 엣지
graph_builder.add_conditional_edges(
    "chatbot",
    tools_condition,
)

########## 4. 엣지 추가 ##########

# tools > chatbot
graph_builder.add_edge("tools", "chatbot")

# START > chatbot
graph_builder.add_edge(START, "chatbot")

# chatbot > END
graph_builder.add_edge("chatbot", END)
  • 제공된 checkpointer를 사용하여 그래프를 컴파일
# 그래프 빌더 컴파일
graph = graph_builder.compile(checkpointer=memory)
  • 그래프의 연결성은 LangGraph-Agent 와 동일
  • 단지, 이번에 추가된 것은 그래프가 각 노드를 처리하면서 State를 체크포인트하는 것뿐이다.
from langchain_teddynote.graphs import visualize_graph

# 그래프 시각화
visualize_graph(graph)

1.2 RunnableConfig 설정

  • RunnableConfig 을 정의하고 recursion_limitthread_id 를 설정한다.
    • recursion_limit: 최대 방문할 노드 수. 그 이상은 RecursionError 발생
    • thread_id: 스레드 ID 설정
  • thread_id 는 대화 세션을 구분하는 데 사용된다.
  • 즉, 메모리의 저장은 thread_id 에 따라 개별적으로 이루어진다.
from langchain_core.runnables import RunnableConfig

config = RunnableConfig(
    recursion_limit=10,  # 최대 10개의 노드까지 방문. 그 이상은 RecursionError 발생하게 하여 무한 루프 방지
    configurable={"thread_id": "1"},  # 스레드 ID 설정
)
# 첫 질문 (메모리 유지 기능 있음)
question = (
    "내 이름은 `테디노트` 입니다. YouTube 채널을 운영하고 있어요. 만나서 반가워요"
)

for event in graph.stream({"messages": [("user", question)]}, config=config):
    for value in event.values():
        value["messages"][-1].pretty_print()
================================== Ai Message ==================================

안녕하세요, 테디노트님! 만나서 반갑습니다. YouTube 채널을 운영하고 계시다니 멋지네요! 어떤 콘텐츠를 주로 다루고 계신가요?
# 이어지는 질문
question = "내 이름이 뭐라고 했지?"

for event in graph.stream({"messages": [("user", question)]}, config=config):
    for value in event.values():
        value["messages"][-1].pretty_print()
================================== Ai Message ==================================

당신의 이름은 테디노트입니다.
  • 이번에는 RunnableConfigthread_id 를 변경한 뒤, 이전 대화 내용을 기억하고 있는지 물어본다.
from langchain_core.runnables import RunnableConfig

question = "내 이름이 뭐라고 했지?"

config = RunnableConfig(
    recursion_limit=10,  # 최대 10개의 노드까지 방문. 그 이상은 RecursionError 발생
    configurable={"thread_id": "2"},  # 스레드 ID 설정
)

for event in graph.stream({"messages": [("user", question)]}, config=config):
    for value in event.values():
        value["messages"][-1].pretty_print()
================================== Ai Message ==================================

죄송하지만, 당신의 이름을 기억할 수 있는 기능이 없습니다. 이전 대화에서 이름을 언급하신 적이 없다면, 제가 알 수 없습니다. 이름을 알려주시면 좋겠습니다!

1.3 스냅샷: 저장된 State 확인

  • Checkpoint 에는 현재 상태 값, 해당 구성, 그리고 처리할 next 노드가 포함되어 있다.
  • 주어진 설정에서 그래프의 state를 검사하려면 언제든지 get_state(config)를 호출한다.
from langchain_core.runnables import RunnableConfig

config = RunnableConfig(
    configurable={"thread_id": "1"},  # 스레드 ID 설정
)
# 그래프 상태 스냅샷 생성
snapshot = graph.get_state(config)
snapshot.values["messages"]
[HumanMessage(content='내 이름은 `테디노트` 입니다. YouTube 채널을 운영하고 있어요. 만나서 반가워요', additional_kwargs={}, response_metadata={}, id='0b4fb756-225d-49a0-a90b-acb39cb42d48'), AIMessage(content='안녕하세요, 테디노트님! 만나서 반갑습니다. YouTube 채널을 운영하고 계시다니 멋지네요! 어떤 콘텐츠를 주로 다루고 계신가요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 45, 'prompt_tokens': 105, 'total_tokens': 150, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-c9143f45-843c-478c-9360-47d2bce2207e-0', usage_metadata={'input_tokens': 105, 'output_tokens': 45, 'total_tokens': 150, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}}), HumanMessage(content='내 이름이 뭐라고 했지?', additional_kwargs={}, response_metadata={}, id='cf67e3b1-414a-4193-a797-f1a5c8f41d28'), AIMessage(content='당신의 이름은 테디노트입니다.', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 165, 'total_tokens': 177, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-7cc53d3a-982d-430c-8d7c-7520a7c894a4-0', usage_metadata={'input_tokens': 165, 'output_tokens': 12, 'total_tokens': 177, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}}), HumanMessage(content='내 이름이 뭐라고 했지?', additional_kwargs={}, response_metadata={}, id='c0c1ad0a-7c51-49ba-9aa8-fa125b501ab2'), AIMessage(content='당신의 이름은 테디노트입니다.', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 192, 'total_tokens': 204, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-e65d423b-fb03-4237-8f51-15d8b56df860-0', usage_metadata={'input_tokens': 192, 'output_tokens': 12, 'total_tokens': 204, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}})]
  • snapshot.config 를 출력하게 설정된 config 정보를 확인할 수 있다.
# 설정된 config 정보
snapshot.config
{'configurable': {'thread_id': '1',  'checkpoint_ns': '',  'checkpoint_id': '1ef99493-62b0-6020-8007-4fb8c65ebabc'}}
  • snapshot.values 를 출력하여 지금까지 누적 저장된 state 값을 확인할 수 있다.
# 저장된 값(values)

#snapshot.values['messages']
snapshot.values
{'messages': [HumanMessage(content='내 이름은 `테디노트` 입니다. YouTube 채널을 운영하고 있어요. 만나서 반가워요', additional_kwargs={}, response_metadata={}, id='0b4fb756-225d-49a0-a90b-acb39cb42d48'),  AIMessage(content='안녕하세요, 테디노트님! 만나서 반갑습니다. YouTube 채널을 운영하고 계시다니 멋지네요! 어떤 콘텐츠를 주로 다루고 계신가요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 45, 'prompt_tokens': 105, 'total_tokens': 150, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-c9143f45-843c-478c-9360-47d2bce2207e-0', usage_metadata={'input_tokens': 105, 'output_tokens': 45, 'total_tokens': 150, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}}),  HumanMessage(content='내 이름이 뭐라고 했지?', additional_kwargs={}, response_metadata={}, id='cf67e3b1-414a-4193-a797-f1a5c8f41d28'),  AIMessage(content='당신의 이름은 테디노트입니다.', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 165, 'total_tokens': 177, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-7cc53d3a-982d-430c-8d7c-7520a7c894a4-0', usage_metadata={'input_tokens': 165, 'output_tokens': 12, 'total_tokens': 177, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}}),  HumanMessage(content='내 이름이 뭐라고 했지?', additional_kwargs={}, response_metadata={}, id='c0c1ad0a-7c51-49ba-9aa8-fa125b501ab2'),  AIMessage(content='당신의 이름은 테디노트입니다.', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 192, 'total_tokens': 204, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-e65d423b-fb03-4237-8f51-15d8b56df860-0', usage_metadata={'input_tokens': 192, 'output_tokens': 12, 'total_tokens': 204, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}})]}
  • snapshot.next 를 출력하여 현재 시점에서 앞으로 찾아갈 다음 노드를 확인 할 수 있다.
  • END 에 도달하였기 때문에 다음 노드는 빈 값이 출력된다.
# 다음 노드
snapshot.next
()
  • 하지만, 중간 노드에 Stop을 걸어둘 수 있는데 snapshot.next를 통해 다음 노드로 어디로 갈지 확인할 수 있다.
snapshot.metadata["writes"]["chatbot"]["messages"][0]
AIMessage(content='당신의 이름은 테디노트입니다.', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 192, 'total_tokens': 204, 'completion_tokens_details': {'audio_tokens': None, 'reasoning_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': None, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-mini-2024-07-18', 'system_fingerprint': 'fp_0ba0d124f1', 'finish_reason': 'stop', 'logprobs': None}, id='run-e65d423b-fb03-4237-8f51-15d8b56df860-0', usage_metadata={'input_tokens': 192, 'output_tokens': 12, 'total_tokens': 204, 'input_token_details': {'cache_read': 0}, 'output_token_details': {'reasoning': 0}})

복잡한 구조의 metadata 를 시각화하기 위해 display_message_tree 함수를 사용한다.

from langchain_teddynote.messages import display_message_tree

# 메타데이터(tree 형태로 출력)
display_message_tree(snapshot.metadata)

다음과 같은 다양한 정보를 얻을 수 있다:
* snapshot.metadatawrites 항목을 보면, 각 노드에서 어떤 값이 쓰여졌는지 확인할 수 있다. * chatbot 노드에서 messages가 쓰여졌고, 그 값이 당신의 이름은 테디노트입니다. 라는 것을 확인할 수 있다 * chache_read가 0인 것을 보면, 이전에 저장된 값을 읽어오지 않고 새로 계산된 값을 사용했다는 것을 알 수 있다. (즉, 캐시에서 읽어온 값이 없다는 의미)

Subscribe

Enjoy this blog? Get notified of new posts by email: