티스토리 뷰

반응형

LangChain 대화형 챗봇 만들기 — 메시지 히스토리와 문맥 유지를 처음부터 이해하는 방법

여기서부터 진짜 “챗봇 같다”는 느낌이 나기 시작합니다.

이전 글까지는 프롬프트를 만들고, 모델을 호출하고, 출력 형식을 다루는 흐름이었죠.
그런데 아직은 사실 한 번 물어보고 한 번 답하는 도구에 더 가까웠어요.

사용자가 이렇게 묻는 순간부터 문제가 생깁니다.

  • “조금 더 쉽게 설명해줘”
  • “방금 말한 예시 다시 보여줘”
  • “그럼 그걸 FastAPI 기준으로 바꾸면?”
  • “아까 말한 3번째 방식만 코드로 줘”

이 질문들은 전부 이전 대화 맥락이 있어야만 제대로 답할 수 있습니다.
LangChain 문서도 메시지를 모델과의 상호작용에서 대화 상태를 표현하는 기본 단위라고 설명하고, RunnableWithMessageHistory는 다른 runnable을 감싸서 채팅 메시지 히스토리를 읽고 업데이트한다고 설명해요. (LangChain Docs)

즉, 오늘 글의 핵심은 이겁니다.

챗봇은 단순히 채팅 UI가 아니라, 이전 메시지를 문맥으로 다시 넣어줄 수 있어야 한다.


왜 “대화형 챗봇”은 생각보다 빨리 꼬일까

처음엔 보통 이렇게 만듭니다.

question = input("질문: ")
response = model.invoke(question)
print(response.content)

이 코드는 첫 질문에는 잘 답합니다.
그런데 두 번째 질문부터 맥락이 끊겨요.

예를 들면:

  • 사용자: “LangChain chain이 뭐야?”
  • AI: “프롬프트, 모델, 파서를 연결하는 구조예요.”
  • 사용자: “그걸 예시로 다시 보여줘.”

마지막 질문의 “그걸”이 뭔지, 모델은 원래 모릅니다.
우리가 이전 대화를 다시 함께 넣어주지 않으면요.

LangChain 메시지 문서가 말하는 것도 결국 이 부분이에요.
메시지는 단순 텍스트가 아니라, 역할과 내용, 메타데이터를 포함해서 대화 상태를 표현하는 단위입니다. (LangChain Docs)


오늘 글에서 얻어갈 것

이번 글에서는 아래 4가지를 잡겠습니다.

  • 메시지 히스토리가 왜 필요한지
  • 대화형 챗봇의 가장 단순한 구조
  • RunnableWithMessageHistory를 이용해 문맥 유지하는 법
  • “메모리”와 “긴 대화 저장”을 너무 빨리 같은 개념으로 섞지 않는 법

여기서 특히 마지막이 중요해요.
처음 공부할 때 memory, chat history, conversation state가 다 비슷하게 들리는데, 일단 지금 단계에서는 이렇게 이해하는 게 제일 편합니다.

  • chat history: 이전 메시지 목록
  • memory: 그 히스토리를 포함한 더 넓은 상태 관리 개념
  • long-term memory: 사용자 정보나 요약, 장기 저장까지 포함한 개념

오늘은 그중에서도 가장 기초인 message history까지만 확실히 잡겠습니다.


1. LangChain에서 메시지는 왜 특별한가

이전 글들에서 문자열 prompt와 메시지 prompt를 잠깐 봤죠.

LangChain에서는 메시지가 기본입니다.
공식 문서도 메시지를 모델 입력과 출력의 기본 단위로 설명하고, SystemMessage, HumanMessage, AIMessage, ToolMessage 같은 역할을 둡니다. (LangChain Docs)

이게 중요한 이유는 단순합니다.

대화는 결국 이런 리스트로 표현되기 때문이에요.

[
    SystemMessage("너는 친절한 Python 멘토다."),
    HumanMessage("LangChain chain이 뭐야?"),
    AIMessage("프롬프트, 모델, 파서를 연결하는 방식이에요."),
    HumanMessage("그걸 예시로 다시 보여줘."),
]

이렇게 보면 갑자기 이해가 쉬워집니다.
챗봇은 무슨 신비한 존재가 아니라,
이전 메시지 리스트를 계속 들고 가는 프로그램에 더 가깝습니다.


2. 가장 단순한 대화형 챗봇 구조

먼저 히스토리 자동 관리 없이, 수동으로 해보겠습니다.
이 단계가 있어야 RunnableWithMessageHistory가 왜 편한지도 보이거든요.

pip install -U "langchain[openai]"
import os
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage, AIMessage


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        raise ValueError("OPENAI_API_KEY 환경변수가 설정되어 있지 않습니다.")

    model = init_chat_model("gpt-5-nano", model_provider="openai")

    messages = [
        SystemMessage("너는 주니어 개발자를 돕는 친절한 LangChain 멘토다.")
    ]

    while True:
        user_input = input("사용자: ").strip()
        if user_input.lower() in {"exit", "quit"}:
            print("대화를 종료합니다.")
            break

        messages.append(HumanMessage(user_input))
        response = model.invoke(messages)
        ai_text = response.content

        print(f"AI: {ai_text}\n")
        messages.append(AIMessage(ai_text))


if __name__ == "__main__":
    main()

이 코드는 실제로 잘 동작합니다.
그리고 꽤 중요한 사실을 보여줘요.

문맥 유지의 본질은, 이전 대화를 다시 모델에 넣는 것이다.

그런데 이 방식은 금방 귀찮아집니다.

  • 메시지 저장을 매번 직접 해야 하고
  • 세션별 분리가 없고
  • 다른 체인과 연결하기 불편하고
  • 나중에 저장소를 바꾸기 어렵습니다

그래서 LangChain이 RunnableWithMessageHistory를 제공합니다.
공식 레퍼런스는 이 클래스를 다른 Runnable을 감싸서 chat message history를 읽고 업데이트하는 래퍼라고 설명합니다. (LangChain Reference)


3. RunnableWithMessageHistory가 왜 중요한가

반응형

이 이름이 처음엔 좀 무섭습니다.
근데 뜻은 생각보다 단순해요.

  • Runnable: 실행 가능한 체인
  • WithMessageHistory: 거기에 메시지 기록을 붙인다

즉, 우리가 이미 만들던 prompt | model | parser 같은 체인을
“대화 이력 포함 버전”으로 감싸는 겁니다.

LangChain의 chat history 관련 레퍼런스는 BaseChatMessageHistory와 InMemoryChatMessageHistory 같은 추상/기본 구현을 제공하고, RunnableWithMessageHistory가 그 히스토리와 runnable을 엮어준다고 설명합니다. (LangChain Reference)

이걸 한국말로 풀면 이렇습니다.

“사용자별로 대화 기록을 가져와서, 이번 요청에 같이 넣고, 응답이 끝나면 다시 기록에 저장해줘.”

이제 좀 덜 무섭죠.


4. 첫 번째 제대로 된 예제: 히스토리가 있는 체인

아래 코드는 지금 글 기준으로 가장 중요한 예제입니다.
복붙해서 실행해볼 만한 최소 구조로 정리했습니다.

import os
from typing import Dict

from langchain.chat_models import init_chat_model
from langchain_core.chat_history import InMemoryChatMessageHistory, BaseChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.output_parsers import StrOutputParser


# 세션별 대화 기록 저장소
store: Dict[str, BaseChatMessageHistory] = {}


def get_session_history(session_id: str) -> BaseChatMessageHistory:
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]


def build_chain():
    model = init_chat_model("gpt-5-nano", model_provider="openai")

    prompt = ChatPromptTemplate.from_messages(
        [
            ("system", "너는 주니어 개발자를 돕는 친절한 LangChain 멘토다."),
            MessagesPlaceholder(variable_name="history"),
            ("human", "{question}"),
        ]
    )

    chain = prompt | model | StrOutputParser()

    return RunnableWithMessageHistory(
        chain,
        get_session_history,
        input_messages_key="question",
        history_messages_key="history",
    )


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        raise ValueError("OPENAI_API_KEY 환경변수가 설정되어 있지 않습니다.")

    chat_chain = build_chain()
    session_id = "user-1"

    while True:
        question = input("사용자: ").strip()
        if question.lower() in {"exit", "quit"}:
            print("대화를 종료합니다.")
            break

        result = chat_chain.invoke(
            {"question": question},
            config={"configurable": {"session_id": session_id}},
        )
        print(f"AI: {result}\n")


if __name__ == "__main__":
    main()

이 코드의 핵심 포인트는 세 가지입니다.

MessagesPlaceholder(variable_name="history")

이 부분이 “이 자리에 이전 대화 메시지를 넣어라”는 뜻입니다.
메시지 기반 프롬프트를 쓸 때, 히스토리를 자연스럽게 삽입하는 자리라고 생각하면 됩니다. 메시지 문서가 말하는 메시지 리스트 기반 컨텍스트 구성과 딱 맞는 방식이에요. (LangChain Docs)

get_session_history(session_id)

세션 ID별로 대화 기록을 꺼내거나 새로 만듭니다.
즉, 사용자 A와 사용자 B의 대화가 섞이지 않게 하는 가장 기초적인 구조예요.

config={"configurable": {"session_id": session_id}}

이게 중요합니다.
호출할 때 세션 ID를 넘겨줘야 어떤 기록을 불러올지 알 수 있어요.
RunnableWithMessageHistory 공식 레퍼런스도 세션 또는 thread 같은 식별자를 기반으로 히스토리를 불러오는 패턴을 사용합니다. (LangChain Reference)


5. 이 코드가 실제로 어떻게 돌아가는지

이제 진짜 이해가 필요한 부분입니다.

사용자가 첫 번째 질문을 입력합니다.

“LangChain chain이 뭐야?”

그럼 내부적으로 대충 이런 일이 벌어집니다.

  1. session_id="user-1"에 해당하는 히스토리를 가져온다
  2. 지금은 비어 있으니 빈 history를 prompt에 넣는다
  3. 질문을 모델에 보낸다
  4. 응답을 받는다
  5. 사용자 질문과 AI 응답을 그 세션의 history에 저장한다

그리고 두 번째 질문에서:

“그걸 예시 코드로 보여줘.”

이번엔 이렇게 됩니다.

  1. 같은 session_id="user-1"의 히스토리를 다시 가져온다
  2. 아까 대화가 이미 들어 있다
  3. 새 질문과 함께 이전 대화도 prompt에 들어간다
  4. 모델은 “그걸”이 무엇인지 더 잘 이해한다
  5. 새 대화가 다시 history에 추가된다

이게 바로 문맥 유지입니다.


6. 왜 수동 리스트 방식보다 이 방식이 낫나

수동으로 messages.append(...) 하는 방식도 원리는 같습니다.
그런데 RunnableWithMessageHistory가 좋은 이유는 역할이 분리되기 때문입니다.

  • prompt는 prompt 역할만 한다
  • model은 모델 역할만 한다
  • parser는 결과 정리 역할만 한다
  • history wrapper는 대화 기록 관리 역할만 한다

이게 진짜 큽니다.
LangChain이 runnable 조합을 제공하는 철학 자체가 결국 이거예요.
부품별 역할을 분리해서 조립 가능하게 만드는 것. (LangChain Reference)


7. “메모리”라고 부르기 전에 먼저 이해해야 할 것

이쯤 되면 다들 말합니다.

“오, 메모리 붙였네.”

틀린 말은 아닌데, 지금은 조금 더 정확하게 보는 게 좋습니다.

지금 우리가 한 건
대화 메시지 히스토리를 저장하고 다시 넣는 것입니다.

아직 안 한 것들은 많아요.

  • 오래된 대화 요약
  • 사용자 프로필 기억
  • 프로젝트별 지식 유지
  • DB 영구 저장
  • 세션 종료 후 재접속 복원
  • 여러 디바이스 동기화

즉, 지금 단계는 short-term conversation history에 더 가깝습니다.
공식 레퍼런스도 RunnableWithMessageHistory를 chat message history 관리용 래퍼로 설명하고, 더 복잡한 상태/지속성은 별도 구조를 고려하게 됩니다. (LangChain Reference)

이걸 너무 빨리 “완전한 메모리”라고 생각하면 나중에 개념이 좀 섞입니다.


8. 실무에서 바로 부딪히는 문제들

이건 미리 알고 가는 게 좋습니다.

1) 대화가 길어질수록 토큰이 계속 늘어난다

히스토리를 계속 넣는다는 건, 모델 입력이 점점 길어진다는 뜻입니다.
메시지 문서가 말하는 것처럼 메시지는 대화 상태를 그대로 담기 때문에, 무한히 쌓으면 비용과 지연도 같이 커집니다. (LangChain Docs)

2) 메모리가 아니라 히스토리일 뿐이다

아까 말했듯, 지금은 “이전 메시지 재주입” 단계입니다.
사용자 선호나 장기 정보 관리까지 된 건 아닙니다.

3) 인메모리는 서버 재시작하면 날아간다

InMemoryChatMessageHistory는 말 그대로 메모리 안에만 저장합니다.
실습엔 좋지만 운영용 영구 저장소는 아닙니다. 공식 레퍼런스도 in-memory 구현과 다양한 history backend 계층을 구분해서 제공합니다. (LangChain Reference)

4) 세션 ID 전략을 잘 잡아야 한다

실서비스에서는 user_id만으로 부족할 때가 많습니다.
보통은 user_id + conversation_id 같은 구조가 더 안전해요.
그래야 한 사용자가 여러 대화를 동시에 열어도 안 섞입니다.


9. 조금 더 실무적으로 바꿔보기

지금 코드는 session_id = "user-1"로 고정했죠.
그럼 멀티 대화를 흉내 내볼 수 있습니다.

current_session_id = input("세션 ID를 입력하세요: ").strip()

이렇게 바꿔서 같은 프로그램 안에서:

  • project-a
  • project-b
  • study-chat

이런 식으로 다른 세션을 써보면 감이 확 옵니다.

같은 질문을 던져도 세션마다 이전 문맥이 다르게 유지돼요.
이때 비로소 “아, 대화는 사용자만이 아니라 대화 스레드 단위로 관리해야 하는구나”가 느껴집니다.


10. FastAPI 같은 백엔드로 가면 어떻게 보이냐

아주 단순하게 보면 이런 그림입니다.

  • 프론트가 session_id와 question을 보낸다
  • 백엔드는 RunnableWithMessageHistory 체인을 호출한다
  • 세션별 기록을 불러온다
  • 응답을 반환한다

즉, API 요청 스펙도 자연스럽게 이런 식이 됩니다.

{
  "session_id": "study-langchain-001",
  "question": "방금 설명한 내용을 다시 쉽게 말해줘."
}

이 구조가 좋은 이유는, 나중에 저장소를 바꿔도 API는 거의 안 바뀌기 때문입니다.

  • 지금은 인메모리
  • 나중엔 Redis
  • 더 나중엔 DB

이렇게 갈아끼우기 쉬워져요.


11. 이번 글에서 꼭 가져가야 할 한 문장

오늘 내용을 한 줄로 줄이면 이겁니다.

대화형 챗봇의 핵심은 “채팅 UI”가 아니라, 이전 메시지를 같은 세션 문맥으로 다시 넣어줄 수 있는 구조다.

이 감각이 잡히면 다음 단계가 쉬워집니다.

  • 대화 요약
  • 긴 히스토리 자르기
  • RAG와 히스토리 합치기
  • 툴 호출이 있는 에이전트에 대화 문맥 넣기

전부 결국 “어떤 상태를 어떻게 다음 턴에 넘길 것인가”의 문제거든요.


12. 이번 글의 전체 실행 코드

복붙용으로 다시 한 번 정리해둘게요.

import os
from typing import Dict

from langchain.chat_models import init_chat_model
from langchain_core.chat_history import BaseChatMessageHistory, InMemoryChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory


store: Dict[str, BaseChatMessageHistory] = {}


def get_session_history(session_id: str) -> BaseChatMessageHistory:
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]


def build_chat_chain():
    model = init_chat_model("gpt-5-nano", model_provider="openai")

    prompt = ChatPromptTemplate.from_messages(
        [
            ("system", "너는 주니어 개발자를 위한 친절한 Python/LangChain 멘토다."),
            MessagesPlaceholder(variable_name="history"),
            ("human", "{question}"),
        ]
    )

    chain = prompt | model | StrOutputParser()

    return RunnableWithMessageHistory(
        chain,
        get_session_history,
        input_messages_key="question",
        history_messages_key="history",
    )


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        raise ValueError("OPENAI_API_KEY 환경변수가 설정되어 있지 않습니다.")

    chat_chain = build_chat_chain()
    session_id = input("세션 ID를 입력하세요: ").strip() or "default-session"

    while True:
        question = input("사용자: ").strip()
        if question.lower() in {"exit", "quit"}:
            print("대화를 종료합니다.")
            break

        answer = chat_chain.invoke(
            {"question": question},
            config={"configurable": {"session_id": session_id}},
        )

        print(f"AI: {answer}\n")


if __name__ == "__main__":
    main()

마무리

저는 처음에 챗봇을 만들 때 “기억하는 AI”라는 표현을 너무 쉽게 썼어요.
근데 막상 구현해보니까, 그 기억이라는 게 생각보다 되게 구체적이더라고요.

  • 뭘 저장할 건지
  • 어디까지 다시 넣을 건지
  • 어떤 단위로 세션을 나눌 건지
  • 언제 버릴 건지

결국 이런 걸 다 정해야 했습니다.

그래서 지금은 이렇게 생각해요.

대화형 챗봇의 시작은 거창한 메모리가 아니라,
이전 메시지를 제대로 관리하는 작은 설계에서 시작한다고요.

이게 잡혀야 그다음도 됩니다. 진짜로요.


다음 글 예고

다음 글에서는
LangChain 대화 히스토리 다루기 — 긴 대화를 자르고, 요약하고, 비용을 관리하는 방법
으로 이어가겠습니다.

여기서부터는 “기억한다”의 진짜 문제가 나옵니다.
문맥을 유지하고 싶지만, 무한정 다 넣을 수는 없거든요.


출처

  • LangChain Messages 문서: 메시지는 모델 상호작용에서 대화 상태를 표현하는 기본 단위이며, 역할과 메타데이터를 포함합니다. (LangChain Docs)
  • RunnableWithMessageHistory 레퍼런스: 다른 runnable을 감싸서 chat message history를 읽고 업데이트하는 래퍼로 설명됩니다. (LangChain Reference)
  • langchain_core / chat history 레퍼런스: BaseChatMessageHistory, InMemoryChatMessageHistory 등 메시지 히스토리 저장 계층이 제공됩니다. (LangChain Reference)
  • langchain_community chat message histories 레퍼런스: 운영 환경에서 사용할 수 있는 다양한 히스토리 백엔드 구현이 제공됩니다. (LangChain Reference)

LangChain, RunnableWithMessageHistory, Chat History, LangChain Python, 대화형챗봇, 생성형AI, LLM개발, 메모리관리, 주니어개발자, Python튜토리얼

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함
반응형