Published on

AutoGPT 메모리 누수? 벡터DB TTL·요약 전략

Authors

AutoGPT류 에이전트(Agentic LLM)를 운영하다 보면, 처음엔 잘 돌아가다가 시간이 지날수록 응답이 느려지고 토큰 비용이 증가하며, 검색 결과가 산만해지는 현상을 자주 겪습니다. 많은 팀이 이를 “메모리 누수”라고 부르지만, 전통적인 프로세스 메모리 누수라기보다 에이전트가 축적하는 장기기억(벡터 메모리)과 작업 로그가 무제한으로 커지는 데이터 누수에 가깝습니다.

이 글에서는 AutoGPT의 메모리 구조를 운영 관점에서 분해하고, 벡터DB TTL, 요약(압축) 전략, 계층형 메모리, 삭제·리텐션 정책, 관측 지표까지 한 번에 정리합니다. 특히 “왜 검색 품질이 떨어지는지”를 임베딩 검색의 특성으로 설명하고, TTL과 요약을 어떻게 조합해야 비용과 품질을 동시에 잡는지 실전 설계를 제안합니다.

관련해서 에이전트가 무한루프에 빠지며 로그와 메모리가 폭증하는 경우도 흔합니다. 그 쪽은 별도 체크리스트가 유용합니다: LangChain 에이전트 무한루프·비용폭탄 9가지 차단법

1) “메모리 누수”의 정체: 프로세스가 아니라 기억 저장소가 새는 중

AutoGPT의 전형적인 메모리 구성은 다음 세 덩어리로 나뉩니다.

  1. 단기 컨텍스트: 프롬프트에 직접 들어가는 최근 대화, 작업 상태
  2. 중기 작업 로그: 계획, tool 실행 기록, 중간 산출물
  3. 장기 메모리(벡터DB): 임베딩된 문서/대화 조각을 유사도 검색으로 회수

운영 중 문제가 되는 건 보통 2, 3입니다.

  • 작업 로그가 계속 쌓이면 “요약 없이 원문을 계속 붙이는” 형태로 프롬프트가 비대해집니다.
  • 벡터DB가 무한히 커지면, 검색 단계에서 후보가 늘어나면서 검색 품질이 오히려 떨어지거나(노이즈 증가), 리트리벌 비용이 증가합니다.

즉, “메모리 누수”라는 표현은 결과적으로 리텐션 정책 부재요약/압축 부재가 만든 운영 장애입니다.

2) 벡터DB가 커질수록 품질이 떨어지는 이유

벡터 검색은 대략 다음 흐름입니다.

  • 텍스트를 임베딩 벡터로 변환
  • 유사도(코사인 등)로 상위 k개를 검색
  • 상위 k를 LLM 컨텍스트에 넣고 답변 생성

문제는 데이터가 커질수록 다음이 발생한다는 점입니다.

  • 근접 이웃의 밀도 증가: 비슷한 내용이 많아지면 상위 k가 중복/유사 조각으로 채워짐
  • 시간적 관련성 상실: 오래된 사실이 최신 상태를 덮어씌움(특히 “현재 목표/현재 설정” 같은 상태성 정보)
  • 스코어 분포가 평평해짐: 임베딩 모델 특성상 점수 차가 작아져 재랭킹 없이는 정밀도가 떨어짐

결론적으로, 장기기억은 “많을수록 좋다”가 아니라 적절히 썩혀야(만료) 정확해집니다.

3) TTL로 장기기억을 통제하는 기본 정책

TTL(Time To Live)은 가장 강력한 운영 레버입니다. 핵심은 “모든 메모리가 영구적일 필요가 없다”는 점입니다.

3.1 TTL 설계 원칙

다음처럼 메모리 타입별 TTL을 다르게 주는 것이 일반적으로 가장 안정적입니다.

  • 관찰/로그성 메모리: 짧게(예: 1일~7일)
  • 사용자 취향/선호: 길게(예: 30일~180일)
  • 계정/정책/규칙: 변경 이벤트 기반(만료보다 버전 관리)
  • 작업 단위 메모리: 워크플로 종료 시점에 정리

세션/상태를 TTL로 관리하는 패턴은 Redis 운영에서도 매우 흔합니다. TTL과 리텐션을 어떻게 잡느냐가 곧 장애를 예방합니다: Spring Boot Redis 세션 꼬임 - TTL·동시로그인 해법

3.2 벡터DB에서 TTL을 구현하는 3가지 방식

벡터DB마다 기능이 다르므로, 보통 다음 중 하나를 택합니다.

  1. DB 네이티브 TTL: 일부 저장소는 만료/컬렉션 정책 제공
  2. 메타데이터 기반 필터링: expires_at을 저장하고 쿼리에서 now 조건으로 제외
  3. 백그라운드 GC 작업: 주기적으로 만료 문서를 삭제(또는 cold storage로 이동)

실무에서는 2와 3의 조합이 가장 흔합니다. “검색에서는 제외하되, 삭제는 배치로 처리”하면 비용과 단순성이 좋아집니다.

4) 요약(압축) 전략: TTL만으로는 부족하다

TTL은 노이즈를 줄이지만, “최근 데이터”가 폭증하는 상황(예: 에이전트가 2시간 동안 1,000번 tool 호출)에서는 TTL만으로 프롬프트가 커집니다. 그래서 요약은 TTL의 짝입니다.

요약은 크게 두 종류가 있습니다.

  • 대화/로그 요약: 최근 N턴을 요약해 컨텍스트를 고정 크기로 유지
  • 메모리 요약(클러스터링): 유사 조각을 묶어 상위 개념으로 압축

4.1 언제 요약할 것인가: 이벤트 기반 트리거

다음 트리거 중 2개 이상을 동시에 쓰는 것을 권합니다.

  • 토큰 예산 초과 임박(예: 프롬프트가 70%를 넘으면)
  • tool 호출 횟수 초과(예: 30회)
  • 동일 주제 반복 감지(유사도 또는 키워드 반복)
  • 워크플로 단계 전환(계획 단계 종료, 실행 단계 종료)

4.2 요약 품질을 유지하는 포맷

요약은 “짧게”보다 “재사용 가능하게”가 중요합니다. 추천 포맷은 다음입니다.

  • 목표/현재 상태
  • 확정된 사실
  • 미해결 이슈
  • 다음 행동 후보
  • 금지/주의 사항

이렇게 하면 에이전트가 장기적으로 자기모순을 덜 내고, 검색 없이도 핵심 상태를 유지합니다.

5) 계층형 메모리: 단기·중기·장기를 분리하라

운영에서 가장 효과가 큰 구조는 계층형 메모리(Hierarchical Memory) 입니다.

  • L0: 실행 컨텍스트(최근 10~30개 메시지)
  • L1: 작업 요약(고정 크기, 항상 포함)
  • L2: 벡터 검색 결과(상위 k, 필터링/재랭킹)
  • L3: 아카이브(검색 제외, 감사/재현용)

핵심은 L1을 “상태 저장소”로 보고, L2는 “참고 문서 검색”으로만 쓰는 것입니다. 많은 구현이 L2에 상태까지 넣어버리는데, 그러면 오래된 상태가 검색으로 튀어나와 현재 상태를 오염시킵니다.

6) 벡터DB 스키마: TTL과 요약을 가능하게 하는 메타데이터

벡터DB에 다음 메타데이터를 꼭 넣어야 TTL/요약/필터링이 쉬워집니다.

  • tenant_id 또는 user_id
  • session_id 또는 run_id
  • memory_type (예: observation, preference, policy, summary)
  • created_at, expires_at
  • importance (0~1)
  • source (tool 이름, url, 파일 등)
  • hash (중복 제거)

중복 제거는 특히 중요합니다. 에이전트는 같은 내용을 형태만 바꿔 여러 번 저장하는 경향이 있어, 중복이 쌓이면 검색 품질이 급격히 나빠집니다.

7) 구현 예제: TTL 필터링 + 배치 GC + 요약 파이프라인

아래 예제는 “메타데이터로 만료를 필터링하고, 주기적으로 삭제하며, 토큰 예산을 넘기면 요약을 생성”하는 최소 설계입니다.

7.1 Python: 메모리 레코드와 TTL 필터

<> 문자가 본문에 노출되면 MDX에서 문제가 될 수 있어, 비교 연산은 코드 블록 안에서만 사용합니다.

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional

@dataclass
class MemoryRecord:
    id: str
    text: str
    embedding: list[float]
    user_id: str
    session_id: str
    memory_type: str  # observation, preference, policy, summary
    created_at: datetime
    expires_at: Optional[datetime]
    importance: float = 0.5

    def is_expired(self, now: Optional[datetime] = None) -> bool:
        now = now or datetime.now(timezone.utc)
        return self.expires_at is not None and self.expires_at <= now


def ttl_for(memory_type: str) -> Optional[timedelta]:
    if memory_type == "observation":
        return timedelta(days=3)
    if memory_type == "summary":
        return timedelta(days=14)
    if memory_type == "preference":
        return timedelta(days=90)
    if memory_type == "policy":
        return None  # versioning recommended
    return timedelta(days=7)

7.2 검색 시 만료 제외 + 타입 필터

from datetime import datetime, timezone

ALLOWED_TYPES = {"summary", "preference", "policy", "observation"}

def build_vector_query_filter(user_id: str, session_id: str | None = None):
    now = datetime.now(timezone.utc)
    # 실제 벡터DB 필터 DSL은 제품마다 다르므로 개념만 표현
    f = {
        "user_id": user_id,
        "memory_type": {"$in": list(ALLOWED_TYPES)},
        "$or": [
            {"expires_at": None},
            {"expires_at": {"$gt": now.isoformat()}},
        ],
    }
    if session_id:
        # 세션 범위 검색이 필요할 때만
        f["session_id"] = session_id
    return f

7.3 배치 GC: 만료 삭제

def gc_expired(memories: list[MemoryRecord]) -> list[MemoryRecord]:
    now = datetime.now(timezone.utc)
    return [m for m in memories if not m.is_expired(now)]

7.4 토큰 예산 기반 요약 트리거(개념 코드)

def should_summarize(prompt_tokens: int, budget: int, tool_calls: int) -> bool:
    # budget의 70% 초과 또는 tool call 과다 시 요약
    return (prompt_tokens >= int(budget * 0.7)) or (tool_calls >= 30)


def summarize_log(log_text: str) -> str:
    # 실제로는 LLM 호출. 여기서는 포맷만 예시.
    return (
        "목표: ...\n"
        "현재 상태: ...\n"
        "확정 사실: ...\n"
        "미해결 이슈: ...\n"
        "다음 행동: ...\n"
        "주의 사항: ...\n"
    )

요약 결과는 원문 로그를 대체하지 말고, L1 요약 메모리로 저장한 뒤 원문은 L3 아카이브로 내리거나 TTL을 짧게 줘서 자연 소멸시키는 편이 안전합니다.

8) 운영 지표: “누수”를 수치로 잡아내는 방법

다음 지표를 대시보드로 만들면, 메모리 폭증을 조기에 감지할 수 있습니다.

  • 사용자별 벡터 레코드 수, 총 토큰 추정량
  • memory_type별 레코드 비율(관찰 로그가 90%면 위험)
  • 검색 상위 k의 중복률(동일 hash, 유사도 과다)
  • 검색 결과의 평균 유사도 및 분산(분산이 너무 낮으면 평평한 분포)
  • 요약 생성 빈도와 요약 길이
  • 프롬프트 토큰 대비 응답 토큰 비율

또한 에이전트가 데드라인 없이 tool을 기다리거나 재시도 폭주로 로그를 쌓는 경우가 많습니다. 네트워크 호출에는 타임아웃/리트라이 상한이 필수입니다: Go gRPC 데드라인 초과? 컨텍스트·리트라이 튜닝

9) 자주 하는 실수 7가지

  1. 모든 메모리를 장기 저장: 관찰 로그는 대부분 쓸모가 빨리 사라집니다.
  2. 상태를 벡터DB에만 저장: 현재 목표/설정은 L1 요약 또는 별도 상태 DB가 정답입니다.
  3. 요약을 원문 대체로 사용: 요약은 손실 압축입니다. 감사/재현이 필요하면 원문은 아카이브로.
  4. 검색 결과를 그대로 컨텍스트에 붙임: 중복 제거, 타입 필터, recency 가중치가 필요합니다.
  5. TTL 없이 배치 삭제만: 검색 단계에서 만료를 제외하지 않으면 “삭제 전까지” 품질이 계속 나쁩니다.
  6. 세션 경계가 없음: session_id가 없으면 다른 작업의 기억이 섞여 환각이 늘어납니다.
  7. 관측 지표 부재: 비용이 터진 뒤에야 원인을 찾게 됩니다.

10) 추천 조합: 가장 현실적인 기본값

팀/서비스 성격에 따라 다르지만, 범용적으로 잘 먹히는 기본 조합은 다음입니다.

  • L0 최근 대화: 20~40턴 유지
  • L1 작업 요약: 항상 포함, 최대 500~1,000토큰
  • L2 벡터 검색: 상위 k는 5~12, memory_type 필터 + expires_at 필터
  • 관찰 로그 TTL: 3~7일
  • 요약 TTL: 14~30일(단, 최신 요약 1개는 항상 유지)
  • 선호/정책: TTL보다 버전/승인 플로우 고려
  • GC 배치: 1시간~1일 주기

이 구조로 가면 “메모리 누수처럼 보이는 현상”의 대부분이 정리됩니다. 핵심은 벡터DB를 무한한 지식 저장소로 취급하지 않고, 리텐션과 압축이 있는 캐시 계층으로 운영하는 것입니다.


체크리스트 요약

  • TTL은 검색 단계 필터와 배치 삭제를 함께 적용한다
  • 상태성 정보는 벡터DB가 아니라 L1 요약 또는 별도 상태 저장소로 분리한다
  • 요약은 토큰 예산/단계 전환/반복 감지 기반으로 자동 생성한다
  • 메타데이터(expires_at, memory_type, session_id)가 없으면 운영이 불가능하다
  • 중복 제거와 관측 지표로 “누수”를 숫자로 관리한다