왜 문제가 되는가
LLM 에이전트는 도구 호출과 추론을 반복하며 목표에 도달한다. 문제는 이 반복에 자연스러운 종료 조건이 없다는 점이다. 도구가 빈 결과를 반환하거나, 모델이 같은 판단을 되풀이하거나, 실패한 호출을 계속 재시도하면 루프는 끝나지 않는다. 실무에서 이 상황은 세 가지 비용으로 돌아온다. 토큰 과금 폭증, 다운스트림 API의 레이트리밋 소진, 그리고 사용자 응답 지연이다. 특히 자율성이 높은 에이전트일수록 "언제 멈춰야 하는가"를 모델의 자기 판단에만 맡기면 안 된다. 종료는 런타임이 강제하는 안전장치여야 한다.
스텝 예산의 세 가지 축
단일 카운터로는 부족하다. 다음 세 축을 독립적으로 걸어야 한다.
| 축 | 제한 대상 | 초과 시 동작 |
|---|---|---|
| 스텝 수 | 추론-도구 호출 사이클 횟수 | 강제 종료 후 부분 결과 반환 |
| 토큰 예산 | 누적 입력·출력 토큰 | 요약 압축 또는 종료 |
| 벽시계 시간 | 전체 실행 시간 | 타임아웃 예외 |
세 축을 함께 두는 이유는 실패 모드가 다르기 때문이다. 스텝은 적지만 매 스텝이 거대한 컨텍스트를 물고 오는 경우가 있고, 반대로 짧은 호출을 수백 번 반복하는 경우도 있다.
기본 예산 루프 구현
가장 먼저 스텝·토큰·시간을 한곳에서 강제하는 실행 루프를 둔다.
import time
class BudgetExceeded(Exception):
pass
def run_agent(agent, task, max_steps=20, max_tokens=120_000, max_seconds=90):
started = time.monotonic()
used_tokens = 0
for step in range(max_steps):
if time.monotonic() - started > max_seconds:
raise BudgetExceeded(f"time limit at step {step}")
if used_tokens > max_tokens:
raise BudgetExceeded(f"token limit at step {step}")
resp = agent.step(task)
used_tokens += resp.usage.input_tokens + resp.usage.output_tokens
if resp.is_final:
return resp.output
# 예산 소진 시에도 지금까지의 진행분을 돌려준다
return agent.summarize_partial()
핵심은 예산 초과가 크래시가 아니라 "부분 결과 반환"으로 이어져야 한다는 점이다. 사용자에게 아무것도 주지 못하는 실패가 가장 나쁘다.
진짜 무한루프: 반복 상태 탐지
스텝 상한은 낭비를 막지만 근본 원인인 "제자리걸음"은 못 잡는다. 모델이 동일한 도구를 동일한 인자로 반복 호출하는 패턴이 대표적이다. 이럴 때는 호출의 지문(fingerprint)을 해싱해 최근 이력과 비교한다.
import hashlib
from collections import deque
class LoopGuard:
def __init__(self, window=6, repeat_threshold=3):
self.history = deque(maxlen=window)
self.threshold = repeat_threshold
def check(self, tool_name, args):
key = hashlib.sha1(
f"{tool_name}:{sorted(args.items())}".encode()
).hexdigest()
self.history.append(key)
if self.history.count(key) >= self.threshold:
raise BudgetExceeded(f"repeated call: {tool_name}")
return key
임계값을 3 정도로 두면 정당한 재시도(일시적 네트워크 오류 등)는 허용하되, 무의미한 반복은 차단할 수 있다. 인자를 정규화(정렬·소문자화)해야 오탐을 줄인다.
예산 소진 직전의 우아한 축소
예산의 80%를 넘긴 시점에는 곧바로 끊지 말고 모드를 전환하는 편이 낫다. 남은 스텝에서는 새 도구 탐색을 금지하고 "지금까지 수집한 정보로 결론을 내라"는 지시를 주입한다. 이렇게 하면 강제 종료보다 훨씬 쓸모 있는 답이 나온다.
def budget_pressure(step, max_steps):
ratio = step / max_steps
if ratio >= 0.8:
return "남은 예산이 부족하다. 새 조회 없이 현재 정보로 최종 답을 작성하라."
return None
운영 관점의 설정 분리
예산 값을 코드에 하드코딩하면 튜닝이 어렵다. 태스크 유형별로 외부 설정에 두고, 배포 없이 조정할 수 있게 한다.
agent_budgets:
quick_qa: { max_steps: 6, max_tokens: 30000, max_seconds: 20 }
research: { max_steps: 25, max_tokens: 200000, max_seconds: 120 }
code_fix: { max_steps: 15, max_tokens: 120000, max_seconds: 90 }
loop_guard:
window: 6
repeat_threshold: 3
또한 모든 종료 사유(정상 완료, 스텝 초과, 반복 탐지, 타임아웃)를 구조화 로그로 남겨야 한다. 어떤 사유가 지배적인지 알아야 상한을 올릴지, 프롬프트를 고칠지 판단할 수 있다.
주의점
첫째, 상한을 너무 낮게 잡으면 정상 태스크가 잘려 품질 저하로 위장된다. 실측 분포의 상위 95퍼센타일을 기준으로 여유를 두고 설정한다. 둘째, 반복 탐지는 오탐 가능성이 있으므로 차단 전에 반드시 로그를 남기고, 초기에는 차단 대신 경고로 운영하며 임계값을 보정한다. 셋째, 중첩 에이전트(서브에이전트) 구조에서는 예산이 상위에서 하위로 배분·차감되어야 한다. 하위가 각자 전체 예산을 쓰면 합산 비용이 통제 불능이 된다. 예산은 개별 호출이 아니라 요청 트리 전체를 기준으로 관리하는 것이 원칙이다.