Published on

AutoGPT 메모리 누수·루프폭주 막는 가드레일 6가지

Authors

AutoGPT 스타일 에이전트는 계획 수립 → 도구 호출 → 관찰 → 다음 행동 루프를 반복합니다. 이 구조는 문제 해결에 강하지만, 장시간 실행되면 메모리 누수처럼 보이는 상태(컨텍스트 팽창, 로그/아티팩트 폭증, 캐시 미정리)루프 폭주(동일 행동 반복, 실패 재시도 폭증, 도구 호출 난사) 로 쉽게 이어집니다.

특히 다음 조합에서 사고가 자주 납니다.

  • 관찰(Observation)과 중간 산출물을 그대로 모두 프롬프트에 누적
  • 실패한 도구 호출을 무제한 재시도
  • 종료 조건이 모호하거나, 성공 판정이 불가능한 작업(예: 웹 검색으로 “완벽한 답” 찾기)
  • 에이전트가 생성한 파일/로그/벡터DB가 계속 쌓이는데 정리 정책이 없음

이 글은 AutoGPT/Agentic loop를 운영 환경에서 돌릴 때, 메모리 누수와 루프 폭주를 막는 6가지 가드레일을 실전 코드와 함께 정리합니다.

참고로 “장시간 실행 프로세스에서 누수는 애플리케이션 레벨의 누적이 원인인 경우가 많다”는 관점은 웹 서버에서도 동일합니다. Next.js 환경에서의 누수 유형을 정리한 글도 함께 보면 진단 감이 빨리 잡힙니다: Next.js 14 App Router 메모리 누수 9가지


1) 하드 리미트: 스텝·시간·토큰·비용 예산을 강제

가장 강력한 가드레일은 예산(budget) 기반 중단입니다. “잘 되면 끝낸다”가 아니라 “예산이 끝나면 끝낸다”를 기본값으로 둬야 폭주가 멈춥니다.

권장하는 4종 예산은 아래입니다.

  • max_steps: 루프 반복 횟수 상한
  • max_wall_time_sec: 총 실행 시간 상한
  • max_prompt_tokens / max_completion_tokens: 대화 컨텍스트 상한
  • max_cost_usd: 모델 호출 비용 상한(가능하면)

코드 예제: BudgetGuard

import time
from dataclasses import dataclass

@dataclass
class Budget:
    max_steps: int = 30
    max_wall_time_sec: int = 120

class BudgetExceeded(Exception):
    pass

class BudgetGuard:
    def __init__(self, budget: Budget):
        self.budget = budget
        self.start = time.time()
        self.steps = 0

    def tick(self):
        self.steps += 1
        if self.steps > self.budget.max_steps:
            raise BudgetExceeded(f"max_steps exceeded: {self.steps}")
        if time.time() - self.start > self.budget.max_wall_time_sec:
            raise BudgetExceeded("max_wall_time_sec exceeded")

# loop
budget_guard = BudgetGuard(Budget(max_steps=20, max_wall_time_sec=60))
while True:
    budget_guard.tick()
    # plan -> act -> observe

운영 팁:

  • 예산 초과는 “에러”가 아니라 “정상 종료 사유”로 분류하고 결과를 요약해 반환하세요.
  • 예산 초과 시 마지막 상태를 저장하면, 다음 실행에서 이어달리기(resume)도 가능합니다.

2) 컨텍스트 가드: 관찰 로그를 그대로 누적하지 말고 요약·슬라이딩 윈도우

AutoGPT 계열에서 “메모리 누수”처럼 보이는 가장 흔한 원인은 프롬프트 컨텍스트가 계속 커지는 것입니다. 실제 RAM 누수라기보다 LLM 입력이 비대해져서 지연과 비용이 폭증하고, 결국 OOM 또는 타임아웃으로 이어집니다.

핵심은 아래 3가지를 분리하는 것입니다.

  • working_memory: 현재 스텝에 필요한 최소 정보
  • episodic_memory: 사건 단위 요약(문장 몇 줄)
  • artifact_store: 원본 로그/HTML/JSON/파일은 프롬프트에 넣지 말고 외부 저장

코드 예제: 슬라이딩 윈도우 + 요약 메모리

from collections import deque

class ContextManager:
    def __init__(self, max_messages=12):
        self.window = deque(maxlen=max_messages)
        self.summary = ""  # long-term summary

    def add(self, role, content):
        self.window.append({"role": role, "content": content})

    def should_summarize(self):
        return len(self.window) >= self.window.maxlen

    def summarize(self, llm):
        # window 내용을 요약하고, summary에 누적한 뒤 window를 줄인다
        prompt = (
            "다음 대화를 핵심 사실/결정/남은 할일 중심으로 10줄 이내로 요약해줘.\n\n"
            f"기존 요약:\n{self.summary}\n\n"
            f"새 대화:\n{self.window}"
        )
        new_summary = llm(prompt)
        self.summary = new_summary
        self.window.clear()
        self.window.append({"role": "system", "content": f"요약 메모리:\n{self.summary}"})

    def build_messages(self):
        return list(self.window)

운영 팁:

  • 원본 관찰(예: 웹 페이지 HTML)은 저장소에 넣고, 프롬프트에는 핵심 추출 결과만 넣으세요.
  • “요약 메모리”는 사실과 결정 위주로. 감정/수사는 토큰만 늘립니다.

로컬 LLM을 쓰는 경우엔 컨텍스트가 커질수록 VRAM과 KV 캐시 부담이 커져 OOM이 더 빨리 옵니다. 관련 튜닝 관점은 이 글도 참고할 만합니다: Transformers 로컬 LLM OOM - 4bit+KV 캐시 튜닝


3) 반복 탐지: 동일 행동·동일 도구 호출을 자동 차단

루프 폭주는 대부분 “같은 행동을 같은 입력으로 반복”하면서 생깁니다.

  • 검색 키워드를 조금씩 바꾸며 무한 검색
  • 같은 URL을 계속 크롤링
  • 같은 파일을 계속 수정
  • 같은 실패를 같은 파라미터로 재시도

따라서 행동 시그니처(action signature) 를 만들고, 최근 N개에서 중복이 일정 횟수 이상이면 차단하거나 강제 분기(사람에게 질문, 다른 전략 선택)로 보내야 합니다.

코드 예제: ActionSignature + 반복 차단

import hashlib
from collections import Counter, deque

class LoopDetected(Exception):
    pass

class RepeatGuard:
    def __init__(self, window=10, max_same=3):
        self.recent = deque(maxlen=window)
        self.max_same = max_same

    def _sig(self, action: dict) -> str:
        # action 예: {"tool": "web_search", "args": {"q": "..."}}
        raw = str(action).encode("utf-8")
        return hashlib.sha256(raw).hexdigest()[:16]

    def check(self, action: dict):
        sig = self._sig(action)
        self.recent.append(sig)
        c = Counter(self.recent)
        if c[sig] >= self.max_same:
            raise LoopDetected(f"repeated action detected: sig={sig}")

repeat_guard = RepeatGuard(window=12, max_same=3)

# before executing tool
repeat_guard.check(next_action)

운영 팁:

  • “완전 동일”만 잡으면 부족합니다. 정규화된 입력(공백 제거, 소문자화, URL 파라미터 정리) 기반 시그니처도 고려하세요.
  • 반복이 감지되면 단순 종료보다 “왜 반복되는지”를 에이전트에게 설명하고, 다음 행동 후보를 제한하는 프롬프트가 효과적입니다.

4) 재시도 가드: 지수 백오프·최대 횟수·서킷 브레이커

도구 호출 실패는 흔합니다. 문제는 실패를 무제한 재시도할 때입니다. HTTP 429, 일시적 DNS 오류, 모델 타임아웃이 발생하면 에이전트는 “다시 해보자”를 영원히 반복할 수 있습니다.

여기서는 SRE에서 흔히 쓰는 3가지를 그대로 적용합니다.

  • max_retries: 최대 재시도 횟수
  • exponential backoff: 재시도 간 대기
  • circuit breaker: 특정 오류가 연속되면 일정 시간 도구 사용을 차단

코드 예제: 간단한 서킷 브레이커

import time

class CircuitOpen(Exception):
    pass

class CircuitBreaker:
    def __init__(self, failure_threshold=5, cool_down_sec=30):
        self.failure_threshold = failure_threshold
        self.cool_down_sec = cool_down_sec
        self.failures = 0
        self.open_until = 0

    def before_call(self):
        if time.time() < self.open_until:
            raise CircuitOpen("circuit is open")

    def on_success(self):
        self.failures = 0

    def on_failure(self):
        self.failures += 1
        if self.failures >= self.failure_threshold:
            self.open_until = time.time() + self.cool_down_sec

breaker = CircuitBreaker()

def call_tool(tool_fn):
    breaker.before_call()
    try:
        res = tool_fn()
        breaker.on_success()
        return res
    except Exception:
        breaker.on_failure()
        raise

운영 팁:

  • 429503은 백오프 대상으로, 400 계열은 즉시 실패로 분류하는 등 오류 타입별 정책을 두세요.
  • 서킷이 열리면 에이전트가 다른 도구로 우회하거나, 사용자에게 입력을 요청하도록 설계합니다.

5) 샌드박스·리소스 상한: 프로세스 격리, 파일/디스크/네트워크 쿼터

에이전트가 실행하는 코드는 생각보다 위험합니다.

  • 로그를 무한히 쓰거나, 아티팩트를 계속 생성해 디스크를 채움
  • 크롤링을 과하게 해서 네트워크 사용량 폭증
  • 파이썬 런타임에서 객체를 계속 쌓아 RSS가 상승

따라서 “에이전트 1회 실행”을 격리된 프로세스로 두고, OS 레벨에서 상한을 거는 것이 가장 확실합니다.

  • 컨테이너: cpu, memory, pids, ephemeral-storage 제한
  • 프로세스: ulimit(파일 디스크립터, 프로세스 수 등)
  • 네트워크: egress 정책, 도메인 allowlist

코드 예제: Docker 실행에 리소스 제한 적용

docker run --rm \
  --cpus=1 \
  --memory=2g \
  --pids-limit=256 \
  --read-only \
  --tmpfs /tmp:rw,size=256m \
  -e AGENT_MAX_STEPS=30 \
  my-autogpt-agent:latest

운영 팁:

  • --read-onlytmpfs 조합은 “파일을 무한 생성”하는 사고를 크게 줄입니다.
  • 네트워크는 가능하면 allowlist로. 에이전트에게 인터넷은 곧 무한 루프 연료입니다.

6) 종료 조건을 기계적으로 만들기: Done 스키마·검증기·테스트

루프 폭주의 본질은 “언제 끝났는지”를 에이전트가 판정하지 못하는 데 있습니다. 그래서 종료는 자연어가 아니라 스키마로 받아야 합니다.

권장 패턴:

  • 에이전트 출력에 status를 강제: "status": "done" | "need_input" | "failed"
  • done일 때는 deliverable(최종 산출물)과 acceptance_checks(자기검증 체크리스트)를 함께 받기
  • 서버 측에서 validator로 스키마와 체크를 확인하고, 실패 시 다음 스텝에서 “무엇이 부족한지”를 명시적으로 알려주기

코드 예제: JSON 스키마 형태의 종료 계약

import json

def validate_final(result: dict) -> None:
    required = ["status", "deliverable"]
    for k in required:
        if k not in result:
            raise ValueError(f"missing key: {k}")
    if result["status"] not in ["done", "need_input", "failed"]:
        raise ValueError("invalid status")

# LLM이 반드시 JSON만 출력하도록 유도했다고 가정
raw = '{"status":"done","deliverable":"...","acceptance_checks":["..."],"notes":"..."}'
result = json.loads(raw)
validate_final(result)

운영 팁:

  • done 판정은 LLM에게 맡기되, “서버 검증기”가 최종 게이트를 잡아야 합니다.
  • 산출물 품질을 자동 평가하기 어렵다면, 최소한 “필수 항목이 모두 채워졌는지”는 기계적으로 검사하세요.

운영 체크리스트: 6가지 가드레일을 한 번에 묶기

실제로는 위 가드레일을 각각 따로 두기보다, 에이전트 런타임에 공통 미들웨어처럼 묶는 편이 관리가 쉽습니다.

  • 실행 전: 예산 초기화, 도구 allowlist 로딩, 샌드박스 준비
  • 매 스텝 전: BudgetGuard.tick()
  • 행동 결정 후: RepeatGuard.check(action)
  • 도구 호출 전: CircuitBreaker.before_call() 및 도메인/쿼터 검사
  • 관찰 수집 후: ContextManager.add() 후 필요 시 요약
  • 종료 시: 스키마 검증 및 아티팩트 정리

코드 예제: 간단한 런타임 골격

def run_agent(llm, tools, objective):
    budget = BudgetGuard(Budget(max_steps=25, max_wall_time_sec=90))
    repeat = RepeatGuard(window=12, max_same=3)
    breaker = CircuitBreaker(failure_threshold=4, cool_down_sec=20)
    ctx = ContextManager(max_messages=10)

    ctx.add("system", "너는 안전 가드레일을 준수하는 에이전트다.")
    ctx.add("user", objective)

    while True:
        budget.tick()

        if ctx.should_summarize():
            ctx.summarize(llm)

        action = plan_next_action(llm, ctx.build_messages())  # dict
        if action.get("type") == "final":
            validate_final(action["result"])
            return action["result"]

        repeat.check(action)

        breaker.before_call()
        try:
            obs = tools[action["tool"]](**action.get("args", {}))
            breaker.on_success()
        except Exception as e:
            breaker.on_failure()
            obs = f"tool_error: {type(e).__name__}: {e}"

        ctx.add("assistant", f"action: {action}")
        ctx.add("tool", f"observation: {obs}")

마무리: 폭주는 의지 문제가 아니라 시스템 설계 문제

AutoGPT는 “똑똑한 단일 호출”이 아니라 “장시간 도는 분산 시스템”에 가깝습니다. 그래서 안정성은 프롬프트 엔지니어링보다 예산, 반복 탐지, 재시도 정책, 격리, 종료 계약 같은 시스템 가드레일이 좌우합니다.

정리하면 아래 6가지만 먼저 넣어도 체감이 큽니다.

  1. 스텝/시간/토큰/비용 하드 리미트
  2. 컨텍스트 요약과 슬라이딩 윈도우
  3. 행동 시그니처 기반 반복 탐지
  4. 지수 백오프 + 서킷 브레이커
  5. 컨테이너/프로세스 샌드박스와 리소스 쿼터
  6. done을 스키마로 강제하고 서버 검증기로 종료 판정

이후에야 “더 좋은 플래너”, “더 강한 모델”, “더 많은 도구”가 의미를 가집니다. 가드레일 없이 능력만 키우면, 더 빠르고 더 비싼 폭주가 될 뿐입니다.