Published on

AutoGPT 메모리 폭증 해결 - pgvector+TTL 설계

Authors

AutoGPT류 에이전트(자율 실행, 반복적인 계획-실행 루프)는 시간이 지날수록 “메모리”가 기하급수로 늘어납니다. 여기서 말하는 메모리는 단순한 RAM이 아니라, 벡터 스토어에 쌓이는 임베딩과 원문(대화, 관찰, 툴 호출 결과, 요약 등) 데이터 전체를 의미합니다. 초반에는 검색이 잘 되다가, 어느 순간부터는 다음 문제가 같이 터집니다.

  • 벡터 테이블이 커지며 검색 지연이 증가
  • 유사도 검색 결과가 오래된/무의미한 기억에 오염
  • 디스크 및 백업 비용 폭증
  • 인덱스가 비대해져 VACUUM, REINDEX, 체크포인트 비용 증가
  • 멀티 테넌트 환경에서 특정 유저/에이전트가 리소스를 독점

이 글은 PostgreSQL pgvector를 기반으로, “기억은 쌓되 자동으로 잊게” 만드는 TTL 만료 설계를 제시합니다. 핵심은 다음 3가지입니다.

  1. 스키마를 “원문 이벤트”와 “벡터 인덱스 대상”으로 분리
  2. TTL을 단일 기준이 아니라, 메모리 타입/중요도/최근성에 따라 계층화
  3. 삭제가 성능을 망치지 않도록 파티셔닝 또는 배치 삭제 전략을 같이 설계

추가로, 벡터 인덱스가 기대만큼 안 타는 케이스를 점검하는 방법도 함께 다룹니다. 인덱스 자체 튜닝은 PostgreSQL 인덱스 안 타는 이유 9가지와 해결도 같이 보면 좋습니다.

왜 AutoGPT는 메모리가 폭증하는가

AutoGPT는 대체로 다음 패턴을 반복합니다.

  • 관찰(Observation) 생성: 웹 검색 결과, 파일 내용, API 응답
  • 내부 사고/계획(Thought/Plan) 생성
  • 툴 호출(Action) 및 결과 저장
  • 다음 루프에서 과거 기록을 검색해 컨텍스트에 주입

문제는 “저장 단위가 너무 세밀하고, 삭제 기준이 없다”는 점입니다. 특히 아래가 폭증의 주범입니다.

  • 매 루프마다 생성되는 중간 산출물(임시 계획, 실패한 시도)
  • 동일한 문서/페이지를 반복적으로 긁어온 결과
  • 요약을 만들었는데도 원문을 계속 보관
  • 벡터 검색용 임베딩을 원문과 1:1로 무한히 누적

해결의 방향은 명확합니다.

  • 검색에 유효한 기억만 벡터화하고
  • 원문은 보관 레벨을 나눠 비용을 제어하며
  • TTL로 자동 만료시키고
  • 삭제가 운영을 망치지 않게 물리 설계를 맞춥니다.

목표 아키텍처: pgvector + TTL + 계층형 메모리

권장 아키텍처는 아래처럼 “두 겹”으로 나누는 것입니다.

  • memory_event: 원문 이벤트 저장(텍스트, JSON, 메타데이터)
  • memory_embedding: 벡터 검색 대상(임베딩, 최소 메타데이터)

그리고 TTL 정책은 “테이블 전체 7일”처럼 단순하게 두지 말고, 보통 다음처럼 계층화합니다.

  • scratch(임시): 1~6시간
  • working(작업 메모): 1~3일
  • episodic(에피소드): 7~30일
  • semantic(정제/요약): 90일 이상 또는 수동 보존

또한 “중요도(importance)”나 “핀 고정(pinned)”을 두면 운영이 훨씬 편해집니다. 예를 들어 유저 프로필, 프로젝트 규칙, API 키 사용 정책 같은 것은 TTL로 지우면 안 됩니다.

스키마 설계 예시

아래 예시는 PostgreSQL 15+ 기준이며, pgvector 확장 설치가 필요합니다.

-- 확장
CREATE EXTENSION IF NOT EXISTS vector;

-- 원문 이벤트: 최대한 유연하게
CREATE TABLE IF NOT EXISTS memory_event (
  id              BIGSERIAL PRIMARY KEY,
  tenant_id       TEXT NOT NULL,
  agent_id        TEXT NOT NULL,
  session_id      TEXT,

  -- memory_type: scratch/working/episodic/semantic 등
  memory_type     TEXT NOT NULL,
  importance      SMALLINT NOT NULL DEFAULT 0,
  pinned          BOOLEAN NOT NULL DEFAULT FALSE,

  -- 원문(텍스트) + 구조화 메타
  content         TEXT NOT NULL,
  meta            JSONB NOT NULL DEFAULT '{}'::jsonb,

  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_accessed_at TIMESTAMPTZ,

  -- TTL 만료 시각(정책에 의해 설정)
  expires_at      TIMESTAMPTZ
);

-- 벡터 테이블: 검색에 필요한 최소한만
-- embedding 차원은 사용하는 모델에 맞춰 설정(예: 1536, 3072 등)
CREATE TABLE IF NOT EXISTS memory_embedding (
  id              BIGSERIAL PRIMARY KEY,
  tenant_id       TEXT NOT NULL,
  agent_id        TEXT NOT NULL,
  session_id      TEXT,

  event_id        BIGINT NOT NULL REFERENCES memory_event(id) ON DELETE CASCADE,

  embedding       vector(1536) NOT NULL,
  memory_type     TEXT NOT NULL,
  importance      SMALLINT NOT NULL DEFAULT 0,
  pinned          BOOLEAN NOT NULL DEFAULT FALSE,

  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at      TIMESTAMPTZ
);

-- 조회/만료에 필요한 인덱스
CREATE INDEX IF NOT EXISTS idx_event_tenant_agent_created
  ON memory_event (tenant_id, agent_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_event_expires
  ON memory_event (expires_at)
  WHERE pinned = FALSE AND expires_at IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_embed_expires
  ON memory_embedding (expires_at)
  WHERE pinned = FALSE AND expires_at IS NOT NULL;

여기서 포인트는 다음입니다.

  • expires_at을 “계산 컬럼”으로 두지 말고 명시적으로 저장합니다. 그래야 배치 삭제가 단순해지고 인덱싱이 쉽습니다.
  • pinned는 TTL 삭제 쿼리에서 빠르게 제외하기 위해 부분 인덱스를 같이 둡니다.
  • ON DELETE CASCADE로 원문이 지워지면 벡터도 같이 정리되게 합니다(반대로도 가능하지만, 원문이 기준이 되는 편이 운영이 단순합니다).

pgvector 인덱스 설계: HNSW 또는 IVFFLAT

pgvector는 대표적으로 hnsw, ivfflat 인덱스를 씁니다.

  • hnsw: 업데이트가 잦고, 높은 리콜을 원할 때 유리(일반적으로 운영 난이도 낮음)
  • ivfflat: 대규모 데이터에서 메모리 효율이 좋지만, ANALYZE/튜닝과 쿼리 패턴에 민감

AutoGPT 메모리는 “쓰기 많고, TTL 삭제도 많고, 조회는 상위 k개”인 패턴이므로 hnsw가 무난한 선택인 경우가 많습니다.

-- hnsw 인덱스(코사인 거리)
CREATE INDEX IF NOT EXISTS idx_embed_hnsw
  ON memory_embedding
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 128);

-- 필터링에 쓰는 보조 인덱스
CREATE INDEX IF NOT EXISTS idx_embed_tenant_agent
  ON memory_embedding (tenant_id, agent_id);

중요: 벡터 검색은 보통 아래처럼 “벡터 유사도 + 메타 필터”가 결합됩니다. 이때 플래너가 기대대로 인덱스를 못 타는 경우가 많습니다. 그런 상황을 점검하는 기본기는 위에서 언급한 글(PostgreSQL 인덱스 안 타는 이유 9가지와 해결)이 도움이 됩니다.

TTL 정책: 타입별 만료 + 중요도 가중치

TTL을 단순히 “생성 후 7일”로 두면 두 가지 문제가 생깁니다.

  • 자주 참조되는 유용한 기억도 같이 삭제
  • 쓸모없는 임시 기억이 7일간 쌓여 폭증

그래서 다음처럼 “기본 TTL + 중요도 보정”을 추천합니다.

  • scratch: 6시간
  • working: 3일
  • episodic: 14일
  • semantic: 180일
  • importance가 높으면 TTL을 늘림
  • last_accessed_at이 최근이면 TTL 연장(선택)

애플리케이션에서 expires_at을 계산해 넣는 예시(Python)입니다.

from datetime import datetime, timedelta, timezone

TTL_BY_TYPE = {
    "scratch": timedelta(hours=6),
    "working": timedelta(days=3),
    "episodic": timedelta(days=14),
    "semantic": timedelta(days=180),
}

def compute_expires_at(memory_type: str, importance: int, pinned: bool) -> datetime | None:
    if pinned:
        return None

    base = TTL_BY_TYPE.get(memory_type, timedelta(days=7))

    # importance 0~10 가정: 1단계당 10% 연장(상한 2배)
    factor = min(2.0, 1.0 + max(0, importance) * 0.1)
    ttl = timedelta(seconds=int(base.total_seconds() * factor))

    return datetime.now(timezone.utc) + ttl

여기서 expires_at = NULL은 “만료 없음”으로 해석합니다. 이 규칙은 쿼리/인덱스/배치 삭제에서 일관되게 유지해야 합니다.

검색 쿼리 패턴: 만료/핀/테넌트 필터를 먼저

유사도 검색은 대략 아래처럼 작성합니다.

-- query_embedding은 애플리케이션에서 파라미터로 전달
-- 유효한(만료되지 않은) 메모리만 대상으로 top-k 검색
SELECT
  me.event_id,
  me.memory_type,
  me.importance,
  me.created_at,
  (me.embedding <=> $1) AS distance
FROM memory_embedding me
WHERE me.tenant_id = $2
  AND me.agent_id = $3
  AND (me.pinned = TRUE OR me.expires_at IS NULL OR me.expires_at > now())
ORDER BY me.embedding <=> $1
LIMIT 20;

팁:

  • expires_at > now() 조건이 항상 들어가면 플래너가 보수적으로 변할 수 있습니다. 대신 “만료된 데이터는 물리적으로 삭제”하는 TTL 배치를 강하게 가져가면, 조회 쿼리에서 만료 조건을 단순화할 수 있습니다.
  • 멀티 테넌트라면 tenant_id, agent_id 필터는 필수입니다. 그렇지 않으면 한 테넌트의 데이터가 인덱스 탐색 비용을 올립니다.

TTL 삭제 전략 3가지: 소규모부터 대규모까지

TTL을 “정책”으로만 두고 삭제를 안 하면 결국 폭증은 계속됩니다. 운영에서 중요한 건 “어떻게 지울 것인가”입니다.

1) 배치 삭제: 가장 단순하고 범용

크론 또는 워커로 주기적으로 만료 데이터를 지웁니다.

-- 한번에 너무 많이 지우면 락/IO가 커지므로 배치로
WITH doomed AS (
  SELECT id
  FROM memory_event
  WHERE pinned = FALSE
    AND expires_at IS NOT NULL
    AND expires_at <= now()
  ORDER BY expires_at
  LIMIT 5000
)
DELETE FROM memory_event e
USING doomed d
WHERE e.id = d.id;
  • memory_event만 지워도 memory_embeddingON DELETE CASCADE로 같이 정리됩니다.
  • 배치 크기는 DB 스펙과 트래픽에 따라 조정합니다.

2) 파티셔닝: 대규모에서 삭제 비용을 “드롭”으로 바꾸기

데이터가 수천만 건으로 커지면, DELETE는 VACUUM 부담이 커집니다. 이때는 시간 기준 파티셔닝으로 “파티션 드롭”을 사용합니다.

예시: created_at 월별 파티션.

CREATE TABLE memory_event (
  id BIGSERIAL,
  tenant_id TEXT NOT NULL,
  agent_id TEXT NOT NULL,
  memory_type TEXT NOT NULL,
  importance SMALLINT NOT NULL DEFAULT 0,
  pinned BOOLEAN NOT NULL DEFAULT FALSE,
  content TEXT NOT NULL,
  meta JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ,
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- 예: 2026-02 파티션
CREATE TABLE memory_event_2026_02
  PARTITION OF memory_event
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

파티셔닝을 쓰면 TTL은 “이 파티션은 이제 전부 만료” 같은 시점에 DROP TABLE로 정리할 수 있습니다. 단, expires_at이 타입별로 다르면 파티션 드롭만으로는 부족할 수 있어, 보통은 “짧은 TTL 데이터는 파티션 드롭”, “긴 TTL 데이터는 배치 삭제”처럼 혼합합니다.

3) Soft delete + 비동기 정리: 높은 트래픽에서 안전

삭제가 즉시 필요하지만 DB 부하를 분산하고 싶다면 deleted_at을 두고, 조회에서는 제외한 뒤 백그라운드에서 물리 삭제합니다. 다만 이 방식은 테이블이 계속 커질 수 있으니, 장기적으로는 파티셔닝 또는 주기적 컴팩션 전략이 필요합니다.

메모리 품질을 올리는 핵심: “요약을 남기고 원문을 버려라”

폭증을 막는 가장 강력한 방법은 TTL만이 아닙니다. 저장 자체를 줄여야 합니다.

권장 패턴:

  • scratch 원문은 짧게 유지(또는 아예 저장하지 않기)
  • 일정 루프마다 episodicsemantic 요약으로 승격
  • 요약이 생성되면, 원문은 TTL을 더 짧게 재설정

예: 원문 이벤트가 요약된 경우 TTL을 재조정.

UPDATE memory_event
SET expires_at = now() + interval '24 hours'
WHERE tenant_id = $1
  AND agent_id = $2
  AND memory_type IN ('scratch','working')
  AND pinned = FALSE
  AND meta ? 'summarized_into_event_id';

이렇게 하면 “검색은 요약(semantic) 위주로”, “원문은 짧게”라는 구조가 만들어져 검색 품질과 비용이 동시에 좋아집니다.

운영 체크리스트: 폭증을 조기에 잡는 지표

다음 지표를 대시보드로 걸어두면 폭증을 조기에 감지할 수 있습니다.

  • 테넌트/에이전트별 memory_event 건수, 증가율
  • memory_embedding 테이블 크기, 인덱스 크기
  • TTL 삭제 배치의 처리량(초당 삭제 row), 지연(만료 후 실제 삭제까지 시간)
  • 유사도 검색 p95/p99 latency
  • VACUUMautovacuum 지연, dead tuple 비율

또한 에이전트가 외부 API를 많이 호출하는 환경이라면, 재시도 폭주가 메모리 폭증으로 이어질 수 있습니다(실패 로그가 반복 저장). 이런 경우 백오프/재시도 정책을 먼저 안정화하는 것이 중요합니다. 관련해서는 LangChain에서 OpenAI 429·타임아웃 재시도 백오프를 참고해 재시도 설계를 점검해보세요.

장애 패턴과 처방

검색이 느려졌는데 인덱스가 있는 것 같은데도 느리다

  • EXPLAIN (ANALYZE, BUFFERS)로 실제로 hnsw 또는 ivfflat가 사용되는지 확인
  • tenant_id, agent_id 필터가 선택도를 충분히 만드는지 확인
  • 통계가 오래되면 ANALYZE가 안 맞아 플래너가 잘못 판단할 수 있음

인덱스가 기대대로 안 타는 원인과 해결은 앞서 언급한 글(PostgreSQL 인덱스 안 타는 이유 9가지와 해결)의 체크리스트가 그대로 적용됩니다.

TTL 삭제가 DB를 때린다

  • 한 번에 크게 지우지 말고 LIMIT 배치로 쪼개기
  • 트래픽 낮은 시간대에 실행
  • 파티셔닝으로 “드롭 삭제” 비중 확대
  • autovacuum 설정을 테이블별로 조정(대규모 테이블은 기본값으로 부족한 경우가 많음)

유사도 검색 결과가 오래된 기억에 오염된다

  • TTL을 더 공격적으로(특히 scratch, working)
  • importance 낮은 항목은 더 빨리 만료
  • 검색 시 memory_type 가중치 또는 필터 적용

예: 최신성 가중을 위해 created_at을 함께 점수화하는 방식도 있습니다. 다만 점수 결합은 애플리케이션 레이어에서 하는 편이 단순합니다.

마무리: “기억”은 자산이 아니라 부채가 될 수 있다

AutoGPT의 메모리 폭증은 단순히 저장공간 문제가 아니라, 검색 품질 저하와 운영 비용 폭탄으로 이어지는 전형적인 “데이터 부채”입니다. pgvector를 쓴다면 다음 순서로 접근하는 것이 가장 안전합니다.

  1. 원문과 벡터를 분리하고, expires_at을 명시적으로 저장
  2. 타입별 TTL과 pinned, importance로 정책을 계층화
  3. 배치 삭제를 먼저 도입하고, 규모가 커지면 파티셔닝으로 전환
  4. 요약 승격 전략으로 “저장량 자체”를 줄이기

이 조합이면 메모리 폭증을 막으면서도, 에이전트의 장기 문맥 유지 능력은 오히려 더 안정적으로 만들 수 있습니다.