티스토리 뷰
LLM Agent는 왜 같은 Tool을 30번 호출했을까 1편: LangChain4j 기반 Agent의 Tool Loop 문제 발생과 근본 원인 분석
ebson 2026. 3. 20. 22:00사건의 시작
"RSS 피드 전체 수집해주세요."
관리자 페이지에서 이 한마디를 입력했을 뿐입니다. LangChain4j 기반으로 구현한 Agent가 등록된 RSS 소스에서 피드를 수집하고 결과를 요약해주는, 평소라면 20초 남짓이면 끝나는 작업이었습니다.
그런데 이번에는 달랐습니다. 서버 로그를 열어보니 collect_rss_feeds(provider=OPENAI)가 반복되고 있었습니다. 한 번, 두 번이 아니라 같은 provider에 대해 수십 회. 약 2분 30초가 지난 후 LangChain4j의 내부 제한에 걸려 다음과 같은 예외가 터졌습니다.
java.lang.RuntimeException: Something is wrong, exceeded 30 sequential tool invocations
프론트엔드에서는 SSE 연결이 먼저 끊겨 ClientAbortException: Broken pipe가 뒤따랐고, 사용자에게는 "Agent 호출 실패" 팝업만 표시되었습니다. 같은 날, 다른 요청에서도 비슷한 일이 벌어졌습니다. "OpenAI Python SDK 최신 릴리스 조회해주세요"라는 메시지에 fetch_github_releases(owner=openai, repo=openai-python)가 동일한 인자로 30회 연속 호출된 것입니다.
두 사례 모두 LLM이 Tool 호출을 스스로 중단하지 못하고 같은 동작을 반복하다 프레임워크의 최대 호출 횟수 제한에 걸려 강제 종료된 케이스였습니다. 이 글에서는 이 두 가지 사고를 분석하면서, LLM Agent 시스템에서 Tool Loop가 발생하는 근본 원인이 어디에 있었는지 정리합니다.
첫 번째 사고 - 수집 완료인데 왜 다시 수집하나
첫 번째 사고의 흐름을 서버 로그 기준으로 재구성하면 다음과 같습니다.
Agent는 시스템 프롬프트에 정의된 규칙에 따라 collect_rss_feeds(OPENAI), collect_rss_feeds(GOOGLE), collect_scraped_articles(ANTHROPIC), collect_scraped_articles(META) 순서로 각 provider별 수집을 정상적으로 완료했습니다. 여기까지는 의도한 동작이었습니다.
문제는 그 다음이었습니다. 4개 provider의 수집이 모두 끝났음에도 Agent는 결과를 요약하고 응답을 종료하는 대신, collect_rss_feeds(OPENAI)를 다시 호출했습니다. 이 호출의 결과는 new=0, duplicate=41이었습니다. 이미 수집한 피드이므로 신규 항목 없이 전부 중복 처리된 것입니다. 그런데 LLM은 이 결과를 보고 "작업이 아직 끝나지 않았다"고 판단한 것으로 보입니다. 동일한 호출이 반복되었고, 매번 new=0이라는 동일한 결과를 받았지만 LLM은 멈추지 않았습니다.
이 Tool에는 중복 호출을 감지하거나 차단하는 어떤 메커니즘도 없었습니다. 기존에 fetch_github_releases와 collect_github_releases 사이에는 방어 로직이 구현되어 있었습니다. collect로 수집이 완료된 저장소를 fetch로 다시 조회하면 BLOCKED 응답을 반환하고, 이런 차단이 3회를 초과하면 AgentLoopDetectedException을 던져 Agent를 graceful하게 종료시키는 구조였습니다. 하지만 collect_rss_feeds와 collect_scraped_articles에는 이 패턴이 적용되지 않은 상태였습니다. 어떤 provider로 수집을 완료했는지 추적하는 필드도 없었고, @Tool annotation의 description에도 "이미 수집한 provider를 다시 호출하지 말 것"이라는 힌트가 포함되어 있지 않았습니다.
결과적으로 이 사고는 "동일한 Tool에 대한 자기 반복 호출"을 추적하는 구조가 빠져 있었던 것이 직접적인 원인이었습니다.
두 번째 사고 - 에러인데 빈 결과로 보인 경우
두 번째 사고는 첫 번째와는 다른 양상이었습니다. fetch_github_releases(owner=openai, repo=openai-python)가 동일 인자로 30회 연속 호출되었는데, 이 경우에는 세 가지 원인이 복합적으로 작용했습니다.
먼저, fetch_github_releases 자체의 반복 호출을 추적하는 구조가 없었습니다. 기존 방어 로직은 collect 후 fetch 차단, 즉 "이미 수집까지 완료한 저장소를 다시 조회하는 것"만 막고 있었습니다. collect를 하지 않고 단순 조회만 반복하는 케이스는 몇 번이든 통과되는 구조였습니다.
두 번째 원인은 GitHubToolAdapter의 예외 처리 방식이었습니다. GitHub API 호출 중 403이나 429 같은 명시적 에러는 별도로 처리하고 있었지만, 그 외의 일반 예외(404, 네트워크 타임아웃 등)는 다음과 같이 처리되고 있었습니다.
} catch (Exception e) {
log.error("GitHub releases 조회 실패: ...", e);
return List.of();
}
예외를 로그에 남기고 빈 리스트를 반환하는 코드입니다. 서버 로그에는 에러가 찍히지만, 이 반환값을 받는 EmergingTechAgentTools는 빈 리스트를 "해당 저장소에 릴리스가 없다"는 정상 응답으로 처리합니다. 그리고 LLM에게는 "openai/openai-python 저장소에 릴리스가 없습니다."라는 텍스트가 전달됩니다.
LLM 입장에서 이 응답은 납득하기 어려운 것이었을 겁니다. openai-python은 릴리스가 수백 개 있는 활발한 저장소이기 때문입니다. LLM은 "릴리스가 없다니 이상하다, 다시 시도해보자"라고 판단하고 같은 호출을 반복한 것으로 보입니다. 물론 매번 같은 예외가 발생하고, 매번 같은 빈 리스트가 반환되고, 매번 LLM은 재시도를 결정하는 루프가 계속되었습니다.
세 번째 원인은 에러 발생 시 LLM에게 전달되는 메시지의 문구였습니다. ToolErrorHandlers에서 Tool 실행 실패 시 반환하는 메시지는 다음과 같았습니다.
"Tool '%s' 실행 실패: %s. 다른 방법을 시도해주세요."
"다른 방법을 시도해주세요"라는 문구는 개발자가 읽으면 "다른 접근법을 찾아라"는 의미이지만, LLM은 이를 "같은 Tool을 다시 호출해보라"로 해석할 수 있습니다. 실제로 LLM에게 주어진 Tool이 fetch_github_releases 하나뿐인 상황에서 "다른 방법"이란 같은 Tool을 다시 부르는 것 외에는 선택지가 없기 때문입니다.
이 사고의 루프 시퀀스를 정리하면 이렇습니다. LLM이 fetch_github_releases를 호출하면, Adapter에서 API 에러가 발생하고, 빈 리스트로 변환되어 "릴리스가 없습니다"가 반환됩니다. LLM은 이 결과가 이상하다고 판단하여 재시도합니다. 이 사이클이 30회 반복된 후 프레임워크의 제한에 걸려 종료된 것입니다.
LLM Agent에서 Tool 반환값이 갖는 의미
이 두 사고를 분석하면서 한 가지 분명해진 점이 있습니다. LLM Agent 시스템에서 Tool의 반환값은 단순한 데이터가 아니라 LLM의 다음 행동을 결정하는 신호라는 것입니다.
전통적인 소프트웨어에서 함수의 반환값은 호출자가 로직으로 처리합니다. 빈 리스트가 반환되면 "데이터 없음"으로 처리하고, 예외가 발생하면 catch 블록에서 복구하거나 상위로 전파합니다. 반환값의 의미는 코드에 의해 결정적으로 해석됩니다.
하지만 LLM Agent 시스템에서는 Tool의 반환값이 LLM의 "입력"이 됩니다. LLM은 이 텍스트를 읽고 다음에 무엇을 할지 확률적으로 판단합니다. 빈 리스트가 "데이터가 없다"를 의미하는지 "에러가 발생했다"를 의미하는지, LLM은 문맥으로 추론할 수밖에 없습니다. 그리고 이 추론이 틀릴 수 있다는 점이 핵심입니다.
첫 번째 사고에서는 new=0, duplicate=41이라는 성공 응답이 "작업 미완료" 신호로 해석되었습니다. 두 번째 사고에서는 예외가 빈 리스트로 변환되면서 에러 신호가 소실되었고, LLM은 "데이터 없음"과 "에러"를 구분할 수 없었습니다. 두 경우 모두 Tool이 반환한 값의 의미가 LLM에게 정확히 전달되지 않은 것이 문제였습니다.
이 관점에서 보면, Agent 시스템에서 Tool을 설계할 때 "이 반환값을 LLM이 어떻게 해석할 것인가"를 함께 고려해야 한다는 점을 알 수 있습니다. 예외를 삼켜서 빈 결과로 바꾸는 것은 전통적인 시스템에서는 graceful degradation일 수 있지만, LLM Agent 시스템에서는 잘못된 판단을 유도하는 원인이 됩니다.
근본 원인의 계층적 구조
두 사고의 근본 원인을 정리해보면, 문제가 단일 계층이 아니라 여러 계층에 걸쳐 있다는 것을 확인할 수 있었습니다.
첫 번째는 LLM의 판단 계층입니다. @Tool annotation의 description은 LLM이 Tool을 선택하고 사용하는 기준이 됩니다. 여기에 "수집이 완료되면 결과를 요약하고 종료하라"거나 "이미 조회한 대상을 다시 호출하지 말라"는 힌트가 없었기 때문에, LLM은 종료 시점을 판단할 기준이 부족했습니다. Tool의 기능 설명만 있고 사용 조건이나 제약이 명시되지 않으면, LLM은 Tool을 "쓸 수 있으니까 쓴다"는 판단을 내리기 쉽습니다.
두 번째는 에러 처리 계층입니다. Tool 실행이 실패했을 때 LLM에게 전달되는 메시지가 재시도를 유도하는 문구였습니다. "다른 방법을 시도해주세요"라는 표현은 LLM에게 동일 Tool 재호출의 근거를 제공합니다. 에러 메시지는 사람이 읽는 것이 아니라 LLM이 읽고 행동을 결정하는 입력이므로, "재시도하지 말고 다음 작업으로 진행하라"는 명확한 지시가 필요했습니다.
세 번째는 Adapter 계층입니다. 외부 API 호출 실패 시 예외를 빈 리스트나 기본값으로 변환하면, LLM은 에러가 발생했다는 사실 자체를 알 수 없습니다. 예외를 상위로 전파하여 ToolErrorHandlers가 LLM에게 에러를 명시적으로 전달해야, LLM이 "이 작업은 실패했으니 건너뛴다"는 올바른 판단을 내릴 수 있습니다.
이 세 가지 원인은 각각 독립적으로도 루프를 유발할 수 있지만, 실제로는 복합적으로 작용했습니다. 첫 번째 사고에서는 주로 LLM 판단 계층의 문제가, 두 번째 사고에서는 세 가지 모두가 동시에 작용했습니다.
LangChain4j의 Tool 호출 제한 - 안전장치인가, 실패의 증거인가
LangChain4j는 AiServices 빌더에서 Tool의 최대 연속 호출 횟수를 설정할 수 있도록 합니다. LangChain4j 공식 문서에 따르면 기본값이 설정되어 있으며, 이 값을 초과하면 RuntimeException이 발생합니다.
Something is wrong, exceeded {N} sequential tool invocations
이 제한이 존재하기 때문에 Agent가 무한히 루프를 돌지는 않습니다. 하지만 이 제한에 도달했다는 것 자체가 이미 문제가 발생했다는 의미입니다. 첫 번째 사고에서는 30회 동안 같은 RSS 소스에 대해 실제 HTTP 요청이 반복되었고, 두 번째 사고에서는 30회 동안 GitHub API가 호출되었습니다. API rate limit, 불필요한 네트워크 비용, 그리고 사용자 대기 시간 모두 낭비된 것입니다.
이 제한을 높이거나 낮추는 것은 근본적인 해결이 되지 않습니다. 값을 낮추면 정상적인 멀티스텝 작업도 중간에 잘릴 수 있고, 값을 높이면 루프 발생 시 더 많은 비용이 낭비됩니다. 제한값에 의존하지 않고, 애초에 루프가 발생하지 않도록 Tool 레벨에서 방어하는 것이 올바른 접근이라는 점을 이번 사고에서 확인할 수 있었습니다.
기존 방어가 있었는데 왜 확산되지 않았나
흥미로운 점은 fetch_github_releases에서 collect_github_releases로 이어지는 흐름에는 이미 방어 로직이 존재했다는 것입니다. ToolExecutionMetrics에 수집 완료된 저장소를 추적하는 collectedRepos 필드가 있었고, 이미 수집한 저장소를 fetch로 다시 조회하면 BLOCKED 응답을 반환하고, 차단이 3회를 초과하면 AgentLoopDetectedException을 발생시키는 구조였습니다.
이 패턴이 다른 Tool에 확산되지 않은 이유를 돌이켜보면, "이 방어가 필요한 Tool이 무엇인가"에 대한 체계적인 점검이 없었기 때문입니다. fetch → collect 간의 교차 호출 루프는 개발 시점에 이미 인지하고 방어했지만, 같은 Tool의 자기 반복 호출이나 다른 Tool 조합에서의 루프 가능성은 검토되지 않았습니다. collect_rss_feeds나 scrape_web_page가 LLM에 의해 반복 호출될 수 있다는 시나리오를 사전에 고려하지 못한 것입니다.
이번 사고를 통해, Agent에 새로운 Tool을 추가할 때는 "이 Tool이 동일 인자로 반복 호출되면 어떤 일이 일어나는가"를 반드시 검토해야 한다는 점을 다시 확인하게 되었습니다. LLM은 개발자가 의도한 흐름대로만 Tool을 사용하지 않습니다. 성공 응답을 받고도 같은 호출을 반복할 수 있고, 에러 상황에서 재시도를 포기하지 않을 수 있습니다.
마무리
이번에 겪은 두 가지 Tool Loop 사고를 정리하면, 원인은 LLM의 판단 오류처럼 보이지만 실제로는 Tool 설계와 에러 처리의 문제였습니다. LLM은 주어진 정보 안에서 최선의 판단을 내리려고 할 뿐이고, 그 정보가 부정확하거나 모호하면 잘못된 행동으로 이어집니다.
전통적인 소프트웨어 개발에서는 함수의 반환값이나 예외 처리를 "호출자 코드"의 관점에서 설계합니다. 하지만 LLM Agent 시스템에서는 호출자가 LLM이라는 점, 그리고 LLM은 코드가 아니라 텍스트를 기반으로 판단한다는 점을 고려해야 합니다. Tool의 description, 반환 메시지, 에러 문구 하나하나가 LLM의 다음 행동에 영향을 미칩니다.
다음 글에서는 이 문제를 해결하기 위해 적용한 3계층 방어 패턴, 즉 Tool 레벨의 멱등성 추적, @Tool description을 통한 프롬프트 방어, 그리고 에러 메시지 설계를 통한 시스템 방어에 대해 정리하겠습니다.
'TECH AND AI' 카테고리의 다른 글
| LLM 엔지니어링 용어의 계보 1편 - 프롬프트 엔지니어링 — 문장 쓰기가 엔지니어링이 된 출발점 (1) | 2026.06.13 |
|---|---|
| LLM Agent는 왜 같은 Tool을 30번 호출했을까 2편: LLM Agent의 Tool Loop를 막는 3계층 방어 패턴 (1) | 2026.03.20 |
| RAG 아키텍처 발전사 4편 - Agentic RAG: 에이전트가 파이프라인을 설계한다 (0) | 2026.03.06 |
| RAG 아키텍처 발전사 번외편 - Ontology-Enhanced RAG: 도메인 지식의 구조를 검색에 심다 (0) | 2026.03.06 |
| RAG 아키텍처 발전사 3편 - Modular / Self-Corrective RAG: 스스로 판단하고 교정하는 파이프라인 (0) | 2026.03.06 |
- Total
- Today
- Yesterday
- Redis 성능 개선
- 백엔드 성능 설계
- Initialization-on-Demand Holder Idiom
- DB 트랜잭션
- 백엔드 성능
- InterruptedException
- Java Performance
- Eager Initialization
- mybatis
- Cache Penetration
- 캐시 성능 비교
- 스레드 생명주기
- Cache Avalanche
- 캐시 장애
- 백엔드 성능 튜닝
- 동시성처리
- Spring Batch
- Cache Aside
- Redis 캐시 전략
- 캐시와 인덱스
- Enum 기반 싱글톤
- Redis vs DB
- Hot Key 문제
- DB 인덱스 성능
- 트랜잭션 관리
- spring batch 5
- Double-Checked Locking
- 트래픽 처리
- TTL 설계
- 백엔드 아키텍처
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
