- Published on
AutoGPT 메모리 폭주 해결 - SQLite 체크포인트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT 계열 에이전트를 장시간 돌리다 보면 어느 순간부터 프로세스 RSS가 계속 증가하고, 응답 지연이 커지다가 OOM으로 죽는 경우가 많습니다. 특히 memory 혹은 vector store 라는 이름으로 구현된 레이어가 대화/관찰/툴 결과를 계속 쌓고, 이를 매 스텝마다 다시 읽어 프롬프트에 붙이거나 임베딩을 재계산하면서 폭주가 가속됩니다.
이 글은 “모든 것을 메모리에 들고 있는 구조”를 “SQLite로 체크포인트를 남기고, 필요한 만큼만 로드하는 구조”로 바꾸는 방법을 다룹니다. 핵심은 다음 3가지입니다.
- WAL 모드 + 주기적 체크포인트로 SQLite 파일 크기와 메모리 사용을 안정화
- 스텝 단위 스냅샷(체크포인트) + 재시작 복구로 장시간 실행을 안전하게
- **메모리 정책(요약/TTL/상한)**을 DB 레벨에서 강제해 누적을 차단
관련해서 캐시가 꼬이거나 오래된 상태가 남아 문제를 만들 때의 접근법은 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법도 사고방식이 유사합니다. “상태가 어디에 저장되고, 언제 무효화되는가”를 끝까지 추적해야 합니다.
1) AutoGPT 메모리 폭주의 전형적인 원인
AutoGPT 류 구현체를 보면 메모리 폭주는 대개 아래 조합에서 발생합니다.
1-1. 관찰(Observation)과 툴 결과가 무한 누적
브라우징, 코드 실행, 파일 읽기 같은 툴 결과는 길이가 큽니다. 이를 그대로 히스토리에 붙이면 토큰도 커지고, 내부적으로 문자열/리스트가 계속 커져 RSS도 증가합니다.
1-2. 벡터스토어 인덱싱이 과도하게 자주 수행
스텝마다 새 문서를 임베딩하고 인덱스에 추가하면, 인덱스가 커질수록 검색/머지 비용이 커집니다. 인메모리 인덱스면 더 치명적입니다.
1-3. “요약”이 오히려 누적을 가속
요약을 생성해도 원문을 버리지 않으면, 원문 + 요약이 둘 다 남습니다. 요약을 여러 번 하면 요약의 요약이 생기고, 결국 중복 데이터가 폭증합니다.
1-4. SQLite를 쓰는데도 파일이 계속 커짐(WAL 방치)
SQLite를 이미 쓰고 있어도 journal_mode=WAL 상태에서 체크포인트를 하지 않으면 -wal 파일이 계속 커질 수 있습니다. 디스크만 커지는 게 아니라, 특정 상황에서는 캐시/페이지 관리가 비효율적으로 돌아 체감 메모리 사용도 증가합니다.
2) 해결 전략: SQLite 체크포인트를 “메모리 브레이크”로 만들기
여기서 말하는 체크포인트는 2가지 의미를 함께 씁니다.
- (A) SQLite WAL 체크포인트: WAL에 쌓인 변경분을 본 DB 파일로 합쳐
-wal파일 성장을 제어 - (B) 에이전트 실행 체크포인트: 스텝 단위로 상태를 DB에 스냅샷으로 저장해, 메모리에서 버리고 필요 시 복구
둘을 같이 적용하면 장시간 실행에서 메모리와 디스크 모두 안정성이 크게 올라갑니다.
3) SQLite 설정: WAL + 동기화 + 체크포인트 주기
3-1. 권장 PRAGMA 세트
아래는 “단일 프로세스 에이전트” 또는 “소수 동시성”에서 흔히 쓰는 조합입니다.
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA wal_autocheckpoint = 0;
journal_mode=WAL: 읽기/쓰기 동시성, 크래시 내성에 유리synchronous=NORMAL: 성능과 안정성 균형(엄격한 내구성이 필요하면FULL)wal_autocheckpoint=0: 자동 체크포인트를 끄고 우리 주기로 강제(예측 가능)
3-2. 수동 체크포인트 실행
스텝이 N번 진행될 때마다 한 번씩 체크포인트를 걸어줍니다.
PRAGMA wal_checkpoint(TRUNCATE);
TRUNCATE: 체크포인트 후 WAL 파일을 줄여 디스크 성장을 즉시 억제- 높은 처리량이 필요하면
RESTART또는PASSIVE로 완화할 수 있습니다.
4) “에이전트 체크포인트” 스키마 설계
핵심은 “현재 스텝에서 필요한 최소 상태”만 DB에 남기고, 메모리에는 캐시 수준만 유지하는 것입니다.
4-1. 테이블 예시
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS steps (
run_id TEXT NOT NULL,
step_no INTEGER NOT NULL,
created_at INTEGER NOT NULL,
user_input TEXT,
model_output TEXT,
tool_name TEXT,
tool_input TEXT,
tool_output TEXT,
summary TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
PRIMARY KEY (run_id, step_no)
);
CREATE TABLE IF NOT EXISTS kv_state (
run_id TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (run_id, key)
);
CREATE INDEX IF NOT EXISTS idx_steps_run_created ON steps(run_id, created_at);
포인트:
steps는 “감사 로그”이자 복구 근거입니다.kv_state는 현재 목표, 작업 큐, 파일 작업 목록 등 “실행 상태”를 JSON으로 저장합니다.tool_output은 길어질 수 있으니 분리 테이블로 빼거나 gzip 압축을 고려할 수 있습니다.
4-2. 큰 텍스트(툴 출력) 분리 전략
tool_output 이 너무 크면 아래처럼 분리하세요.
CREATE TABLE IF NOT EXISTS blobs (
blob_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
step_no INTEGER NOT NULL,
kind TEXT NOT NULL,
content BLOB NOT NULL,
created_at INTEGER NOT NULL
);
ALTER TABLE steps ADD COLUMN tool_output_blob_id TEXT;
이렇게 하면 steps 조회 시 기본적으로 큰 데이터를 읽지 않아도 됩니다.
5) Python 예제: 스텝 저장 + WAL 체크포인트
아래 코드는 sqlite3 표준 라이브러리만으로 “스텝 저장”과 “주기적 체크포인트”를 구현한 예시입니다.
import json
import sqlite3
import time
import uuid
from contextlib import contextmanager
DB_PATH = "agent.db"
@contextmanager
def connect():
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA wal_autocheckpoint=0")
try:
yield conn
conn.commit()
finally:
conn.close()
def init_schema():
with connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS steps (
run_id TEXT NOT NULL,
step_no INTEGER NOT NULL,
created_at INTEGER NOT NULL,
user_input TEXT,
model_output TEXT,
tool_name TEXT,
tool_input TEXT,
tool_output TEXT,
summary TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
PRIMARY KEY (run_id, step_no)
);
CREATE TABLE IF NOT EXISTS kv_state (
run_id TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (run_id, key)
);
CREATE INDEX IF NOT EXISTS idx_steps_run_created ON steps(run_id, created_at);
"""
)
def create_run():
run_id = str(uuid.uuid4())
now = int(time.time())
with connect() as conn:
conn.execute(
"INSERT INTO runs(run_id, created_at, status) VALUES(?, ?, ?)",
(run_id, now, "running"),
)
return run_id
def save_step(run_id: str, step_no: int, payload: dict):
now = int(time.time())
with connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO steps(
run_id, step_no, created_at,
user_input, model_output,
tool_name, tool_input, tool_output,
summary, tokens_in, tokens_out
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run_id,
step_no,
now,
payload.get("user_input"),
payload.get("model_output"),
payload.get("tool_name"),
payload.get("tool_input"),
payload.get("tool_output"),
payload.get("summary"),
payload.get("tokens_in"),
payload.get("tokens_out"),
),
)
# 예: 20스텝마다 WAL 체크포인트
if step_no % 20 == 0:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
def set_state(run_id: str, key: str, value):
now = int(time.time())
with connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO kv_state(run_id, key, value_json, updated_at) VALUES(?, ?, ?, ?)",
(run_id, key, json.dumps(value, ensure_ascii=False), now),
)
def get_state(run_id: str, key: str, default=None):
with connect() as conn:
row = conn.execute(
"SELECT value_json FROM kv_state WHERE run_id=? AND key=?",
(run_id, key),
).fetchone()
if not row:
return default
return json.loads(row[0])
if __name__ == "__main__":
init_schema()
run_id = create_run()
for i in range(1, 101):
# 여기서는 예시로 길이가 커질 수 있는 tool_output을 일부러 생성
tool_output = "x" * (i * 1000)
save_step(
run_id,
i,
{
"user_input": f"step {i}",
"model_output": "ok",
"tool_name": "dummy",
"tool_input": "-",
"tool_output": tool_output,
"summary": "short",
"tokens_in": 100,
"tokens_out": 50,
},
)
set_state(run_id, "last_step", i)
이 구조의 장점은 “이전 스텝 전체를 메모리에 들고 있지 않아도” 운영이 가능하다는 점입니다. 에이전트 루프는 최근 K개 스텝만 필요할 때 DB에서 로드하면 됩니다.
6) 프롬프트에 붙는 메모리 상한: 최근 K개 + 요약 1개
메모리 폭주를 막는 실전 규칙은 단순합니다.
- 원문 히스토리는 최근
K개만 프롬프트에 포함 - 그 이전은 “단일 요약”으로만 포함
- 요약도 무한히 쌓지 말고 “요약을 갱신”하는 방식으로 1개를 유지
6-1. 로드 쿼리 예시
SELECT step_no, user_input, model_output, tool_name, tool_output, summary
FROM steps
WHERE run_id = ?
ORDER BY step_no DESC
LIMIT ?;
그리고 오래된 구간의 요약은 kv_state 에 rolling_summary 같은 키로 1개만 유지합니다.
7) 데이터 정리(가비지 컬렉션): TTL과 상한을 DB에서 강제
메모리 폭주를 “프롬프트 정책”만으로 막으면, DB는 계속 커질 수 있습니다. 장시간 실행/다중 런 환경에서는 DB 레벨의 GC가 필요합니다.
7-1. 스텝 상한 기반 삭제
예: 각 run_id 에서 5,000스텝을 넘기면 오래된 스텝을 삭제합니다.
DELETE FROM steps
WHERE run_id = ?
AND step_no <= (
SELECT MAX(step_no) - 5000 FROM steps WHERE run_id = ?
);
7-2. 시간 기반 TTL
DELETE FROM steps
WHERE run_id = ?
AND created_at < ?;
정리 후에는 공간 회수가 필요할 수 있습니다.
- WAL 모드에서는 보통
VACUUM을 자주 돌리지 않습니다. - 대신 주기적으로
PRAGMA wal_checkpoint(TRUNCATE)를 실행해 WAL 비대화를 먼저 제어합니다.
8) 체크포인트가 오히려 느려질 때: 병목과 완화
체크포인트는 I/O 작업입니다. 너무 자주 실행하면 TPS가 떨어집니다. 아래 기준으로 튜닝하세요.
- “스텝 N회마다”가 아니라 “WAL 크기 기준”으로 실행
TRUNCATE대신RESTART로 완화- 큰
tool_output을 분리해 일반 조회 경로에서 제외
WAL 크기 기반은 파일 시스템에서 agent.db-wal 크기를 보고 결정할 수 있습니다.
9) 운영에서 자주 만나는 함정 5가지
9-1. 하나의 커넥션을 오래 물고 가며 거대 트랜잭션 생성
에이전트 루프 전체를 하나의 트랜잭션으로 묶으면 WAL이 폭발합니다. 스텝 단위로 커밋하세요. 트랜잭션 경계가 문제를 키우는 패턴은 서버 개발에서도 반복됩니다. 트랜잭션 전파/격리로 인한 예상 밖의 누적 이슈는 Spring Boot 3 @Transactional 전파·격리 함정 7가지도 참고할 만합니다.
9-2. 툴 출력이 바이너리급 크기인데 TEXT로 저장
스크린샷, HTML 원문, PDF 텍스트 덤프 등은 분리/압축이 필요합니다.
9-3. “요약 성공” 후 원문을 그대로 유지
요약이 목적이면 원문은 TTL로 제거하거나, 최소한 프롬프트에서 제외해야 합니다.
9-4. 멀티프로세스에서 SQLite 잠금 경합
동시성이 커지면 SQLite 대신 Postgres로 가는 게 낫습니다. 그래도 SQLite를 쓴다면 busy_timeout 과 짧은 트랜잭션이 필수입니다.
9-5. 체크포인트 시점에 읽기 작업이 길게 붙잡혀 있음
장시간 SELECT가 열려 있으면 체크포인트가 진전되지 않습니다. 큰 결과를 스트리밍하거나 페이지네이션 하세요.
10) 최소 적용 체크리스트
-
journal_mode=WAL적용 -
wal_autocheckpoint=0후, 스텝 N회마다PRAGMA wal_checkpoint(TRUNCATE) - 스텝 단위 커밋(거대 트랜잭션 금지)
- 프롬프트 히스토리 최근
K개 + 롤링 요약 1개 -
tool_output분리(또는 압축) + TTL/상한 삭제
마무리
AutoGPT 메모리 폭주는 “모델이 똑똑해져서”가 아니라, 대부분 상태를 무한히 누적하는 저장 전략 때문에 발생합니다. SQLite 체크포인트를 단순히 DB 관리 기법으로만 보지 말고, 에이전트 아키텍처에서 “메모리 브레이크”로 사용하면 장시간 실행 안정성이 확 올라갑니다.
다음 단계로는 (1) 툴 출력의 구조화 저장, (2) 요약 품질을 유지하면서 원문을 제거하는 정책, (3) 벡터 인덱스 업데이트를 배치로 묶는 전략을 붙이면 메모리/디스크/지연시간이 함께 안정화됩니다.