Published on

AutoGPT 메모리 폭주 막기 - SQLite RAG 압축

Authors

서버에서 AutoGPT 같은 에이전트를 장시간 돌리다 보면 어느 순간부터 컨텍스트가 기하급수적으로 커지고, 검색 인덱스가 비대해지며, 결국 비용과 지연이 함께 폭주합니다. 대부분의 “메모리” 문제는 RAM만이 아니라 토큰 컨텍스트, 벡터 스토어 크기, 로그/이벤트 누적, 중복 문서가 합쳐져 발생합니다.

이 글에서는 메모리 폭주의 전형적인 패턴을 짚고, SQLite를 RAG 저장소로 쓰면서도 (1) 적재 단계 압축, (2) 요약 기반 계층화, (3) 중복 제거, (4) TTL 및 가비지 컬렉션, (5) 검색 품질을 유지하는 스키마/쿼리 최적화로 “메모리 폭주”를 막는 방법을 정리합니다.

또한 운영 중 만나는 400 계열 요청 에러나 입력 크기 문제는 OpenAI Responses API 400 invalid_request_error 해결에서 함께 점검하면 좋습니다. CSV나 로그를 그대로 넣다가 메모리가 터지는 패턴은 pandas read_csv DtypeWarning·메모리 폭발 해결법도 유사한 원인 분석에 도움이 됩니다.

AutoGPT 메모리 폭주의 4가지 근본 원인

1) “대화 메모리”가 사실상 무한 append

에이전트는 매 스텝마다 관찰(observation)과 생각(thought), 도구 호출 결과를 붙입니다. 이때 다음이 동시에 발생합니다.

  • 프롬프트에 포함되는 메시지 길이 증가 (토큰 비용 증가)
  • RAG에 저장되는 청크 수 증가 (디스크/검색 비용 증가)
  • 과거 결과가 다시 요약 없이 재주입되어 중복 강화

2) RAG 적재가 “원문 그대로”로만 누적

원문을 그대로 청크로 쪼개 저장하면, 시간이 지날수록 다음이 악화됩니다.

  • 같은 내용이 여러 번 저장됨 (로그, 재시도, 반복 관찰)
  • 청크 단위가 너무 작거나 너무 커서 검색 품질이 흔들림
  • 벡터/메타데이터가 불필요하게 커짐

3) 검색 결과가 과다 주입(topk) + 장문 컨텍스트

검색을 잘못 튜닝하면 top_k=20 같은 설정이 기본값으로 굳어지고, 각 청크가 1,000 토큰이면 한 번에 2만 토큰이 주입됩니다. “정확도”를 올리려다 “폭주”를 부릅니다.

4) 실패 재시도와 로그 적재가 RAG를 오염

도구 호출 실패, 네트워크 재시도, 에러 스택트레이스 같은 결과가 RAG에 들어가면 검색 시 계속 튀어나와 컨텍스트를 오염시킵니다. 분산 시스템에서 재시도 폭주가 전체를 망치는 패턴은 gRPC MSA에서 데드라인·리트라이 폭주 막는 법과 유사한 구조입니다.

목표: SQLite RAG에서 “압축”을 정의하기

여기서 말하는 압축은 단순히 gzip만 의미하지 않습니다. 실전에서는 아래 3가지를 함께 해야 효과가 납니다.

  1. 바이트 압축: 원문/요약을 zstd 또는 gzip으로 압축해 저장
  2. 정보 압축: 원문을 요약/정규화하여 “정보량 대비 토큰”을 줄임
  3. 구조 압축: 중복 제거, TTL, 계층형 메모리로 저장소의 엔트로피를 낮춤

핵심은 “검색 품질을 유지하면서도 주입되는 토큰을 제한”하는 것입니다.

SQLite 스키마: 원문·요약·임베딩을 분리

SQLite는 단일 파일로 운영이 단순하고, WAL 모드로 동시성도 어느 정도 확보됩니다. RAG의 기본 테이블을 아래처럼 나누면 압축과 GC가 쉬워집니다.

  • documents: 문서 메타 (소스, 해시, 생성 시각)
  • chunks: 청크 텍스트 (압축 저장 가능), 토큰 수, 중요도
  • embeddings: 청크 임베딩 벡터 (BLOB)
  • summaries: 문서/세션 요약(계층형 메모리)
-- SQLite
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;

CREATE TABLE IF NOT EXISTS documents (
  id INTEGER PRIMARY KEY,
  source TEXT NOT NULL,          -- 예: tool:web, tool:db, agent:obs
  source_id TEXT,                -- URL, 파일명, 요청 ID 등
  sha256 TEXT NOT NULL UNIQUE,   -- 중복 제거 키
  created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS chunks (
  id INTEGER PRIMARY KEY,
  document_id INTEGER NOT NULL,
  chunk_index INTEGER NOT NULL,
  content_zstd BLOB NOT NULL,    -- 압축된 텍스트
  content_len INTEGER NOT NULL,  -- 압축 전 문자 수
  token_est INTEGER NOT NULL,    -- 대략 토큰 수
  importance REAL NOT NULL DEFAULT 0.5,
  created_at INTEGER NOT NULL,
  FOREIGN KEY(document_id) REFERENCES documents(id)
);

CREATE TABLE IF NOT EXISTS embeddings (
  chunk_id INTEGER PRIMARY KEY,
  dim INTEGER NOT NULL,
  vector_f32 BLOB NOT NULL,      -- float32 배열
  FOREIGN KEY(chunk_id) REFERENCES chunks(id)
);

CREATE TABLE IF NOT EXISTS summaries (
  id INTEGER PRIMARY KEY,
  scope TEXT NOT NULL,           -- session, document, topic
  scope_id TEXT NOT NULL,
  summary_zstd BLOB NOT NULL,
  token_est INTEGER NOT NULL,
  created_at INTEGER NOT NULL,
  UNIQUE(scope, scope_id)
);

CREATE INDEX IF NOT EXISTS idx_chunks_doc_time ON chunks(document_id, created_at);
CREATE INDEX IF NOT EXISTS idx_docs_time ON documents(created_at);

포인트는 content를 평문으로 두지 않고 content_zstd 같은 BLOB로 두는 것입니다. SQLite는 텍스트도 압축해 저장하긴 하지만(페이지 단위), 장문 로그/관찰이 반복되는 워크로드에서는 애플리케이션 레벨 압축이 체감이 큽니다.

적재 파이프라인: 해시·정규화·압축·요약을 한 번에

1) 정규화로 “중복을 중복으로 인식”시키기

에이전트 로그는 타임스탬프/요청 ID/랜덤 값이 섞여서 동일한 내용도 해시가 달라집니다. 먼저 정규화한 텍스트로 해시를 만들면 중복 제거율이 올라갑니다.

import re
import hashlib

def normalize_text(text: str) -> str:
    text = text.strip()
    text = re.sub(r"\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b", "<ts>", text)
    text = re.sub(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "<uuid>", text, flags=re.I)
    text = re.sub(r"\s+", " ", text)
    return text

def sha256_hex(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

주의: 본문에 <ts> 같은 문자열을 그대로 쓰면 MDX에서 JSX로 오인할 수 있으니, 코드 블록 밖에서는 &lt;ts&gt;로 쓰거나 인라인 코드로 감싸야 합니다.

2) zstd로 텍스트 압축 저장

gzip도 되지만 속도와 압축률의 균형은 zstd가 좋습니다.

import time
import zstandard as zstd

_cctx = zstd.ZstdCompressor(level=6)
_dctx = zstd.ZstdDecompressor()

def compress_text(text: str) -> bytes:
    return _cctx.compress(text.encode("utf-8"))

def decompress_text(blob: bytes) -> str:
    return _dctx.decompress(blob).decode("utf-8")

def now_ts() -> int:
    return int(time.time())

3) 청크 크기 전략: “토큰 기준”으로 자르기

문자 수 기준은 언어/기호에 따라 토큰이 크게 흔들립니다. 가능하면 토크나이저를 써서 청크를 자르되, 운영 편의상 “대략 토큰 추정”이라도 넣어야 합니다.

def estimate_tokens_rough(text: str) -> int:
    # 매우 러프한 휴리스틱: 한글/영문 혼합 환경에서 보수적으로 잡기
    # 실제 운영에서는 모델별 토크나이저 사용 권장
    return max(1, len(text) // 3)

def chunk_text(text: str, max_tokens: int = 400) -> list[str]:
    words = text.split(" ")
    chunks, buf = [], []
    buf_tokens = 0

    for w in words:
        t = estimate_tokens_rough(w + " ")
        if buf and buf_tokens + t > max_tokens:
            chunks.append(" ".join(buf).strip())
            buf, buf_tokens = [], 0
        buf.append(w)
        buf_tokens += t

    if buf:
        chunks.append(" ".join(buf).strip())
    return chunks

“요약 기반 계층형 메모리”로 토큰 폭주를 차단

압축만으로는 한계가 있습니다. 장기 실행 에이전트는 결국 “기억”이 늘어나기 때문입니다. 해결책은 RAG를 2계층으로 나누는 것입니다.

  • Cold memory: 원문 청크(압축 저장), 필요할 때만 검색
  • Warm memory: 세션/토픽 요약(짧은 텍스트), 기본 주입

운영 규칙 예시:

  • N 스텝마다 최근 로그를 요약해 summaries(scope="session")에 갱신
  • top_k 검색은 기본 3~5로 제한, 부족하면 2차 검색
  • 프롬프트에는 “요약 1개 + 최신 메시지 몇 개 + 검색 청크 소수”만 포함

요약 업데이트 예시 코드

import sqlite3

def upsert_session_summary(conn: sqlite3.Connection, session_id: str, summary_text: str):
    blob = compress_text(summary_text)
    token_est = estimate_tokens_rough(summary_text)
    conn.execute(
        """
        INSERT INTO summaries(scope, scope_id, summary_zstd, token_est, created_at)
        VALUES(?, ?, ?, ?, ?)
        ON CONFLICT(scope, scope_id)
        DO UPDATE SET summary_zstd=excluded.summary_zstd,
                      token_est=excluded.token_est,
                      created_at=excluded.created_at
        """,
        ("session", session_id, blob, token_est, now_ts())
    )
    conn.commit()

요약 프롬프트 설계 팁:

  • “사실/결정/미해결 TODO/금지사항” 섹션을 분리
  • 숫자/경로/키 같은 민감 정보는 마스킹
  • 길이 상한을 명시: 예를 들어 300~500 토큰

중복 제거: SHA-256 + 근사 중복(선택)

1) 강한 중복 제거(정확)

정규화 텍스트의 sha256documents.sha256UNIQUE로 걸어두면 동일 문서가 다시 들어오는 것을 막을 수 있습니다.

def insert_document_if_new(conn, source: str, source_id: str | None, text: str) -> int | None:
    norm = normalize_text(text)
    h = sha256_hex(norm)

    try:
        cur = conn.execute(
            "INSERT INTO documents(source, source_id, sha256, created_at) VALUES(?, ?, ?, ?)",
            (source, source_id, h, now_ts())
        )
        conn.commit()
        return int(cur.lastrowid)
    except sqlite3.IntegrityError:
        return None

2) 근사 중복 제거(옵션)

로그가 조금만 달라도 중복으로 못 잡는 경우가 많습니다. 이때는 SimHash/MinHash 같은 근사 기법을 추가할 수 있습니다. 다만 구현 복잡도가 올라가므로, 우선은 “정규화 + 정확 해시”만으로도 효과가 큰 편입니다.

검색 단계 압축: topk 제한 + MMR + 길이 페널티

RAG 폭주의 직접 원인은 “너무 많이 가져오는 것”입니다. 다음 3가지를 같이 적용하면 체감이 큽니다.

  1. top_k 기본값을 작게(예: 4)
  2. MMR(Maximal Marginal Relevance)로 서로 비슷한 청크를 덜 가져오기
  3. 길이 페널티로 장문 청크를 자동으로 밀어내기

SQLite만으로 벡터 검색을 하려면 확장(예: sqlite-vss)을 쓰거나, 임베딩을 애플리케이션에서 로드해 근접 탐색을 해야 합니다. 여기서는 “개념”에 집중해 MMR 의사코드를 제시합니다.

import numpy as np

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))

def mmr_select(query_vec, cand_vecs, cand_ids, k=4, lam=0.7):
    selected = []
    selected_vecs = []

    scores = [cosine(query_vec, v) for v in cand_vecs]
    remaining = list(range(len(cand_ids)))

    for _ in range(min(k, len(remaining))):
        best_i, best_val = None, -1e9
        for idx in remaining:
            relevance = scores[idx]
            diversity = 0.0
            if selected_vecs:
                diversity = max(cosine(cand_vecs[idx], sv) for sv in selected_vecs)
            val = lam * relevance - (1 - lam) * diversity
            if val > best_val:
                best_val, best_i = val, idx
        selected.append(cand_ids[best_i])
        selected_vecs.append(cand_vecs[best_i])
        remaining.remove(best_i)

    return selected

여기에 “길이 페널티”를 얹으려면 relevance에서 token_est에 비례해 감점하면 됩니다.

TTL + 가비지 컬렉션: 저장소가 영원히 커지지 않게

장기 운영의 핵심은 “성장 곡선을 꺾는 것”입니다. 다음 정책을 권장합니다.

  • 관찰/로그 계열(sourceagent:obs, tool:*)은 TTL 7~30일
  • 핵심 지식(문서, 스펙, 결정 기록)은 TTL 없이 유지
  • 세션 요약은 최신 1개만 유지(이미 UNIQUE(scope, scope_id))

TTL 삭제 SQL

-- 30일 이전의 로그성 문서 삭제 (예시)
DELETE FROM documents
WHERE source IN ('agent:obs', 'tool:web', 'tool:api')
  AND created_at < strftime('%s','now') - 30*24*60*60;

-- 고아 chunk/embedding 정리
DELETE FROM chunks WHERE document_id NOT IN (SELECT id FROM documents);
DELETE FROM embeddings WHERE chunk_id NOT IN (SELECT id FROM chunks);

SQLite는 삭제 후 파일이 바로 줄지 않으니 주기적으로 VACUUM을 고려합니다. 다만 VACUUM은 잠금과 시간이 크므로, 운영에서는 다음 중 하나를 선택합니다.

  • 야간 배치에서만 VACUUM
  • PRAGMA auto_vacuum = INCREMENTAL; + PRAGMA incremental_vacuum;

“메모리 폭주”를 조기에 감지하는 지표

압축/요약을 해도, 모니터링이 없으면 다시 무너집니다. 아래 지표를 최소로 잡아두면 원인 추적이 쉬워집니다.

  • 프롬프트 총 토큰(입력)과 출력 토큰
  • 검색 주입 토큰 합(sum(token_est))
  • top_k 대비 실제 주입 개수
  • 문서/청크 증가율(시간당 insert 수)
  • 중복 제거율(해시 충돌이 아니라 “중복으로 스킵된 비율”)

특히 입력이 커져 API가 400을 내기 시작하면 “기능 장애”로 이어집니다. 이 경우는 단순 재시도보다 “입력 축소”가 먼저이며, 위에서 말한 요약/주입 제한이 정답인 경우가 많습니다.

운영 팁: SQLite로도 충분히 버티는 구성

  • WAL 모드로 쓰기 성능 확보
  • 한 트랜잭션에 “문서 1개 + 청크 N개 + 임베딩 N개”를 묶어 커밋 횟수 감소
  • 임베딩 계산은 비동기 큐로 분리해 에이전트 루프를 막지 않기
  • 압축 레벨은 3~6 정도에서 시작(너무 높이면 CPU가 병목)

정리: SQLite RAG 압축의 체크리스트

  • 정규화 후 해시로 정확 중복 제거
  • 청크 텍스트는 zstd로 압축 저장
  • 요약 기반 계층형 메모리(세션/토픽 요약)를 기본 주입
  • 검색은 top_k를 작게, MMR로 다양성 확보
  • TTL + 고아 레코드 정리 + (필요 시) VACUUM
  • 주입 토큰/청크 증가율/중복 제거율을 지표로 상시 관찰

이 구성을 적용하면 “기억을 많이 저장하는 에이전트”가 아니라 “기억을 잘 정리하는 에이전트”로 바뀌고, 장기 실행에서도 비용과 지연이 선형에 가깝게 유지됩니다.