누군가 하룻밤 사이에 200달러를 날리는 것을 보고 LLM 에이전트용 서킷 브레이커(Circuit Breaker)를 만들었습니다
요약
LLM 에이전트의 무한 루핑과 과도한 API 비용 발생 문제를 해결하기 위한 'AgentBrake' 개발 사례를 소개합니다. 관측 도구와 달리 실행 도중에 루프, 예산 초과, 허용되지 않은 도구 호출을 즉시 차단하는 서킷 브레이커 기능을 구현했습니다.
핵심 포인트
- 에이전트의 무한 루핑으로 인한 예기치 못한 비용 폭증 위험 경고
- 기존 관측 도구(Observability)의 한계인 '사후 기록' 문제 지적
- AgentBrake: 루프, 예산, 권한을 실시간 검증하는 Python 데코레이터
- 도구 호출 전 예외를 발생시켜 피해를 사전에 방지하는 메커니즘
몇 주 전, 저는 실제 과제를 하는 대신 LangChain Slack을 눈팅하고 있었는데, 두 개의 메시지가 머릿속을 떠나지 않았습니다.
첫 번째 메시지: 누군가가 **첫 번째 질문에 대해 211번의 루핑 실행(looping runs)**에 대한 비용을 청구받았습니다. 100번째 질문이 아니라, 바로 첫 번째 질문에서 말이죠. 에이전트가 그냥 계속 실행된 것입니다.
두 번째 메시지: 한 개발자가 800위안(약 110달러)을 잃었습니다. LangChain의 기본 재귀 제한(recursion limit)이 9999로 설정되어 있었기 때문입니다. 아무도 의도적으로 그 값을 설정하지 않습니다. 그저 기본값일 뿐이며, 그 기본값은 "사실상 무한"에 가깝습니다.
저는 학생입니다. 저에게 110달러는 큰 돈입니다. 잘못된 도구 응답(tool response)과 아무도 읽지 않은 기본값 때문에, 당신이 잠든 사이 에이전트가 조용히 그 돈을 태워버릴 수 있다는 생각은 정말 저를 겁나게 했습니다. 그래서 저는 무언가를 만들었습니다. 이것은 그 결과물에 대한 이야기입니다.
문제점: 에이전트에게는 가속 페달은 있지만 브레이크는 없습니다
당신이 첫 에이전트를 출시할 때 아무도 말해주지 않는 사실이 있습니다.
LangGraph를 연결하고, 모델에 tools 배열을 주고, 멋진 프롬프트(prompt)를 작성하면 데모에서는 잘 작동합니다. 좋습니다. 하지만 프로덕션(production) 환경으로 넘어가면 결국 다음 세 가지 중 하나가 발생합니다.
- 루핑(Looping)이 발생합니다. 도구가 쓰레기 값을 반환하고, 모델은 재시도하기로 결정하며, 다시 같은 쓰레기 값을 받고, 또 재시도합니다... 영원히 말이죠. 혹은 당신의 예산이 바닥날 때까지 말입니다.
- 과다 지출이 발생합니다. 아무것도 "고장"나지 않았습니다. 단지 많은 작업을 수행하고 있을 뿐이며, 청구 금액은 합리적인 수준을 넘어 조용히 치솟습니다.
- 해서는 안 될 일을 합니다. 사용자가 지원 봇에 프롬프트 인젝션(prompt-injection)을 시도하고, 갑자기 봇이
delete_database를 호출합니다. 왜냐하면, 글쎄요, 그 함수가 도구 목록에 있었고 아무도 안 된다고 말하지 않았으니까요.
그리고 저를 가장 괴롭혔던 부분은 이것입니다: 우리는 이미 이를 위한 도구들을 가지고 있지만, 그것들은 모두 행동하는 대신 관찰만 한다는 점입니다.
LangSmith, Langfuse, Helicone, AgentOps — 이들은 훌륭합니다. 저도 사용합니다. 하지만 이것들은 관측성(observability) 도구입니다. 이것들은 아름다운 대시보드를 통해 당신의 에이전트가 211번 루핑했다는 것을 보여줍니다. 이미 벌어진 후에 말이죠. 대시보드는 보안 카메라와 같습니다. 침입 사건을 기록할 뿐, 문을 잠그지는 않습니다.
저는 계속해서 생각했습니다. 브레이크 페달은 어디에 있는가? 피해가 발생한 다음 날 읽는 보고서가 아니라, 실행 도중에, 즉 피해가 발생하기 전에 에이전트를 멈춰 세울 수 있는 장치는 어디에 있는가?
마음에 드는 것을 찾을 수 없었습니다. 그래서 직접 만들었습니다.
제가 만든 것: AgentBrake
AgentBrake는 도구 호출 (tool calls) 앞에 위치하는 Python 데코레이터 (decorator)입니다. 에이전트가 시도하는 모든 도구 호출은 먼저 이를 통과하며, 호출을 실행하기 전에 다음 세 가지를 확인합니다:
- 루프 (Loop) — 동일한 인자 (arguments)로 같은 도구를 반복해서 호출하고 있는가?
- 예산 (Budget) — 이 호출이 설정한 달러 상한선을 초과하게 만드는가?
- 에스컬레이션 (Escalation) — 이 도구가 허용된 목록 (allowed list)에 포함되어 있는가?
어떤 확인 사항이라도 걸리면, 도구를 실행하는 대신 예외 (exception)를 발생시킵니다. 에이전트는 실행 도중에 멈춥니다. 돈이 소비되거나 데이터베이스가 삭제되기 전에 말이죠.
이것이 핵심입니다. 이것은 서킷 브레이커 (circuit breaker)입니다. 상황이 잘못되면 작동하여, 그 하위의 어떤 것도 전력을 공급받지 못하게(실행되지 않게) 만듭니다.
작동 방식 (정말로 단 3줄입니다)
한 번만 설정하면 됩니다:
import agentbrake
agentbrake.init(
...
그 다음, 도구를 배정하는 함수에 데코레이터를 붙입니다:
@agentbrake.guard()
def call_tool(name: str, args: dict):
return my_tools[name](**args)
그게 전부입니다. 이것이 통합 방식입니다. 만약 에이전트가 루프를 돌거나, 5달러 예산을 초과하거나, allowed_tools 외부의 무언가를 호출하려고 하면, call_tool은 도구를 실행하는 대신 AgentBrakeInterrupt를 발생시킵니다.
에이전트를 실행하는 어디에서든 이를 포착(catch)하면 됩니다:
from agentbrake import AgentBrakeInterrupt
try:
...
내부적으로 데코레이터는 메모리에 작은 RunState를 유지합니다. 여기에는 실행 ID (run ID), 현재 실행 비용, 그리고 전체 호출 이력이 포함됩니다. 모든 호출은 세 가지 탐지기 (detectors)를 순서대로 (에스컬레이션 → 루프 → 예산) 통과하며, 가장 먼저 작동하는 탐지기가 도구가 실행되기도 전에 예외를 발생시킵니다.
각 탐지기에 대해 설명하겠습니다. 생각보다 훨씬 간단하기 때문입니다.
**에스컬레이션(Escalation)**은 한 줄짜리 검사입니다. 도구 이름이 허용 목록(allow-list)에 있습니까? 아니요? 중단합니다. 이것이 가장 저렴하고 결정적인 확인 절차이므로 가장 먼저 작동합니다. 예산 초과 여부는 신경 쓰지 않습니다. 만약 delete_database를 호출하려고 하는데 그게 목록에 없다면, 그것은 명백한 거절입니다.**예산(Budget)**은 호출 전에 비용을 예측합니다. 현재 누적된 총액을 가져와 다음 호출이 얼마의 비용이 들지 더하고, 만약 그 예측된 숫자가 상한선을 초과하면 작동합니다. 핵심 단어는 '예측'입니다. 선을 넘기 전에 멈추게 하고, 난 후에 멈추게 하지 않습니다.**루프(Loop)**는 제가 가장 자랑스러워하는 부분이라 별도의 섹션을 할애했습니다.
제가 가장 자랑스러운 것: 루프를 위한 구조적 해싱 (structural hashing for loops)
제가 처음 생각한 루프 감지 방법은 명백한 것이었습니다. 도구 이름과 인수를 문자열로 비교하는 것입니다. 만약 마지막 3번의 호출이 동일한 문자열이라면, 그것은 루프입니다.
하지만 이것은 취약합니다. `{
실제 LangGraph 에이전트에서 세 가지 탐지기(loop, runaway budget, privilege escalation)가 모두 작동하는 모습을 보여주는 짧은 시연 영상을 녹화했습니다:
📺 https://youtu.be/uHbjP2SGMsI
루프 중간에 에이전트가 중단되는 모습을 지켜보는 것은 예상했던 것보다 훨씬 더 만족스러웠습니다.
이것을 만들며 실제로 배운 점
이 부분은 친구와 커피를 마시며 이야기할 법한 내용인데, 저조차도 몇몇 부분은 놀라웠기 때문입니다.
1. 프레임워크가 당신과 적극적으로 싸웁니다. 이것이 가장 큰 문제였습니다. 제가 만든 탐지기들은 개별적으로는 완벽하게 작동했지만, 실제 LangGraph 에이전트에 연결하자... 아무것도 멈추지 않았습니다. 알고 보니 LangGraph의 ToolNode는 기본적으로 도구 예외(tool exceptions)를 삼켜버리고(swallows), 이를 관찰(observation)로서 모델에게 다시 전달합니다. 그래서 제 서킷 브레이커(breaker)가 작동하여 예외를 발생시키면
3. 항상 Fail closed(실패 시 차단) 하세요. 저는 브라우저에서 사람이 플래그(flagged)된 실행을 승인하거나 중단할 수 있는 선택적인 원격 모드(remote mode)를 추가했습니다. 여기서 당연한 의문이 생깁니다: 만약 그 백엔드(backend)에 접속할 수 없다면 어떻게 될까요? 제 첫 번째 버전은 그냥... 호출을 통과시켰습니다. 에러를 내는 것이 사용자에게 적대적(user-hostile)이라고 느껴졌기 때문입니다. 하지만 곧 그것이 정확히 반대로 되어 있다는 것을 깨달았습니다. 전원이 끊겼을 때 풀려버리는 브레이크는 브레이크가 아닙니다. 그래서 이제는 검증 서버(validation server)가 다운되면 실행을 중단합니다. 실패 시 열리는(fails open) 안전 장치는 안전 장치가 없는 것보다 더 나쁩니다. 왜냐하면 당신은 자신이 보호받고 있다고 생각하기 때문입니다.
4. 의도적으로 비동기(async)보다 동기(sync)를 선택하세요. 저는 더 "실제"처럼 느껴지기 때문에 모든 곳에 async를 사용하고 싶었습니다. 하지만 @guard()는 완전히 동기적(synchronous)일 수도 있는 임의의 사용자 함수를 감싸는 형태입니다. 제 브레이커(breaker)가 사람의 검토를 위해 일시 정지할 수 있도록 누군가에게 이벤트 루프(event loop)를 억지로 붙이게 만드는 것은 끔찍한 개발자 경험(developer experience)이 될 것입니다. 때로는 지루한 선택이 올바른 선택입니다.
아직 초기 단계이며, 여러분의 검토를 기다립니다
솔직한 상태를 말씀드리자면, 이것은 v0.0.1입니다. 로컬 모드(Local mode)는 견고하며(15/15 테스트 통과), LangGraph 예제와 원격 검증 UI(remote validation UI)는 엔드 투 엔드(end to end)로 작동합니다. 비용 모델(cost model)은 현재 실제 토큰 계산(token accounting) 방식이 아닌 호출당 고정 추정치 방식을 사용하고 있습니다. 이것이 제 다음 작업 목록에 있는 사항입니다. 제가 API를 확정하기 전에 실제 사용자들이 어디가 잘못되었는지 말해주길 바라기 때문에 이것을 공개합니다.
만약 에이전트(agent)가 멍청하고 비용이 많이 드는 행동을 하는 것을 본 적이 있다면, 진심으로 여러분의 피드백을 받고 싶습니다.
pip install agentbrake
- GitHub: https://github.com/BOSSMETALIQUE/agentbrake
- Landing page: https://bossmetalique.github.io/agentbrake/
대시보드(dashboards)는 그대로 두셔도 좋습니다. 브레이크 페달도 하나 추가하세요.
읽어주셔서 감사합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기