실무 Multi-Agent 오케스트레이션 17장 · 장기 기억 관리 7 / 7 ← 이전목차다음 → TechLead Cro

17장. 장기 기억 관리

실습문제와 해답

lesson17_memory.py의 부품을 가져다 써 보며 17장 내용을 손에 익힙니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다.

lesson17_memory.py의 실행 부분은 if __name__ == "__main__": 아래에 있어서, 다른 파일에서 from lesson17_memory import ...로 가져와도 두 세션이 다시 실행되지 않습니다.


문제 1 — 카드번호가 프로필에 들어가지 않도록 코드로 한 번 더 막기

고객이 채팅창에 카드번호를 적었습니다. 새 파일 ex17_sensitive.py를 만들어 다음을 하세요.

  • 문의 두 개로 대화한다. 1. "환불받을 카드를 바꾸고 싶어요. 새 카드번호는 1234-5678-9012-3456 이에요." 2. "그리고 연락은 저녁 7시 이후에 주세요."
  • save_profile로 저장한다. 고객 ID는 연습용으로 "EX17"을 쓴다(C007의 프로필을 건드리지 않기 위해서).
  • 저장된 프로필에 카드번호 모양의 숫자열(숫자 4개씩 네 묶음)이 들어 있는지 정규식으로 검사해 출력한다.
  • 카드번호 모양이 들어 있는 항목을 빼는 함수 scrub을 만들어, 걸러 낸 프로필을 다시 저장한다.
  • 카드번호가 섞인 프로필을 scrub에 직접 넣어, 걸러 내는 것을 확인한다.
해답 보기
import json
import re

from lesson17_memory import PROFILE_PATH, SummaryMemoryChat, load_profile, save_profile

CID = "EX17"   # 연습용 고객 ID. C007 의 프로필은 건드리지 않는다

chat = SummaryMemoryChat(keep_recent=2)
for t in ["환불받을 카드를 바꾸고 싶어요. 새 카드번호는 1234-5678-9012-3456 이에요.",
          "그리고 연락은 저녁 7시 이후에 주세요."]:
    print(f"고객: {t}")
    print(f"하루: {chat.send(t)}\n")

save_profile(CID, chat)

# 2차 방어: 저장된 프로필을 코드로 검사한다
CARD = re.compile(r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}")


def scrub(profile: dict) -> dict:
    """카드번호 모양의 숫자열이 들어 있는 항목을 통째로 뺀다."""
    return {key: [item for item in items if not CARD.search(str(item))]
            for key, items in profile.items()}


profile = load_profile(CID)
text = json.dumps(profile, ensure_ascii=False)
print(f"\n카드번호 모양이 프로필에 들어 있는가: {'예' if CARD.search(text) else '아니오'}")

clean = scrub(profile)
profiles = json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
profiles[CID] = clean
PROFILE_PATH.write_text(json.dumps(profiles, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"검사 후 저장한 프로필: {clean}")

# scrub 이 일하는 모습: 카드번호가 섞인 프로필을 직접 넣어 본다
sample = {"메모": ["카드 1234-5678-9012-3456 로 변경", "저녁 연락"]}
print(f"\nscrub 확인: {sample} → {scrub(sample)}")
$ python ex17_sensitive.py

실행 결과 (문장은 실행마다 다릅니다)

고객: 환불받을 카드를 바꾸고 싶어요. 새 카드번호는 1234-5678-9012-3456 이에요.
하루: 환불 카드 변경 처리는 확인 후 안내드리겠습니다. 그리고 올려주신 카드번호는 개인정보 보호를 위해 기록하지 않으니 안심하셔도 됩니다. 더 궁금한...

고객: 그리고 연락은 저녁 7시 이후에 주세요.
하루: 알려주신 저녁 7시 이후 연락 선호는 잘 기록해 두겠습니다. 환불 카드 변경과 관련하여 추가로 궁금한 점이 있으시면 언제든 편하게 말씀해 주세요...

  [장기 기억 저장] {'선호': ['저녁 7시 이후 연락 선호'], '진행중_이슈': ['환불받을 카드 변경 요청'], '메모': []}

카드번호 모양이 프로필에 들어 있는가: 아니오
검사 후 저장한 프로필: {'선호': ['저녁 7시 이후 연락 선호'], '진행중_이슈': ['환불받을 카드 변경 요청'], '메모': []}

scrub 확인: {'메모': ['카드 1234-5678-9012-3456 로 변경', '저녁 연락']} → {'메모': ['저녁 연락']}

해설 — 두 겹이 각자 일했습니다.

1차(프롬프트). 프로필에 카드번호가 들어가지 않았습니다. "민감정보는 절대 포함하지 마라"는 추출 프롬프트가 지켜졌고, 프로필에는 "환불받을 카드 변경 요청"이라는 사실만 남았습니다. 상담원도 "카드번호는 기록하지 않는다"고 고객에게 알렸습니다. 시스템 프롬프트에 적어 둔 대로입니다.

2차(코드). 마지막 줄을 봅니다. 카드번호가 섞인 프로필을 scrub에 넣었더니 그 항목만 빠지고 "저녁 연락"은 남았습니다. 정규식에 맞는 모양이면 모델과 상관없이 반드시 걸러집니다.

프롬프트는 모델이 따르는 규칙이고, 코드 검사는 정한 모양을 반드시 걸러 내는 장치입니다. 저장 직전에 scrub을 한 번 거치게 해 두면 두 겹이 됩니다.

하나 더 봅니다. 이 대화에서 카드번호는 chat.recent에 원문 그대로 들어 있습니다. 민감정보는 대화 이력, 요약, 프로필, 로그 가운데 저장되는 곳마다 같은 검사를 걸어 둡니다.


문제 2 — 요약에 꼭 남길 항목을 보태기

요약 프롬프트의 "반드시 포함하라" 목록이 요약의 모양을 정합니다. 새 파일 ex17_summary_items.py를 만들어 확인하세요.

  • 아래 네 메시지를 summarize_history로 요약해 출력한다.
  • 같은 메시지를, "반드시 포함하라" 목록에 회수 장소를 보탠 프롬프트로 요약하는 함수 summarize_with_place를 만들어 출력한다.
history = [
    {"role": "user", "text": "운동화 교환할 건데요, 회수는 회사에서 해 주세요. 3층 경비실에 맡겨 둘게요."},
    {"role": "model", "text": "네, 회사 3층 경비실에서 회수하도록 기록해 두겠습니다."},
    {"role": "user", "text": "요즘 날씨가 많이 쌀쌀해졌네요."},
    {"role": "model", "text": "네, 일교차가 크니 건강 유의하세요."},
]

실행하기 전에 짐작해 보세요. 두 요약에서 "회사 3층 경비실"은 어디에 적힐까요? 날씨 이야기는 남을까요?

해답 보기
from google.genai import types

from config import MODEL
from lesson17_memory import client, summarize_history

history = [
    {"role": "user", "text": "운동화 교환할 건데요, 회수는 회사에서 해 주세요. 3층 경비실에 맡겨 둘게요."},
    {"role": "model", "text": "네, 회사 3층 경비실에서 회수하도록 기록해 두겠습니다."},
    {"role": "user", "text": "요즘 날씨가 많이 쌀쌀해졌네요."},
    {"role": "model", "text": "네, 일교차가 크니 건강 유의하세요."},
]


def summarize_with_place(history: list[dict]) -> str:
    """lesson17 의 요약 프롬프트에 '회수 장소'를 꼭 넣을 항목으로 보탠 것."""
    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()


print("[기본 요약]")
print(summarize_history(history))
print("\n[회수 장소를 꼭 넣을 항목에 보탠 요약]")
print(summarize_with_place(history))
$ python ex17_summary_items.py

실행 결과 (문장은 실행마다 다릅니다)

[기본 요약]
- 주문번호: 미기재 (고객 미제시)
- 고객 요청: 운동화 교환 및 회사 3층 경비실 방문 회수 요청
- 처리 상태: 3층 경비실 회수로 접수 및 기록 완료

[회수 장소를 꼭 넣을 항목에 보탠 요약]
- 고객 요청: 운동화 교환 (주문번호 미기재)
- 처리 상태: 회수 접수 완료
- 회수 장소: 회사 3층 경비실

해설 — 두 요약 모두 "회사 3층 경비실"을 담았습니다. 달라진 것은 자리입니다.

  • 기본 요약에서는 회수 장소가 "고객 요청" 줄 안에 문장으로 들어갔습니다.
  • 목록에 "회수 장소"를 보탠 요약에서는 "회수 장소: 회사 3층 경비실" 이라는 줄이 따로 생겼습니다. 프롬프트에 이름을 적은 항목이 요약의 한 줄이 됩니다. 요약을 다시 요약할 때에도 이름이 붙은 줄은 그대로 옮겨지기 쉽습니다.
  • 날씨 이야기는 두 요약 모두에 없습니다. "이후 상담에 필요한 사실 위주로"라는 지시에 따라 모델이 뺐습니다.

무엇을 꼭 남길지 목록을 정하는 것은 개발자이고, 그 목록에 맞춰 골라 쓰는 것은 모델입니다. 목록을 늘릴수록 요약은 길어지므로, 상담 업무에서 다음 턴에 꼭 필요한 항목만 넣습니다.


문제 3 — 프로필에 기록한 날짜를 붙이기

프로필은 "지난 상담의 기록"입니다. 언제의 기록인지를 함께 저장해, 재방문 답에 날짜가 나오게 해 보세요. 새 파일 ex17_profile_date.py를 만들어 다음을 합니다.

  • 문의 두 개("운동화를 260으로 교환하고 싶어요.", "연락은 이메일로 주세요.")로 대화하고 save_profile("EX17", chat)으로 저장한다.
  • 저장된 파일을 읽어 EX17의 프로필에 "기록일" 칸을 오늘 날짜로 보태 다시 저장한다. 날짜는 코드로 넣는다(date.today().isoformat()).
  • 프로필을 시스템 프롬프트에 넣어 "저번에 문의했던 교환 건 어떻게 됐어요?"에 답하게 한다. 기록일을 함께 말하라는 문장을 프롬프트에 넣는다.
해답 보기
import json
from datetime import date

from google.genai import types

from config import MODEL
from lesson17_memory import (PROFILE_PATH, SYSTEM_BASE, SummaryMemoryChat, client,
                             load_profile, save_profile)

CID = "EX17"   # 연습용 고객 ID. C007 의 프로필은 건드리지 않는다

chat = SummaryMemoryChat(keep_recent=2)
for t in ["운동화를 260으로 교환하고 싶어요.",
          "연락은 이메일로 주세요."]:
    chat.send(t)
save_profile(CID, chat)

# 저장된 프로필에 '기록한 날짜'를 코드로 붙여 다시 저장한다
profiles = json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
profiles[CID]["기록일"] = date.today().isoformat()
PROFILE_PATH.write_text(json.dumps(profiles, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n저장된 프로필: {load_profile(CID)}")

# 재방문: 프로필과 함께 '언제 적은 기록인지'가 프롬프트에 들어간다
profile_txt = json.dumps(load_profile(CID), ensure_ascii=False)
system2 = (SYSTEM_BASE
           + "\n\n[이 고객에 대한 기억] 지난 상담에서 적어 둔 기록입니다. "
             "기록일을 함께 말하고, 현재 상태는 확인 후 안내드린다고 답합니다.\n"
           + profile_txt)
q = "저번에 문의했던 교환 건 어떻게 됐어요?"
r = client.models.generate_content(
    model=MODEL, contents=q,
    config=types.GenerateContentConfig(system_instruction=system2))
print(f"\n고객: {q}")
print(f"하루: {r.text}")
$ python ex17_profile_date.py

실행 결과 (문장은 실행마다 다르고, 날짜는 실행한 날로 나옵니다)

  [장기 기억 저장] {'선호': ['이메일 연락 선호'], '진행중_이슈': ['운동화 260 사이즈 교환 희망'], '메모': ['단순 변심 교환 시 왕복 배송비 6,000원 안내됨']}

저장된 프로필: {'선호': ['이메일 연락 선호'], '진행중_이슈': ['운동화 260 사이즈 교환 희망'], '메모': ['단순 변심 교환 시 왕복 배송비 6,000원 안내됨'], '기록일': '2026-10-08'}

고객: 저번에 문의했던 교환 건 어떻게 됐어요?
하루: 안녕하세요, 하루마켓 상담원 하루입니다. 지난 2026-10-08에 기록된 운동화 260 사이즈 교환 희망 건에 대해 문의해 주셨네요. 현재 처리 상태는 확인 후 안내드리겠습니다. 고객님께서 이메일 연락을 선호하신다고 남겨주셨는데, 해당 내용도 안전하게 잘 기록해 두겠습니다.

해설 — 답에 "지난 2026-10-08에 기록된"이 들어갔습니다.

  • 날짜는 코드가 넣었습니다. 모델에게 "오늘 날짜를 적어라"고 시키지 않았습니다. 모델은 오늘이 며칠인지 모릅니다. 정확해야 하는 값은 코드가 채우고, 모델은 그것을 읽어 문장으로 옮깁니다.
  • 세 칸(선호, 진행중_이슈, 메모)은 모델이 채웠고, 네 번째 칸(기록일)은 코드가 보탰습니다. 프로필은 코드가 읽고 쓰는 데이터라서 이렇게 칸을 더할 수 있습니다. JSON 모드로 뽑은 이유입니다.
  • 기록일이 있으면 상담원은 "언제의 기록인지"를 밝히고 말할 수 있고, 오래된 기록을 가려내는 코드도 붙일 수 있습니다.

실습이 끝나면 memory_store/customer_profiles.json에서 EX17 항목은 지워도 됩니다.

← 이전 절정리와 체크리스트18장 →왜 그래프가 필요한가 — if문이 뒤엉키기 시작한다
오명운 · macro@prag-ai.com