Published on

LangChain 에이전트 무한루프 차단 실전 가이드

Authors

서버에서 LangChain 에이전트를 운영하다 보면, 특정 입력에서 에이전트가 같은 툴을 반복 호출하거나(예: 검색 query를 미세하게 바꾸며 무한 재시도), Final Answer로 수렴하지 못하고 계속 사고를 이어가는 케이스가 종종 발생합니다. 개발 환경에서는 “그냥 멈추면 되지”로 끝나지만, 프로덕션에서는 비용 폭증, 레이트리밋 연쇄 실패, 사용자 지연, 워커 고갈로 이어집니다.

이 글에서는 무한루프를 사전에 구조적으로 봉쇄하는 방법을 다룹니다. 핵심은 다음 4가지 축입니다.

  1. 출력 형식을 OpenAI JSON Schema로 강제해 “다음 행동”을 구조화
  2. LangChain 실행을 **max_iterations/max_execution_time**로 하드컷
  3. 툴 호출에 idempotency·dedup·circuit breaker를 적용해 같은 행동 반복을 차단
  4. 관측(로그/트레이싱)과 재시도 정책을 분리해 “실패는 실패로 끝내기”

레이트리밋이 동반되는 환경이라면, 무한루프는 429를 폭발시키며 더 큰 장애로 번집니다. 429 재시도 설계는 별도로 정리해 두었으니 함께 참고하세요: OpenAI 429 rate_limit_exceeded 재시도 설계, OpenAI Responses API 429 쿼터·레이트리밋 대응

무한루프가 생기는 대표 패턴

1) “툴 결과가 마음에 안 든다” 루프

검색/DB 조회 결과가 기대와 다르면 모델은 같은 툴을 다시 부르려 합니다. 특히 다음 상황에서 자주 터집니다.

  • 툴이 빈 결과를 반환했는데, 모델이 “내가 쿼리를 잘못 만들었다”고 판단
  • 툴이 에러를 반환했는데, 모델이 “다시 하면 되겠지”로 반복
  • 툴이 너무 많은 결과를 반환해 요약이 어려워 “더 좁혀서 다시 검색” 반복

2) “형식 불안정” 루프

에이전트가 최종 응답과 중간 도구 호출을 섞어 쓰면, 파서가 실패하고 에이전트가 “형식을 고쳐서 다시”를 반복합니다.

  • ReAct 스타일에서 Action:/Observation: 텍스트가 깨짐
  • JSON을 요구했는데 JSON이 깨져 파싱 실패

3) “스트리밍/메시지 중복”으로 인한 재호출

스트리밍 토큰 중복/누락, 메시지 히스토리 누적 버그로 인해 이전 단계가 다시 실행되는 것처럼 보일 수 있습니다. 스트리밍 계열 이슈는 별도 글도 참고하세요: LangChain 스트리밍 토큰 중복·누락 버그 해결

1단계: OpenAI JSON Schema로 ‘행동’을 구조화하기

무한루프를 줄이는 가장 강력한 방법 중 하나는, 모델이 매 턴마다 반드시 아래 중 하나만 선택하도록 강제하는 것입니다.

  • tool_call: 어떤 툴을 어떤 인자로 호출할지
  • final: 사용자에게 반환할 최종 답
  • abort: 더 진행하면 안 되는 상황(권한 부족, 데이터 없음, 정책 위반 등)

즉, “자유 텍스트로 사고하다가 도구를 부르거나 답을 하라”가 아니라, 정해진 스키마로 다음 행동을 선언하게 만들면 파싱 실패/애매한 행동이 크게 줄어듭니다.

아래는 OpenAI 측의 JSON Schema 강제(일반적으로 response_formatjson_schema)를 사용하는 예시입니다. 실제 SDK/버전마다 필드명이 조금 다를 수 있지만, 핵심은 스키마 강제 + additionalProperties: false + oneOf 패턴입니다.

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

const AgentStepSchema = {
  name: "agent_step",
  schema: {
    type: "object",
    additionalProperties: false,
    properties: {
      type: { type: "string", enum: ["tool_call", "final", "abort"] },
      tool: {
        type: "object",
        additionalProperties: false,
        properties: {
          name: { type: "string" },
          arguments: { type: "object" }
        },
        required: ["name", "arguments"]
      },
      final: { type: "string" },
      reason: { type: "string" }
    },
    required: ["type"],
    allOf: [
      {
        if: { properties: { type: { const: "tool_call" } } },
        then: { required: ["tool"] }
      },
      {
        if: { properties: { type: { const: "final" } } },
        then: { required: ["final"] }
      },
      {
        if: { properties: { type: { const: "abort" } } },
        then: { required: ["reason"] }
      }
    ]
  }
} as const;

export async function decideNextStep(input: string) {
  const resp = await client.responses.create({
    model: "gpt-4.1-mini",
    input,
    response_format: {
      type: "json_schema",
      json_schema: AgentStepSchema
    }
  });

  // SDK에 따라 resp.output_text 대신 구조화된 output을 꺼내야 할 수 있음
  const text = resp.output_text;
  return JSON.parse(text) as {
    type: "tool_call" | "final" | "abort";
    tool?: { name: string; arguments: Record<string, unknown> };
    final?: string;
    reason?: string;
  };
}

스키마 설계 팁: 루프를 줄이는 필드

  • reason: 왜 이 결정을 했는지 짧게 강제하면, “그냥 다시 해볼까?” 같은 충동적 반복이 줄어듭니다.
  • tool.arguments에 “재시도 횟수” 같은 값을 모델이 임의로 늘리지 못하게, 서버에서만 카운트합니다.
  • tool.name을 enum으로 제한하면 “없는 툴” 호출로 인한 재시도 루프가 줄어듭니다.

2단계: LangChain에서 하드컷 걸기 (max_iterations, max_execution_time)

구조화를 해도 100%는 아닙니다. 그래서 하드 리미트는 필수입니다.

LangChain의 에이전트 실행은 보통 다음 두 가지 컷을 제공합니다.

  • max_iterations: 몇 번의 “생각/툴/관측” 루프를 허용할지
  • max_execution_time: 총 실행 시간을 초 단위로 제한

아래는 LangChain JS 기준의 예시 패턴입니다(사용하는 에이전트/러너에 따라 옵션 키가 다를 수 있으니, 현재 버전 문서를 확인하세요).

import { AgentExecutor } from "langchain/agents";
import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({
  model: "gpt-4.1-mini",
  temperature: 0
});

// tools, agent 구성은 생략

const executor = new AgentExecutor({
  agent,
  tools,
  maxIterations: 6,
  // 일부 런타임에서는 maxExecutionTime 또는 maxExecutionTimeMs 형태
  maxExecutionTime: 20
});

export async function runAgent(input: string) {
  try {
    const result = await executor.invoke({ input });
    return result;
  } catch (e: any) {
    // 하드컷에 걸리면 여기로 떨어지게 만들고,
    // 사용자에게는 "추가 정보 요청" 같은 안전한 응답을 반환
    return {
      output:
        "요청을 처리하는 중 반복이 감지되어 중단했습니다. 필요한 정보(기간/대상/제약)를 조금 더 알려주세요."
    };
  }
}

실무 권장값

  • maxIterations: 4~8 사이에서 시작
  • maxExecutionTime: 10~30초 사이(툴 latency에 따라)

중요한 건 “정답률”보다 “시스템 생존”입니다. 복잡한 작업은 애초에 단일 턴 에이전트로 끝내려 하지 말고, 단계형 UX로 쪼개는 편이 안정적입니다.

3단계: 반복 호출을 서버에서 차단하는 가드레일

무한루프의 본질은 “모델이 같은 행동을 반복”하는 것입니다. 그러면 서버는 행동 단위로 반복을 감지하고, 다음을 수행해야 합니다.

  • 같은 툴/같은 인자 호출 반복이면 중단
  • 에러가 연속으로 발생하면 중단(서킷 브레이커)
  • 결과가 비어 있음이 확정이면 중단(예: 권한/기간/키워드 문제)

3-1) 툴 호출 fingerprint로 dedup

import crypto from "crypto";

type ToolCall = { name: string; args: Record<string, unknown> };

function stableStringify(obj: any) {
  return JSON.stringify(obj, Object.keys(obj).sort());
}

function fingerprint(call: ToolCall) {
  const raw = `${call.name}:${stableStringify(call.args)}`;
  return crypto.createHash("sha256").update(raw).digest("hex");
}

export class LoopGuard {
  private seen = new Map<string, number>();

  constructor(
    private readonly maxSameCall = 2,
    private readonly maxTotalCalls = 10
  ) {}

  check(call: ToolCall) {
    const fp = fingerprint(call);
    const count = (this.seen.get(fp) ?? 0) + 1;
    this.seen.set(fp, count);

    const total = Array.from(this.seen.values()).reduce((a, b) => a + b, 0);

    if (total > this.maxTotalCalls) {
      throw new Error("LoopGuard: too many tool calls");
    }
    if (count > this.maxSameCall) {
      throw new Error("LoopGuard: repeated identical tool call");
    }
  }
}

이 가드는 “모델이 조금씩 쿼리를 바꿔가며 재시도”하는 케이스에는 약합니다. 그래서 다음의 휴리스틱을 추가합니다.

  • 검색 쿼리 정규화(공백/대소문자/불용어 제거) 후 fingerprint
  • top_k 같은 파라미터만 바꾸는 경우는 동일 호출로 취급

3-2) 연속 에러 서킷 브레이커

export class ErrorCircuitBreaker {
  private consecutiveErrors = 0;

  constructor(private readonly threshold = 2) {}

  onSuccess() {
    this.consecutiveErrors = 0;
  }

  onError() {
    this.consecutiveErrors += 1;
    if (this.consecutiveErrors >= this.threshold) {
      throw new Error("CircuitBreaker: too many consecutive tool errors");
    }
  }
}

툴이 429나 타임아웃을 뱉을 때, 에이전트가 “다시!”를 반복하면 악순환이 됩니다. 재시도는 에이전트가 아니라 인프라 레이어에서 통제해야 합니다.

4단계: “중단 가능한 종료 상태”를 프롬프트에 명시하기

JSON Schema로 행동을 강제하더라도, 모델이 tool_call만 계속 선택할 수 있습니다. 따라서 시스템 프롬프트에 다음 종료 규칙을 명시하세요.

  • 동일 목적의 툴 호출은 최대 N회
  • 정보가 부족하면 abort로 전환하고 “추가 질문”을 생성
  • 툴 결과가 빈 값이면 원인 가설을 1개만 제시하고 추가 정보 요청

예시(시스템 메시지 일부):

너는 툴을 사용할 수 있는 에이전트다.
다음 규칙을 반드시 따른다.
1) 같은 목적의 툴 호출을 2회 이상 반복하지 않는다.
2) 필요한 정보가 부족하면 type=abort로 종료하고, 사용자에게 필요한 추가 정보를 1~3개 질문한다.
3) 툴 결과가 비어 있으면 쿼리를 무한히 바꾸지 말고, 실패 원인을 한 문장으로 설명한 뒤 abort한다.
4) 최종 답을 낼 수 있으면 type=final로 종료한다.

여기서 중요한 건 “반복하지 마” 같은 추상 규칙이 아니라, abort라는 명시적 종료 액션을 제공하는 것입니다.

5단계: 관측 가능성(Observability) 없이는 루프가 다시 온다

무한루프는 재현이 어렵습니다. 그래서 최소한 아래 로그는 남겨야 합니다.

  • run_id(요청 단위), step_index(에이전트 스텝)
  • 선택한 type(tool_call/final/abort)
  • 툴 이름, 정규화된 인자, fingerprint
  • 툴 latency, 에러 타입(타임아웃/429/5xx)
  • 중단 사유(LoopGuard/CircuitBreaker/MaxIterations/Timeout)

이 정도만 있어도 “어떤 입력에서 어떤 툴이 어떤 패턴으로 반복되는지”가 보이고, 프롬프트/툴 스펙/리미트 값을 조정할 수 있습니다.

6단계: 실전 조합 레시피(권장 아키텍처)

프로덕션 기준으로는 아래 조합이 가장 무난합니다.

  1. 모델 출력은 JSON Schema로 tool_call|final|abort 중 하나로 고정
  2. LangChain 실행에 max_iterationsmax_execution_time을 반드시 설정
  3. 툴 실행 래퍼에 LoopGuard(동일 호출 제한) + CircuitBreaker(연속 에러 제한)
  4. 429/네트워크 재시도는 에이전트가 아니라 HTTP 클라이언트/큐 레이어에서 담당
  5. 중단되면 “추가 질문” UX로 전환(에이전트를 다시 돌리기 전에 입력을 보강)

아래는 전체 흐름을 합친 축약 예시입니다.

async function runWithGuards(input: string) {
  const loopGuard = new LoopGuard(2, 8);
  const breaker = new ErrorCircuitBreaker(2);

  for (let i = 0; i < 6; i += 1) {
    const step = await decideNextStep(input);

    if (step.type === "final") return step.final;
    if (step.type === "abort") return `중단: ${step.reason}`;

    const call = { name: step.tool!.name, args: step.tool!.arguments };
    loopGuard.check(call);

    try {
      const obs = await runTool(call.name, call.args);
      breaker.onSuccess();
      input = `${input}\n\n관측 결과: ${JSON.stringify(obs)}`;
    } catch (e) {
      breaker.onError();
      input = `${input}\n\n툴 에러: ${String(e)}`;
    }
  }

  return "반복 한도에 도달해 중단했습니다. 조건을 더 구체화해 주세요.";
}

주의할 점은 관측 결과를 계속 누적하면 컨텍스트가 비대해져 또 다른 실패(비용/지연/잘림)를 부릅니다. 관측은 요약해서 넣거나, 핵심 필드만 넣는 방식으로 제한하세요.

체크리스트: 무한루프를 ‘사고’가 아니라 ‘설계’로 막기

  • 모델 출력이 자유 텍스트가 아니라 JSON Schema로 강제되는가
  • tool_call/final/abort의 종료 상태가 명확한가
  • LangChain에 max_iterations/max_execution_time 하드컷이 있는가
  • 동일 툴 호출 반복을 fingerprint로 차단하는가
  • 연속 에러 서킷 브레이커가 있는가
  • 429 재시도는 에이전트가 아닌 인프라 레이어에서 통제되는가
  • 중단 시 사용자에게 추가 질문을 반환하는 UX가 있는가

마무리

LangChain 에이전트의 무한루프는 “모델이 멍청해서”가 아니라, 종료 조건이 설계되지 않았기 때문에 발생하는 경우가 대부분입니다. OpenAI JSON Schema로 다음 행동을 구조화하고, LangChain의 반복/시간 제한으로 하드컷을 걸고, 서버에서 툴 호출을 dedup·circuit breaker로 통제하면 루프는 통계적으로 급격히 줄어듭니다.

특히 운영 환경에서는 “최대한 더 시도해서 맞추기”보다, 빨리 멈추고 입력을 보강하도록 유도하는 것이 비용과 안정성 측면에서 더 좋은 결과를 만듭니다.