Published on

AutoGPT 메모리 폭주, Redis TTL·요약으로 잡기

Authors

AutoGPT 류 에이전트를 장시간 돌리다 보면 어느 순간부터 응답 품질은 떨어지고, 비용은 오르고, 프로세스 메모리와 Redis 사용량이 같이 치솟는 현상을 자주 겪습니다. 원인은 단순합니다. 에이전트가 쌓아두는 “작업 메모리(working memory)”와 “대화/관찰 로그”가 무제한으로 누적되는데, 이를 그대로 다음 프롬프트에 계속 넣거나(토큰 폭증), 벡터/키-값 저장소에 계속 적재하기(스토리지 폭증) 때문입니다.

이 글은 AutoGPT 메모리 폭주를 Redis TTL로 자동 만료시키고, 남겨야 할 정보는 요약(압축) 메모리로 치환해 장기 실행을 안정화하는 패턴을 설명합니다. “무조건 저장”이 아니라 저장 수명과 해상도(원문 vs 요약)를 분리하는 것이 핵심입니다.

운영 중 API 호출이 증가하면서 재시도까지 겹치면 폭주가 더 빨라집니다. 재시도 설계는 별도 글인 Claude API 529·429 재시도 전략과 구현 패턴도 함께 참고하면 좋습니다.

왜 AutoGPT 메모리가 폭주하는가

1) “메모리”가 사실상 로그 스트림이기 때문

대부분의 에이전트는 다음을 계속 저장합니다.

  • 사용자/에이전트 대화 메시지
  • 툴 호출 입력·출력(웹 검색 결과, 파일 내용, DB 결과 등)
  • 중간 추론(체인 오브 쏘트는 저장하지 않더라도 관찰/계획 텍스트)
  • 작업 상태(플랜, 태스크 큐, 스크래치패드)

문제는 이 데이터가 시간에 따라 단조 증가한다는 점입니다. “최근 N개만 유지” 같은 제한이 없으면, 프롬프트 컨텍스트는 토큰 한도에 부딪히고, 저장소는 비용과 지연이 증가합니다.

2) Redis를 쓰면 더 빨리 ‘보이는’ 문제

Redis는 빠르고 편해서 “일단 다 Redis에 넣자”가 되기 쉽습니다. 하지만 TTL이 없으면 Redis는 사실상 무한 성장하는 메모리 DB가 됩니다. 특히 다음 조합이 위험합니다.

  • 세션/런 단위 키가 계속 생성됨
  • 툴 출력(HTML, JSON, 문서 텍스트)이 큰 값으로 저장됨
  • 리스트/스트림 구조로 append만 수행됨

3) 요약이 없으면 ‘선택적 기억’이 불가능

사람은 모든 대화를 원문으로 기억하지 않습니다. 에이전트도 마찬가지로,

  • 단기: 최근 몇 분~몇 시간의 원문(고해상도)
  • 장기: 핵심 사실/결정/제약만 요약(저해상도)

이 두 층이 있어야 장기 실행이 가능합니다.

해결 전략 한 장 요약: TTL + 요약 + 예산

  • TTL로 자동 삭제: “원문 로그/관찰”은 짧은 TTL로 유지
  • 요약으로 압축: 중요한 정보만 장기 메모리로 승격
  • 예산(Quota) 강제: 키 개수/바이트/토큰 예산을 넘으면 강제 축소

실무에서는 이 세 가지를 같이 써야 합니다. TTL만 쓰면 중요한 정보도 날아가고, 요약만 쓰면 요약 대상 원문이 무한히 쌓입니다.

Redis 키 설계: 세션, 원문, 요약을 분리

권장 키 네임스페이스

  • agent:run:{run_id}:events : 이벤트 로그(관찰/툴 출력 등)
  • agent:run:{run_id}:messages : 대화 메시지
  • agent:run:{run_id}:summary:v1 : 누적 요약(장기)
  • agent:run:{run_id}:facts : 구조화된 사실(선택)

여기서 중요한 건 원문과 요약을 절대 같은 컬렉션에 섞지 않는 것입니다. 섞이면 TTL을 걸기 어렵고, retrieval 시에도 불필요한 원문이 섞여 토큰이 다시 폭주합니다.

TTL 정책 예시

  • events, messages: TTL 6시간~24시간
  • summary: TTL 7일~30일 또는 무제한(대신 크기 제한)
  • facts: TTL 길게(단, 업데이트 정책 필요)

“사용자가 다시 돌아올 가능성”과 “런타임 디버깅 필요 기간”을 기준으로 원문 TTL을 정합니다.

Redis TTL 적용: 원문은 자동 만료시키기

다음은 Python redis 클라이언트로 리스트/스트림에 이벤트를 추가하고 TTL을 보장하는 예시입니다.

import json
import time
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

RUN_ID = "run_123"
KEY_EVENTS = f"agent:run:{RUN_ID}:events"
TTL_SECONDS = 60 * 60 * 12  # 12h


def append_event(event: dict):
    payload = {
        "ts": int(time.time()),
        "type": event.get("type", "unknown"),
        "data": event.get("data", {}),
    }

    # LPUSH/RPUSH 어느 쪽이든 상관없지만, trim과 같이 쓰는 게 중요
    pipe = r.pipeline()
    pipe.rpush(KEY_EVENTS, json.dumps(payload))

    # 이벤트가 늘어날수록 메모리 폭주하므로 길이 제한도 함께
    pipe.ltrim(KEY_EVENTS, -2000, -1)  # 최근 2000개만 유지

    # TTL은 매번 재설정해도 되고, 최초 생성 시에만 설정해도 됨
    pipe.expire(KEY_EVENTS, TTL_SECONDS)
    pipe.execute()


append_event({"type": "tool_output", "data": {"text": "..."}})

포인트는 두 가지입니다.

  1. EXPIRE만 걸면 “키는 남는데 값이 너무 커짐” 문제가 생길 수 있습니다. 그래서 LTRIM 같은 길이 제한을 같이 둡니다.
  2. TTL을 “키 생성 시 1회만” 설정하려면 SET 계열에서 EX 옵션을 쓰거나, EXPIRENX 조건으로 걸고 싶을 수 있습니다. Redis 7+라면 EXPIRE key seconds NX 같은 옵션을 사용할 수 있습니다. (클라이언트 지원 여부 확인)

요약(압축) 메모리: 원문을 줄이고 의미를 남기기

요약을 언제 수행할까

요약 트리거는 보통 세 가지 중 하나로 잡습니다.

  • 이벤트/메시지 개수 기준: 최근 200개를 넘으면 요약
  • 토큰 예산 기준: 컨텍스트 토큰이 budget의 70%를 넘으면 요약
  • 시간 기준: 10분마다 배치 요약

실전에서는 토큰 예산 기준이 가장 안전합니다. “메모리 폭주”의 체감 문제는 대부분 토큰 폭주로 먼저 나타나기 때문입니다.

요약의 출력 형태

요약은 자유 텍스트보다 다음 형태가 운영에 유리합니다.

  • 결정 사항: 무엇을 하기로 했는가
  • 제약/요구사항: 반드시 지켜야 하는 규칙
  • 진행 상태: 완료/진행/대기
  • 실패 원인: 어떤 시도가 왜 실패했는가
  • 다음 액션: 우선순위 높은 다음 단계

이렇게 만들면 retrieval 시에도 “요약만 넣어도” 에이전트가 맥락을 빠르게 복원합니다.

누적 요약 패턴(rolling summary)

요약을 매번 새로 만들면 비용이 큽니다. 대신 기존 요약에 새 구간 요약을 병합하는 누적 요약이 효과적입니다.

  • summary_old + recent_chunksummary_new

이를 통해 요약 길이를 일정하게 유지할 수 있습니다.

요약 파이프라인 구현 예시(Python)

아래 예시는

  • 최근 이벤트 일부를 가져와
  • LLM으로 “구간 요약”을 만들고
  • 기존 누적 요약과 병합해
  • Redis에 저장

하는 흐름입니다. (LLM 호출 함수는 의사 코드)

import json
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)


def llm_summarize(prompt: str) -> str:
    # 실제로는 OpenAI/Claude 등 호출
    # 여기서는 예시로만 둠
    raise NotImplementedError


def get_recent_events(run_id: str, n: int = 200) -> list[dict]:
    key = f"agent:run:{run_id}:events"
    raw = r.lrange(key, -n, -1)
    return [json.loads(x) for x in raw]


def get_summary(run_id: str) -> str:
    key = f"agent:run:{run_id}:summary:v1"
    return r.get(key) or ""


def set_summary(run_id: str, summary: str, ttl_seconds: int = 60 * 60 * 24 * 14):
    key = f"agent:run:{run_id}:summary:v1"
    # 요약은 길게 보관하되, 무한 보관이 부담이면 TTL을 길게
    r.set(key, summary, ex=ttl_seconds)


def summarize_run(run_id: str):
    events = get_recent_events(run_id, n=200)
    summary_old = get_summary(run_id)

    # 툴 출력이 너무 길면 요약 전에 1차로 자르거나 필터링
    compact_lines = []
    for e in events:
        t = e.get("type")
        data = e.get("data", {})
        text = data.get("text") or data.get("content") or ""
        if len(text) > 800:
            text = text[:800] + "..."
        compact_lines.append(f"- {t}: {text}")

    chunk = "\n".join(compact_lines)

    prompt = (
        "You are a memory compressor for an autonomous agent.\n"
        "Update the running summary with new events.\n\n"
        "Rules:\n"
        "- Keep it under 2500 characters.\n"
        "- Preserve decisions, constraints, failures, and next actions.\n"
        "- Remove redundant details.\n\n"
        f"Current summary:\n{summary_old}\n\n"
        f"New events:\n{chunk}\n\n"
        "Return the updated summary only."
    )

    summary_new = llm_summarize(prompt)
    set_summary(run_id, summary_new)

    return summary_new

운영 팁:

  • 요약 프롬프트에 “문자 수 제한”을 강제하면 저장소/토큰 예산을 안정적으로 지킬 수 있습니다.
  • 이벤트 텍스트는 1차로 잘라서 LLM 입력 비용을 줄입니다.
  • 요약이 실패하면(LLM 오류) 재시도 폭주가 날 수 있으니, 재시도는 지수 백오프와 상한을 둡니다. 재시도 설계는 Python 데코레이터로 async 타임아웃·재시도 구현도 참고할 만합니다.

TTL만으로 부족한 이유: “중요 정보”가 같이 날아간다

TTL을 짧게 걸면 원문이 사라져 디버깅이 어려워질 수 있습니다. 그래서 다음 중 하나를 추가합니다.

  • 요약을 장기 보관: 원문은 삭제돼도 요약은 남음
  • 구조화된 사실 저장: 예를 들어 목표 URL, 계정/환경, 완료된 태스크 ID 같은 핵심만 별도 키에 저장
  • 샘플링 보관: 모든 이벤트가 아니라 오류/실패 이벤트만 길게 보관

특히 “실패 이벤트만 장기 보관”은 비용 대비 효율이 좋습니다.

메모리 예산 모델: 토큰/바이트/키 개수 3종

AutoGPT 메모리 폭주는 한 가지 지표만 보면 놓치기 쉽습니다.

  • 토큰 예산: 프롬프트에 넣는 컨텍스트 크기
  • 바이트 예산: Redis used_memory, 키 별 value 크기
  • 키 개수 예산: run_id가 계속 늘며 키가 폭증

권장하는 최소 관측 지표는 다음입니다.

  • used_memory, mem_fragmentation_ratio
  • keyspace_hits, keyspace_misses
  • expired_keys, evicted_keys
  • run_id 당 평균 키 수, 평균 value 크기

evicted_keys가 증가한다면 TTL이 아니라 maxmemory 정책에 의한 강제 퇴출이 발생 중일 수 있습니다. 이 경우 “중요 요약 키”도 날아갈 수 있으니, Redis maxmemory 정책과 분리(별도 Redis 인스턴스 또는 DB 번호 분리)를 고려하세요.

Redis 운영 설정 체크리스트

1) maxmemory와 eviction policy

  • maxmemory를 명시적으로 설정
  • maxmemory-policy는 워크로드에 맞게 선택
    • 캐시 성격이면 allkeys-lru 고려
    • TTL 기반이면 volatile-ttl 또는 volatile-lru 고려

요약 키를 반드시 지켜야 한다면, 요약은 Redis가 아니라 영속 DB(Postgres 등)에 두고 Redis는 캐시로만 쓰는 것도 방법입니다.

2) 큰 값 저장 금지(툴 출력 필터링)

가장 흔한 폭주 원인은 “웹 페이지 전체 HTML” 같은 대형 텍스트입니다.

  • 원문은 파일/오브젝트 스토리지로 보내고 Redis에는 포인터만 저장
  • Redis에는 content_hash와 요약만 저장

3) 세션 종료 시 정리

런이 끝났는데 키가 남아 있다면 TTL이 길거나, 일부 키에 TTL이 누락된 겁니다.

  • agent:run:{run_id}:*를 스캔해서 TTL 누락 키를 탐지
  • 종료 훅에서 UNLINK로 비동기 삭제(대형 키 삭제 시 지연 완화)

UNLINK는 대형 키 삭제로 메인 스레드가 멈추는 현상을 줄여줍니다.

Retrieval 단계에서의 토큰 폭주 방지

저장만 잘해도, retrieval에서 “원문을 몽땅 끌어오면” 다시 폭주합니다. 다음 우선순위를 추천합니다.

  1. 누적 요약(summary)을 기본 컨텍스트로 사용
  2. 최근 메시지 N개만 추가
  3. 필요할 때만 이벤트 원문 일부를 추가(예: 마지막 실패 로그)

벡터 검색을 쓰는 경우에도 “요약 임베딩”과 “원문 임베딩”을 분리하면 비용과 품질이 안정됩니다. 하이브리드 검색 튜닝은 Pinecone·Milvus 하이브리드검색 튜닝 - RRF+MMR에서 RRF, MMR로 중복을 줄이는 접근이 도움이 됩니다.

실전 적용 순서(가장 효과 큰 것부터)

  1. 원문 키에 TTL을 걸고, 길이 제한(LTRIM)을 추가
  2. 요약 키를 분리하고, 누적 요약(rolling) 파이프라인 구축
  3. 툴 출력 필터링(대형 텍스트 저장 금지, 포인터화)
  4. 관측 지표 대시보드화(expired_keys, evicted_keys, run_id 당 메모리)
  5. retrieval에서 요약 우선 정책 적용(원문은 필요할 때만)

이 순서대로 하면 “Redis 메모리 폭주”와 “컨텍스트 토큰 폭주”를 동시에 잡을 수 있습니다.

마무리: 기억은 쌓는 게 아니라 ‘관리’하는 것

AutoGPT의 메모리 문제는 LLM 성능 문제가 아니라 데이터 수명 관리 문제인 경우가 대부분입니다. Redis TTL로 원문을 자동 만료시키고, 남겨야 할 정보는 요약으로 승격해 해상도를 낮춰 저장하면 장기 실행이 가능해집니다.

정리하면 다음 한 줄입니다.

  • 원문은 짧게, 요약은 길게, retrieval은 최소로

이 원칙을 지키면 에이전트가 오래 일해도 비용과 지연이 선형에 가깝게 유지됩니다.