Published on

AutoGPT 메모리 누수 막는 벡터DB TTL·압축

Authors

AutoGPT류 에이전트는 “기억”을 벡터DB(또는 임베딩 인덱스)에 축적하면서 점점 똑똑해지는 대신, 장기 실행 환경에서는 메모리 누수처럼 보이는 현상을 자주 만듭니다. 정확히는 프로세스 힙이 새는 것이라기보다, (1) 벡터 스토어에 문서가 무한히 누적되고 (2) 검색 후보가 늘면서 (3) 재랭킹·요약·프롬프트 구성 비용이 폭증해 RAM/디스크/토큰 비용이 계속 증가하는 구조적 문제입니다.

이 글에서는 AutoGPT의 “장기 기억”을 운영 가능한 수준으로 만들기 위해, 벡터DB에 TTL(Time To Live) 기반 만료 정책을 넣고, 오래된 기억을 압축(요약·병합·다운샘플링) 하는 설계를 정리합니다. 또한 인덱스(HNSW 등) 관점에서 누적 데이터가 성능을 어떻게 망가뜨리는지, 어떤 튜닝 포인트가 있는지도 함께 다룹니다.

운영에서 실제로는 “메모리 누수”로 오해되어 장애 대응이 꼬이는 경우가 많습니다. 컨테이너가 OOMKilled로 재시작되는 상황이라면 원인 진단 관점에서 함께 읽어볼 만합니다: K8s CrashLoopBackOff - OOMKilled·Probe 실패 진단

AutoGPT에서 ‘메모리 누수’가 생기는 전형적 패턴

1) 벡터DB 문서가 무한 적재된다

AutoGPT는 대개 아래와 같은 이벤트에서 기억을 저장합니다.

  • 유저 입력
  • 에이전트의 중간 추론(Thought/Observation/Action)
  • 외부 도구 호출 결과(웹 검색, DB 조회, 파일 읽기)
  • 최종 답변

이때 “저장 기준”이 느슨하면, 매 턴마다 수십 개의 chunk가 쌓이고, 며칠만 지나도 수십만~수백만 레코드가 됩니다.

2) 검색 품질이 떨어지면서 더 많이 검색한다

데이터가 많아질수록 topk 검색 결과가 “그럴듯하지만 쓸모없는 기억”으로 채워지고, 이를 보정하려고

  • 더 큰 k
  • 더 넓은 time window
  • 추가 rerank 모델

을 붙이면서 CPU/RAM/토큰이 더 증가합니다.

3) 인덱스가 커져서 업데이트/검색 비용이 올라간다

HNSW 같은 ANN 인덱스는 검색이 빠르지만, 데이터가 커지면 메모리 상주량이 커지고, 삽입/삭제 정책이 빈약한 경우 “삭제는 논리 삭제만 되고 실제 공간은 회수되지 않는” 식으로 디스크와 메모리 압박이 커질 수 있습니다.

해결 전략 개요: TTL + 압축 + 인덱스/스키마 설계

운영 관점에서 가장 안전한 조합은 아래 3가지를 함께 적용하는 것입니다.

  1. TTL(만료)로 양을 제한: “오래된 원본 기억”은 자동 삭제
  2. 압축(요약·병합)으로 의미를 보존: 원본을 지우기 전에 “요약 기억”으로 승격
  3. 인덱스/스키마로 검색 비용을 제한: namespace, metadata 필터, topk 제한, HNSW 튜닝

핵심은 “삭제”만 하면 회상 품질이 급락하므로, 삭제 전 압축을 설계하는 것입니다.

TTL 설계: 무엇을 언제 지울 것인가

TTL은 ‘문서 타입’별로 다르게 잡아야 한다

모든 기억을 같은 TTL로 지우면 안 됩니다. 보통 아래처럼 계층화합니다.

  • Scratch(단기): 중간 추론 로그, 도구 응답 원문
    • TTL: 1시간~24시간
  • Episodic(에피소드): 특정 작업 세션의 핵심 사건
    • TTL: 7일~30일
  • Semantic(장기 요약): 사용자 선호, 프로젝트 규칙, 자주 쓰는 사실
    • TTL: 90일~무기한(단, 주기적 재검증)

즉, “원본은 짧게, 요약은 길게”가 기본입니다.

TTL을 구현하는 3가지 방식

  1. 벡터DB 자체 TTL 기능 사용
  • Redis Stack, 일부 managed vector store는 TTL을 키 레벨로 지원합니다.
  1. 애플리케이션 레벨 TTL(권장)
  • expires_at 컬럼을 두고, 쿼리에서 필터링 + 배치 삭제
  • 장점: DB 교체해도 유지 가능, 압축 워크플로우와 결합 쉬움
  1. 파티셔닝/샤딩으로 시간 단위 폐기
  • 날짜별 컬렉션/테이블로 분리하고 오래된 파티션을 드롭
  • 장점: 삭제가 빠름, 단점: 운영 복잡도 증가

압축 설계: 삭제 전에 ‘요약 기억’으로 승격하기

TTL만 걸면 “그때는 유용했던 맥락”이 사라져 에이전트가 퇴행합니다. 그래서 만료 직전에 다음을 수행합니다.

  • 요약: 여러 chunk를 하나의 요약 문서로
  • 병합: 같은 주제/태그를 가진 기억을 합쳐 중복 제거
  • 다운샘플링: 중요도가 낮은 기억은 대표 샘플만 남김

여기서 중요한 건 “요약도 벡터화해서 다시 저장”하되, 원본과 다른 namespace(또는 memory_type)로 분리하는 것입니다.

압축 트리거(언제 요약할까)

  • expires_at까지 남은 시간이 compress_window 이하일 때
  • 컬렉션 크기가 임계치(예: 100k 문서)를 넘었을 때
  • 동일 세션의 chunk 수가 임계치(예: 200개)를 넘었을 때

데이터 모델(스키마) 예시

벡터DB가 무엇이든, 메타데이터는 최소 아래를 가지는 것을 권장합니다.

  • agent_id: 에이전트 인스턴스 식별
  • user_id 또는 workspace_id: 멀티테넌시 분리
  • memory_type: scratch / episodic / semantic
  • importance: 01 (또는 0100)
  • created_at, expires_at
  • source: tool:web / tool:db / chat
  • session_id, topic
  • hash: 중복 검출용(정규화 텍스트 해시)

이 메타데이터가 있어야 TTL/압축/필터링이 모두 쉬워집니다.

구현 예제 1: TTL 필드 + 배치 삭제(파이썬)

아래 코드는 벡터 스토어가 무엇이든 적용 가능한 “애플리케이션 TTL” 패턴입니다. 예시는 PostgreSQL에 메타데이터를 저장하고, 벡터 인덱스는 별도 컬렉션이라고 가정합니다(실제로는 pgvector로 합쳐도 됩니다).

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

@dataclass
class MemoryRecord:
    id: str
    text: str
    embedding: list[float]
    metadata: dict


def compute_ttl(memory_type: str) -> timedelta:
    if memory_type == "scratch":
        return timedelta(hours=12)
    if memory_type == "episodic":
        return timedelta(days=14)
    if memory_type == "semantic":
        return timedelta(days=180)
    return timedelta(days=7)


def make_record(*, id: str, text: str, embedding: list[float], agent_id: str, user_id: str, memory_type: str) -> MemoryRecord:
    now = datetime.now(timezone.utc)
    expires_at = now + compute_ttl(memory_type)

    return MemoryRecord(
        id=id,
        text=text,
        embedding=embedding,
        metadata={
            "agent_id": agent_id,
            "user_id": user_id,
            "memory_type": memory_type,
            "created_at": now.isoformat(),
            "expires_at": expires_at.isoformat(),
        },
    )


def purge_expired(*, db, vector_store, batch_size: int = 1000) -> int:
    """만료된 레코드를 찾아 메타데이터/벡터 인덱스에서 함께 삭제"""
    now = datetime.now(timezone.utc).isoformat()

    ids = db.fetch_all(
        """
        SELECT id
        FROM memories
        WHERE expires_at <= :now
        ORDER BY expires_at ASC
        LIMIT :limit
        """,
        {"now": now, "limit": batch_size},
    )

    if not ids:
        return 0

    id_list = [row["id"] for row in ids]

    # 1) 벡터 인덱스에서 삭제
    vector_store.delete(ids=id_list)

    # 2) 메타데이터 테이블에서 삭제
    db.execute("DELETE FROM memories WHERE id = ANY(:ids)", {"ids": id_list})

    return len(id_list)

포인트는 “검색 시에도 expires_at 필터를 적용”하는 것입니다. 배치가 지연되더라도 만료 데이터가 검색에 섞이지 않게 해야 품질과 비용이 안정됩니다.

구현 예제 2: 만료 직전 압축(요약) 워커

다음은 만료 1시간 전(compress_window)에 scratch 메모리를 세션 단위로 모아 요약하고, 요약본을 semantic으로 저장한 뒤 원본을 삭제하는 패턴입니다.

from datetime import datetime, timedelta, timezone

COMPRESS_WINDOW = timedelta(hours=1)


def compress_due_memories(*, db, vector_store, llm, limit_sessions: int = 50) -> int:
    now = datetime.now(timezone.utc)
    threshold = (now + COMPRESS_WINDOW).isoformat()

    sessions = db.fetch_all(
        """
        SELECT session_id
        FROM memories
        WHERE memory_type = 'scratch'
          AND expires_at <= :threshold
        GROUP BY session_id
        ORDER BY MIN(expires_at) ASC
        LIMIT :limit
        """,
        {"threshold": threshold, "limit": limit_sessions},
    )

    compressed = 0

    for row in sessions:
        session_id = row["session_id"]
        items = db.fetch_all(
            """
            SELECT id, text
            FROM memories
            WHERE session_id = :sid
              AND memory_type = 'scratch'
            ORDER BY created_at ASC
            """,
            {"sid": session_id},
        )

        if len(items) < 5:
            continue

        joined = "\n\n".join([f"- {it['text']}" for it in items])

        prompt = (
            "다음은 에이전트의 단기 로그입니다.\n"
            "중복을 제거하고, 사실/결론/사용자 선호/해야 할 일 중심으로 10줄 이내로 요약하세요.\n\n"
            f"로그:\n{joined}"
        )

        summary = llm.generate(prompt)

        # 요약본 저장(semantic)
        emb = llm.embed(summary)
        rec = {
            "id": f"semantic:{session_id}:{int(now.timestamp())}",
            "text": summary,
            "embedding": emb,
            "metadata": {
                "memory_type": "semantic",
                "session_id": session_id,
                "created_at": now.isoformat(),
                "expires_at": (now + timedelta(days=180)).isoformat(),
            },
        }

        vector_store.upsert([rec])
        db.execute(
            "INSERT INTO memories(id, text, memory_type, session_id, created_at, expires_at) VALUES(:id, :text, 'semantic', :sid, :ca, :ea)",
            {"id": rec["id"], "text": summary, "sid": session_id, "ca": rec["metadata"]["created_at"], "ea": rec["metadata"]["expires_at"]},
        )

        # 원본 scratch 삭제
        ids_to_delete = [it["id"] for it in items]
        vector_store.delete(ids=ids_to_delete)
        db.execute("DELETE FROM memories WHERE id = ANY(:ids)", {"ids": ids_to_delete})

        compressed += 1

    return compressed

이 워커는 “요약 비용”이 추가되지만, 장기적으로는 검색/프롬프트 비용을 크게 줄여줍니다. 특히 AutoGPT가 매 턴마다 과거 로그를 긁어오며 토큰을 태우는 구조라면 효과가 큽니다.

요약 호출이 잦아지면 API 429나 과부하(529)가 빈번해질 수 있으니, 재시도/백오프 패턴은 필수입니다. 관련 구현 패턴은 다음 글이 도움이 됩니다: Claude API 529·429 재시도 전략과 구현 패턴

검색 비용을 줄이는 운영 팁: 필터링과 topk 제한

1) namespace(또는 컬렉션)를 분리하라

  • scratch_memories
  • semantic_memories

처럼 물리적으로 분리하면, 단기 로그가 장기 기억 검색을 오염시키지 않습니다.

2) 메타데이터 필터를 강하게 걸어라

검색 시 최소한 아래는 기본 필터로 추천합니다.

  • agent_id 일치
  • user_id 또는 workspace_id 일치
  • memory_type in ['semantic', 'episodic'] (상황에 따라 scratch 제외)
  • expires_at > now

3) topk는 작게, rerank는 선택적으로

처음부터 topk를 50~100으로 잡으면 비용이 폭발합니다.

  • 1차 ANN 검색 topk = 10~20
  • 필요 시에만 rerank(예: cross-encoder)로 topk = 5

이렇게 “좁게 가져와서 정확히 고르는” 방식이 장기 운영에서 안정적입니다.

중복/노이즈 억제: 해시 기반 디듀프 + 중요도 스코어

디듀프(중복 제거)

같은 내용이 반복 저장되는 경우가 많습니다(도구 호출 결과, 상태 출력 등). 저장 전에 정규화 후 해시를 만들어 최근 N시간 내 동일 해시가 있으면 스킵합니다.

import hashlib
import re


def normalize(text: str) -> str:
    text = text.strip().lower()
    text = re.sub(r"\s+", " ", text)
    return text


def content_hash(text: str) -> str:
    return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()


def should_store(*, db, text: str, window_hours: int = 6) -> bool:
    h = content_hash(text)
    return not db.fetch_one(
        """
        SELECT 1
        FROM memories
        WHERE hash = :h
          AND created_at >= NOW() - INTERVAL '1 hour' * :w
        LIMIT 1
        """,
        {"h": h, "w": window_hours},
    )

중요도(importance)로 압축 우선순위 결정

모든 기억을 동일하게 취급하면 압축 비용도 커집니다. 아래 기준으로 importance를 올리는 식이 실전에서 잘 먹힙니다.

  • 사용자가 “이건 기억해”라고 명시
  • 실패/에러/재시도 같은 사건(운영 관점에서 재발 방지 가치)
  • 프로젝트 규칙, 선호, 금지사항

importance가 낮은 scratch는 TTL만 적용하고 요약 없이 삭제해도 됩니다.

인덱스 관점: HNSW가 커질수록 생기는 문제와 완화

벡터DB 내부 구현은 다르지만, HNSW 계열에서는 대체로 다음이 병목이 됩니다.

  • 인덱스 그래프가 커지며 RAM 상주량 증가
  • 삽입이 누적되면 빌드/머지 비용 증가
  • 삭제가 “논리 삭제”로 남아 검색 품질/성능 저하

완화책은 크게 3가지입니다.

  1. 주기적 리빌드(또는 컴팩션)
  • 삭제가 누적되는 구조라면 일정 주기마다 새 인덱스로 재구축
  1. 시간 파티션으로 인덱스 분리
  • semantic_2025_02, semantic_2025_03처럼 월 단위 분리 후 오래된 파티션 드롭
  1. 검색 범위를 메타데이터로 강제 제한
  • topic, project, session_id로 필터
  • “전체를 다 뒤지는 검색”을 금지

PostgreSQL 기반으로 운영한다면, 삭제/업데이트가 누적될 때 bloat 관리가 중요합니다. 벡터 인덱스와 별개로 메타데이터 테이블이 커지면 쿼리 지연이 생길 수 있으니, 다음 글의 VACUUM/bloat 관점도 참고할 만합니다: PostgreSQL VACUUM 안됨? bloat·wraparound 7분 해결

운영 체크리스트: “누수처럼 보이는 증가”를 숫자로 제어하기

반드시 대시보드로 봐야 할 지표

  • 벡터 컬렉션 문서 수(전체/타입별)
  • 평균 chunk 길이, 평균 임베딩 호출 수(턴당)
  • 검색 topk와 실제 컨텍스트로 채택된 문서 수
  • 요약 워커 호출 수, 요약 실패율(레이트 리밋 포함)
  • 컨테이너 RSS/heap, 디스크 사용량, 인덱스 크기

흔한 실패 모드

  • TTL은 걸었는데 검색 쿼리에 expires_at 필터가 없어 만료 데이터가 계속 회상됨
  • 압축 워커가 실패해 원본도 못 지우고 요약도 못 남김(재시도/큐잉 필요)
  • semantic까지 무한히 쌓여 결국 같은 문제 재발(semantic에도 TTL/재검증 필요)

결론: TTL은 안전장치, 압축은 품질 유지 장치

AutoGPT의 장기 실행에서 “메모리 누수”를 막는 가장 현실적인 답은 벡터DB를 무한 저장소로 쓰지 않는 것입니다.

  • TTL로 원본 기억의 상한을 만들고
  • 만료 직전에 요약/병합으로 의미를 보존하며
  • 검색을 namespace/필터/topk로 강하게 제한하면

장기 운영에서도 RAM/디스크/토큰 비용이 선형으로 폭증하는 문제를 상당 부분 제어할 수 있습니다.

다음 단계로는 “요약의 품질 평가(회상 정확도 측정)”와 “중요도 기반 정책(사용자별/프로젝트별)”을 붙여, 삭제와 품질 사이의 균형을 수치로 튜닝하는 것을 권장합니다.