- Published on
AutoGPT 메모리 누수? 벡터DB TTL로 비용 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT 같은 에이전트 프레임워크를 붙여서 며칠, 몇 주 단위로 돌리다 보면 “메모리 누수”라는 표현이 자주 등장합니다. 엄밀히 말해 프로세스 힙이 새는 전통적 의미의 누수라기보다, 벡터DB에 저장되는 장기 메모리(임베딩)가 끝없이 증가하면서 저장 비용과 검색 비용이 같이 커지는 현상을 말하는 경우가 많습니다.
특히 다음 조건이 겹치면 비용이 가파르게 상승합니다.
- 대화/작업 로그를 자동으로 요약하지 않고 원문 그대로 임베딩
- 동일한 이벤트를 여러 번 저장(중복 삽입)
- Retrieval 시
topK를 높게 잡고, 매 쿼리마다 전 범위 검색 - 인덱스가 커져서 검색 latency가 증가하고, 이를 보완하려고 리소스를 증설
이 글은 “AutoGPT 메모리 누수”를 데이터 보존 정책 문제로 재정의하고, 벡터DB에서 TTL을 활용해 비용을 줄이면서도 품질을 유지하는 설계를 다룹니다.
관련해서 캐시/무효화로 데이터가 안 갱신되거나 꼬이는 이슈를 겪었다면, 벡터 메모리도 결국 비슷한 “수명 관리” 문제라는 관점이 도움이 됩니다. 예: Next.js App Router 캐시로 데이터가 안 갱신될 때, Next.js 14 RSC 캐시 무효화로 데이터 꼬임 해결
왜 벡터 메모리가 비용을 폭발시키는가
벡터DB 비용은 보통 아래 항목의 합으로 나타납니다.
- 저장 비용: 벡터(예: 1536차원
float32) + 메타데이터 + 인덱스 구조 - 쓰기 비용: upsert/insert QPS, 배치 크기, 인덱싱 오버헤드
- 검색 비용: 쿼리당 탐색 범위,
topK, 필터 유무, 인덱스 크기 - 운영 비용: 샤딩/레플리카, 백업, 컴팩션, 모니터링
장기 메모리가 무제한으로 늘면 인덱스가 커지고, 검색 latency가 증가하며, 결과적으로 topK 를 키우거나 리소스를 키우는 악순환이 생깁니다. 즉 “메모리가 늘어서 저장비만 늘었다”가 아니라 검색비와 운영비까지 동반 상승합니다.
TTL이 필요한 메모리와 남겨야 할 메모리
모든 메모리에 TTL을 걸면 품질이 떨어질 수 있습니다. 핵심은 메모리의 등급을 나누고, 등급별 보존 기간을 다르게 가져가는 것입니다.
메모리 등급 예시
- Episodic(에피소드) 메모리: 최근 대화/작업 로그, 관찰 이벤트
- 특징: 단기 유효, 양이 많음, 중복이 잦음
- 권장: TTL 적극 적용(예: 7일~30일)
- Semantic(의미) 메모리: 요약/교훈/규칙/사용자 선호
- 특징: 양이 적고 가치가 높음, 장기 보존 가치
- 권장: TTL 길게 또는 무기한(대신 업데이트/정제 필요)
- Artifact(산출물) 메모리: 코드 스니펫, 문서, 결정 기록
- 특징: 참조 가능성 높음, 변경 이력 필요
- 권장: 별도 스토리지(오브젝트 스토리지) + 벡터DB에는 “요약 + 포인터”만
결론적으로 벡터DB에 원문 전체를 장기 보관하지 말고, “최근 원문은 TTL”, “장기 기억은 요약/규칙만”으로 구조를 바꾸는 게 비용 대비 효과가 큽니다.
TTL 설계: 단순 만료가 아니라 ‘수명주기’로 보자
TTL을 단순히 expiresAt 하나로 끝내면, 중요한 메모리까지 사라지거나 반대로 중요한 메모리가 제대로 승격되지 못합니다. 실전에서는 수명주기를 명시하는 편이 안정적입니다.
추천 메타데이터 필드
memoryType:episodic | semantic | artifactcreatedAt: 생성 시각lastAccessAt: 마지막으로 retrieval에 사용된 시각expiresAt: 만료 시각(없으면 무기한)importance: 01 또는 15source:chat | tool | web | filededupeKey: 중복 방지 키
여기서 lastAccessAt 을 쓰면 LRU 비슷한 정책을 구현할 수 있습니다. 예를 들어 “최근 30일간 한 번도 쓰이지 않은 episodic 메모리는 삭제” 같은 룰을 만들 수 있습니다.
구현 패턴 1: 벡터DB 자체 TTL 기능 활용
일부 벡터DB는 레코드 TTL 또는 컬렉션 TTL을 지원합니다. 지원하지 않더라도, expiresAt 필드를 두고 주기적으로 삭제 작업을 돌리면 동일한 효과를 낼 수 있습니다.
아래는 “DB가 TTL을 직접 지원하지 않는 경우”를 가정한 의사 코드입니다.
// TypeScript pseudo-code
type MemoryRecord = {
id: string;
embedding: number[];
text: string;
metadata: {
memoryType: "episodic" | "semantic" | "artifact";
createdAt: string;
lastAccessAt?: string;
expiresAt?: string; // ISO timestamp
importance?: number;
dedupeKey?: string;
};
};
function computeExpiresAt(memoryType: MemoryRecord["metadata"]["memoryType"], importance = 0.3) {
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
// episodic는 기본 14일, 중요도가 높으면 30일까지
if (memoryType === "episodic") {
const ttlDays = importance >= 0.7 ? 30 : 14;
return new Date(now + ttlDays * day).toISOString();
}
// semantic은 기본 180일, 중요도가 높으면 무기한(또는 365일)
if (memoryType === "semantic") {
if (importance >= 0.9) return undefined;
return new Date(now + 180 * day).toISOString();
}
// artifact는 벡터DB에 오래 두지 말고 포인터만: 30일
return new Date(now + 30 * day).toISOString();
}
async function upsertMemory(input: Omit<MemoryRecord, "metadata"> & { metadata: MemoryRecord["metadata"] }) {
const expiresAt = computeExpiresAt(input.metadata.memoryType, input.metadata.importance);
const record: MemoryRecord = {
...input,
metadata: {
...input.metadata,
expiresAt,
createdAt: input.metadata.createdAt ?? new Date().toISOString(),
},
};
// 1) dedupeKey 기반 중복 방지(선조회 또는 unique index 활용)
// 2) upsert
await vectorDb.upsert(record);
}
async function purgeExpired(batchSize = 1000) {
const nowIso = new Date().toISOString();
while (true) {
const expiredIds: string[] = await vectorDb.listIds({
filter: {
expiresAt: { $lte: nowIso },
},
limit: batchSize,
});
if (expiredIds.length === 0) break;
await vectorDb.deleteMany(expiredIds);
}
}
포인트는 두 가지입니다.
- 만료 시각을 “데이터 삽입 시” 결정해 두면, 삭제 작업이 단순해집니다.
- purge는 반드시 배치로 돌려야 하고, 인덱스/컴팩션 비용까지 고려해야 합니다.
구현 패턴 2: Retrieval 시 TTL 필터로 품질 방어
TTL 삭제 작업이 지연되거나, 컴팩션 때문에 즉시 삭제가 부담될 수 있습니다. 이때는 검색 단계에서 만료 데이터를 제외하는 필터를 함께 적용하면 품질을 안정화할 수 있습니다.
# Python pseudo-code
from datetime import datetime, timezone
now_iso = datetime.now(timezone.utc).isoformat()
results = vector_db.query(
embedding=query_embedding,
top_k=20,
filter={
"$or": [
{"expiresAt": {"$gt": now_iso}},
{"expiresAt": {"$exists": False}},
],
"memoryType": {"$in": ["episodic", "semantic"]},
},
)
이렇게 하면 실제 삭제가 늦어도 사용자에게 “죽은 기억”이 섞여 들어오는 문제를 줄일 수 있습니다.
구현 패턴 3: 요약 승격(Summarization promotion)
TTL의 가장 큰 부작용은 “중요한 맥락이 사라져서 에이전트가 멍청해지는 것”입니다. 이를 막는 방법이 요약 승격입니다.
전략은 간단합니다.
- episodic 메모리는 짧은 TTL로 쌓는다
- 일정 조건(반복 등장, 중요도 상승, 태스크 완료 등)을 만족하면
- episodic 여러 개를 묶어 semantic 요약 1개로 승격 저장
- 원본 episodic는 TTL 만료로 자연 삭제
// TypeScript pseudo-code
async function promoteToSemantic(userId: string) {
// 최근 7일 episodic 중 중요도가 높은 것만 모음
const episodic = await vectorDb.list({
filter: {
userId,
memoryType: "episodic",
importance: { $gte: 0.6 },
},
limit: 200,
orderBy: "createdAt_desc",
});
if (episodic.length < 10) return;
const textBundle = episodic.map(m => m.text).join("\n\n");
const summary = await llm.summarize({
input: textBundle,
instruction: "사용자 선호/규칙/결정사항 위주로 중복 제거 요약",
});
await upsertMemory({
id: `semantic-${userId}-${Date.now()}`,
embedding: await embed(summary),
text: summary,
metadata: {
userId,
memoryType: "semantic",
importance: 0.8,
createdAt: new Date().toISOString(),
},
});
}
요약 승격을 넣으면 “오래된 원문”은 지워도 “학습된 교훈”은 남기게 되어, 비용과 품질 사이 균형을 잡기 쉬워집니다.
중복 삽입 방지: TTL만으로는 비용이 안 내려갈 때
TTL을 넣었는데도 비용이 기대만큼 줄지 않는 경우가 흔합니다. 이유는 중복 데이터가 TTL 기간 동안 계속 쌓이기 때문입니다.
중복 방지는 아래 중 하나로 해결합니다.
dedupeKey를 만들고, 같은 키면 upsert로 덮어쓰기- 원문 정규화 후 해시(예:
sha256(normalizedText))를 키로 사용 - “세션ID + 이벤트 타입 + 핵심 엔티티ID” 조합으로 키 설계
import crypto from "crypto";
function dedupeKeyFromText(text: string) {
const normalized = text.trim().replace(/\s+/g, " ").toLowerCase();
return crypto.createHash("sha256").update(normalized).digest("hex");
}
async function upsertEpisodic(text: string, userId: string) {
const key = dedupeKeyFromText(text);
await upsertMemory({
id: `epi-${userId}-${key}`,
embedding: await embed(text),
text,
metadata: {
userId,
memoryType: "episodic",
importance: 0.3,
dedupeKey: key,
createdAt: new Date().toISOString(),
},
});
}
이 패턴은 특히 “툴 실행 로그”처럼 동일한 메시지가 반복적으로 생성되는 워크로드에서 효과가 큽니다.
운영 관점: TTL은 삭제가 아니라 ‘부하’다
TTL을 넣는 순간, 시스템은 새로운 종류의 부하를 갖습니다.
- 주기적 삭제로 인한 IO 스파이크
- 인덱스 컴팩션/리빌드 비용
- 삭제 지연 시 스토리지 사용량이 예상보다 늦게 감소
따라서 다음을 같이 설계해야 합니다.
- purge 작업을 트래픽 한가한 시간대로 스케줄링
- 배치 크기 제한, 점진적 삭제
- 삭제/컴팩션 메트릭 모니터링
- “검색 단계 TTL 필터”로 사용자 품질 방어
Kubernetes에서 운영 중이라면, 이런 배치 작업이 이미지 풀/레이트리밋 같은 외부 요인으로 실패하면서 비용 최적화가 지연될 수도 있습니다. 비슷한 운영 장애 대응 관점으로는 EKS에서 ECR ImagePullBackOff 429 해결법 같은 글의 접근 방식(원인 분해, 재현, 완화책)이 그대로 적용됩니다.
비용 절감 체크리스트
아래를 순서대로 적용하면 “체감 비용”이 빠르게 내려갑니다.
- episodic TTL 도입: 7일~30일 범위에서 시작
- 검색 시 만료 필터 적용: purge 지연에도 품질 유지
- 중복 방지 키 도입: TTL 기간 동안의 누적량 자체를 감소
- 요약 승격 파이프라인: 장기 기억은 요약만 유지
- Artifact 분리: 원문/파일은 오브젝트 스토리지, 벡터DB에는 포인터
- topK 튜닝 + 필터 강화: 불필요한 검색 범위 축소
마무리
AutoGPT의 “메모리 누수”는 많은 경우 애플리케이션 메모리 문제가 아니라 벡터DB에 무제한으로 쌓이는 장기 메모리의 수명 관리 실패입니다. TTL을 단순 만료 기능으로만 보지 말고, memoryType 분리, 요약 승격, 중복 방지, 검색 필터링까지 포함한 수명주기 설계로 접근하면 비용을 크게 줄이면서도 에이전트 품질을 유지할 수 있습니다.
다음 단계로는, 현재 워크로드에서 “어떤 메모리가 실제로 retrieval에 기여하는지”를 계측해 lastAccessAt 기반 정책을 도입해 보세요. TTL은 결국 데이터의 가치와 비용을 수치로 맞추는 작업이고, 계측이 들어가는 순간 최적화 속도가 달라집니다.