Published on

AutoGPT 메모리 폭주? Redis+PGVector로 해결

Authors

AutoGPT를 돌리다 보면 초반에는 잘 굴러가다가, 어느 순간부터 RAM이 치솟고 응답이 느려지며 결국 프로세스가 죽는 경우가 많습니다. 원인은 대개 “기억”을 한 곳에 몰아넣는 구조에 있습니다. 단기 대화/작업 상태, 중기 요약, 장기 지식(검색용 임베딩)을 모두 애플리케이션 메모리나 파일 기반 스토리지에 쌓아두면, 컨텍스트가 커질수록 토큰 사용량과 메모리 사용량이 같이 폭증합니다.

이 글에서는 AutoGPT 계열 에이전트의 메모리 폭주를 Redis(단기/세션 상태)PostgreSQL+PGVector(장기기억/검색) 로 분리해 해결하는 접근을 다룹니다. “모든 걸 LLM 프롬프트에 넣는 방식”에서 벗어나, 요약 + 검색(RAG) 기반 회상으로 컨텍스트를 얇게 유지하는 것이 핵심입니다.

관련해서 메모리/토큰 폭탄을 막는 일반 원칙은 LangChain 메모리 누수·토큰폭탄 7가지 차단법에서도 함께 참고하면 좋습니다. 실제로 컨테이너 환경에서 죽는다면 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl도 진단에 도움이 됩니다.

왜 AutoGPT는 메모리가 폭주할까

1) “기억”이 프롬프트에 누적되는 구조

AutoGPT류는 다음 데이터를 반복적으로 컨텍스트에 싣습니다.

  • 목표/규칙(시스템 프롬프트)
  • 지금까지의 대화/생각/행동 로그
  • 도구 호출 결과(웹 스크랩, 파일 내용, DB 결과)
  • 과거의 유사 작업 히스토리(회상)

이 중 일부는 매 스텝마다 재전송되며, 길이가 선형으로 증가합니다. 컨텍스트 길이가 커지면 토큰 비용이 증가할 뿐 아니라, 애플리케이션 측에서도 “이전 로그를 계속 들고 있는” 구조면 메모리가 계속 올라갑니다.

2) 회상(retrieval)을 “전체 주입”으로 구현하는 실수

장기기억을 파일(예: JSON)로 저장해두고, 매번 통째로 읽어 프롬프트에 붙이면 사실상 영구 컨텍스트가 됩니다. 검색 기반으로 필요한 일부만 가져와야 하는데, 구현이 단순하다는 이유로 전체를 넣는 패턴이 흔합니다.

3) 도구 결과가 너무 크다

웹 페이지 원문, 로그 파일, 코드 전체를 그대로 프롬프트에 넣으면 즉시 토큰 폭탄이 납니다. 게다가 이런 큰 문자열을 Python 프로세스가 계속 보관하면 RSS도 상승합니다.

해결 전략: 단기/장기 기억을 분리하라

핵심은 “에이전트가 매 스텝 들고 있어야 하는 상태”와 “나중에 찾아보면 되는 지식”을 분리하는 것입니다.

  • Redis: 세션 상태, 스텝별 작업 큐, 최근 N턴 대화, 임시 요약, 락, 레이트리밋 등
  • PostgreSQL + PGVector: 장기기억(문서/사실/결정/요약)을 임베딩으로 저장하고, 필요할 때만 Top-K 검색

이렇게 하면 프로세스 메모리는 일정 수준에서 유지되고, 프롬프트는 “최근 대화 + 요약 + 검색 결과 일부”로 얇아집니다.

아키텍처 설계: Redis는 휘발성, PGVector는 회상용

Redis에 넣을 것

  • session:{id}:recent_messages : 최근 메시지 N개(리스트)
  • session:{id}:running_state : 현재 목표, 진행률, 마지막 행동(해시)
  • session:{id}:summary : 중기 요약(문자열)
  • locks:* : 동시 실행 방지

Redis는 TTL을 적극적으로 사용합니다. 예를 들어 세션이 24시간 동안 사용되지 않으면 자동 만료시키면, 유령 세션이 메모리를 잡아먹지 않습니다.

PGVector에 넣을 것

  • 장기기억 문서(결정, 배운 점, 외부 문서 요약)
  • 각 문서의 임베딩 벡터
  • 메타데이터(세션, 태그, 출처 URL, 중요도, 생성 시각)

회상은 “검색 결과 Top-K”만 프롬프트에 넣습니다. 나머지는 DB에 남아 있어도 상관없습니다.

구현 1: PostgreSQL에 PGVector 세팅

먼저 pgvector 확장을 활성화합니다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS agent_memory (
  id BIGSERIAL PRIMARY KEY,
  session_id TEXT NOT NULL,
  kind TEXT NOT NULL, -- e.g. decision, fact, summary, doc
  content TEXT NOT NULL,
  embedding vector(1536) NOT NULL,
  importance REAL NOT NULL DEFAULT 0.5,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS agent_memory_session_idx
  ON agent_memory(session_id);

-- 벡터 인덱스는 데이터 규모에 따라 선택합니다.
-- ivfflat은 빌드 시 ANALYZE가 중요합니다.
CREATE INDEX IF NOT EXISTS agent_memory_embedding_ivfflat
  ON agent_memory USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

vector(1536)은 OpenAI text-embedding-3-small 계열처럼 1536 차원을 가정한 예시입니다. 다른 임베딩 모델이면 차원을 맞춰야 합니다.

구현 2: Redis에 세션 상태 저장(최근 대화, 요약)

Python 예시입니다.

import json
import redis

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

def push_recent_message(session_id: str, role: str, content: str, max_len: int = 20):
    key = f"session:{session_id}:recent_messages"
    r.lpush(key, json.dumps({"role": role, "content": content}))
    r.ltrim(key, 0, max_len - 1)
    r.expire(key, 60 * 60 * 24)  # 24h TTL

def get_recent_messages(session_id: str):
    key = f"session:{session_id}:recent_messages"
    raw = r.lrange(key, 0, -1)
    return [json.loads(x) for x in reversed(raw)]

def set_summary(session_id: str, summary: str):
    key = f"session:{session_id}:summary"
    r.setex(key, 60 * 60 * 24, summary)

def get_summary(session_id: str) -> str:
    key = f"session:{session_id}:summary"
    return r.get(key) or ""

포인트는 두 가지입니다.

  • 최근 대화는 N개로 고정해서 무한정 늘지 않게 합니다.
  • 요약은 Redis에 “한 덩어리”로 유지하고, 필요 시에만 갱신합니다.

구현 3: 장기기억 저장과 검색(PGVector)

아래는 psycopg를 사용한 간단 예시입니다. 임베딩 생성은 모델/SDK에 맞춰 교체하면 됩니다.

from typing import List, Tuple
import psycopg

# 임베딩은 예시로 더미 함수 처리
def embed(text: str) -> List[float]:
    # 실제로는 embedding API 호출
    return [0.0] * 1536

def save_memory(conn: psycopg.Connection, session_id: str, kind: str, content: str, importance: float = 0.5):
    vec = embed(content)
    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO agent_memory(session_id, kind, content, embedding, importance)
            VALUES (%s, %s, %s, %s, %s)
            """,
            (session_id, kind, content, vec, importance),
        )
    conn.commit()

def search_memory(conn: psycopg.Connection, session_id: str, query: str, k: int = 5) -> List[Tuple[str, float]]:
    qvec = embed(query)
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT content,
                   1 - (embedding <=> %s::vector) AS score
            FROM agent_memory
            WHERE session_id = %s
            ORDER BY embedding <=> %s::vector
            LIMIT %s
            """,
            (qvec, session_id, qvec, k),
        )
        return cur.fetchall()

여기서 중요한 점은 k를 작게 유지하는 것입니다. Top-5 또는 Top-10 정도로 제한하고, 각 결과도 길다면 “회상용 요약”만 넣는 식으로 2차 압축을 거칩니다.

프롬프트 구성: “최근 N턴 + 요약 + Top-K 회상”

에이전트가 매 스텝마다 LLM에 보내는 입력을 다음처럼 구성합니다.

  1. 시스템 규칙(고정)
  2. 목표(고정 또는 짧은 상태)
  3. Redis의 요약(중기)
  4. Redis의 최근 메시지 N턴(단기)
  5. PGVector 검색 결과 Top-K(장기 회상)
  6. 이번 스텝의 관찰/도구 결과(필요 최소)

이 순서로 만들면 “긴 과거 로그”를 통째로 넣지 않아도 문맥이 유지됩니다.

간단한 조립 예시입니다.

def build_context(session_id: str, user_query: str, conn) -> str:
    summary = get_summary(session_id)
    recent = get_recent_messages(session_id)
    memories = search_memory(conn, session_id, user_query, k=5)

    memory_block = "\n".join([f"- {c}" for (c, score) in memories if score > 0.2])
    recent_block = "\n".join([f"{m['role']}: {m['content']}" for m in recent])

    parts = [
        "You are an autonomous agent.",
        f"Session summary: {summary}",
        "Recent messages:\n" + recent_block,
        "Relevant long-term memories:\n" + memory_block,
        f"User request: {user_query}",
    ]
    return "\n\n".join([p for p in parts if p.strip()])

이 방식의 장점은 명확합니다.

  • 컨텍스트가 “대략 일정한 크기”로 유지됩니다.
  • 장기기억은 DB에 무한히 쌓여도 프롬프트에는 일부만 들어갑니다.
  • 장애가 나도 Redis/PG에 상태가 남아 재시작 복구가 쉽습니다.

메모리 폭주를 더 줄이는 운영 팁

1) 도구 출력은 저장 전 요약하라

웹 페이지나 로그를 그대로 저장하지 말고, 다음 파이프라인을 권장합니다.

  • 원문은 오브젝트 스토리지나 파일로 보관(필요 시)
  • LLM 또는 규칙 기반으로 “회상용 요약” 생성
  • PGVector에는 회상용 요약만 저장

이렇게 하면 벡터 검색 결과가 곧바로 프롬프트에 들어가도 안전합니다.

2) 중요도(importance)와 TTL을 함께 쓰기

모든 기억이 동일한 가치가 아닙니다.

  • 중요도가 낮은 기록은 일정 기간 이후 삭제
  • 중요도가 높은 결정/규칙은 영구 보관

PGVector 테이블에 importance, created_at이 있는 이유가 여기 있습니다. 예를 들어 아래처럼 청소 작업을 돌릴 수 있습니다.

DELETE FROM agent_memory
WHERE importance < 0.3
  AND created_at < now() - interval '14 days';

3) Redis는 “세션 캐시”로만 쓰고, 영구 저장을 기대하지 말기

Redis에 모든 걸 넣고 RDB/AOF로 영속화하면, 결국 “메모리 폭주”가 Redis로 옮겨갈 뿐입니다. Redis는 짧고 빠른 상태에 집중시키고, 영구 데이터는 PostgreSQL로 보내는 쪽이 운영이 편합니다.

4) 컨테이너 환경이라면 OOM과 재시작 루프를 함께 관찰

AutoGPT가 죽는 현상은 종종 OOMKilled로 나타나고, Kubernetes에서는 CrashLoopBackOff로 이어집니다. 증상만 보고 “버그”로 단정하기보다, 메모리 상한과 로그를 먼저 확인하세요.

장애 패턴별 체크리스트

패턴 A: 프로세스 RSS가 계속 증가한다

  • 최근 메시지 리스트가 무한히 커지지 않는지 확인(반드시 ltrim)
  • 도구 출력(HTML, 로그)을 메모리에 계속 들고 있지 않은지 확인
  • “전체 히스토리 문자열”을 매 스텝 누적하는 변수가 없는지 확인

패턴 B: 토큰 사용량이 기하급수로 증가한다

  • 프롬프트에 과거 로그를 통째로 붙이지 않는지 확인
  • 검색 결과 Top-K가 너무 크거나, 각 문서가 너무 긴지 확인
  • 요약 갱신 주기가 너무 잦아 요약 자체가 비대해지는지 확인

패턴 C: Redis 메모리가 먼저 터진다

  • TTL이 설정되어 있는지 확인
  • 세션 키 네이밍이 누락되어 공유 키에 계속 쌓이지 않는지 확인
  • 리스트/셋 자료구조가 무한히 증가하지 않는지 확인

결론: “기억을 저장”하지 말고 “기억을 회상”하라

AutoGPT의 메모리 폭주는 대체로 저장소의 문제가 아니라 기억을 다루는 방식의 문제입니다. 단기 상태는 Redis로 제한된 크기와 TTL로 관리하고, 장기기억은 PGVector에 넣어 “필요할 때만 Top-K로 회상”하면, 프롬프트와 프로세스 메모리 모두 안정적으로 유지됩니다.

정리하면 다음 3가지만 지켜도 체감이 큽니다.

  • 최근 대화는 N개로 고정
  • 장기기억은 벡터 검색으로 일부만 회상
  • 큰 도구 출력은 저장 전 요약

이 구조로 바꾸면 장시간 실행, 다중 세션, 컨테이너 운영까지 한 단계 수월해집니다.