tool-calling 에이전트가 존재하지 않는 함수를 반복 호출하며 같은 실패를 되풀이합니다. 무한 재시도를 막는 루프는 어떻게 설계해야 할까요?
tool-calling 방식으로 에이전트를 만들고 있는데, 모델이 정의하지 않은 함수명을 호출해 실패하면 그다음 턴에서도 똑같은 함수를 또 부릅니다. 단순히 예외를 잡아 재시도(try/except로 다시 호출)만 걸어놨더니 같은 호출을 max_retries까지 그대로 반복하다 끝나버립니다. 백오프도 넣어봤지만 없는 함수라 몇 초 기다린다고 달라지는 게 없더군요.
실패했을 때 모델이 스스로 다른 접근을 하도록 유도하려면 루프를 어떻게 구성해야 할까요? 그리고 어떤 실패는 재시도하고 어떤 실패는 하지 말아야 하는지 기준도 궁금합니다.
답변 1개
- 채택된 답변
무한 반복의 원인은 재시도 자체가 아니라 같은 컨텍스트로 같은 호출을 다시 시킨 데 있습니다. 실패를 삼키고 그대로 다시 부르면 모델 입력이 안 바뀌고, 입력이 같으면 출력도 같습니다. 순서대로 설계하면 잡힙니다.
- 실패를 구조화해 관측: 예외를 삼키지 말고
{error, message, available}형태로 만들어 tool_result로 되돌립니다. 자연어 "에러남"으로 던졌을 때보다 파싱 가능한 신호로 던졌을 때 다음 턴의 교정률이 눈에 띄게 높았습니다. - 에러를 컨텍스트에 되먹임: 그 결과를 messages에 push해야 다음 턴이 실패를 봅니다. 이게 빠지면 반성 프롬프트를 넣어도 소용없습니다.
- 반복 시그니처 감지로 무한 루프 차단:
hash(name+args)카운트가 임계(예: 3)를 넘으면 실행하지 말고 강제 중단 신호를 되먹입니다. 전체 step 상한만 두면 같은 호출로 상한을 다 태웁니다. - 툴 화이트리스트: 레지스트리에 없는 이름은 실행 전 거부합니다. tools 스키마를 줘도 모델은 없는 함수명을 환각합니다.
for (let s = 0; s < MAX; s++) { const c = await llm(messages, { tools }); if (!c.tool) return c.text; if (!reg.has(c.tool.name)) { // 환각 차단 push(err(c.id, "unknown_tool", [...reg.keys()])); continue; } const sig = hash(c.tool.name + JSON.stringify(c.tool.args)); seen[sig] = (seen[sig] ?? 0) + 1; if (seen[sig] >= 3) { // 같은 호출 3회=실행 없이 중단 push(err(c.id, "repeated_failure", "다른 인자나 다른 도구로 접근하세요")); continue; } try { push(ok(c.id, await reg.get(c.tool.name)(c.tool.args))); delete seen[sig]; // 성공하면 카운터 리셋 } catch (e) { if (transient(e)) { await sleep(backoff(seen[sig])); continue; } push(err(c.id, e.code, e.message)); // 결정적 실패는 백오프 X } }핵심은 3번 분기가 임계 도달 시 tool 실행을 건너뛰고(
continue) 실패 신호만 되먹이는 부분입니다. 여기서continue를 빼면 "repeated_failure"를 알려주고도 같은 호출을 그대로 실행해 버려서 차단이 무의미해집니다.when-NOT: 백오프 재시도는 429·5xx·timeout 같은 일시적 실패 전용입니다. 없는 함수·400·401 같은 결정적 실패는 입력을 안 바꾸면 몇 번을 돌려도 결과가 같으니, 재시도가 아니라 반성·에스컬레이트 대상입니다. 반성 단계는 실패했을 때만 켜서 토큰과 지연을 아끼고, args-hash는 의미가 같고 문자열만 다른 반복은 못 잡으니 step 상한을 안전망으로 병행하세요.
- 실패를 구조화해 관측: 예외를 삼키지 말고