Published on

AutoGPT 루프 폭주 막는 메모리·가드레일 설계

Authors

AutoGPT류 에이전트는 계획(Plan)도구 호출(Tool)을 반복하며 목표를 달성합니다. 문제는 이 반복이 스스로 멈추지 못하는 구조로 쉽게 변한다는 점입니다. 프롬프트가 애매하거나, 메모리가 오염되거나, 도구 결과가 불안정하면 에이전트는 같은 행동을 변형만 하며 재시도하고, 결국 무한 루프, 토큰/비용 폭주, 외부 시스템 부하로 이어집니다.

이 글에서는 루프 폭주의 전형적인 원인을 분해하고, 이를 막기 위한 메모리 계층 설계가드레일(Guardrails) 실행 제어를 실전 관점에서 정리합니다. 분산 시스템의 타임아웃/보상 트랜잭션 개념을 에이전트 실행에 이식하는 방식도 함께 다룹니다.

참고로, 에이전트의 폭주는 “시간 제한 없는 RPC 재시도”와 유사합니다. 타임아웃과 취소 전파가 없는 호출은 결국 deadline exceeded 류 장애로 돌아옵니다. 관련 맥락은 Go gRPC context deadline exceeded 원인 7가지도 함께 보면 감이 빨리 잡힙니다.

1) AutoGPT 루프 폭주가 발생하는 6가지 전형

1-1. 목표 함수가 불명확하거나 측정 불가

에이전트는 “완료”를 판정할 수 있어야 멈춥니다. 하지만 목표가 최대한 조사해줘, 완벽히 작성해줘처럼 종료 조건이 없는 최적화 문제면, 모델은 계속 개선을 시도합니다.

1-2. 도구 결과가 비결정적이거나 실패를 정상으로 오인

웹 검색, 크롤링, API 호출은 종종 실패합니다. 실패 응답이 빈 문자열이나 200 OK + 에러 메시지 텍스트처럼 애매하면, 에이전트는 성공으로 간주하고 다음 단계로 넘어가며 더 큰 혼란을 만듭니다.

1-3. 메모리 오염: 잘못된 가정이 장기 기억에 박힘

한 번의 hallucination이 “사실”처럼 요약되어 장기 메모리에 들어가면 이후 루프는 그 가정을 강화합니다. 결과적으로 같은 결론을 반복하거나, 실패한 접근을 계속 재시도합니다.

1-4. 관측 불가능성: 왜 반복하는지 사람이 모름

로그가 thought 중심이거나, tool input/output가 누락되면 운영자가 원인을 파악하지 못해 더 오래 방치됩니다.

1-5. 재시도 정책 부재

네트워크/레이트리밋/일시 장애는 재시도가 필요하지만, 재시도에 횟수, 백오프, 대체 경로, 중단 조건이 없으면 루프가 됩니다.

1-6. 안전 가드레일 부재: 도구 남용과 권한 과다

파일 삭제, 결제, 배포, DB 변경 같은 고위험 도구가 “한 번의 LLM 판단”으로 실행되면, 루프는 곧 사고로 이어집니다.

2) 멈추게 만드는 설계: 메모리 계층 3단 + 실행 상태 머신

루프 폭주를 막는 핵심은 메모리를 덜 믿고, 실행을 더 엄격히 상태로 관리하는 것입니다. 추천하는 기본 구조는 다음 3가지 메모리 계층입니다.

2-1. 작업 메모리(Working Memory): 단기 컨텍스트

  • 현재 스텝의 입력/출력
  • 최신 관측값(도구 결과)
  • 당장 필요한 변수

특징: 쉽게 버리고, 길이를 제한합니다.

2-2. 에피소드 메모리(Episodic Memory): 실행 로그 요약

  • step_id, tool, tool_input_hash, tool_output_hash, status, latency_ms
  • 실패 원인 코드(예: RATE_LIMIT, TIMEOUT, BAD_SCHEMA)

특징: 루프 탐지재시도 제어에 직접 사용합니다. 자연어 요약만 저장하면 탐지가 어려우니, 구조화 필드를 반드시 포함합니다.

2-3. 장기 메모리(Long-term / Vector Memory): 사실 후보 저장소

  • 문서/정책/과거 결과
  • 단, “사실”이 아니라 “근거가 있는 후보”로 취급

특징: 장기 메모리는 오염되기 쉬우므로, **출처/신뢰도/만료( TTL )**를 함께 저장합니다.

2-4. 실행 상태 머신(필수)

에이전트를 while true로 굴리면 언젠가 폭주합니다. 다음처럼 명시적 상태를 둡니다.

  • PLAN: 다음 행동 계획 생성
  • ACT: 도구 실행
  • OBSERVE: 결과 검증(스키마/정합성)
  • EVALUATE: 목표 달성 여부 판단
  • RECOVER: 실패 복구(대체 도구, 백오프)
  • STOP: 종료

이 구조는 분산 트랜잭션에서 “실패를 정상 흐름으로 모델링”하는 사고와 닮았습니다. 복구 단계가 없으면 실패가 곧 루프가 됩니다. 분산 관점의 복구 설계는 MSA Saga 보상 트랜잭션 설계와 디버깅 실전에서의 접근을 에이전트에도 그대로 적용할 수 있습니다.

3) 가드레일 10종 세트: 루프·비용·도구 남용 차단

아래는 운영에서 효과가 컸던 가드레일 목록입니다. 1~3개만 넣어도 체감이 크지만, 조합이 중요합니다.

3-1. 스텝 예산(Step budget)

  • 전체 실행 max_steps
  • 목표별 max_steps_per_goal
  • 상태별 max_retries_per_tool

종료 시에는 “부분 결과 + 다음에 사람이 해야 할 일”을 출력하게 합니다.

3-2. 토큰/비용 예산(Token budget)

  • 요청 단위 max_tokens
  • 실행 단위 max_total_tokens
  • 비용 추정치 기반 max_cost

토큰 상한은 루프 방지뿐 아니라 “긴 프롬프트로 인한 품질 저하”도 막습니다.

3-3. 시간 제한(Deadline)과 취소 전파

각 도구 호출에 timeout_ms를 부여하고, 전체 실행에 deadline을 둡니다. 타임아웃은 “실패”가 아니라 “관측값”입니다. 이후 RECOVER로 넘어가야 합니다.

3-4. 반복 패턴 탐지(Loop detector)

다음 신호를 조합합니다.

  • 동일 tool + 동일 input hash 반복
  • 유사한 계획 문장 n회 반복(문장 임베딩 cosine 유사도)
  • 실패 코드가 같은 재시도 반복

탐지되면 STOP 또는 “전략 변경 프롬프트”로 강제 전환합니다.

3-5. 도구 호출 스키마 검증(Contract)

도구 입력/출력은 반드시 JSON 스키마로 검증합니다.

  • 입력 누락, 타입 불일치
  • 출력이 텍스트 에러인데 200으로 온 경우

검증 실패는 OBSERVE에서 즉시 차단합니다.

3-6. 권한 분리와 위험 등급(RBAC + Risk tier)

도구를 read-only, write, destructive로 나누고,

  • destructive는 사람 승인 필요
  • write는 dry-run 우선
  • read-only만 자동 허용

3-7. Dry-run / Plan-only 모드

실제 실행 전에 “예상 변경 사항”을 출력하게 하고, 승인 후 실행합니다.

3-8. 외부 시스템 레이트리밋·서킷브레이커

  • 429/5xx 급증 시 잠시 차단
  • 백오프 + 지수 증가
  • 대체 데이터 소스 사용

3-9. 메모리 TTL과 신뢰도 스코어

장기 메모리에 저장할 때 다음을 함께 저장합니다.

  • source_url 또는 tool_run_id
  • confidence (0~1)
  • expires_at

만료된 기억은 검색 결과에서 제외하거나 가중치를 낮춥니다.

3-10. 관측성(Observability) 필수 필드

운영에서 가장 중요한 가드레일은 “빨리 알아차리는 것”입니다.

  • run_id, step_id, state
  • tool input/output 크기, 해시
  • latency, tokens, cost
  • stop reason(예: MAX_STEPS, LOOP_DETECTED)

배포 자동화 환경이라면, 상태 드리프트나 동기화 실패가 에이전트의 “복구 루프”를 유발하기도 합니다. 운영 중 배포/동기화 이슈 점검은 Argo CD Sync 실패 - OutOfSync·Degraded 해결 같은 체크리스트가 도움이 됩니다.

4) 구현 예시: 상태 머신 + 루프 탐지 + 예산

아래 예시는 Python 스타일의 의사코드입니다. 핵심은 while을 돌리더라도 상태/예산/탐지/검증이 모두 끼어있어야 한다는 점입니다.

import time
import hashlib
from dataclasses import dataclass


def sha(x: str) -> str:
    return hashlib.sha256(x.encode("utf-8")).hexdigest()[:16]


@dataclass
class Budgets:
    max_steps: int = 20
    max_total_tokens: int = 20000
    max_total_cost_usd: float = 2.0
    deadline_ts: float = 0.0  # epoch seconds


@dataclass
class ToolCall:
    name: str
    input_json: str


@dataclass
class StepRecord:
    step_id: int
    state: str
    tool_name: str | None
    tool_input_hash: str | None
    tool_output_hash: str | None
    status: str
    tokens_used: int
    cost_usd: float
    latency_ms: int


class LoopDetector:
    def __init__(self, window=6, same_call_threshold=3):
        self.window = window
        self.same_call_threshold = same_call_threshold
        self.recent = []  # list of (tool, input_hash)

    def push(self, tool: str, input_hash: str):
        self.recent.append((tool, input_hash))
        if len(self.recent) > self.window:
            self.recent.pop(0)

    def is_looping(self) -> bool:
        if len(self.recent) < self.same_call_threshold:
            return False
        last = self.recent[-1]
        return sum(1 for x in self.recent if x == last) >= self.same_call_threshold


def validate_tool_output(tool_name: str, output_json: str) -> tuple[bool, str]:
    # 실제로는 JSON schema validator 사용 권장
    if not output_json or "error" in output_json.lower():
        return (False, "BAD_OUTPUT")
    return (True, "OK")


def run_agent(goal: str, budgets: Budgets):
    state = "PLAN"
    step_id = 0
    total_tokens = 0
    total_cost = 0.0
    detector = LoopDetector()
    history: list[StepRecord] = []

    while True:
        now = time.time()
        if now >= budgets.deadline_ts:
            return {"status": "STOP", "reason": "DEADLINE", "history": history}
        if step_id >= budgets.max_steps:
            return {"status": "STOP", "reason": "MAX_STEPS", "history": history}
        if total_tokens >= budgets.max_total_tokens:
            return {"status": "STOP", "reason": "MAX_TOKENS", "history": history}
        if total_cost >= budgets.max_total_cost_usd:
            return {"status": "STOP", "reason": "MAX_COST", "history": history}

        if state == "PLAN":
            # LLM에게 다음 행동 계획을 요청 (여기서는 생략)
            planned = ToolCall(name="web_search", input_json='{"q":"' + goal + '"}')
            state = "ACT"

        elif state == "ACT":
            t0 = time.time()
            tool = planned.name
            inp_hash = sha(planned.input_json)

            detector.push(tool, inp_hash)
            if detector.is_looping():
                return {"status": "STOP", "reason": "LOOP_DETECTED", "history": history}

            # 도구 실행 (여기서는 더미)
            output = '{"result":"..."}'
            latency_ms = int((time.time() - t0) * 1000)

            ok, code = validate_tool_output(tool, output)
            history.append(
                StepRecord(
                    step_id=step_id,
                    state="ACT",
                    tool_name=tool,
                    tool_input_hash=inp_hash,
                    tool_output_hash=sha(output),
                    status=code,
                    tokens_used=200,
                    cost_usd=0.01,
                    latency_ms=latency_ms,
                )
            )
            total_tokens += 200
            total_cost += 0.01

            state = "OBSERVE" if ok else "RECOVER"

        elif state == "OBSERVE":
            # 결과를 목표 달성 관점에서 평가 (여기서는 생략)
            done = True
            state = "STOP" if done else "PLAN"

        elif state == "RECOVER":
            # 백오프/대체 도구/프롬프트 수정 등
            time.sleep(0.5)
            state = "PLAN"

        elif state == "STOP":
            return {"status": "STOP", "reason": "DONE", "history": history}

        step_id += 1

이 예시에서 루프 폭주를 막는 장치는 다음과 같습니다.

  • Budgets로 스텝/토큰/비용/시간을 모두 상한
  • LoopDetector로 동일 도구 호출 반복을 탐지
  • validate_tool_output으로 실패를 조기에 RECOVER로 라우팅
  • 모든 스텝을 StepRecord로 구조화 기록(관측성)

5) 메모리 설계 디테일: “요약”만 믿으면 망한다

5-1. 장기 메모리에 넣기 전, 사실성 게이트를 둔다

장기 메모리에는 다음 조건을 통과한 것만 저장하는 편이 안전합니다.

  • 출처가 명확하다(문서 URL, tool run id)
  • 동일 사실이 2개 이상의 독립 소스에서 확인됨
  • 혹은 내부 정책 문서처럼 신뢰 가능한 단일 소스

이를 코드로 표현하면 “저장 함수가 곧 가드레일”입니다.

def should_promote_to_long_term(source_type: str, confidence: float, has_citation: bool) -> bool:
    if not has_citation:
        return False
    if source_type in ["policy", "internal_doc"]:
        return True
    return confidence >= 0.75

5-2. 메모리 TTL과 회수(garbage collection)

에이전트가 오래 돌수록 “오래된 결론”이 현재 상황과 충돌합니다. 따라서 장기 메모리는 expires_at 기반으로 회수하고, 검색 시에도 만료를 반영해야 합니다.

5-3. 실행 로그는 벡터DB가 아니라 이벤트 스토어에

루프 탐지/감사/비용 추적에는 벡터 검색보다 “정확한 필드 검색”이 유리합니다.

  • tool_name = X AND status = TIMEOUT AND count 급증
  • input_hash 중복

즉, 에피소드 메모리는 Postgres나 로그 스토리지(예: Loki, OpenSearch)에 두는 편이 운영이 쉽습니다.

6) 운영 체크리스트: 폭주를 ‘사고’가 아니라 ‘신호’로

6-1. Stop reason을 반드시 남긴다

DONE 말고도 최소한 아래는 필요합니다.

  • MAX_STEPS
  • MAX_TOKENS
  • MAX_COST
  • DEADLINE
  • LOOP_DETECTED
  • TOOL_SCHEMA_INVALID
  • TOOL_TIMEOUT

6-2. 실패 코드를 표준화한다

도구마다 오류 문자열이 제각각이면, 에이전트도 운영자도 학습이 불가능합니다.

  • RATE_LIMIT
  • TIMEOUT
  • BAD_OUTPUT
  • UNAUTHORIZED
  • NOT_FOUND

6-3. 인간 개입 지점(HITL)을 명시한다

다음 상황에서는 자동 실행을 멈추고 승인/질문으로 전환합니다.

  • 파괴적 작업
  • 동일 실패 3회 이상
  • 비용 예산 80% 도달
  • 결과가 목표에 대한 근거를 제공하지 못함

7) 결론: 루프를 막는 건 프롬프트가 아니라 “제어면(Control Plane)”

AutoGPT 루프 폭주는 대개 “모델이 멍청해서”가 아니라, 실행 제어가 없는 자동화에서 발생합니다. 해결책은 프롬프트 튜닝 단독이 아니라 다음의 결합입니다.

  • 메모리 계층화: 작업/에피소드/장기 메모리를 분리
  • 상태 머신: PLAN-ACT-OBSERVE-EVALUATE-RECOVER-STOP
  • 예산: 스텝/토큰/비용/시간 상한
  • 반복 탐지: 해시/유사도/실패 코드 기반
  • 스키마 검증과 권한 분리: 도구를 안전하게
  • 관측성: stop reason과 구조화 로그

이 정도를 갖추면, 에이전트는 “무한히 시도하는 자동화”가 아니라 “실패를 관리하는 실행기”로 바뀝니다. 그때부터는 루프 폭주가 장애가 아니라, 개선 가능한 데이터가 됩니다.