왜 메모리 분리가 문제가 되는가

LLM 에이전트를 오래 돌리다 보면 컨텍스트 윈도우가 한계에 부딪힌다. 대화가 길어질수록 토큰 비용이 선형으로 늘고, 오래된 지시가 최신 지시를 밀어내며, 관련 없는 잡담이 검색 품질을 떨어뜨린다. 이걸 프롬프트 하나에 다 밀어넣으면 비용과 정확도가 동시에 무너진다.

해법은 메모리를 단기(working)와 장기(long-term)로 분리하는 것이다. 단기는 현재 태스크에 필요한 최근 턴을, 장기는 세션을 넘어 재사용할 사실·선호·요약을 담는다. 각각 저장소와 수명 관리 전략이 다르다.

두 계층의 요구사항 비교

구분단기 메모리장기 메모리
수명세션/태스크 단위영속
접근 패턴최근성 기반 순차의미 기반 검색
저장소Redis, 인메모리 버퍼벡터 DB + RDB
주 비용토큰임베딩·스토리지
축출 전략슬라이딩 윈도우, 요약중요도·TTL·중복 제거

단기 메모리: 요약 압축 버퍼

단순 슬라이딩 윈도우는 오래된 맥락을 그냥 버린다. 실무에서는 임계치를 넘으면 오래된 턴을 요약해 요약 슬롯에 접어 넣는 방식이 효과적이다.

class SummarizingBuffer:
    def __init__(self, max_tokens=3000, keep_recent=6):
        self.summary = ""
        self.turns = []          # 최근 원본 턴
        self.max_tokens = max_tokens
        self.keep_recent = keep_recent

    def add(self, role, content):
        self.turns.append({"role": role, "content": content})
        if self._token_count() > self.max_tokens:
            old = self.turns[:-self.keep_recent]
            self.turns = self.turns[-self.keep_recent:]
            self.summary = summarize(self.summary, old)  # LLM 호출

    def render(self):
        head = [{"role": "system", "content": f"이전 요약: {self.summary}"}]
        return head + self.turns

핵심은 keep_recent로 원본 턴을 일부 남기는 것이다. 전부 요약하면 지시의 뉘앙스가 사라져 에이전트가 미묘하게 오작동한다.

장기 메모리: 벡터 검색과 메타데이터

장기 메모리는 "무엇을 저장하느냐"보다 "무엇을 꺼내느냐"가 어렵다. 임베딩 유사도만으로는 오래되고 틀린 사실이 계속 상위에 올라온다. 그래서 벡터 유사도에 최근성·중요도 가중을 곱하고, 메타데이터로 필터링한다.

CREATE TABLE memories (
    id          UUID PRIMARY KEY,
    user_id     TEXT NOT NULL,
    kind        TEXT NOT NULL,          -- fact | preference | summary
    content     TEXT NOT NULL,
    embedding   VECTOR(1536),
    importance  REAL DEFAULT 0.5,
    created_at  TIMESTAMPTZ DEFAULT now(),
    expires_at  TIMESTAMPTZ
);
CREATE INDEX ON memories USING hnsw (embedding vector_cosine_ops);

-- 유사도 + 최근성 + 중요도 결합 검색
SELECT content,
       (1 - (embedding <=> $1)) * 0.6
       + importance * 0.2
       + exp(-extract(epoch FROM now() - created_at) / 604800.0) * 0.2
       AS score
FROM memories
WHERE user_id = $2
  AND (expires_at IS NULL OR expires_at > now())
ORDER BY score DESC
LIMIT 5;

604800은 일주일(초)로, 최근성 감쇠의 반감기 역할을 한다. 도메인에 맞게 조정한다.

쓰기 정책: 무엇을 언제 승격하는가

모든 턴을 장기 메모리에 넣으면 노이즈로 검색이 오염된다. 승격은 별도 판단 단계를 둔다. 규칙 기반(사용자 선호 표현, 명시적 "기억해") + LLM 추출을 병행하고, 저장 직전 유사 항목을 검색해 중복이면 importance만 올리고 새로 쓰지 않는다.

def promote(user_id, text):
    facts = extract_facts(text)          # LLM: 사실만 뽑기
    for f in facts:
        emb = embed(f)
        dup = search_similar(user_id, emb, threshold=0.92)
        if dup:
            bump_importance(dup.id)       # 중복은 강화만
        else:
            insert_memory(user_id, "fact", f, emb)

운영 시 주의점

  • PII와 삭제권. 장기 메모리는 개인정보를 영속 저장하므로 user_id 기준 삭제(cascade)와 TTL을 처음부터 설계한다. 나중에 붙이면 임베딩 재계산까지 얽혀 어렵다.
  • 요약 드리프트. 요약을 요약하는 재귀 구조에서는 오류가 누적된다. 주기적으로 원본 로그에서 다시 요약하는 재생성 경로를 남겨둔다.
  • 검색 오염. 임계치를 너무 낮게 잡으면 관련 없는 기억이 딸려온다. 리콜보다 정밀도를 우선하고, 반환 개수를 5개 안팎으로 제한한다.
  • 비용 관측. 임베딩 호출과 요약 LLM 호출을 분리 계측한다. 대개 비용 폭증의 원인은 승격 단계의 과도한 임베딩이다.

정리

단기는 최근성·압축, 장기는 의미 검색·중요도 관리로 역할을 나누는 것이 핵심이다. 저장은 아끼고 검색은 결합 점수로 조이며, 삭제·감쇠·중복 제거를 처음부터 스키마에 녹여야 오래 운영해도 무너지지 않는다.