- Published on
AutoGPT 메모리 누수? 벡터DB·요약·TTL 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT 같은 에이전트 프레임워크를 운영 환경에 올리면, 며칠~몇 주만 지나도 다음 증상이 같이 나타나는 경우가 많습니다.
- 프로세스 RSS가 계속 증가하거나, 컨테이너가
OOMKilled로 재시작됨 - 벡터DB 인덱스가 비대해져 검색이 느려짐
- 컨텍스트가 길어져 LLM 호출 비용과 지연이 선형으로 증가
- “기억”이 많아질수록 오히려 품질이 떨어짐(노이즈/중복/오래된 정보)
이걸 흔히 “메모리 누수”라고 부르지만, 실제로는 두 층위가 섞여 있습니다.
- 런타임 메모리 누수: 프로세스가 참조를 놓지 못해 GC가 못 치우는 문제
- 제품 설계 상의 누수: 에이전트가 저장하는 장기 기억(로그, 임베딩, 요약, 상태)이 계속 누적되는 문제
AutoGPT류에서 더 자주 터지는 건 2번입니다. 즉, “메모리”를 기능으로 구현했는데 삭제/만료/요약/압축 전략이 없어서 시스템이 스스로 비대해지는 겁니다. 이 글은 2번을 중심으로, 벡터DB·요약·TTL 설계를 한 번에 잡는 실전 패턴을 정리합니다.
왜 AutoGPT의 기억은 누수처럼 커지는가
에이전트의 기억은 보통 아래 3종을 섞어서 씁니다.
- 단기 기억(working memory): 현재 작업에 필요한 메시지/툴 결과
- 중기 기억(episodic memory): 최근 세션의 진행 기록, 중간 산출물
- 장기 기억(semantic memory): 사용자 선호, 지식, 문서, 규칙, 벡터 임베딩
문제는 대부분의 구현이 “저장”만 있고 “수명”이 없습니다.
- 모든 툴 결과를 그대로 임베딩해서 벡터DB에 넣는다
- 매 턴마다 요약을 덧붙이되 이전 요약을 교체하지 않는다
- 동일한 사실을 여러 번 저장한다(중복)
- 검색 결과를 다시 컨텍스트에 넣고, 그 컨텍스트를 다시 저장한다(피드백 루프)
결국 시간이 지날수록 검색 후보가 늘고, 컨텍스트가 길어지고, LLM 호출이 비싸지고, 품질은 떨어지는 악순환이 생깁니다.
설계 목표: “기억”을 데이터 제품으로 취급하기
운영 가능한 에이전트 메모리는 다음을 만족해야 합니다.
- 수명(TTL): 기억은 기본적으로 만료된다
- 가치 기반 보존: 중요한 기억만 남는다(나머지는 요약/삭제)
- 중복 제거: 동일 의미는 하나로 수렴
- 관측 가능성: 저장량, 검색 히트율, 요약 압축률, 비용을 측정
- GC 가능성: 삭제가 실제로 공간/성능 회복으로 이어짐
여기서 핵심은 “벡터DB + 요약 + TTL”을 각각 따로가 아니라, 하나의 파이프라인으로 연결하는 겁니다.
전체 아키텍처: Raw 로그는 짧게, 요약은 길게, 벡터는 선택적으로
권장 파이프라인을 단계로 나누면 이렇습니다.
- Raw 이벤트 저장(짧은 TTL): 사용자 입력, 툴 호출, 결과, 에러 등
- 세션 요약(중간 TTL): 일정 토큰/턴마다 요약을 “교체” 방식으로 갱신
- 장기 기억 후보 추출: 요약에서 “사실/선호/규칙/결정”만 뽑아 구조화
- 벡터DB 인덱싱(선별): 장기 기억 후보 중 검색 가치 있는 것만 임베딩
- 만료/삭제/병합 GC: TTL 만료 + 중복 병합 + 낮은 가치 삭제
중요한 포인트는 다음입니다.
- 벡터DB는 모든 것을 담는 쓰레기통이 아니라 검색 인덱스입니다.
- Raw 로그는 디버깅에 유용하지만, 장기적으로는 비용만 키우므로 TTL이 짧아야 합니다.
- 요약은 “추가”가 아니라 “교체/압축”이 기본입니다.
벡터DB 설계: 스키마와 메타데이터가 성능을 좌우한다
벡터DB에 최소한 아래 메타데이터는 넣어야 TTL/GC가 가능합니다.
tenant_id/user_idsession_idmemory_type(fact,preference,rule,artifact등)created_at,last_accessed_atttl_expires_atimportance_score(0~1)source_hash(중복 제거용)version(요약/갱신 시)
예시(개념 스키마, PostgreSQL + pgvector 가정):
-- 벡터 저장 테이블
create table agent_memory (
id bigserial primary key,
tenant_id text not null,
user_id text not null,
session_id text,
memory_type text not null,
content text not null,
embedding vector(1536) not null,
source_hash text not null,
importance_score real not null default 0.3,
created_at timestamptz not null default now(),
last_accessed_at timestamptz,
ttl_expires_at timestamptz not null,
version int not null default 1
);
create index on agent_memory (tenant_id, user_id, memory_type);
create index on agent_memory (ttl_expires_at);
-- pgvector 인덱스(예: ivfflat)
create index agent_memory_embedding_idx
on agent_memory using ivfflat (embedding vector_cosine_ops);
만약 PostgreSQL를 쓰면, 삭제/업데이트가 쌓일 때 성능이 저하될 수 있습니다. 이때는 dead tuple과 autovacuum 튜닝이 실제로 중요해집니다. 운영 중 “삭제했는데 용량/성능이 안 돌아온다”면 아래 글도 같이 보세요.
TTL 전략: “전부 동일 TTL”은 실패한다
기억은 종류별로 TTL이 달라야 합니다. 예를 들어:
- Raw 이벤트: 1~7일
- 세션 요약: 14~30일
- 사용자 선호(preference): 180일(갱신 시 연장)
- 규칙/정책(rule): 365일(버전 관리)
- 작업 산출물(artifact): 프로젝트 종료 시점까지
TTL을 결정할 때는 “시간”만 보지 말고 접근 기반(access-based) 만료를 섞는 게 효과적입니다.
ttl_expires_at = created_at + base_ttl- 단, 검색에 실제로 사용되어
last_accessed_at이 갱신되면 TTL을 일정 범위에서 연장
예시 로직(의사코드):
function computeExpiry({
memoryType,
createdAt,
lastAccessedAt,
importanceScore,
}: {
memoryType: string;
createdAt: Date;
lastAccessedAt?: Date;
importanceScore: number;
}) {
const baseDaysByType: Record<string, number> = {
raw: 3,
summary: 21,
fact: 90,
preference: 180,
rule: 365,
};
const baseDays = baseDaysByType[memoryType] ?? 30;
// 중요도가 높을수록 조금 더 길게
const importanceBoost = Math.round(baseDays * Math.min(0.5, importanceScore));
// 최근 접근이 있다면 연장(최대 2배)
const anchor = lastAccessedAt ?? createdAt;
const days = Math.min(baseDays * 2, baseDays + importanceBoost);
return new Date(anchor.getTime() + days * 24 * 60 * 60 * 1000);
}
핵심은 “기억은 기본적으로 사라진다”를 기본값으로 두는 것입니다.
요약 설계: append가 아니라 replace, 그리고 계층화
AutoGPT 계열에서 비용 폭증의 주범은 대개 컨텍스트가 계속 늘어나는 것입니다. 이를 막는 가장 단순하고 강력한 방법은 요약을 누적하지 않고 교체하는 것입니다.
1) 슬라이딩 윈도우 + 세션 요약 교체
- 최근
N턴은 원문 유지(정확도) - 그 이전은 “세션 요약” 1개로 유지(압축)
- 요약은 일정 턴마다 새로 만들고 기존 요약을 덮어씀
type Message = { role: "user" | "assistant" | "tool"; content: string };
function buildContext({
recentMessages,
sessionSummary,
}: {
recentMessages: Message[];
sessionSummary?: string;
}) {
const system = {
role: "assistant" as const,
content: "You are an agent. Use tools when needed.",
};
const summaryBlock = sessionSummary
? [{ role: "assistant" as const, content: `Session summary: ${sessionSummary}` }]
: [];
return [system, ...summaryBlock, ...recentMessages];
}
2) 계층 요약(hierarchical summarization)
긴 프로젝트라면 요약도 계층이 필요합니다.
- 일별 요약
- 주간 요약
- 프로젝트 요약
그리고 하위 요약이 갱신되면 상위 요약도 재생성하거나 부분 업데이트합니다. 이렇게 하면 “요약이 요약을 낳는” 구조가 아니라, 트리 형태로 압축됩니다.
3) 요약 품질을 올리는 프롬프트 규칙
요약은 문학적으로 예쁘게 쓰는 게 아니라, 검색/의사결정에 쓰기 좋게 구조화해야 합니다.
- 사실은 원자적으로 쪼개기
- 날짜/버전/결정사항을 명시
- 불확실성은 불확실하다고 표시
- 개인 정보는 최소화
예시 템플릿(인라인 코드로만 부등호 회피):
Summarize the session into:
1) Decisions (bullet)
2) Facts learned (bullet, atomic)
3) Open questions (bullet)
4) User preferences (bullet)
Keep it concise. If uncertain, mark as "uncertain".
벡터DB에 무엇을 넣을지: “검색 가치”로 게이트를 둔다
가장 흔한 실패는 “모든 메시지 임베딩”입니다. 대신 아래 기준으로 인덱싱 게이트를 둡니다.
- 재사용 가능성: 다음 세션에서도 쓸 정보인가?
- 범용성: 특정 턴에만 의미 있는 로그가 아닌가?
- 안정성: 사실로 확정됐나, 추측인가?
- 민감도: 개인정보/비밀이 포함되나?
실무적으로는 “장기 기억 후보 추출기”를 따로 두는 게 좋습니다.
type Candidate = {
memoryType: "fact" | "preference" | "rule";
content: string;
importanceScore: number;
sourceHash: string;
};
function shouldIndex(c: Candidate) {
if (c.importanceScore < 0.55) return false;
if (c.content.length < 40) return false; // 너무 짧으면 검색 가치 낮음
return true;
}
이렇게 하면 벡터DB는 “장기 기억의 검색 인덱스”로 남고, raw 로그/중간 산출물은 별도 스토리지에서 TTL로 정리됩니다.
중복 제거와 병합: source hash + 의미 유사도
중복은 두 종류입니다.
- 텍스트 중복: 같은 문장을 여러 번 저장
- 의미 중복: 표현만 다르고 내용은 동일
텍스트 중복은 source_hash 로 쉽게 막습니다.
import crypto from "crypto";
function hashContent(s: string) {
return crypto.createHash("sha256").update(s.trim()).digest("hex");
}
의미 중복은 비용이 들지만, 다음 중 하나로 완화할 수 있습니다.
- 새 후보를 넣기 전에 top-k 유사 검색을 하고, cosine 유사도가 특정 임계치 이상이면 “업데이트”로 처리
- 혹은 배치 작업으로 주기적으로 클러스터링/병합
업데이트 방식의 장점은 인덱스가 폭증하기 전에 막을 수 있다는 점입니다.
GC(삭제) 설계: “지우기”는 비동기 작업으로
대부분의 시스템에서 삭제는 느립니다.
- 벡터 인덱스 리빌드 비용
- DB vacuum 필요
- 스토리지/캐시 정합성
그래서 온라인 요청 경로에서 삭제를 동기 처리하면 지연이 튀고 장애가 납니다. 권장 패턴은:
- 온라인 경로:
ttl_expires_at만 설정(soft delete 성격) - 오프라인 워커: 만료 레코드 실제 삭제 + 인덱스 정리
-- 만료된 메모리 삭제(배치)
delete from agent_memory
where ttl_expires_at < now();
삭제 워커가 밀리면 큐잉/재시도 설계가 필요합니다. 외부 API 호출(임베딩/요약)이 섞이면 더더욱 그렇습니다. 과부하 상황에서의 재시도·큐잉 패턴은 아래 글이 참고됩니다.
운영 관측 지표: 누수는 “그래프”로 잡는다
다음 지표를 대시보드로 보면, 메모리 누수처럼 보이는 현상을 빠르게 분해할 수 있습니다.
- 벡터DB 문서 수, 테넌트별 증가율
- 평균/95p 검색 latency, top-k 후보 수
- 컨텍스트 토큰 수(요약 전/후), 압축률
- “검색된 기억이 실제로 답변에 사용됐는가” 비율(휴리스틱으로 측정)
- TTL 만료 후 실제 삭제까지의 지연(백로그)
- 임베딩/요약 비용(일별)
특히 컨테이너로 운영한다면, RSS 증가와 OOM은 애플리케이션 레벨 누수인지 설계 누수인지 함께 봐야 합니다. OOM 진단 체크리스트는 아래 글이 같이 도움이 됩니다.
실전 체크리스트: 지금 당장 적용할 10가지
- Raw 로그에 TTL을 걸고, 장기 저장이 필요하면 별도 아카이브로 분리
- 세션 요약은 append가 아니라 replace로 갱신
- 벡터DB 인덱싱은 “후보 추출 + 게이트”를 통과한 것만
- 메모리 타입별 TTL을 다르게(선호/규칙은 길게, 이벤트는 짧게)
last_accessed_at기반 TTL 연장(단, 무한 연장은 금지)source_hash로 텍스트 중복 차단- 유사도 임계치로 의미 중복을 업데이트 처리
- 삭제는 비동기 배치로, 온라인 경로는 만료 마킹 중심
- 테넌트별 쿼터(문서 수/일일 임베딩 수/스토리지)를 강제
- 지표를 먼저 만들고(증가율/압축률/히트율), 그 다음 튜닝
마무리
AutoGPT의 “메모리 누수”는 대부분 GC가 못 도는 언어 런타임 문제가 아니라, 기억을 데이터로 설계하지 않은 결과입니다. 벡터DB는 만능 저장소가 아니라 검색 인덱스이며, 요약은 누적이 아니라 압축이고, TTL은 선택이 아니라 기본값입니다.
벡터DB·요약·TTL을 하나의 파이프라인으로 묶고, 삭제/병합을 비동기로 돌리며, 지표로 증가율을 통제하면 장기 실행 에이전트도 충분히 안정적으로 운영할 수 있습니다.