콘텐츠로 이동

종료 조건의 분류학

“루프를 돌게 만드는 것”에 집중하다 보면 “루프를 멈추는 것”을 소홀히 하기 쉽다. 그러나 종료 조건이 없는 에이전틱 루프는 무한히 실행되거나, 무한정 비용을 발생시키거나, 같은 실수를 끝없이 반복하는 **둠 루프(doom loop)**로 전락한다. 2023년 초 AutoGPT와 BabyAGI가 바이럴되었다가 급속도로 식어버린 이유 중 하나도 종료 조건이 없거나 불명확한 루프가 제어 불능으로 치달았기 때문이다.

루프 종료 조건은 크게 네 가지 유형으로 분류할 수 있다. 이 분류표를 처음에 파악해 두면 설계 시 누락 없이 대응할 수 있다.

┌──────────────────────────────────────────────────────────────┐
│ 루프 종료 조건 분류표 │
├─────────────────┬────────────────────────────────────────────┤
│ 유형 │ 설명 및 예시 │
├─────────────────┼────────────────────────────────────────────┤
│ ① 의미적 완료 │ 모델이 "작업 완료"를 선언 (stop_reason │
│ (Semantic) │ == "end_turn"). 올바른 성공 종료. │
├─────────────────┼────────────────────────────────────────────┤
│ ② 반복 한도 │ 최대 이터레이션 수 도달. 하드 안전망. │
│ (Iteration) │ 예: max_iterations = 30 │
├─────────────────┼────────────────────────────────────────────┤
│ ③ 자원 상한 │ 토큰 수, 비용(USD), 실행 시간(wall-clock) │
│ (Resource) │ 초과. 예: max_tokens = 200_000 │
├─────────────────┼────────────────────────────────────────────┤
│ ④ 무진전 감지 │ 연속 N회 출력이 동일하거나 목표 대비 │
│ (No-progress) │ 진전이 없음 감지. 출력 해시 비교 등. │
└─────────────────┴────────────────────────────────────────────┘

이 네 가지 중 하나라도 빠지면 그에 대응하는 실패 모드가 생긴다. ①이 없으면 루프가 끝날 수 없다. ②가 없으면 모델이 계속 “더 해야 한다”고 착각하면 영원히 돈다. ③이 없으면 비용이 폭발한다. ④가 없으면 같은 실수를 무한히 반복하는 둠 루프에 빠진다.

모델이 스스로 작업이 완료됐다고 판단하고 stop_reason == "end_turn" 또는 finish_reason == "stop"을 반환하면 루프를 종료한다. 이것이 정상적인 성공 종료 경로다.

# 개념 이해용 의사 코드이며 실제 API와 다를 수 있습니다.
while True:
response = model.generate(messages, tools=tools)
# 의미적 완료: 모델이 "끝"을 선언
if response.stop_reason == "end_turn":
return {"status": "success", "result": response.content}
# 아직 할 일이 있음: 도구 실행 후 계속
for call in response.tool_calls:
result = run_tool(call)
messages.append(tool_result(result))

이 경로만 믿으면 위험하다. 모델은 실제로 작업이 완료되지 않았음에도 “완료됐다”고 선언할 수 있다. 이른 성공 종료(premature success)는 Anthropic의 장기 실행 에이전트 가이드에서 명시적으로 경계하는 실패 모드다. 의미적 완료를 **검증자(verifier)**가 재확인하는 구조가 필요하다(6-2, 6-3, 6-4 챕터 참조).

유형 ②: 반복 한도 (max_iterations)

섹션 제목: “유형 ②: 반복 한도 (max_iterations)”

어떤 상황에서도 반드시 루프를 멈추게 하는 하드 안전망이다.

# 개념 이해용 의사 코드이며 실제 API와 다를 수 있습니다.
MAX_ITERATIONS = 30
for iteration in range(MAX_ITERATIONS):
response = model.generate(messages, tools=tools)
if response.stop_reason == "end_turn":
return {"status": "success", "result": response.content}
for call in response.tool_calls:
result = run_tool(call)
messages.append(tool_result(result))
# 최대 반복 수 도달 시
return {"status": "max_iterations_exceeded", "partial": get_current_state()}

max_iterations를 얼마로 설정할지는 태스크 복잡도에 따라 다르다. 단순 QA는 5회, 복잡한 코딩 에이전트는 50~100회가 적정할 수 있다. 너무 낮으면 정상 작업이 중단되고, 너무 높으면 안전망 역할을 못 한다.

자원 유형 상한 예시 초과 시 처리
입력 토큰 수 200,000 토큰 compaction 또는 강제 종료
누적 비용 $5.00 USD 작업 중단 + 사용자 알림
실행 시간 30분 wall-clock 타임아웃 종료
출력 토큰 수 10,000 토큰/호출 응답 잘림 방지 처리

자원 상한은 특히 **예측 불가능한 반복(open-ended loops)**에서 필수다. 버그를 찾아서 고치는 에이전트가 해결할 수 없는 버그를 만나면 무한정 도구를 호출할 수 있다. 시간 또는 비용 상한이 없으면 사용자가 인지하기 전에 큰 비용이 발생한다.

유형 ④: 무진전 감지 (No-progress Detection)

섹션 제목: “유형 ④: 무진전 감지 (No-progress Detection)”

가장 미묘한 종료 조건이다. 루프가 돌고 있지만 실질적인 진전이 없는 상태를 감지한다.

가장 단순한 구현은 출력 해시 비교다. 연속 두 이터레이션의 모델 출력(또는 코드베이스 상태)이 동일하면 루프가 제자리를 맴돌고 있다는 신호다.

# 개념 이해용 의사 코드이며 실제 API와 다를 수 있습니다.
import hashlib
def detect_no_progress(response_history: list, window: int = 3) -> bool:
"""
최근 N개 응답의 해시가 모두 동일하면 무진전으로 판단.
"""
if len(response_history) < window:
return False
recent = response_history[-window:]
hashes = [
hashlib.md5(str(r).encode()).hexdigest()
for r in recent
]
return len(set(hashes)) == 1 # 모두 동일 = 제자리걸음
# 루프에서 사용
response_history = []
for iteration in range(MAX_ITERATIONS):
response = model.generate(messages, tools=tools)
response_history.append(response.content)
if detect_no_progress(response_history, window=3):
return {"status": "no_progress", "last_state": get_current_state()}
if response.stop_reason == "end_turn":
return {"status": "success", "result": response.content}
# ...

무진전 감지는 해시 비교 외에 더 정교한 방법도 있다. 완료된 하위 작업 목록이 늘어나지 않는지, 수정된 파일 수가 증가하지 않는지, 테스트 통과율이 개선되지 않는지 등을 확인하는 도메인 특화 지표를 활용할 수 있다.

네 가지 조건을 모두 포함한 루프

섹션 제목: “네 가지 조건을 모두 포함한 루프”
# 개념 이해용 의사 코드이며 실제 API와 다를 수 있습니다.
def run_safe_loop(task: str, tools: list,
max_iters: int = 30,
max_tokens: int = 200_000,
max_cost_usd: float = 5.0,
timeout_sec: int = 1800) -> dict:
messages = [{"role": "user", "content": task}]
response_history = []
start_time = now()
total_tokens = 0
total_cost = 0.0
for i in range(max_iters):
# ③ 자원 상한 확인
if total_tokens >= max_tokens:
return {"status": "token_limit_exceeded"}
if total_cost >= max_cost_usd:
return {"status": "cost_limit_exceeded"}
if elapsed(start_time) >= timeout_sec:
return {"status": "timeout"}
response = model.generate(messages, tools=tools)
total_tokens += response.usage.total_tokens
total_cost += response.usage.cost_usd
response_history.append(response.content)
# ① 의미적 완료
if response.stop_reason == "end_turn":
return {"status": "success", "result": response.content}
# ④ 무진전 감지
if detect_no_progress(response_history, window=3):
return {"status": "no_progress"}
for call in response.tool_calls:
result = run_tool(call)
messages.append(tool_result(result))
# ② 반복 한도 (for loop 종료)
return {"status": "max_iterations_exceeded"}

Simon Willison은 “아무것도 없는 것보다 멈추는 것이 낫다”고 말했다. 루프가 실패를 선언하면 재시작하거나 사용자에게 도움을 요청할 수 있다. 멈추지 않는 루프는 아무것도 할 수 없다. 종료 조건은 루프의 안전 밸브이자 자원 보호막이다.

참고 자료