Published on

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

Authors

AutoGPT 스타일 에이전트(계획 수립 -> 툴 호출 -> 관찰 -> 재계획)는 “스스로 오래 달리게” 만들수록 사고가 납니다. 대표 증상이 두 가지입니다.

  • 메모리 폭주: 대화 히스토리와 관찰 로그가 계속 누적되어 컨텍스트가 비대해지고, 토큰 비용과 지연이 기하급수적으로 증가합니다.
  • 루프 반복: 같은 도구를 같은 인자로 반복 호출하거나, 목표가 모호해 계속 재계획만 하다가 종료 조건 없이 맴돕니다.

이 글은 AutoGPT의 구현체가 무엇이든 적용 가능한 **6가지 가드레일(Guardrail)**을 “코드로 강제”하는 방법에 초점을 둡니다. 특히 운영 환경에서는 “프롬프트로 부탁”하는 방식이 아니라, 런타임 제약과 관측 가능성으로 문제를 닫아야 합니다.

아래 가드레일은 서로 독립적이지만, 실제로는 1번(예산) + 2번(상태 머신) + 3번(루프 감지)만 적용해도 장애율이 크게 줄어듭니다.

1) 실행 예산(Budget) 강제: 토큰·스텝·시간 상한

루프와 메모리 폭주의 공통점은 “언제 멈출지”가 코드에 없다는 점입니다. 가장 먼저 해야 할 일은 예산을 수치로 정의하고, 초과 시 강제 종료 또는 축소 모드로 전환하는 것입니다.

권장 예산 3종 세트:

  • Step budget: 에이전트 루프 최대 반복 횟수
  • Time budget: wall-clock 기준 최대 실행 시간
  • Token budget: 입력 컨텍스트 토큰 + 출력 토큰 상한
import time
from dataclasses import dataclass

@dataclass
class Budget:
    max_steps: int = 20
    max_seconds: int = 60
    max_input_tokens: int = 6000
    max_output_tokens: int = 1500

class BudgetExceeded(Exception):
    pass

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

    def on_step(self, input_tokens: int, output_tokens: int):
        self.steps += 1
        if self.steps > self.budget.max_steps:
            raise BudgetExceeded("step budget exceeded")
        if time.time() - self.started_at > self.budget.max_seconds:
            raise BudgetExceeded("time budget exceeded")
        if input_tokens > self.budget.max_input_tokens:
            raise BudgetExceeded("input token budget exceeded")
        if output_tokens > self.budget.max_output_tokens:
            raise BudgetExceeded("output token budget exceeded")

운영 팁:

  • 예산 초과 시 무조건 실패로 끝내기보다, 요약 모드로 전환하거나 최소 결과만 반환하도록 설계하면 UX가 좋아집니다.
  • 컨테이너 환경에서는 메모리 상한을 함께 잡아야 합니다. 에이전트가 프로세스 메모리를 잡아먹는 경우는 Kubernetes OOMKilled로 이어지기 쉽습니다. 실전 튜닝은 Kubernetes OOMKilled 메모리 튜닝 실전 가이드도 함께 참고하세요.

2) 상태 머신으로 “허용된 전이”만 실행

AutoGPT 구현이 단순 while True 루프이면, 모델이 이상한 결정을 내렸을 때 바로 무한 루프로 빠집니다. 이를 막는 강력한 방법이 명시적 상태 머신(FSM) 입니다.

핵심은 “모델이 다음 액션을 제안하더라도, 코드가 허용하지 않으면 실행하지 않는다”입니다.

예시 상태:

  • PLAN: 목표를 하위 작업으로 쪼개기
  • ACT: 도구 호출
  • OBSERVE: 결과 반영
  • FINALIZE: 결과 요약/제출
  • ABORT: 실패/예산 초과/정책 위반
from enum import Enum, auto

class State(Enum):
    PLAN = auto()
    ACT = auto()
    OBSERVE = auto()
    FINALIZE = auto()
    ABORT = auto()

ALLOWED = {
    State.PLAN: {State.ACT, State.FINALIZE, State.ABORT},
    State.ACT: {State.OBSERVE, State.ABORT},
    State.OBSERVE: {State.PLAN, State.ACT, State.FINALIZE, State.ABORT},
    State.FINALIZE: set(),
    State.ABORT: set(),
}

def transition(cur: State, nxt: State) -> State:
    if nxt not in ALLOWED[cur]:
        return State.ABORT
    return nxt

운영에서 중요한 포인트:

  • PLAN 단계에서만 “새로운 목표/하위작업 생성”을 허용하고, ACT에서는 도구 호출만 허용합니다.
  • OBSERVE에서만 관찰 로그를 저장하고, PLAN에서만 장기 메모리에 반영하는 식으로 책임을 분리하면 메모리 누수도 줄어듭니다.

3) 루프 감지: 동일 툴·동일 인자 반복 차단

무한 루프의 전형은 아래 패턴입니다.

  • 같은 검색 쿼리를 반복
  • 같은 URL을 재요청
  • 같은 파일을 계속 읽고 쓰기
  • 에러가 나는데도 동일 호출을 재시도

따라서 “최근 N회 호출”을 해시로 기록하고, 동일 호출이 일정 횟수 이상 반복되면 차단합니다.

import hashlib
from collections import deque, Counter

class LoopDetected(Exception):
    pass

class ToolLoopGuard:
    def __init__(self, window=12, max_repeat=3):
        self.window = window
        self.max_repeat = max_repeat
        self.recent = deque(maxlen=window)

    def _sig(self, tool_name: str, args: dict) -> str:
        raw = tool_name + "|" + repr(sorted(args.items()))
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def on_tool_call(self, tool_name: str, args: dict):
        sig = self._sig(tool_name, args)
        self.recent.append(sig)
        counts = Counter(self.recent)
        if counts[sig] >= self.max_repeat:
            raise LoopDetected(f"tool call repeated: {tool_name}")

추가로 효과 좋은 차단 규칙:

  • 동일 호출 반복뿐 아니라, 서로 다른 호출이지만 결과가 동일한 실패(예: HTTP 429)이면 백오프 후 중단
  • “재시도”는 모델에게 맡기지 말고, 코드에서 최대 재시도 횟수지수 백오프를 강제

4) 메모리 가드: 요약·압축·TTL로 컨텍스트 비만 방지

AutoGPT의 “메모리”는 보통 아래 3종이 섞여 있습니다.

  • 단기 메모리: 최근 대화/관찰 로그(컨텍스트에 직접 들어감)
  • 작업 메모리: 현재 목표/체크리스트/중간 산출물
  • 장기 메모리: 벡터DB나 파일로 저장되는 지식

문제는 단기 메모리가 계속 커져서 컨텍스트가 비만해지는 것입니다. 해결책은 요약 + TTL + 가중치 기반 보존입니다.

(1) 관찰 로그는 원문이 아니라 “구조화 요약”만 남기기

def compress_observation(obs: str, max_chars: int = 800) -> str:
    # 1차로 로컬 규칙 기반 축약(비용 0)
    obs = obs.strip().replace("\n\n", "\n")
    if len(obs) <= max_chars:
        return obs
    return obs[:max_chars] + "..."

실전에서는 규칙 기반 축약만으로 부족하니, 예산이 허용될 때만 LLM 요약을 한 번 더 수행합니다.

(2) TTL(Time To Live)로 오래된 메모리 폐기

  • “지난 10분” 또는 “최근 8스텝”만 컨텍스트에 유지
  • 그 이전은 요약본만 남기고 원문은 저장소로 이동

(3) 장기 메모리 삽입은 샘플링

모든 관찰을 벡터DB에 넣으면 검색 결과가 오염되고 비용이 증가합니다.

  • “새로운 사실” 또는 “결정에 영향을 준 근거”만 저장
  • 동일 주제 중복 삽입 방지(유사도 임계치)

메모리/토큰 최적화 관점은 로컬 LLM에서도 동일합니다. 컨텍스트, KV 캐시, 양자화 이슈는 Transformers 로컬 LLM OOM 방지 - 4bit+KV 캐시에서 다룬 방식이 그대로 도움이 됩니다.

5) 툴 호출 가드: 스키마 검증·권한·비용 상한

루프는 종종 “툴 호출이 너무 쉽다”는 데서 시작합니다. 모델이 실수해도 도구가 실행되면, 실패 -> 재시도 -> 로그 증가 -> 컨텍스트 증가의 악순환이 됩니다.

따라서 툴 호출은 반드시 아래를 통과해야 합니다.

  • 스키마 검증: 필수 인자 누락, 타입 오류 차단
  • 권한 정책: 파일 삭제/외부 POST 같은 위험 액션은 별도 승인
  • 비용 상한: 네트워크 요청 수, 크롤링 페이지 수, DB 쿼리 수 제한
from pydantic import BaseModel, HttpUrl, ValidationError, Field

class WebGetArgs(BaseModel):
    url: HttpUrl
    timeout_sec: int = Field(ge=1, le=20, default=10)

class ToolPolicyViolation(Exception):
    pass

def guarded_web_get(args: dict):
    try:
        parsed = WebGetArgs(**args)
    except ValidationError as e:
        raise ToolPolicyViolation(str(e))

    # 예: 내부망/메타데이터 차단 등
    if "169.254.169.254" in str(parsed.url):
        raise ToolPolicyViolation("blocked url")

    return do_http_get(str(parsed.url), timeout=parsed.timeout_sec)

추가 팁:

  • 도구 결과는 원문 전체를 컨텍스트에 넣지 말고, 필요한 필드만 추출해서 넘기세요.
  • “도구 호출 전”에 모델이 한 번 더 자기검증하도록 프롬프트를 넣는 방식도 있지만, 이는 보조적입니다. 핵심은 런타임에서 차단입니다.

6) 관측 가능성(Observability): 원인 추적 가능한 로그·메트릭

가드레일이 있어도, 운영에서는 “왜 중단됐는지”를 모르면 다시 같은 장애가 납니다. 최소한 아래 메트릭은 반드시 남기세요.

  • step 번호, 상태(PLAN/ACT 등)
  • 입력 토큰/출력 토큰 추정치
  • 최근 툴 호출 시그니처(해시)
  • 메모리 크기(메시지 수, 요약본 길이)
  • 종료 사유(예산 초과, 루프 감지, 정책 위반, 성공)
import json

def log_event(event: dict):
    # 운영에서는 구조화 로그(JSON)로 쌓아야 검색이 됩니다.
    print(json.dumps(event, ensure_ascii=False))

# 예시
log_event({
    "step": 7,
    "state": "ACT",
    "tool": "web_get",
    "input_tokens": 5120,
    "output_tokens": 320,
    "memory_messages": 18,
    "reason": "ok"
})

특히 스트리밍 출력이나 콜백 기반 구현에서는 “메모리 누수처럼 보이는 현상”이 자주 발생합니다. 토큰 중복, 콜백 누적, 버퍼 해제가 안 되는 문제는 에이전트에서도 동일하게 나타나므로, 유사 증상은 LangChain 스트리밍 중복토큰·메모리누수 9분 해결에서 제시한 점검 순서를 응용할 수 있습니다.

실전 조합 예시: 6가드레일을 한 루프에 묶기

아래는 개념을 하나의 실행 루프에 결합한 축약 예시입니다.

def run_agent(llm, tools, initial_state):
    budget = BudgetGuard(Budget(max_steps=18, max_seconds=45))
    loop_guard = ToolLoopGuard(window=10, max_repeat=3)

    state = State.PLAN
    memory = []  # 단기 메모리(요약된 메시지)

    for _ in range(10_000):
        # 입력 토큰 추정은 구현에 맞게 교체
        input_tokens = estimate_tokens(memory)

        if state in (State.FINALIZE, State.ABORT):
            break

        action = llm.decide(state=state.name, memory=memory)
        # action 예: {"next_state": "ACT", "tool": "web_get", "args": {...}}

        next_state = State[action.get("next_state", "ABORT")]
        state = transition(state, next_state)

        if state == State.ACT:
            tool = action["tool"]
            args = action.get("args", {})

            loop_guard.on_tool_call(tool, args)
            result = tools[tool](args)

            obs = compress_observation(str(result))
            memory.append({"role": "observation", "content": obs})

            output_tokens = estimate_tokens([{"content": obs}])
            budget.on_step(input_tokens=input_tokens, output_tokens=output_tokens)

            state = State.OBSERVE

        elif state == State.PLAN:
            plan = action.get("plan", "")
            memory.append({"role": "plan", "content": compress_observation(plan, 500)})
            budget.on_step(input_tokens=input_tokens, output_tokens=estimate_tokens([{ "content": plan }]))

        else:
            # FINALIZE/ABORT는 상위에서 처리
            budget.on_step(input_tokens=input_tokens, output_tokens=0)

    return state.name, memory

이 구조의 장점은 명확합니다.

  • 예산 초과, 루프 감지, 정책 위반이 모두 예외 또는 상태 전이로 수렴해 디버깅이 쉬움
  • 메모리 추가 지점이 제한되어 컨텍스트 비만을 통제 가능
  • 운영 로그를 붙이기도 쉬움

마무리: “모델의 지능”보다 “런타임 제약”이 먼저다

AutoGPT가 불안정해지는 이유는 대부분 모델이 멍청해서가 아니라, 실수해도 멈추지 않는 실행 구조 때문입니다. 따라서 안정화의 우선순위는 다음 순서가 좋습니다.

  1. 예산으로 멈출 수 있게 만들기
  2. 상태 머신으로 허용된 행동만 실행하기
  3. 루프 감지로 반복을 끊기
  4. 메모리 압축과 TTL로 컨텍스트 비만 방지
  5. 툴 호출을 스키마/정책/비용으로 감싸기
  6. 관측 가능성으로 원인 추적하기

이 6가지 가드레일을 적용하면, 에이전트가 실패하더라도 “통제된 실패”로 끝나고, 비용과 장애가 폭발하지 않습니다.