- Published on
AutoGPT 메모리 폭주 해결 - 벡터DB TTL·요약
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT 류 에이전트를 장시간 돌리다 보면 어느 순간부터 응답이 느려지고, 검색 결과가 산만해지며, 인프라 비용까지 튀는 현상을 겪습니다. 흔히 “메모리 폭주”라고 부르지만, 실제로는 저장소(벡터DB/키밸류)와 프롬프트 컨텍스트가 무제한으로 누적되고, 그 누적분을 매 턴마다 다시 검색·재랭킹하면서 생기는 지연과 비용의 합성 문제인 경우가 많습니다.
이 글에서는 AutoGPT의 메모리를 TTL(만료)로 자동 정리하고, 남겨야 할 정보는 요약으로 압축해 장기기억을 “작고 날카롭게” 유지하는 방법을 다룹니다. 설계 원칙부터 스키마, 코드 예시(파이썬), 운영 체크리스트까지 한 번에 정리합니다.
관련해서 “대화기억이 무한히 커지는 문제를 TTL로 잡는” 접근은 LangChain에서도 동일하게 통합니다. 자세한 TTL 아이디어는 LangChain 대화기억 폭증? Redis TTL로 해결도 함께 참고하면 좋습니다.
AutoGPT 메모리 폭주의 전형적인 증상
다음 증상이 함께 나타나면, 대부분 메모리 관리(저장·검색·압축) 설계를 손봐야 합니다.
- 실행 시간이 늘수록 검색 후보 수가 폭증하며 응답이 점점 느려짐
- 에이전트가 과거 irrelevant한 기억을 자꾸 끌고 와서 목표에서 이탈
- 벡터DB 용량이 계속 증가하고, 인덱스/컴팩션 비용이 커짐
- 동일한 사실을 여러 형태로 반복 저장해 중복 임베딩 비용이 증가
- 장기기억을 매 턴마다 top-k로 가져오는데도 컨텍스트가 지저분해짐
핵심은 “저장량” 자체보다도, 누적된 기억을 검색·선별하는 비용이 턴마다 반복된다는 점입니다.
원인: 장기기억을 ‘로그’처럼 쌓아두는 설계
AutoGPT에서 기억은 보통 이런 형태로 누적됩니다.
- 이벤트(관찰/행동/결과)를 텍스트로 저장
- 임베딩 생성
- 벡터DB에 upsert
- 다음 턴마다 유사도 검색으로 관련 기억 top-k를 가져와 프롬프트에 포함
이 흐름 자체는 맞지만, 만료 정책 없이 무한 누적되면 시간이 곧 비용이 됩니다.
- 오래된 기억도 동일한 가중치로 검색됨
- 중복/유사 기억이 인덱스를 오염시킴
- top-k를 늘릴수록 프롬프트가 비대해지고, 줄이면 필요한 기억을 놓침
해결은 크게 두 축입니다.
- TTL로 “사라져도 되는 기억”을 자동 제거
- 요약으로 “남겨야 할 기억”을 압축·정규화
이 둘은 경쟁 관계가 아니라, 함께 써야 효과가 큽니다.
해결 전략 1: 벡터DB TTL(만료)로 자동 정리
TTL을 어디에 걸어야 하나
AutoGPT의 기억을 3계층으로 나누면 설계가 쉬워집니다.
- 단기 기억(Short-term): 최근 N턴의 대화/관찰. 프롬프트에 직접 포함. TTL 짧게.
- 작업 기억(Working set): 현재 목표 달성에 필요한 문서/메모. 목표 단위 TTL.
- 장기 기억(Long-term): 재사용 가치가 높은 사실/선호/결정. TTL 길게 혹은 수동 유지.
벡터DB에 “모든 것”을 장기기억으로 넣지 말고, 최소한 다음을 분리하세요.
- 단기·작업 기억은 TTL 필수
- 장기 기억은 요약+정규화 후 선별 저장
TTL을 위한 메타데이터 스키마
벡터DB가 TTL을 네이티브로 지원하지 않아도, 메타데이터에 expires_at을 넣고 주기적으로 삭제하면 됩니다.
memory_type:short/work/longcreated_at: ISO timestampexpires_at: ISO timestamptask_id: 목표/세션 식별자importance:0~1(또는low/mid/high)source:observation/tool_result/user_pref등
중요한 점은 검색 시에도 필터링하는 것입니다. 삭제 잡이 늦어져도, 조회 단계에서 expires_at이 지난 문서를 제외하면 즉시 효과가 납니다.
파이썬 예시: TTL 저장 + 조회 필터 + 정리 잡
아래는 “TTL 메타데이터를 넣어 저장하고, 조회 시 만료된 문서는 제외하며, 백그라운드로 정리하는” 최소 패턴입니다. 벡터DB SDK는 제품마다 다르므로, VectorStoreClient는 의사 인터페이스로 두었습니다.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
UTC = timezone.utc
@dataclass
class MemoryItem:
id: str
text: str
embedding: List[float]
metadata: Dict[str, Any]
class VectorStoreClient:
def upsert(self, items: List[MemoryItem]) -> None:
raise NotImplementedError
def query(self, embedding: List[float], top_k: int, where: Dict[str, Any]) -> List[MemoryItem]:
raise NotImplementedError
def delete_where(self, where: Dict[str, Any]) -> int:
raise NotImplementedError
def now_iso() -> str:
return datetime.now(UTC).isoformat()
def iso_in(hours: int) -> str:
return (datetime.now(UTC) + timedelta(hours=hours)).isoformat()
def put_short_memory(
vs: VectorStoreClient,
*,
item_id: str,
text: str,
embedding: List[float],
task_id: str,
ttl_hours: int = 6,
) -> None:
item = MemoryItem(
id=item_id,
text=text,
embedding=embedding,
metadata={
"memory_type": "short",
"task_id": task_id,
"created_at": now_iso(),
"expires_at": iso_in(ttl_hours),
"importance": 0.2,
},
)
vs.upsert([item])
def query_valid_memories(
vs: VectorStoreClient,
*,
embedding: List[float],
task_id: str,
top_k: int = 8,
) -> List[MemoryItem]:
# 만료되지 않은 것만 필터링
where = {
"task_id": task_id,
"expires_at_gte": now_iso(),
}
return vs.query(embedding=embedding, top_k=top_k, where=where)
def gc_expired(vs: VectorStoreClient) -> int:
# 만료된 문서를 물리 삭제
where = {"expires_at_lt": now_iso()}
return vs.delete_where(where)
TTL 값은 어떻게 정하나
정답은 없지만, 운영에서 잘 먹히는 출발점은 다음과 같습니다.
- 단기 기억:
1~24시간 - 작업 기억:
1~7일(목표 완료 시 즉시 삭제) - 장기 기억: 기본은 무기한이 아니라
30~180일 정도로 두고, 중요도 높은 것만 갱신
여기서 중요한 트릭은 만료 연장(renewal) 입니다.
- 어떤 기억이 검색 결과로 자주 뽑히고 실제로 도움이 된다면
expires_at을 뒤로 미루어 “살아남게” 만들 수 있습니다. - 반대로 거의 쓰이지 않는 기억은 자연스럽게 사라집니다.
이 방식은 LRU 캐시와 비슷한 효과를 냅니다.
해결 전략 2: 요약으로 장기기억을 “압축 + 정규화”
TTL은 “버릴 것”을 정리합니다. 하지만 “남겨야 할 것”을 그대로 쌓아두면 결국 다시 커집니다. 그래서 요약이 필요합니다.
요약의 목표는 문장 줄이기가 아니라 ‘검색 품질’
좋은 요약은 단순 축약이 아니라, 다음을 만족해야 합니다.
- 중복 표현 제거(동일 사실을 하나로)
- 엔티티/결정/제약조건을 구조화
- 검색에 유리한 키워드를 포함
- 시간이 지나도 의미가 유지되는 형태로 저장
즉, 장기기억을 “대화 로그”가 아니라 지식 카드로 바꾸는 작업입니다.
요약 저장 포맷: 텍스트 + 구조화 메타
아래처럼 요약을 구조화하면 검색과 후처리가 쉬워집니다.
summary_text: 5~10줄 내외facts: 핵심 사실 리스트decisions: 결정/합의constraints: 금지/제약open_questions: 미해결entities: 사람/서비스/리포지토리 등
벡터DB에는 summary_text를 임베딩하고, 나머지는 메타데이터로 둬도 됩니다.
파이썬 예시: 일정 턴마다 “대화 청크”를 요약해 장기기억으로 승격
from typing import Tuple
def should_summarize(turn_count: int, chunk_size: int = 12) -> bool:
return turn_count % chunk_size == 0
def build_summary_prompt(messages: list[dict]) -> str:
# 실제 운영에서는 시스템 프롬프트/스키마 강제 등을 추가
joined = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
return (
"다음 대화를 장기기억으로 저장할 수 있게 요약하라. "
"중복을 제거하고, 사실/결정/제약/미해결을 분리해라.\n\n"
f"대화:\n{joined}"
)
def summarize_to_long_memory(
vs: VectorStoreClient,
*,
llm_call,
embed,
task_id: str,
chunk_messages: list[dict],
memory_id: str,
ttl_days: int = 90,
) -> None:
prompt = build_summary_prompt(chunk_messages)
summary = llm_call(prompt)
emb = embed(summary)
item = MemoryItem(
id=memory_id,
text=summary,
embedding=emb,
metadata={
"memory_type": "long",
"task_id": task_id,
"created_at": now_iso(),
"expires_at": (datetime.now(UTC) + timedelta(days=ttl_days)).isoformat(),
"importance": 0.7,
"source": "summary",
},
)
vs.upsert([item])
여기서 핵심은 “요약본만 장기기억으로 올리고, 원본 청크는 TTL로 사라지게” 만드는 것입니다.
- 원본은 단기/작업 기억으로 저장하고
6~48시간 내 만료 - 요약은 장기기억으로 저장하고
90일 등으로 유지
이렇게 하면 장기기억은 선형 증가가 아니라, 완만한 증가로 바뀝니다.
TTL과 요약을 같이 쓸 때의 운영 패턴
1) 메모리 파이프라인을 “수집 → 선별 → 승격”으로 바꾸기
무조건 저장이 아니라, 다음 단계로 나누면 폭주를 막기 쉽습니다.
- 수집: 관찰/툴 결과를 단기 저장(TTL 짧게)
- 선별: 중요도 스코어링(규칙 기반 또는 LLM 기반)
- 승격: 중요도가 일정 기준 이상이면 요약 후 장기 저장
- 정리: 만료 삭제 + 중복 병합
2) 검색 단계에서 시간 감쇠(time decay) 적용
유사도 점수만으로 top-k를 뽑으면 “옛날에 비슷했던 내용”이 계속 올라옵니다. 검색 점수에 시간 감쇠를 섞으면 안정적입니다.
예: 최종 점수 score = similarity * (0.7 + 0.3 * freshness)
freshness는created_at이 최근일수록1에 가깝게- 오래된 장기기억은 유사도가 아주 높을 때만 살아남음
3) 중복 제거: 해시 키로 “같은 사실”은 하나만
특히 툴 결과(예: 동일한 API 응답 요약)가 반복 저장되면 인덱스가 급격히 오염됩니다.
- 정규화한 텍스트로
sha256을 만들고dedupe_key로 저장 - upsert 시
dedupe_key가 같으면 업데이트로 처리
비용과 레이트리밋: 요약도 무한히 하면 또 폭주한다
요약은 LLM 호출이므로 비용/레이트리밋의 영향을 받습니다. 요약 호출을 “매 턴” 하지 말고, 다음처럼 제어하세요.
- 청크 크기(예:
12턴)마다 요약 - 토큰 수가 임계치 이상일 때만 요약
- 중요 이벤트(결정/에러/사용자 선호) 감지 시에만 요약
- 백그라운드 큐로 비동기 처리
레이트리밋 대응은 재시도/백오프 패턴이 필수입니다. 운영에서 바로 써먹을 패턴은 OpenAI API 429 재시도·백오프 패턴 실전 가이드를 참고하세요.
트러블슈팅 체크리스트
메모리 폭주가 계속된다면
- TTL이 “저장”에는 적용됐는데, “조회”에서 필터가 빠져 있지 않은가
expires_at타임존이 섞여 만료 판단이 틀리지 않는가(UTC 고정 권장)- 요약본까지 너무 자주 생성해 장기기억이 오히려 늘지 않는가
- top-k를 과도하게 크게 잡고 있지 않은가(먼저
6~12부터) - 임베딩 대상 텍스트가 불필요하게 길지 않은가(툴 로그 원문 그대로 저장 금지)
성능이 느리다면
- 벡터DB 인덱스가 커져서 검색이 느린 것인지, LLM 호출이 느린 것인지 분리 측정
- 메모리 검색 자체를 캐시할 수 있는지 검토(동일 쿼리 반복 시)
- Next.js 기반 UI에서 스트리밍/캐시가 꼬여 체감이 느려지는 경우도 있으니, 프론트 성능 이슈가 의심되면 Next.js 14 RSC 캐시·라우터 성능 트러블슈팅도 같이 확인
권장 아키텍처 요약
정리하면, AutoGPT 메모리 폭주를 막는 가장 현실적인 조합은 다음입니다.
- 단기/작업 기억: 벡터DB 또는 Redis 등에 저장하되 TTL 강제
- 장기 기억: 요약으로 승격된 것만 저장(원본은 만료)
- 조회: 만료 필터 + 시간 감쇠 + 중요도 가중치
- 운영: 주기적 GC, 중복 제거, 요약 호출 제어(청크/임계치/이벤트 기반)
이 패턴을 적용하면 “오래 돌릴수록 똑똑해지는” 것이 아니라, 오래 돌려도 컨텍스트가 건강하게 유지되는 에이전트에 가까워집니다. 메모리는 무한 저장이 아니라, 제품 요구사항에 맞춘 “수명 관리(lifecycle)”가 핵심입니다.