Published on

AutoGPT 메모리 폭주 해결 - Qdrant+RAG 튜닝

Authors

AutoGPT를 돌리다 보면 어느 순간부터 RAM이 급격히 증가하고, 결국 프로세스가 OOM으로 죽거나 시스템 전체가 느려지는 현상을 자주 만납니다. 특히 에이전트가 장시간 실행되거나, 웹 브라우징·문서 요약·코드 생성처럼 컨텍스트가 계속 쌓이는 작업에서 폭발적으로 나타납니다.

이 글은 “그냥 메모리 제한 걸기”가 아니라, 왜 메모리가 폭주하는지를 구조적으로 쪼개고, Qdrant 기반 장기기억 + RAG 검색 튜닝으로 메모리 사용량과 품질을 동시에 안정화하는 방법을 정리합니다.

  • 단기 컨텍스트(LLM 입력)와 장기 메모리(벡터DB)를 분리
  • Qdrant 컬렉션/인덱스/페이로드 설계
  • RAG 검색 파라미터(Top-k, score threshold, MMR)와 청킹 전략
  • 중복 메모리 억제(디듑, TTL, 요약 압축)

문맥상 함께 보면 좋은 글도 연결합니다.

AutoGPT 메모리 폭주의 3가지 근본 원인

1) 단기 컨텍스트가 “누적 로그”처럼 자란다

AutoGPT류 에이전트는 보통 다음을 반복합니다.

  1. 목표와 현재 상태를 프롬프트로 구성
  2. 이전 생각/행동/관찰을 컨텍스트에 포함
  3. 다음 액션을 결정

여기서 이전 기록을 매 턴마다 그대로 붙이는 방식이면, 토큰 수가 선형이 아니라 사실상 누적되어 커집니다. 모델 입력이 커지면:

  • 프롬프트 문자열 자체가 메모리를 많이 먹고
  • 토큰화/추론 과정에서 임시 버퍼가 증가하고
  • 응답도 길어져 다음 턴에 다시 누적되는 악순환이 생깁니다.

2) 장기 메모리가 “중복 + 무제한”으로 쌓인다

장기기억을 벡터DB에 넣더라도 다음이 흔한 문제입니다.

  • 같은 사실을 표현만 바꿔 계속 저장(중복)
  • 모든 관찰을 무조건 저장(무제한)
  • 검색 시 너무 많은 결과를 다시 컨텍스트에 주입(재-팽창)

즉, 저장도 과하고 검색도 과하면 “장기기억이 단기 컨텍스트를 다시 비대하게 만드는” 구조가 됩니다.

3) 청킹/임베딩/검색 설정이 품질을 떨어뜨려 더 많은 턴을 만든다

검색 품질이 낮으면 에이전트는 정답을 못 찾고 더 많은 시도를 합니다.

  • 청크가 너무 작아 문맥이 끊김
  • 청크가 너무 커서 임베딩이 뭉개짐
  • top-k가 너무 커서 노이즈 주입
  • score threshold가 없어 무관한 문서가 들어옴

결국 더 많은 턴, 더 많은 로그, 더 많은 메모리로 이어집니다.

목표 아키텍처: “단기 4KB, 장기 무제한(하지만 통제)”

핵심 원칙은 단순합니다.

  • 단기 컨텍스트는 강하게 제한: 최근 N턴 + 요약 1개 + 검색 결과 소량
  • 장기 메모리는 벡터DB에 저장: 저장은 하되, 중복 제거/TTL/요약 압축으로 통제
  • 검색 결과는 ‘주입 예산’으로 제한: 토큰 예산 내에서만 RAG 컨텍스트 구성

이 구조를 Qdrant로 구현하면 운영 난이도 대비 성능이 좋습니다.

Qdrant 컬렉션 설계: 메모리 폭주를 막는 스키마

포인트 구조(벡터 + 페이로드)

장기기억을 단순 텍스트로만 넣지 말고, 페이로드에 메타데이터를 반드시 넣습니다.

  • type: observation, decision, fact, summary
  • agent_id / task_id: 에이전트/작업 단위 격리
  • ts: 타임스탬프
  • source: web, file, user, tool
  • hash: 정규화된 텍스트 해시(중복 제거)
  • importance: 중요도 점수(에이전트가 부여)

이 메타데이터가 있어야 필터링으로 검색 범위를 줄이고, 불필요한 주입을 막을 수 있습니다.

컬렉션 생성 예시(Python)

아래 코드는 Qdrant에 컬렉션을 만들고, 코사인 거리 기반 벡터를 저장할 준비를 합니다.

from qdrant_client import QdrantClient
from qdrant_client.http import models

QDRANT_URL = "http://localhost:6333"
COLLECTION = "autogpt_memory"
VECTOR_SIZE = 1536  # 예: text-embedding-3-small

client = QdrantClient(url=QDRANT_URL)

client.recreate_collection(
    collection_name=COLLECTION,
    vectors_config=models.VectorParams(
        size=VECTOR_SIZE,
        distance=models.Distance.COSINE,
    ),
)

# 자주 쓰는 필드에 payload index를 걸어 필터 성능을 확보
client.create_payload_index(
    collection_name=COLLECTION,
    field_name="agent_id",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
    collection_name=COLLECTION,
    field_name="task_id",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
    collection_name=COLLECTION,
    field_name="type",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
    collection_name=COLLECTION,
    field_name="ts",
    field_schema=models.PayloadSchemaType.INTEGER,
)

VECTOR_SIZE가 임베딩 모델과 다르면 즉시 검색/업서트에서 에러가 납니다. 차원 불일치 디버깅은 LangChain RAG 벡터 차원 불일치 에러 해결법을 참고하세요.

저장 단계 튜닝: “무조건 저장”을 끊어야 한다

1) 중복 제거(디듑): 해시 기반 업서트 차단

메모리 폭주의 1차 원인은 중복입니다. 텍스트를 정규화한 뒤 해시를 만들고, 동일 해시가 이미 있으면 저장하지 않습니다.

import hashlib
import re
from datetime import datetime


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


def text_hash(s: str) -> str:
    return hashlib.sha256(normalize_text(s).encode("utf-8")).hexdigest()


def should_store(memory_text: str, importance: float) -> bool:
    # 중요도 낮은 관찰은 저장하지 않도록 게이트
    return importance >= 0.35 and len(memory_text) >= 40


def build_payload(agent_id: str, task_id: str, mtype: str, text: str, importance: float):
    return {
        "agent_id": agent_id,
        "task_id": task_id,
        "type": mtype,
        "ts": int(datetime.utcnow().timestamp()),
        "hash": text_hash(text),
        "importance": float(importance),
        "text": text,
    }

실무적으로는 “저장 게이트”만 넣어도 메모리 증가율이 크게 줄어듭니다.

2) 요약 압축: 관찰을 그대로 쌓지 말고 “에피소드 요약”으로 바꾼다

매 턴의 관찰을 모두 저장하면, 나중에 검색 결과도 많아지고 주입도 늘어납니다.

권장 패턴:

  • 관찰은 임시 버퍼에 모음
  • 일정 턴 수 또는 토큰 수가 넘으면 요약 1개를 생성
  • 원본 관찰은 TTL로 만료 또는 삭제

요약은 typesummary로 저장하고, 검색 시 summary를 우선 사용하면 훨씬 안정적입니다.

검색 단계 튜닝: RAG가 단기 컨텍스트를 비대하게 만들지 않게

1) 필터링을 먼저, top-k는 작게

Qdrant 검색은 “벡터 유사도 + 필터” 조합이 강력합니다. 먼저 범위를 줄이고, top_k는 작게 시작하세요.

  • agent_id, task_id로 격리
  • type을 제한(예: fact, summary 위주)
  • 최신성 가중이 필요하면 ts를 활용한 후처리
from qdrant_client.http import models


def qdrant_search(client, query_vector, agent_id, task_id, top_k=6, score_threshold=0.25):
    flt = models.Filter(
        must=[
            models.FieldCondition(
                key="agent_id",
                match=models.MatchValue(value=agent_id),
            ),
            models.FieldCondition(
                key="task_id",
                match=models.MatchValue(value=task_id),
            ),
            models.FieldCondition(
                key="type",
                match=models.MatchAny(any=["summary", "fact"]),
            ),
        ]
    )

    hits = client.search(
        collection_name="autogpt_memory",
        query_vector=query_vector,
        query_filter=flt,
        limit=top_k,
        score_threshold=score_threshold,
        with_payload=True,
    )
    return hits

top_k를 크게 잡으면 “관련 없는 기억”이 섞여 들어오고, 그걸 다시 프롬프트에 넣으면서 토큰이 증가합니다. 보통 4에서 8 사이가 출발점입니다.

2) score threshold는 필수

score_threshold 없이 top-k만 쓰면, “유사도가 낮아도 무조건 k개”를 반환합니다. 이게 컨텍스트 오염과 메모리 팽창을 동시에 유발합니다.

실전 팁:

  • 초기엔 0.2에서 0.35 사이를 실험
  • 임베딩 모델이 바뀌면 점수 분포도 바뀌므로 재튜닝

3) MMR(다양성)로 중복 문서 주입을 줄인다

벡터 검색은 비슷한 문서가 몰려 나오는 경향이 있습니다. 결과가 서로 유사하면, 컨텍스트는 길어지는데 정보량은 늘지 않습니다.

애플리케이션 레벨에서 MMR을 적용하면 “덜 겹치게” 뽑을 수 있습니다. 아래는 간단한 형태의 MMR 스케치입니다.

import numpy as np

def cosine(a, b):
    a = np.asarray(a)
    b = np.asarray(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))


def mmr_select(query_vec, candidates, k=6, lambda_mult=0.7):
    # candidates: list of dict {"vec": ..., "payload": ...}
    selected = []
    remaining = candidates[:]

    while remaining and len(selected) < k:
        best = None
        best_score = -1e9
        for c in remaining:
            rel = cosine(query_vec, c["vec"])
            div = 0.0
            if selected:
                div = max(cosine(c["vec"], s["vec"]) for s in selected)
            score = lambda_mult * rel - (1 - lambda_mult) * div
            if score > best_score:
                best_score = score
                best = c
        selected.append(best)
        remaining.remove(best)
    return selected

MMR은 “top-k를 키우지 않고도” 정보 다양성을 올려서, 재시도 턴을 줄이는 데 도움이 됩니다.

청킹 전략: 너무 잘게도, 너무 크게도 자르지 말기

청킹이 나쁘면 검색 품질이 떨어지고, 에이전트가 더 많은 턴을 돌면서 메모리가 불어납니다.

권장 가이드(경험칙):

  • 일반 문서: 400에서 900 토큰 사이 청크
  • 코드/로그: 함수/섹션 단위로 자르되 overlap은 작게
  • overlap50에서 150 토큰 정도부터 시작

중요한 건 “청크는 검색용 단위”라는 점입니다. 프롬프트에 그대로 넣을 텍스트는 청크와 다를 수 있습니다. 즉:

  • 저장: 청크 단위
  • 주입: 청크를 요약하거나 필요한 부분만 발췌

컨텍스트 주입 예산: 토큰 상한을 코드로 강제

메모리 폭주를 막는 가장 확실한 안전장치는 “주입 예산”입니다.

  • 검색 결과를 전부 넣지 말고
  • 예산(예: 1200 토큰 또는 문자 수) 안에서만 구성

토큰 계산은 모델별 토크나이저가 필요하지만, 간단히 문자 수 기반으로도 1차 방어가 됩니다.

def build_rag_context(hits, max_chars=4000):
    parts = []
    total = 0

    for h in hits:
        text = h.payload.get("text", "")
        block = f"[memory score={h.score:.3f}]\n{text}\n"
        if total + len(block) > max_chars:
            break
        parts.append(block)
        total += len(block)

    return "\n".join(parts)

이렇게 하면 Qdrant에서 6개를 가져와도 실제 주입은 2개만 들어가는 식으로 제어됩니다.

운영 팁: Qdrant와 AutoGPT를 함께 돌릴 때 흔한 함정

1) 동시성: 저장/검색 요청 폭주를 제한

에이전트가 여러 개 돌면 Qdrant에 업서트와 검색이 동시에 몰립니다.

  • 에이전트별 큐를 두고 max_inflight 제한
  • 배치 업서트 사용
  • 저장은 비동기로 보내고, 검색만 동기 경로로 유지

2) 페이로드가 너무 크면 메모리/디스크 모두 커진다

payload["text"]를 원문 그대로 넣으면 커집니다.

  • 원문은 오브젝트 스토리지에 두고, Qdrant에는 요약/키 문장만 저장
  • 또는 text를 잘라서 저장하고, source_uri로 원문을 참조

3) 관측성: “컨텍스트 길이”를 메트릭으로 뽑아라

메모리 폭주는 대개 “프롬프트 길이 증가”가 선행 지표입니다.

최소한 아래를 로그/메트릭으로 남기면 원인 파악이 쉬워집니다.

  • 턴별 프롬프트 문자 수(또는 토큰 수)
  • RAG 주입 블록의 길이
  • Qdrant 검색 hit 수와 평균 score
  • 저장된 메모리 개수 증가율

추천 튜닝 프리셋(초기값)

프로젝트마다 다르지만, 출발점으로 아래 조합이 안정적입니다.

  • 저장 게이트: 중요도 0.35 미만 저장 금지
  • 요약 정책: 8에서 12턴마다 에피소드 요약 1개 생성
  • 검색: top_k=6, score_threshold=0.28
  • 필터: agent_id, task_id, type은 최소한 포함
  • 주입 예산: RAG 컨텍스트 max_chars=4000 내로 제한
  • 중복 억제: 정규화 해시로 동일 텍스트 저장 차단

이 프리셋으로도 메모리 폭주가 계속된다면, 대부분은 단기 컨텍스트 누적(대화 로그를 매번 붙이는 구조) 문제입니다. 이 경우 “최근 N턴 + 전체 요약 1개” 형태로 프롬프트 템플릿을 바꾸는 게 효과가 큽니다.

마무리: 폭주를 막는 건 ‘저장’이 아니라 ‘주입’이다

AutoGPT의 메모리 폭주는 표면적으로는 “장기기억이 많아서”처럼 보이지만, 실제로는 검색 결과를 단기 컨텍스트에 과도하게 주입하면서 발생하는 경우가 많습니다.

정리하면 다음 순서로 접근하세요.

  1. 단기 컨텍스트 상한(주입 예산)을 코드로 강제
  2. Qdrant 검색을 필터 + 작은 top-k + threshold로 보수적으로 시작
  3. 저장은 게이트/디듑/요약으로 ‘양’이 아니라 ‘밀도’를 관리
  4. 청킹과 MMR로 검색 품질을 올려 재시도 턴을 줄이기

이 흐름대로 튜닝하면 “메모리 폭주 없이 오래 도는 에이전트”에 가까워지고, 동시에 RAG 품질도 안정적으로 올라갑니다.