에이전트 메모리 설계
컨텍스트 윈도, 외부 저장소, 컴팩션, 도구 기반 메모리를 조합해 끊기지 않는 LLM 에이전트를 만드는 법
LLM 에이전트를 처음 만들 때는 "대화 히스토리를 배열에 쌓아서 매번 통째로 보낸다"로 충분하다. 문제는 이 방식이 정확히 두 군데서 무너진다는 점이다. 하나는 컨텍스트 윈도가 꽉 차는 순간(요청이 400/오버플로로 죽거나, 비용이 선형으로 치솟는다), 다른 하나는 세션이 끝나는 순간(에이전트가 어제 한 일을 전부 잊는다). 이 두 경계를 어떻게 다루느냐가 곧 에이전트 메모리 설계다.
메모리는 단일 기능이 아니라 4개의 서로 다른 층위로 나눠 생각해야 한다. 각각은 수명(lifetime)과 비용 구조가 다르다.
| 층위 | 사는 곳 | 수명 | 주된 비용 | 대표 기법 |
|---|---|---|---|---|
| 단기(Short-term) | 컨텍스트 윈도 자체 | 한 요청 | 입력 토큰 | messages 배열, 프롬프트 캐싱 |
| 요약/컴팩션(Compaction) | 컨텍스트 윈도 (압축본) | 한 세션 | 요약 1회 + 압축본 토큰 | 서버 컴팩션, context editing |
| 장기(Long-term) | 외부 저장소(DB/파일) | 영구 | 저장소 R/W + 재주입 토큰 | memory tool, 파일/DB |
| 벡터(Vector) | 임베딩 인덱스 | 영구 | 임베딩 + 검색 | RAG, 시맨틱 검색 |
핵심 원리 하나만 기억하면 된다: LLM API는 stateless다. 모델은 요청과 요청 사이에 아무것도 기억하지 않는다. "메모리"란 전부 당신이 다음 요청에 무엇을 다시 넣어주느냐의 문제다. 따라서 메모리 설계는 본질적으로 "무엇을 컨텍스트에 넣고, 무엇을 빼고, 빠진 것을 어디에 보관했다가 언제 다시 꺼낼 것인가"를 결정하는 일이다.
이 가이드는 Claude API(Anthropic SDK) 기준으로 4개 층위를 각각 구현하는 법을 다룬다. 다만 패턴 자체는 provider 무관하므로 OpenAI/Gemini 등 다른 모델로도 그대로 옮길 수 있다. 모델 ID·컨텍스트 윈도·캐싱 동작 같은 수치는 2026년 6월 기준 실제 값으로 적었고, 변할 수 있는 부분은 명시했다.
1. 왜 메모리를 따로 설계해야 하는가 — stateless API와 컨텍스트 윈도의 물리적 한계
대부분의 메모리 버그는 두 가지 오해에서 나온다.
오해 1: "모델이 대화를 기억한다." 아니다. /v1/messages는 완전히 stateless다. 멀티턴 대화는 당신이 매 요청마다 전체 히스토리를 다시 보내기 때문에 성립하는 착시다.
import anthropic
client = anthropic.Anthropic()
messages = []
def chat(user_text: str) -> str:
messages.append({"role": "user", "content": user_text})
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
messages=messages, # 매번 전체 히스토리를 통째로 전송
)
text = next(b.text for b in resp.content if b.type == "text")
messages.append({"role": "assistant", "content": text}) # 어시스턴트 턴도 직접 누적
return text
chat("내 이름은 Egan이야")
chat("내 이름이 뭐였지?") # "Egan" — messages에 1번 턴이 들어있어서 맞히는 것뿐
messages를 직접 누적하지 않으면 두 번째 호출은 이름을 모른다. 이게 메모리의 출발점이다.
오해 2: "컨텍스트 윈도가 크니까 그냥 다 넣으면 된다." 컨텍스트가 크다고 공짜가 아니다. 두 가지 물리적 벽이 있다.
- 토큰 비용은 매 턴 누적된다. 50턴짜리 에이전트 루프에서 매 턴 전체 히스토리를 보내면, 입력 토큰은 턴 수에 대해 *2차(quadratic)*로 증가한다(턴 1은 1배, 턴 50은 50배 크기를 보냄 → 누적 합은 N²/2). 프롬프트 캐싱 없이는 비용이 폭발한다.
- 컨텍스트 윈도는 유한하다. Claude Opus 4.8 / Sonnet 4.6 등은 1M 토큰, Haiku 4.5는 200K다(2026-06 기준; 모델별 정확한 값은 Models API
client.models.retrieve(id).max_input_tokens로 조회). 한도를 넘기면 요청이 거부되거나stop_reason: "model_context_window_exceeded"가 돌아온다.
그래서 메모리 설계는 "무한 기억"을 만드는 게 아니라, 유한한 컨텍스트 예산을 어떻게 배분하느냐의 문제다. 매 요청의 컨텍스트를 다음과 같이 예산으로 본다.
[ 시스템 프롬프트 ] + [ 장기 메모리에서 끌어온 관련 정보 ]
+ [ 요약된 과거 대화 ] + [ 최근 원문 N턴 ] + [ 현재 입력 ]
≤ 컨텍스트 윈도
각 항목을 채우는 기법이 뒤따르는 섹션들이다. 시작 전에 항상 토큰 예산을 측정하라 — 추정하지 말고 count_tokens로 재라(섹션 8).
2. 단기 메모리 — messages 배열, 슬라이딩 윈도, 그리고 프롬프트 캐싱이라는 진짜 핵심
단기 메모리 = 컨텍스트 윈도에 실제로 들어가는 것. 가장 단순한 구현은 전체 히스토리를 다 보내는 것이고, 그 다음 단계가 슬라이딩 윈도(최근 N턴만 유지)다.
class ShortTermMemory:
def __init__(self, keep_turns: int = 20):
self.keep_turns = keep_turns # 최근 N개 메시지만 원문 유지
self.messages: list[dict] = []
def add(self, role: str, content):
self.messages.append({"role": role, "content": content})
def window(self) -> list[dict]:
# 첫 메시지는 user여야 한다는 규칙을 지키며 잘라낸다
win = self.messages[-self.keep_turns:]
while win and win[0]["role"] != "user":
win = win[1:]
return win
슬라이딩 윈도의 함정: 단순히 잘라내면 잘린 부분의 정보가 그냥 사라진다. 그래서 윈도만 쓰면 안 되고, 잘리는 내용을 요약(섹션 4)하거나 장기 저장소(섹션 5)에 흘려보내야 한다. 윈도는 "무엇을 컨텍스트에 유지할지"를 정하는 한 축일 뿐이다.
그런데 단기 메모리에서 비용을 가장 크게 좌우하는 건 윈도 크기가 아니라 프롬프트 캐싱이다. 이게 이 섹션의 진짜 요지다.
프롬프트 캐싱은 prefix 매치다. 렌더 순서는 tools → system → messages이고, prefix의 단 1바이트라도 바뀌면 그 지점 이후 캐시가 전부 무효화된다. 캐시 읽기는 기본 입력가의 약 0.1배, 캐시 쓰기는 5분 TTL 기준 약 1.25배다. 멀티턴 에이전트는 이걸 제대로 쓰느냐 마느냐로 비용이 한 자릿수 배 차이 난다.
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
system=[{
"type": "text",
"text": LARGE_STABLE_SYSTEM_PROMPT, # 고정 — 절대 안 바뀌어야 함
"cache_control": {"type": "ephemeral"},
}],
messages=conversation,
)
print(resp.usage.cache_read_input_tokens) # 0이면 캐시가 안 먹고 있다는 신호
멀티턴 대화에서는 가장 최근에 추가된 턴의 마지막 content 블록에 breakpoint를 둔다. 그러면 다음 요청이 직전까지의 전체 prefix를 캐시로 읽는다.
# 마지막 user 턴의 마지막 블록에 캐시 마커
conversation[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"}
캐시를 조용히 깨는 흔한 실수들 — 단기 메모리 코드에서 이걸 먼저 감사하라:
| 안티패턴 | 왜 깨지나 |
|---|---|
시스템 프롬프트에 datetime.now() / 현재 시각: 삽입 | prefix가 매 요청 달라짐 → 전부 무효 |
| 세션/유저 ID를 시스템 프롬프트에 f-string으로 끼움 | 유저별로 prefix가 달라 캐시 공유 불가 |
json.dumps(d)를 sort_keys=True 없이 직렬화 | 키 순서가 비결정적이라 바이트가 달라짐 |
| 대화 중간에 tool 목록을 추가/제거/재정렬 | tools는 위치 0 → 전체 캐시 리빌드 |
| 대화 중간에 모델 변경 | 캐시는 모델 단위라 전체 무효 |
실무 규칙: "오늘 날짜", "유저 이름", "모드" 같은 동적 컨텍스트를 시스템 프롬프트에 넣지 마라. 그건 prefix 맨 앞이라 뒤를 다 무효화한다. 대신 messages 뒤쪽에 넣어라 — 지원 모델에서는 {"role": "system", ...} 메시지를 messages 배열에 append하는 mid-conversation system 메시지(beta mid-conversation-system-2026-04-07)를 쓰면 캐시된 prefix를 건드리지 않고 운영자 권한 지시를 주입할 수 있다.
3. 요약 메모리 — 직접 굴리는 summarization 루프와 그 함정
슬라이딩 윈도로 잘려나간 과거를 잃지 않으려면 "잘리기 전에 요약해서 한 덩어리로 압축"한다. 이게 요약 메모리다. 가장 흔한 패턴은 임계치 기반 요약: 토큰이 일정 수준을 넘으면, 오래된 턴들을 별도 LLM 호출로 요약해서 단일 시스템성 메시지로 치환한다.
class SummarizingMemory:
def __init__(self, client, model="claude-opus-4-8",
trigger_tokens=120_000, keep_recent=10):
self.client = client
self.model = model
self.trigger_tokens = trigger_tokens
self.keep_recent = keep_recent
self.running_summary = "" # 누적 요약
self.messages: list[dict] = []
def _count(self) -> int:
return self.client.messages.count_tokens(
model=self.model, messages=self._render(),
).input_tokens
def _render(self) -> list[dict]:
head = []
if self.running_summary:
head = [{"role": "user",
"content": f"[이전 대화 요약]\n{self.running_summary}"}]
return head + self.messages
def maybe_compact(self):
if self._count() < self.trigger_tokens:
return
# 최근 keep_recent개는 원문 보존, 나머지를 요약 대상으로
old, recent = self.messages[:-self.keep_recent], self.messages[-self.keep_recent:]
if not old:
return
transcript = "\n".join(
f"{m['role']}: {self._as_text(m['content'])}" for m in old
)
summary = self.client.messages.create(
model=self.model, max_tokens=2048,
system=("기존 요약과 새 대화를 통합해 갱신된 요약을 작성하라. "
"결정사항·미해결 항목·사용자 선호·고유명사(이름/ID/경로)는 "
"반드시 보존하라. 잡담은 버려라."),
messages=[{"role": "user",
"content": f"[기존 요약]\n{self.running_summary}\n\n"
f"[새 대화]\n{transcript}"}],
)
self.running_summary = next(b.text for b in summary.content if b.type=="text")
self.messages = recent
@staticmethod
def _as_text(content):
if isinstance(content, str):
return content
return " ".join(b.get("text", "") for b in content if isinstance(b, dict))
요약 메모리의 함정 — 실무에서 거의 반드시 부딪힌다:
- 정보 손실은 비대칭적이다. 요약 모델은 "중요해 보이는 것"을 남기지만, 나중에 필요해질 ID·파일 경로·정확한 수치를 곧잘 버린다. 위 시스템 프롬프트처럼 무엇을 반드시 보존할지를 명시하라. 잡담이 아니라 고유명사·결정·미해결 항목이 살아남아야 한다.
- 요약의 요약은 정보를 마모시킨다(lossy 누적).
running_summary를 반복해서 다시 요약하면 점점 뭉개진다. 완화책: 원문 transcript를 장기 저장소(섹션 5)에 별도로 보관해 두고, 요약은 "인덱스"로만 쓴다. - 요약 호출 자체가 캐시를 깬다. 요약은 보통 별도 API 호출인데, 이때
system/tools/model을 부모 요청과 다르게 구성하면 부모의 캐시를 전혀 못 읽는다. 포크 호출은 부모의system/tools/model을 그대로 복사하고 포크 전용 내용만 뒤에 붙여라. - 요약 비용을 단순 작업에 과하게 쓰지 마라. 요약은 Haiku 같은 저비용 모델로 돌려도 충분한 경우가 많다. 단, 메인 루프와 모델을 섞으면 캐시가 깨지므로, 요약은 서브에이전트 호출로 분리하는 게 정석이다.
직접 요약 루프는 제어가 세밀하지만 손이 많이 간다. 다음 섹션의 서버 컴팩션은 이 로직의 상당 부분을 API가 대신 해준다.
4. 컴팩션 — 서버가 대신 요약해 주는 방식과 반드시 지켜야 할 한 가지
직접 요약 루프를 짜는 대신, Claude API의 **컴팩션(compaction)**을 쓰면 컨텍스트가 한도에 가까워질 때 서버가 자동으로 과거 컨텍스트를 요약해 준다. 2026-06 기준 beta(compact-2026-01-12)이며 Opus 4.6/4.7/4.8, Sonnet 4.6 등에서 지원된다. 트리거 임계치는 기본 약 150K 토큰이다.
client = anthropic.Anthropic()
messages = []
def chat(user_text: str) -> str:
messages.append({"role": "user", "content": user_text})
resp = client.beta.messages.create(
betas=["compact-2026-01-12"],
model="claude-opus-4-8",
max_tokens=8192,
messages=messages,
context_management={"edits": [{"type": "compact_20260112"}]},
)
# ❗핵심: text만이 아니라 response.content 전체를 다시 append 해야 한다
messages.append({"role": "assistant", "content": resp.content})
return next(b.text for b in resp.content if b.type == "text")
여기서 단 하나 절대 틀리면 안 되는 것: 응답에서 텍스트 문자열만 뽑아 messages에 넣지 말고, response.content(블록 리스트 전체)를 그대로 append해야 한다. 컴팩션이 발생하면 응답에 compaction 블록이 들어오는데, 이 블록은 다음 요청에서 서버가 압축된 히스토리를 복원하는 데 쓰인다. 텍스트만 추출해 넣으면 컴팩션 상태가 조용히 사라지고, 다음 요청부터 비용·동작이 어긋난다.
컴팩션 vs 직접 요약 — 언제 무엇을:
| 항목 | 서버 컴팩션 | 직접 요약 루프(섹션 3) |
|---|---|---|
| 구현 부담 | 낮음(파라미터 하나) | 높음(트리거/프롬프트 직접) |
| 무엇을 남길지 제어 | 서버 정책에 위임 | 완전 제어(보존 규칙 지정) |
| 원문 보존 | 압축본만(원문은 사라짐) | 원하면 별도 보관 가능 |
| 적합 상황 | 긴 일반 대화/에이전트 루프 | 도메인 핵심값 보존이 중요한 경우 |
컴팩션의 형제 기능: context editing. 컴팩션이 "요약해서 압축"이라면, context editing은 "오래된 tool 결과나 thinking 블록을 요약 없이 잘라낸다". 에이전트 루프에서 과거 tool 출력이 더 이상 필요 없을 때 트랜스크립트를 가볍게 유지하는 용도다. 둘은 배타적이지 않다 — 많은 장기 실행 에이전트는 context editing(오래된 tool 결과 정리) + compaction(한도 임박 시 요약) + memory(세션 간 영속, 섹션 5)을 동시에 쓴다.
주의: 컴팩션도 결국 "세션 내" 기법이다. 세션이 끝나면 압축본도 사라진다. 세션을 넘어 기억하려면 다음 섹션의 장기 메모리가 필요하다.
5. 장기 메모리 — memory tool로 세션을 넘어 기억시키기
지금까지의 기법(윈도·요약·컴팩션)은 전부 한 세션 안의 일이다. 세션이 끝나면 전부 증발한다. 세션·프로세스 재시작을 넘어 기억하려면 외부 저장소가 필요하고, Claude는 이를 위한 1급 도구로 memory tool을 제공한다.
memory tool은 client-side 도구다. 즉 Claude가 /memories 디렉터리에 대한 읽기/쓰기 명령을 내리면, 실제 저장은 당신이 구현한 백엔드가 처리한다. 명령 종류는 view, create, str_replace, insert, delete, rename.
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
messages=[{"role": "user",
"content": "내가 선호하는 언어는 Python이라는 걸 기억해 둬"}],
tools=[{"type": "memory_20250818", "name": "memory"}],
)
저장 백엔드는 SDK 헬퍼로 구현한다. 파이썬에서는 BetaAbstractMemoryTool을 상속해 각 명령을 당신의 스토리지(로컬 파일, S3, DB 등)에 매핑한다.
from anthropic.lib.tools import BetaAbstractMemoryTool
import pathlib, json
class FileMemory(BetaAbstractMemoryTool):
def __init__(self, root="./memories"):
super().__init__()
self.root = pathlib.Path(root); self.root.mkdir(exist_ok=True)
def _p(self, path): # /memories/foo.md -> ./memories/foo.md (경로 탈출 방지)
rel = path.lstrip("/").removeprefix("memories/")
p = (self.root / rel).resolve()
if not str(p).startswith(str(self.root.resolve())):
raise ValueError("path traversal")
return p
def view(self, command):
p = self._p(command.path)
return p.read_text() if p.exists() else ""
def create(self, command):
p = self._p(command.path); p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(command.file_text); return "created"
def str_replace(self, command):
p = self._p(command.path); t = p.read_text()
p.write_text(t.replace(command.old_str, command.new_str)); return "ok"
def insert(self, command): ...
def delete(self, command): self._p(command.path).unlink(missing_ok=True); return "deleted"
def rename(self, command):
self._p(command.old_path).rename(self._p(command.new_path)); return "renamed"
memory = FileMemory()
runner = client.beta.messages.tool_runner(
model="claude-opus-4-8", max_tokens=4096, tools=[memory],
messages=[{"role": "user", "content": "내 선호를 기억하고, 다음에 참고해"}],
)
for message in runner:
pass # 러너가 memory 명령을 자동으로 백엔드에 위임
장기 메모리의 보안·운영 규칙 (반드시):
- 경로 탈출 방지. memory 경로를 그대로 파일 시스템에 매핑하면
../../etc/...류 공격에 노출된다. 위_p()처럼 root 밖으로 나가지 못하게 정규화·검증하라. - 비밀·PII를 메모리에 쓰지 마라. API 키·토큰·비밀번호는 절대 메모리 파일에 저장 금지. PII는 GDPR/CCPA 등 규제를 확인하고 저장하라. 레퍼런스 구현에는 접근 제어가 없으므로, 멀티유저라면 유저별 메모리 디렉터리 + 인증을 당신 핸들러에서 직접 강제해야 한다.
- 메모리 포맷을 모델에게 가르쳐라. "한 파일에 한 가지 교훈, 맨 위에 한 줄 요약", "중복 만들지 말고 기존 노트를 갱신", "틀린 노트는 삭제" 같은 규칙을 시스템 프롬프트에 명시하면 메모리가 깔끔하게 유지된다.
- 장기 메모리 ≠ 전체 히스토리 덤프. 메모리에는 재사용할 가치가 있는 압축된 사실(선호, 결정, 프로젝트 컨텍스트)만 넣는다. 원문 로그를 통째로 넣으면 그냥 또 다른 비대한 컨텍스트가 된다.
6. 벡터 메모리 — 임베딩 검색(RAG)을 메모리로 쓸 때와 쓰지 말아야 할 때
장기 저장소가 커지면 "전부 다시 컨텍스트에 넣기"가 불가능해진다. 이때 벡터 메모리가 등장한다: 과거 정보를 임베딩해 인덱스에 저장하고, 현재 입력과 시맨틱하게 유사한 조각만 검색해서 컨텍스트에 끼워 넣는다. 사실상 RAG를 메모리 백엔드로 쓰는 것이다.
전형적 파이프라인:
쓰기: 사실/노트 → 임베딩 → (벡터, 원문) 인덱스에 upsert
읽기: 현재 질의 → 임베딩 → top-k 근접 검색 → 원문 k개를 컨텍스트에 주입
# pgvector(Supabase/Postgres) 예시 — 개념 골격
import psycopg
def remember(conn, text: str, embed):
vec = embed(text) # 임베딩 모델 호출(provider 무관)
conn.execute(
"INSERT INTO agent_memory (content, embedding) VALUES (%s, %s)",
(text, vec),
)
def recall(conn, query: str, embed, k: int = 5) -> list[str]:
qvec = embed(query)
rows = conn.execute(
# <=> 는 pgvector 코사인 거리 연산자
"SELECT content FROM agent_memory ORDER BY embedding <=> %s LIMIT %s",
(qvec, k),
).fetchall()
return [r[0] for r in rows]
# 매 턴, 검색 결과를 시스템성 컨텍스트로 주입
relevant = recall(conn, user_text, embed, k=5)
messages = [
{"role": "user",
"content": "[관련 기억]\n" + "\n".join(f"- {r}" for r in relevant)
+ f"\n\n[질문]\n{user_text}"},
]
임베딩 모델은 Cohere, OpenAI, Voyage 등 어느 것이든 쓸 수 있다(Anthropic은 임베딩 엔드포인트 대신 파트너 임베딩 사용을 권장). 인덱스는 pgvector, Pinecone, Qdrant, Weaviate 등.
벡터 메모리를 쓸 때의 현실적 함정:
- 검색 주입은 캐시를 흔든다. 검색 결과를 시스템 프롬프트 앞쪽에 넣으면 매 턴 prefix가 달라져 캐싱이 깨진다. 검색 결과 같은 변동 콘텐츠는 마지막 cache breakpoint 뒤, 즉 user 메시지 쪽에 넣어라.
- top-k는 만능이 아니다. 정확한 사실("내 API 베이스 URL")은 시맨틱 검색이 자주 놓친다. 키워드/정확매치가 필요한 정보는 벡터가 아니라 구조화 저장(섹션 5의 memory tool, 또는 일반 DB 조회)으로 다뤄라. 벡터는 "비슷한 맥락"에 강하고 "정확한 값"에 약하다.
- 벡터 메모리가 항상 정답은 아니다. 데이터가 수십~수백 건 수준이면 임베딩 인덱스를 세우는 것보다, 그냥 전부(또는 요약본)를 컨텍스트에 넣는 게 더 단순하고 정확하다. 벡터는 "컨텍스트에 다 넣기엔 너무 많을 때"의 도구다. 규모가 그 임계를 넘기 전에 인프라를 깔지 마라.
- stale 데이터 관리. 임베딩 인덱스는 갱신/삭제가 누락되기 쉽다. "선호가 바뀌었다"는 사실을 새로 넣기만 하고 옛 레코드를 안 지우면, 검색이 모순된 두 사실을 같이 끌어온다. 쓰기 경로에서 upsert/무효화를 반드시 설계하라.
실무 조합: **memory tool(정확한 사실·구조화) + 벡터(과거 대화·문서의 시맨틱 회상)**를 함께 쓰는 게 가장 견고하다. 둘은 경쟁이 아니라 보완이다.
7. 도구 기반 메모리 패턴 — 모델에게 "기억을 직접 관리"시키기
지금까지는 메모리를 당신이 채우고 비웠다(코드가 윈도를 자르고, 요약을 호출하고, 검색 결과를 주입). 또 다른 축은 모델 스스로 도구를 통해 메모리를 관리하게 하는 것이다. memory tool(섹션 5)이 그 한 형태이고, 더 일반적으로는 custom tool로 "기억해"/"꺼내와"를 노출한다.
from anthropic import beta_tool
@beta_tool
def save_memory(key: str, value: str) -> str:
"""사용자 선호·결정·중요 사실을 영구 저장한다.
재사용할 가치가 있는 사실에만 사용하라 — 잡담은 저장하지 마라.
Args:
key: 짧고 안정적인 식별자 (예: 'preferred_language')
value: 저장할 사실 본문
"""
store[key] = value
return f"saved: {key}"
@beta_tool
def search_memory(query: str) -> str:
"""질의와 관련된 과거 기억을 검색한다. 답이 대화에 없을 때 먼저 호출하라."""
return "\n".join(recall(store, query))
runner = client.beta.messages.tool_runner(
model="claude-opus-4-8", max_tokens=4096,
tools=[save_memory, search_memory],
messages=[{"role": "user", "content": "앞으로 코드 예시는 Python으로만 줘"}],
)
for _ in runner:
pass
도구 설계가 메모리 품질을 좌우한다 — 핵심 베스트 프랙티스:
- description에 언제 호출할지를 박아라. 최근 모델(특히 Opus 4.8)은 도구를 보수적으로 호출한다. "이 도구는 무엇을 한다"만 적으면 호출이 잘 안 된다. "사용자 선호/결정을 들으면 저장하라", "답이 대화에 없으면 먼저 검색하라"처럼 트리거 조건을 명시하면 should-call 비율이 측정 가능하게 올라간다.
- 공격적 명령(
CRITICAL: 반드시)을 남발하지 마라. 같은 4.8급 모델은 시스템 프롬프트를 매우 충실히 따른다. "무조건 저장" 류는 과호출(잡담까지 저장)로 메모리를 오염시킨다. "~할 때"로 조건화하라. - 자율 루프에서는 메모리 사용을 명시적으로 권하라. Opus 4.8은 기본적으로 메모리·서브에이전트 같은 "의식적으로 결정해야 하는" 기능을 덜 쓴다. "몇 턴 이상 걸리는 작업 전에는 메모리 파일을 확인하고, 진행하며 새 발견을 기록하라" 같은 지시를 주면 활용도가 올라간다.
- manual loop vs tool runner. tool runner(
tool_runner)는 호출→실행→결과 피드백 루프를 자동으로 돈다. 사람 승인(human-in-the-loop)이나 조건부 실행이 필요하면 manual agentic loop로 직접stop_reason == "tool_use"를 보고 실행하라. 메모리 쓰기는 부수효과가 있으므로, 민감 환경에서는 manual loop로 쓰기 전 검증을 끼우는 게 안전하다.
도구 기반 메모리의 장점은 모델이 무엇을 기억할지 스스로 판단한다는 점이고, 단점은 그 판단이 틀릴 수 있다는 점이다. 그래서 description 튜닝과 (필요하면) 쓰기 게이트가 중요하다.
8. 토큰 예산 측정과 비용 — count_tokens, usage, 그리고 캐시 검증
메모리 설계는 "감"이 아니라 "측정"으로 한다. 추정용 토크나이저(tiktoken 등)는 Claude 토큰을 15~20% 이상 어긋나게 세므로 쓰지 마라. count_tokens 엔드포인트가 정답이다 — 추론에 쓸 모델 ID와 동일하게 호출해야 한다(토큰 수는 모델별로 다르다).
# 현재 메모리 상태가 컨텍스트 예산을 얼마나 먹는지 측정
count = client.messages.count_tokens(
model="claude-opus-4-8",
system=system_prompt,
messages=conversation,
)
print(count.input_tokens)
# 컴팩션/요약 트리거를 이 측정값에 연동
if count.input_tokens > 0.7 * CONTEXT_LIMIT:
memory.maybe_compact()
응답의 usage로 메모리 경제성을 실시간 검증하라:
resp = client.messages.create(...)
u = resp.usage
print(u.input_tokens) # 캐시 안 탄, 풀 가격 입력
print(u.cache_creation_input_tokens) # 캐시에 쓴 양 (~1.25x)
print(u.cache_read_input_tokens) # 캐시에서 읽은 양 (~0.1x)
# 전체 프롬프트 크기 = 위 셋의 합. input_tokens만 보면 착시가 생긴다.
cache_read_input_tokens가 동일 prefix 반복 요청에서 0이라면 어딘가 silent invalidator가 있다는 뜻이다(섹션 2 표 참조). 두 요청의 렌더된 프롬프트 바이트를 diff해서 범인을 찾아라.
모델·컨텍스트 윈도 사실 (2026-06 기준, 변동 가능):
| 모델 | 컨텍스트 윈도 | 비고 |
|---|---|---|
| Claude Opus 4.8 / 4.7 / 4.6 | 1M | 장기 컨텍스트 프리미엄 없음(표준가) |
| Claude Sonnet 4.6 | 1M | 속도/지능 균형 |
| Claude Haiku 4.5 | 200K | 저비용·고속, 요약/분류용 |
정확한 런타임 값은 코드에서 직접 조회하는 게 안전하다 — 가이드의 표는 캐시된 값이다.
m = client.models.retrieve("claude-opus-4-8")
print(m.max_input_tokens, m.max_tokens) # 컨텍스트 윈도, 최대 출력
비용 관점 메모리 설계 원칙:
- 캐시 쓰기는 5분 TTL 기준 ~1.25배, 읽기는 ~0.1배. 같은 prefix를 2회 이상 재사용하면 캐싱이 이득이다(손익분기 ≈ 2요청). 멀티턴 에이전트는 거의 항상 이득.
- 요약/분류 같은 보조 메모리 작업은 Haiku 4.5로 내려 비용을 줄이되, 메인 루프와 모델을 섞으면 캐시가 깨지므로 서브에이전트로 분리하라.
- 5분 캐시 TTL이 트래픽 간격보다 짧으면 캐시가 식는다. 버스트성 트래픽이면 1시간 TTL(
{"type": "ephemeral", "ttl": "1h"}, 쓰기 ~2배)을 검토하라. 다만 1시간 TTL은 손익분기가 ~3요청으로 올라간다.
9. 4개 층위를 하나로 합친 참조 아키텍처
실전 에이전트는 한 층위만 쓰지 않는다. 아래는 4개 층위를 결합한 참조 매니저다. 매 요청의 컨텍스트를 "예산 배분"으로 구성한다.
class AgentMemory:
"""단기(윈도) + 요약 + 장기(memory tool) + 벡터(검색)을 한 컨텍스트로 조립."""
def __init__(self, client, model="claude-opus-4-8",
keep_recent=12, compact_at=0.7):
self.client = client
self.model = model
self.keep_recent = keep_recent # 단기: 원문 유지 턴 수
self.compact_at = compact_at # 컴팩션 트리거(윈도 대비 비율)
self.messages = [] # 단기 원문
self.summary = "" # 요약 메모리
self.ctx_limit = client.models.retrieve(model).max_input_tokens
def build_request(self, user_text, vector_recall, long_term_facts):
# 1) 안정적 시스템 프롬프트 — 캐시 prefix (절대 동적 값 금지)
system = [{"type": "text", "text": STABLE_SYSTEM,
"cache_control": {"type": "ephemeral"}}]
# 2) 변동 콘텐츠(요약·벡터검색·장기사실)는 user 쪽, 캐시 breakpoint 뒤로
preamble = []
if long_term_facts: # 장기 메모리(정확한 사실)
preamble.append("[기억된 사실]\n" + long_term_facts)
if self.summary: # 요약 메모리(압축된 과거)
preamble.append("[이전 대화 요약]\n" + self.summary)
if vector_recall: # 벡터 메모리(시맨틱 회상)
preamble.append("[관련 기억]\n" + "\n".join(vector_recall))
head = []
if preamble:
head = [{"role": "user", "content": "\n\n".join(preamble)}]
# 3) 단기 원문 윈도 + 현재 입력
window = self._window()
msgs = head + window + [{"role": "user", "content": user_text}]
return system, msgs
def _window(self):
win = self.messages[-self.keep_recent:]
while win and win[0]["role"] != "user":
win = win[1:]
return win
def remember_turn(self, role, content):
self.messages.append({"role": role, "content": content})
요청 흐름(매 턴):
user_text임베딩 → 벡터 인덱스 검색(vector_recall).- 정확매치가 필요한 사실은 memory tool/DB에서 조회(
long_term_facts). build_request로 컨텍스트 조립 — 고정은 system(캐시 prefix), 변동은 user 쪽 뒤.- 토큰 측정 →
compact_at초과면 컴팩션(섹션 4) 또는 요약(섹션 3). - 응답의
content전체를 단기 메모리에 누적(컴팩션 쓸 땐 필수). - 모델이
save_memory도구를 호출하면 장기/벡터 저장소에 반영.
층위별 역할 분담 정리:
| 정보 종류 | 어디에 | 왜 |
|---|---|---|
| 직전 몇 턴의 원문 | 단기 윈도 | 즉각 맥락, 정확성 |
| 길어진 과거 대화 | 요약/컴팩션 | 토큰 절약, 흐름 보존 |
| 사용자 선호·결정·ID·경로 | 장기(memory tool/DB) | 정확매치, 세션 간 영속 |
| 과거 문서·대화의 "비슷한 맥락" | 벡터 인덱스 | 대규모 시맨틱 회상 |
이 분담을 어기면 전형적 사고가 난다 — 정확한 ID를 벡터에 넣어 못 찾거나, 원문 로그를 장기 메모리에 통째로 넣어 또 다른 비대 컨텍스트를 만들거나.
10. 흔한 함정 모음 (실전 디버깅 체크리스트)
메모리 시스템에서 반복적으로 나오는 버그들을 증상 기준으로 정리한다. 새 에이전트를 짤 때 이 목록을 먼저 훑어라.
증상: 비용이 턴 수에 비례해 폭발한다.
- 원인: 프롬프트 캐싱이 안 먹고 있다.
usage.cache_read_input_tokens가 0인지 확인. - 점검: 시스템 프롬프트에
datetime.now()·UUID·세션ID가 있는지,json.dumps에sort_keys=True가 빠졌는지, 대화 중간에 tool 목록/모델을 바꿨는지.
증상: 컴팩션을 켰는데 다음 턴부터 동작이 이상하다.
- 원인: 응답에서
text만 추출해messages에 넣고compaction블록을 버렸다. - 해결:
messages.append({"role": "assistant", "content": resp.content})— content 전체를 넣는다.
증상: 에이전트가 어제 한 일을 못 기억한다.
- 원인: 윈도·요약·컴팩션만 썼다 — 전부 세션 내 기법. 세션이 끝나면 사라진다.
- 해결: memory tool(섹션 5) 또는 외부 DB로 세션 간 영속 추가.
증상: 요약 후 중요한 ID·경로·수치가 사라졌다.
- 원인: 요약 모델이 "중요해 보이는 것"만 남기며 정확한 값을 버렸다.
- 해결: 요약 시스템 프롬프트에 "고유명사·ID·경로·결정은 반드시 보존"을 명시. 원문은 별도 저장.
증상: 벡터 검색이 "정확한 사실"을 못 찾는다.
- 원인: 시맨틱 검색은 정확매치에 약하다. 정확한 값을 벡터에만 의존했다.
- 해결: 정확매치가 필요한 사실은 구조화 저장(memory tool/DB)으로, 벡터는 "비슷한 맥락"에만.
증상: 메모리에 모순된 두 사실이 같이 검색된다.
- 원인: 사실이 바뀔 때 새 레코드만 넣고 옛 레코드를 무효화하지 않았다.
- 해결: 쓰기 경로에서 upsert/삭제를 설계. "선호 변경"은 추가가 아니라 갱신.
증상: 멀티유저인데 다른 유저의 기억이 섞인다.
- 원인: memory tool 레퍼런스 구현에는 접근 제어가 없다. 유저별 격리를 안 했다.
- 해결: 유저별 메모리 디렉터리/네임스페이스 + 인증을 당신의 핸들러에서 강제. 경로 탈출 방지(
../차단)도 필수.
증상: 모델이 메모리 도구를 거의 안 쓴다.
- 원인: 최근 모델은 도구를 보수적으로 호출한다. description이 "무엇을 한다"만 적혀 있다.
- 해결: description과 시스템 프롬프트에 언제 호출할지("답이 대화에 없으면 먼저 검색")를 명시.
CRITICAL: 반드시는 과호출을 유발하니 조건화하라.
증상: 컨텍스트가 차서 요청이 죽거나 model_context_window_exceeded가 뜬다.
- 원인: 트리거 없이 무한 누적했다.
- 해결:
count_tokens기반 임계치(예: 윈도의 70%)에서 컴팩션/요약을 자동 발동.stop_reason도 분기 처리.
마지막 원칙: 메모리는 단순한 것부터 시작하라. 윈도+캐싱으로 충분한 워크로드에 벡터 인덱스를 깔지 마라. 데이터가 "컨텍스트에 다 넣기엔 너무 많다"는 임계를 실제로 넘은 뒤에 한 층위씩 올려라. 각 층위는 추가 복잡도와 새로운 실패 모드를 함께 들여온다.