Blog
ai-agentllmmemorycontext-windowcompactionprompt-cachingpythonanthropic

에이전트 메모리 컴팩션, 직접 구현하기 — 멀티턴 루프부터 캐시·장기 메모리까지

입문편이 '왜'를 다뤘다면, 이 글은 '어떻게'입니다. 멀티턴 루프, 토큰 측정, 임계치 기반 컴팩션 엔진, 도구 출력 축약, 프롬프트 캐싱과의 상호작용, 그리고 장기 메모리까지 Anthropic SDK 기반 파이썬 코드와 그림으로 한 줄씩 구현합니다.

Data Dynamics2026년 6월 25일28 min read

이 글은 AI 에이전트는 어떻게 기억할까? — 멀티턴과 메모리 컴팩션 쉽게 이해하기심화편입니다. 입문편에서 "책상(컨텍스트 창)", "회의록 요약(컴팩션)", "서랍(장기 메모리)"이라는 비유로 그런 설계가 필요한지를 짚었다면, 이번 글은 그 비유들을 실제로 동작하는 코드로 옮깁니다.

비유는 더 이상 쓰지 않습니다. 대신 멀티턴 루프 한 벌을 짜고, 토큰을 실제로 측정하고, 임계치에 닿으면 발동하는 컴팩션 엔진을 붙이고, 도구 출력을 축약하고, 프롬프트 캐싱과 충돌하지 않게 배치하고, 마지막으로 세션을 넘는 장기 메모리까지 얹습니다. 코드는 Anthropic Python SDK(anthropic)와 Claude 모델 기준이지만, 구조 자체는 어떤 LLM API에도 그대로 옮길 수 있습니다.

이 글에서 만드는 것

  • 메시지 배열을 상태로 굴리는 멀티턴 루프
  • count_tokens실제 점유량을 재는 계측
  • 임계치에서 발동하는 컴팩션 엔진(분할 → 요약 → 치환)
  • 양식 강제 요약 프롬프트와 검증
  • 거대한 도구 출력을 결론 + 포인터로 축약
  • 프롬프트 캐싱을 깨지 않는 컴팩션 배치
  • 세션을 넘기는 파일형 / 검색형 장기 메모리
  • 위 전부를 묶은 Agent 클래스 한 벌

전제: 입문편의 핵심 한 줄 — "LLM은 stateless다. 멀티턴은 매 턴 대화 전체를 다시 보내는 것이고, 컴팩션은 오래된 부분을 요약으로 바꿔 끼우는 것이다." 이 문장이 이 글 전체의 골격입니다.


1. 상태는 메시지 배열 하나다

가장 먼저 분명히 할 것: 에이전트의 "기억"은 특별한 자료구조가 아니라 메시지 배열(list) 하나입니다. 매 턴 우리는 이 배열에 사용자 메시지를 덧붙이고, 배열 전체를 모델에 보내고, 돌아온 답을 다시 배열에 덧붙입니다.

import anthropic
 
client = anthropic.Anthropic()  # ANTHROPIC_API_KEY 환경변수 사용
 
MODEL = "claude-sonnet-4-6"
SYSTEM = "너는 데이터 플랫폼 운영을 돕는 시니어 엔지니어다. 한국어로 간결하게 답한다."
 
# 이 리스트가 곧 '단기 기억'이다. 그 이상도 이하도 아니다.
messages: list[dict] = []
 
def chat(user_text: str) -> str:
    messages.append({"role": "user", "content": user_text})
 
    resp = client.messages.create(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM,
        messages=messages,          # ← 매 턴 '전체'를 다시 보낸다
    )
 
    assistant_text = "".join(
        block.text for block in resp.content if block.type == "text"
    )
    messages.append({"role": "assistant", "content": assistant_text})
    return assistant_text

여기서 입문편의 핵심 그림이 코드로 그대로 드러납니다. messages는 누적되고, create() 호출은 매번 그 전체를 인자로 받습니다. 모델은 상태를 갖지 않으므로, 상태를 들고 있는 주체는 오직 이 messages 리스트입니다.

Loading diagram…

문제는 이 루프를 그냥 두면 messages무한히 자란다는 것입니다. 도구를 쓰는 에이전트라면 더 빨리 자랍니다. 그래서 우리는 이 배열의 크기를 측정하고, 일정 선을 넘으면 압축해야 합니다. 측정부터 봅시다.


2. 토큰을 추측하지 말고 측정하라

컴팩션을 언제 발동할지 정하려면 "지금 이 messages가 컨텍스트 창의 몇 %를 먹고 있는가"를 알아야 합니다. 글자 수나 단어 수로 어림하면 도구 출력·코드·다국어가 섞일 때 크게 빗나갑니다. 다행히 Anthropic SDK는 요청을 실제로 보내지 않고 토큰만 세어 주는 엔드포인트를 제공합니다.

def count_tokens(messages: list[dict], system: str, tools: list | None = None) -> int:
    res = client.messages.count_tokens(
        model=MODEL,
        system=system,
        messages=messages,
        tools=tools or [],
    )
    return res.input_tokens

count_tokens는 과금 없이(별도 토큰 카운트 한도만 사용) 호출 전 점유량을 알려 줍니다. 시스템 프롬프트와 도구 정의(JSON 스키마)까지 포함해 세 주는 게 중요합니다 — 도구 스키마는 의외로 무겁고, 매 턴 항상 붙기 때문입니다.

실무 팁: 매 턴 count_tokens를 호출하면 네트워크 왕복이 한 번 더 생깁니다. 그래서 보통은 응답에 들어 있는 usage를 1차 신호로 쓰고, 임계치 근처에서만 count_tokens로 정밀 확인합니다.

# create() 응답에는 항상 usage가 들어 있다 — 추가 호출 없이 점유량을 안다.
resp = client.messages.create(...)
used = resp.usage.input_tokens + resp.usage.output_tokens

컨텍스트 창 크기(예: 200K, 모델에 따라 1M)를 분모로 두면 점유율이 나옵니다. 이 점유율이 컴팩션 트리거의 입력입니다.

CONTEXT_WINDOW = 200_000   # 사용하는 모델의 컨텍스트 창
COMPACT_AT = 0.75          # 75% 차면 컴팩션
KEEP_RECENT_TOKENS = 20_000  # 최근 이만큼은 원문 보존

왜 100%가 아니라 75%인가? 컴팩션 자체도 모델 호출이고, 그 호출에도 입력 토큰이 든다. 가득 찬 다음에 압축하려 하면 압축 요청을 보낼 자리조차 없다. 항상 여유를 두고 발동해야 한다.


3. 컴팩션 엔진: 분할 → 요약 → 치환

이제 핵심입니다. 컴팩션은 세 단계로 쪼개집니다.

  1. 분할(split): messages를 "오래된 부분"과 "최근 부분"으로 가른다. 최근 부분은 절대 건드리지 않는다.
  2. 요약(summarize): 오래된 부분을 모델에게 양식에 맞춰 요약시킨다.
  3. 치환(replace): 오래된 부분을 통째로 비우고, 그 자리에 요약 한 덩어리를 끼운다.
Loading diagram…

3-1. 분할: 무엇이 '최근'인가

가장 단순하면서 안전한 기준은 뒤에서부터 토큰을 누적해, 보존하기로 한 양(KEEP_RECENT_TOKENS)에 닿을 때까지를 '최근'으로 잡는 것입니다. 턴 개수가 아니라 토큰으로 자르는 이유는, 한 턴이 거대한 도구 출력 하나로 수만 토큰을 먹을 수도 있기 때문입니다.

def split_old_recent(messages: list[dict], keep_recent_tokens: int):
    """뒤에서부터 누적하여 최근 부분을 확보하고, 나머지를 오래된 부분으로 반환."""
    recent: list[dict] = []
    acc = 0
    # 뒤(최근)에서 앞(과거)으로 훑는다
    for msg in reversed(messages):
        t = count_tokens([msg], system="")  # 메시지 단위 근사 측정
        if acc + t > keep_recent_tokens and recent:
            break
        recent.append(msg)
        acc += t
    recent.reverse()
    split_idx = len(messages) - len(recent)
    old = messages[:split_idx]
    return old, recent

한 가지 함정: 도구를 쓰는 에이전트에서는 assistant의 도구 호출(tool_use)과 그에 대한 user의 도구 결과(tool_result)가 을 이뤄야 합니다. 분할선이 이 짝의 한가운데를 지나면 API가 거부합니다. 그래서 분할 직후에는 경계를 한 칸 밀어 짝을 보존하는 보정이 필요합니다.

def fix_tool_boundary(old: list[dict], recent: list[dict]):
    """recent의 첫 메시지가 tool_result로 시작하면, 짝이 되는 tool_use가
    old의 마지막에 있다는 뜻이다. 경계를 한 턴 당겨 짝을 보존한다."""
    while recent and _starts_with_tool_result(recent[0]):
        old = old + [recent.pop(0)]  # 어차피 이 한 줄도 곧 요약된다
    return old, recent
 
def _starts_with_tool_result(msg: dict) -> bool:
    content = msg.get("content")
    if isinstance(content, list) and content:
        return content[0].get("type") == "tool_result"
    return False

3-2. 요약: 자유 작문 금지, 양식 강제

입문편에서 강조한 "빈칸 채우기 양식"을 실제 프롬프트로 옮깁니다. 핵심은 모델에게 자유 서술을 허용하지 않는 것입니다. 항목을 고정하고, 빈 항목은 "없음"으로 명시하게 합니다.

COMPACT_PROMPT = """\
아래는 한 에이전트 세션의 지난 대화 기록이다. 이어서 작업을 계속할 수 있도록
핵심만 추려 '인수인계 메모'를 작성하라. 반드시 아래 항목 구조를 그대로 따르고,
해당 없는 항목은 "없음"이라고 적어라. 추측·창작 금지, 기록에 있는 사실만.
 
## 사용자의 목표
## 확정된 결정·제약·선호
## 현재 진행 중인 작업(가장 중요)
## 알아낸 핵심 사실(이름/경로/버전/수치/식별자)
## 시도했다 폐기한 막다른 길(반복 방지용)
## 다음에 할 일
"""
 
def summarize_old(old: list[dict]) -> str:
    res = client.messages.create(
        model=MODEL,
        max_tokens=2048,
        system="너는 대화 기록을 인수인계 메모로 압축하는 요약기다.",
        messages=old + [{"role": "user", "content": COMPACT_PROMPT}],
    )
    return "".join(b.text for b in res.content if b.type == "text")

요약을 받은 뒤에는 검증을 한 단계 둡니다. "현재 진행 중인 작업"이 비어 버리는 사고가 가장 위험하므로, 그 항목이 "없음"으로 채워졌는지 가볍게 확인하고 필요하면 한 번 재요청합니다.

def validate_summary(text: str) -> bool:
    # 양식이 유지됐고, 진행 중 작업 칸이 통째로 비지 않았는지 최소 확인
    must_have = ["## 현재 진행 중인 작업", "## 다음에 할 일"]
    return all(h in text for h in must_have)

3-3. 치환: 요약을 대화에 다시 심기

요약본은 한 개의 user 메시지로 배열 맨 앞에 심는 게 가장 단순하고 안전합니다. 모델이 "이건 이전 대화의 압축본"임을 알도록 명확한 표식을 붙입니다.

def compact(messages: list[dict]) -> list[dict]:
    old, recent = split_old_recent(messages, KEEP_RECENT_TOKENS)
    if not old:
        return messages  # 압축할 과거가 없음
 
    old, recent = fix_tool_boundary(old, recent)
    summary = summarize_old(old)
    if not validate_summary(summary):
        summary = summarize_old(old)  # 1회 재시도
 
    marker = (
        "[이전 대화 요약 — 원문은 컨텍스트 절약을 위해 압축됨]\n\n" + summary
    )
    return [{"role": "user", "content": marker}] + recent

이 세 함수가 입문편의 "회의록 요약" 그림을 한 치의 비유 없이 그대로 실행합니다. old(길고 장황한 과거) → summary(짧은 한 장) → [summary] + recent(정리된 책상).


4. 도구 출력은 들어올 때 줄여라

입문편에서 "책상을 가장 많이 먹는 건 도구 출력"이라고 했습니다. 컴팩션이 사후 정리라면, 도구 출력 축약은 사전 예방입니다. 200줄 파일을 읽었으면, 그 200줄을 배열에 그대로 넣지 말고 결론 + 다시 읽을 위치 포인터만 남깁니다.

Loading diagram…
TOOL_RESULT_MAX_CHARS = 4000
 
def shrink_tool_result(name: str, args: dict, raw: str) -> str:
    if len(raw) <= TOOL_RESULT_MAX_CHARS:
        return raw
    head = raw[:1500]
    tail = raw[-1500:]
    pointer = f"{name}({args})"  # 동일 인자로 다시 부르면 전체를 재취득 가능
    return (
        f"{head}\n"
        f"... [중략: 전체 {len(raw)}자 중 일부만 보존. "
        f"전체가 다시 필요하면 `{pointer}`를 재실행할 것] ...\n"
        f"{tail}"
    )

여기엔 중요한 설계 원칙이 숨어 있습니다. 버리는 게 아니라 "다시 가져올 수 있게" 버린다. 포인터(도구 이름 + 인자)를 남겨 두면, 모델은 필요할 때 같은 도구를 같은 인자로 다시 호출해 원문을 복구할 수 있습니다. 컨텍스트는 캐시가 아니라 작업대이고, 작업대는 비울 수 있어야 합니다.

도구 루프에 이 축약을 끼우면 다음과 같습니다.

def run_tool_and_record(messages, tool_use_block, registry):
    name = tool_use_block.name
    args = tool_use_block.input
    raw = registry[name](**args)              # 실제 도구 실행
    shrunk = shrink_tool_result(name, args, raw)
    messages.append({
        "role": "user",
        "content": [{
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": shrunk,                # ← 축약본을 기억에 넣는다
        }],
    })

사전 축약(도구 출력)과 사후 압축(컴팩션)은 둘 다 필요합니다. 전자는 폭증을 막고, 후자는 누적을 정리합니다. 하나만으로는 긴 세션을 버티지 못합니다.


5. 프롬프트 캐싱과 컴팩션은 정면충돌한다

입문편 마지막에서 예고한 함정을 코드로 정확히 짚습니다. Anthropic의 프롬프트 캐싱은 메시지 앞쪽의 변하지 않는 구간을 재사용해 비용·지연을 크게 줄입니다. cache_control 표식을 붙인 블록까지를 캐시 경계(prefix)로 잡습니다.

resp = client.messages.create(
    model=MODEL,
    max_tokens=1024,
    system=[
        {"type": "text", "text": SYSTEM},
        # 시스템 프롬프트는 거의 안 변하므로 캐시하기 좋다
        {"type": "text", "text": LONG_STABLE_GUIDE,
         "cache_control": {"type": "ephemeral"}},
    ],
    messages=messages,
)
# 캐시 효과는 usage로 확인한다
print(resp.usage.cache_creation_input_tokens,  # 이번에 새로 캐시한 양
      resp.usage.cache_read_input_tokens)       # 캐시에서 재사용한 양

문제는 명확합니다. 캐시는 "앞부분이 그대로일 때" 듣는데, 컴팩션은 바로 그 앞부분(오래된 메시지)을 통째로 교체합니다. 컴팩션이 일어난 턴은 캐시가 무효화되어, 그 한 번은 비싸고 느려집니다.

Loading diagram…

여기서 두 가지 실전 규칙이 나옵니다.

  • 자주 조금씩 압축하지 마라. 매 턴 앞부분을 손대면 캐시가 매번 깨진다. 임계치에서 한 번에 크게 압축해, 그 뒤 여러 턴 동안 새 prefix로 캐시를 다시 태우는 편이 총비용이 낮다.
  • 캐시 경계를 컴팩션 경계와 맞춰라. 컴팩션 직후, 새로 만든 [요약] + 최근 구조의 요약 블록 끝에 cache_control을 찍어 두면, 이어지는 턴들이 이 안정된 요약을 캐시로 재사용한다.
def with_cache_breakpoint(messages: list[dict]) -> list[dict]:
    """맨 앞 요약 메시지에 캐시 경계를 부여한다(컴팩션 직후 호출)."""
    if not messages:
        return messages
    first = messages[0]
    # content를 블록 리스트로 정규화한 뒤 마지막 블록에 cache_control 부여
    blocks = (first["content"] if isinstance(first["content"], list)
              else [{"type": "text", "text": first["content"]}])
    blocks[-1] = {**blocks[-1], "cache_control": {"type": "ephemeral"}}
    return [{**first, "content": blocks}] + messages[1:]

정리: 컴팩션은 캐시를 한 번 버리는 대가로 컨텍스트를 비운다. 그러니 그 한 번을 드물고 크게 만들고, 비운 직후의 새 prefix를 곧장 캐시 대상으로 지정해 회수 비용을 빨리 상쇄한다.


6. 세션을 넘기는 장기 메모리

지금까지는 한 세션 안의 단기 기억(messages)을 다뤘습니다. 세션이 끝나면 이 배열은 사라집니다. 입문편의 "서랍"을 구현할 차례입니다.

6-1. 파일형(사실 메모) — 가장 단순하고 강력

핵심 사실을 작은 파일로 적어 두고, 다음 세션 시작 때 시스템 프롬프트에 깔아 줍니다. "한 파일 = 한 사실" 구조면 갱신·삭제가 깔끔합니다(이 블로그를 만드는 도구의 메모리가 정확히 이 방식입니다).

import json, pathlib
 
MEM_DIR = pathlib.Path("./agent_memory")
MEM_DIR.mkdir(exist_ok=True)
 
def remember(key: str, fact: str):
    (MEM_DIR / f"{key}.json").write_text(
        json.dumps({"key": key, "fact": fact}, ensure_ascii=False))
 
def load_long_term() -> str:
    facts = []
    for f in sorted(MEM_DIR.glob("*.json")):
        d = json.loads(f.read_text())
        facts.append(f"- {d['fact']}")
    return "\n".join(facts)
 
# 세션 시작 시: 장기 기억을 시스템 프롬프트에 주입
def build_system() -> str:
    longterm = load_long_term()
    if not longterm:
        return SYSTEM
    return SYSTEM + "\n\n[이전 세션에서 알게 된 사실]\n" + longterm

remember()는 컴팩션 단계에서 함께 호출하면 자연스럽습니다 — 요약을 만들 때 "다음 세션까지 보존할 사실"을 따로 뽑아 파일로 떨어뜨리는 식입니다. 단기 기억(책상)에서 장기 기억(서랍)으로 승격시키는 셈입니다.

Loading diagram…

6-2. 검색형(RAG 메모리) — 양이 방대할 때

사실이 수천 개로 불어나면 전부를 시스템 프롬프트에 깔 수 없습니다. 그때는 외부 저장소(벡터 DB 등)에 넣고, 현재 질문과 관련된 조각만 검색해 그 턴에만 책상에 올립니다.

def recall_relevant(query: str, k: int = 5) -> str:
    hits = vector_store.search(embed(query), top_k=k)  # 임베딩 유사도 검색
    return "\n".join(f"- {h.text}" for h in hits)
 
def chat_with_recall(user_text: str):
    # 이번 턴에만 필요한 기억을 끌어와 일시적으로 끼운다
    relevant = recall_relevant(user_text)
    augmented = user_text
    if relevant:
        augmented = f"[참고 기억]\n{relevant}\n\n[질문]\n{user_text}"
    messages.append({"role": "user", "content": augmented})
    # ... 이하 동일

파일형과 검색형의 공통 철학은 하나입니다: 컨텍스트 창은 비싸고 작으니, 평소엔 밖에 두고 필요한 것만 그때그때 올린다. 파일형은 "항상 깔아도 될 만큼 작은 핵심"에, 검색형은 "방대해서 골라 써야 하는 것"에 맞습니다. 실무에서는 둘을 섞어 씁니다.


7. 전부 묶기 — Agent 클래스 한 벌

지금까지의 조각을 하나로 엮으면, 긴 세션을 견디는 최소한의 에이전트가 됩니다. 골격만 추리면 다음과 같습니다.

class CompactingAgent:
    def __init__(self, tools: dict):
        self.tools = tools
        self.messages: list[dict] = []
 
    def step(self, user_text: str) -> str:
        self.messages.append({"role": "user", "content": user_text})
 
        while True:
            resp = client.messages.create(
                model=MODEL,
                max_tokens=2048,
                system=build_system(),                 # 장기 기억 주입
                messages=self.messages,
                tools=[t.schema for t in self.tools.values()],
            )
            self.messages.append({"role": "assistant", "content": resp.content})
 
            if resp.stop_reason == "tool_use":
                for block in resp.content:
                    if block.type == "tool_use":
                        run_tool_and_record(self.messages, block, self.tools)
                self._maybe_compact()                  # 도구 후에도 점검
                continue                               # 도구 결과로 한 번 더
 
            self._maybe_compact()                      # 턴 종료 후 점검
            return "".join(b.text for b in resp.content if b.type == "text")
 
    def _maybe_compact(self):
        used = count_tokens(self.messages, build_system(),
                            [t.schema for t in self.tools.values()])
        if used / CONTEXT_WINDOW > COMPACT_AT:
            self.messages = compact(self.messages)
            self.messages = with_cache_breakpoint(self.messages)

전체 제어 흐름을 한 장으로 보면 이렇습니다. 입문편의 모든 비유가 이 다이어그램의 박스 하나하나로 환원됩니다.

Loading diagram…

8. 운영에서 데지 않으려면 — 테스트와 체크리스트

컴팩션은 "조용히" 망가지는 부류의 기능입니다. 잘못 버려도 에러가 나지 않고, 그냥 에이전트가 조금 멍청해질 뿐이라 발견이 늦습니다. 그래서 다음을 자동 테스트로 박아 두길 권합니다.

  • 결정 보존 테스트: 대화 초반에 "파일은 절대 건드리지 마"라는 제약을 주고, 컴팩션을 강제로 한 번 돌린 뒤, 그 제약이 요약에 남아 있는지 검사한다.
  • 도구 짝 무결성 테스트: tool_use / tool_result 쌍의 한가운데를 지나는 분할을 일부러 만들고, fix_tool_boundary 후 API가 거부하지 않는지 검사한다.
  • 포인터 복구 테스트: 도구 출력을 축약한 뒤, 모델이 포인터를 보고 같은 도구를 재호출해 원문을 복구할 수 있는지 확인한다.
  • 캐시 회계 테스트: 컴팩션 직후 턴은 cache_read_input_tokens가 떨어지고, 그 다음 턴부터 다시 오르는지 usage로 확인한다.

마지막으로 구현 체크리스트입니다(입문편 체크리스트의 코드 대응판).

  • 상태를 메시지 배열 하나로 단순하게 유지하는가
  • 점유율을 글자 수가 아니라 토큰으로 측정하는가
  • 컴팩션을 임계치(70~80%)에서, 크게 한 번 발동하는가
  • 최근 구간을 토큰 기준으로 원문 보존하고, 도구 짝을 깨지 않는가
  • 요약을 양식으로 강제하고 핵심 항목을 검증하는가
  • 도구 출력을 결론 + 재취득 포인터로 축약하는가
  • 컴팩션 직후 캐시 경계를 새 prefix에 다시 찍는가
  • 세션을 넘길 사실을 장기 메모리로 승격하는가

마치며

입문편이 "에이전트의 기억은 마법이 아니라 정리 정돈"이라고 했다면, 이 글은 그 정리 정돈을 함수 몇 개로 분해해 보였습니다. count_tokens로 재고, split → summarize → replace로 압축하고, 도구 출력은 들어올 때 줄이고, 캐시 경계를 컴팩션 경계에 맞추고, 보존할 것은 서랍으로 승격한다 — 이게 전부입니다.

화려한 자료구조도, 비밀스러운 모델 내부도 없습니다. stateless한 모델 위에, 상태를 들고 있는 부지런한 루프 하나. 좋은 에이전트 메모리는 결국 이 루프를 얼마나 규율 있게 짰느냐의 문제입니다.

다음 글 예고: 이 시리즈는 곧 장기 메모리를 RAG로 본격 구현하기(임베딩·청킹·재랭킹)와, 여러 에이전트가 기억을 공유하는 멀티 에이전트 메모리를 같은 코드 수준으로 다룹니다.