Published on

AutoGPT/BabyAGI 메모리 누수? ChromaDB GC·압축

Authors

서버에서 AutoGPT/BabyAGI를 장시간 실행하면 RSS 메모리가 계단형으로 증가하고, 어느 순간 OOMKilled 또는 응답 지연으로 이어지는 경우가 많습니다. 겉으로는 “메모리 누수”처럼 보이지만, 실제로는 벡터 스토어에 쌓이는 문서/임베딩, 삭제 후에도 즉시 반환되지 않는 메모리, GC가 늦게 도는 파이썬 런타임 특성, 백그라운드 작업/큐가 비워지지 않는 구조가 합쳐진 “누적형 메모리 증가”인 경우가 흔합니다.

이 글에서는 AutoGPT/BabyAGI에서 자주 쓰는 ChromaDB를 기준으로, 메모리 증가를 재현·관찰하는 법부터 GC 트리거, 컬렉션 관리(삭제/아카이빙/TTL), 압축(컴팩션) 관점의 운영 전략까지 한 번에 정리합니다.

참고: 누수 진단 관점은 언어가 달라도 패턴이 유사합니다. 고루틴 누수 체크리스트가 필요하면 Go 고루틴 누수 진단 - context·채널 close 7패턴도 함께 보면 원인 분리가 빨라집니다.

1) “누수”처럼 보이는 대표 원인 4가지

1-1. 벡터 스토어가 계속 커지는 정상 동작

AutoGPT/BabyAGI는 목표 달성 과정에서 검색 가능한 메모리를 축적합니다. 즉, 새로운 태스크, 웹 스크랩, 요약 결과가 계속 add 되면 Chroma 컬렉션 크기는 계속 증가합니다. 이건 누수가 아니라 설계입니다.

문제는 다음 조건이 겹칠 때 발생합니다.

  • 동일/유사 문서가 중복으로 계속 들어감(중복 제거 없음)
  • 태스크 단위로 컬렉션을 분리하지 않고 하나에 계속 적재
  • 오래된 메모리를 삭제하지 않음
  • 삭제하더라도 인덱스/스토리지 레벨에서 공간이 즉시 줄지 않음

1-2. 삭제는 했는데 RSS가 안 줄어드는 현상

파이썬은 객체 참조가 끊겨도 메모리 풀/아레나 정책 때문에 OS에 즉시 반환되지 않는 경우가 많습니다. 또한 NumPy/FAISS/BLAS 계열 네이티브 메모리도 RSS를 유지할 수 있습니다.

즉, “삭제했는데 top에서 메모리가 안 내려간다”는 것만으로 누수라고 단정하기 어렵습니다. 대신 다음을 확인해야 합니다.

  • 프로세스 내부 객체 수가 계속 증가하는지
  • 컬렉션 카운트/인덱스 파일 크기가 계속 증가하는지
  • 요청량이 줄어도 메모리가 계속 증가하는지(진짜 누수 신호)

1-3. Chroma 클라이언트/컬렉션 객체를 매 요청 생성

웹 API로 에이전트를 감싸면, 요청마다 chromadb.Client() 또는 get_collection()을 새로 만들고 전역으로 쌓이거나, 세션 캐시에 남는 경우가 있습니다.

권장 패턴은 프로세스당 클라이언트 1개(또는 워커당 1개)로 재사용하고, 컬렉션도 필요 이상으로 생성하지 않는 것입니다.

1-4. 백그라운드 작업 큐가 비워지지 않는 구조

AutoGPT/BabyAGI는 태스크를 생성하고 다시 태스크를 생성하는 루프 구조입니다. 다음이 있으면 메모리 증가가 가속됩니다.

  • 태스크 결과를 전부 메모리에 보관(로그/트레이스 포함)
  • 실패 재시도 큐가 무한히 쌓임
  • 스트리밍 응답을 버퍼링하고 해제하지 않음

2) 먼저 관측: “Chroma가 문제인지” 10분 안에 확인하기

2-1. 컬렉션 카운트와 프로세스 RSS를 같이 찍기

다음은 Chroma 컬렉션 개수/문서 수와 RSS를 주기적으로 로깅하는 예시입니다.

import os
import time
import psutil
import chromadb

process = psutil.Process(os.getpid())
client = chromadb.PersistentClient(path="./chroma")
col = client.get_or_create_collection(name="agent_memory")

while True:
    rss_mb = process.memory_info().rss / 1024 / 1024
    count = col.count()
    print(f"rss_mb={rss_mb:.1f} count={count}")
    time.sleep(10)
  • count가 증가하면서 RSS도 같이 증가하면 “정상 누적”일 가능성이 큽니다.
  • count가 일정한데 RSS만 계속 증가하면 “진짜 누수” 가능성이 커집니다(캐시, 참조 유지, 네이티브 메모리 등).

2-2. 파이썬 힙 추적: tracemalloc으로 상위 할당 위치 확인

import tracemalloc
import time

tracemalloc.start()

# ... 에이전트 루프 실행 ...

time.sleep(60)

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:20]:
    print(stat)

Chroma 자체라기보다, 프롬프트/로그 문자열 누적, 문서 원문을 통째로 들고 있는 리스트, 중복 임베딩 배치 같은 곳이 상위에 뜨는 경우가 많습니다.

3) ChromaDB “GC”를 현실적으로 이해하기

ChromaDB에는 언어 런타임의 GC처럼 “버튼 하나로 메모리가 싹 정리되는” 개념이 있는 게 아니라, 보통 다음 3층을 나눠서 봐야 합니다.

  1. 파이썬 객체 GC: gc.collect()로 순환 참조 정리 가능
  2. 네이티브 메모리/메모리 풀: OS 반환이 즉시 안 될 수 있음
  3. 스토리지/인덱스 공간: 삭제해도 파일이 곧바로 줄지 않을 수 있음(컴팩션 필요)

따라서 “ChromaDB GC”는 실무적으로는 (A) 파이썬 GC 트리거 + (B) 컬렉션 단위 정리 + (C) 주기적인 재구성/컴팩션의 조합으로 접근합니다.

4) 파이썬 GC 트리거: 효과가 있는 케이스/없는 케이스

4-1. 루프 단위로 gc.collect()를 호출할 때

에이전트가 태스크를 수천 번 반복하면, 순환 참조가 쌓여 GC 타이밍이 늦어질 수 있습니다. 이때는 루프 단위로 제한적으로 호출하는 것이 도움이 됩니다.

import gc

def run_agent_loop(iterations: int = 1000):
    for i in range(iterations):
        # ... plan / act / store ...
        if i % 50 == 0:
            unreachable = gc.collect()
            # 너무 자주 호출하면 오히려 성능 저하
            print(f"gc collected: {unreachable}")

다만 다음 상황에서는 gc.collect()가 RSS를 크게 줄이지 못합니다.

  • NumPy/FAISS 등 네이티브 메모리 비중이 큰 경우
  • 파이썬 메모리 풀이 OS에 반환하지 않는 경우

이때는 “RSS가 줄어드는가”보다 “증가 속도가 완만해지는가”를 봐야 합니다.

4-2. 문자열/로그 누적을 먼저 의심

AutoGPT/BabyAGI는 디버그 로그가 길고, 프롬프트/응답이 커서 메모리를 빠르게 먹습니다. 로그를 파일로 스트리밍하고 메모리에 쌓지 않도록 바꾸는 것만으로도 효과가 큽니다.

5) Chroma 컬렉션 관리: TTL, 아카이빙, 세그먼트 분리

5-1. “하나의 컬렉션에 영원히 쌓기”를 금지

운영에서 가장 안전한 방식은 런/세션/날짜 단위로 컬렉션을 분리하고, 오래된 컬렉션을 통째로 삭제(또는 아카이빙)하는 것입니다.

  • agent_memory_2026_02_25
  • agent_memory_run_ + uuid

이렇게 하면 개별 문서 삭제보다 훨씬 단순하고, 인덱스 단편화도 줄어듭니다.

import uuid
import chromadb

client = chromadb.PersistentClient(path="./chroma")
run_id = str(uuid.uuid4())
col = client.get_or_create_collection(name=f"agent_memory_run_{run_id}")

5-2. 메타데이터로 TTL 흉내 내기(주기적 purge)

Chroma가 DB처럼 완전한 TTL을 제공하지 않는 환경이라면, 문서에 created_at을 넣고 주기적으로 삭제합니다.

import time

now = int(time.time())
col.add(
    ids=["m1"],
    documents=["..."],
    metadatas=[{"created_at": now, "type": "thought"}],
)

# purge: 7일 이전 데이터 삭제
cutoff = int(time.time()) - 7 * 24 * 3600
# Chroma where 필터는 버전에 따라 지원 범위가 다를 수 있음
old = col.get(where={"created_at": {"$lt": cutoff}}, include=["ids"])
if old["ids"]:
    col.delete(ids=old["ids"])

핵심은 “삭제” 자체보다, 삭제 대상이 명확하고 주기적으로 실행되는가입니다.

5-3. 중복 삽입 방지: 콘텐츠 해시를 ID로 사용

동일 문서가 계속 들어가면 메모리/디스크 모두 폭증합니다. 가장 간단한 방어는 문서 본문 해시를 ID로 써서 upsert처럼 동작하게 만드는 것입니다.

import hashlib

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

text = "some scraped content"
col.add(ids=[doc_id(text)], documents=[text])

이렇게 하면 같은 텍스트가 다시 들어와도 중복이 크게 줄어듭니다.

6) “압축(컴팩션)” 전략: 삭제 후 공간 회수는 별도 작업이다

Chroma를 Persistent 모드로 쓰면 디스크에 인덱스/세그먼트가 남습니다. 많은 DB/LSM 계열 시스템처럼, 삭제는 tombstone으로 남고 공간 회수는 컴팩션에서 일어나는 패턴이 흔합니다.

Chroma 내부 구현과 버전에 따라 컴팩션 동작은 달라질 수 있지만, 실무에서 통하는 접근은 다음 3가지입니다.

6-1. 가장 확실한 컴팩션: “새 컬렉션으로 재구성”

운영 중인 컬렉션에서 오래된 데이터를 지우고 계속 쓰면 단편화가 누적됩니다. 가장 확실한 압축은 필요한 데이터만 새 컬렉션으로 복사한 뒤, 기존 컬렉션을 삭제하는 것입니다.

import chromadb

client = chromadb.PersistentClient(path="./chroma")
old_col = client.get_collection("agent_memory")
new_col = client.get_or_create_collection("agent_memory_compacted")

batch = old_col.get(include=["ids", "documents", "metadatas"])
new_col.add(ids=batch["ids"], documents=batch["documents"], metadatas=batch["metadatas"])

# 스위칭 후 기존 컬렉션 삭제
client.delete_collection("agent_memory")
# 새 컬렉션을 표준 이름으로 다시 만들고 싶다면, 다시 복사/rename 전략을 사용

장점: 효과가 확실함 단점: 컬렉션이 크면 시간이 걸림(오프피크에 수행 권장)

6-2. “세션 단위 컬렉션”으로 컴팩션 자체를 회피

앞서 말한 것처럼 세션/런 단위로 컬렉션을 쪼개면, 오래된 세션은 컬렉션 삭제로 끝납니다. 이게 사실상 가장 운영 친화적인 “압축”입니다.

6-3. 프로세스 재시작을 운영 루틴에 포함

파이썬/네이티브 메모리 풀 때문에 RSS가 잘 안 내려가는 서비스는, 일정 주기 또는 임계치에서 graceful restart를 넣는 것이 현실적인 해법이 됩니다.

  • 예: RSS 2.5GB 초과 시 워커 롤링 재시작
  • 예: 하루 1회 오프피크 재시작

쿠버네티스라면 liveness/readiness와 함께 HPA보다 먼저 메모리 상한 기반의 재시작 정책을 검토할 수 있습니다. 이미지 풀/배포 이슈로 장애가 나지 않게 운영 체크리스트도 함께 갖추는 게 좋습니다. 필요하면 Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트도 참고하세요.

7) AutoGPT/BabyAGI 코드 레벨에서 메모리 증가를 줄이는 팁

7-1. “원문 저장” 대신 “요약 저장”으로 전환

벡터 검색이 목적이라면 원문 전체를 저장할 필요가 없습니다.

  • 원문: 객체 크기 큼, 중복 많음
  • 요약: 토큰 수 제한 가능, 검색 품질도 종종 더 좋음
def to_memory_text(raw: str, max_chars: int = 2000) -> str:
    raw = raw.strip()
    return raw if len(raw) <= max_chars else raw[:max_chars] + "..."

col.add(ids=["x"], documents=[to_memory_text(long_text)])

7-2. top-k/score threshold로 “덜 넣고 덜 찾기”

검색 결과를 많이 가져오면 프롬프트가 비대해지고, 그 프롬프트를 다시 메모리에 저장하는 악순환이 생깁니다.

  • retrieval k를 줄이고
  • 유사도 임계치를 두고
  • 태스크 타입별로 네임스페이스(컬렉션/메타데이터) 분리

7-3. 동시성 제어: 임베딩 배치 크기 제한

임베딩을 한 번에 크게 돌리면 일시적으로 메모리 피크가 튑니다. 작업 큐/세마포어로 동시 임베딩 수를 제한하세요.

import asyncio

sem = asyncio.Semaphore(2)  # 동시에 2개만 임베딩

async def embed_and_store(text: str):
    async with sem:
        # embedding 호출
        # col.add(...)
        pass

8) 운영 체크리스트: “누수”를 장애로 만들지 않는 장치

  • 컬렉션 크기/문서 수/디스크 사용량을 메트릭으로 노출
  • RSS, Python 힙, GC time을 함께 관측
  • 세션 단위 컬렉션 또는 날짜 파티셔닝
  • 중복 방지 ID(해시) 적용
  • purge(보존기간) 잡을 크론으로 고정
  • 컴팩션은 “재구성”으로 오프피크 수행
  • 메모리 임계치 기반 롤링 재시작

동시성으로 인한 리소스 고갈은 DB 커넥션 문제와도 구조가 유사합니다. 에이전트를 API로 감쌀 때 요청이 늘면 외부 리소스가 먼저 고갈되는 경우가 많으니, 필요하면 Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기처럼 “동시성은 늘었는데 병목 리소스는 그대로”인 상황을 함께 점검하는 것이 좋습니다.

9) 결론: ChromaDB GC·압축은 “기능”이 아니라 “운영 설계”다

AutoGPT/BabyAGI의 메모리 증가는 많은 경우 ChromaDB 자체 버그라기보다, 메모리(벡터) 축적을 무제한으로 허용한 설계삭제/보존/재구성 루틴 부재에서 시작합니다.

정리하면 우선순위는 다음이 가장 효율적입니다.

  1. 관측: count, RSS, 힙 스냅샷으로 원인 분리
  2. 축적 제한: 중복 제거, 요약 저장, top-k 축소
  3. 수명 관리: 세션/날짜 파티셔닝 + purge
  4. 공간 회수: 필요 시 새 컬렉션 재구성(사실상 컴팩션)
  5. 안정장치: 임계치 기반 롤링 재시작

이렇게 접근하면 “메모리 누수처럼 보이는” 문제의 대부분을 예측 가능하고 운영 가능한 형태로 바꿀 수 있습니다.