- Published on
AutoGPT 메모리 누수? Redis TTL·요약으로 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 메모리가 꾸준히 증가하고, Redis 키 수와 벡터 인덱스가 끝없이 커지며, 결국 OOM 또는 비용 폭탄으로 이어지는 현상은 AutoGPT 같은 에이전트 시스템에서 흔합니다. 엄밀히 말해 프로세스 힙이 회수되지 않는 전통적 의미의 메모리 누수가 아니라, "기억"(대화 히스토리, 관찰 로그, 툴 출력, 임시 상태, 벡터 메모리)이 무제한으로 축적되는 설계 결함에 가깝습니다.
이 글에서는 AutoGPT 스타일 아키텍처에서 메모리(상태) 폭증이 발생하는 지점을 분해하고, Redis TTL로 수명 관리를 걸고, 요약으로 토큰/저장소를 압축해 장기 운영 가능한 형태로 만드는 방법을 다룹니다.
AutoGPT의 ‘메모리 누수’가 생기는 지점
AutoGPT류 에이전트는 보통 아래 데이터를 지속적으로 쌓습니다.
- 세션 상태: 목표, 플랜, 현재 스텝, 도구 설정, 실패 카운트
- 대화 히스토리: user/assistant 메시지 전체
- 관찰(Observation) 로그: 툴 호출 결과, 스크래핑 결과, 에러 스택
- 작업 큐/이벤트: 다음 행동 후보, 재시도 태스크
- 장기 메모리 저장소: Redis, Postgres, S3, 벡터DB(Pinecone/Milvus/Qdrant 등)
문제는 대개 2~4번이 **"디버깅에 유용하다"**는 이유로 무제한 보관되면서 시작합니다. 특히 툴 출력이 크면(HTML, JSON, 로그) 토큰 비용과 저장 비용이 동시에 폭증합니다.
또한 에이전트는 반복 루프를 돌며 계속 컨텍스트를 늘리기 때문에, 저장소가 아니라 LLM 호출 비용도 함께 증가합니다. 즉, 메모리 폭증은 성능 문제이자 비용 문제입니다.
해결 전략 개요: TTL로 수명 제한 + 요약으로 압축
실전에서 가장 안정적인 조합은 아래 두 가지를 같이 쓰는 것입니다.
- Redis TTL: “이 데이터는 언제까지 유효한가”를 강제한다
- 요약(Summarization): “필요한 정보만 남기고 나머지는 압축한다”
TTL만 걸면 중요한 히스토리가 사라져 품질이 떨어질 수 있고, 요약만 하면 저장소는 줄어도 키가 계속 늘어날 수 있습니다. 둘을 결합하면 상태 크기와 상태 개수를 동시에 제어할 수 있습니다.
Redis 키 설계: 세션 단위로 수명 관리하기
Redis를 단순 캐시가 아니라 “에이전트 상태 저장소”로 쓰는 경우, 키 설계가 곧 비용과 안정성을 결정합니다.
권장 키 네임스페이스
agent:{agentId}:session:{sessionId}:stateagent:{agentId}:session:{sessionId}:messagesagent:{agentId}:session:{sessionId}:observationsagent:{agentId}:session:{sessionId}:summaryagent:{agentId}:session:{sessionId}:locks
여기서 중요한 원칙은 세션 단위로 묶어서 TTL을 일관되게 적용하는 것입니다.
TTL 정책 예시
state: 24시간messages: 6시간observations: 1시간 (툴 출력은 보통 가장 크므로 짧게)summary: 7일 (압축본은 길게)locks: 30초~2분
세션이 길게 이어질 수 있다면 “슬라이딩 TTL”을 적용해, 세션이 활성화될 때마다 TTL을 갱신합니다.
코드 예제: Redis TTL과 슬라이딩 만료(Typescript)
아래 예시는 ioredis 기반으로 세션 데이터에 TTL을 적용하고, 요청이 들어올 때마다 TTL을 갱신하는 패턴입니다.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const TTL = {
stateSec: 60 * 60 * 24,
messagesSec: 60 * 60 * 6,
observationsSec: 60 * 60,
summarySec: 60 * 60 * 24 * 7,
};
function keys(agentId: string, sessionId: string) {
const base = `agent:${agentId}:session:${sessionId}`;
return {
state: `${base}:state`,
messages: `${base}:messages`,
observations: `${base}:observations`,
summary: `${base}:summary`,
};
}
export async function appendMessage(params: {
agentId: string;
sessionId: string;
role: "user" | "assistant";
content: string;
}) {
const k = keys(params.agentId, params.sessionId);
const msg = JSON.stringify({
ts: Date.now(),
role: params.role,
content: params.content,
});
// 메시지 리스트에 추가
await redis.rpush(k.messages, msg);
// 슬라이딩 TTL 갱신
await redis.expire(k.messages, TTL.messagesSec);
await redis.expire(k.state, TTL.stateSec);
}
export async function appendObservation(params: {
agentId: string;
sessionId: string;
tool: string;
output: unknown;
}) {
const k = keys(params.agentId, params.sessionId);
const obs = JSON.stringify({
ts: Date.now(),
tool: params.tool,
output: params.output,
});
await redis.rpush(k.observations, obs);
await redis.expire(k.observations, TTL.observationsSec);
}
export async function setSummary(params: {
agentId: string;
sessionId: string;
summary: string;
}) {
const k = keys(params.agentId, params.sessionId);
await redis.set(k.summary, params.summary, "EX", TTL.summarySec);
}
핵심은 “저장할 때마다 expire을 다시 건다”입니다. 이 방식은 활성 세션만 살아남고, 방치된 세션은 자연스럽게 정리됩니다.
리스트가 계속 커지는 문제: LTRIM으로 상한선 두기
TTL만으로는 활성 세션에서 리스트가 무한히 커질 수 있습니다. 예를 들어 messages가 6시간 TTL이라도 6시간 동안 1만 건이 쌓이면 메모리 폭증은 그대로입니다.
따라서 길이 기반 상한선을 같이 둡니다.
- 메시지: 최근 50~200턴
- 관찰 로그: 최근 20~100개
export async function appendMessageCapped(params: {
agentId: string;
sessionId: string;
role: "user" | "assistant";
content: string;
keepLast: number;
}) {
const k = keys(params.agentId, params.sessionId);
const msg = JSON.stringify({ ts: Date.now(), role: params.role, content: params.content });
const pipeline = redis.pipeline();
pipeline.rpush(k.messages, msg);
// 최근 keepLast개만 유지
pipeline.ltrim(k.messages, -params.keepLast, -1);
pipeline.expire(k.messages, TTL.messagesSec);
pipeline.expire(k.state, TTL.stateSec);
await pipeline.exec();
}
이 패턴은 “TTL로 시간 상한, LTRIM으로 크기 상한”을 동시에 제공합니다.
요약 전략: 토큰과 저장소를 동시에 줄이기
요약은 단순히 대화 내용을 줄이는 것 이상입니다. 에이전트의 품질을 유지하려면 “무엇을 남길지”가 중요합니다.
추천 요약 포맷
요약은 자유 텍스트보다 구조화된 텍스트가 유지보수에 유리합니다.
- 목표/제약
- 현재까지 확정된 사실
- 미해결 이슈
- 다음 행동 후보
- 사용한 도구와 중요한 결과(핵심만)
예시:
Goal: ...
Constraints: ...
Facts:
- ...
Decisions:
- ...
Open Questions:
- ...
Next Actions:
- ...
이렇게 만들면 다음 턴에서 LLM에게 컨텍스트로 넣기 쉽고, 검색/필터링도 수월합니다.
코드 예제: 일정 길이를 넘으면 자동 요약 후 원본 축소
아래는 “메시지가 N개를 넘으면 요약을 갱신하고, 원본 메시지는 최근 M개만 남기는” 흐름입니다.
주의: 본문에 부등호 기호가 노출되면 MDX 빌드 에러가 날 수 있으므로, 비교 연산자는 코드 블록 안에서만 사용합니다.
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function summarizeMessages(rawMessages: Array<{ role: string; content: string }>) {
const prompt = [
{
role: "system",
content:
"You are a summarizer for an autonomous agent. Summarize into: Goal, Constraints, Facts, Decisions, Open Questions, Next Actions.",
},
{
role: "user",
content: rawMessages.map(m => `${m.role}: ${m.content}`).join("\n"),
},
] as const;
const resp = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: prompt,
temperature: 0.2,
});
return resp.choices[0]?.message?.content ?? "";
}
export async function maybeSummarize(params: {
agentId: string;
sessionId: string;
summarizeWhenMoreThan: number;
keepLastAfterSummarize: number;
}) {
const k = keys(params.agentId, params.sessionId);
const len = await redis.llen(k.messages);
if (len <= params.summarizeWhenMoreThan) return;
const raw = await redis.lrange(k.messages, 0, -1);
const parsed = raw.map(s => JSON.parse(s) as { role: string; content: string });
const summary = await summarizeMessages(parsed);
await setSummary({ agentId: params.agentId, sessionId: params.sessionId, summary });
// 원본 메시지는 최근 일부만 남기고 축소
await redis.ltrim(k.messages, -params.keepLastAfterSummarize, -1);
}
운영 팁:
- 요약 호출도 비용이므로, “메시지 개수” 외에 “총 토큰 추정치” 기준을 추가하면 더 안정적입니다.
- 요약은 실패할 수 있으니, 실패 시 원본을 유지하고 다음 턴에 재시도하도록 설계합니다. API 재시도/백오프는 이 글과 함께 보면 좋습니다: OpenAI 429·insufficient_quota 재시도와 백오프 설계
Redis에 무엇을 남기고, 무엇을 외부 저장소로 보낼까
Redis는 빠르지만 메모리 기반이라 비용이 빠르게 증가합니다. 따라서 다음 원칙이 유용합니다.
- Redis: “지금 당장 다음 행동을 위해 필요한 상태”
- 외부 저장소(S3, Postgres, 로그 시스템): “감사/재현/분석을 위한 원본”
예를 들어 관찰 로그 원본은 S3에 gzip으로 저장하고, Redis에는 해시(요약/메타)만 남길 수 있습니다.
관찰 로그 압축/샘플링
툴 출력이 큰 경우 다음을 고려합니다.
- 크기 제한:
output이 특정 바이트를 넘으면 앞부분/뒷부분만 저장 - 샘플링: 반복 루프에서 동일한 에러가 계속 나면 동일 로그는 1회만 저장
- 정규화: HTML 전체 대신 본문 텍스트만 추출
이런 “관찰 로그 다이어트”만으로도 Redis 메모리 사용량이 급격히 줄어듭니다.
벡터 메모리까지 함께 새는 경우: TTL과 삭제 전략
AutoGPT가 장기 메모리를 벡터DB에 넣는 구조라면, Redis TTL만으로는 부족합니다. 벡터 인덱스에 계속 쌓이면 결국 검색 품질과 비용이 같이 망가집니다.
- 너무 많은 저품질 메모리가 들어가면 최근/중요 정보가 묻힘
- 인덱스가 커지면 검색 지연과 비용이 증가
이때는 다음 중 하나가 필요합니다.
- 벡터 메모리도 TTL을 흉내 내는 삭제 작업
- 업서트 시점에 중요도 점수 부여 후, 낮은 점수부터 주기적 청소
- 요약본만 벡터화하고 원본은 저장하지 않기
벡터 검색 품질이 갑자기 떨어질 때 점검 포인트는 이 글도 도움이 됩니다: Pinecone·Milvus HNSW 리콜 급락 원인 6가지
운영에서 바로 쓰는 체크리스트
배포 후 “메모리 누수처럼 보이는 현상”을 빨리 잡으려면 지표가 필요합니다.
Redis 지표
- 키 개수 증가율(네임스페이스별)
- 메모리 사용량과 eviction 발생 여부
maxmemory-policy설정(예:allkeys-lruvsnoeviction)- 핫키 여부(특정 세션만 과도한 트래픽)
애플리케이션 지표
- 세션당 메시지 수 분포(상한이 지켜지는지)
- 요약 호출 빈도와 실패율
- LLM 호출 토큰 사용량(요약 전후)
장애로 이어지는 대표 시나리오
- Redis가
noeviction인데 TTL이 안 걸린 키가 누적되어 쓰기 실패 - 관찰 로그가 크고 빈번해 네트워크/직렬화 비용이 폭증
- 요약이 없어서 프롬프트가 길어지고 지연이 증가
쿠버네티스 환경에서 OOM으로 크래시가 반복된다면, 애플리케이션 레벨 누적뿐 아니라 리소스 제한/GC/버퍼도 함께 점검해야 합니다: EKS CrashLoopBackOff? OOMKilled 진단 7단계
결론: “기억은 자산”이지만 “수명과 압축”이 필수다
AutoGPT의 메모리 누수는 대개 버그라기보다 무제한 축적을 허용한 설계의 결과입니다. Redis TTL로 세션과 로그의 수명을 강제하고, LTRIM으로 활성 세션의 크기를 제한하며, 요약으로 핵심만 남기면 다음 효과를 얻습니다.
- Redis 메모리 사용량이 안정화
- 프롬프트 길이가 줄어 응답 지연과 비용 감소
- 장기 운영에서 품질 저하(컨텍스트 혼탁) 완화
실무에서는 “TTL만” 혹은 “요약만”으로는 부족한 경우가 많습니다. **TTL(수명) + 상한선(크기) + 요약(압축)**을 기본 세트로 두고, 벡터DB까지 쓰는 경우에는 벡터 메모리의 삭제/정리 전략까지 포함해 설계하는 것이 가장 안전합니다.