17장. 장기 기억 관리
따라하기 — 요약 메모리와 고객 프로필
출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.
목표
고객 한 명의 두 번의 방문을 실행합니다. 첫 방문에서는 네 턴을 대화하며 요약 메모리가 언제 사용되는지 보고, 대화가 끝날 때 프로필이 파일로 저장되는 것을 확인합니다. 두 번째 방문에서는 저장된 프로필만으로 "저번에 문의했던 교환 건"을 이어받는지 봅니다. 그리고 상담원이 안내한 금액이 요약과 프로필을 거쳐 다음 방문까지 어떻게 전달되는지 살펴봅니다.
0. 실습 준비
이 장의 실습 고객 —
C007강예준 고객(실버 등급)으로 로그인한 상태라고 정해 두고 실습합니다.
VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.
$ conda activate myenv
이번 장은 새로 설치할 것이 없고, 다른 장의 파일도 불러 쓰지 않습니다. config.py만 있으면 됩니다.
실행하면 haru-market/memory_store/customer_profiles.json 파일이 새로 생깁니다. 8장에서 티켓을 만들어 봤다면 memory_store 폴더와 tickets.json이 이미 있을 텐데, 그 파일은 건드리지 않습니다.
1. 파일 만들기
haru-market 폴더 맨 위에 새 파일을 만듭니다.
lesson17_memory.py
아래 코드 전체를 복사해 붙여 넣고 저장합니다.
# -*- coding: utf-8 -*-
"""[17장] 요약 메모리와 고객 프로필 장기 기억
16장의 두 가지 한계를 해결한다.
1) 이력을 자르면(trim) 초반 내용이 사라진다
→ 자르는 대신 '요약'해서 압축한다 (요약 메모리)
2) 세션이 끝나면 전부 사라진다. 재방문 고객을 매번 처음처럼 대한다
→ 세션 종료 시 핵심만 뽑아 파일에 저장한다 (장기 기억: 고객 프로필)
기억하면 안 되는 것도 정한다: 카드번호·비밀번호 등 민감정보는 저장 금지.
실행: python lesson17_memory.py
"""
import json
from google.genai import types
from config import MODEL, ROOT, get_client
client = get_client()
PROFILE_PATH = ROOT / "memory_store" / "customer_profiles.json"
# 이 파일의 상담원에게는 도구도 정책 검색도 없다. 그래서 근거로 삼을 정책 발췌를
# 프롬프트에 직접 적어 주고, 여기에 없는 것은 말하지 않게 한다.
# (기억에 들어가는 내용이 믿을 만해야 그 기억도 믿을 수 있다)
SYSTEM_BASE = """당신은 하루마켓 고객지원 상담원 '하루'입니다. 존댓말, 3~5문장.
금액·기간·절차는 아래 [정책 발췌]에 있는 내용만 안내합니다.
발췌에 없는 금액·기간·절차·처리 상태는 지어내지 않고 "확인 후 안내드리겠습니다"라고 답합니다.
인사말은 대화의 첫 답변에서만 합니다. 고객이 알려 준 연락 선호는 기록해 두겠다고 답합니다.
고객이 카드번호·계좌번호·비밀번호를 적었을 때만, 그 정보는 기록하지 않는다고 알립니다.
[정책 발췌] 반품·교환·환불 정책 제4조 (교환)
- 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청할 수 있다.
- 단순 변심 교환의 왕복 배송비는 6,000원이며 고객이 부담한다."""
# ── 1. 요약 메모리 — 오래된 턴을 버리지 않고 압축한다 ─────────────────
def summarize_history(history: list[dict]) -> str:
"""대화 이력(오래된 부분)을 상담 맥락 요약으로 압축한다."""
transcript = "\n".join(f"{h['role']}: {h['text']}" for h in history)
r = client.models.generate_content(
model=MODEL,
contents=f"다음 상담 대화를, 이후 상담에 필요한 사실 위주로 3줄 이내 요약하라.\n"
f"고객 요청·주문번호·처리 상태를 반드시 포함하라.\n\n{transcript}",
config=types.GenerateContentConfig(temperature=1.0),
)
return r.text.strip()
class SummaryMemoryChat:
"""이력이 길어지면: 최근 메시지 몇 개만 원문으로 남기고, 그 앞은 요약문 1개로 교체."""
def __init__(self, keep_recent: int = 4):
self.summary = "" # 압축된 과거
self.recent: list[dict] = [] # 원문 그대로 둔 최근 메시지
self.keep_recent = keep_recent # 요약한 뒤 원문으로 남길 메시지 수 (2 = 문답 한 쌍)
def send(self, text: str) -> str:
# 컨텍스트 = [과거 요약] + [최근 원문] + [이번 질문]
context = ""
if self.summary:
context += f"[이전 상담 요약]\n{self.summary}\n\n"
for h in self.recent:
context += f"{'고객' if h['role'] == 'user' else '하루'}: {h['text']}\n"
context += f"고객: {text}"
r = client.models.generate_content(
model=MODEL, contents=context,
config=types.GenerateContentConfig(
system_instruction=SYSTEM_BASE, temperature=1.0),
)
answer = r.text.strip()
self.recent += [{"role": "user", "text": text},
{"role": "model", "text": answer}]
# 원문이 keep_recent 의 두 배를 넘으면 앞쪽을 요약으로 흡수
if len(self.recent) > self.keep_recent * 2:
old = self.recent[: -self.keep_recent]
merged = ([{"role": "system", "text": f"(기존 요약) {self.summary}"}]
if self.summary else []) + old
self.summary = summarize_history(merged)
self.recent = self.recent[-self.keep_recent:]
print(f" [요약 메모리 갱신] {self.summary}")
return answer
# ── 2. 장기 기억 — 세션이 끝나면 고객 프로필로 남긴다 ─────────────────
def load_profile(customer_id: str) -> dict:
if PROFILE_PATH.exists():
return json.loads(PROFILE_PATH.read_text(encoding="utf-8")).get(customer_id, {})
return {}
def save_profile(customer_id: str, chat: SummaryMemoryChat):
"""세션 내용에서 '다음 상담에 도움될 사실'만 뽑아 저장한다.
저장 금지: 카드번호·계좌·비밀번호 등 민감정보 (프롬프트로 명시)
"""
transcript = (f"요약: {chat.summary}\n" if chat.summary else "") + "\n".join(
f"{h['role']}: {h['text']}" for h in chat.recent)
r = client.models.generate_content(
model=MODEL,
contents="다음 상담에서, 이 고객을 다시 응대할 때 유용한 사실만 JSON 으로 추출하라.\n"
'형식: {"선호": [], "진행중_이슈": [], "메모": []}\n'
"카드번호·계좌번호·비밀번호 등 민감정보는 절대 포함하지 마라.\n\n"
+ transcript,
config=types.GenerateContentConfig(
temperature=1.0, response_mime_type="application/json"),
)
profiles = {}
if PROFILE_PATH.exists():
profiles = json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
profiles[customer_id] = json.loads(r.text)
PROFILE_PATH.parent.mkdir(exist_ok=True)
PROFILE_PATH.write_text(json.dumps(profiles, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f" [장기 기억 저장] {profiles[customer_id]}")
if __name__ == "__main__":
cid = "C007"
# ── 첫 방문 세션: 길게 대화 → 요약 메모리 동작 확인 ──
print("=" * 55, "\n■ 세션 1 (첫 방문)")
chat = SummaryMemoryChat(keep_recent=2)
for t in ["운동화를 샀는데 사이즈가 작아요. 260으로 교환하고 싶어요.",
"교환 배송비는 얼마예요?",
"네 알겠어요. 참, 저는 문자보다 이메일 연락이 좋아요.",
"교환 신청은 오늘 할게요. 감사합니다."]:
print(f"\n고객: {t}")
print(f"하루: {chat.send(t)}")
save_profile(cid, chat)
# ── 재방문 세션: 프로필을 시스템 프롬프트에 주입 ──
print("\n" + "=" * 55, "\n■ 세션 2 (일주일 뒤 재방문)")
profile = load_profile(cid)
profile_txt = json.dumps(profile, ensure_ascii=False)
# 프로필은 '지난 상담의 기록'이다. 지금의 처리 상태가 아니라는 것을 함께 적어 준다.
system2 = (SYSTEM_BASE
+ "\n\n[이 고객에 대한 기억] 지난 상담에서 적어 둔 기록입니다. "
"현재 처리 상태는 아니므로, 지난 상담 내용으로만 말하고 "
"현재 상태는 확인 후 안내드린다고 답합니다.\n"
+ profile_txt)
r = client.models.generate_content(
model=MODEL,
contents="저번에 문의했던 교환 건 어떻게 됐는지 궁금해서요.",
config=types.GenerateContentConfig(system_instruction=system2, temperature=1.0),
)
print(f"\n고객: 저번에 문의했던 교환 건 어떻게 됐는지 궁금해서요.")
print(f"하루: {r.text}")
print("""
──────────────────────────────────────────────────────────
기억 3계층 정리
단기: 이번 세션의 원문 이력 (16장)
중기: 세션 내 요약 메모리 — 길어진 대화 압축 (오늘 1)
장기: 세션을 넘는 고객 프로필 파일 (오늘 2) ← 민감정보 저장 금지
──────────────────────────────────────────────────────────""")
2. 코드에서 볼 곳
| 부분 | 내용 | 볼 것 |
|---|---|---|
SYSTEM_BASE |
상담원의 역할과 정책 발췌 두 줄 | "발췌에 없는 것은 확인 후 안내" |
summarize_history |
오래된 대화를 3줄 이내로 요약 | 프롬프트의 "사실 위주", "반드시 포함" |
SummaryMemoryChat |
요약 + 최근 원문을 이어 붙여 보내는 대화 | send() 끝의 요약 조건 |
save_profile / load_profile |
프로필을 뽑아 파일에 쓰고 읽는다 | JSON 모드, 민감정보 금지 문구 |
| 실행 | 세션 1(네 턴) → 저장 → 세션 2(한 턴) | 세션 2가 무엇만 받는가 |
실행 부분을 봅니다. 세션 1은 keep_recent=2로 시작합니다.
chat = SummaryMemoryChat(keep_recent=2)
for t in [...]: # 문의 네 개
print(f"\n고객: {t}")
print(f"하루: {chat.send(t)}")
save_profile(cid, chat)
현재 코드는 답변과 요약 전체를 출력합니다. 아래의 기존 실행 기록은 축약된 예시이며, 실제 실행 화면에서는 전체 내용을 확인합니다.
세션 2가 받는 것은 프로필과 새 문의 한 문장뿐입니다. 세션 1의 대화 원문도, 요약도 넘어가지 않습니다.
profile = load_profile(cid)
profile_txt = json.dumps(profile, ensure_ascii=False)
system2 = (SYSTEM_BASE
+ "\n\n[이 고객에 대한 기억] 지난 상담에서 적어 둔 기록입니다. "
"현재 처리 상태는 아니므로, 지난 상담 내용으로만 말하고 "
"현재 상태는 확인 후 안내드린다고 답합니다.\n"
+ profile_txt)
r = client.models.generate_content(
model=MODEL,
contents="저번에 문의했던 교환 건 어떻게 됐는지 궁금해서요.",
config=types.GenerateContentConfig(system_instruction=system2, temperature=1.0),
)
3. 실행하기
실행하기 전에 짐작해 보세요.
keep_recent=2입니다.[요약 메모리 갱신]은 몇 번째 문의에서 나올까요? (앞 절 「요약 메모리」의 표를 떠올려 보세요.)- 두 번째 문의는 "교환 배송비는 얼마예요?"입니다. 상담원은 무엇을 보고 답할까요? 그 답은 요약과 프로필에 어떤 모양으로 남을까요?
$ python lesson17_memory.py
LLM을 일곱 번 호출하므로 15~30초 걸립니다.
실행 결과 (문장은 실행할 때마다 달라집니다)
=======================================================
■ 세션 1 (첫 방문)
고객: 운동화를 샀는데 사이즈가 작아요. 260으로 교환하고 싶어요.
하루: 안녕하세요, 하루마켓 상담원 하루입니다. 고객님, 동일 상품의 다른 색상이나 사이즈 교환은 수령일로부터 7일 이내에 신청하실 수 있습니다. 단순 변심에 의한 교환의 왕복 배송비는 6,000원이며 고객님께서 부담하셔야 합니다. 교환 절차와 관련하여 더 궁금하신 점이 있으
고객: 교환 배송비는 얼마예요?
하루: 단순 변심 교환의 왕복 배송비는 6,000원이며 고객님께서 부담하셔야 합니다. 교환과 관련하여 더 도움이 필요하신 부분이 있으신가요?
고객: 네 알겠어요. 참, 저는 문자보다 이메일 연락이 좋아요.
[요약 메모리 갱신] - 고객 요청: 운동화 사이즈 260으로 교환 (주문번호 미확인)
- 처리 상태: 교환 가능 안내 완료 (왕복 배송비 6,00...
하루: 알려주신 이메일 연락 선호는 안전하게 기록해 두겠습니다. 앞으로 안내가 필요하실 때 참고하도록 하겠습니다. 추가로 궁금하신 점이 있으시면 언제든지 말씀해 주세요.
고객: 교환 신청은 오늘 할게요. 감사합니다.
하루: 오늘 교환 신청을 진행해 주시면, 수령일로부터 7일 이내에 동일 상품의 다른 색상이나 사이즈로 신청하실 수 있습니다. 단순 변심으로 인한 교환의 왕복 배송비 6,000원은 고객님께서 부담해 주셔야 합니다. 주문번호와 구체적인 교환 접수 절차 등 추가로 필요한 사항은 확
[장기 기억 저장] {'선호': ['문자보다 이메일 연락 선호'], '진행중_이슈': ['운동화 사이즈 260으로 교환 진행 중', '오늘 교환 신청 예정', '왕복 배송비 6,000원 고객 부담 안내됨'], '메모': ['주문번호 확인 및 구체적인 교환 접수 절차 진행 필요']}
=======================================================
■ 세션 2 (일주일 뒤 재방문)
고객: 저번에 문의했던 교환 건 어떻게 됐는지 궁금해서요.
하루: 안녕하세요, 하루마켓 상담원 하루입니다. 지난 상담에서 고객님께서는 문자보다 이메일 연락을 선호하신다고 하셨는데, 해당 내용은 잘 기록해 두겠습니다. 지난 기록을 보면 운동화 사이즈 260으로 교환 진행 중이셨고, 오늘 교환 신청 예정이며 왕복 배송비 6,000원은 고객 부담으로 안내되었습니다. 다만 현재 처리 상태에 대해서는 정확한 확인 후 안내드리겠습니다.
(이하 생략)
실행이 끝나면 VS Code 탐색기에서 memory_store/customer_profiles.json을 열어 봅니다.
{
"C007": {
"선호": [
"문자보다 이메일 연락 선호"
],
"진행중_이슈": [
"운동화 사이즈 260으로 교환 진행 중",
"오늘 교환 신청 예정",
"왕복 배송비 6,000원 고객 부담 안내됨"
],
"메모": [
"주문번호 확인 및 구체적인 교환 접수 절차 진행 필요"
]
}
}
| 이렇게 나오면 | 원인과 조치 |
|---|---|
[설정 필요] GOOGLE_API_KEY 가 없습니다 |
.env 파일을 확인합니다(2장) |
[요약 메모리 갱신]이 나온다 |
요약 전체를 읽고 주문·요청·금액이 보존됐는지 확인합니다 |
4. 무엇을 관찰했나
요약은 세 번째 문의에서 일했다
[요약 메모리 갱신]이 세 번째 문의 뒤에 한 번 나왔습니다. 세 번째 턴이 끝나며 원문이 6개가 되어 한도(4개)를 넘었고, 앞의 두 턴이 요약으로 바뀌었습니다. 네 번째 턴에서는 원문이 다시 4개라 요약하지 않았습니다.
요약에는 "260으로 교환", "주문번호 미확인", "왕복 배송비 6,00…"이 들어 있습니다. 프롬프트에서 "고객 요청·주문번호·처리 상태를 반드시 포함하라"고 했더니, 주문번호가 없다는 사실까지 적었습니다.
[요약 메모리 갱신] 줄이 "하루:"보다 먼저 보이는 것은 send()가 답을 돌려주기 전에 요약을 하고 그 결과를 출력하기 때문입니다.
지나가듯 한 말이 프로필에 남았다
"참, 저는 문자보다 이메일 연락이 좋아요"는 교환과 상관없이 덧붙인 한마디였습니다. 그것이 선호 칸에 들어갔고, 세션 2의 상담원은 "선호하시는 이메일로도"라고 말했습니다.
- 무엇이 선호이고 무엇이 진행 중인 일인지 가려 담은 것은 모델입니다.
- 세 칸이라는 틀을 정하고, 파일에 쓰고, 다음에 읽어 프롬프트에 넣은 것은 우리 코드입니다.
세션 2는 교환 건을 이어받았다
세션 2의 상담원은 "운동화 사이즈 260으로 교환 진행 중이셨고"라고 바로 말했습니다. 세션 1의 대화는 한 줄도 받지 않았고, 프로필 몇 줄만 받았습니다. 재방문 고객을 처음 보는 사람처럼 대하던 숙제가 풀렸습니다.
6,000원이 지나간 길
두 번째 턴의 답 "왕복 배송비는 6,000원"이 어디를 거쳐 갔는지 따라가 봅니다.
| 어디에 | 무엇이 |
|---|---|
시스템 프롬프트의 [정책 발췌] |
"단순 변심 교환의 왕복 배송비는 6,000원이며 고객이 부담한다" |
| 세션 1, 두 번째 답 | "왕복 배송비는 6,000원이며 고객님께서 부담하셔야 합니다" |
| 요약 | "교환 가능 안내 완료 (왕복 배송비 6,00…" |
프로필의 진행중_이슈 |
"왕복 배송비 6,000원 고객 부담 안내됨" |
| 세션 2의 답 | "왕복 배송비 6,000원은 고객 부담으로 안내되었습니다" |
숫자 하나가 프롬프트 → 답 → 요약 → 프로필 → 다음 방문의 답 으로 그대로 이어졌습니다. 요약도 프로필 추출도 대화에 있던 말을 옮겨 적는 일이므로, 맨 앞의 답이 근거에서 나왔으면 끝까지 맞는 값이 갑니다. 앞 절에서 "기억은 근거 위에 얹는다"고 한 것이 이 표입니다.
- 금액의 근거를 프롬프트에 적어 준 것은 개발자입니다.
- 그 근거를 읽고 답하고, 요약하고, 프로필 칸에 옮겨 담은 것은 모델입니다.
지난 일은 지난 일로, 지금 상태는 확인 후에
세션 2의 답은 두 부분으로 나뉩니다. "지난 기록을 보면 … 안내되었습니다"와 "현재 처리 상태에 대해서는 정확한 확인 후 안내드리겠습니다"입니다.
상담원은 이 대화에서 아무것도 조회하지 않았습니다. 그래서 프로필에 적힌 것은 지난 상담의 기록으로만 말하고, 지금의 상태는 미뤘습니다. 프로필을 프롬프트에 넣을 때 "현재 처리 상태는 아니므로 … 확인 후 안내드린다고 답합니다"라고 함께 적어 둔 대로입니다.
프로필이 알려 주는 것은 지난번에 무슨 일이 있었는가입니다. 지금 어떤 상태인가는 주문 데이터를 조회해야 압니다.
5. 다시 실행할 때
같은 명령으로 한 번 더 실행하면 세션 1부터 다시 시작합니다. 세션 1은 프로필을 읽지 않고, 저장할 때는 앞 절에서 본 profiles[customer_id] = ... 한 줄이 C007의 프로필을 통째로 새것으로 바꿉니다. 그래서 앞 실행이 남긴 파일이 있어도 결과가 달라지지 않습니다. 답의 문장은 LLM이 쓰므로 실행마다 조금씩 다릅니다.
프로필 파일을 지우고 처음 상태에서 시작하고 싶으면 VS Code 탐색기에서 memory_store/customer_profiles.json을 골라 지웁니다. memory_store 폴더와 그 안의 tickets.json(8장)은 지우지 않습니다.
6. 지금 폴더의 모습
haru-market/
├── config.py
├── haru_tools.py
├── (lesson02 ~ lesson15 생략)
├── lesson16_multiturn.py
├── lesson17_memory.py ← 이번 장
└── memory_store/
├── customer_profiles.json ← 이번 장 (실행하면 생깁니다)
└── tickets.json (8장에서 티켓을 만들었다면)
핵심 정리
keep_recent=2에서 요약은 세 번째 턴이 끝날 때 일했습니다. 앞의 두 턴이 요약 한 덩어리가 되었습니다.- 지나가듯 말한 연락 선호가 프로필에 저장되어 다음 방문의 답에 반영되었습니다.
- 세션 2는 대화 원문 없이 프로필 몇 줄만으로 교환 건을 이어받았습니다.
- 정책 발췌의 6,000원이 답, 요약, 프로필을 거쳐 세션 2의 답까지 그대로 이어졌습니다.
- 세션 2는 프로필을 지난 상담의 기록으로 말하고, 현재 상태는 확인 후 안내한다고 답했습니다.
- 같은 고객의 프로필을 다시 저장하면 덮어씁니다.