Published on

AutoGPT 메모리 폭주? 벡터DB·요약 전략 7단계

Authors

AutoGPT 같은 에이전트 프레임워크를 붙여 장기 작업을 돌리다 보면, 어느 순간부터 응답이 느려지거나 토큰 비용이 급증하고, 결국 컨텍스트 길이 초과로 실패하는 경우가 흔합니다. 문제의 핵심은 “메모리”가 실제로는 단일 대화 로그(또는 거대한 작업 로그)로 누적되고, 매 턴마다 이를 그대로 프롬프트에 주입하는 구조가 되기 쉽다는 점입니다.

이 글은 메모리 폭주를 벡터DB 기반 검색 + 계층 요약(hierarchical summarization) 으로 제어하는 7단계 전략을 제시합니다. 목표는 다음 3가지를 동시에 만족하는 것입니다.

  • 토큰 상한을 강제해 비용과 지연을 예측 가능하게 만들기
  • 필요한 정보만 높은 회수율(recall) 로 가져오기
  • “요약이 누적되며 사실이 변형되는 문제”를 감사 가능(auditable) 하게 관리하기

운영 관점에서의 디버깅/장애 대응 패턴은 쿠버네티스에서의 반복 재시작 원인 추적과 비슷한 면이 있습니다. 예를 들어 메모리 폭주로 프로세스가 불안정해질 때는 리소스/헬스체크의 상호작용도 점검해야 합니다. 관련해서는 EKS CrashLoopBackOff - livenessProbe 오진단 해결 글의 접근법이 참고가 됩니다.

왜 AutoGPT 메모리가 폭주하는가

대부분의 “메모리 폭주”는 아래 조합에서 발생합니다.

  1. 원문 로그를 전부 프롬프트에 재주입
  2. 검색 없이 “최근 N턴”만 붙이는 방식(중요 정보가 오래되면 사라짐)
  3. 요약을 하더라도 요약본을 또 요약하며 정보가 드리프트
  4. 툴 호출 결과(웹 크롤링, 코드, 에러 로그 등)가 그대로 저장되어 노이즈가 누적

즉 해결책은 단순합니다.

  • 원문은 원문대로 저장하되 프롬프트에는 제한적으로만
  • 장기 기억은 검색 기반으로 회수
  • 요약은 정책적으로 생성/갱신/검증

이제 7단계로 설계를 고정해 봅니다.

7단계 전략 개요

  • 1단계: 메모리 타입을 분리(작업/사실/대화/툴 결과)
  • 2단계: 저장 스키마와 식별자(세션, 태스크, 소스)를 표준화
  • 3단계: 벡터DB에 “청크 + 메타데이터”로 적재
  • 4단계: 검색 파이프라인(쿼리 재작성, 필터, rerank)
  • 5단계: 계층 요약(턴 요약 → 에피소드 요약 → 프로젝트 요약)
  • 6단계: 프롬프트 예산화(토큰 budget)와 압축 규칙
  • 7단계: 관측/평가(회수율, 근거 링크, 드리프트 감지)

아래부터 단계별로 구현 관점에서 설명합니다.

1단계: 메모리 타입을 분리하라

“메모리”를 하나의 버킷으로 보면 폭주를 막기 어렵습니다. 최소한 아래 4가지는 분리하세요.

  • Conversation: 사용자/에이전트 대화(짧고 흐름 중심)
  • Facts: 변하지 않는 사실(계정, 도메인 규칙, API 키 존재 여부 같은 메타는 제외)
  • Worklog: 의사결정, 계획, 중간 산출물(왜 그렇게 했는지)
  • Tool outputs: 크롤링 HTML, 로그, 스택트레이스 등(대부분 노이즈)

분리하면 무엇이 좋아지냐면, 검색 시 필터링이 가능해집니다. 예를 들어 “정책/요구사항”은 Facts 에서만 가져오고, “최근 진행 상황”은 Worklog 에서만 가져오는 식으로 토큰을 아낄 수 있습니다.

2단계: 저장 스키마와 식별자를 표준화

세션이 길어질수록 “이 텍스트가 어디서 왔는지”가 중요해집니다. 요약 드리프트를 막으려면 근거 추적이 가능해야 합니다.

권장 메타데이터(최소):

  • tenant_id
  • project_id
  • session_id
  • memory_type (conversation, facts, worklog, tool)
  • source (user, agent, tool_name)
  • created_at
  • turn_index
  • hash (중복 제거용)

이 구조는 캐시 키를 설계할 때 “무엇이 캐시 무효화 조건인지”를 명확히 하는 것과 유사합니다. 캐시 키 설계 감각은 GitHub Actions 캐시가 안 먹을 때 key 전략과 디버깅 글의 사고방식과 통합니다.

3단계: 벡터DB에 청크와 메타데이터로 적재

청킹 규칙

  • 문서/로그를 무조건 일정 길이로 자르지 말고, 의미 단위(섹션, 함수, 에러 블록) 기반으로 자르기
  • 청크 크기는 대략 300~800 토큰 범위를 많이 씁니다
  • 오버랩은 50~150 토큰 정도로 시작

예시: Python으로 청크 생성 및 적재

아래 예시는 “원문 저장소(예: Postgres) + 벡터DB(예: Qdrant)”를 같이 쓰는 전형적인 패턴입니다. 원문은 감사/재요약에 필요하므로 반드시 남겨두는 편이 안전합니다.

from dataclasses import dataclass
from datetime import datetime
import hashlib

@dataclass
class MemoryChunk:
    id: str
    text: str
    metadata: dict


def stable_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:24]


def make_chunks(text: str, *, chunk_size: int = 1200, overlap: int = 200):
    # 여기서는 단순 문자 기반 예시(실전에서는 토큰 기반 권장)
    chunks = []
    i = 0
    while i < len(text):
        j = min(len(text), i + chunk_size)
        chunk = text[i:j]
        chunks.append(chunk)
        i = max(j - overlap, j)
    return chunks


def build_memory_chunks(
    raw_text: str,
    *,
    tenant_id: str,
    project_id: str,
    session_id: str,
    memory_type: str,
    source: str,
    turn_index: int,
):
    now = datetime.utcnow().isoformat()
    chunks = []
    for idx, c in enumerate(make_chunks(raw_text)):
        h = stable_hash(f"{tenant_id}:{project_id}:{session_id}:{memory_type}:{turn_index}:{idx}:{c}")
        chunks.append(
            MemoryChunk(
                id=h,
                text=c,
                metadata={
                    "tenant_id": tenant_id,
                    "project_id": project_id,
                    "session_id": session_id,
                    "memory_type": memory_type,
                    "source": source,
                    "turn_index": turn_index,
                    "chunk_index": idx,
                    "created_at": now,
                    "hash": h,
                },
            )
        )
    return chunks

벡터DB 적재 시에는 id 를 안정적으로 만들어 중복 삽입을 방지하고, 업데이트/재요약 시에도 동일 레코드를 추적 가능하게 합니다.

4단계: 검색 파이프라인을 3단으로 쪼개라

“벡터 검색 한 번”으로 끝내면, 회수율이 흔들릴 때가 많습니다. 실전에서는 아래 3단을 추천합니다.

  1. 쿼리 재작성: 현재 사용자 질문을 “검색 친화적 질의”로 변환
  2. 1차 검색: 벡터 검색 + 메타데이터 필터(프로젝트, 타입)
  3. rerank: 상위 후보를 reranker로 재정렬(또는 LLM 기반 선택)

예시: 검색 쿼리 재작성 프롬프트

쿼리 재작성은 길게 할 필요가 없습니다. 대신 “찾고 싶은 범위”를 명시합니다.

System: 너는 검색 쿼리 생성기다.
User: 아래 질문을 벡터 검색용 쿼리로 1문장으로 바꾸고,
      필요한 경우 키워드 5개를 추가해라.
      질문: {user_question}
Output JSON: {"query": "...", "keywords": ["...", "..."]}

메타데이터 필터링 예시

  • project_id 는 반드시 고정
  • memory_type 은 상황별로 제한
  • tool 출력은 기본적으로 제외하고, 필요할 때만 포함

이렇게 하면 “툴 로그가 검색 결과를 오염”시키는 문제를 크게 줄일 수 있습니다.

5단계: 계층 요약으로 장기 기억을 안정화

요약은 필수지만, 잘못하면 “요약이 진실을 대체”합니다. 그래서 계층 구조로 관리합니다.

  • Turn summary: 각 턴(또는 N턴)마다 3~5문장 요약
  • Episode summary: 특정 작업 단위(예: 기능 하나 완료)마다 요약
  • Project summary: 프로젝트 전반의 목표/제약/결정 사항

중요한 규칙:

  • 요약에는 근거 청크 ID 목록을 붙입니다
  • 요약은 “새로 덮어쓰기”가 아니라 버전을 남깁니다

예시: 근거를 포함한 요약 스키마

{
  "summary_id": "sum_2026_02_26_001",
  "level": "episode",
  "project_id": "p1",
  "session_id": "s9",
  "text": "...",
  "evidence_chunk_ids": ["a1b2...", "c3d4..."],
  "created_at": "2026-02-26T10:00:00Z"
}

이렇게 하면 나중에 “왜 이런 결론이 나왔지”를 역추적할 수 있고, 요약 드리프트가 발생해도 원문으로 복구가 가능합니다.

6단계: 프롬프트 예산화와 압축 규칙을 강제

메모리 폭주는 대부분 “프롬프트에 넣을 수 있는 만큼 넣자”라는 암묵적 정책에서 시작합니다. 반대로 토큰 예산을 먼저 정해두면 설계가 단순해집니다.

권장 접근:

  • 시스템/정책: 고정 X 토큰
  • 사용자 입력: 평균 Y 토큰
  • 도구 지시문: Z 토큰
  • 메모리 컨텍스트: 최대 B 토큰(예: 1200)

그리고 메모리 컨텍스트는 다음 우선순위로 채웁니다.

  1. 프로젝트 요약(짧게)
  2. 이번 태스크 관련 Facts(검색 결과 상위)
  3. Worklog(최근 + 관련)
  4. Conversation 최근 1~3턴
  5. Tool output은 기본 제외(필요 시만)

예시: 토큰 예산 기반 컨텍스트 조립(의사코드)

def assemble_context(*, budget_tokens: int, blocks: list[dict]):
    # blocks: [{"name": "facts", "text": "...", "priority": 10, "tokens": 300}, ...]
    blocks = sorted(blocks, key=lambda b: b["priority"], reverse=True)

    chosen = []
    used = 0
    for b in blocks:
        if used + b["tokens"] <= budget_tokens:
            chosen.append(b)
            used += b["tokens"]
        else:
            # 남은 예산이 작으면 압축 요약을 한 번 더 시도
            # (실전에서는 여기서 "compress" 프롬프트 호출)
            continue

    return "\n\n".join([f"[{b['name']}]\n{b['text']}" for b in chosen])

핵심은 “예산을 초과하면 잘라내거나 재요약”이지, “예산을 늘려서 해결”이 아니라는 점입니다.

7단계: 관측과 평가로 드리프트를 잡아라

구성이 끝났으면 운영 지표를 넣어야 합니다. 메모리는 모델 품질뿐 아니라 비용에도 직결되므로, 최소한 아래를 로그로 남기세요.

  • 턴별 입력/출력 토큰 수
  • 메모리 검색 쿼리와 상위 k 결과의 청크 ID
  • 실제로 프롬프트에 포함된 청크 ID 목록
  • 요약 생성 시 evidence 목록
  • 실패 케이스(원하는 정보를 못 찾음, 환각)

간단한 오프라인 평가 아이디어

  • “질문 세트”를 만들고, 정답 근거가 들어있는 청크가 검색 상위 k 안에 드는지 측정
  • memory_type 별로 회수율을 따로 측정
  • 요약본만 넣었을 때와 원문 청크를 넣었을 때의 정답률 비교

운영 중 장애 대응 관점에서는 “원인이 검색 품질인지, 요약 드리프트인지, 프롬프트 예산 정책인지”를 빠르게 분리해야 합니다. 이런 분리/진단 루틴은 API 호출 에러 원인 추적과도 닮아 있습니다. 모델 호출 단계에서 403 같은 오류를 만나면 메모리 문제가 아닌 인프라/권한 문제일 수 있으니, OpenAI Responses API 403 model_not_found 해결 가이드 같은 체크리스트를 곁에 두는 것도 좋습니다.

실전 구성 예시: 최소 아키텍처

아래는 “작게 시작해서 점진적으로 고도화”하기 좋은 기본 조합입니다.

  • 원문 저장: Postgres(테이블 파티셔닝 권장)
  • 벡터DB: Qdrant 또는 pgvector
  • 요약 저장: Postgres(버전 관리)
  • 인덱싱 워커: 큐 기반(예: Redis queue)

주의할 점은 원문 저장소가 빠르게 비대해진다는 것입니다. 메모리 로그도 결국 데이터이므로, 보존 정책과 정리가 필요합니다. 데이터가 쌓여 성능이 흔들릴 때의 접근은 DB 유지보수 문제와 동일합니다. 대용량 테이블이 불어나면 VACUUM/파티셔닝/보존 정책이 중요해지는데, 이 결은 PostgreSQL VACUUM 안 돌면 테이블 폭증 해결법 과 같은 맥락입니다.

자주 터지는 함정 5가지

  1. 툴 출력(로그/HTML)을 그대로 임베딩: 노이즈가 많아 검색 품질이 떨어집니다. 먼저 정규화/요약 후 임베딩하세요.
  2. 요약만 믿고 원문을 버림: 드리프트가 오면 복구가 불가능합니다.
  3. 세션 경계가 없음: 프로젝트가 바뀌어도 과거 메모리가 섞여 환각이 늘어납니다.
  4. 필터 없는 벡터 검색: 다른 프로젝트/다른 태스크 청크가 섞여 들어옵니다.
  5. 토큰 예산 정책 부재: 결국 비용 폭주로 이어집니다.

결론: “저장”과 “주입”을 분리하면 폭주는 멈춘다

AutoGPT 메모리 폭주는 대개 “많이 저장해서”가 아니라 “저장한 것을 매번 다 주입해서” 생깁니다. 해결의 핵심은 다음 한 줄로 요약됩니다.

  • 원문은 충분히 저장하되, 프롬프트에는 검색된 일부 + 검증 가능한 요약만 넣는다.

이 글의 7단계(타입 분리 → 스키마 표준화 → 벡터 적재 → 검색 파이프라인 → 계층 요약 → 예산화 → 관측/평가)를 그대로 적용하면, 장기 작업에서도 토큰 비용을 예측 가능하게 만들고, 필요한 기억을 안정적으로 회수하는 에이전트를 만들 수 있습니다.

원한다면 다음 확장도 고려해볼 만합니다.

  • memory_type 별로 서로 다른 임베딩 모델/인덱스를 사용
  • rerank를 도입해 상위 후보 품질을 강화
  • “결정 사항(Decision)”만 따로 추출해 Facts 로 승격시키는 파이프라인 추가