- Published on
AutoGPT 무한루프 막는 종료조건·가드레일 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT 스타일의 에이전트는 plan -> act(tool) -> observe -> reflect 루프를 돌며 목표를 달성합니다. 문제는 목표가 모호하거나, 툴이 불안정하거나, 관측(환경 상태)이 충분히 변하지 않을 때 에이전트가 동일한 시도를 계속 반복하며 비용과 시간을 태운다는 점입니다. 이 글에서는 “모델을 더 똑똑하게” 만드는 접근이 아니라, 종료조건(termination conditions) 과 가드레일(guardrails) 을 제품/플랫폼 레벨에서 설계해 무한루프를 구조적으로 막는 방법을 다룹니다.
아래 내용은 AutoGPT에만 국한되지 않고, LangGraph, CrewAI, 자체 구현 에이전트 등 “툴 호출 기반 에이전트” 전반에 적용됩니다.
왜 무한루프가 생기나: 루프의 유형 분류
무한루프를 막으려면 먼저 루프를 관측 가능한 패턴으로 분해해야 합니다.
1) 상태 비변화 루프
- 툴 호출 결과가 실패하거나(타임아웃, 5xx)
- 성공해도 외부 상태가 변하지 않거나(권한 부족, 잘못된 파라미터)
- 에이전트가 그 사실을 충분히 반영하지 못할 때
예: “API 호출이 계속 408 으로 실패하는데도 재시도만 반복”
관련해서 타임아웃을 재현하고 원인을 분해하는 접근은 다음 글과 결이 같습니다.
2) 계획-실행 불일치 루프
- 계획은 바뀌는데 실행은 늘 같은 툴/같은 입력
- 또는 실행은 바뀌는데 목표 함수가 없어서 평가가 불가능
3) 자기반성(Reflection) 과다 루프
- “생각해보니 다시 계획을 세워야겠어”가 반복
- 실제 환경에 영향을 주는 행동 없이 토큰만 소모
4) 재시도 폭주 루프
- 실패한 툴 호출에 대해 지수 백오프 없이 즉시 재시도
- 병렬 에이전트가 동시에 재시도하여 외부 시스템에 부하
이 패턴은 MSA에서 데드라인과 리트라이 정책을 잘못 잡으면 발생하는 “리트라이 폭주”와 구조가 동일합니다.
종료조건 설계: “언제 멈출지”를 명시적으로
에이전트가 멈추는 조건은 보통 “목표 달성”뿐인데, 실제 운영에서는 실패를 인정하고 멈추는 조건이 더 중요합니다. 종료조건은 크게 4종으로 나누는 것이 실용적입니다.
1) 하드 리밋: 스텝, 시간, 비용 예산
가장 강력하고 단순한 안전장치입니다.
- 최대 스텝 수:
max_steps - 벽시계 시간 제한:
wall_clock_deadline_ms - 비용 제한:
max_tokens,max_cost_usd
이 3가지는 반드시 서버 사이드에서 강제해야 합니다. 프롬프트에 “10번만 시도해”라고 적는 방식은 신뢰할 수 없습니다.
2) 목표 기반 종료: 성공 판정 함수
성공을 모델에게 묻게 되면(“성공했니?”) 자기합리화가 들어가기 쉽습니다. 가능하면 결정가능한 판정 함수로 바꾸세요.
예:
- 파일이 생성되었는가
- DB row가 특정 상태로 변경되었는가
- API 응답이 특정 스키마를 만족하는가
3) 실패 기반 종료: 반복 실패, 영구 실패, 외부 신호
- 동일 툴 호출이
n회 연속 실패 - 동일 에러 코드가
n회 반복 401/403같이 “권한 문제”는 재시도해도 의미가 없으므로 즉시 중단- 운영자가 “중단” 플래그를 켜면 즉시 종료
4) 진행 기반 종료: 진척(Progress) 없으면 종료
가장 중요한데 구현이 까다로운 축입니다.
- 최근
k스텝 동안 “상태 변화”가 없으면 종료 - 목표에 대한 점수(heuristic score)가 개선되지 않으면 종료
핵심은 “상태(state)”를 정의하는 것입니다. 에이전트 시스템에서는 대개 다음을 상태로 삼습니다.
- 관측 결과 요약(정규화된 텍스트)
- 주요 변수(예: 검색된 후보 수, 생성된 파일 수)
- 마지막 툴 호출과 결과 코드
가드레일 설계: “어떻게 행동할지”를 제한하기
종료조건이 브레이크라면, 가드레일은 핸들입니다. 에이전트가 위험한 방향으로 가기 전에 행동 공간을 줄여야 합니다.
1) 툴 호출 정책: allowlist, rate limit, parameter validation
- 툴 allowlist: 허용된 툴만 호출
- 툴별 호출 빈도 제한:
tool_rate_limit - 툴별 파라미터 검증: 스키마 기반
예를 들어 “웹 검색” 툴은 1분에 10회까지만, “결제/삭제” 툴은 인간 승인 후에만 실행.
2) 재시도 정책: 지수 백오프 + 지터 + 에러 분류
무한루프의 70%는 재시도 정책 부재에서 시작합니다.
5xx,429,408은 재시도 대상일 수 있음4xx중401/403/404는 보통 즉시 중단 또는 다른 전략 필요- 백오프:
base * 2^attempt - 지터: 랜덤 분산으로 동시 재시도 방지
3) 반성(Reflection) 제한: 빈도, 길이, 목적을 강제
- 매 스텝마다 반성하지 않기
- 반성은 “다음 행동의 변경”이 있을 때만 허용
- 반성 결과가 다음 툴 호출 파라미터에 반영되지 않으면 반성 금지
4) 인간 개입 지점(HITL) 설계
무한루프를 완전히 자동으로 막기 어렵다면, 안전한 지점에서 인간에게 넘기는 것이 최적입니다.
- 예산 80% 소모 시
needs_review - 동일 실패 3회 시
needs_review - 위험 툴 호출 전
approval_required
구현 예시: 루프 감시자(Loop Watchdog) 코드
아래는 Node.js/TypeScript로 “에이전트 실행 루프”를 감싸서 종료조건과 가드레일을 강제하는 예시입니다. 실제 LLM 호출부는 추상화했고, 포인트는 상태 해시로 반복을 감지하고, 스텝/시간/실패를 강제하는 구조입니다.
type ToolResult = {
ok: boolean;
status?: number;
errorCode?: string;
observation: string;
};
type Step = {
tool: string;
input: unknown;
};
type AgentConfig = {
maxSteps: number;
deadlineMs: number;
maxConsecutiveFailures: number;
noProgressWindow: number; // 최근 k 스텝
maxRepeatedState: number; // 동일 상태 반복 허용 횟수
};
function stableHash(s: string): string {
// 간단 예시: 실제로는 sha256 같은 안정 해시 권장
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return String(h);
}
function normalizeObservation(obs: string): string {
return obs
.trim()
.replaceAll(/\s+/g, " ")
.slice(0, 2000);
}
function classifyRetryable(r: ToolResult): "retry" | "stop" | "change_strategy" {
const status = r.status ?? 0;
if (!r.ok) {
if (status === 401 || status === 403) return "stop";
if (status === 404) return "change_strategy";
if (status === 408 || status === 429 || (status >= 500 && status <= 599)) return "retry";
return "change_strategy";
}
return "change_strategy";
}
async function runAgentLoop(
config: AgentConfig,
proposeNextStep: (ctx: { history: ToolResult[] }) => Promise<Step>,
runTool: (step: Step) => Promise<ToolResult>
) {
const started = Date.now();
const history: ToolResult[] = [];
let consecutiveFailures = 0;
const stateCounts = new Map<string, number>();
const recentStates: string[] = [];
for (let i = 0; i < config.maxSteps; i++) {
if (Date.now() - started > config.deadlineMs) {
return { status: "terminated", reason: "deadline_exceeded", history };
}
const step = await proposeNextStep({ history });
// Tool allowlist 예시
const allowedTools = new Set(["web_search", "http_request", "write_file"]);
if (!allowedTools.has(step.tool)) {
return { status: "terminated", reason: "tool_not_allowed", history };
}
const result = await runTool(step);
history.push(result);
// 실패 카운트
if (!result.ok) consecutiveFailures++;
else consecutiveFailures = 0;
if (consecutiveFailures >= config.maxConsecutiveFailures) {
return { status: "terminated", reason: "too_many_failures", history };
}
// 상태 반복 감지
const normalized = normalizeObservation(result.observation);
const stateKey = stableHash(step.tool + "|" + normalized);
const c = (stateCounts.get(stateKey) ?? 0) + 1;
stateCounts.set(stateKey, c);
if (c >= config.maxRepeatedState) {
return { status: "terminated", reason: "repeated_state_loop", history };
}
recentStates.push(stateKey);
if (recentStates.length > config.noProgressWindow) recentStates.shift();
// 진행 없음 감지: 최근 k개 상태가 모두 동일하거나 매우 적게 변하면 중단
const unique = new Set(recentStates);
if (recentStates.length === config.noProgressWindow && unique.size <= 1) {
return { status: "terminated", reason: "no_progress", history };
}
// 에러 분류 기반 중단/전략 변경 힌트
if (!result.ok) {
const action = classifyRetryable(result);
if (action === "stop") {
return { status: "terminated", reason: "non_retryable_error", history };
}
// retry/change_strategy는 proposeNextStep에서 history를 보고 결정
}
// 성공 판정은 가능하면 외부에서 결정가능하게
// 예: 특정 파일 존재 여부, DB 상태, API 응답 스키마 만족 등
}
return { status: "terminated", reason: "max_steps_reached", history };
}
위 코드에서 핵심은 다음입니다.
- 서버가 강제하는 하드 리밋:
maxSteps,deadlineMs - 반복 상태 감지:
stateKey를 만들고 동일 상태 반복을 제한 - 진척 없음 감지: 최근
k스텝이 사실상 같은 상태면 중단 - 재시도 가능/불가능 에러 분류:
401/403등은 즉시 중단
“상태”를 어떻게 잡아야 반복 탐지가 잘 되나
반복 탐지의 품질은 상태 정의에 달려 있습니다. 너무 넓으면(원문 전체) 매번 해시가 달라져 루프를 못 잡고, 너무 좁으면(에러 코드만) 정상 흐름도 루프로 오탐합니다.
추천하는 상태 구성은 다음 3요소 조합입니다.
- 마지막 툴 이름
- 결과의 핵심 코드(예: HTTP status, DB error code)
- 관측의 정규화 요약(길이 제한, 공백 정리)
또한 관측 텍스트에는 타임스탬프, 랜덤 ID가 섞이는 경우가 많습니다. 이런 값은 정규식으로 제거해야 해시가 안정화됩니다.
function scrubNonDeterministic(obs: string): string {
return obs
.replaceAll(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, "TIMESTAMP")
.replaceAll(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "UUID")
.replaceAll(/\breq_[A-Za-z0-9]+\b/g, "REQID");
}
운영 관점 가드레일: 관측성, 알림, 자동 중단
에이전트 무한루프는 “버그”이기도 하지만 “운영 장애”입니다. 따라서 SRE 관점의 가드레일도 같이 들어가야 합니다.
1) 메트릭: 루프를 수치로 만들기
최소한 아래는 Prometheus 같은 곳에 내보내는 것을 권장합니다.
agent_steps_total{agent_id, run_id}agent_tool_calls_total{tool}agent_failures_total{tool, status}agent_cost_total_usd{model}또는agent_tokens_total{model}agent_no_progress_events_total
2) 알림: “예산 소모” 기반이 가장 빠르다
무한루프는 성공/실패보다 비용 곡선이 먼저 이상해집니다.
- 분당 토큰 사용량 급증
- 동일 툴 호출 비율이 특정 임계치 초과
run_id단위 비용이 상한 초과
3) 자동 중단: circuit breaker
외부 API가 불안정한데 에이전트가 계속 두드리면, 상대 시스템에도 피해를 줍니다. 툴 호출을 감싸는 서킷 브레이커를 두면 루프가 “외부 장애”로 증폭되는 것을 막을 수 있습니다.
타임아웃과 데드라인을 명확히 두는 습관은 분산 시스템에서도 동일하게 중요합니다.
프롬프트만으로는 부족한 이유: 강제력의 위치
많은 팀이 다음처럼 프롬프트에 적습니다.
- “같은 행동을 반복하지 마라”
- “10번 이상 시도하지 마라”
하지만 이것은 소프트 룰입니다. 모델이 맥락을 잃거나, 툴 실패가 길어지거나, 프롬프트가 잘려나가면 쉽게 무력화됩니다. 따라서 종료조건과 가드레일은 아래 우선순위를 가져야 합니다.
- 런타임 강제(서버 코드)
- 툴 레이어 강제(allowlist, rate limit, circuit breaker)
- 프롬프트/정책(가이드)
실전 체크리스트: 무한루프를 막는 최소 세트
프로덕션에서 “최소한 이 정도는 있어야 한다” 기준으로 정리하면 다음과 같습니다.
max_steps와deadline_ms를 서버에서 강제max_tokens또는max_cost예산 강제- 동일 툴/동일 상태 반복 탐지(해시 기반)
- 최근
k스텝 진척 없음 탐지 - 에러 분류 기반 재시도 정책(
408/429/5xx만 제한적으로) - 백오프 + 지터
- 위험 툴은 인간 승인(HITL)
- 메트릭/알림(특히 비용·호출량)
마무리: “에이전트는 멈추는 법부터 배워야 한다”
AutoGPT류 에이전트의 무한루프는 모델 성능 문제가 아니라, 대부분 시스템 설계의 빈틈에서 발생합니다. 종료조건은 브레이크이고, 가드레일은 차선을 만드는 일입니다. 이 둘을 런타임에서 강제하고, 실패를 관측 가능하게 만들면 “가끔 미친 듯이 돈을 태우는 에이전트”가 아니라 “예측 가능한 자동화 컴포넌트”로 운영할 수 있습니다.
다음 단계로는, 여러분의 에이전트가 사용하는 각 툴에 대해 timeout, retry, circuit breaker, idempotency key를 표준화하고, run 단위 비용 상한을 제품 정책으로 못 박는 것을 권장합니다.