17장. 장기 기억 관리
요약 메모리 — 버리지 말고 줄여서 남긴다
한 줄 요약
요약 메모리(summary memory) 는 이력이 길어졌을 때 오래된 부분을 지우는 대신 LLM으로 요약해 짧은 글로 남겨 두는 방법입니다. 그 뒤로 모델에게 들어가는 것은 "과거 요약 + 최근 대화 원문 + 새 문의"입니다.
1. trim과 무엇이 다른가
같은 자리에서 일하지만 하는 일이 다릅니다.
| trim (16장) | 요약 메모리 (이번 장) | |
|---|---|---|
| 언제 | 이력이 한도를 넘을 때 | 이력이 한도를 넘을 때 |
| 오래된 부분을 | 버린다 | 요약으로 바꾼다 |
| 추가 비용 | 없음 | 요약하는 LLM 호출 1회 |
| 남는 것 | 최근 턴의 원문 | 과거 요약 + 최근 원문 |
2. 요약도 프롬프트로 시킨다
요약은 LLM 호출 한 번입니다. 무엇을 남길지는 프롬프트에 적습니다.
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()
| 프롬프트의 문구 | 왜 넣었나 |
|---|---|
| "이후 상담에 필요한 사실 위주로" | 이 말이 없으면 "고객이 불편을 겪었고 상담원이 친절히 안내했다" 같은 분위기 묘사가 되기 쉽습니다 |
| "3줄 이내" | 요약이 길면 줄인 보람이 없습니다. 길이는 숫자로 정합니다(3장) |
| "고객 요청·주문번호·처리 상태를 반드시 포함" | 다음 턴에 꼭 필요한 항목을 이름으로 지정합니다 |
temperature=1.0 |
기록은 창의적일 필요가 없습니다 |
무엇을 남길지 정한 것은 개발자이고, 그 기준에 따라 실제로 골라 쓴 것은 모델입니다.
3. 대화 클래스 — 요약과 원문을 나눠 들고 있는다
SummaryMemoryChat은 기억을 두 칸에 나눠 담습니다.
class SummaryMemoryChat:
def __init__(self, keep_recent: int = 4):
self.summary = "" # 압축된 과거
self.recent: list[dict] = [] # 원문 그대로 둔 최근 메시지
self.keep_recent = keep_recent # 요약한 뒤 원문으로 남길 메시지 수 (2 = 문답 한 쌍)
문의가 오면 두 칸을 이어 붙여 하나의 글로 만들어 보냅니다.
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}"
16장에서는 이력을 types.Content의 리스트로 보냈습니다. 여기서는 대화를 하나의 글로 정리해 보냅니다. 도구를 쓰지 않으니 이렇게 해도 충분하고, 요약문을 넣기도 쉽습니다.
4. 언제, 어떻게 요약으로 바꾸는가
답을 받은 뒤 원문이 너무 쌓였는지 확인합니다.
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:]
keep_recent=2일 때를 손으로 따라가 봅니다. 문답 한 쌍이 메시지 두 개입니다.
| 턴이 끝난 뒤 | recent의 메시지 수 |
4개를 넘는가 | 일어나는 일 |
|---|---|---|---|
| 1 | 2 | 아니오 | 없음 |
| 2 | 4 | 아니오 | 없음 |
| 3 | 6 | 예 | 앞의 4개(1·2턴)를 요약으로 바꾸고, 3턴의 2개만 원문으로 남긴다 |
| 4 | 4 | 아니오 | 없음 |
| 5 | 6 | 예 | 기존 요약 + 앞의 4개를 다시 요약한다 |
다섯 번째 줄이 중요합니다. 이미 요약이 있으면 그 요약까지 재료에 넣어 다시 요약합니다. 그래서 대화가 아무리 길어져도 들고 있는 것은 "요약 하나 + 최근 원문 조금"으로 크기가 일정합니다.
5. 요약의 두 가지 비용
호출 비용. 요약 자체가 LLM 호출 한 번입니다. 다만 그 뒤의 모든 턴에서 "원문 여러 턴" 대신 "요약 몇 줄"이 나가므로, 대화가 길수록 이득이 커집니다.
정보 손실. 요약은 손실 압축(lossy compression) 입니다. 줄이는 과정에서 무언가는 빠집니다. 무엇이 중요한지를 요약하는 시점의 모델이 판단하므로, 나중에 필요해질 세부가 빠질 수 있습니다. 요약을 다시 요약할 때마다 한 번 더 걸러집니다.
trim은 오래된 것을 전부 잊고, 요약은 오래된 것 중 덜 중요해 보인 것을 잊습니다. 원문을 보존하는 방법은 아닙니다.
핵심 정리
- 요약 메모리는 오래된 이력을 버리는 대신 요약으로 바꿉니다.
- 요약 프롬프트에 사실 위주, 길이, 꼭 넣을 항목을 적습니다. 기준은 개발자가, 요약은 모델이 합니다.
- 기억은 요약(
summary) 과 최근 원문(recent) 두 칸에 나눠 담습니다. - 이미 요약이 있으면 그 요약까지 넣어 다시 요약하므로 크기가 일정하게 유지됩니다.
- 요약에는 호출 비용과 정보 손실이 따릅니다.