Open in Colab Open in LangChain Academy

메시지 요약 및 외부 DB 메모리를 갖춘 챗봇

복습

그래프 상태 스키마와 리듀서를 커스터마이징하는 방법을 다뤘습니다.

또한 그래프 상태에서 메시지를 트리밍하거나 필터링하는 여러 트릭을 보여드렸습니다.

이러한 개념들을 대화의 실행 요약을 생성하는 메모리를 갖춘 챗봇에서 사용했습니다.

목표

하지만 챗봇이 무기한으로 지속되는 메모리를 갖기를 원한다면 어떻게 해야 할까요?

이제 외부 데이터베이스를 지원하는 더 고급 체크포인터를 소개하겠습니다.

여기서는 Sqlite를 체크포인터로 사용하는 방법을 보여드리겠지만, Postgres와 같은 다른 체크포인터도 사용할 수 있습니다!

%%capture --no-stderr
%pip install --quiet -U langgraph-checkpoint-sqlite langchain_core langgraph langchain_openai
from dotenv import load_dotenv
 
load_dotenv("../.env", override=True)
True
import os
import getpass
 
 
def _set_env(var: str):
    if not os.environ.get(var):
        os.environ[var] = getpass.getpass(f"{var}: ")
 
 
_set_env("OPENAI_API_KEY")

Sqlite

여기서 좋은 시작점은 SqliteSaver 체크포인터입니다.

Sqlite는 작고, 빠르고, 매우 인기 있는 SQL 데이터베이스입니다.

":memory:"를 제공하면 인메모리 Sqlite 데이터베이스를 생성합니다.

import sqlite3
 
# In memory
conn = sqlite3.connect(":memory:", check_same_thread=False)

하지만 db 경로를 제공하면 데이터베이스를 생성해줍니다!

# 파일이 존재하지 않으면 파일을 불러오고 로컬 데이터베이스에 연결합니다.
!mkdir -p state_db && [ ! -f state_db/example.db ] && wget -P state_db https://github.com/langchain-ai/langchain-academy/raw/main/module-2/state_db/example.db
 
db_path = "state_db/example.db"
conn = sqlite3.connect(db_path, check_same_thread=False)
# 여기 우리의 체크포인트입니다
from langgraph.checkpoint.sqlite import SqliteSaver
 
memory = SqliteSaver(conn)

우리의 챗봇을 재정의해 봅시다.

from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, RemoveMessage
 
from langgraph.graph import END
from langgraph.graph import MessagesState
 
model = ChatOpenAI(model="gpt-4o", temperature=0)
 
 
class State(MessagesState):
    summary: str
 
 
# 모델 호출 로직 정의
def call_model(state: State):
    # 요약이 존재하면 가져오기
    summary = state.get("summary", "")
 
    # 요약이 있다면 추가합니다
    if summary:
        # 시스템 메시지에 요약 추가
        system_message = f"이전 대화 요약: {summary}"
 
        # 요약문을 최신 메시지에 추가하십시오
        messages = [SystemMessage(content=system_message)] + state["messages"]
 
    else:
        messages = state["messages"]
 
    response = model.invoke(messages)
    return {"messages": response}
 
 
def summarize_conversation(state: State):
    # 먼저, 기존 요약문을 가져옵니다.
    summary = state.get("summary", "")
 
    # 요약 프롬프트 생성
    if summary:
        # A summary already exists
        summary_message = (
            f"지금까지의 대화 요약입니다: {summary}\n\n"
            "위의 새로운 메시지를 고려하여 요약을 확장하십시오.:"
        )
 
    else:
        summary_message = "위의 대화 내용을 요약하세요.:"
 
    # 우리의 기록에 프롬프트를 추가하세요
    messages = state["messages"] + [HumanMessage(content=summary_message)]
    response = model.invoke(messages)
 
    # 가장 최근의 2개 메시지를 제외한 모든 메시지를 삭제하세요
    delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]]
    return {"summary": response.content, "messages": delete_messages}
 
 
# 대화를 종료할지 요약할지 결정하십시오
def should_continue(state: State):
    """Return the next node to execute."""
 
    messages = state["messages"]
 
    # 메시지가 여섯 개 이상일 경우 대화를 요약합니다
    if len(messages) > 6:
        return "summarize_conversation"
 
    # 그렇지 않으면 그냥 종료합니다
    return END

이제 SQLite 체크포인트 도구를 사용해 재컴파일합니다.

from IPython.display import Image, display
from langgraph.graph import StateGraph, START
 
# Define a new graph
workflow = StateGraph(State)
workflow.add_node("conversation", call_model)
workflow.add_node(summarize_conversation)
 
# Set the entrypoint as conversation
workflow.add_edge(START, "conversation")
workflow.add_conditional_edges("conversation", should_continue)
workflow.add_edge("summarize_conversation", END)
 
# Compile
graph = workflow.compile(checkpointer=memory)
# display(Image(graph.get_graph().draw_mermaid_png()))

이제 그래프를 여러 번 호출할 수 있습니다.

# Create a thread
config = {"configurable": {"thread_id": "1"}}
 
# Start conversation
input_message = HumanMessage(content="안녕하세요! 저는 랜스입니다.")
output = graph.invoke({"messages": [input_message]}, config)
for m in output["messages"][-1:]:
    m.pretty_print()
 
input_message = HumanMessage(content="내 이름이 뭐지?")
output = graph.invoke({"messages": [input_message]}, config)
for m in output["messages"][-1:]:
    m.pretty_print()
 
input_message = HumanMessage(content="저는 49ers를 좋아해요!")
output = graph.invoke({"messages": [input_message]}, config)
for m in output["messages"][-1:]:
    m.pretty_print()
================================== Ai Message ==================================

안녕하세요, 랜스! 다시 만나서 반갑습니다. 49ers에 대해 더 이야기하고 싶으신가요, 아니면 다른 주제에 대해 이야기하고 싶으신가요? 어떤 것이든 말씀해 주세요!
================================== Ai Message ==================================

당신의 이름은 랜스입니다. 맞나요?
================================== Ai Message ==================================

49ers를 좋아하시는군요! 정말 멋진 팀이죠. 49ers의 어떤 점이 가장 마음에 드시나요? 특정 선수나 경기, 아니면 팀의 역사에 대해 이야기하고 싶으신 부분이 있나요?

우리의 상태가 로컬에 저장되었는지 확인해 봅시다.

config = {"configurable": {"thread_id": "1"}}
graph_state = graph.get_state(config)
graph_state
StateSnapshot(values={'messages': [HumanMessage(content='안녕하세요! 저는 랜스입니다.', additional_kwargs={}, response_metadata={}, id='a8750f2e-7ee0-4527-b880-e67c6c0007f1'), AIMessage(content='안녕하세요, 랜스! 다시 만나서 반갑습니다. 49ers에 대해 더 이야기하고 싶으신가요, 아니면 다른 주제에 대해 이야기하고 싶으신가요? 어떤 것이든 말씀해 주세요!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 49, 'prompt_tokens': 343, 'total_tokens': 392, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRIP7qzVUffY3RXSPY72oXz3f7XU', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--aa675b2c-c5e1-47cc-835c-89e04c8c1890-0', usage_metadata={'input_tokens': 343, 'output_tokens': 49, 'total_tokens': 392, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), HumanMessage(content='내 이름이 뭐지?', additional_kwargs={}, response_metadata={}, id='5050ee42-eea0-4fa1-9376-d1478340c9a4'), AIMessage(content='당신의 이름은 랜스입니다. 맞나요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 215, 'total_tokens': 227, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRITN8olR9YcC3P9Ihgq0ChBcZQm', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--686962af-1f9a-4d30-b17a-e657a6c43bac-0', usage_metadata={'input_tokens': 215, 'output_tokens': 12, 'total_tokens': 227, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), HumanMessage(content='저는 49ers를 좋아해요!', additional_kwargs={}, response_metadata={}, id='d1719077-e848-479b-bf02-ba9037514e75'), AIMessage(content='49ers를 좋아하시는군요! 정말 멋진 팀이죠. 49ers의 어떤 점이 가장 마음에 드시나요? 특정 선수나 경기, 아니면 팀의 역사에 대해 이야기하고 싶으신 부분이 있나요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 53, 'prompt_tokens': 245, 'total_tokens': 298, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRIUa3jIsOveUqyPeXSVq7krmLqJ', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--380f9c84-b183-49cc-b7d1-2e1fd6cfe62e-0', usage_metadata={'input_tokens': 245, 'output_tokens': 53, 'total_tokens': 298, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})], 'summary': 'Lance introduced himself twice and expressed his interest in the San Francisco 49ers. The AI assistant acknowledged his name each time and offered to discuss various aspects of the 49ers, such as their history, current roster, memorable games, and more. Despite the AI\'s attempts to engage in a deeper conversation about the team, Lance did not directly respond to these prompts. Instead, he reintroduced himself in Korean, saying "안녕하세요! 저는 랜스입니다," which translates to "Hello! I am Lance." The conversation remained brief and somewhat repetitive, with the focus primarily on introductions and the mention of the 49ers.'}, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f09de2f-5d63-6a54-801b-ae08edb4d40e'}}, metadata={'source': 'loop', 'step': 27, 'parents': {}}, created_at='2025-09-30T09:50:56.111220+00:00', parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f09de2f-4f46-6fd4-801a-d247a66e8209'}}, tasks=(), interrupts=())

상태 지속하기

Sqlite와 같은 데이터베이스를 사용한다는 것은 상태가 지속된다는 의미입니다!

예를 들어, 노트북 커널을 재시작해도 디스크의 Sqlite DB에서 여전히 로드할 수 있는 것을 확인할 수 있습니다.

# Create a thread
config = {"configurable": {"thread_id": "1"}}
graph_state = graph.get_state(config)
graph_state
StateSnapshot(values={'messages': [HumanMessage(content='안녕하세요! 저는 랜스입니다.', additional_kwargs={}, response_metadata={}, id='a8750f2e-7ee0-4527-b880-e67c6c0007f1'), AIMessage(content='안녕하세요, 랜스! 다시 만나서 반갑습니다. 49ers에 대해 더 이야기하고 싶으신가요, 아니면 다른 주제에 대해 이야기하고 싶으신가요? 어떤 것이든 말씀해 주세요!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 49, 'prompt_tokens': 343, 'total_tokens': 392, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRIP7qzVUffY3RXSPY72oXz3f7XU', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--aa675b2c-c5e1-47cc-835c-89e04c8c1890-0', usage_metadata={'input_tokens': 343, 'output_tokens': 49, 'total_tokens': 392, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), HumanMessage(content='내 이름이 뭐지?', additional_kwargs={}, response_metadata={}, id='5050ee42-eea0-4fa1-9376-d1478340c9a4'), AIMessage(content='당신의 이름은 랜스입니다. 맞나요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 215, 'total_tokens': 227, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRITN8olR9YcC3P9Ihgq0ChBcZQm', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--686962af-1f9a-4d30-b17a-e657a6c43bac-0', usage_metadata={'input_tokens': 215, 'output_tokens': 12, 'total_tokens': 227, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), HumanMessage(content='저는 49ers를 좋아해요!', additional_kwargs={}, response_metadata={}, id='d1719077-e848-479b-bf02-ba9037514e75'), AIMessage(content='49ers를 좋아하시는군요! 정말 멋진 팀이죠. 49ers의 어떤 점이 가장 마음에 드시나요? 특정 선수나 경기, 아니면 팀의 역사에 대해 이야기하고 싶으신 부분이 있나요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 53, 'prompt_tokens': 245, 'total_tokens': 298, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRIUa3jIsOveUqyPeXSVq7krmLqJ', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--380f9c84-b183-49cc-b7d1-2e1fd6cfe62e-0', usage_metadata={'input_tokens': 245, 'output_tokens': 53, 'total_tokens': 298, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})], 'summary': 'Lance introduced himself twice and expressed his interest in the San Francisco 49ers. The AI assistant acknowledged his name each time and offered to discuss various aspects of the 49ers, such as their history, current roster, memorable games, and more. Despite the AI\'s attempts to engage in a deeper conversation about the team, Lance did not directly respond to these prompts. Instead, he reintroduced himself in Korean, saying "안녕하세요! 저는 랜스입니다," which translates to "Hello! I am Lance." The conversation remained brief and somewhat repetitive, with the focus primarily on introductions and the mention of the 49ers.'}, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f09de2f-5d63-6a54-801b-ae08edb4d40e'}}, metadata={'source': 'loop', 'step': 27, 'parents': {}}, created_at='2025-09-30T09:50:56.111220+00:00', parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f09de2f-4f46-6fd4-801a-d247a66e8209'}}, tasks=(), interrupts=())
from langchain_core.runnables import RunnableConfig
 
config = RunnableConfig(recursion_limit=10, configurable={"thread_id": "1"})
graph_state = graph.get_state(config)
graph_state
StateSnapshot(values={'messages': [HumanMessage(content='안녕하세요! 저는 랜스입니다.', additional_kwargs={}, response_metadata={}, id='a8750f2e-7ee0-4527-b880-e67c6c0007f1'), AIMessage(content='안녕하세요, 랜스! 다시 만나서 반갑습니다. 49ers에 대해 더 이야기하고 싶으신가요, 아니면 다른 주제에 대해 이야기하고 싶으신가요? 어떤 것이든 말씀해 주세요!', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 49, 'prompt_tokens': 343, 'total_tokens': 392, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRIP7qzVUffY3RXSPY72oXz3f7XU', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--aa675b2c-c5e1-47cc-835c-89e04c8c1890-0', usage_metadata={'input_tokens': 343, 'output_tokens': 49, 'total_tokens': 392, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), HumanMessage(content='내 이름이 뭐지?', additional_kwargs={}, response_metadata={}, id='5050ee42-eea0-4fa1-9376-d1478340c9a4'), AIMessage(content='당신의 이름은 랜스입니다. 맞나요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 12, 'prompt_tokens': 215, 'total_tokens': 227, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRITN8olR9YcC3P9Ihgq0ChBcZQm', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--686962af-1f9a-4d30-b17a-e657a6c43bac-0', usage_metadata={'input_tokens': 215, 'output_tokens': 12, 'total_tokens': 227, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}}), HumanMessage(content='저는 49ers를 좋아해요!', additional_kwargs={}, response_metadata={}, id='d1719077-e848-479b-bf02-ba9037514e75'), AIMessage(content='49ers를 좋아하시는군요! 정말 멋진 팀이죠. 49ers의 어떤 점이 가장 마음에 드시나요? 특정 선수나 경기, 아니면 팀의 역사에 대해 이야기하고 싶으신 부분이 있나요?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 53, 'prompt_tokens': 245, 'total_tokens': 298, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o-2024-08-06', 'system_fingerprint': 'fp_f33640a400', 'id': 'chatcmpl-CLRIUa3jIsOveUqyPeXSVq7krmLqJ', 'service_tier': 'default', 'finish_reason': 'stop', 'logprobs': None}, id='run--380f9c84-b183-49cc-b7d1-2e1fd6cfe62e-0', usage_metadata={'input_tokens': 245, 'output_tokens': 53, 'total_tokens': 298, 'input_token_details': {'audio': 0, 'cache_read': 0}, 'output_token_details': {'audio': 0, 'reasoning': 0}})], 'summary': 'Lance introduced himself twice and expressed his interest in the San Francisco 49ers. The AI assistant acknowledged his name each time and offered to discuss various aspects of the 49ers, such as their history, current roster, memorable games, and more. Despite the AI\'s attempts to engage in a deeper conversation about the team, Lance did not directly respond to these prompts. Instead, he reintroduced himself in Korean, saying "안녕하세요! 저는 랜스입니다," which translates to "Hello! I am Lance." The conversation remained brief and somewhat repetitive, with the focus primarily on introductions and the mention of the 49ers.'}, next=(), config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f09de2f-5d63-6a54-801b-ae08edb4d40e'}}, metadata={'source': 'loop', 'step': 27, 'parents': {}}, created_at='2025-09-30T09:50:56.111220+00:00', parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f09de2f-4f46-6fd4-801a-d247a66e8209'}}, tasks=(), interrupts=())