- 책: AI 에이전트 포켓 가이드: LLMs로 자율 시스템을 구축하는 패턴
- 나의 다른 책: Go로 사고하기 (2책 시리즈) — Go 프로그래밍 완전 가이드 + Go에서의 헥사곤 아키텍처
- 나의 프로젝트: 헬멘스 IDE |GitHub — 개발자들이 Claude Code와 다른 AI 코딩 도구와 함께 출시하는 데 사용되는 IDE
- 나: xgabriel.com | GitHub
$47K 루프
A LangChain 사용자가 한 주말에 약 4만 7천 달러를 탕진했습니다. 그 이유는 그들의 에이전트가 한 도구 호출을 계속 반복했기 때문입니다. 이 이야기는 2023년에 트위터와 HN에서 회자되었고, 실패의 모양은 여전히 변하지 않았습니다. 에이전트는 동일한 검색 도구를, 동일한 인자를 반복해서 호출했고, 프레임워크는 기쁨으로 모든 결과를 다음 프롬프트에 다시 넣고 각 라운드마다 청구했습니다.
추적을 통해 10초면 볼 수 있습니다. 연속 47개의 시퀀스, 같은 tool_name, 같은 args 페이로드, 다른 타임스탬프입니다. 아무도 그렇게 쓰지 않습니다. 아무 모델도 그렇게 쓰고 싶지 않습니다. 하지만 도구를 사용하는 에이전트를 흐릿한 질문과 약간 고장 난 도구 앞에 두면 같은 호출을 반복해서 반복해서 반복해 그것을 멈추게 할 것입니다.
사라져야 할 것을 살리는 것은 20줄의 파이썬 코드입니다. 이것은 에이전트에 있지 않습니다. 이것은 추적 파이프라인에 있으므로 프레임워크 교체, 모델 업그레이드, 그리고 금요일 오후 4시에 팀이 하는 다음 리팩토링을 생존합니다.
왜 max_iterations는 잘못된 조절기입니다
구글의 첫 페이지에서 받는 조언은 "설정하세요"입니다max_iterations=10". 이것은 고속도로에 대한 주거지역의 속도 제한이 잘못되었던 동일한 이유로 잘못되었습니다. 이는 합법적인 작업을 처벌합니다.
두 에이전트가 동일한 제품에서 실행 중인 경우를 고려해 보세요.
에이전트 A는 심층 연구 보조원입니다. PDF를 가져오고 검색을 실행하며 요약하고, 세 가지 인용문을 따르고, 세 번 더 검색을 실행하며, 발견 결과를 중복 제거하고, 메모를 작성합니다. 80개의 도구 호출, 모두 다르고, 모두 유용합니다. 사용자는 그 깊이를 위해 지불했습니다.
에이전트 B는 불안정한 벡터 인덱스를 가진 질문-답변기입니다. 쿼리 #1에서 search_docs(query="refund policy")을 호출합니다. 결과는 최신 임베딩이 없어 비어 있습니다. 에이전트는 "다시 시도해야겠다"고 생각하고 search_docs(query="refund policy")을 두 번째로 호출합니다. 그 다음 세 번째로. 7단계까지 이르러서면 동일한 도구를 정확히 동일한 인자로 일곱 번 연속으로 호출하고 있습니다.
10번째 자르기에서는 에이전트 A를 완료하기 전에 끊고, 에이전트 B는 네 번째 반복까지 불타고 나서야 멈춘다. 반대를 원한다: 에이전트 A는 진전이 있을 때까지 계속 실행되고, 에이전트 B는 4번째 반복에서 죽는다. 반복이 신호이지, 깊이는 아니다.
20줄 안의 감지기
여기 있다. 슬라이딩-윈도우 카운터는 (tool_name, args_hash)에 대한 키로 연결된다. 모든 도구 호출을 밀어 넣다. 어떤 키가 나타나면threshold지난 시간 동안window전화를 걸다, 일으키다.
from collections import deque, Counter
from dataclasses import dataclass, field
import hashlib
import json
class LoopDetected(Exception):
pass
@dataclass
class LoopDetector:
window: int = 10
threshold: int = 4
_calls: deque = field(default_factory=deque)
def observe(self, tool_name: str, args: dict) -> None:
key = (tool_name, _args_hash(args))
self._calls.append(key)
if len(self._calls) > self.window:
self._calls.popleft()
counts = Counter(self._calls)
most_common_key, hits = counts.most_common(1)[0]
if hits >= self.threshold:
raise LoopDetected(
f"{most_common_key[0]} called {hits}x "
f"in last {len(self._calls)} steps"
)
def _args_hash(args: dict) -> str:
canonical = json.dumps(_canonicalize(args), sort_keys=True)
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
_VOLATILE_KEYS = {
"timestamp", "request_id", "trace_id", "span_id",
"nonce", "now", "_ts", "correlation_id",
}
def _canonicalize(value):
# strip keys that change every call but don't change intent
if isinstance(value, dict):
return {
k: _canonicalize(v)
for k, v in value.items()
if k not in _VOLATILE_KEYS
}
if isinstance(value, list):
return [_canonicalize(v) for v in value]
return value
그것이 전체 감지기입니다. 가져오기(imports)를 어떻게 세느냐에 따라 약 스물 줄 정도입니다. 넣고 호출(call)하세요observe()모든 도구 호출 후에 포착하다LoopDetected, 유용한 일을 해보세요.
해시가 16개의 16진수 문자로 잘려나갑니다. 충돌은 여기서는 중요하지 않습니다. 잘못된 긍정(두 개의 다른 호출이 동일한 해시를 생성하는 경우)은 본래의 루프가 실제가 아니기 때문에 비용이 들지 않으며 다음 정당한 호출이 패턴을 깨뜨립니다. 잘못된 부정(해시가 충돌하여 실제 루프가 통과하는 경우)은 10번의 호출 창 동안 16개의 16진수 문자로서 통계적으로 무관합니다.
어디에 넣을까요
세 가지 옵션이 있습니다. 가장 나쁜 순서대로 나열되어 있습니다.
에이전트 루프 안에. LoopDetector 를 에이전트 러너에 가져와 호출합니다.observe() 각 도구 호출 후. 쉽다. 하지만 취약하다. LangChain을 LangGraph로 바꾸거나 분기 중에 다른 프레임워크로 이동한 날, 감지기는 오래된 코드와 함께 간다. 또한 새로운 에이전트를 모든 곳에 장비화해야 한다. 팀이 급하게 출시하는 세 번째 에이전트는 이를 갖지 못할 것이다.
프레임워크 콜백. LangChain은BaseCallbackHandler은 LangGraph에 노드 훅이 있고, OpenAI의 Agents SDK에는 라이프사이클 이벤트가 있습니다. observe()을 호출하는 하나의 콜백을 작성합니다. 인라인보다 좋습니다. 여전히 프레임워크에 특화되어 있습니다. 여전히 교체하면 사라집니다.
OTel span exporter. 이곳이 그것이 속해야 할 위치입니다. 이미 트레이스가 exporter를 통해 흐르고 있습니다. 추가하면SpanProcessor는 도구 호출 범위를 모니터링하고 그 위에서 감지기를 실행합니다. 프레임워크에 독립적입니다. 잊어서는 안 됩니다. 오늘 출시되었든 지난 분기에 출시되었든 여러분의 함선에 있는 모든 에이전트를 포착합니다.
배치는 다음과 같습니다.
from opentelemetry.sdk.trace import SpanProcessor
from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
)
from collections import defaultdict
class LoopDetectingProcessor(SpanProcessor):
def __init__(self, inner: SpanProcessor):
self.inner = inner
# one detector per trace_id
self._detectors = defaultdict(LoopDetector)
def on_start(self, span, parent_context=None):
self.inner.on_start(span, parent_context)
def on_end(self, span) -> None:
# GenAI semconv name for a tool invocation
if span.name == "execute_tool":
attrs = span.attributes or {}
tool_name = attrs.get(
"gen_ai.tool.name", "unknown"
)
# tool args often live under gen_ai.tool.call.arguments
raw_args = attrs.get(
"gen_ai.tool.call.arguments", "{}"
)
try:
args = json.loads(raw_args)
except (TypeError, ValueError):
args = {"_raw": str(raw_args)}
trace_id = format(span.context.trace_id, "032x")
detector = self._detectors[trace_id]
try:
detector.observe(tool_name, args)
except LoopDetected as exc:
span.set_attribute("loop.detected", True)
span.set_attribute("loop.reason", str(exc))
# signal the agent runtime via your own channel:
# Redis pub/sub, a kill flag in DB, etc.
self.inner.on_end(span)
def shutdown(self):
self.inner.shutdown()
def force_flush(self, timeout_millis=30000):
return self.inner.force_flush(timeout_millis)
기존의 내보내기 도구를 감싸서 추적 제공자에 등록합니다. 이제 감지기는 플랫폼이 실행하는 모든 에이전트에서 발생하는 모든 도구 범위를 볼 수 있습니다. 속성 이름은 OpenTelemetry GenAI 의미적 규범(gen_ai.tool.name, gen_ai.tool.call.arguments)을 따르므로 이 코드는 해당 범위를 발생시키는 모든 것과 작동합니다.
튜닝 창과 임계값
실제 운영에서 유효한 기본값: window=10threshold=4.
사유. 잘 행동하는 ReAct 에이전트가 첫 결과가 불분명하다고 도구를 다시 방문할 때, 두 번을 때릴 수 있으며, 약간 다른 인자를 사용하여 세 번을 때릴 수도 있습니다. 10단계에 동일한 호출이 네 번이면 탐색하고 있지 않습니다. 멈춰 있습니다. 임계값을 3으로 밀어 넣으면 한 단계 일찍 루프를 포착하지만, 일부 정당한 재시도를 표시합니다. 5로 밀어 넣으면 루프당 한 번 더 낭비된 호출을 허용하여, GPT-4 클래스 토큰 비용으로는 실제 돈입니다.
만약 당신의 에이전트에 지수적 백오프가 내장되어 있다면 (호출, 대기, 다시 호출, 더 길게 대기), 창을 15-20으로 넓히고 임계값을 4로 유지하세요. 백오프는 반복을 더 많은 단계에 걸쳐 늘려주기 때문에, 넓은 창은 정당한 재시도에 대해 과도하게 반응하지 않고 그것을 포착할 수 있습니다.
만약 당신의 도구 카탈로그가 작다면 (3-5개 도구) 그리고 에이전트가 정당하게 많은 도구를 다시 방문한다면, 예를 들어 read_file 코드 에이전트에서처럼search_web는 연구 에이전트에서 (tool_name, args_hash)에 중점을 두지 말고 tool_name에만 집중해야 합니다. 인자 해시는 "search_web을 8번 다른 쿼리로 호출" (괜찮음)과 "search_web을 8번 동일한 쿼리로 호출" (불량)을 구분하는 것입니다.
탐지 시 해야 할 일
세 가지 옵션, 에이전트를 얼마나 신뢰하는지에 따라 증가하는 순서로.
Killswitch. 기본값. 예외를 발생시키고, 루프를 로그에 기록하고, 호출자에게 구조화된 오류를 반환합니다. 저렴하고 안전합니다. 사용자가 다시 시도합니다.
경고 메시지와 함께 강화합니다. 시스템 메시지를 주입합니다: "동일한 인자로 search_docs를 네 번 호출했습니다. 도구는 동일한 결과를 반환하고 있습니다. 다른 접근 방식을 시도하거나 중지하고 발견한 내용을 보고하세요." 모델이 일반적으로 터질 때가 있습니다. 때로는 그렇지 않고 그 다음 관찰 시 killswitch가 작동합니다.
페이지 상태입니다.루프가 실제 장애를 의미하는 에이전트에 대해 (예: 사용자 재시도가 없는 내부 자율 도구와 같은 경우) LoopDetected를 PagerDuty에 연결합니다. 드물지만, 루프를 절대로 하면 안 되는 에이전트에 대해서는 페이지가 올바른 형태입니다.
쿨리프트 스위치부터 시작하세요. 복구 가능한 루프 정보를 얻었을 때만 다운그레이드 경고 후 진행하세요.
두 가지 경계 사례가 문제를 일으킵니다
비결정적 인자. 도구 인자에 타임스탬프, 요청 ID, 또는 nonce가 포함되어 있으면 해시가 매 호출마다 다르게 나뉩니다. 위의 정규화기는 일정한 유동 키 세트(timestamp, request_id)를 제거합니다trace_id , span_id , nonce , now , _ts , correlation_id 에 전환하기 전에 추가하세요. 자신의 도구 스키마에서 새로운 변동 필드를 만나면 이 집합에 추가하세요. created_at: <now> 를 인수로 끼워 넣는 에이전트는 다른 방법으로는 잡지 못할 에이전트입니다.
스트리밍 도구 호출. 일부 프레임워크는 도구 호출이 여전히 실행 중일 때 일부 스팬을 발생시킵니다. gen_ai.tool.call.id를 가진 스팬만 필터링하고 호출이 여전히 스트리밍 중인 경우를 무시하세요. 그렇지 않으면 하나의 느린 도구 호출을 여러 관찰로 계산하여 스스로 잘못된 긍정을 당하게 될 수 있습니다.
이가 당신의 스택에서 어디에 위치하는지
이 감지기는 생산 에이전트가 가져야 할 세 가지 런타임 가드 중 하나입니다.
토큰 예산. 각 에이전트 호출당 누적 입력 + 출력 토큰에 대한 엄격한 제한. 루프 탐지가 놓치는 "프롬프트가 200K 토큰으로 커졌다"는 실패 모드를 포착합니다.
루프 감지기. 이 포스트에 언급된 것. 고정된 반복을 포착합니다.
목표 완료 검증기. 마지막에 별도의 작은 LLM 호출을 통해 "이 에이전트가 사용자의 요청을 실제로 수행했는지, 아니면 포인트를 놓치는 자신감 있는 출력을 생성했는지"를 확인합니다. 첫 두 가지를 놓치는 "30단계 동안 실행되었지만 쓰레기가 생성되었음" 오류를 포착합니다.
트레이스 파이프라인에서 모든 세 가지를 실행하세요, 에이전트 안에서는 안 됩니다. 에이전트는 신뢰할 수 없는 부분입니다. 파이프라인이 경비가 있는 곳입니다.
가장 나쁜 에이전트 루프를 생산 환경으로 배포한 적이 있나요? 트레이스를 댓글에 올려주세요. 누군가 47을 깨뜨렸는지 알고 싶어요.
이것이 유용했다면
런타임 가드 삼각형(토큰 예산, 루프 감지기, 목표 검증기)은 AI 에이전트 포켓 가이드: LLMs로 자율 시스템을 구축하는 패턴들 중 하나입니다.는 나머지 생산 체크리스트를 다루고 있습니다: 도구 카탈로그 규율, 하위 대리인 경계, 재생 및 이동 감지, 그리고 모든 것을 관찰할 수 있게 하는 추적 레이어 인스트루먼트입니다. 대리인을 배포하고 하나의 장소에서 패턴을 제시하고 싶다면 그 책입니다.











