Published on

AutoGPT 비용 폭주 막기 - 토큰·툴 호출 한도 설계

Authors

AutoGPT 같은 에이전트형 시스템은 LLM 호출툴 호출이 서로를 증폭시키는 구조라서, 한번 루프가 꼬이면 비용이 선형이 아니라 기하급수적으로 튈 수 있습니다. 특히 검색 -> 요약 -> 재검색 -> 파일 생성 -> 재검토 같은 플로우에서 작은 프롬프트 품질 저하가 곧바로 추가 토큰추가 툴 호출로 이어집니다.

이 글은 “토큰을 조금 줄이자” 수준이 아니라, 예산을 시스템적으로 강제하는 설계를 다룹니다. 핵심은 다음 3가지를 동시에 잡는 것입니다.

  1. 토큰 예산을 요청 단위가 아니라 세션/작업 단위로 관리
  2. 툴 호출을 “허용 목록”이 아니라 비용/위험 기반 정책으로 제한
  3. 예산 초과를 실패로 끝내지 말고 우아한 강등으로 마무리

아래는 실무에서 바로 적용 가능한 패턴들입니다.

비용 폭주의 전형적인 원인

1) 자기 강화 루프

에이전트가 다음 행동을 결정하기 위해 LLM을 호출하고, 그 결과로 툴을 호출하고, 툴 결과를 다시 LLM에 넣는 구조는 기본적으로 루프입니다. 여기에 “목표 달성 실패”가 섞이면 다음이 반복됩니다.

  • 더 긴 컨텍스트를 넣는다
  • 더 많은 검색을 한다
  • 더 많은 후보를 생성한다
  • 더 많은 검증 단계를 추가한다

즉, 실패할수록 비용이 늘어납니다.

2) 컨텍스트 누적

메모리나 로그를 통째로 프롬프트에 붙이는 방식은 토큰을 빠르게 태웁니다. 특히 툴 결과 원문을 그대로 붙이면, 모델이 읽기만 하고 결론은 못 내는 상황이 자주 발생합니다.

3) 툴 호출의 숨은 비용

툴 호출은 LLM 비용만이 아닙니다.

  • 웹 검색 API 과금
  • 크롤링 트래픽
  • DB 쿼리 부하
  • 벡터 검색 비용
  • 외부 SaaS rate limit으로 인한 재시도

이게 쌓이면 “모델 토큰은 줄였는데 총 비용은 그대로”가 됩니다.

설계 목표: 예산을 코드로 강제하기

에이전트 비용 제어는 “권장”이 아니라 “강제”여야 합니다. 권장은 프롬프트가 무시하면 끝입니다.

예산 단위 정의

아래 3단계로 예산을 쪼개면 운영이 쉬워집니다.

  • Run budget: 사용자 요청 1회(작업 1개)에 대한 총 예산
  • Phase budget: 계획, 수집, 실행, 검증 같은 단계별 예산
  • Action budget: LLM 호출 1회, 툴 호출 1회 같은 액션별 예산

이 구조를 잡으면 “어느 단계에서 새는지”가 바로 보입니다.

토큰 한도 설계 패턴

1) 하드 캡: 요청당 최대 토큰

가장 기본은 max_output_tokensmax_context_tokens를 명시하는 것입니다. 하지만 이것만으로는 부족합니다. 컨텍스트가 길어지면 출력이 짧아져도 계속 호출을 반복할 수 있기 때문입니다.

2) 세션 총량 캡: 누적 토큰 예산

아래처럼 BudgetManager를 두고, LLM 호출 전에 반드시 예산을 예약하고, 실제 사용량으로 정산하는 방식이 안전합니다.

// TypeScript 예시

type Budget = {
  maxInputTokens: number;
  maxOutputTokens: number;
  maxTotalTokens: number; // input + output 누적
};

type Usage = {
  inputTokens: number;
  outputTokens: number;
  totalTokens: number;
};

class BudgetExceededError extends Error {}

class BudgetManager {
  private usedTotal = 0;

  constructor(private budget: Budget) {}

  reserve(estimatedTotalTokens: number) {
    if (this.usedTotal + estimatedTotalTokens > this.budget.maxTotalTokens) {
      throw new BudgetExceededError(
        `Budget exceeded: used=${this.usedTotal}, reserve=${estimatedTotalTokens}, max=${this.budget.maxTotalTokens}`
      );
    }
    // 예약 시점에 미리 차감해서 동시성에서도 안전하게
    this.usedTotal += estimatedTotalTokens;
  }

  settle(actual: Usage, reserved: number) {
    const diff = reserved - actual.totalTokens;
    // diff가 양수면 덜 썼으니 되돌려줌
    this.usedTotal -= Math.max(0, diff);
  }

  remaining() {
    return Math.max(0, this.budget.maxTotalTokens - this.usedTotal);
  }
}

핵심은 reserve입니다. 예약 없이 “써보고 초과하면 중단”은 동시 실행에서 예산이 깨집니다.

3) 단계별 캡: Phase별 토큰 상한

에이전트는 보통 다음 단계로 흘러갑니다.

  • plan: 목표를 쪼개고 전략을 세움
  • gather: 검색, 크롤링, RAG 조회
  • execute: 코드 작성, 파일 생성, API 실행
  • verify: 테스트, 재검토

실무에서는 gather 단계가 비용 폭주의 1순위입니다. 그래서 gather에 더 빡센 캡을 걸고, 초과 시에는 “추가 검색” 대신 “현재 자료로 답변”으로 강등시키는 게 효과적입니다.

툴 호출 한도 설계 패턴

1) 툴 호출도 비용 모델을 가져야 한다

툴을 단순히 N회까지로 제한하면, 값싼 툴과 비싼 툴이 섞일 때 최적화가 안 됩니다. 추천은 툴마다 cost unit을 부여하는 방식입니다.

  • 웹 검색 1회: 5 units
  • 크롤링 1회: 10 units
  • DB 쿼리 1회: 2 units
  • 코드 실행 1회: 8 units

그리고 작업당 maxToolUnits를 둡니다.

type ToolName = "web_search" | "crawl" | "db_query" | "code_exec";

const TOOL_COST: Record<ToolName, number> = {
  web_search: 5,
  crawl: 10,
  db_query: 2,
  code_exec: 8,
};

class ToolBudgetManager {
  private used = 0;

  constructor(private maxUnits: number) {}

  checkAndConsume(tool: ToolName) {
    const cost = TOOL_COST[tool];
    if (this.used + cost > this.maxUnits) {
      throw new Error(
        `Tool budget exceeded: tool=${tool}, used=${this.used}, cost=${cost}, max=${this.maxUnits}`
      );
    }
    this.used += cost;
  }

  remaining() {
    return Math.max(0, this.maxUnits - this.used);
  }
}

이렇게 하면 에이전트가 “검색을 무한 반복”하는 것보다, 남은 유닛으로 더 가치 있는 툴을 선택하도록 유도할 수 있습니다.

2) 툴 호출 회로 차단기: 실패율과 반복 패턴 감지

비용 폭주는 종종 “계속 실패하는 툴을 재시도”하면서 발생합니다. 그래서 다음 조건에서 회로 차단기를 여는 것이 좋습니다.

  • 동일 파라미터로 연속 실패 k
  • 최근 n회 호출의 실패율이 p 이상
  • 동일 도메인 크롤링이 반복됨
type ToolCallKey = string;

type CallResult = { ok: boolean; status?: number };

class CircuitBreaker {
  private failures: Map<ToolCallKey, number> = new Map();

  constructor(private maxConsecutiveFailures: number) {}

  beforeCall(key: ToolCallKey) {
    const f = this.failures.get(key) ?? 0;
    if (f >= this.maxConsecutiveFailures) {
      throw new Error(`Circuit open for key=${key}, failures=${f}`);
    }
  }

  afterCall(key: ToolCallKey, result: CallResult) {
    const f = this.failures.get(key) ?? 0;
    if (result.ok) this.failures.set(key, 0);
    else this.failures.set(key, f + 1);
  }
}

여기서 keytoolName + normalizedArgs로 잡는 게 포인트입니다.

3) 툴 결과를 그대로 넣지 말고 요약 게이트를 둔다

툴 결과를 LLM에 다시 넣을 때는 다음 중 하나를 강제하세요.

  • 요약 모델을 별도로 두고 tool result -> summary로 축약
  • 규칙 기반 추출(예: 상위 10개 항목만)
  • RAG라면 top-k를 줄이고 리랭커로 품질을 확보

리랭커를 끼워 환각과 불필요한 재검색을 줄이는 전략은 비용 절감에도 직결됩니다. 관련해서는 RAG 리랭커로 환각 줄이기 - Cohere·bge도 함께 참고하면 좋습니다.

“중단”이 아니라 “강등”으로 끝내기

예산 초과 시 무조건 에러를 반환하면 사용자 경험이 깨지고, 재시도로 오히려 비용이 더 듭니다. 그래서 예산이 바닥나면 아래 순서로 강등하세요.

  1. 추가 검색 금지, 현재 컨텍스트로 답변
  2. 출력 포맷 단순화(표, 코드 생성 등 고비용 작업 제거)
  3. 검증 단계 생략, 대신 불확실성 고지
  4. 최종적으로만 중단

프롬프트에도 예산 상태를 명시적으로 주는 게 좋습니다.

  • remaining_token_budget
  • remaining_tool_units
  • phase

단, 이것이 “모델이 알아서 절약”을 보장하진 않으므로, 서버 측 강제가 필수입니다.

관측 가능성: 비용 폭주는 로그가 아니라 지표로 잡는다

비용 이슈는 장애처럼 다뤄야 합니다. 다음 지표를 최소로 추천합니다.

  • 작업당 total_tokens, input_tokens, output_tokens
  • 툴별 call_count, error_rate, p95_latency
  • 단계별 token_spendtool_unit_spend
  • “반복 패턴” 지표: 동일 쿼리 재검색 횟수, 동일 URL 크롤링 횟수

그리고 알림은 “총 비용”보다 선행 지표가 더 유용합니다.

  • tool_call_rate 급증
  • gather phase 토큰 비중 급증
  • circuit breaker open 증가

운영 환경에서 문제가 생겼을 때 로그가 충분하지 않은 경우도 많습니다. 쿠버네티스에서 원인 파악이 어려운 케이스에 대한 접근 방식은 Kubernetes CrashLoopBackOff, 로그 없이 진단하는 법과 유사한 사고방식으로 적용할 수 있습니다. “로그가 없으니 추측”이 아니라, 지표와 상태 신호로 시스템을 관찰하는 쪽이 재현성과 속도가 좋습니다.

레퍼런스 아키텍처: Budgeted Agent Loop

아래는 “예산 강제”를 루프에 끼워 넣은 형태의 의사 코드입니다.

type Phase = "plan" | "gather" | "execute" | "verify";

type AgentState = {
  phase: Phase;
  step: number;
  memorySummary: string;
};

async function runAgent(task: string) {
  const tokenBudget = new BudgetManager({
    maxInputTokens: 20000,
    maxOutputTokens: 6000,
    maxTotalTokens: 25000,
  });

  const toolBudget = new ToolBudgetManager(40);
  const breaker = new CircuitBreaker(3);

  let state: AgentState = { phase: "plan", step: 0, memorySummary: "" };

  while (state.step < 20) {
    state.step += 1;

    // 단계별 예산 정책 예시
    if (state.phase === "gather" && toolBudget.remaining() < 10) {
      state.phase = "execute"; // 강등: 더 이상 수집하지 않음
    }

    // LLM 호출 전 토큰 예약(대략치)
    const reserved = 1200;
    tokenBudget.reserve(reserved);

    const llmResp = await callLLM({
      task,
      phase: state.phase,
      remainingTokens: tokenBudget.remaining(),
      remainingToolUnits: toolBudget.remaining(),
      memory: state.memorySummary,
    });

    tokenBudget.settle(
      {
        inputTokens: llmResp.usage.input,
        outputTokens: llmResp.usage.output,
        totalTokens: llmResp.usage.input + llmResp.usage.output,
      },
      reserved
    );

    if (llmResp.type === "final") return llmResp.text;

    if (llmResp.type === "tool") {
      const tool = llmResp.toolName as any;
      toolBudget.checkAndConsume(tool);

      const key = `${tool}:${stableStringify(llmResp.args)}`;
      breaker.beforeCall(key);

      const result = await callTool(tool, llmResp.args)
        .then((data) => ({ ok: true, data }))
        .catch((e) => ({ ok: false, error: String(e) }));

      breaker.afterCall(key, { ok: result.ok });

      // 툴 결과는 요약해서 메모리에 누적
      state.memorySummary = await summarizeToolResult(state.memorySummary, result);

      // 다음 phase 전이(간단 예시)
      state.phase = state.phase === "plan" ? "gather" : state.phase;
      continue;
    }

    // 그 외 액션은 phase를 진행
    state.phase = nextPhase(state.phase);
  }

  return "Budget-safe fallback: partial answer with current evidence.";
}

이 구조의 장점은 다음과 같습니다.

  • 예산이 바닥나면 자연스럽게 “수집 중단” 같은 강등이 발생
  • 툴 재시도 폭주를 회로 차단기로 제어
  • 툴 결과를 요약해 컨텍스트 누적을 억제

비용을 줄이면서 품질을 유지하는 실전 팁

1) 요약은 항상 “목적 기반”으로

요약 프롬프트에 “무조건 짧게”만 넣으면, 나중에 다시 검색하게 되어 총비용이 늘 수 있습니다. 요약은 다음 스키마를 추천합니다.

  • facts: 검증 가능한 사실
  • open_questions: 아직 모르는 것
  • next_actions: 다음에 필요한 툴 호출 1~2개만

이렇게 하면 불필요한 브레인스토밍 토큰을 줄입니다.

2) 툴 스키마 오류는 즉시 비용 누수로 이어진다

툴 호출이 schema mismatch로 실패하면, 에이전트는 같은 호출을 변형하며 여러 번 시도합니다. 이게 비용 폭주의 흔한 트리거입니다. 툴 스키마와 JSON 강제는 품질 이슈이면서 비용 이슈입니다. 관련 사례는 Claude Tool Use 400 오류 - schema·JSON 해결 가이드에서 패턴을 참고할 수 있습니다.

3) 캐시를 “툴 결과”에 걸어라

LLM 응답 캐시는 프롬프트가 조금만 바뀌어도 히트율이 떨어집니다. 반면 툴 결과는 파라미터가 안정적이면 캐시 효율이 높습니다.

  • 검색 쿼리 캐시(짧은 TTL)
  • URL 크롤링 결과 캐시(중간 TTL)
  • DB 조회 캐시(데이터 특성에 맞게)

캐시는 비용 절감뿐 아니라, 툴 호출 지연을 줄여 에이전트가 “기다리다 다시 호출”하는 루프를 줄입니다.

체크리스트: 배포 전에 반드시 걸어야 할 안전장치

  • 작업당 maxTotalTokens가 서버에서 강제되는가
  • 단계별 예산이 있고, 초과 시 강등 정책이 있는가
  • 툴별 비용 유닛과 작업당 maxToolUnits가 있는가
  • 동일 호출 반복에 대한 회로 차단기가 있는가
  • 툴 결과를 요약하거나 top-k를 제한하는 게이트가 있는가
  • 지표로 phase별 토큰 비중툴 실패율을 보는가

마무리

AutoGPT 비용 폭주는 “모델이 비싸서”가 아니라, 에이전트 루프가 예산을 모르는 채로 확장되기 때문에 발생합니다. 해결은 프롬프트 최적화보다 먼저, 예산을 상태로 만들고 정책으로 강제하는 것입니다.

토큰은 누적 예산으로, 툴은 비용 유닛회로 차단기로, 예산 초과는 우아한 강등으로 처리하면 운영 가능한 에이전트가 됩니다.