Published on

AutoGPT 메모리 폭주 해결 - 벡터DB+요약 캐시

Authors

AutoGPT류 에이전트를 오래 돌리다 보면 어느 순간부터 응답이 느려지고, 토큰 비용이 기하급수적으로 늘고, 결국 프로세스 메모리까지 불어나며 “기억이 많아질수록 더 똑똑해지는” 게 아니라 “기억 때문에 죽는” 상태가 됩니다. 이 글은 그 현상을 재현 가능한 문제로 쪼갠 뒤, 벡터DB(장기 기억) + 요약 캐시(작업 기억) 를 조합해 메모리 폭주를 구조적으로 막는 방법을 다룹니다.

핵심은 간단합니다.

  • 원인: “모든 것을 컨텍스트에 다시 넣으려는” 설계(무한 로그, 무한 회상, 무한 임베딩)
  • 해결: 기억을 계층화하고, 각 계층에 수명(TTL)압축(요약)검색 한도(top-k) 를 부여

관련해서 더 깊게 TTL과 요약을 다룬 글은 AutoGPT 메모리 폭주 해결 - 벡터DB TTL·요약도 함께 참고하면 좋습니다.

AutoGPT 메모리 폭주는 왜 생기나

실무에서 폭주의 패턴은 대체로 아래 3가지가 동시에 발생합니다.

1) 컨텍스트 윈도우를 “로그 저장소”로 오해

에이전트가 매 턴마다 이전 대화/툴 결과/스크래치패드를 전부 프롬프트에 붙이면, 토큰 사용량은 선형이 아니라 누적 합 형태로 증가합니다. 즉 N턴에서 비용은 대략 O(N^2)로 튀기 쉽습니다.

2) 벡터DB에 “영구 기억”을 무제한 적재

임베딩은 싸지 않습니다. 또한 벡터DB에 무제한 적재하면:

  • 검색 후보가 늘어 검색 시간 증가
  • 비슷한 내용이 중복 저장되어 정확도 하락(노이즈 증가)
  • 결국 “회상(retrieval)”이 매 턴 더 비싸짐

3) 요약이 없거나, 요약이 단발성이라 누적되지 않음

요약을 한 번만 하고 계속 원문을 쌓으면 효과가 제한적입니다. “최근 작업 상태”는 계속 변하므로, 요약도 증분(rolling) 으로 갱신되어야 합니다.

목표 아키텍처: 계층형 메모리(Working + Long-term)

권장 구조는 다음 2계층(또는 3계층)입니다.

  • Working Memory(작업 기억): 현재 목표 달성에 필요한 최소 정보만 유지
    • 형태: 짧은 요약 + 최신 몇 턴 원문
    • 저장소: Redis/인메모리
    • 특징: 빠르고 작아야 함
  • Long-term Memory(장기 기억): 검색 기반으로만 불러오는 과거 정보
    • 형태: 문서 청크 + 임베딩
    • 저장소: 벡터DB(Chroma, Qdrant, Pinecone 등)
    • 특징: TTL/압축/중복 제거가 필요

(선택) Episodic Memory(에피소드 기억): “세션 단위 요약”만 저장해 장기 기억의 노이즈를 줄이는 레이어

아래는 한 턴에서의 데이터 흐름입니다.

  1. 사용자 입력
  2. 작업 기억(요약 캐시) + 최근 로그 일부를 프롬프트에 포함
  3. 벡터DB에서 top-k 검색(필요할 때만)
  4. LLM 응답 + 도구 호출
  5. 결과를 (a) 작업 기억 요약에 반영 (b) 장기 기억에 적재(필터링/TTL 적용)

설계 포인트 1: 요약 캐시를 “상태 머신”처럼 다루기

요약 캐시는 단순히 “대화 요약”이 아니라, 에이전트가 다음 행동을 결정하는 데 필요한 상태(state) 여야 합니다.

권장 필드 예시:

  • goal: 현재 목표
  • plan: 현재 계획(단계)
  • done: 완료된 작업
  • open_questions: 미해결 질문/리스크
  • constraints: 제약(시간/예산/정책)
  • artifacts: 생성된 파일/URL/결과물

이걸 매 턴 “증분 업데이트”합니다. 즉, 매번 전체 로그를 요약하지 말고, 이전 요약 + 이번 턴 변화분만 반영합니다.

요약 캐시 업데이트 예시(Python)

from dataclasses import dataclass, asdict
import json

@dataclass
class WorkingSummary:
    goal: str = ""
    plan: list[str] = None
    done: list[str] = None
    open_questions: list[str] = None
    constraints: list[str] = None
    artifacts: list[str] = None

    def __post_init__(self):
        self.plan = self.plan or []
        self.done = self.done or []
        self.open_questions = self.open_questions or []
        self.constraints = self.constraints or []
        self.artifacts = self.artifacts or []


def update_summary(llm, prev: WorkingSummary, new_events: str) -> WorkingSummary:
    prompt = f"""
너는 에이전트의 작업 요약을 갱신하는 모듈이다.

[이전 요약 JSON]
{json.dumps(asdict(prev), ensure_ascii=False)}

[이번 턴 이벤트]
{new_events}

규칙:
- 목표/계획/완료/미해결/제약/산출물을 최신 상태로 갱신
- 중복 항목은 제거
- 불확실하면 open_questions에 남긴다
- JSON만 출력
"""
    out = llm(prompt)
    data = json.loads(out)
    return WorkingSummary(**data)

이 방식의 장점은:

  • 프롬프트에 들어가는 “기억”이 상수 크기에 가까워짐
  • 장기 실행 시에도 토큰 비용이 안정화
  • 에이전트의 다음 행동 품질이 올라감(계획/미해결이 명시되므로)

설계 포인트 2: 벡터DB는 무조건 TTL과 중복 제거를 건다

장기 기억은 “다 저장”이 아니라 “나중에 유의미하게 회상할 것만 저장”이어야 합니다.

TTL 전략

  • 기본 TTL: 예를 들어 7d 또는 30d
  • 예외: “프로젝트 핵심 지식”은 태그를 달아 TTL을 길게
  • 세션 메모리는 TTL을 짧게(예: 24h) 두고, 중요한 건 세션 요약으로 승격

중복 제거(near-duplicate) 전략

  • 같은 URL/파일/해시를 가진 문서는 재적재 금지
  • 임베딩 코사인 유사도가 임계치 이상이면 “업데이트”만 하고 새로 추가하지 않기

Qdrant TTL 인덱싱 예시

Qdrant는 payload에 타임스탬프를 넣고, 주기적으로 삭제 작업을 걸 수 있습니다.

import time
from qdrant_client import QdrantClient
from qdrant_client.http.models import Filter, FieldCondition, Range

client = QdrantClient(url="http://localhost:6333")
COLLECTION = "autogpt_memory"


def purge_expired(ttl_seconds: int):
    now = int(time.time())
    cutoff = now - ttl_seconds

    flt = Filter(
        must=[
            FieldCondition(
                key="created_at",
                range=Range(lt=cutoff)
            )
        ]
    )

    client.delete(
        collection_name=COLLECTION,
        points_selector=flt
    )

운영에서는 이 purge를 cron이나 워커로 주기 실행합니다.

설계 포인트 3: Retrieval은 “필요할 때만”, 그리고 top-k를 작게

메모리 폭주를 만드는 흔한 실수는 “매 턴 벡터 검색”입니다. 검색 결과를 프롬프트에 붙이는 순간 토큰이 늘고, 모델이 그걸 모두 읽느라 지연이 늘며, 결국 다시 더 많은 검색을 하게 되는 악순환이 생깁니다.

권장 규칙:

  • 검색 트리거를 명시: 사용자가 과거 언급을 요구, 특정 엔티티/파일 참조, 계획 단계 전환 등
  • top-k는 작게: 보통 3에서 8 사이
  • 청크 길이 제한: 200에서 500 토큰 수준
  • 점수 임계치: 유사도가 낮으면 아예 붙이지 않기

Retrieval 게이팅 예시

def should_retrieve(user_text: str, summary: WorkingSummary) -> bool:
    keywords = ["전에", "기억", "지난", "어제", "이전", "로그", "참조"]
    if any(k in user_text for k in keywords):
        return True
    # 계획 단계가 바뀌는 경우(예: plan에 새로운 단계 추가)도 트리거
    if "다음 단계" in user_text or "이제" in user_text:
        return True
    return False


def build_context(user_text, summary, recent_messages, retrieved_docs):
    # retrieved_docs는 top-k + score threshold를 통과한 것만
    parts = []
    parts.append("[WORKING_SUMMARY]\n" + json.dumps(asdict(summary), ensure_ascii=False))
    parts.append("[RECENT]\n" + "\n".join(recent_messages[-6:]))
    if retrieved_docs:
        parts.append("[RETRIEVED]\n" + "\n\n".join(d["text"] for d in retrieved_docs))
    parts.append("[USER]\n" + user_text)
    return "\n\n".join(parts)

설계 포인트 4: “요약 캐시”와 “장기 기억”의 승격 규칙 만들기

모든 이벤트를 장기 기억에 넣지 말고, 아래처럼 승격 규칙을 둡니다.

  • 도구 실행 결과 중 재사용 가치가 큰 것만 저장
    • 예: API 응답 스키마, 에러 원인/해결, 결정된 설정 값
  • 단순 채팅/잡담/중복 로그는 저장 금지
  • 세션 종료 시 “세션 요약”을 장기 기억에 저장(원문 대신)

승격 필터 예시

def should_promote_to_long_term(event: dict) -> bool:
    t = event.get("type")
    if t in ["final_answer", "decision", "config", "error_analysis", "api_schema"]:
        return True

    text = event.get("text", "")
    # 너무 짧으면 정보 가치가 낮다고 가정
    if len(text) < 200:
        return False

    # 로그/진행 메시지 제외
    noisy_markers = ["thinking", "progress", "step", "retry"]
    if any(m in text.lower() for m in noisy_markers):
        return False

    return True

운영 관점 체크리스트: 폭주를 조기에 탐지하기

장기 실행 에이전트는 “기능”보다 “관측”이 먼저입니다. 다음 지표를 최소한으로 잡으세요.

  • 턴당 입력 토큰/출력 토큰
  • Retrieval로 붙는 문서 수, 평균 길이
  • 벡터DB 포인트 수, 컬렉션 크기
  • 요약 캐시 길이(문자 수 또는 토큰 추정)
  • LLM latency p95

이런 지표는 프론트/서버 모두에 영향을 주는데, UI가 있는 에이전트라면 Long Task로 인한 체감 성능 저하도 같이 점검해야 합니다. 웹 성능 관점은 Chrome INP 악화? Long Task 원인추적·해결에서 진단 방법을 참고할 수 있습니다.

흔한 실패 사례와 처방

실패 1) 요약이 너무 공격적이라 중요한 디테일이 사라짐

  • 처방: open_questionsconstraints를 강제 필드로 두고, 불확실하면 삭제 대신 이 필드로 이동
  • “원문 링크(artifact)”를 요약에 남겨 추적 가능하게 만들기

실패 2) 벡터 검색 결과가 항상 비슷한 것만 나옴

  • 처방: 청크 전략 재설계(헤더/섹션/코드 블록 경계)
  • 메타데이터 필터 추가(프로젝트, 날짜, 타입)
  • top-k를 늘리기 전에 score threshold를 먼저 조정

실패 3) 저장은 줄였는데도 토큰이 계속 증가

  • 처방: 최근 로그 포함 개수를 고정(-6 같은 상수)
  • 스크래치패드(Thought/Chain-of-Thought)를 저장하지 말고 “결론만” 저장
  • 도구 출력 원문을 그대로 붙이지 말고 요약/스키마만 포함

예시: 전체 파이프라인 골격(요약 캐시 + 벡터DB)

아래는 “한 턴”을 처리하는 최소 골격입니다.

class AgentMemory:
    def __init__(self, llm, vector_store, summary_store):
        self.llm = llm
        self.vector_store = vector_store
        self.summary_store = summary_store  # Redis 같은 KV를 가정

    def step(self, session_id: str, user_text: str, recent_messages: list[str]):
        summary = self.summary_store.get(session_id) or WorkingSummary(goal="")

        retrieved = []
        if should_retrieve(user_text, summary):
            retrieved = self.vector_store.search(
                query=user_text,
                top_k=5,
                score_threshold=0.25,
                filters={"session_id": session_id}
            )

        prompt = build_context(user_text, summary, recent_messages, retrieved)
        answer, events = self.llm.run(prompt)  # events: tool 결과/결정 등 구조화 로그

        # 작업 기억 갱신(증분)
        new_events_text = "\n".join(e.get("text", "") for e in events)
        summary = update_summary(self.llm, summary, new_events_text)
        self.summary_store.set(session_id, summary)

        # 장기 기억 승격
        for e in events:
            if should_promote_to_long_term(e):
                self.vector_store.upsert(
                    text=e["text"],
                    metadata={
                        "session_id": session_id,
                        "type": e.get("type", "unknown"),
                        "created_at": int(time.time()),
                    }
                )

        return answer

구현체는 어떤 프레임워크를 쓰든(예: LangChain, LlamaIndex, 자체 구현) 이 구조를 유지하는 게 중요합니다.

마무리: “기억을 늘리는” 대신 “기억을 관리”하라

AutoGPT 메모리 폭주는 모델 문제가 아니라 대개 메모리 설계 문제입니다. 해결의 핵심은 다음 4줄로 요약됩니다.

  • 작업 기억은 요약 캐시로 상수 크기를 유지
  • 장기 기억은 벡터DB로 보내되 TTL + 중복 제거를 기본값으로
  • Retrieval은 게이팅하고 top-k와 청크 길이를 제한
  • 세션 종료 시 원문 누적 대신 세션 요약 승격으로 노이즈를 줄이기

이 패턴을 적용하면 장기 실행에서도 비용과 지연이 안정화되고, “필요할 때만 정확히 기억을 꺼내 쓰는” 에이전트에 가까워집니다.