- Published on
AutoGPT 도구 호출 무한루프 차단 - FSM·가드레일
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
에이전트형 LLM(예: AutoGPT 스타일)은 plan -> tool -> observe -> plan 사이클을 반복하며 목표를 달성합니다. 문제는 이 루프가 “유한한 종료 조건”을 갖지 못할 때입니다. 대표적으로 같은 도구를 동일 인자로 재호출하거나, 에러를 복구하지 못한 채 재시도만 반복하거나, 관찰 결과를 잘못 해석해 같은 분기를 다시 타는 경우가 있습니다.
이 글에서는 무한루프를 “프롬프트로 설득”하는 수준이 아니라, 런타임에서 강제로 차단하고 복구 가능한 방향으로 유도하는 패턴을 다룹니다. 핵심은 두 가지입니다.
- FSM(유한상태기계)로 에이전트 실행을 상태/전이로 모델링해 불가능한 전이를 막고, 종료 상태를 명시한다.
- 가드레일(guardrails)로 도구 호출을 정량적 한도(예: budget, retry, TTL) 와 정성적 규칙(예: 동일 호출 금지, 결과 검증) 으로 감싼다.
또한 도구 출력 포맷이 어긋나면 모델이 “계속 고치려고” 재호출을 반복하는 경우가 많습니다. 이때는 도구 출력 스키마를 엄격히 맞추는 게 우선입니다. 관련해서는 OpenAI Responses API 400 invalid_tool_output 해결법도 함께 참고하면 좋습니다.
무한루프가 생기는 전형적인 패턴
1) 동일 입력의 재호출
- 예:
search("foo")를 10번 반복 - 원인: 관찰 결과를 “새 정보”로 인식하지 못하거나, 다음 행동 선택 로직이 빈약함
2) 에러 재시도 폭주
- 예: 429/5xx/timeout 발생 후 즉시 재시도, backoff 없음
- 원인: 재시도 정책이 LLM에게만 맡겨져 있음
3) 관찰 결과 검증 부재
- 예: 도구가 빈 결과를 반환했는데도 성공으로 간주하거나, 반대로 성공인데 실패로 해석
- 원인: 도구 결과에 대한
success/error의 명확한 계약이 없음
4) 종료 조건 부재
- 예: “완료”에 대한 정의가 없고, 모델이 계속 개선/확장하려 함
- 원인: 목표가 측정 불가능하거나, 종료 상태(terminal state)가 코드에 없음
해결 전략 1: FSM으로 실행을 ‘상태 기계’로 고정
FSM을 쓰는 이유는 단순합니다. 에이전트는 본질적으로 상태를 갖습니다.
- 계획 수립 중인지
- 도구 실행 중인지
- 관찰을 평가 중인지
- 완료/중단 상태인지
이걸 코드로 명시하면, “모델이 하고 싶어 하는 행동”이 아니라 “시스템이 허용하는 전이”만 실행됩니다.
상태 설계 예시
IDLE: 시작 전PLANNING: 다음 행동(도구 호출 또는 종료) 결정TOOL_RUNNING: 도구 실행EVALUATING: 관찰 결과 평가 및 다음 전이 결정DONE: 목표 달성FAILED: 회복 불가ESCALATED: 사람에게 넘김(또는 안전한 중단)
핵심은 DONE/FAILED/ESCALATED 같은 종료 상태를 반드시 두는 것입니다.
TypeScript 예시: FSM 스켈레톤
아래 코드는 “도구 호출 루프”를 FSM으로 감싼 최소 구조입니다. 실제로는 도구 레지스트리, 메시지 히스토리, 메트릭 등을 더 붙입니다.
type State =
| "IDLE"
| "PLANNING"
| "TOOL_RUNNING"
| "EVALUATING"
| "DONE"
| "FAILED"
| "ESCALATED";
type ToolCall = {
name: string;
args: Record<string, unknown>;
};
type Observation = {
ok: boolean;
data?: unknown;
error?: { code: string; message: string };
};
type Ctx = {
state: State;
step: number;
maxSteps: number;
lastToolCall?: ToolCall;
toolCallHistory: Array<{ fingerprint: string; atStep: number }>;
observation?: Observation;
};
function fingerprintToolCall(call: ToolCall): string {
// JSON stringify는 키 순서 이슈가 있어 안정화가 필요할 수 있음
return `${call.name}:${JSON.stringify(call.args)}`;
}
function assertTransition(from: State, to: State) {
const allowed: Record<State, State[]> = {
IDLE: ["PLANNING"],
PLANNING: ["TOOL_RUNNING", "DONE", "ESCALATED", "FAILED"],
TOOL_RUNNING: ["EVALUATING", "FAILED"],
EVALUATING: ["PLANNING", "DONE", "ESCALATED", "FAILED"],
DONE: [],
FAILED: [],
ESCALATED: [],
};
if (!allowed[from].includes(to)) {
throw new Error(`Invalid transition: ${from} -> ${to}`);
}
}
function transition(ctx: Ctx, to: State): Ctx {
assertTransition(ctx.state, to);
return { ...ctx, state: to };
}
이제 “에이전트가 상태를 넘어서는 행동”은 원천 차단됩니다. 예를 들어 DONE 상태에서 도구를 또 호출하려 하면 전이 자체가 막힙니다.
해결 전략 2: 가드레일로 도구 호출을 ‘검문소’ 통과시키기
FSM이 큰 틀이라면, 가드레일은 각 도구 호출 전후에 붙는 검문소입니다. 가드레일은 보통 아래를 포함합니다.
- 스텝/시간 예산:
maxSteps,deadline,toolBudget - 중복 호출 차단: 동일 호출 fingerprint 제한
- 재시도 정책: 지수 백오프, 최대 재시도, 에러 코드별 분기
- 결과 검증: 스키마 검증, 빈 결과 처리, 성공 조건 평가
- 서킷 브레이커: 특정 도구가 계속 실패하면 잠시 차단
1) 스텝/시간 예산(하드 리밋)
가장 확실한 안전장치입니다.
maxSteps초과 시ESCALATED또는FAILED- 전체 실행
deadline초과 시 중단
function guardStepBudget(ctx: Ctx): Ctx {
if (ctx.step >= ctx.maxSteps) {
return transition(ctx, "ESCALATED");
}
return ctx;
}
실무에서는 “무한루프 차단”의 1차 방어선이 이 하드 리밋입니다. 다만 이것만으로는 사용자 경험이 나쁘므로, 아래의 정교한 가드레일이 필요합니다.
2) 동일 도구 호출 반복 차단(중복 fingerprint)
동일한 도구/인자 조합을 계속 호출하는 경우, 일정 횟수 이상이면 막고 다른 전략을 선택하게 해야 합니다.
function guardDuplicateToolCall(
ctx: Ctx,
call: ToolCall,
limit: number
): { ok: true } | { ok: false; reason: string } {
const fp = fingerprintToolCall(call);
const count = ctx.toolCallHistory.filter(h => h.fingerprint === fp).length;
if (count >= limit) {
return { ok: false, reason: `Duplicate tool call blocked: ${fp}` };
}
return { ok: true };
}
function recordToolCall(ctx: Ctx, call: ToolCall): Ctx {
const fp = fingerprintToolCall(call);
return {
...ctx,
lastToolCall: call,
toolCallHistory: [...ctx.toolCallHistory, { fingerprint: fp, atStep: ctx.step }],
};
}
여기서 중요한 포인트는 “완전 동일”만 막으면 부족하다는 점입니다. 예를 들어 검색 쿼리가 약간씩만 바뀌며 무한히 확장될 수 있습니다. 그래서 다음의 보완 규칙이 자주 필요합니다.
- 유사도 기반(Levenshtein, embedding cosine)으로 “사실상 동일”을 감지
- 도구별로
limit를 다르게 설정(검색은 3회, 결제/삭제는 1회 등)
3) 재시도 가드레일: 에러 코드별 정책
LLM에게 “재시도해”를 맡기면 보통 즉시 재시도 폭주로 이어집니다. 도구 실행 계층에서 다음을 강제하세요.
- 429: 지수 백오프 + jitter
- 5xx: 제한된 재시도
- 4xx(유효성 오류): 재시도 금지, 즉시
PLANNING으로 돌아가 프롬프트/인자 수정 유도
type RetryPolicy = {
maxRetries: number;
baseDelayMs: number;
};
async function withRetry<T>(
fn: () => Promise<T>,
classify: (e: unknown) => { retryable: boolean; code: string },
policy: RetryPolicy
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (e) {
const c = classify(e);
if (!c.retryable || attempt >= policy.maxRetries) throw e;
const delay = policy.baseDelayMs * Math.pow(2, attempt);
const jitter = Math.floor(Math.random() * 200);
await new Promise(r => setTimeout(r, delay + jitter));
attempt++;
}
}
}
이렇게 하면 “도구 호출 무한루프”의 상당 부분이 “재시도 무한루프”였다는 사실이 드러납니다.
4) 결과 검증: 스키마와 성공 조건을 분리
관찰 결과(Observation)는 최소한 아래를 만족해야 합니다.
- 항상
ok를 포함 - 실패 시
error.code,error.message를 포함 - 성공 시
data의 스키마가 고정
도구 출력이 흔들리면 모델이 출력을 해석하지 못해 같은 호출을 반복합니다. 특히 OpenAI 계열 툴콜에서 tool_output 포맷이 어긋나면 400이 나고, 모델이 “수정 시도”를 반복하며 루프가 됩니다. 이때는 먼저 출력 계약부터 고치세요. 자세한 케이스는 OpenAI Responses API 400 invalid_tool_output 해결법에 정리돼 있습니다.
아래는 zod로 결과를 검증하는 예시입니다.
import { z } from "zod";
const SearchResultSchema = z.object({
items: z.array(
z.object({
title: z.string(),
url: z.string().url(),
snippet: z.string().optional(),
})
),
});
type SearchResult = z.infer<typeof SearchResultSchema>;
function validateSearchObservation(obs: Observation):
| { ok: true; value: SearchResult }
| { ok: false; reason: string } {
if (!obs.ok) return { ok: false, reason: obs.error?.message ?? "unknown error" };
const parsed = SearchResultSchema.safeParse(obs.data);
if (!parsed.success) return { ok: false, reason: parsed.error.message };
return { ok: true, value: parsed.data };
}
검증 실패 시에는 “같은 호출 반복”이 아니라 PLANNING으로 되돌려 인자를 수정하거나 다른 도구로 전환하게 해야 합니다.
FSM + 가드레일 결합 실행 흐름
아래는 전체 실행 루프의 예시 골격입니다.
async function runAgent(initialCtx: Ctx) {
let ctx = transition(initialCtx, "PLANNING");
while (true) {
ctx = guardStepBudget(ctx);
if (["DONE", "FAILED", "ESCALATED"].includes(ctx.state)) return ctx;
if (ctx.state === "PLANNING") {
// LLM에게 다음 행동을 요청: tool call 또는 done
// (여기서는 의사 코드)
const decision = await decideNextAction(ctx);
if (decision.type === "done") {
ctx = transition(ctx, "DONE");
continue;
}
if (decision.type === "tool") {
const dup = guardDuplicateToolCall(ctx, decision.call, 2);
if (!dup.ok) {
// 동일 호출 반복이면 에스컬레이션 또는 다른 전략 유도
ctx = transition(ctx, "ESCALATED");
continue;
}
ctx = recordToolCall(ctx, decision.call);
ctx = transition(ctx, "TOOL_RUNNING");
continue;
}
ctx = transition(ctx, "FAILED");
continue;
}
if (ctx.state === "TOOL_RUNNING") {
const call = ctx.lastToolCall!;
const obs = await executeTool(call);
ctx = { ...ctx, observation: obs };
ctx = transition(ctx, "EVALUATING");
continue;
}
if (ctx.state === "EVALUATING") {
const obs = ctx.observation!;
// 도구별 검증/평가
const evalResult = await evaluateObservation(ctx, obs);
if (evalResult.type === "done") ctx = transition(ctx, "DONE");
else if (evalResult.type === "replan") ctx = transition(ctx, "PLANNING");
else if (evalResult.type === "escalate") ctx = transition(ctx, "ESCALATED");
else ctx = transition(ctx, "FAILED");
ctx = { ...ctx, step: ctx.step + 1 };
continue;
}
}
}
이 구조의 장점은 다음과 같습니다.
- 무한루프가 발생해도
maxSteps에서 반드시 끝남 - 중복 호출은 fingerprint로 조기에 차단
- 실패가 누적되면
ESCALATED로 빠져 “안전한 실패”가 가능
운영 관점: 루프는 장애 패턴이다
도구 호출 무한루프는 단순한 기능 버그를 넘어, 비용 폭주(토큰/외부 API), 레이트리밋, 장애 전파로 이어집니다. 특히 쿠버네티스에서 에이전트 워커를 운영한다면, 비정상 루프가 프로세스 재시작과 결합해 더 큰 문제를 만들 수 있습니다. 애플리케이션이 계속 죽고 뜨는 패턴은 K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기처럼 인프라 레벨의 증상으로도 관측됩니다.
따라서 아래 메트릭을 추천합니다.
- 에이전트 실행당
tool_calls_total - 도구별
tool_errors_total및error.code분포 duplicate_tool_call_blocked_totalagent_terminated_reason(done, failed, escalated, budget_exceeded)
이런 지표가 있어야 “프롬프트를 바꿔서 좋아졌다”가 아니라, 실제로 루프가 줄었는지 확인할 수 있습니다.
실전 팁: 프롬프트만으로 막지 말 것
프롬프트에 “같은 도구를 반복 호출하지 마라”를 넣는 건 도움이 되지만, 신뢰할 수 없습니다. 다음을 반드시 코드 레벨에서 강제하세요.
maxSteps및deadline- 도구 호출 fingerprint 기반 중복 차단
- 에러 코드 기반 재시도 정책(특히 4xx는 재시도 금지)
- 도구 출력 스키마 검증(성공/실패 계약)
- 종료 상태(
DONE/ESCALATED/FAILED)를 FSM에 명시
추가로, 도구 호출이 많아 성능이 문제라면 모델 추론 자체를 최적화해야 하는 경우도 있습니다. 로컬 LLM을 운영 중이라면 Transformers 로컬 LLM 속도 2배 - FlashAttention2 적용처럼 추론 최적화가 “루프의 비용”을 줄이는 데도 직접적인 영향을 줍니다.
체크리스트
- 실행 루프가 FSM으로 구현되어 있고, 종료 상태가 명확한가
-
maxSteps/deadline이 하드 리밋으로 동작하는가 - 동일 도구 호출이 fingerprint 또는 유사도 기준으로 제한되는가
- 도구 출력이
ok/error계약을 만족하며 스키마 검증이 있는가 - 4xx/429/5xx에 대한 재시도 정책이 코드에 고정되어 있는가
-
ESCALATED경로(사람에게 넘김, 안전한 중단)가 준비되어 있는가
무한루프는 “모델이 멍청해서”가 아니라, 시스템이 종료 조건과 실패 처리의 책임을 모델에게 떠넘길 때 생깁니다. FSM으로 실행을 구조화하고, 가드레일로 도구 호출을 제어하면 AutoGPT 스타일 에이전트를 운영 가능한 소프트웨어로 끌어올릴 수 있습니다.