- Published on
AutoGPT 무한루프 막는 Tool 호출 제한·메모리 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
AutoGPT 스타일의 에이전트(계획 수립 → Tool 호출 → 결과 관찰 → 다음 행동)는 한 번 잘못 설계되면 같은 행동을 계속 반복하는 무한루프에 빠지기 쉽습니다. 특히 검색·브라우징·코드 실행 같은 Tool이 붙는 순간, 모델은 “조금만 더 확인하면 될 것 같다”는 방향으로 스스로를 정당화하며 호출을 늘립니다.
이 글은 “왜 루프가 생기는지”를 시스템적으로 분해하고, Tool 호출 제한(예산) + 종료 조건 + 메모리(기억) 설계를 조합해 루프를 끊는 방법을 정리합니다. 구현 예시는 TypeScript 기준으로 설명합니다.
참고로, 에이전트가 외부 API를 호출할 때 요청 형식이 조금만 어긋나도 재시도 루프가 생깁니다. OpenAI 쪽 요청 에러를 먼저 정리하고 싶다면 OpenAI Responses API 400 에러 원인 8가지도 같이 보면 좋습니다.
AutoGPT가 무한루프에 빠지는 5가지 전형적 원인
1) 종료 조건이 “정성적”이고 검증 가능한 형태가 아님
예: “충분히 조사했으면 종료” 같은 문구만 있고, 충분함을 판정하는 체크리스트가 없습니다. 그러면 모델은 계속 조사하려고 합니다.
해결 핵심은 종료를 다음처럼 검증 가능한 조건으로 바꾸는 것입니다.
- 산출물 형태가 명확: 예)
JSON 스키마,테이블,파일 목록 - 완료 판정이 가능: 예) “필수 항목 8개가 모두 채워졌는가”
- 실패 판정도 가능: 예) “3회 시도 후도 값이 없으면
UNKNOWN으로 확정”
2) Tool 결과가 불완전하거나 비결정적이라 재시도가 합리적으로 보임
검색 결과가 매번 달라지거나, 웹 페이지가 403/429로 막히면 모델은 “다시 하면 될지도”라고 판단합니다.
- 네트워크 계열 Tool은 에러를 구조화해서 반환해야 합니다.
- “재시도 가능”과 “재시도 불가”를 구분해 모델에게 알려야 합니다.
3) 관찰(Observation)을 메모리에 쌓지만, ‘중복’과 ‘진전’을 구분하지 못함
에이전트는 매 스텝마다 로그를 남기지만, 그 로그가 “새로운 정보인지” 판단하지 못하면 같은 결론을 반복합니다.
- 최근 N개의 행동이 동일하면 루프 의심
- 새로 얻은 사실(팩트)이 증가하지 않으면 루프 의심
4) Tool 호출 비용(토큰/시간/돈) 예산이 없거나 느슨함
“최대한 잘해봐”는 사실상 무제한 호출을 허용합니다. 예산은 안전장치이자 설계의 일부입니다.
5) 실패 모드가 설계되지 않음
성공만 정의하고, 실패 시 무엇을 반환할지 없으면 에이전트는 계속 시도합니다.
PARTIAL결과를 허용UNKNOWN을 명시적으로 허용- “추가 권한/키/데이터가 필요” 같은 요구사항을 산출물로 내게 하기
1단계: Tool 호출 제한을 ‘카운트’가 아니라 ‘예산’으로 설계하기
단순히 “최대 10번 호출”은 현실에서 취약합니다. 어떤 Tool은 1회 호출이 매우 비싸고, 어떤 Tool은 가볍습니다. 그래서 예산(budget) 개념이 더 안전합니다.
- 시간 예산: 전체 30초, Tool당 5초
- 비용 예산: API 비용 추정치 합산
- 토큰 예산: 입력/출력 토큰 상한
- 위험 예산: 파일 삭제/결제 같은 위험 Tool은 1회만
예산 기반 정책 예시 (TypeScript)
type ToolName = "search" | "browser" | "code" | "db";
type Budget = {
maxSteps: number;
maxToolCalls: number;
maxCostUsd: number;
maxWallTimeMs: number;
perToolMaxCalls: Partial<Record<ToolName, number>>;
};
type Usage = {
steps: number;
toolCalls: number;
costUsd: number;
startedAt: number;
perToolCalls: Partial<Record<ToolName, number>>;
};
export function canContinue(budget: Budget, usage: Usage, tool?: ToolName) {
const now = Date.now();
if (usage.steps >= budget.maxSteps) return { ok: false, reason: "maxSteps" };
if (usage.toolCalls >= budget.maxToolCalls) return { ok: false, reason: "maxToolCalls" };
if (usage.costUsd >= budget.maxCostUsd) return { ok: false, reason: "maxCostUsd" };
if (now - usage.startedAt >= budget.maxWallTimeMs) return { ok: false, reason: "maxWallTime" };
if (tool) {
const used = usage.perToolCalls[tool] ?? 0;
const limit = budget.perToolMaxCalls[tool];
if (limit !== undefined && used >= limit) return { ok: false, reason: `perToolMaxCalls:${tool}` };
}
return { ok: true as const };
}
이 정책의 포인트는 “루프를 막는다”에 그치지 않고, 중단 사유가 명확히 기록된다는 점입니다. 중단 사유가 있어야 프롬프트/Tool/메모리 설계를 개선할 수 있습니다.
2단계: 종료 조건을 ‘스키마’로 강제해 루프를 끊기
AutoGPT류 루프는 대부분 “언제 멈춰야 하는지”가 불명확해서 생깁니다. 해결책은 최종 응답을 구조화하고, 구조가 채워지면 종료하게 만드는 것입니다.
예: 조사형 태스크라면 다음처럼 종료 스키마를 둡니다.
answer: 최종 결론evidence: 근거 3개 이상unknowns: 끝내 확인 불가한 항목next_steps: 사람이 해야 할 일
type FinalReport = {
answer: string;
evidence: Array<{ source: string; quote: string }>;
unknowns: string[];
nextSteps: string[];
status: "COMPLETE" | "PARTIAL";
};
그리고 에이전트 루프 내부에서 “스키마를 채울 수 있는가”를 매 스텝 평가합니다.
evidence가 3개 미만이면 Tool 호출 허용unknowns가 늘기만 하고evidence가 안 늘면 종료(부분 완료)
이렇게 하면 “계속 더 조사”가 아니라 “스키마를 채우기 위한 최소 행동”으로 수렴합니다.
3단계: 루프 감지(Loop Detection) — ‘중복 행동’과 ‘진전 없음’을 신호로 사용
루프는 보통 다음 두 형태로 나타납니다.
- 같은 Tool을 같은 인자로 반복 호출
- 다른 Tool을 호출해도 새 정보가 추가되지 않음
(A) 행동 시그니처로 중복 감지
import crypto from "crypto";
type Action = {
tool: string;
input: unknown;
};
export function actionSignature(action: Action) {
const json = JSON.stringify(action);
return crypto.createHash("sha256").update(json).digest("hex");
}
export function isLooping(recentSignatures: string[], threshold = 3) {
if (recentSignatures.length < threshold) return false;
const tail = recentSignatures.slice(-threshold);
return tail.every((x) => x === tail[0]);
}
(B) “새 팩트 수”가 증가하는지 감시
관찰 결과에서 팩트를 추출해 facts 집합 크기가 늘지 않으면, 모델은 사실상 제자리입니다.
- 팩트 추출은 완벽할 필요는 없습니다.
- 단순 키워드/엔티티/URL 기준만으로도 효과가 큽니다.
루프 감지 시에는 다음 중 하나를 강제합니다.
- 전략 전환 프롬프트 삽입: “다른 접근을 시도하라”
- Tool 차단: 같은 Tool을 일정 시간 금지
- 부분 완료로 종료:
status = PARTIAL
4단계: 메모리 설계 — ‘다 쌓기’가 아니라 ‘계층화’가 핵심
무한루프를 막는 메모리는 “기억을 많이 저장”하는 게 아니라 기억이 의사결정에 영향을 주도록 구조화하는 것입니다.
메모리의 3계층
- Working Memory (단기)
- 최근 대화/최근 Tool 결과
- 길이 제한이 명확해야 함 (예: 최근 10개 이벤트)
- Episodic Memory (에피소드)
- 이번 태스크에서 얻은 핵심 사실, 실패한 시도, 확정된 결론
- “무엇을 했고, 왜 실패했는지”가 포함돼야 재시도를 막음
- Semantic Memory (장기 지식)
- 재사용 가능한 규칙/가이드
- 예: “이 API는 429가 잦으니 백오프 필요”
중요한 원칙: ‘실패도 메모리로 승격’해야 루프가 줄어든다
많은 에이전트가 성공한 정보만 저장합니다. 하지만 루프를 막는 건 오히려 실패 기록입니다.
- “이 URL은 403이라 접근 불가”
- “이 검색 쿼리는 결과가 없었음”
- “이 DB 쿼리는 권한 부족”
이 실패 메모리가 다음 스텝의 행동 공간을 줄여줍니다.
5단계: Tool 설계 — 결과를 모델 친화적으로, 재시도 정책을 함께 반환
Tool이 단순히 문자열을 반환하면 모델은 맥락을 오해합니다. 다음처럼 구조화된 응답이 좋습니다.
ok: 성공 여부retryable: 재시도 가능 여부cooldownMs: 재시도까지 대기 시간data: 성공 데이터error: 실패 사유(코드 포함)
type ToolResult<T> =
| { ok: true; data: T }
| {
ok: false;
error: { code: string; message: string };
retryable: boolean;
cooldownMs?: number;
};
async function searchTool(query: string): Promise<ToolResult<{ urls: string[] }>> {
try {
// ... call provider
return { ok: true, data: { urls: ["https://example.com"] } };
} catch (e: any) {
const code = e?.code ?? "UNKNOWN";
const retryable = code === "RATE_LIMIT" || code === "ETIMEDOUT";
return {
ok: false,
error: { code, message: String(e?.message ?? e) },
retryable,
cooldownMs: retryable ? 1500 : undefined
};
}
}
이렇게 하면 에이전트가 “왜 실패했는지”를 이해하고, 무의미한 즉시 재시도를 줄일 수 있습니다.
스트리밍 기반으로 Tool 결과를 흘려보내는 구조라면, 중복 토큰/끊김이 루프 트리거가 되는 경우도 있습니다. 이 경우 LangChain 스트리밍 끊김·중복 토큰 해결법에서 소개한 것처럼 출력 조립 로직을 먼저 안정화하는 게 좋습니다.
6단계: “계획-실행”을 분리하고, 실행은 결정론적으로 만들기
무한루프는 모델이 “계획도 만들고 실행도 판단”할 때 더 자주 발생합니다. 완화 전략은 다음입니다.
- 모델은 계획(Plan) 만 생성
- 실행기는 정책(Policy) 으로만 Tool 호출
- 정책은 예산/루프감지/재시도 규칙을 강제
즉, 모델의 자유도를 “행동 선택”에서 빼고 “의도 표현” 쪽으로 옮깁니다.
Plan 스키마 예시
type PlanStep = {
goal: string;
tool: "search" | "browser" | "code" | "none";
input: Record<string, any>;
successCriteria: string[];
};
type Plan = {
steps: PlanStep[];
stopWhen: string[];
};
실행기는 tool = none이거나 stopWhen을 만족하면 종료합니다. 모델이 “계속 해도 된다”고 말해도, 정책이 아니면 실행되지 않습니다.
7단계: 운영 관점 체크리스트 — 루프를 ‘장애’로 다루기
에이전트 무한루프는 비용/지연을 유발하는 운영 장애입니다. 따라서 다음을 권장합니다.
- 스텝/Tool 호출/비용/시간을 메트릭으로 노출
- 중단 사유(
maxSteps,maxWallTime등)를 태그로 남김 - 같은 태스크에서
PARTIAL비율이 올라가면 프롬프트/Tool 품질 이슈로 분류
이 관점은 고루틴 누수나 리소스 누수와 닮았습니다. “계속 살아있는 작업”을 조기에 감지하고 차단해야 합니다. 비슷한 운영 마인드셋은 Go 고루틴 누수 원인 8가지와 진단법에서도 얻을 수 있습니다.
종합 설계 패턴: 무한루프를 실용적으로 막는 조합
현장에서 가장 효과가 좋았던 조합은 아래 순서입니다.
- 예산(Budget) 강제:
maxSteps,maxToolCalls,maxWallTimeMs, Tool별 상한 - 종료 스키마:
COMPLETE또는PARTIAL을 명시 - 루프 감지: 행동 시그니처 중복 + 팩트 증가율 감시
- 메모리 계층화: 실패 기록을 에피소드 메모리로 승격
- Tool 결과 구조화:
retryable,cooldownMs포함 - 계획-실행 분리: 모델은 계획, 실행기는 정책
이렇게 설계하면 “모델이 똑똑해져서 루프를 안 돈다”가 아니라, 루프가 돌 수 없는 시스템이 됩니다. AutoGPT류 에이전트를 제품에 넣을 때 필요한 건 더 강한 모델보다, 이런 제약과 관측 가능성(Observability)입니다.
부록: 최소 실행 루프 예시(의사코드)
아래는 위 아이디어를 한 번에 묶은 간단한 루프 구조입니다.
type State = {
usage: Usage;
recentActionSigs: string[];
facts: Set<string>;
report: FinalReport;
};
async function runAgent(initial: State, budget: Budget) {
const state = initial;
while (true) {
state.usage.steps += 1;
const cont = canContinue(budget, state.usage);
if (!cont.ok) {
state.report.status = "PARTIAL";
state.report.unknowns.push(`Stopped by budget: ${cont.reason}`);
return state.report;
}
if (state.report.evidence.length >= 3 && state.report.answer.trim().length > 0) {
state.report.status = "COMPLETE";
return state.report;
}
const action: Action = await decideNextAction(state); // model output
const sig = actionSignature(action);
state.recentActionSigs.push(sig);
if (isLooping(state.recentActionSigs, 3)) {
state.report.status = "PARTIAL";
state.report.unknowns.push("Loop detected: repeated action signature");
return state.report;
}
const contTool = canContinue(budget, state.usage, action.tool as any);
if (!contTool.ok) {
state.report.status = "PARTIAL";
state.report.unknowns.push(`Tool blocked: ${contTool.reason}`);
return state.report;
}
state.usage.toolCalls += 1;
state.usage.perToolCalls[action.tool as ToolName] =
(state.usage.perToolCalls[action.tool as ToolName] ?? 0) + 1;
const obs = await callTool(action);
applyObservationToMemory(state, obs);
if (noNewFactsRecently(state)) {
state.report.status = "PARTIAL";
state.report.unknowns.push("No progress: facts not increasing");
return state.report;
}
}
}
decideNextAction, applyObservationToMemory, noNewFactsRecently 같은 함수는 팀/도메인별로 달라지지만, 예산 + 종료 스키마 + 루프 감지의 3종 세트는 거의 모든 케이스에서 효과가 있습니다.