도구 실패를 데이터로 만들기 — OpenClaw에서 에러 객체를 컨텍스트에 주입해 자가 복구 루프를 설계하는 법
에이전트를 처음 프로덕션에 올렸을 때 제일 당혹스러웠던 건 외부 API가 503을 뱉었을 때 에이전트가 그냥 멈춰버리는 상황이었습니다. 예외를 던지거나, 무한 대기 상태로 빠지거나. 솔직히 당시엔 "그래서 어떻게 하라는 거지?"라는 생각이 먼저 들었는데, 결국 핵심은 아주 단순한 인식의 전환이었습니다. 도구 실패를 실행 중단의 신호가 아니라, 모델이 읽고 해석할 수 있는 데이터로 취급하는 것.
OpenClaw GitHub 이슈 #8288을 보면 503 오류 시 에이전트가 최대 600초 동안 대기 상태에 빠지는 버그가 실제로 보고돼 있습니다. 이건 단순히 재시도 로직의 문제가 아닙니다. 에이전트가 실패를 "모르기" 때문에 생기는 문제입니다. 에러 텍스트가 모델의 컨텍스트 창에 들어가지 않으면, 모델은 자신이 막혀 있다는 사실조차 인식하지 못합니다.
이 글에서는 OpenClaw의 ToolResult 구조체와 ErrorBoundary 개념을 활용해 에러 객체를 컨텍스트에 주입하고, SELF_CORRECT부터 FALLBACK_TOOL까지 계층적 복구 루프를 설계하는 법을 다룹니다. LangGraph나 PydanticAI와 함께 쓸 때의 패턴도 함께 살펴볼게요.
에러 메시지가 곧 다음 프롬프트다
기존 예외 처리 패턴이 안 맞는 이유
전통적인 서버 애플리케이션에서 예외 처리는 "잡아서 로깅하고, 사용자에게 500 반환"이 전부였습니다. 그런데 에이전트 시스템에선 이 패턴이 근본적으로 맞지 않습니다.
에이전트는 추론-행동-관찰의 루프 위에서 동작합니다. 도구 호출 결과가 관찰(Observation)로 모델에 피드백되는 구조인데, 실패했을 때 그 관찰이 빈 값이거나 파이썬 스택 트레이스 덩어리라면 모델은 아무 판단도 할 수 없습니다.
바꿔 말하면, 도구가 실패한 이유·상황·복구 힌트가 모델의 컨텍스트에 자연어로 들어가야 모델이 다음 행동을 결정할 수 있습니다. 에러를 "예외"가 아니라 "관찰 데이터"로 다루는 관점이 자가 복구 설계의 출발점입니다.
OpenClaw 에이전트 루프의 흐름
OpenClaw의 에이전트 루프는 직렬화된 상태 머신으로 구현되어 있습니다. 도구 실행 결과는 항상 ToolResult 객체로 반환되며, 이 객체가 다음 컨텍스트 조립 단계의 입력이 됩니다.
핵심은 G 단계입니다. ToolResult의 recoverable 필드 값에 따라 복구 경로와 에스컬레이션 경로가 갈립니다. 이 분기를 제대로 설계하느냐가 에이전트 안정성의 대부분을 결정합니다.
ToolResult 구조체 이해하기
아래는 OpenClaw ToolResult가 어떤 정보를 담아야 하는지를 보여주는 개념적 예시입니다. 실제 필드명과 시그니처는 OpenClaw 공식 문서의 Agent loop 페이지를 확인해 주세요.
# 개념적 예시 — ToolResult가 담아야 할 최소 정보
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolResult:
success: bool
error: str | None # 모델이 읽을 수 있는 자연어 에러 설명
recoverable: bool # True면 복구 시도, False면 즉각 에스컬레이션
data: Any | None = None # 성공 시 반환 데이터여기서 error 필드의 내용이 매우 중요합니다. 단순히 "HTTP 503" 같은 기술 코드만 담으면 모델이 복구 방향을 잡지 못합니다. 이유(why), 힌트(hint), 재시도 가능 여부를 포함한 자연어 설명이 들어가야 합니다.
# 나쁜 예
ToolResult(
success=False,
error="HTTPError: 503",
recoverable=True,
)
# 좋은 예
ToolResult(
success=False,
error=(
"외부 결제 API가 일시적으로 응답 불가 상태입니다 (HTTP 503). "
"서비스가 과부하 상태일 가능성이 높습니다. "
"캐시된 결제 수단 목록을 대신 사용하거나, "
"이 단계를 건너뛰고 나중에 재시도하는 방법을 고려해 보세요."
),
recoverable=True,
)이 차이가 모델이 "서비스 일시 불가 상황"을 인지하고 캐시 사용이나 작업 건너뛰기 같은 결정을 자율적으로 내릴 수 있느냐 없느냐를 가릅니다.
복구 전략의 계층 설계
네 가지 기본 전략과 적용 시점
OpenClaw 계열 프레임워크에서 자주 쓰이는 복구 전략들을 언제 어떻게 쓸지 정리하면 다음과 같습니다.
| 전략 | 적용 상황 | 주의점 |
|---|---|---|
SELF_CORRECT |
모델 판단 오류, 잘못된 인자 전달 | 에러 품질에 결과 의존 |
RETRY_ONCE |
네트워크 타임아웃, 일시적 연결 오류 | 근본 원인 해결 안 됨 |
EXPONENTIAL_BACKOFF |
Rate limit, 서비스 과부하 | 인프라 레이어에 위임 권장 |
FALLBACK_TOOL |
주 도구 지속 실패, 대체 도구 존재 | 폴백 결과 품질 검증 필요 |
가장 자주 쓰게 되는 건 역시 SELF_CORRECT입니다. 에러 텍스트를 그대로 다음 추론 사이클의 컨텍스트에 주입해서 모델이 스스로 "뭐가 잘못됐고 어떻게 고칠지"를 판단하게 합니다.
SELF_CORRECT 실전 구현
# 개념적 예시 — SELF_CORRECT 전략 구현
import os
MAX_RETRIES = int(os.getenv("OPENCLAW_MAX_RETRIES", "3"))
def run_with_self_correct(agent, tool_call, context):
for attempt in range(MAX_RETRIES):
result = agent.execute_tool(tool_call, context)
if result.success:
return result
if not result.recoverable:
raise NonRecoverableError(result.error)
context.add_observation(
role="tool_error",
content=(
f"[도구 실행 실패 — 시도 {attempt + 1}/{MAX_RETRIES}]\n"
f"도구: {tool_call.name}\n"
f"오류: {result.error}\n"
f"재시도 가능: {result.recoverable}\n\n"
"위 오류를 참고해 다른 접근 방법을 시도해 주세요."
),
)
tool_call = agent.replan(context)
return ToolResult(
success=False,
error=f"최대 재시도 횟수({MAX_RETRIES})를 초과했습니다.",
recoverable=False,
)한 가지 팁으로, 재시도 횟수 상한과 별개로 재시도 사이의 대기 시간 상한도 함께 걸어두는 것이 좋습니다. 두 값을 하나의 환경변수에 섞어 쓰지 마시고, 이름을 분리해서 관리하시길 추천드립니다.
FALLBACK_TOOL로 폴백 체인 구성
recoverable=True를 감지해 대체 도구로 자동 전환하는 패턴입니다. 데이터 조회 에이전트에서 기본 SQL 도구가 실패하면 읽기 전용 캐시 도구로 전환하는 상황을 떠올리면 됩니다.
# 개념적 예시 — FALLBACK_TOOL 체인
TOOL_FALLBACK_CHAIN = {
"sql_query_tool": "readonly_cache_tool",
"payment_api_tool": "cached_payment_data_tool",
"realtime_search_tool": "indexed_search_tool",
}
def run_with_fallback(agent, tool_call, context):
result = agent.execute_tool(tool_call, context)
if result.success:
return result
fallback_tool_name = TOOL_FALLBACK_CHAIN.get(tool_call.name)
if not fallback_tool_name or not result.recoverable:
return result
context.add_observation(
role="tool_error",
content=(
f"{tool_call.name} 실패. {fallback_tool_name}으로 전환합니다. "
f"원인: {result.error}"
),
)
fallback_call = tool_call.with_name(fallback_tool_name)
return agent.execute_tool(fallback_call, context)ErrorBoundary라는 개념적 격리 영역
특정 도구나 단계를 "에러 격리 영역"으로 묶고 복구 전략을 미리 선언해 두는 방식은 여러 프레임워크에서 공통적으로 등장하는 패턴입니다. 아래는 어디까지나 개념적 예시입니다. OpenClaw에서 이 개념을 어떤 클래스명·시그니처로 노출하는지는 Retry policy 문서와 Tool plugins 문서를 확인해 주세요.
# 개념적 예시 — 격리 영역 선언 아이디어
class ErrorBoundary:
def __init__(self, strategy: str, **params):
self.strategy = strategy
self.params = params
enrichment_boundary = ErrorBoundary(
strategy="skip_and_note",
note_template="외부 데이터 보강 단계를 건너뜁니다. 이유: {error}",
)
cache_boundary = ErrorBoundary(
strategy="use_cached",
cache_key="last_successful_result",
)
payment_boundary = ErrorBoundary(
strategy="ask_human",
prompt="결제 처리 도구가 실패했습니다. 수동으로 처리하시겠습니까?",
)핵심 아이디어는 "어디까지가 격리 영역이고, 그 안에서 실패했을 때 무엇을 할지"를 코드 흐름과 분리해서 선언한다는 점입니다.
세션 내 자기 수정 루프
자기 비판을 컨텍스트에 누적하기
Shinn et al. (2023)의 Reflexion 논문은 에피소드 단위 자기평가 결과를 외부 메모리에 저장하고 다음 에피소드에 주입하는 구조입니다. 이 글에서 다루는 것은 그와 구조적으로 다른, 세션 내 자기 수정 루프입니다. Reflexion에서 영감을 받은 형태지만, "왜 실패했는지"에 대한 자기 비판을 같은 세션 안에서 컨텍스트에 쌓아 다음 시도에 반영한다는 점만 공유합니다.
코드 생성 에이전트에서 이 패턴이 특히 효과적입니다. 컴파일 에러를 ToolResult.error에 담아 다음 추론 사이클로 전달하면, 모델이 잘못된 import 경로나 타입 불일치를 읽고 수정된 코드를 생성합니다.
# 개념적 예시 — 세션 내 자기 수정 루프
def self_correction_loop(agent, task, max_attempts=5):
context = agent.build_context(task)
critique_history = []
for attempt in range(max_attempts):
result = agent.execute(context)
if result.success:
return result
critique = agent.generate_critique(
task=task,
attempt=result,
error=result.error,
history=critique_history,
)
critique_history.append(critique)
context.add_reflection(
content=f"시도 {attempt + 1} 실패 분석:\n{critique}"
)
return ToolResult(
success=False,
error="최대 시도 횟수 초과",
recoverable=False,
)루프 감지와 강제 개입
에이전트가 같은 도구를 같은 인자로 계속 호출하는 상황은 실무에서 흔합니다. 이걸 방치하면 API 비용이 조용히 폭발합니다. 아래는 정확히 동일한 도구 호출 반복을 감지하는 개념적 예시입니다. "의미적으로 동등하지만 표현이 다른 호출"까지 잡으려면 임베딩 기반 유사도가 필요하지만, 대부분의 실무 사례는 정확 중복 감지만으로도 충분합니다.
# 개념적 예시 — 정확히 동일한 도구 호출 반복 감지
from collections import deque
import hashlib
class LoopDetector:
def __init__(self, window=3, threshold=3):
self.recent_steps = deque(maxlen=window)
self.threshold = threshold
def record(self, step):
canonical = f"{step.tool_name}:{sorted(step.args.items())}"
step_hash = hashlib.md5(canonical.encode()).hexdigest()
self.recent_steps.append(step_hash)
def is_looping(self) -> bool:
if len(self.recent_steps) < self.threshold:
return False
return len(set(self.recent_steps)) == 1
def get_reflection_prompt(self) -> str:
return (
"현재 접근 방식이 반복적으로 실패하고 있습니다. "
"지금까지 시도한 방법을 재검토하고, "
"완전히 다른 도구나 전략을 선택해 주세요."
)이런 감지 로직이 트리거되면 "반성 프롬프트(reflection prompt)"를 다음 컨텍스트에 강제로 주입해서 모델의 계획을 재편성하게 만듭니다. 프레임워크가 내장 기능을 제공하는지 여부는 OpenClaw 공식 문서에서 확인하시고, 없다면 위와 같은 형태를 직접 얹으시면 됩니다.
LangGraph, PydanticAI와 함께 쓰기
LangGraph 조건부 엣지로 복구 노드 라우팅
LangGraph에서 도구 에러 발생 시 조건부 엣지(conditional edge)로 복구 노드에 라우팅하는 구조는 자주 쓰이는 패턴 중 하나입니다. 공식 문서의 Add and manage memory / Error handling 관련 예제를 참고하시면 실제 시그니처와 함께 확인할 수 있습니다.
# 개념적 예시 — LangGraph에서 에러 라우팅
from langgraph.graph import StateGraph, END
from typing import TypedDict, Literal
class AgentState(TypedDict):
messages: list
error_count: int
last_error: str | None
def route_on_error(state: AgentState) -> Literal["recover", "escalate", "continue"]:
if state.get("last_error") is None:
return "continue"
if state["error_count"] >= 3:
return "escalate"
return "recover"
def recovery_node(state: AgentState) -> AgentState:
error_message = {
"role": "tool",
"content": f"이전 시도 실패: {state['last_error']}. 다른 방법을 시도해 주세요.",
}
return {
**state,
"messages": state["messages"] + [error_message],
"last_error": None,
}
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.add_node("recover", recovery_node)
graph.add_node("escalate", escalate_node)
graph.add_conditional_edges(
"tools",
route_on_error,
{"continue": "agent", "recover": "recover", "escalate": END},
)
graph.add_edge("recover", "agent")PydanticAI ModelRetry로 검증 실패 재프롬프트
PydanticAI(Pydantic v2 기반)에서는 도구 함수나 결과 검증기 안에서 ModelRetry를 raise하면, 그 메시지가 모델의 재프롬프트로 전달됩니다. Pydantic 필드 validator 안이 아니라 도구 함수 본문이나 result validator 안에서 raise 하는 것이 실제 사용 방식입니다.
# 개념적 예시 — PydanticAI의 ModelRetry 사용
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
from pydantic_ai.exceptions import ModelRetry
class AnalysisResult(BaseModel):
summary: str
confidence: float
agent = Agent(
model="claude-sonnet-5",
result_type=AnalysisResult,
retries=3,
)
@agent.result_validator
async def validate_confidence(ctx: RunContext, result: AnalysisResult) -> AnalysisResult:
if not 0.0 <= result.confidence <= 1.0:
raise ModelRetry(
f"confidence는 0.0~1.0 사이여야 합니다. 받은 값: {result.confidence}"
)
return result이렇게 하면 "구조는 맞지만 값이 유효하지 않은" 응답에 대해서도 모델이 에러 문구를 읽고 스스로 재생성하도록 유도할 수 있습니다.
인프라 레이어와 에이전트 레이어의 분리
저도 처음엔 Rate limit 처리를 에이전트 루프 안에서 직접 구현했다가 복잡도가 크게 늘어나는 경험을 했습니다. 두 종류의 실패를 명확히 분리하는 편이 훨씬 깔끔합니다.
OpenClaw의 Model failover 문서에 따르면 과부하 및 속도 제한 오류 발생 시 동일 제공자의 인증 프로파일을 재시도한 후 폴백 모델로 전환하는 모델 폴백 기능을 내장하고 있습니다. Rate limit처럼 에이전트 로직과 무관한 인프라 문제는 LiteLLM 프록시 같은 레이어에서 처리하고, 에이전트 루프는 실제 작업 복구에만 집중하는 분리가 코드를 훨씬 단순하게 유지해 줍니다.
트레이드오프와 흔한 실수
장단점 요약
| 측면 | 장점 | 위험 |
|---|---|---|
| 복구 자율성 | 인간 개입 없이 모델이 복구 경로 선택 | 잘못 설계 시 비용 폭발 루프 |
| 관찰 가능성 | 구조화된 에러 객체로 로깅·분석 용이 | 에러 품질 낮으면 모델이 방향 못 잡음 |
| 점진적 복구 | RETRY → FALLBACK → REPLAN → ESCALATE 계층화 | 폴백 성공처럼 보이는 조용한 품질 저하 |
| 컨텍스트 누적 | 세션 내 동일 오류 반복 감소 | 긴 실행에서 컨텍스트 창 오염 위험 |
실무에서 자주 보이는 실수들
최대 재시도 없는 자가 복구 루프. 재시도 횟수 상한과 대기 시간 상한을 반드시 함께 설정해야 합니다. 설정하지 않으면 실패한 도구 호출이 수 분 만에 막대한 API 비용을 유발합니다.
스택 트레이스를 그대로 에러 필드에 넣기. 파이썬 스택 트레이스는 모델이 복구에 활용하기 어렵습니다. 원인, 힌트, 재시도 가능 여부를 자연어로 설명한 텍스트를 제공해야 합니다.
에러 분류 없이 모든 실패를 recoverable 처리. recoverable 필드를 제대로 분류하지 않으면 복구 불가능한 에러에도 재시도를 반복합니다. 사전에 recoverable과 non-recoverable 에러 스키마를 정의해 두는 것이 중요합니다.
조용한 품질 저하 미탐지. 폴백 도구가 성공적으로 응답했더라도 원래 도구보다 훨씬 낮은 품질의 결과를 반환할 수 있습니다. 폴백 체인에는 결과 품질 검증 단계를 포함시키는 것이 좋습니다.
시뮬레이션된 도구 호출 우회. OpenClaw에서 시뮬레이션된 도구 호출을 허용하는 이슈(#8288)가 보고된 바 있으니, 에러 주입 흐름이 의도치 않게 우회되지 않는지 프로덕션 배포 전 반드시 확인해 두시길 권장드립니다.
멀티에이전트 시스템으로 확장하려는 경우라면, 개별 에이전트 실패가 전체 파이프라인으로 캐스케이딩되는 것을 막는 벌크헤드(bulkhead) 패턴도 함께 검토해 볼 만합니다.
마무리
결국 자가 복구 루프 설계는 하나의 원칙으로 수렴됩니다.
에러는 예외가 아니라 데이터다.
모델은 정보가 있으면 판단할 수 있고, 정보가 없으면 멈춥니다. 에러 텍스트를 stack trace 덩어리로 두느냐, "무엇이·왜·어떻게 하면 될지"를 담은 자연어 문장으로 바꾸느냐 — 이 한 끗 차이가 우리 에이전트가 "장애 앞에서 얼어붙는 시스템"이 될지, "스스로 다음 수를 두는 시스템"이 될지를 가릅니다. 오늘 여러분의 에이전트가 마지막으로 실패했을 때, 그 실패는 모델의 컨텍스트에 어떤 문장으로 남아 있었는지 한번 열어보시길 권합니다. 거기서부터 시작하시면 됩니다.
참고 자료
- Agent loop · OpenClaw 공식 문서
- Retry policy · OpenClaw 공식 문서
- Model failover · OpenClaw 공식 문서
- Tool plugins · OpenClaw 공식 문서
- Bug #8288: Agent hangs on failed tool calls — openclaw/openclaw GitHub
- Reflexion: Language Agents with Verbal Reinforcement Learning — Shinn et al., 2023
- LangGraph 공식 문서
- PydanticAI 공식 문서
- LiteLLM 공식 문서
- Saving Crashed AI Agents: Simple Recovery for OpenClaw — Level Up Coding
- Mastering the OpenClaw Agentic Loop Upgrade — DEV Community
- Tool Call Failures in OpenClaw: Diagnosis & Solutions — ShopClawMart
- Failover and Retry Logic — DeepWiki
- AI Agent Failure Modes: Tool-Calling Errors, Infinite Loops & Propagation — Openlayer
- Agentic AI self-correction: How to build systems that fix their own mistakes — Weights & Biases
- LangGraph Error Handling: Retries & Fallback Strategies — machinelearningplus
- Agentic loops explained: From ReAct to loop engineering — Data Science Dojo
- Dissecting OpenClaw — Medium (Sau Sheong)
- How OpenClaw Works: Understanding AI Agents Through a Real Architecture — Medium (Bibek Poudel)
- AI Agent Error Handling: Best Practices & Patterns — Fastio
- AI Agent Retry Patterns - Exponential Backoff Guide — Fastio