단일 루프로 묶는 Claude Tool Use — stop_reason 분기, 병렬 호출, 오류 데이터화까지
경쟁사 가격을 세 군데서 동시에 가져오고, 관련 뉴스까지 함께 수집해야 하는 상황을 생각해보세요. 전통적인 방식이라면 API 호출 → 결과 파싱 → 집계 → 다음 단계 결정을 직접 코드로 짜야 합니다. 분기 로직, 부분 실패 처리, 재시도 정책까지 챙기다 보면 비즈니스 로직보다 인프라 코드가 더 많아집니다.
Claude API의 Tool Use는 이 문제를 다르게 접근합니다. 도구 정의만 Claude에게 넘겨주면, Claude가 어떤 도구를 언제 어떤 순서로 호출할지 스스로 결정합니다. 클라이언트는 그 결정을 실행하고 결과를 돌려주면 됩니다. 그리고 독립적인 도구 여러 개를 단 한 번의 응답에서 동시에 요청받을 수 있어서, 한 이터레이션 안에서 여러 외부 API를 동시에 처리할 수 있습니다(공식 문서).
이 글에서는 에이전틱 루프의 실제 구조, 병렬 도구 호출 구현, 오류를 데이터로 다루는 패턴, 그리고 프로덕션에서 마주치는 트레이드오프를 다룹니다. 경쟁사 가격 모니터링 파이프라인을 예시 삼아 루프를 처음부터 만들어보겠습니다.
에이전틱 루프의 뼈대 — stop_reason이 모든 분기를 결정한다
저도 처음 Tool Use를 쓸 때 가장 헷갈렸던 부분이 '루프를 어떻게 끝내느냐'였습니다. 답은 Claude의 응답에 담긴 stop_reason 필드에 있습니다. Messages API의 실제 값은 end_turn, max_tokens, stop_sequence, tool_use, pause_turn 다섯 가지입니다.
Claude가 stop_reason: "tool_use"를 반환하면 도구를 실행하고 결과를 돌려준 뒤 다시 호출합니다. stop_reason: "end_turn"이 오면 Claude가 최종 응답을 완성했다는 신호이므로 루프를 멈춥니다.
여기서 중요한 규칙이 하나 있습니다. 메시지 히스토리는 매번 새로 만들지 않고, 누적 배열에 계속 append합니다. 어시스턴트의 tool_use 블록이 포함된 응답 전체를 그대로 추가하고, 바로 뒤에 tool_result들을 담은 user 턴을 삽입하는 구조입니다. 이 누적 구조가 없으면 Claude가 이전 도구 호출 맥락을 잃어버립니다.
기본 뼈대를 코드로 보면 이렇습니다.
import anthropic
client = anthropic.Anthropic()
MAX_ITERATIONS = 25 # 예시 기준. 실제 상한은 파이프라인 특성에 맞춰 조정하세요.
def run_agentic_loop(initial_prompt: str, tools: list) -> str:
messages = [{"role": "user", "content": initial_prompt}]
for iteration in range(MAX_ITERATIONS):
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
for block in response.content:
if block.type == "text":
return block.text
return ""
elif response.stop_reason == "tool_use":
tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
# execute_tools_parallel은 다음 섹션에서 정의합니다
tool_results = asyncio.run(execute_tools_parallel(tool_use_blocks))
messages.append({"role": "user", "content": tool_results})
else:
# max_tokens, stop_sequence, pause_turn 등
raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")
raise RuntimeError(f"Max iterations ({MAX_ITERATIONS}) reached")stop_reason을 자연어로 판단하려는 시도(예: 응답 텍스트에 '완료'가 있는지 파싱)는 신뢰성이 없습니다. 반드시 이 필드를 기준으로 분기해야 합니다.
병렬 도구 호출 — 한 이터레이션에서 N개 도구 동시 실행
Claude는 단일 응답에서 독립적인 여러 도구를 tool_use 블록 배열로 한 번에 반환할 수 있습니다. 클라이언트가 이를 병렬 실행한 뒤 결과를 하나의 user 메시지에 담아 전송하면, 순차 호출이었다면 여러 이터레이션에 걸쳐 처리했을 도구들이 한 라운드로 압축됩니다. 참고로 이 기능은 Claude 3.5 Sonnet을 포함한 Claude 3 계열부터 지원됩니다(공식 문서). API 왕복 자체가 사라지는 것은 아니고, 도구 호출/결과 전달이 하나의 라운드로 통합된다는 점을 명확히 해둘 필요가 있습니다.
각 tool_result는 tool_use_id로 대응되는 호출과 연결됩니다. Claude는 모든 결과를 받은 뒤 종합 추론을 수행합니다. 위 그림처럼 최소 두 번의 Claude API 왕복이 필요합니다.
병렬 실행 코드는 asyncio.gather로 처리합니다. 여기서는 개별 tool 함수 내부에서 예외를 잡아 is_error로 변환하는 방식으로 통일합니다.
import asyncio
async def execute_single_tool(block) -> dict:
try:
result_content = await dispatch_tool(block.name, block.input)
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": result_content,
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": block.id,
"is_error": True,
"content": f"Tool execution failed: {type(e).__name__}: {str(e)}",
}
async def execute_tools_parallel(tool_use_blocks: list) -> list:
tasks = [execute_single_tool(block) for block in tool_use_blocks]
return await asyncio.gather(*tasks)execute_single_tool 내부에서 이미 모든 예외를 흡수하므로 return_exceptions=True나 이후 isinstance(result, Exception) 체크는 실질적으로 dead code입니다. 예외 처리 경로는 한 곳에만 두는 편이 읽기도 쉽고 실수도 줄어듭니다.
실전 코드 — 경쟁사 가격 모니터링 파이프라인
이제 도구 정의부터 전체 루프까지 실제 파이프라인을 조립해보겠습니다. 세 가지 데이터 소스(가격 API, 뉴스 검색, 시장 통계)를 동시에 수집해 종합 분석을 내놓는 시나리오입니다.
import anthropic
import asyncio
import json
client = anthropic.Anthropic()
TOOLS = [
{
"name": "fetch_price",
"description": "경쟁사의 특정 제품 가격을 가져옵니다",
"input_schema": {
"type": "object",
"properties": {
"competitor": {
"type": "string",
"description": "경쟁사 식별자 (예: alpha, beta, gamma)",
},
"product_id": {
"type": "string",
"description": "조회할 제품 ID",
},
},
"required": ["competitor", "product_id"],
},
},
{
"name": "search_news",
"description": "특정 키워드로 최신 뉴스 기사를 검색합니다",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "검색 키워드"},
"max_results": {
"type": "integer",
"description": "반환할 최대 결과 수",
"default": 5,
},
},
"required": ["query"],
},
},
{
"name": "get_market_data",
"description": "카테고리별 시장 통계와 트렌드 데이터를 가져옵니다",
"input_schema": {
"type": "object",
"properties": {
"category": {"type": "string", "description": "제품 카테고리"},
},
"required": ["category"],
},
},
]
# 개념적 예시. 실제 환경에서는 httpx.AsyncClient 등으로 외부 API를 호출합니다.
async def dispatch_tool(name: str, inputs: dict) -> str:
if name == "fetch_price":
await asyncio.sleep(0.3)
return json.dumps({"competitor": inputs["competitor"], "price": 29900, "currency": "KRW"})
elif name == "search_news":
await asyncio.sleep(0.5)
return json.dumps({"articles": [{"title": f"{inputs['query']} 관련 최신 뉴스", "sentiment": "neutral"}]})
elif name == "get_market_data":
await asyncio.sleep(0.4)
return json.dumps({"category": inputs["category"], "market_size": "1.2조 원", "growth_rate": "8.3%"})
raise ValueError(f"Unknown tool: {name}")
async def execute_single_tool(block) -> dict:
try:
content = await dispatch_tool(block.name, block.input)
return {"type": "tool_result", "tool_use_id": block.id, "content": content}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": block.id,
"is_error": True,
"content": f"{type(e).__name__}: {str(e)}",
}
async def execute_tools_parallel(tool_use_blocks: list) -> list:
return await asyncio.gather(*(execute_single_tool(b) for b in tool_use_blocks))
SYSTEM_PROMPT = (
"당신은 경쟁 환경 분석 에이전트입니다. 여러 도구를 병렬로 호출해 데이터를 수집하세요.\n"
"도구가 is_error로 실패하면: (1) 파라미터 오류로 보이면 수정 후 최대 1회 재시도, "
"(2) 외부 API 장애로 보이면 대체 소스가 있는지 판단, (3) 복구가 어려우면 해당 데이터 "
"누락을 명시하고 나머지로 분석을 완성하세요. 무한 재시도는 금지합니다."
)
async def run_pipeline(product_id: str, category: str) -> str:
prompt = (
f"제품 ID '{product_id}'에 대해 경쟁사 3곳(alpha, beta, gamma)의 가격을 조회하고, "
f"'{category}' 카테고리 관련 최신 뉴스와 시장 데이터를 수집한 뒤 "
"종합적인 경쟁 환경 분석을 제공해주세요."
)
messages = [{"role": "user", "content": prompt}]
MAX_ITERATIONS = 25
for iteration in range(MAX_ITERATIONS):
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
for block in response.content:
if block.type == "text":
return block.text
return ""
elif response.stop_reason == "tool_use":
tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
print(f"[{iteration+1}] Claude가 {len(tool_use_blocks)}개 도구 병렬 실행 요청")
tool_results = await execute_tools_parallel(tool_use_blocks)
messages.append({"role": "user", "content": tool_results})
else:
raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")
raise RuntimeError(f"Max iterations ({MAX_ITERATIONS}) exceeded")
if __name__ == "__main__":
result = asyncio.run(run_pipeline(product_id="PROD-001", category="소비자가전"))
print(result)한 가지 짚고 넘어갈 점이 있습니다. 루프 전체를 async def로 두고 최상단에서만 asyncio.run()을 한 번 호출하도록 만들었습니다. 초안처럼 매 이터레이션마다 asyncio.run()을 호출하면 FastAPI 핸들러나 Jupyter 노트북처럼 이미 이벤트 루프가 있는 환경에서 RuntimeError: This event loop is already running으로 죽습니다. 이런 환경에 통합할 계획이라면 await run_pipeline(...)으로 직접 await하거나, 정 동기 컨텍스트에서 호출해야 한다면 nest_asyncio.apply() 같은 우회를 고려하세요.
오류를 예외가 아닌 데이터로 다루기
에이전틱 파이프라인에서 도구 실패를 Python 예외로 처리하면 루프 전체가 죽습니다. 대신 is_error: true와 오류 메시지를 tool_result에 담아 Claude에게 돌려주면, Claude가 이를 컨텍스트로 삼아 다음 행동을 결정할 수 있습니다.
여기서 중요한 실무 팁이 있습니다. 오류를 넘겨준다고 해서 Claude가 알아서 재시도하거나 대체 전략을 세워주지는 않습니다. 시스템 프롬프트에 복구 지침이 없으면 오류를 그냥 요약하고 end_turn으로 끝내버리는 경우가 흔합니다. 앞선 SYSTEM_PROMPT처럼 '실패 시 파라미터 수정 후 재시도 최대 1회', '외부 API 장애면 대체 소스 판단', '복구 어렵다면 누락을 명시하고 나머지로 분석 완성' 같은 규칙을 명시해야 우리가 기대하는 복구 흐름이 재현됩니다.
오류 반환 패턴 자체는 단순합니다.
import httpx
# 잘못된 방식 — 루프 전체를 죽입니다
async def bad_tool_handler(block):
result = await call_external_api(block.input) # 실패 시 예외 발생
return result
# 올바른 방식 — 오류를 데이터로 Claude에게 전달합니다
async def good_tool_handler(block) -> dict:
try:
content = await call_external_api(block.input)
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": content,
}
except httpx.TimeoutException:
return {
"type": "tool_result",
"tool_use_id": block.id,
"is_error": True,
"content": (
f"API timeout for {block.name} with input {block.input}. "
"Last successful call was over 2 hours ago. "
"Consider retrying once or noting the missing data."
),
}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": block.id,
"is_error": True,
"content": f"Unexpected error: {type(e).__name__}: {str(e)}",
}오류 메시지는 Claude가 판단할 수 있게 충분히 구체적으로 써주는 것이 좋습니다. error occurred보다 timeout after 5s, last successful call was 2h ago 쪽이 다음 행동을 결정하기에 훨씬 유용합니다.
프로덕션에서 마주치는 트레이드오프
솔직히 말하면 에이전틱 파이프라인은 생각보다 까다롭습니다. 개념은 단순하지만 프로덕션 환경에서는 여러 복잡도가 더해집니다.
장단점 한눈에 보기
| 항목 | 장점 | 고려사항 |
|---|---|---|
| 병렬 도구 호출 | 한 이터레이션에 N개 도구를 병렬로 실행 → 벽시계 시간 단축 | Claude API 왕복 자체는 최소 2회 유지 |
| 오류 복구 | Claude가 상황 판단 후 다른 도구·다른 파라미터로 우회 가능 | 시스템 프롬프트에 복구 지침을 명시해야 실제로 작동 |
| 도구 정의 = 계약 | 인터페이스가 명확해 유지보수성 높음 | 매 요청마다 컨텍스트에 포함 → 토큰 비용 |
| 동적 태스크 계획 | 미리 정해진 플로우 없이 Claude가 계획 | 복잡한 다단계 태스크에서 실패율이 급격히 상승할 수 있음 |
| 메시지 히스토리 누적 | 컨텍스트 유지로 복잡한 다단계 작업 가능 | 히스토리가 길어질수록 컨텍스트 창 압박 |
개별 정확도의 함정
에이전트 하나의 정확도가 95%여도, 5단계가 직렬로 협력하면 각 단계 성공률이 독립이라는 단순 가정만 놓고 봐도 전체 성공률은 약 77%(0.95⁵)로 떨어집니다. 실제로는 조율 오버헤드와 컨텍스트 손실이 겹치기 때문에 더 나빠질 수 있습니다. 파이프라인이 복잡해질수록 각 단계의 신뢰성 기준을 높게 잡아야 합니다.
프로덕션에서 꼭 챙겨야 할 것들
루프 이터레이션 상한 설정: 저는 대화형 플로우는 10회, 배치 작업은 25회를 상한으로 잡아 쓰고 있는데, 이건 업계 표준이 아니라 제 경험에서 나온 예시값입니다. 파이프라인의 평균 이터레이션 수를 계측한 뒤 그 위쪽에 안전 마진을 두는 방식이 정공법입니다. 무한 루프가 돌면 API 비용이 기하급수적으로 증가하니 어떤 값이든 상한 자체는 반드시 두세요.
도구별 타임아웃: 느린 도구 하나가 전체 asyncio.gather를 블로킹하지 않도록 per-tool 타임아웃을 설정합니다.
async def execute_single_tool_with_timeout(block, timeout_seconds: float = 10.0) -> dict:
try:
content = await asyncio.wait_for(
dispatch_tool(block.name, block.input),
timeout=timeout_seconds,
)
return {"type": "tool_result", "tool_use_id": block.id, "content": content}
except asyncio.TimeoutError:
return {
"type": "tool_result",
"tool_use_id": block.id,
"is_error": True,
"content": f"Tool '{block.name}' timed out after {timeout_seconds}s",
}도구 정의 프롬프트 캐싱: 도구 정의는 모든 요청의 컨텍스트에 포함되므로, Anthropic의 프롬프트 캐싱을 적용하면 반복 호출 비용을 줄일 수 있습니다. 대규모 파이프라인에서 비용 최적화의 핵심 전략입니다.
관찰성(Observability): 세션 평균 이터레이션 수, 도구별 레이턴시, is_error 빈도를 계측하세요. 평균 이터레이션이 갑자기 늘어나면 도구 스키마 변경이나 다운스트림 API 변경의 신호일 수 있습니다. LangSmith, Langfuse 같은 LLM 특화 모니터링 도구와 연동하는 패턴이 자리 잡고 있습니다.
최근 추가된 기능들 (2026년 기준)
기본 루프를 잘 이해했다면 최근 추가된 기능들이 어디에 도움이 되는지 파악하기 쉬워집니다. 아래 내용은 시점에 따라 베타/일반 공개 상태가 바뀔 수 있으니 사용 전 Anthropic 공식 문서에서 현재 상태와 정확한 필드명을 다시 확인하세요.
Programmatic Tool Calling: Claude가 개별 API 왕복 없이 단일 추론 패스에서 코드를 작성·실행해 여러 도구를 오케스트레이션하는 방식입니다. 여러 도구 결과를 코드로 필터링·집계한 뒤 필요한 부분만 다시 Claude 컨텍스트에 올려 토큰 사용량을 줄일 수 있습니다. 활성화에 필요한 필드명은 릴리스에 따라 달라지므로 공식 문서 스키마를 그대로 참고하는 편이 안전합니다.
Tool Search: 수백 개의 도구 정의를 컨텍스트에 미리 로드하지 않고, 에이전트가 필요할 때 도구를 검색·발견하는 방식입니다. 도구 수가 많아 컨텍스트 창이 부담스러운 파이프라인에서 직접적인 최적화 수단이 됩니다.
Claude Agent SDK: 기존 Claude Code SDK가 개명된 공식 라이브러리입니다. 비동기 설계로 여러 에이전트 대화를 동시에 실행할 수 있으며, 파일 읽기, 셸 명령, 웹 검색, MCP 서버 호출을 단일 인터페이스로 통합합니다.
Temporal + LangGraph 조합: 프로덕션 시스템에서 Temporal(워크플로우 내구성·재시도·상태 영속성)과 LangGraph(LLM 로직·도구 호출·메모리)를 조합하는 패턴이 자리를 잡고 있습니다. Claude API를 LangGraph의 LLM 백엔드로 연동하는 방식입니다. 단순한 파이프라인은 직접 구현한 루프로도 충분하지만, 장기 실행 워크플로우나 체크포인트가 필요한 경우 이 조합을 고려해볼 만합니다.
마무리
Claude Tool Use로 파이프라인을 여러 번 만들면서 제가 가장 크게 느낀 건, 에이전틱 루프의 신뢰성은 결국 도구가 아니라 '오류가 났을 때 다음 행동을 어떻게 지시했느냐'로 결정된다는 점입니다. stop_reason 분기, 병렬 실행, is_error 반환 같은 뼈대 규칙은 하루면 익힐 수 있습니다. 하지만 시스템 프롬프트에 복구 지침이 없으면 Claude는 오류를 그저 정중하게 요약하고 손을 놓아버리고, 이터레이션 상한이 없으면 API 청구서로 그 대가를 치릅니다.
새 파이프라인을 세팅할 때 저는 while 루프의 뼈대보다 시스템 프롬프트의 실패 대응 조항과 이터레이션 상한, 그리고 is_error 빈도 계측 이 세 가지에 훨씬 더 많은 시간을 씁니다. 도구 몇 개를 새로 붙이는 것보다, 이미 있는 도구들이 실패했을 때 어떻게 움직이길 원하는지 문장으로 명시해두는 편이 프로덕션 안정성에 훨씬 크게 기여했습니다.