Python & AI Tutorials Logo
LangChain & LangGraph

8. 대화 상태와 메모리

지금까지 우리가 구축한 모든 LLM 상호작용은 stateless였습니다. Stateless란 각 요청이 독립적이라는 뜻입니다. 모델은 이전 대화를 전혀 기억하지 못합니다. 문서 요약이나 단발성 질문에는 이것으로 충분합니다.

하지만 대화형 에이전트를 만들려면 얘기가 달라집니다. 앞에서 나눈 대화를 기억하고, "그거" "저건" 같은 대명사를 이해하며, 대화 흐름 속에서 맥락을 유지해야 합니다. 이를 위해서는 상태(state)를 명시적으로 관리해야 합니다.

(여기서 "상태"는 프로그램이 기억하고 있는 정보를 의미합니다. 대화형 에이전트의 경우, 이전 대화 내용이 바로 상태입니다.)

이 챕터에서는 다음을 다룹니다:

  • LLM이 왜 대화를 "기억"하지 못하는가
  • LangChain의 메시지 히스토리 도구로 대화 메모리를 구현하는 법
  • 토큰 예산 관리로 컨텍스트 오버플로를 방지하는 법

8.1) LLM이 잊어버리는 이유

LLM은 메모리가 없습니다

LLM에게는 아주 중요한 특징이 있습니다: 과거 대화 내용을 전혀 기억하지 못합니다.

LLM API를 호출하면 모델은 입력을 처리하고 응답을 생성합니다. 하지만 그 기록을 어디에도 저장하지 않습니다. 모델 내부에는 상태를 유지하는 메모리도, 대화 기록도 없습니다. 각 API 호출은 완전히 독립적입니다. 매번 처음부터 시작하는 것과 같습니다.

이것은 의도된 설계입니다. LLM은 상태를 저장하지 않는 함수와 같이 동작합니다: 입력을 제공하면 출력을 생성하고, 아무것도 보존되지 않습니다. 지금 OpenAI 서버에서 실행 중인 모델은 방금 전에 무엇을 물었는지 기록이 없습니다.

LLM이 상태를 기억하는 것처럼 느껴지는 이유

하지만 잠깐—ChatGPT나 Claude를 사용할 때 대화를 기억하는 것처럼 느껴집니다. "파리에 대해 알려줘"라고 말한 다음 "인구는 얼마야?"라고 후속 질문을 하면 모델은 여전히 파리에 대해 이야기하고 있다는 것을 알고 있습니다. 어떻게 작동하는 걸까요?

원리는 이렇습니다: 애플리케이션이 새 메시지를 보낼 때마다 이전 대화 내역을 함께 보냅니다.

실제로 일어나는 일은 다음과 같습니다:

LLMAppUserLLMAppUser앱이 메시지 히스토리를 저장합니다"파리에 대해 알려줘"[메시지 1: "파리에 대해 알려줘"]"파리는 프랑스의 수도입니다...""파리는 프랑스의 수도입니다...""인구는 얼마야?"[메시지 1: "파리에 대해 알려줘"메시지 2: "파리는 프랑스의 수도..."메시지 3: "인구는 얼마야?"]"파리의 인구는 약 210만 명입니다...""파리의 인구는 약 210만 명입니다..."

LLM은 이전에 파리에 대해 물었다는 것을 "기억하지" 못합니다—애플리케이션이 이전 대화 내역을 함께 전송했기 때문에 LLM이 이를 알 수 있는 것입니다. 결국 상태를 관리하는 것은 모델이 아니라 애플리케이션입니다.

"상태"가 모델이 아닌 애플리케이션에서 관리되어야 하는 이유

LLM을 순수 함수(pure function)로 생각하세요: 입력이 주어지면 그에 따른 출력을 생성해낼 뿐입니다. 애플리케이션이 제공한 메시지 이외에 LLM이 따로 관리하는 상태는 없습니다. 이것은 의도된 설계입니다.

이는 대화형 에이전트의 경우 상태가 애플리케이션에서 관리되어야 함을 의미합니다.

상태—대화 히스토리—는 모델이 아닌 여러분의 애플리케이션 코드에 존재합니다. 이는 여러분이 다음을 책임져야 한다는 것을 의미합니다:

  • 대화 히스토리 저장
  • 각 새 요청과 함께 관련 히스토리 전송
  • 히스토리의 크기 관리 (섹션 8.3에서 자세히 설명)

섹션 8.2에서는 LangChain의 메시지 히스토리 도구를 사용하여 이를 구현할 것입니다.

상태를 관리하지 않으면 무슨 일이 일어나는가?

상태를 관리하지 않으면 에이전트(당신의 애플리케이션)가 일관된 대화를 유지할 수 없습니다. 가장 흔한 실패 유형은 다음과 같습니다:

1. 이전 대화 내용을 기억하지 못함

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
# 첫 번째 질문
response1 = llm.invoke([HumanMessage(content="내 이름은 Alice야")])
print(response1.content)  # 출력: 만나서 반가워요, Alice!
 
# 두 번째 질문 (히스토리 전송 안 함)
response2 = llm.invoke([HumanMessage(content="내 이름이 뭐야?")])
print(response2.content)  # 출력: 죄송하지만 당신의 이름을 모릅니다...

두 번째 호출에서 해당 정보를 전송하지 않았기 때문에 모델은 이름이 Alice라고 말한 것을 전혀 모릅니다.

2. 대명사가 무엇을 가리키는지 알지 못함

python
# 사용자가 주제에 대해 질문합니다
response1 = llm.invoke([HumanMessage(content="Python에 대해 알려줘")])
print(response1.content)
# 출력: Python은 가독성으로 유명한 고수준 프로그래밍 언어입니다...
 
# 사용자가 대명사로 후속 질문을 합니다
response2 = llm.invoke([HumanMessage(content="그거 주요 기능은 뭐야?")])
print(response2.content)
# 출력: 기꺼이 도와드리겠습니다! 무엇에 대해 물으시는지 구체적으로 말씀해주시겠어요?

이전 메시지 없이는 그것이 무엇을 가리키는지 알 수 없습니다.

실제 영향:

상태 관리 없이 고객 지원 에이전트를 구축한다고 상상해보세요:

사용자: "주문 번호 #12345에 문제가 있어요"
에이전트: "죄송합니다. 어떤 문제가 있으신가요?"
사용자: "배송 주소가 잘못되었어요"
에이전트: "확인해드리겠습니다. 주문 번호를 알려주시겠어요?"
사용자: "방금 말했잖아요..."

이런 경험은 사용자를 답답하게 만들고 신뢰를 잃게 합니다. 상태 관리는 대화형 에이전트에 선택 사항이 아닙니다—일관되고 유용한 상호작용을 만들기 위해 필수적입니다.

섹션 8.2에서는 LangChain의 메시지 히스토리 도구를 사용하여 상태 관리를 구현하고, CLI 채팅에 대화 기억 기능을 추가할 것입니다.

8.2) 대화 상태 관리하기

상태 관리가 왜 중요한지 이해했으니, 이제 어떻게 구현하는지 배워봅시다. LangChain의 내장 메시지 히스토리 도구를 사용하여 대화 내역을 관리하는 방법을 알아볼 것입니다. 마지막에는 배운 내용을 Chapter 3의 CLI 채팅에 적용하여 상태 관리 기능을 추가하겠습니다.

메시지 타입 이해하기: HumanMessage, AIMessage, SystemMessage

대화 상태 관리하는 법을 배우기 전에, 대화 상태에 사용되는 메시지 타입들에 대해 알아야 합니다. LangChain은 세 가지 메시지 타입으로 대화를 표현합니다: HumanMessage(사용자 입력), AIMessage(모델 응답), SystemMessage(지시사항). 각 메시지는 역할(메시지 타입)과 내용(실제 텍스트)을 가집니다.

python
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
 
# SystemMessage: 모델의 동작에 대한 지침
system_msg = SystemMessage(content="당신은 Python 프로그래밍을 전문으로 하는 도움이 되는 어시스턴트입니다.")
 
# HumanMessage: 사용자 입력
user_msg = HumanMessage(content="Python에서 파일을 읽는 방법은?")
 
# AIMessage: 모델의 응답
# (실제로는 LangChain이 모델 응답을 이 객체로 감싸서 반환 - 여기서는 설명을 위해 직접 작성)
ai_msg = AIMessage(content="`open()` 함수를 컨텍스트 매니저와 함께 사용할 수 있습니다...")

메시지 타입을 분리하는 이유

모델이 대화 내역을 효과적으로 이해하려면, 각 메시지의 목적과 누가 말했는지 알아야 합니다. 세 가지 메시지 타입은 각각 다른 역할을 합니다:

  • SystemMessage: 모델이 어떻게 동작해야 하는지 정의하는 지시사항 (예: "간결하게 답변하라", "너는 Python 튜터다")
  • HumanMessage: 사용자가 말한 내용
  • AIMessage: 모델이 이전에 응답한 내용

이 구조를 통해 모델은 지시사항, 사용자 질문, 자신의 이전 답변을 구별할 수 있으며—이는 일관된 다중 턴 대화를 유지하는 데 필수적입니다.

수동으로 대화 내역 구성하기

이제 세 가지 메시지 타입을 이해했으니, 수동으로 대화 내역을 구성하는 방법을 알아봅시다:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
# 대화 내역 구성
# SystemMessage는 모델 지시사항으로 맨 앞에 한 번만 사용
# 그 다음: 사용자 입력 → AI 응답 → 사용자 입력 (자연스러운 대화 흐름)
messages = [
    SystemMessage(content="당신은 간결한 Python 튜터입니다."),
    HumanMessage(content="리스트 컴프리헨션이 뭐야?"),
    AIMessage(content="리스트 컴프리헨션은 리스트를 만드는 간결한 방법입니다: [x*2 for x in range(5)]"),
    HumanMessage(content="더 복잡한 예제를 보여줄 수 있어?")
]
 
# 새 질문과 함께 전체 히스토리 전송
response = llm.invoke(messages)
print(response.content)

출력:

물론이죠! 다음은 필터링과 변환을 하는 리스트 컴프리헨션입니다:
[x**2 for x in range(10) if x % 2 == 0]
출력 결과는 다음과 같습니다:
[0, 4, 16, 36, 64]

전체 대화 히스토리를 전송했기 때문에 모델은 "더 복잡한 예제"가 리스트 컴프리헨션의 더 복잡한 예제를 가리킨다는 것을 이해합니다.

InMemoryChatMessageHistory 사용하기

대화가 길어질수록 메시지 리스트를 수동으로 관리하는 것은 번거로워집니다. InMemoryChatMessageHistory는 메시지를 추가하고 전체 히스토리를 조회하는 메서드를 제공하여 이를 간편하게 만들어줍니다.

주요 메서드:

  • add_message(message): 단일 메시지 추가 (HumanMessage, AIMessage, SystemMessage)
  • add_messages(messages): 여러 메시지를 한 번에 추가
  • messages: 전체 메시지 리스트를 반환하는 프로퍼티
  • clear(): 모든 메시지 제거 (새로 시작할 때 유용)

예제:

python
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
 
# 메시지 히스토리 저장소 생성
history = InMemoryChatMessageHistory()
 
# 여러 메시지를 한 번에 추가
history.add_messages([
    SystemMessage(content="You are a helpful Python tutor."),
    HumanMessage(content="What's a decorator in Python?")
])
 
# 메시지를 하나씩 추가
history.add_message(AIMessage(content="A decorator is a function that modifies another function's behavior..."))
history.add_message(HumanMessage(content="Can you show an example?"))
 
# 모든 메시지 조회
messages = history.messages

실전 예제: InMemoryChatMessageHistory를 활용한 상태 관리:

python
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
history = InMemoryChatMessageHistory()
 
# 시스템 메시지를 히스토리에 추가 (시작 시 한 번만)
system_msg = SystemMessage(content="You are a helpful Python tutor.")
history.add_message(system_msg)
 
# 대화 함수
def chat(user_input):
    """사용자 입력을 받아 LLM에게 요청하고 대화 내역을 자동으로 관리"""
    # 사용자 입력을 히스토리에 추가
    history.add_message(HumanMessage(content=user_input))
    
    # 사용자 입력을 이전 대화 내역과 함께 전송
    response = llm.invoke(history.messages)
    
    # 모델 응답을 히스토리에 추가
    history.add_message(response)
    
    return response.content
 
# 대화 시뮬레이션
print(chat("What's a lambda function?"))
print(chat("Show me an example"))  # 모델이 맥락 기억
print(chat("What's the difference from a regular function?"))  # 여전히 기억

출력:

A lambda function is an anonymous function defined with the lambda keyword...
 
Here's an example: square = lambda x: x**2
You can use it like: square(5) # Returns 25
 
Lambda functions are limited to a single expression, while regular functions...

chat() 함수는 상태 관리를 자동으로 처리합니다: 각 사용자 요청을 이전 대화 내역과 함께 LLM에 전송하고, 요청과 응답을 모두 히스토리에 다시 추가합니다. chat()만 호출하면 별도의 관리 없이도 대화 상태가 유지됩니다.

Chapter 3 리팩토링: CLI 채팅에 메모리 추가하기

Chapter 3의 스트리밍 CLI 채팅을 가져와서 대화 메모리를 추가해봅시다. 다음은 원래의 상태 비저장 버전입니다:

python
# chapter3_cli.py (원본 - 상태 비저장)
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
def chat_loop():
    print("채팅이 시작되었습니다. 종료하려면 'quit'을 입력하세요.\n")
    while True:
        user_input = input("You: ")
        if user_input.lower() == "quit":
            break
        
        # 상태 비저장 - 히스토리 없음
        response = llm.stream([HumanMessage(content=user_input)])
        print("AI: ", end="", flush=True)
        for chunk in response:
            print(chunk.content, end="", flush=True)
        print("\n")
 
if __name__ == "__main__":
    chat_loop()

메모리가 있는 리팩토링 버전:

python
# chapter8_cli.py (메모리 포함)
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
def chat_loop():
    print("채팅이 시작되었습니다. 종료하려면 'quit'을 입력하세요.\n")
    
    # 히스토리 생성 및 시스템 메시지 추가
    history = InMemoryChatMessageHistory()
    history.add_message(SystemMessage(content="당신은 도움이 되는 어시스턴트입니다."))
    
    while True:
        user_input = input("You: ")
        if user_input.lower() == "quit":
            break
        
        # 히스토리에 사용자 메시지 추가
        history.add_message(HumanMessage(content=user_input))
        
        # 응답 스트리밍
        print("AI: ", end="", flush=True)
        full_response = ""
        for chunk in llm.stream(history.messages):
            print(chunk.content, end="", flush=True)
            full_response += chunk.content
        print("\n")
        
        # 히스토리에 AI 응답 추가
        history.add_message(AIMessage(content=full_response))
 
if __name__ == "__main__":
    chat_loop()

변경된 사항:

  1. 히스토리 저장소 추가: chat_loop() 내부에 InMemoryChatMessageHistory() 인스턴스 생성
  2. 시스템 메시지: 시작 시 히스토리에 한 번 추가
  3. 사용자 입력을 이전 대화 내역과 함께 LLM으로 전송
  4. 사용자 입력과 LLM 응답을 대화 내역에 추가

리팩토링된 채팅 테스트:

채팅이 시작되었습니다. 종료하려면 'quit'을 입력하세요.
 
You: 내 이름은 Alice야
AI: 만나서 반가워요, Alice! 오늘 어떻게 도와드릴까요?
 
You: 내 이름이 뭐야?
AI: 당신의 이름은 Alice입니다.
 
You: 방금 뭘 물어봤어?
AI: 당신의 이름이 무엇인지 물어보셨습니다.
 
You: quit

이제 모델은 전체 대화에 걸쳐 컨텍스트를 유지합니다. 이름, 이전 질문을 기억하고 대화의 이전 부분을 참조할 수 있습니다.

영속 저장소: 인메모리 방식을 넘어서

InMemoryChatMessageHistory는 로컬 개발 환경에서는 편리하지만, 실제 운영 환경(Production)으로 나아가기 위해서는 반드시 영속성(Persistence)이 보장되는 저장소로 교체해야 합니다.

InMemory 방식의 기술적 한계:

  • 휘발성 메모리(Volatile RAM): 서버 프로세스가 종료되거나 재시작될 경우, 메모리에 저장된 모든 대화 기록은 즉시 삭제됩니다. 업데이트나 오류 복구 시 사용자의 이전 맥락이 완전히 유실됩니다.
  • 수평적 확장 불가: 서비스 규모가 커져 서버를 여러 대(Multi-instance) 운영하게 되면, 각 서버는 자신만의 메모리만 갖게 됩니다. 사용자가 다른 서버로 접속할 경우 대화 기록을 공유할 수 없습니다.
  • 리소스 점유: 모든 대화 기록을 RAM에 보관하는 것은 메모리 비용 측면에서 비효율적이며, 동시 접속자가 늘어날수록 시스템 전체의 안정성을 위협합니다.

프로페셔널 대안:

  • PostgresChatMessageHistory (권장): 가장 견고하고 널리 사용되는 선택지입니다. PostgreSQL을 사용하여 대화 기록을 영구 저장하며, 복잡한 쿼리나 데이터 분석에도 유리합니다.
  • SQLChatMessageHistory: MySQL 등 다른 SQL 데이터베이스를 사용 중이라면 이를 활용할 수 있습니다. 기존 인프라를 그대로 활용 가능합니다.
  • RedisChatMessageHistory: 빠른 응답 속도가 핵심인 서비스에 적합합니다. 인메모리 기반이지만 영속성 옵션을 제공하며, 대규모 트래픽 처리에 특화되어 있습니다.

"저장소 엔진은 바뀌어도, 코드는 그대로 유지됩니다"

LangChain은 모든 저장소에 통일된 인터페이스를 제공합니다. InMemoryChatMessageHistory에서 사용했던 add_message(), add_messages() 같은 메서드를 다른 저장소에서도 그대로 사용할 수 있습니다. 따라서 저장소가 바뀌어도 비즈니스 로직(대화 처리 코드)은 수정할 필요가 없습니다.

python
# [개발] 로컬 인메모리
# from langchain_core.chat_history import InMemoryChatMessageHistory
# history = InMemoryChatMessageHistory()
 
# [프로덕션] PostgreSQL 영구 저장소
import psycopg
from langchain_postgres import PostgresChatMessageHistory
 
# PostgreSQL 연결 생성
sync_connection = psycopg.connect(
    "postgresql://user:password@10.1.1.100:5432/agent_db",
    autocommit=True,
)
 
# DB 연결과 세션 ID 지정
# session_id: 대화를 구분하는 고유 키
#   - 사용자별 대화 관리: session_id = user_id (사용자당 하나의 대화)
#   - 세션별 대화 관리: session_id = uuid (새 대화마다 새 ID 생성)
history = PostgresChatMessageHistory(
    table_name="chat_history",
    session_id="user_123",
    sync_connection=sync_connection
)
 
# --- 저장소 종류와 상관없이 동일한 인터페이스 사용 ---
history.add_message(HumanMessage(content="지난번 주문 내역을 알려줘."))
print(history.messages)

중요: 본 가이드의 실습에서는 빠른 진행을 위해 InMemory를 사용하지만, 실제 프로덕션 배포 시에는 반드시 PostgresChatMessageHistory와 같은 영속성 저장소로 전환해야 합니다.

8.3) 대화 길이 관리하기

이전 섹션에서 다루었던 대화 내역 관리 기능에는 중요한 문제가 있습니다: 메시지를 추가만 하고 있습니다. 이는 대화 내역이 계속 커진다는 의미이며, 다음과 같은 두 가지 문제를 일으킵니다:

  1. 비용: 모든 요청마다 전체 대화 내역이 함께 전송되므로, 내역이 쌓일수록 한 번의 요청에 드는 비용이 계속해서 증가합니다
  2. 컨텍스트 윈도우 제한: 모델은 한 번의 요청에서 처리할 수 있는 최대 입력 크기가 정해져 있습니다 (예: GPT-5의 경우 400K 토큰). 대화 내역이 최대 입력 크기를 넘어서게 되면 모델은 요청을 제대로 처리할 수 없게 됩니다.

이 두 문제를 해결하는 가장 간단한 방법 중 하나는 슬라이딩 윈도우(Sliding Window) 패턴입니다.

슬라이딩 윈도우 패턴 (최근 N개 메시지만 유지)

슬라이딩 윈도우(sliding window) 패턴은 가장 최근 N개의 메시지만 대화 히스토리에 유지하여 위의 두 문제를 해결합니다. 오래된 메시지를 버리는 방식으로 대화 내역을 일정 크기 이내로 관리하여 다음과 같은 효과를 제공합니다:

  1. 비용 관리: 대화가 아무리 길어져도 대화 내역을 일정 수준 이하로 유지하기 때문에 한 번의 요청에 드는 비용이 무한히 커지는 것을 방지합니다
  2. 오버플로 방지: 한 번의 요청에 보내는 입력 크기를 모델의 최대 입력 크기 이내로 유지합니다

개념 다이어그램:

Message 1

Message 2

Message 3

Message 4

Message 5

Message 6

Window Size = 4

윈도우 크기가 4로 설정된 경우, 가장 최근 4개의 메시지(3, 4, 5, 6)만 유지하고 오래된 메시지(1, 2)는 버립니다. 새 메시지(7)가 추가되면 윈도우가 최신 메시지 방향으로 이동하여 가장 오래된 메시지(3)를 삭제하고, 메시지 4, 5, 6, 7을 유지하게 됩니다.

슬라이딩 윈도우의 Trade-off:

  • 장점: 대화 내역 크기를 제한하여 비용을 일정하게 유지하고, 컨텍스트 윈도우 초과를 방지합니다
  • 단점: 윈도우 크기를 넘어선 오래된 대화 내용은 버려지므로, 모델이 이를 참조할 수 없습니다

이 트레이드오프는 문제가 될 수 있습니다. 보완 방법은 슬라이딩 윈도우로 최근 대화만 유지하면서, 현재 대화에 필요한 과거 대화 정보는 별도 저장소에서 찾아서 함께 전송하는 것입니다. 이는 Chapter 9에서 다룰 RAG(Retrieval-Augmented Generation)를 활용하여 구현할 수 있습니다.

윈도우 크기 단위: 메시지 수 vs 토큰 수

위 다이어그램은 메시지 개수 단위로 윈도우 크기를 설정한 예시입니다. 하지만 실제 프로덕션 환경에서는 토큰 수 기반으로 윈도우 크기를 정하는 방식을 더 많이 사용합니다. 메시지마다 크기가 다양하기 때문입니다:

메시지 수 기반 트리밍:

메시지 개수로 윈도우 크기를 제한하는 방식입니다 (예: 최근 20개 메시지만 유지).

  • 특징: 고정된 메시지 개수 유지, 하지만 총 토큰 수는 변동 가능
  • 사용 시기: 메시지 크기가 통제된 환경 (SMS, 글자 수 제한 채팅)
  • 위험: 긴 메시지 하나로도 컨텍스트 윈도우 초과 가능

토큰 수 기반 트리밍 (프로덕션 권장):

LLM이 처리하는 입력 단위인 토큰 수로 윈도우 크기를 제한하는 방식입니다 (예: 최근 5,000 토큰만 유지).

  • 특징: 메시지 길이와 무관하게 컨텍스트 윈도우 절대 초과 안 함
  • 사용 시기: 메시지 크기가 다양한 환경

토큰 수 기반 트리밍 구현: trim_messages()

LangChain은 토큰 수 기반의 슬라이딩 윈도우 패턴을 구현하는 trim_messages() 유틸리티를 제공합니다.

trim_messages() 동작 방식:

이 함수는 전체 메시지 리스트와 최대 토큰 수를 받아서, 최대 토큰 수 이내에 포함되는 최근 메시지들만 반환합니다.

주요 파라미터:

  • messages: 트리밍할 메시지 리스트
  • max_tokens: 유지할 최대 토큰 수
  • token_counter: 각 메시지의 토큰 수를 계산하는 함수 (사용하는 모델의 토크나이저를 활용하여 메시지의 토큰 수를 반환)
  • include_system: SystemMessage를 항상 유지할지 여부 (보통 True)

토큰 카운터 설정:

python
import tiktoken
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
from langchain_core.messages.utils import trim_messages
 
# 모델에 맞는 토크나이저 가져오기 (모델 패밀리마다 다른 토크나이저 사용)
# 사용하는 모델명을 입력하면 해당 모델에 맞는 토크나이저가 반환됨
# Claude 모델: Anthropic의 토크나이저 사용 (tiktoken은 OpenAI 전용 라이브러리)
enc = tiktoken.encoding_for_model("gpt-4o")
 
def token_counter(msg: BaseMessage) -> int:
    """단일 메시지의 토큰 수 계산."""
    return len(enc.encode(msg.content or ""))
 
# 대화 내역 생성
messages = [
    SystemMessage(content="You are a helpful assistant."),
    HumanMessage(content="Hi!"),
    AIMessage(content="Hello! How can I help?"),
    HumanMessage(content="What's 2+2?"),
    AIMessage(content="2+2 equals 4."),
    HumanMessage(content="What's 3+3?"),
    AIMessage(content="3+3 equals 6."),
    HumanMessage(content="What's 4+4?"),
]
 
# 최대 토큰 수 이내에 포함되는 메시지만 유지
trimmed = trim_messages(
    messages,
    max_tokens=30,
    token_counter=token_counter,
    include_system=True,
)
 
print(f"원본: {len(messages)}개 메시지")
print(f"트리밍 후: {len(trimmed)}개 메시지")
for m in trimmed:
    print(f"{type(m).__name__}: {m.content}")

참고: 유지되는 메시지 수는 max_tokens 값과 각 메시지의 실제 토큰 수에 따라 달라집니다. 위 예시에서 max_tokens=30은 테스트 목적으로 매우 작은 값을 선택했습니다. 실제 애플리케이션에서는 평균 메시지 크기와 유지하고자 하는 대화 범위를 고려하여 적절한 값을 설정해야 합니다.

채팅 함수에 트리밍 적용 예제

python
import tiktoken
from langchain_openai import ChatOpenAI
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage, BaseMessage
from langchain_core.messages.utils import trim_messages
 
llm = ChatOpenAI(model="gpt-4o-mini")
history = InMemoryChatMessageHistory()
enc = tiktoken.encoding_for_model("gpt-4o-mini")
 
def token_counter(msg: BaseMessage) -> int:
    """단일 메시지의 토큰 수 계산."""
    return len(enc.encode(msg.content or ""))
 
def chat_with_trimming(user_input: str, max_tokens: int = 1000) -> str:
    """자동 히스토리 트리밍이 적용된 채팅."""
    history.add_message(HumanMessage(content=user_input))
    
    system_msg = SystemMessage(content="You are a helpful assistant.")
    all_messages = [system_msg] + history.messages
    
    # 최대 토큰 수 이내로 트리밍
    trimmed_messages = trim_messages(
        all_messages,
        max_tokens=max_tokens,
        token_counter=token_counter,
        include_system=True,
    )
    
    response = llm.invoke(trimmed_messages)
    history.add_message(response)
    
    return response.content
 
# 사용 예시
print(chat_with_trimming("I'm planning a trip to Japan"))
print(chat_with_trimming("What should I visit in Tokyo?"))
print(chat_with_trimming("How many days should I spend there?"))
# 히스토리가 계속 쌓여도 최대 토큰 수 이내에 포함되는 최근 대화만 LLM에 전송됨

다음 단계: 대화 상태 관리의 한계와 해결책 (Chapter 9: RAG)

모델에 대화 내역을 함께 제공하는 것은 대화 맥락을 유지하는 데 도움이 됩니다. 그러나 이것만으로는 충분하지 않은 경우가 있습니다. 예를 들어:

  • 회사 문서나 매뉴얼에서 정보를 찾아야 할 때
  • 슬라이딩 윈도우 밖으로 밀려난 오래된 대화 내역을 참조해야 할 때

이런 경우 RAG(Retrieval-Augmented Generation)가 필요합니다. RAG는 다음과 같이 동작합니다:

  1. 저장: 정보를 의미 기반 검색이 가능하도록 벡터 데이터베이스에 저장
  2. 검색: 찾고자 하는 의미와 유사한 정보를 조회

만약 RAG를 사용하여 슬라이딩 윈도우 패턴의 한계를 보완한다면:

  • 슬라이딩 윈도우: 최근 20개 메시지 유지 (최근 맥락)
  • RAG: 윈도우 밖으로 밀려난 메시지 중 현재 질문과 관련된 내용을 검색하여 제공

단순히 최신 대화만 기억하는 것이 아니라, RAG를 통해 장기 기억 장치를 마련할 수 있는 것입니다.

RAG는 외부 지식 활용과거 대화 검색을 통해, 에이전트가 더 넓은 지식과 더 긴 맥락을 활용할 수 있게 만듭니다.

Chapter 9에서 RAG를 구현하는 방법을 자세히 다룰 것입니다.


챕터 요약:

이 챕터에서 배운 내용:

  1. LLM이 잊어버리는 이유: 모델은 상태를 저장하지 않습니다—대화를 기억하는 것처럼 보이는 이유는 이전 대화 내역을 매번 다시 전송하기 때문입니다
  2. 메시지 타입: SystemMessage (지시사항), HumanMessage (사용자 입력), AIMessage (모델 응답)
  3. 상태 관리: InMemoryChatMessageHistory로 대화 내역 관리하기
  4. 대화 길이 관리: 대화 내역이 계속 쌓이면 비용과 컨텍스트 윈도우 문제가 발생합니다
  5. 슬라이딩 윈도우: 대화 내역이 무한히 커지지 않도록 최근 메시지만 유지하는 패턴
  6. 토큰 기반 트리밍: trim_messages()와 토큰 카운팅으로 슬라이딩 윈도우 구현하기