LangChain 챗봇4장 · 대화형 챗봇과 메모리 전략19 / 22← 이전목차다음 →MetaContents

4장. 대화형 챗봇과 메모리 전략

실습 6 — Summary 메모리로 앞부분 요약하기

실습 5의 대화에서 오래된 앞부분을 진짜 모델로 요약하고, "요약 + 최근 메시지 4개 + 정리 질문"을 보내 답을 받습니다. 요약 덕분에 마지막 답이 "2박 3일"과 "아이 둘"을 지키는지 봅니다. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다. 다만 1단계처럼 모델을 부르지 않는 줄의 숫자는 tiktoken 으로 센 값이라 실행해도 같습니다.

1단계. 앞부분과 최근 부분 나누기

Python
import json
import os
from pathlib import Path

import tiktoken
from dotenv import find_dotenv, load_dotenv
from langchain_core.messages import HumanMessage, SystemMessage, convert_to_messages
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

load_dotenv(find_dotenv(usecwd=True))
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
model = ChatOpenAI(model=MODEL, temperature=0)

enc = tiktoken.get_encoding("o200k_base")


def count_tokens(messages):
    return sum(len(enc.encode(m.content)) for m in messages)


msgs = convert_to_messages(json.loads(Path("chat_log.json").read_text(encoding="utf-8")))
sent = msgs[:-1]                 # 정리 질문까지의 기록 20개
system, question = sent[0], sent[-1]
recent = sent[-5:-1]             # 질문 바로 앞의 4개
old = sent[1:-5]                 # 그 앞의 오래된 부분

print("오래된 부분:", len(old), "개,", count_tokens(old), "토큰")
print("최근 부분:", len(recent), "개,", count_tokens(recent), "토큰")
print("2박 3일이 든 곳:", "오래된 부분" if any("2박 3일" in m.content for m in old) else "최근 부분")
출력
오래된 부분: 14 개, 221 토큰
최근 부분: 4 개, 59 토큰
2박 3일이 든 곳: 오래된 부분

코드가 의미하는 것 sent 를 시스템, 오래된 부분, 최근 4개, 정리 질문의 네 토막으로 나누는 것이 Summary 메모리의 첫 일입니다. 최근 4개는 원문 그대로 보내고 오래된 부분만 요약할 것이므로, 조건이 어느 토막에 있는지 먼저 확인했습니다. 이 단계는 모델을 부르지 않아 숫자가 실행해도 같습니다.

2단계. 요약 체인으로 앞부분 줄이기

Python
summary_prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 여행 상담 기록을 정리하는 직원이다. 다음 대화를 세 문장 이내로 요약한다. "
               "장소, 박수, 인원, 정한 것은 숫자와 함께 빠뜨리지 않는다. 손님의 이름과 연락처는 적지 않는다."),
    ("human", "{dialog}"),
])
# 요약 체인: 3차시의 prompt | model | StrOutputParser() 모양 그대로
summary_chain = summary_prompt | model | StrOutputParser()

# 메시지를 "역할: 내용" 줄글로 바꿔 넘긴다
dialog = "\n".join(f"{m.type}: {m.content}" for m in old)
summary = summary_chain.invoke({"dialog": dialog})

print(summary)
print("요약:", len(enc.encode(summary)), "토큰 (오래된 부분", count_tokens(old), "토큰)")
출력
손님은 다음 달 아이 둘과 제주도 2박 3일 여행을 간다. 오전 9시 전후 출발, 동쪽 해안(함덕·월정리) 숙소, 첫날 저녁은 숙소 근처 해산물 식당을 권했다. 둘째 날은 비 소식이 있어 실내 박물관이나 아쿠아리움 일정으로 바꾸고, 아쿠아리움은 미리 예매하기로 했다.
요약: 100 토큰 (오래된 부분 221 토큰)

코드가 의미하는 것 summary_chain 은 본문 예시의 가짜 요약 모델 자리에 진짜 모델을 끼운 체인입니다. 요약 프롬프트에 "숫자와 함께 빠뜨리지 않는다"와 "이름과 연락처는 적지 않는다"를 함께 적어, 요약이 지킬 것과 남기지 말 것을 정했습니다. 이 호출도 모델을 한 번 더 부르는 일이라 토큰이 들고, 출력의 요약 글과 토큰 수는 예상 출력입니다.

3단계. 요약과 최근 기록으로 답 받기

Python
# 보낼 메시지: 시스템 지시 + 요약(시스템 메시지) + 최근 4개 + 정리 질문
to_send = [system, SystemMessage("앞의 대화 요약: " + summary)] + recent + [question]
answer = model.invoke(to_send).content

print("보낸 메시지:", len(to_send), "개,", count_tokens(to_send), "토큰")
print("답:", answer)
for fact in ["2박 3일", "아이"]:
    print(f"'{fact}' 이 답에 있나:", fact in answer)
출력
보낸 메시지: 7 개, 210 토큰
답: 다음 달 아이 둘과 함께하는 제주도 2박 3일 일정입니다. 오전 출발, 동쪽 해안 숙소, 둘째 날 실내 일정(아쿠아리움 예매), 공항 렌터카, 15kg 짐 한 개입니다.
'2박 3일' 이 답에 있나: True
'아이' 이 답에 있나: True

코드가 의미하는 것 to_send 는 앞의 대화를 원문 대신 요약으로 바꾼 목록이고, 여기서는 RunnableWithMessageHistory 없이 model.invoke 에 직접 넘겼습니다. fact in answer 는 요약이 조건을 지켰는지를 답에서 거꾸로 확인하는 검사입니다. 예상 출력에서는 두 조건 모두 True 로 보았고, 실제 실행에서는 요약 글에 따라 달라질 수 있습니다.

4단계. 토큰 견주고 저장하기

Python
full_tokens = count_tokens(sent)
summary_tokens = count_tokens(to_send)
print("모두 보내기:", full_tokens, "토큰 / 요약 + 최근 4개:", summary_tokens, "토큰")
print("줄어든 비율:", round(1 - summary_tokens / full_tokens, 2))

Path("out_ch04_summary.json").write_text(
    json.dumps({"summary": summary, "answer": answer}, ensure_ascii=False, indent=2), encoding="utf-8")
print("저장: out_ch04_summary.json")
출력
모두 보내기: 317 토큰 / 요약 + 최근 4개: 210 토큰
줄어든 비율: 0.34
저장: out_ch04_summary.json

코드가 의미하는 것 1 - summary_tokens / full_tokens 는 모두 보내기에 견줘 이번 요청에서 아낀 몫을 비율로 나타낸 것입니다. 여기에는 요약을 쓰는 호출의 토큰이 들어 있지 않으므로, 실제로 아낀 몫은 프로젝트처럼 요약 호출까지 더해 봐야 압니다. 요약과 답을 파일로 남기면 키를 받아 다시 돌렸을 때 요약 글이 어떻게 달라졌는지 견줄 수 있습니다.

이 실습이 뜻하는 것

이 실습은 상담원이 기록철의 앞 장들을 요약 메모 한 장으로 줄여 맨 앞에 붙이고, 다음 상담원이 그 메모와 최근 두 장만 읽고 일정을 정리하는 일과 같습니다. 철을 앞과 뒤로 나누고(1단계), 앞 장들을 요약하고(2단계), 요약과 최근 장으로 답하고(3단계), 넘긴 분량을 잽니다(4단계). 여기서 요약 메모가 summary, 요약을 쓰는 상담원이 summary_chain 입니다. 비유와 다른 점은, 요약을 쓰는 상담원도 모델이라 요약을 쓰는 데 토큰과 요금이 한 번 더 든다는 것입니다.

단계출력뜻
1. 나누기오래된 14개 221 토큰, 최근 4개 59 토큰"2박 3일"은 오래된 부분에 있어서 Window(최근 4개)로는 잃습니다
2. 요약221 토큰 → 100 토큰 안팎숫자(2박 3일, 아이 둘, 오전 9시)를 지키며 절반쯤으로 줄었습니다
3. 답2박 3일 True, 아이 True요약 덕분에 앞에서 말한 조건이 답에 남았습니다
4. 토큰317 → 210 토큰 안팎모두 보내기보다 3분의 1쯤 줄었습니다. 요약 호출의 토큰은 따로 듭니다

한 줄로 줄이면, 요약 체인 하나로 Window 메모리가 잃던 조건을 지키면서 보내는 토큰도 줄일 수 있다는 것을 본 실습입니다. 1단계의 숫자(14개 221 토큰, 4개 59 토큰)와 4단계의 모두 보내기 317 토큰는 모델과 상관없이 같고, 요약의 길이와 답의 글은 예상 출력이라 모델에 따라 달라집니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라집니다. 요약이 "아이 둘"을 빠뜨리면 답도 빠뜨리므로, 요약 프롬프트에 지킬 것을 적어 두는 것이 이 방법의 핵심입니다. 요약 호출에 든 토큰까지 더하면 이번처럼 대화가 짧을 때는 아끼는 몫이 크지 않습니다. 이 셈은 프로젝트에서 합니다.

코드 설명

  • sent[-5:-1] 은 마지막 질문 바로 앞의 4개, sent[1:-5] 는 시스템을 뺀 그 앞의 전부입니다. 음수 번호는 뒤에서부터 센 자리입니다.
  • "\n".join(f"{m.type}: {m.content}" for m in old) 는 메시지 목록을 "human: …", "ai: …" 줄글로 바꿉니다. 요약 체인에는 메시지 목록 대신 이 글 하나를 넘깁니다.
  • SystemMessage("앞의 대화 요약: " + summary) 는 요약을 시스템 메시지로 넣습니다. 손님이 한 말(human)이나 비서의 답(ai)으로 넣으면 모델이 대화의 한 마디로 착각할 수 있습니다.
  • 요약 프롬프트의 "숫자와 함께 빠뜨리지 않는다", "이름과 연락처는 적지 않는다"는 요약이 지킬 것과 남기지 말 것을 정한 줄입니다.
← 이전 절4장 · 실습 5 — Window 메모리로 최근 대화만 남기기다음 절 →4장 · 프로젝트 1 — 메모리 전략 견주기