- Published on
LangChain Tool Calling 무한루프 차단·검증법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 LangChain 기반 에이전트를 붙이면, 가장 먼저 마주치는 운영 이슈 중 하나가 Tool Calling 무한루프입니다. 모델이 같은 도구를 반복 호출하거나, 도구 결과를 잘못 해석해 다시 도구를 부르는 패턴이 누적되면 비용이 폭증하고 응답 지연이 길어지며, 최악의 경우 워커 스레드가 묶여 장애로 이어집니다.
이 글에서는 “모델이 실수해도 시스템이 안전하게 멈춘다”는 목표로, 무한루프를 차단하고 검증하는 방법을 설계 관점과 코드 관점에서 정리합니다. 핵심은 모델의 추론 품질에 의존하지 않고, 런타임에서 강제할 수 있는 안전장치를 여러 겹으로 두는 것입니다.
Tool Calling 무한루프가 생기는 전형적인 패턴
1) 종료 조건이 프롬프트에만 존재
프롬프트에 “작업이 끝나면 종료하라”를 써도, 모델은 컨텍스트가 길어지거나 도구 결과가 애매하면 계속 호출합니다. 즉, 종료 조건이 자연어 규칙 하나에만 있으면 방어가 약합니다.
2) 도구 결과 스키마가 불명확하거나 변동됨
도구가 문자열을 반환하고, 그 안에 상태가 섞여 있으면 모델이 “실패로 오해”하거나 “추가 정보가 필요하다고 판단”해 재호출합니다.
3) 같은 입력으로 같은 도구를 반복 호출
예를 들어 search(query="...") 를 여러 번 호출하는데 query가 거의 동일합니다. 이 경우 중복 호출 감지만으로도 비용을 크게 줄일 수 있습니다.
4) 모델이 도구 호출을 “대화 유지”로 사용
모델이 확신이 없을 때 도구를 호출해 시간을 벌거나, 확인을 반복하는 형태입니다. 이 경우 스텝 제한과 타임아웃이 가장 즉효입니다.
차단 전략 1: 실행 단계(steps) 상한과 타임아웃을 하드로 걸기
가장 기본이자 강력한 방어는 “몇 번까지 도구를 호출할 수 있는가”를 코드로 제한하는 것입니다. LangChain의 에이전트 실행에는 반복 루프가 포함되므로, 해당 반복 횟수와 전체 실행 시간을 제한합니다.
아래 예시는 TypeScript 기준의 개념 코드입니다. 실제 API는 버전에 따라 다를 수 있으니, 구조를 참고해 적용하세요.
import { z } from "zod";
type ToolCall = {
name: string;
args: unknown;
};
type GuardOptions = {
maxSteps: number;
timeoutMs: number;
};
class ToolLoopError extends Error {
constructor(message: string) {
super(message);
this.name = "ToolLoopError";
}
}
export async function runWithGuards<T>(
runner: (ctx: { onToolCall: (call: ToolCall) => void }) => Promise<T>,
options: GuardOptions
): Promise<T> {
let steps = 0;
const startedAt = Date.now();
const onToolCall = (call: ToolCall) => {
steps += 1;
if (steps > options.maxSteps) {
throw new ToolLoopError(`Tool steps exceeded: ${steps}`);
}
const elapsed = Date.now() - startedAt;
if (elapsed > options.timeoutMs) {
throw new ToolLoopError(`Tool run timeout: ${elapsed}ms`);
}
};
return runner({ onToolCall });
}
포인트는 “도구가 실제로 실행되기 직전” 혹은 “도구 호출 이벤트가 발생했을 때” 카운트를 올리고, 상한 초과 시 예외로 끊는 것입니다. 이 예외는 사용자에게 그대로 노출하지 말고, 안전한 폴백 응답을 반환하도록 처리합니다.
운영 팁
maxSteps는 단일 질의의 정상 케이스에서 필요한 평균 tool call 수의 2배 정도로 시작합니다.timeoutMs는 모델 응답 시간과 도구 응답 시간을 합친 P95를 기준으로 잡습니다.
관련해서 요청-응답 경로에서 제한을 두는 사고방식은 분산 트랜잭션의 보상 설계와도 닮았습니다. 실패를 전제로 안전장치를 설계하는 관점은 MSA에서 Saga 보상 트랜잭션 설계 체크리스트에서도 도움이 됩니다.
차단 전략 2: “반복 호출”을 탐지하는 중복 제거(Dedup) 가드
무한루프의 많은 비율은 “같은 도구를 같은 인자로 반복 호출”하는 형태입니다. 따라서 (toolName, normalizedArgs) 를 키로 하여 최근 N개의 호출을 기록하고, 동일 호출이 일정 횟수 이상 반복되면 중단합니다.
import stableStringify from "fast-json-stable-stringify";
type DedupOptions = {
windowSize: number;
maxSameCall: number;
};
export function createDedupGuard(options: DedupOptions) {
const recent: string[] = [];
const counts = new Map<string, number>();
return {
onToolCall(call: { name: string; args: unknown }) {
const key = `${call.name}:${stableStringify(call.args)}`;
recent.push(key);
counts.set(key, (counts.get(key) ?? 0) + 1);
if (recent.length > options.windowSize) {
const removed = recent.shift();
if (removed) {
const c = (counts.get(removed) ?? 1) - 1;
if (c <= 0) counts.delete(removed);
else counts.set(removed, c);
}
}
const same = counts.get(key) ?? 0;
if (same >= options.maxSameCall) {
throw new Error(`Repeated tool call detected: ${key}`);
}
},
};
}
왜 “window” 가 필요한가
정상 플로우에서도 같은 도구를 두 번 부를 수 있습니다(예: 페이지네이션). 그래서 전체 기간 누적이 아니라 “최근 구간”에서의 반복을 기준으로 잡는 게 안전합니다.
차단 전략 3: 도구 입력과 출력 스키마를 강제하고, 실패를 명시적으로 표현
모델이 루프에 빠지는 이유 중 하나는 도구 결과를 애매하게 받아들여 “아직 끝나지 않았다”고 판단하기 때문입니다. 이를 줄이려면:
- 도구 입력은 Zod 같은 스키마로 강제
- 도구 출력도 구조화된 JSON으로 강제
- 실패 시에는
ok: false와errorCode를 반환 - 성공 시에는
ok: true와data를 반환
import { z } from "zod";
const SearchInput = z.object({
query: z.string().min(2),
topK: z.number().int().min(1).max(10).default(5),
});
type SearchResult =
| { ok: true; data: { title: string; url: string; snippet: string }[] }
| { ok: false; errorCode: "RATE_LIMIT" | "BAD_QUERY" | "UPSTREAM"; message: string };
export async function searchTool(raw: unknown): Promise<SearchResult> {
const parsed = SearchInput.safeParse(raw);
if (!parsed.success) {
return { ok: false, errorCode: "BAD_QUERY", message: parsed.error.message };
}
try {
const { query, topK } = parsed.data;
// 실제 검색 호출
const items = await fakeSearch(query, topK);
return { ok: true, data: items };
} catch (e) {
return { ok: false, errorCode: "UPSTREAM", message: "search upstream failed" };
}
}
async function fakeSearch(query: string, topK: number) {
return Array.from({ length: topK }).map((_, i) => ({
title: `Result ${i} for ${query}`,
url: `https://example.com/${i}`,
snippet: "...",
}));
}
이런 형태로 반환하면 모델은 “성공과 실패”를 분명히 인지할 수 있고, 실패 원인이 RATE_LIMIT 인 경우에는 재시도 대신 사용자에게 안내하도록 프롬프트를 설계하기 쉬워집니다.
차단 전략 4: 종료 조건을 “상태 머신”으로 끌어내리기
프롬프트 기반 에이전트는 종종 “계획-실행-검증”을 말로만 합니다. 이를 코드로 내리면 루프가 크게 줄어듭니다.
추천 상태 모델
PLAN: 어떤 도구를 어떤 순서로 쓸지 계획만 생성(도구 호출 금지)ACT: 계획에 따라 도구 호출 수행CHECK: 도구 결과가 충분한지 검증FINAL: 답변 생성 후 종료
중요한 점은 CHECK 단계에서 “추가 도구 호출이 필요한지”를 모델에게만 맡기지 않고, 코드 규칙을 섞는 것입니다. 예를 들어 “필수 필드가 없으면 한 번만 재시도, 그래도 없으면 종료” 같은 정책입니다.
type Phase = "PLAN" | "ACT" | "CHECK" | "FINAL";
type AgentState = {
phase: Phase;
attempts: number;
facts?: { sources: string[]; summary: string };
};
const MAX_ATTEMPTS = 2;
export function shouldContinue(state: AgentState) {
if (state.phase === "FINAL") return false;
if (state.attempts >= MAX_ATTEMPTS) return false;
return true;
}
이 방식은 모델이 “계속 더 찾아보자”라고 말해도, 시스템은 정해진 최대 시도 횟수에서 멈춥니다.
차단 전략 5: 멱등성(idempotency)과 캐시로 비용 폭주를 막기
루프가 완전히 차단되지 않더라도, 같은 호출이 반복될 때 비용이 폭주하지 않게 만들어야 합니다.
- 도구 호출에
idempotencyKey를 부여 - 동일 키면 같은 결과 반환
- 외부 API 호출 도구는 짧은 TTL 캐시 적용
type CacheEntry<T> = { value: T; expiresAt: number };
export function createTtlCache<T>() {
const map = new Map<string, CacheEntry<T>>();
return {
get(key: string) {
const e = map.get(key);
if (!e) return undefined;
if (Date.now() > e.expiresAt) {
map.delete(key);
return undefined;
}
return e.value;
},
set(key: string, value: T, ttlMs: number) {
map.set(key, { value, expiresAt: Date.now() + ttlMs });
},
};
}
이 패턴은 이벤트 기반 시스템의 중복 처리 방지와도 연결됩니다. 운영에서 “중복 호출은 반드시 발생한다”는 전제를 두면 MSA Saga 중복결제 방지 - Outbox+Debezium+Kafka 같은 글의 관점도 응용할 수 있습니다.
검증 전략 1: “루프 재현” 테스트 케이스를 고정하고 회귀를 막기
무한루프는 재현이 어려워 보이지만, 실제로는 몇 가지 패턴으로 고정됩니다.
- 도구가 빈 결과를 반환할 때
- 도구가
ok: false를 반환할 때 - 도구가 느리거나 타임아웃일 때
- 모델이 같은 호출을 반복하도록 유도되는 프롬프트일 때
테스트에서는 실제 LLM을 붙이기보다, 모델 출력을 흉내내는 스텁을 만들어 “도구 호출 이벤트가 반복되는 상황”을 강제로 만들면 됩니다.
import { describe, it, expect } from "vitest";
function createLoopingRunner() {
return async ({ onToolCall }: { onToolCall: (c: any) => void }) => {
for (let i = 0; i < 100; i++) {
onToolCall({ name: "search", args: { query: "same" } });
// 실제로는 여기서 tool 실행
}
return "done";
};
}
describe("tool loop guards", () => {
it("stops by maxSteps", async () => {
await expect(
runWithGuards(createLoopingRunner(), { maxSteps: 5, timeoutMs: 10_000 })
).rejects.toThrow(/steps exceeded/);
});
});
테스트 러너나 런타임이 바뀌면 이런 안전장치 테스트가 깨질 수 있습니다. CI에서 환경 변화로 테스트가 흔들릴 때의 관점은 Bun 1.1에서 Jest 테스트가 깨지는 이유와 해결도 참고할 만합니다.
검증 전략 2: 관측 가능성(Observability)로 “루프 징후”를 조기 탐지
차단은 마지막 방어선이고, 운영에서는 조기 탐지가 더 중요합니다. 최소한 아래 지표를 남기면 루프를 빠르게 찾을 수 있습니다.
- 요청 단위
tool_call_count - 도구별 호출 횟수(예:
tool_call_count{tool="search"}) - 중복 호출 감지 횟수
maxSteps초과로 중단된 비율- 도구 실행 시간 분포와 타임아웃 비율
로그에는 “도구 이름, 정규화된 인자 해시, 호출 순번, 상관관계 ID” 정도만 남기고, 민감 데이터는 마스킹합니다.
import crypto from "node:crypto";
function hashArgs(args: unknown) {
const s = JSON.stringify(args);
return crypto.createHash("sha256").update(s).digest("hex").slice(0, 12);
}
function logToolCall(requestId: string, name: string, args: unknown, step: number) {
const argsHash = hashArgs(args);
console.log(JSON.stringify({
requestId,
event: "tool_call",
name,
argsHash,
step,
ts: Date.now(),
}));
}
실전 체크리스트: 무한루프를 “구조적으로” 없애는 순서
1) 먼저 하드 리밋부터
maxStepstimeoutMs- 도구별 개별 타임아웃(예: 검색 2초, DB 500ms)
2) 그다음 스키마와 실패 모델링
- 입력 스키마 검증
- 출력
ok플래그와errorCode - 빈 결과와 실패의 의미를 구분
3) 중복 호출 방지
- 최근 윈도우 기반 dedup
- 캐시와 멱등성 키
4) 상태 머신 도입
- PLAN 단계에서는 도구 호출 금지
- CHECK 단계에서 재시도 정책을 코드로 고정
5) 테스트와 관측
- 루프 재현 스텁 테스트
- 메트릭과 로그로 조기 탐지
마무리
LangChain Tool Calling 무한루프는 “모델이 멍청해서”가 아니라, 시스템이 종료 조건을 코드로 강제하지 않았기 때문에 발생하는 경우가 많습니다. 따라서 해결도 프롬프트만 다듬는 방향이 아니라, (1) 스텝/시간 하드 리밋, (2) 중복 호출 감지, (3) 스키마 기반 입출력, (4) 상태 머신, (5) 테스트와 관측을 겹겹이 쌓는 방식이 가장 안정적입니다.
다음 단계로는, 실제 트래픽에서 수집한 tool_call_count 분포를 기반으로 maxSteps 와 dedup 정책을 튜닝하고, 실패 코드별로 사용자 메시지와 재시도 정책을 분리해 운영 품질을 끌어올리는 것을 추천합니다.