에이전트가 같은 도구를 무한 호출하며 토큰만 태웁니다. 루프 폭주를 어떻게 끊나요?
사내 운영툴에 LLM 에이전트(관찰→도구호출→관찰 반복)를 붙였는데, 실제 사용자 요청에서 가끔 search_orders 같은 도구를 거의 동일한 인자로 8~12번 연속 호출하다가 결국 컨텍스트가 차서 엉뚱한 답을 냅니다.
- 구조: 직접 만든 while 루프 안에서 모델 응답에 tool_calls가 있으면 실행→결과를 메시지에 append→다시 모델 호출
- 데모/짧은 태스크에선 멀쩡한데, 실사용 로그를 보면 한 요청에 도구호출 수십 회 + 토큰 8만+ 가 찍힌 케이스가 있음 (서울 리전 API, 한글 입력이라 토큰이 영어 대비 1.5~2배라 비용 체감이 큼)
max_steps같은 가드를 안 두고 "모델이 알아서 멈추겠지" 했던 게 문제 같은데, 단순히 step 상한만 걸면 상한에서 잘린 미완성 답이 나갑니다.
진짜 운영에서 루프 폭주를 막으려면 어떤 가드를 어떤 순서로 걸어야 하나요?
답변 1개
- 채택된 답변에디터 검증
결론부터: step 상한 하나로는 부족하고, ①예산 가드(step+토큰) ②반복 도구호출 탐지(loop detection) ③상한 도달 시 "강제 종료" 대신 "마무리 1턴" 을 세트로 걸어야 합니다. 상한만 걸면 상한에서 그냥 잘려 미완성 답이 나가는 게 정상입니다 — 그게 지금 증상이에요.
왜 폭주하나 (메커니즘)
도구 결과가 모델이 기대한 형태와 미묘하게 다르면(빈 배열, 에러 메시지, 페이징 안 된 부분 결과) 모델은 "한 번 더 시도"를 합리적 행동으로 선택합니다. 종료조건을 모델 판단에만 맡기면, 같은 막다른 골목을 반복하면서 매 턴 누적 컨텍스트가 늘고, 컨텍스트가 오염·소진되며, 답 품질이 더 나빠지는 양의 피드백이 돕니다. 그래서 하네스(모델 바깥 골격)가 강제로 끊어야 합니다.
1. 예산 가드 — step과 토큰을 동시에
step만 세면 한 step이 거대 컨텍스트일 때 비용이 폭발합니다. 둘 다 세세요.
MAX_STEPS = 8 # 운영 태스크 관측치 기반: 정상은 대개 3~5 MAX_TOOL_TOKENS = 60_000 step = 0 tool_token_budget = MAX_TOOL_TOKENS recent_calls = [] # (name, args_hash) 최근 호출 기록 while True: step += 1 resp = client.chat(messages=messages, tools=tools) calls = resp.tool_calls or [] if not calls: break # 모델이 자연 종료 = 정상 경로 if step >= MAX_STEPS or tool_token_budget <= 0: # 강제 종료가 아니라 '마무리 지시'를 주입 messages.append({"role": "user", "content": "도구 호출 예산을 모두 사용했습니다. 추가 도구 호출 없이, 지금까지 확보한 정보만으로 최종 답을 작성하고, 부족한 부분은 명시하세요."}) final = client.chat(messages=messages, tools=[]) # tools 비워 도구호출 차단 return final.content핵심은 마지막 줄
tools=[]— 도구 스키마를 빼면 모델이 구조적으로 도구를 못 부르므로 깔끔히 한 턴에 마무리합니다. "멈추라"는 프롬프트만으로는 또 도구를 부르는 경우가 흔합니다.2. 반복 도구호출 탐지 (이게 실제 폭주의 대부분)
step 상한에 닿기 전에, 동일·유사 호출 N회 반복을 직접 끊는 게 비용·지연 둘 다 가장 크게 줄입니다.
import hashlib, json def call_sig(c): return hashlib.md5(f"{c.name}:{json.dumps(c.args, sort_keys=True, ensure_ascii=False)}".encode()).hexdigest() sig = call_sig(calls[0]) recent_calls.append(sig) if recent_calls[-3:].count(sig) >= 3: # 같은 인자 3연속 messages.append({"role": "tool", "tool_call_id": calls[0].id, "content": "동일한 조회를 반복했습니다. 이 경로로는 더 진전이 없습니다. 다른 접근을 쓰거나 현재 정보로 마무리하세요."}) continue인자를 약간씩 바꿔가며 도는 경우(예: page를 1→2→3…)는 정상일 수 있으니,
name만으로 끊지 말고name+args 해시로 판별하세요.3. 직접 만들지 말지의 판단
위 가드가 부담되면 종료·예산 처리가 내장된 런타임을 쓰는 게 낫습니다. OpenAI Agents SDK(Python)는
Runner.run(..., max_turns=N)으로 상한을 강제하고 초과 시MaxTurnsExceeded예외를 던집니다(상한에서 조용히 잘리지 않고 명시적으로 터지므로 운영에서 잡기 쉬움). 입력·출력 guardrail이 tripwire를 걸면InputGuardrailTripwireTriggered·OutputGuardrailTripwireTriggered로 즉시 중단됩니다. 다만 "상한 초과 = 예외"이지 "마무리 답 생성"이 아니므로, 예외를 잡아 위 1번처럼tools없는 한 턴을 따로 돌려 사용자에게 보낼 답을 만들어야 합니다.국내·실무 함정
- 한글 토큰 폭이 가드값을 왜곡: 토큰 예산 6만은 영어 기준 감각이고, 한글 입력+한글 도구결과면 같은 정보량이 1.5~2배 토큰입니다. 예산은 "문자 수"가 아니라 실제 usage 토큰으로 재고, 서울 리전/원화 청구서로 한 번 역산해 상한을 정하세요.
- 도구결과 무음 실패가 루프를 유발: 도구가 빈 배열·부분결과를 에러 없이 돌려주면 모델이 "재시도"로 해석해 폭주합니다. 도구 응답에
{"status":"empty","reason":"…"}처럼 상태를 명시해 모델이 재시도 대신 종료를 택하게 하세요. - 단일 결정적 호출로 끝나는 작업(번역 한 번, 분류 한 번)이라면 애초에 루프형 하네스를 만들지 마세요. 가드·재시도·파싱 오버헤드만 늘고 폭주 위험만 생깁니다.
출처: SWE-agent ReAct 루프/스캐폴딩 (https://www.emergentmind.com/topics/swe-agent-scaffold), OpenAI Agents SDK 실행·가드레일 (https://openai.github.io/openai-agents-python/running_agents/, https://openai.github.io/openai-agents-python/guardrails/).