AI 에이전트가 같은 도구를 계속 호출한다면 먼저 현재 run을 취소하고, 다음 도구 실행을 막은 뒤, 마지막 안전 체크포인트와 실행 원장을 보존하세요. 그다음 모델 프롬프트가 아니라 도구 실행 직전에 LoopGuard를 둡니다. 이 guard는 실행 횟수만 세지 않습니다. 정규화된 호출 지문, 외부 상태 변화, 재시도 가능 여부, 멱등성 키, 경과 시간을 함께 검사하고 completed, fatal, needs_human, loop_blocked 같은 명시적 종료 사유로 run을 닫아야 합니다.
복구 완료 기준도 명확합니다. 이전 trace를 재생했을 때 다음의 네 가지가 모두 보여야 합니다.
- 다음의 동등한 위험 호출이 실행 전에 차단된다.
- 이미 성공한 쓰기 작업은 재시도나 프로세스 재시작 뒤에도 다시 실행되지 않는다.
- trace에 어떤 guard가 발동했는지와 마지막 안전 상태가 남는다.
- 외부 상태가 실제로 변하는 정상적인 장기 polling은 계속 진행된다.
max_iterations 또는 recursion_limit은 피해 규모를 제한하는 안전벨트일 뿐, 루프를 고치는 브레이크가 아닙니다. 반복 횟수를 늘려 다시 실행하지 말고 아래 순서로 사고를 닫으세요.

5분 안에 현재 도구 루프 멈추기
1분: 실행 경로부터 차단
run 취소 신호만 보내고 끝내지 마세요. worker가 신호를 늦게 받거나, sub-agent가 별도 큐에서 실행 중이거나, 재시도 worker가 새 작업을 만들 수 있습니다. 에이전트가 실제로 사용하는 tool dispatcher 앞에 임시 차단 규칙을 넣고 해당 run_id의 새 호출을 거절합니다. 외부 API 쓰기, 결제, 메시지 발송, 파일 변경처럼 부작용이 있는 도구는 우선순위가 가장 높습니다.
차단 응답은 일반 timeout이 아니라 재시도 불가인 터미널 결과여야 합니다.
json{ "status": "loop_blocked", "retryable": false, "reason": "incident_stop", "run_id": "run_kr_1042", "next_action": "human_review" }
2분: 증거와 마지막 안전 상태 보존
최근 호출의 tool_name, 원본 인자, 정규화 인자, tool result, 외부 상태 버전, 시각, agent/handoff ID를 저장합니다. 쓰기 도구라면 provider의 operation ID와 애플리케이션 멱등성 키도 함께 보존합니다. 로그를 먼저 지우거나 context만 요약하면 “모델이 이상했다”는 결론만 남고, 성공 응답 누락인지 실제 실패인지 구분할 수 없습니다.
3분: 완료된 부작용 확인
CRM 업데이트, 티켓 생성, 이메일 발송 같은 작업은 tool result만 보지 말고 외부 시스템에서 완료 여부를 확인합니다. 이미 완료됐다면 같은 의도를 가진 호출을 원장에서 completed로 표시하고 재실행하지 않습니다. 반복 호출 감지기는 세 번째 요청을 막을 수 있지만, 첫 번째 성공을 잃어버린 재시작이나 두 worker의 동시 실행까지 막아주지는 못합니다. 이 경계는 멱등성 키와 내구성 있는 실행 원장이 담당합니다.
4분: 루프 유형 판별
아래 표에서 trace와 맞는 행을 찾습니다. 한 run에 두 유형이 겹칠 수 있으므로 “도구 이름이 같은가”만 보지 말고 외부 상태 변화와 결과 의미를 함께 확인하세요.
| trace 패턴 | 확인할 증거 | 다음 실행 전 조치 | 종료 또는 복구 사유 |
|---|---|---|---|
| 완전 동일 반복 | 같은 도구와 정규화 인자, 상태 변화 없음 | 동일 지문 한도를 넘으면 실행 전 차단 | same_call_repeat |
| 인자 미세 변동 | 공백, 순서, 페이지 크기만 달라지고 의도·결과 동일 | 의미 없는 필드를 정규화하고 전략 변경 요구 | argument_jitter |
| 짧은 순환 | A-B-A-B 또는 agent A↔B handoff 반복 | 최근 지문 주기와 전역 handoff 깊이 차단 | short_cycle |
| 진행 정체 | 호출은 다르지만 외부 task version이 그대로 | 진행 없음 예산 소진 시 중지 | no_progress |
| 소프트 실패 | 빈 결과, ok: true 안의 오류, 불완전 schema | 결과 검증 후 제한된 재시도 또는 fatal 처리 | invalid_tool_result |
| 성공 재실행 | 이미 완료된 operation ID나 멱등성 키 발견 | 캐시된 완료 결과 반환, 도구 실행 금지 | already_completed |
| 권한·정책 실패 | 인증 거절, 입력 오류, policy block | 같은 조건의 retry 금지, 사람에게 전달 | permission_denied |
5분: replay용 fixture 고정
사고 trace를 개인정보와 credential 없이 fake tool 입력으로 축소합니다. 수정 전에는 같은 루프를 재현하고, 수정 후에는 예상 종료 사유와 실행 횟수가 고정되어야 합니다. 이 fixture가 없으면 threshold 변경이 정상 polling까지 막았는지 알기 어렵습니다.
hard cap, 진행 감지, 멱등성, 종결 상태는 서로 대체할 수 없다
네 제어는 같은 “루프 방지”처럼 보이지만 서로 다른 실패를 막습니다.
| 제어 | 답하는 질문 | 막지 못하는 것 |
|---|---|---|
| hard cap | 이 run이 최대로 몇 step·초·token·비용을 쓸 수 있는가? | 실제 진전 여부, 중복 부작용 |
| 진행 감지 | 최근 실행이 외부 목표 상태를 바꿨는가? | 재시작·동시 worker의 성공 재실행 |
| 멱등성 원장 | 이 부작용 의도가 이미 완료됐는가? | 읽기 도구의 A-B-A-B 순환 |
| 터미널 상태 | 왜 끝났고 어떤 행동만 다음에 허용되는가? | 실행 전 차단 자체 |
OpenAI Agents SDK의 runner 가이드는 tool call이나 handoff 뒤에도 runner가 실제 stopping point까지 계속된다는 경계를 설명합니다. LangChain 공식 middleware 문서는 model-call limit과 tool-call limit을 별도 제어로 제공합니다. Google ADK의 LoopAgent 문서도 최대 반복 횟수 또는 명시적 exit/escalation 조건을 요구합니다. API는 다르지만 공통점은 하나입니다. 종료 정책은 모델의 선의가 아니라 orchestration runtime이 집행해야 합니다.
내장 cap이 발동했다는 예외는 피해가 무한하지 않았다는 증거일 뿐입니다. CRM 레코드가 두 번 수정되지 않았는지, 마지막 checkpoint가 일관적인지, 다음 재시도가 다른 전략을 쓸지는 애플리케이션이 별도로 증명해야 합니다.
실행 직전에 두는 framework-neutral LoopGuard
아래 TypeScript는 특정 SDK hook에 의존하지 않는 핵심 guard입니다. 실제 runtime에서는 beforeToolExecution middleware, dispatcher wrapper 또는 workflow activity 앞에서 before()를 호출하세요. 중요한 점은 모델이 “진행했다”고 말하는 문장이 아니라 검증된 외부 상태의 단조 증가 값인 progressVersion을 입력한다는 것입니다.
tstype ToolCall = { tool: string; args: Record<string, unknown>; progressVersion: number; }; type StopReason = | "step_budget" | "wall_clock" | "same_call_repeat" | "short_cycle" | "no_progress"; type GuardDecision = | { allow: true; fingerprint: string } | { allow: false; reason: StopReason }; const stable = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(stable); if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record<string, unknown>) .filter(([key]) => !["request_id", "timestamp", "trace_id"].includes(key)) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => [key, stable(item)]) ); } return typeof value === "string" ? value.trim() : value; }; const fingerprint = (call: ToolCall) => `${call.tool}:${JSON.stringify(stable(call.args))}`; export class LoopGuard { private readonly startedAt = Date.now(); private readonly history: string[] = []; private steps = 0; private noProgress = 0; private lastProgressVersion: number; constructor( private readonly limits = { maxSteps: 12, maxSameCall: 2, maxNoProgress: 3, maxWallMs: 60_000, }, initialProgressVersion = -1, ) { this.lastProgressVersion = initialProgressVersion; } before(call: ToolCall): GuardDecision { if (Date.now() - this.startedAt >= this.limits.maxWallMs) { return { allow: false, reason: "wall_clock" }; } if (this.steps >= this.limits.maxSteps) { return { allow: false, reason: "step_budget" }; } if (call.progressVersion > this.lastProgressVersion) { this.lastProgressVersion = call.progressVersion; this.noProgress = 0; // 검증된 진행 이후에는 새 반복 감지 구간을 시작한다. this.history.length = 0; } else { this.noProgress += 1; } if (this.noProgress >= this.limits.maxNoProgress) { return { allow: false, reason: "no_progress" }; } const fp = fingerprint(call); const sameCount = this.history.filter((item) => item === fp).length; if (sameCount >= this.limits.maxSameCall) { return { allow: false, reason: "same_call_repeat" }; } const recent = [...this.history.slice(-3), fp]; if ( recent.length === 4 && recent[0] === recent[2] && recent[1] === recent[3] ) { return { allow: false, reason: "short_cycle" }; } this.steps += 1; this.history.push(fp); return { allow: true, fingerprint: fp }; } }
이 예시는 안전한 시작점이지 보편적인 threshold가 아닙니다. resume할 때는 저장된 checkpoint 버전을 initialProgressVersion으로 전달해야 첫 번째로 돌아온 동일 버전을 새 진행으로 오판하지 않습니다. 검증된 progressVersion이 증가하면 history를 비워 새 반복 감지 구간을 시작하므로, 동일한 poll({"job":"42"})라도 외부 버전이 계속 증가하면 진행할 수 있습니다. 반대로 버전이 멈춘 반복은 같은 구간에 남아 차단됩니다. 검색 도구의 page=1, page=2는 의미 있는 차이지만 매번 새로 생성되는 request_id는 그렇지 않을 수 있습니다. 도구별 정규화 규칙을 작성하고, 인자를 삭제할 때 서로 다른 실제 작업이 같은 지문으로 합쳐지지 않는지 fixture로 검증하세요.
또한 progressVersion은 대화 turn 수가 아닙니다. 검증된 레코드 수, 완료된 workflow stage, 새 cursor, 테스트 통과 수처럼 목표에 가까워졌음을 외부에서 확인할 수 있는 값이어야 합니다. 정상 polling은 상태가 바뀌거나 backoff 시간이 늘어나므로 통과할 수 있고, 같은 빈 결과만 되풀이하는 polling은 no_progress로 끝납니다.
tool result를 “다시 해봐”가 아닌 계약으로 만들기
도구가 모호한 문자열만 반환하면 planner는 실패와 미완료를 구분하지 못합니다. 최소 결과 계약은 다음 상태를 명시해야 합니다.
| status | retryable | runtime 동작 |
|---|---|---|
success | false | 완료 상태와 operation ID 기록, 다음 단계 또는 run 완료 |
retryable_error | true | 바뀔 수 있는 조건과 backoff가 있을 때만 제한 재시도 |
fatal_error | false | 즉시 종료, 입력 또는 구현 수정 요구 |
permission_denied | false | credential·권한 변경 전 재시도 금지 |
policy_blocked | false | 우회 route 금지, 사람 검토 |
needs_human | false | checkpoint 저장 후 승인 대기 |
Anthropic의 tool use 설명과 tool result 처리 문서는 client tool의 실제 실행과 대응하는 tool_result 반환이 애플리케이션 책임임을 보여줍니다. 이것이 Anthropic에 범용 loop cap이 있다는 뜻은 아닙니다. 어느 provider를 쓰든 애플리케이션이 결과 의미와 recovery policy를 소유해야 한다는 경계입니다.
재시도 허용 조건은 “실패했다”가 아니라 관련 precondition이 바뀔 가능성과 제한된 예산이 모두 있다는 것입니다. 일시적인 network failure는 backoff 뒤 재시도할 수 있지만, 같은 credential의 permission_denied, 같은 payload의 validation error, 완료된 부작용은 재시도해서는 안 됩니다.
부작용 도구에는 멱등성 원장을 추가한다
LoopGuard의 메모리 내 history는 프로세스가 재시작되면 사라집니다. 두 worker가 동시에 같은 tool을 실행할 수도 있습니다. 이메일 발송, 주문 변경, CRM 쓰기처럼 되돌리기 어려운 작업은 run_id + tool + business_operation_id로 멱등성 키를 만들고, 도구 실행 전에 내구성 있는 원장에서 원자적으로 예약합니다.
tsasync function executeSideEffect(call, ledger) { const key = `${call.runId}:${call.tool}:${call.businessOperationId}`; const claim = await ledger.claimAtomically(key); if (claim.status === "completed") { return { status: "success", replayed: true, result: claim.result }; } if (claim.status !== "claimed") { return { status: "needs_human", reason: "operation_in_flight" }; } try { const result = await call.toolExecutor(call.args); await ledger.complete(key, result); return { status: "success", replayed: false, result }; } catch (error) { await ledger.recordFailure(key, classify(error)); throw error; } }
원자적 claim이 없으면 두 worker가 모두 “아직 미완료”를 읽고 같은 쓰기를 실행할 수 있습니다. 외부 provider가 자체 idempotency key를 지원한다면 애플리케이션 키와 함께 사용하되, provider 보존 기간과 재사용 규칙을 확인하세요. 지원하지 않는다면 자체 원장과 사후 reconciliation이 필요합니다.
멈춤, 복구, 사람 전달을 결정하는 순서
모든 이상을 즉시 fatal로 만들면 정상적인 일시 오류를 처리하지 못하고, 모든 오류를 retry하면 루프가 됩니다. 다음 순서로 분기하면 정책을 감사하기 쉽습니다.
- 완료 원장에 같은 부작용이 있으면 실행하지 않고 저장된 성공 결과를 반환합니다.
- 권한, 정책, 잘못된 입력, 이미 완료된 작업이면 terminal stop 또는 사람 전달로 보냅니다.
- 동일 지문, 짧은 cycle, 진행 없음 guard가 발동하면 같은 전략의 자동 retry를 금지합니다.
- 일시 오류이고 retry budget이 남았으며 시간·상태·credential 같은 관련 조건이 바뀔 수 있을 때만 backoff 후 재시도합니다.
- 다른 도구나 계획이 검증된 새 정보를 만들 수 있으면
switch_strategy로 한 번 분기합니다. - hard budget이 소진되면 원인과 무관하게 run을 종료하고 마지막 안전 checkpoint를 반환합니다.
| 판단 | 허용되는 다음 행동 | 금지되는 행동 |
|---|---|---|
| 같은 성공 작업 재등장 | 원장 결과 재사용 | 실제 도구 재실행 |
| retryable + 조건 변화 가능 | backoff 후 제한 재시도 | 즉시 무제한 재시도 |
| 진행 없음 | 전략 변경 또는 사람 전달 | 인자만 조금 바꿔 같은 의도 반복 |
| 권한·정책 실패 | 수정 요청, 승인 대기, 종료 | 다른 key나 provider로 몰래 우회 |
| 예산 소진 | checkpoint와 종료 사유 반환 | 새 sub-agent 생성 |
multi-agent에서는 guard 상태도 공유해야 한다
agent별 step cap만 두면 A가 B에게, B가 다시 A에게 넘기는 동안 각자의 local counter는 낮게 유지될 수 있습니다. parent run에 다음 상태를 공유하세요.
- 전역 tool fingerprint history와 외부
progressVersion handoff_depth, 최근 agent 경로, 같은 역할로 돌아온 횟수- run 전체 step·wall-clock·token·비용 예산
- 완료된 부작용의 멱등성 원장
- 마지막 안전 checkpoint와 terminal reason
handoff는 새 agent 이름이 아니라 새 정보나 새 권한을 가져올 때만 허용합니다. 같은 상태 지문으로 planner → researcher → planner → researcher가 반복되면 short_cycle로 처리합니다. Microsoft Learn의 단일·다중 에이전트 루프 비교는 순차 단계 사이의 programmatic validation gate와 명확한 handoff 조건, evaluator-optimizer의 exit condition을 강조합니다. Azure 제품 구현을 그대로 일반화하기보다, 전역 guard가 필요한 근거로 사용하세요.
공급자 호출 없이 검증한 다섯 fixture
아래 결과는 2026년 7월 27일, 저장소에서 사용할 수 있는 Node.js로 실행한 framework-neutral JavaScript simulation의 관측값입니다. paid model, provider API, network, credential은 사용하지 않았습니다. 입력은 안정적인 인자 정렬, 호출 지문, 전체 step budget, 동일 호출 최대 2회 실행, 진행 없음 최대 3회 실행, wall-clock limit, 명시적 terminal status, 단조 증가하는 progressVersion으로 구성했습니다.
| fixture | 주입한 동작 | 관측 결과 | 통과 의미 |
|---|---|---|---|
success_after_one | CRM형 도구가 첫 호출에 success 반환 | 1회 실행 후 완료 | 정상 성공을 과잉 차단하지 않음 |
identical_retry | 같은 search({"q":"same"})가 retryable이지만 진행 없음 | 세 번째 실행 전 same_call_repeat, 실제 도구 부작용 2회 | 동일 호출 한도가 실행 전에 작동 |
alternating_cycle | read와 check가 상태 변화 없이 교대 | 3회 실행 후 no_progress | 지문이 달라도 정체를 감지 |
changed_state_then_success | poll cursor와 progress version이 함께 증가 | 서로 다른 3회 호출 허용 후 성공 | 생산적인 장기 작업을 허용 |
fatal_no_retry | 쓰기 도구가 permission_denied 반환 | 1회 실행 후 종료 | terminal 실패를 재시도하지 않음 |
이 테스트가 증명하는 범위는 위 다섯 입력에 대한 결정적 분기뿐입니다. 모델이 항상 진행을 정확히 보고한다는 뜻은 아닙니다. production에서는 외부 상태, 검증된 출력, 내구성 있는 checkpoint에서 진행을 계산하세요. 또한 단일 호출 지문만으로는 의미가 같은 인자 변동, 동시 실행, 재시작, 긴 cycle을 모두 막을 수 없습니다. 그래서 no-progress 검사, 멱등성, 내구성 있는 run 상태가 함께 필요합니다.
회귀 테스트에는 적어도 다음 assertion을 넣으세요.
- 차단된 호출의 fake tool counter가 증가하지 않는다.
- terminal 결과 뒤 planner가 새 tool call이나 sub-agent를 만들지 않는다.
- 같은 멱등성 키를 두 worker가 요청해도 외부 쓰기는 한 번만 일어난다.
- 상태가 변하는 polling은
maxNoProgress를 소진하지 않는다. - replay 종료 사유와 실행 횟수가 배포 전후 동일하다.
framework 내장 제어는 어디까지 맡길까
정확한 옵션 이름과 기본값은 바뀔 수 있으므로 배포 시점에 공식 문서를 다시 확인해야 합니다. 역할은 다음처럼 나누면 안정적입니다.
| 계층 | 맡길 일 | 애플리케이션이 계속 소유할 일 |
|---|---|---|
| OpenAI Agents SDK runner | 실제 stopping point와 turn 상한 연결 | 외부 진행 상태, 멱등성, rollback·recovery 정책 |
| LangChain middleware | model/tool call 개수 제한과 exit behavior | 의미상 동등 호출, 부작용 원장, 도구별 terminal 계약 |
| Google ADK LoopAgent | max_iterations와 명시적 exit/escalation | 업무 완료 증거, 전역 handoff 상태, 재실행 안전성 |
| custom loop | dispatcher 전후 모든 정책 | cap부터 terminal state까지 전체 구현 |
프레임워크의 max-turn 오류가 발생하면 “cap이 작아서 실패”라고만 기록하지 마세요. 어떤 지문이 반복됐는지, 외부 상태가 마지막으로 언제 변했는지, 완료된 부작용이 있는지, retry가 무엇을 바꿀지를 함께 남겨야 root cause를 고칠 수 있습니다.
배포 전 최종 체크
- tool dispatcher 앞에서 guard가 실행되는가?
- step, retry, wall-clock, token, 비용 예산이 서로 분리되어 있는가?
- 진행을 모델 문장이 아니라 외부 상태 변화로 검증하는가?
- 동일 호출뿐 아니라 A-B-A-B와 전역 handoff cycle도 보는가?
- tool result에 success, retryable, fatal, permission, policy, human 상태가 있는가?
- 부작용 도구가 멱등성 키와 원자적 원장을 쓰는가?
- terminal 결과 뒤 planner와 sub-agent가 멈추는가?
- 사고 trace의 fake-tool replay가 예상 사유로 종료되는가?
- 정상적인 changed-state polling fixture는 계속 통과하는가?
여기까지 통과하면 “무한히 돌지 않는다”뿐 아니라 왜 멈췄고, 무엇이 이미 실행됐으며, 어떤 조건이 바뀌어야만 다시 시도할 수 있는지 설명할 수 있습니다.
마지막으로 tool loop를 막아도 한 번의 모델 호출이 지나치게 비싸거나 여러 정상 run이 동시에 예산을 소진할 수 있습니다. 요청이 provider로 나가기 전에 금액을 예약하고 차단하는 방법은 LLM 에이전트 API 비용 킬 스위치 가이드에서 이어서 구현하세요. 루프 안전장치와 지출 안전장치는 서로 보완하지만 같은 제어는 아닙니다.


