- Published on
AutoGPT 메모리 폭증 해결 - pgvector+TTL 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT류 에이전트(자율 실행, 반복적인 계획-실행 루프)는 시간이 지날수록 “메모리”가 기하급수로 늘어납니다. 여기서 말하는 메모리는 단순한 RAM이 아니라, 벡터 스토어에 쌓이는 임베딩과 원문(대화, 관찰, 툴 호출 결과, 요약 등) 데이터 전체를 의미합니다. 초반에는 검색이 잘 되다가, 어느 순간부터는 다음 문제가 같이 터집니다.
- 벡터 테이블이 커지며 검색 지연이 증가
- 유사도 검색 결과가 오래된/무의미한 기억에 오염
- 디스크 및 백업 비용 폭증
- 인덱스가 비대해져 VACUUM, REINDEX, 체크포인트 비용 증가
- 멀티 테넌트 환경에서 특정 유저/에이전트가 리소스를 독점
이 글은 PostgreSQL pgvector를 기반으로, “기억은 쌓되 자동으로 잊게” 만드는 TTL 만료 설계를 제시합니다. 핵심은 다음 3가지입니다.
- 스키마를 “원문 이벤트”와 “벡터 인덱스 대상”으로 분리
- TTL을 단일 기준이 아니라, 메모리 타입/중요도/최근성에 따라 계층화
- 삭제가 성능을 망치지 않도록 파티셔닝 또는 배치 삭제 전략을 같이 설계
추가로, 벡터 인덱스가 기대만큼 안 타는 케이스를 점검하는 방법도 함께 다룹니다. 인덱스 자체 튜닝은 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_embedding은ON 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원문은 짧게 유지(또는 아예 저장하지 않기)- 일정 루프마다
episodic을semantic요약으로 승격 - 요약이 생성되면, 원문은 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
VACUUM및autovacuum지연, 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를 쓴다면 다음 순서로 접근하는 것이 가장 안전합니다.
- 원문과 벡터를 분리하고,
expires_at을 명시적으로 저장 - 타입별 TTL과
pinned,importance로 정책을 계층화 - 배치 삭제를 먼저 도입하고, 규모가 커지면 파티셔닝으로 전환
- 요약 승격 전략으로 “저장량 자체”를 줄이기
이 조합이면 메모리 폭증을 막으면서도, 에이전트의 장기 문맥 유지 능력은 오히려 더 안정적으로 만들 수 있습니다.