티스토리 뷰

반응형

LangChain 대화 히스토리 다루기 — 긴 대화를 자르고, 요약하고, 비용을 관리하는 방법

챗봇을 처음 만들면, 이상하게 초반에는 다 잘됩니다.
두세 번 주고받을 때는 “오, 문맥도 기억하네?” 싶어요.

근데 대화가 길어지는 순간부터 느낌이 바뀝니다.

답이 점점 느려지고,
쓸데없이 예전 얘기를 끌고 오고,
가끔은 지금 질문보다 한참 전에 했던 말을 더 중요하게 받아들이기도 하죠.

그때 알게 됩니다.
대화형 챗봇에서 중요한 건 “많이 기억하는 것”이 아니라,
어떤 걸 남기고 어떤 걸 줄일지 설계하는 것이라는 걸요.

LangChain 쪽 문서도 이걸 꽤 분명하게 말합니다. 긴 대화는 LLM의 컨텍스트 윈도우를 넘길 수 있고, 설령 윈도우 안에 들어가도 오래된 내용 때문에 모델이 산만해지고 응답 속도와 비용이 커질 수 있다고 설명해요. 그래서 짧은 메모리, 메시지 관리, 요약, trimming 같은 기법을 같이 봐야 한다고 안내합니다. (LangChain Docs)


왜 대화 히스토리를 무한정 넣으면 안 될까

처음엔 되게 순진하게 생각합니다.

“이전 대화를 다 넣으면 제일 똑똑하지 않을까?”

그런데 실제로는 꼭 그렇지 않습니다.
LangChain 문서의 memory 개요는 긴 대화에서 전체 히스토리가 컨텍스트 윈도우에 안 들어갈 수 있고, 들어가더라도 오래되고 덜 관련된 내용 때문에 모델 성능이 떨어질 수 있다고 설명합니다. 같은 문서에서 이런 이유로 오래된 정보를 수동으로 제거하거나 잊게 만드는 기법이 필요하다고 말해요. (LangChain Docs)

이걸 개발자 입장에서 다시 말하면 이런 문제들이 생깁니다.

  • 비용 증가: 턴이 늘수록 입력 토큰이 계속 커짐
  • 지연 증가: 매 요청마다 더 많은 메시지를 모델에 넣어야 함
  • 정확도 저하: 지금 질문보다 예전 문맥이 더 강하게 작동할 수 있음
  • 운영 불안정: 긴 대화가 쌓이면 어느 순간 아예 한도를 넘겨 실패할 수 있음

솔직히 여기서부터가 진짜 실무예요.
“기억한다”보다 “잘 잊는다”가 더 중요해집니다.


오늘 글에서 잡아야 할 핵심

이번 글은 딱 이 흐름으로 보면 됩니다.

  1. 왜 긴 히스토리가 문제인지 이해한다
  2. 메시지를 잘라서(trim) 넣는 방법을 본다
  3. 오래된 대화를 요약해서(summary) 압축하는 방법을 이해한다
  4. 지금 LangChain 생태계에서 이게 어디에 붙는지 감을 잡는다

최근 문서 기준으로 LangChain/LangGraph 쪽은 짧은 메모리를 thread 단위 상태로 다루고, 메시지 관리에는 trim messages, summarize messages, delete messages 같은 흐름을 안내하고 있습니다. 또 에이전트에서는 middleware를 통해 모델 호출 전에 메시지 trimming이나 context injection을 넣을 수 있다고 설명해요. (LangChain Docs)


1. 가장 쉬운 첫 단계: 최근 대화만 남기기

이건 제일 단순하고, 대부분의 챗봇이 가장 먼저 적용할 만한 방법입니다.

예를 들어 최근 6개 메시지만 남기고 나머지는 버리는 거예요.

장점은 단순합니다.

  • 구현이 쉽다
  • 비용을 바로 줄일 수 있다
  • 너무 오래된 맥락 때문에 산만해지는 걸 막을 수 있다

단점도 분명합니다.

  • 중요한 옛 정보가 사라질 수 있다
  • 대화가 길게 이어질수록 일관성이 깨질 수 있다

그래도 처음엔 이게 꽤 괜찮습니다.
특히 FAQ 챗봇이나 짧은 업무 보조 챗봇은 최근 몇 턴만 유지해도 충분한 경우가 많아요.

LangChain 문서도 긴 메시지 리스트는 비용과 성능 문제를 만들 수 있어서, 수동으로 오래된 내용을 제거하는 기법이 유효하다고 설명합니다. (LangChain Docs)

최근 N개 메시지만 유지하는 간단 예제

지난 글에서 만든 in-memory history 예제를 조금 확장해보겠습니다.

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.messages import BaseMessage
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 trim_recent_messages(messages: list[BaseMessage], max_messages: int = 6) -> list[BaseMessage]:
    return messages[-max_messages:]


def build_chat_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_chat_chain()
    session_id = "study-session"

    while True:
        question = input("사용자: ").strip()
        if question.lower() in {"exit", "quit"}:
            break

        history = get_session_history(session_id)

        # 최근 메시지만 남기기
        trimmed = trim_recent_messages(history.messages, max_messages=6)
        history.clear()
        history.add_messages(trimmed)

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

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


if __name__ == "__main__":
    main()

이 코드는 아주 단순하지만, “무한정 쌓지 않는다”는 감각을 잡는 데 좋습니다.


2. 그런데 단순 잘라내기만으로는 부족한 이유

반응형

문제는 이거예요.

사용자가 초반에 굉장히 중요한 정보를 말했는데, 최근 6개 메시지 밖으로 밀려나면요?

예를 들어:

  • “나는 FastAPI 기준으로 예제를 원해”
  • “답변은 항상 JSON으로 받고 싶어”
  • “초보자 눈높이로 설명해줘”

이런 정보는 대화 전반에 계속 영향을 줘야 하죠.
근데 단순 trimming만 하면 언젠가 날아갑니다.

그래서 그다음 단계가 요약(summarization) 입니다.

LangChain의 built-in middleware 문서는 summarization을 토큰 한도에 가까워질 때 오래된 대화를 자동 요약해서, 최근 메시지는 남기고 오래된 맥락은 압축하는 방식으로 설명합니다. 긴 대화나 멀티턴 대화에서 특히 유용하다고 적혀 있어요. (LangChain Docs)

이 표현이 되게 좋더라고요.
“기억을 버리는 게 아니라 압축한다”는 느낌이니까요.


3. 요약은 언제 필요할까

저는 보통 이렇게 생각합니다.

최근 몇 턴만 보면 되는 챗봇

  • 단순 고객문의
  • 짧은 Q&A
  • 1회성 정보 응답

이런 건 trimming 위주로도 충분할 수 있어요.

앞부분의 중요한 맥락이 계속 필요한 챗봇

  • 학습 코치
  • 개발 멘토 챗봇
  • 프로젝트 상담
  • 장문 글쓰기 보조

이런 건 요약이 훨씬 중요합니다.

LangChain/LangGraph 메모리 문서도 짧은 메모리와 긴 대화 관리에서 message trimming, message summarization, deletion을 별도 관리 기술로 제시합니다. (LangChain Docs)

즉, “다 자르기”와 “다 들고 가기” 사이에
오래된 건 요약본으로 축약하고, 최신 메시지는 원문으로 유지하는 전략이 들어가는 거예요.


4. 직접 구현하는 요약 전략

아직은 이해를 위해 가장 쉬운 구조부터 보겠습니다.

아이디어는 단순합니다.

  1. 대화가 일정 길이를 넘으면
  2. 오래된 메시지를 모델로 요약해서
  3. 요약문을 system-like context나 summary message로 저장하고
  4. 최신 메시지만 원문으로 유지한다

이 방식은 LangChain 문서의 summarization middleware 설명과 같은 방향입니다. 오래된 대화를 압축하면서 최근 메시지는 그대로 유지해, 컨텍스트 윈도우와 비용을 관리하는 접근이죠. (LangChain Docs)

아래는 개념 이해용 예제입니다.

import os
from typing import Dict

from langchain.chat_models import init_chat_model
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory


store: Dict[str, InMemoryChatMessageHistory] = {}
summary_store: Dict[str, str] = {}


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


def summarize_old_messages(session_id: str, model, keep_last: int = 6) -> None:
    history = get_session_history(session_id)
    messages = history.messages

    if len(messages) <= keep_last:
        return

    old_messages = messages[:-keep_last]
    recent_messages = messages[-keep_last:]

    text_for_summary = "\n".join(
        f"{msg.type.upper()}: {msg.content}" for msg in old_messages
    )

    summary_prompt = ChatPromptTemplate.from_messages(
        [
            ("system", "너는 대화 요약 전문가다. 중요한 사용자 선호, 제약, 결정사항만 압축해서 정리해라."),
            ("human", "다음 대화를 짧게 요약해줘:\n\n{text}")
        ]
    )

    summary_chain = summary_prompt | model | StrOutputParser()
    new_summary = summary_chain.invoke({"text": text_for_summary})

    old_summary = summary_store.get(session_id, "")
    summary_store[session_id] = (old_summary + "\n" + new_summary).strip()

    history.clear()
    history.add_messages(recent_messages)


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

    prompt = ChatPromptTemplate.from_messages(
        [
            ("system", "너는 주니어 개발자를 위한 친절한 LangChain 멘토다."),
            ("system", "이전 대화 요약:\n{summary}"),
            MessagesPlaceholder(variable_name="history"),
            ("human", "{question}"),
        ]
    )

    chain = prompt | model | StrOutputParser()

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

    return with_history, model


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

    chat_chain, model = build_chat_chain()
    session_id = "study-session"

    while True:
        question = input("사용자: ").strip()
        if question.lower() in {"exit", "quit"}:
            break

        summarize_old_messages(session_id, model, keep_last=6)

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

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


if __name__ == "__main__":
    main()

이 코드는 운영용 완성본이라기보다,
“요약이 어떤 자리에 들어가는지”를 이해시키는 데 초점을 둔 예제입니다.


5. 요약할 때 무엇을 남겨야 할까

이거 꽤 중요합니다.
요약을 잘못하면 오히려 중요한 맥락을 잃어요.

저는 보통 아래를 남기게 만듭니다.

  • 사용자의 선호
  • 현재 작업 목표
  • 이미 결정된 방향
  • 금지사항이나 제약
  • 아직 해결되지 않은 문제

반대로 이런 건 압축하거나 버려도 됩니다.

  • 반복된 감사 표현
  • 이미 끝난 소소한 질문
  • 지금 주제와 거의 상관없는 잡담
  • 직전 답변과 중복되는 부연 설명

LangChain 문서가 summarization을 “오래된 대화를 압축하면서 최근 메시지는 유지”하는 용도로 설명하는 것도 같은 맥락입니다. 핵심 맥락은 남기되, 토큰을 많이 잡아먹는 원문 대화는 줄이는 거죠. (LangChain Docs)


6. 최근 문서 기준으로는 middleware 쪽 감각도 알아두면 좋다

이 부분은 살짝만 감 잡으면 됩니다.

최신 LangChain 문서에서는 에이전트에 middleware를 붙여서 모델 호출 전후를 커스터마이즈할 수 있고, 여기서 message trimming, context injection, summarization 같은 작업을 할 수 있다고 설명합니다. v1 관련 문서도 before_model 훅에서 prompt 업데이트나 message trimming 같은 use case를 제시해요. (LangChain Docs)

이 말은 뭐냐면,
예전처럼 메모리 로직을 무조건 체인 바깥에서 덕지덕지 붙이기보다,
이제는 모델 호출 직전 메시지를 정리하는 레이어로 보는 흐름이 강해졌다는 뜻입니다.

주니어 입장에선 이렇게 이해하면 충분합니다.

  • 지금은 직접 trimming/summary 로직을 써보며 원리를 익힌다
  • 나중엔 agent middleware나 LangGraph short-term memory 쪽으로 확장한다

7. 짧은 메모리와 긴 메모리를 섞어 생각하면 헷갈린다

이건 꼭 구분해두는 게 좋아요.

LangChain 문서의 memory overview는 short-term memory를 thread-scoped conversation state로 설명하고, long-term memory는 세션을 넘어 저장되는 사용자/앱 수준 정보라고 설명합니다. (LangChain Docs)

즉:

short-term memory

  • 이번 대화 안에서만 유지
  • 최근 메시지, 현재 작업 맥락
  • trimming, summarization 대상

long-term memory

  • 다음 대화에도 남김
  • 사용자 선호, 프로필, 장기 사실
  • 별도 저장소/namespace 개념이 필요

오늘 우리가 다루는 건 거의 전부 short-term memory 관리입니다.

이걸 헷갈리면 “왜 서버 재시작했더니 기억이 사라져요?” 같은 당황스러운 상황이 와요.
사실 그건 당연한 거거든요. 인메모리 히스토리였으니까요.


8. 운영 관점에서 제일 현실적인 전략

개인적으로는 처음 서비스 만들 때 이렇게 가는 걸 추천합니다.

1단계

최근 N개 메시지만 유지
가장 빠르고 단순합니다.

2단계

중요한 서비스라면 오래된 메시지를 summary로 압축
멘토형, 상담형 챗봇에서 특히 효과가 큽니다.

3단계

짧은 메모리와 긴 메모리 분리
예를 들어:

  • 최근 대화는 thread history
  • 사용자 선호는 DB나 namespace 저장

이 흐름은 LangChain/LangGraph 문서가 short-term memory, long-term memory, message summarization을 분리해서 설명하는 방향과도 잘 맞습니다. (LangChain Docs)


9. 실무에서 자주 하는 실수

이건 진짜 많이 봅니다.

실수 1. 다 넣으면 더 좋다고 생각함

아닙니다.
오히려 오래된 잡음 때문에 현재 질문 품질이 떨어질 수 있어요. (LangChain Docs)

실수 2. summary를 너무 장황하게 만듦

요약인데 또 길면 의미가 없습니다.
“중요한 사실만 압축”이 핵심이에요.

실수 3. 최근 메시지까지 요약해버림

그러면 방금 대화의 세밀한 뉘앙스가 사라질 수 있습니다.
최신 몇 턴은 원문 유지가 좋습니다. built-in summarization 설명도 최근 메시지는 유지하고 오래된 대화만 압축하는 방향입니다. (LangChain Docs)

실수 4. short-term memory와 user profile을 한군데 섞어넣음

그러면 관리가 진짜 어려워집니다.
대화 흐름과 사용자 장기 정보는 분리하는 게 낫습니다. (LangChain Docs)


10. 오늘 글에서 꼭 가져가야 할 한 문장

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

좋은 챗봇은 많이 기억하는 챗봇이 아니라, 중요한 것만 남기고 나머지는 잘 줄이는 챗봇이다.

이 감각이 생기면 다음 단계가 쉬워집니다.

  • RAG 문서를 대화 히스토리와 어떻게 섞을지
  • tool 호출 결과를 얼마나 오래 들고 갈지
  • 사용자 장기 선호를 어디에 저장할지

결국 전부 “컨텍스트 관리” 문제거든요.


마무리

저는 예전엔 챗봇이 똑똑하려면 많이 기억해야 한다고 생각했어요.
근데 직접 만들어보니까, 진짜 중요한 건 반대에 가깝더라고요.

얼마나 많이 기억했는지가 아니라,
무엇을 남기고 무엇을 압축했는지가 더 중요했습니다.

조금 웃기지만 사람하고도 비슷한 것 같아요.
대화를 전부 녹취해서 외우는 사람이 더 대화를 잘하는 건 아니잖아요.
핵심을 기억하는 사람이 더 잘하죠.

챗봇도 비슷합니다.
길어진 대화에서 핵심만 붙잡는 설계가, 생각보다 훨씬 큰 차이를 만듭니다.


다음 글 예고

다음 글에서는
LangChain으로 문서 하나 읽고 답하는 AI 만들기 — RAG 전에 꼭 알아야 할 가장 작은 문서 Q&A
로 이어가겠습니다.

이제부터는 외부 지식을 모델 안으로 넣는 흐름으로 들어갑니다.
아직 본격 RAG는 아니고, 그 전에 꼭 필요한 가장 작은 문서 기반 Q&A부터 시작할 거예요.


출처

  • LangChain Memory overview: short-term memory는 thread 단위 대화 상태이고, 긴 대화는 컨텍스트 윈도우/성능/비용 문제를 만든다고 설명합니다. (LangChain Docs)
  • LangChain Short-term memory: agent state와 checkpointer를 통해 thread-level persistence를 제공한다고 설명합니다. (LangChain Docs)
  • LangGraph Memory 문서: short-term memory, long-term memory, trim messages, summarize messages, delete messages 관리 흐름을 안내합니다. (LangChain Docs)
  • LangChain built-in middleware: summarization middleware는 토큰 한도에 가까워질 때 오래된 대화를 요약하고 최근 메시지는 유지한다고 설명합니다. (LangChain Docs)
  • LangChain Agents / v1 docs: middleware는 모델 호출 전 메시지 trimming, context injection 등을 수행할 수 있고, before_model 훅에서 prompt 업데이트와 trimming 같은 작업을 한다고 설명합니다. (LangChain Docs)


LangChain, Chat History, Short-term Memory, Summarization, Message Trimming, LangGraph, 생성형AI, LLM개발, 챗봇개발, 주니어개발자

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
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
글 보관함
반응형