LLM에게 "서울 날씨 알려줘"라고 물으면 한 번의 호출로 답이 나옵니다. 하지만 "우리 DB에서 지난달 매출 1위 상품을 찾고, 그 상품의 현재 재고를 외부 ERP에서 조회한 뒤, 부족하면 발주 수량을 계산해줘"라고 하면 한 번의 호출로는 불가능합니다. 모델이 도구를 호출하고 → 결과를 받고 → 그 결과를 보고 다시 다음 도구를 호출하는 반복(loop)이 필요합니다. 이것이 에이전트 루프(agent loop)의 핵심입니다.

이 글에서는 프레임워크 없이 Claude Python SDK만으로 에이전트 루프를 직접 구현합니다. while stop_reason == "tool_use" 수동 루프를 완성하고, 실제 도구(DB 쿼리·외부 REST 호출·계산기)를 연결하며, 다중·병렬 도구 호출, 부작용 도구의 사람 승인(휴먼인더루프), 무한루프 방지까지 다룹니다. 모든 코드는 실제로 동작하는 형태입니다.

단일 호출 vs 에이전트 루프

두 방식의 차이를 먼저 명확히 합시다. 단일 호출은 요청 1건, 응답 1건입니다. 분류, 요약, 추출, 단순 Q&A처럼 한 번에 끝나는 작업에 적합합니다. 도구를 주더라도 모델이 도구를 한 번 호출하면 거기서 끝납니다 — 결과를 다시 모델에게 돌려주지 않으면 모델은 그 결과를 "보지" 못합니다.

에이전트 루프는 모델이 작업이 끝났다고 판단할 때까지(stop_reasontool_use가 아닐 때까지) 호출을 반복합니다. 모델이 도구 호출을 요청하면 → 우리 코드가 실행하고 → 결과를 메시지에 추가해 다시 모델을 호출합니다. 모델은 누적된 대화를 보며 다음 행동을 결정합니다.

구분단일 호출에이전트 루프
API 호출 횟수1회N회 (모델이 결정)
적합한 작업분류·요약·추출·단순 Q&A다단계 조사, 도구 체이닝, 자율 작업
제어 주체없음 (한 번에 끝)당신의 코드가 루프를 제어
비용·지연낮음·예측 가능높음·가변
오류 복구재시도뿐모델이 결과 보고 경로 수정 가능

중요한 판단 기준: 먼저 가장 단순한 방식을 시도하세요. 작업이 사전에 완전히 명세 가능하고 한 번에 끝난다면 단일 호출로 충분합니다. 작업이 다단계이고 미리 모든 경로를 정할 수 없을 때만 에이전트 루프로 올라가세요.

Claude 도구 호출의 기본 구조

루프를 만들기 전에 Claude의 도구(function calling) 메커니즘을 짚겠습니다. 도구는 tools 배열로 정의하고, 각 도구는 name, description, input_schema(JSON Schema)를 가집니다.

from anthropic import Anthropic

client = Anthropic()  # ANTHROPIC_API_KEY 환경변수 사용

tools = [
    {
        "name": "get_weather",
        "description": "특정 도시의 현재 날씨를 조회합니다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "도시 이름, 예: 서울"},
            },
            "required": ["city"],
        },
    }
]

msg = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=tools,
    messages=[{"role": "user", "content": "서울 날씨 어때?"}],
)

print(msg.stop_reason)   # "tool_use"
for block in msg.content:
    if block.type == "tool_use":
        print(block.name)   # "get_weather"
        print(block.input)  # {"city": "서울"}  ← 이미 파싱된 dict
        print(block.id)     # "toolu_01..."  ← tool_result에 다시 넣어야 함

핵심 포인트가 몇 가지 있습니다.

  • msg.stop_reason == "tool_use" — 모델이 도구를 호출하고 싶을 때의 정지 이유입니다. 이것이 루프의 종료 조건이 됩니다.
  • block.input은 이미 파싱된 dict입니다. 원시 JSON 문자열을 직접 매칭하지 마세요. (원시 문자열이 오는 경우라면 json.loads로 파싱합니다.)
  • block.id는 결과를 돌려줄 때 tool_use_id로 그대로 사용합니다 — 어떤 호출에 대한 결과인지 짝을 맞추는 키입니다.
  • 도구 결과를 모델에게 돌려주려면 messagesassistant 턴(msg.content 전체)user 턴(tool_result 블록)을 추가합니다.

수동 에이전트 루프 완전 구현

이제 본론입니다. while stop_reason == "tool_use"로 루프를 완전히 구현합니다. 실제 도구 3종 — SQLite DB 쿼리, 외부 REST 호출, 계산기 — 을 연결합니다.

먼저 실제 도구 함수들을 정의합니다. 이 함수들이 진짜로 동작하는 부분입니다.

import json
import sqlite3
import urllib.request

# --- 실제 도구 구현 ---

def query_sales_db(product_keyword: str) -> str:
    """SQLite DB에서 상품 매출을 조회한다. (데모용 인메모리 DB)"""
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE sales (product TEXT, revenue INTEGER)")
    conn.executemany(
        "INSERT INTO sales VALUES (?, ?)",
        [("무선마우스", 1200000), ("기계식키보드", 3400000), ("USB허브", 450000)],
    )
    cur = conn.execute(
        "SELECT product, revenue FROM sales WHERE product LIKE ? ORDER BY revenue DESC",
        (f"%{product_keyword}%",),
    )
    rows = cur.fetchall()
    conn.close()
    if not rows:
        return json.dumps({"error": "검색 결과 없음"}, ensure_ascii=False)
    return json.dumps(
        [{"product": p, "revenue": r} for p, r in rows], ensure_ascii=False
    )


def fetch_exchange_rate(base: str, target: str) -> str:
    """외부 REST API를 호출해 환율을 가져온다."""
    url = f"https://open.er-api.com/v6/latest/{base}"
    try:
        with urllib.request.urlopen(url, timeout=10) as resp:
            data = json.loads(resp.read().decode())
        rate = data.get("rates", {}).get(target)
        if rate is None:
            return json.dumps({"error": f"{target} 환율 없음"}, ensure_ascii=False)
        return json.dumps({"base": base, "target": target, "rate": rate}, ensure_ascii=False)
    except Exception as e:
        # 도구 실행 실패도 모델에게 알려준다 (아래 is_error 참고)
        return json.dumps({"error": str(e)}, ensure_ascii=False)


def calculate(expression: str) -> str:
    """안전한 산술 계산기. eval 금지 — AST로 제한된 연산만 허용."""
    import ast
    import operator as op

    ops = {
        ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul,
        ast.Div: op.truediv, ast.Pow: op.pow, ast.USub: op.neg,
    }

    def _eval(node):
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp):
            return ops[type(node.op)](_eval(node.left), _eval(node.right))
        if isinstance(node, ast.UnaryOp):
            return ops[type(node.op)](_eval(node.operand))
        raise ValueError("허용되지 않은 식")

    try:
        tree = ast.parse(expression, mode="eval")
        return json.dumps({"result": _eval(tree.body)}, ensure_ascii=False)
    except Exception as e:
        return json.dumps({"error": str(e)}, ensure_ascii=False)


# --- 도구 이름 → 함수 매핑 (디스패치 테이블) ---
TOOL_FUNCTIONS = {
    "query_sales_db": lambda inp: query_sales_db(inp["product_keyword"]),
    "fetch_exchange_rate": lambda inp: fetch_exchange_rate(inp["base"], inp["target"]),
    "calculate": lambda inp: calculate(inp["expression"]),
}

다음으로 도구 스키마를 정의합니다. description은 모델이 "언제 이 도구를 쓸지" 판단하는 근거이므로, 단순히 무엇을 하는지가 아니라 언제 호출해야 하는지를 명시하는 것이 호출 정확도를 높입니다.

tool_schemas = [
    {
        "name": "query_sales_db",
        "description": "사내 매출 데이터베이스에서 상품별 매출을 조회한다. "
                       "사용자가 매출, 판매량, 인기 상품을 물을 때 호출한다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "product_keyword": {
                    "type": "string",
                    "description": "검색할 상품 키워드 (부분 일치). 전체 조회 시 빈 문자열.",
                },
            },
            "required": ["product_keyword"],
        },
    },
    {
        "name": "fetch_exchange_rate",
        "description": "실시간 환율을 외부 API에서 조회한다. 통화 환산이 필요할 때 호출한다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "base": {"type": "string", "description": "기준 통화 코드, 예: USD"},
                "target": {"type": "string", "description": "대상 통화 코드, 예: KRW"},
            },
            "required": ["base", "target"],
        },
    },
    {
        "name": "calculate",
        "description": "산술 계산을 수행한다. 곱셈·나눗셈 등 정확한 수치 계산이 필요할 때 호출한다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "파이썬 산술식, 예: 1200000 * 1300.5",
                },
            },
            "required": ["expression"],
        },
    },
]

그리고 루프 본체입니다. 이 함수가 에이전트 루프의 심장입니다.

def run_agent(user_prompt: str, max_iterations: int = 10) -> str:
    messages = [{"role": "user", "content": user_prompt}]

    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=2048,
        tools=tool_schemas,
        messages=messages,
    )

    iterations = 0
    # 핵심 루프: 모델이 도구 호출을 멈출 때까지 반복
    while response.stop_reason == "tool_use":
        iterations += 1
        if iterations > max_iterations:
            # 무한루프 방지 — 아래 별도 섹션에서 자세히 설명
            raise RuntimeError(f"최대 반복 횟수({max_iterations}) 초과")

        # 1) assistant 턴(도구 호출 포함)을 대화에 추가
        messages.append({"role": "assistant", "content": response.content})

        # 2) 이번 응답의 모든 tool_use 블록을 실행 (다중 호출 처리)
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            func = TOOL_FUNCTIONS.get(block.name)
            try:
                if func is None:
                    result, is_error = f"알 수 없는 도구: {block.name}", True
                else:
                    result, is_error = func(block.input), False
            except Exception as e:
                # 도구가 던진 예외도 모델에게 전달해 스스로 복구하게 한다
                result, is_error = f"도구 실행 오류: {e}", True

            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,   # 어떤 호출의 결과인지 짝 맞추기
                "content": result,
                "is_error": is_error,
            })

        # 3) 도구 결과를 user 턴으로 추가
        messages.append({"role": "user", "content": tool_results})

        # 4) 누적된 대화로 다시 모델 호출
        response = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=2048,
            tools=tool_schemas,
            messages=messages,
        )

    # stop_reason이 "end_turn" 등 — 최종 텍스트 응답 추출
    final_text = "".join(
        block.text for block in response.content if block.type == "text"
    )
    return final_text


print(run_agent(
    "USB허브의 사내 매출을 조회하고, 그 금액을 현재 환율로 미국 달러(USD)로 환산해줘."
))

이 한 번의 요청에서 모델은 보통 이렇게 동작합니다: ① query_sales_db로 USB허브 매출(450,000원) 조회 → ② fetch_exchange_rate로 USD→KRW 환율 조회 → ③ calculate로 나눗셈 → ④ 최종 답변 작성. 우리는 루프만 돌렸을 뿐, 어떤 도구를 어떤 순서로 쓸지는 모델이 결정했습니다.

꼭 지켜야 할 규칙을 정리합니다.

  • assistant 응답(response.content)을 통째로 메시지에 추가하세요. 텍스트만 뽑아 추가하면 tool_use 블록이 사라져 다음 호출이 깨집니다.
  • tool_result에는 반드시 짝이 되는 tool_use_id를 넣으세요.
  • 한 응답의 모든 도구 호출을 처리한 뒤 결과를 하나의 user 메시지로 묶어 보내세요(다중 호출 처리).
  • 도구 실행 실패는 is_error: true로 표시해 모델에게 알려주면, 모델이 다른 접근을 시도하거나 사용자에게 되묻습니다.

다중·병렬 도구 호출과 disable_parallel_tool_use

Claude는 기본적으로 한 응답에서 여러 도구를 동시에 호출할 수 있습니다. 예를 들어 "서울과 부산 날씨를 동시에 알려줘"라면 get_weather 블록 2개를 한 응답에 담아 보냅니다. 위 루프의 for block in response.content가 이미 이를 처리하고 있습니다 — 모든 tool_use 블록을 순회해 실행하고 결과를 한 묶음으로 반환합니다.

서로 독립적인 읽기 전용 도구라면 실제로 병렬 실행해 지연을 줄일 수 있습니다.

from concurrent.futures import ThreadPoolExecutor

def execute_tools_parallel(response):
    """한 응답의 여러 tool_use 블록을 스레드풀로 병렬 실행한다."""
    tool_use_blocks = [b for b in response.content if b.type == "tool_use"]

    def run_one(block):
        func = TOOL_FUNCTIONS.get(block.name)
        try:
            result, is_error = func(block.input), False
        except Exception as e:
            result, is_error = f"오류: {e}", True
        return {
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": result,
            "is_error": is_error,
        }

    with ThreadPoolExecutor(max_workers=4) as pool:
        # 입력 순서와 무관하게 결과를 모은다 — tool_use_id로 짝이 맞으므로 순서는 상관없음
        return list(pool.map(run_one, tool_use_blocks))

반대로 도구가 부작용을 가지거나(예: send_email, git_push) 순서가 중요하면, 병렬 호출을 막고 한 번에 하나씩만 호출하도록 강제해야 합니다. 이때 tool_choicedisable_parallel_tool_use를 설정합니다.

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=2048,
    tools=tool_schemas,
    tool_choice={"type": "auto", "disable_parallel_tool_use": True},
    messages=messages,
)

tool_choice 옵션도 정리해 둡니다. 어떤 값을 쓰든 disable_parallel_tool_use: true를 함께 넣어 응답당 도구를 1개로 제한할 수 있습니다.

tool_choice동작
{"type": "auto"}모델이 도구 사용 여부를 결정 (기본값)
{"type": "any"}아무 도구나 반드시 하나 사용
{"type": "tool", "name": "..."}지정한 특정 도구를 강제 사용
{"type": "none"}도구를 사용하지 않음

부작용 도구의 사람 승인 (휴먼인더루프)

이메일 전송, 데이터 삭제, 결제, 외부 POST 요청처럼 되돌리기 어려운(side-effecting) 도구는 자동으로 실행하면 위험합니다. 수동 루프의 가장 큰 장점이 바로 여기 있습니다 — 도구를 실행하기 직전에 우리가 개입해 사람의 승인을 받을 수 있습니다. SDK의 자동 도구 러너가 아니라 직접 루프를 짜는 이유 중 하나입니다.

접근법은 간단합니다. 도구를 "자동 실행 가능"과 "승인 필요"로 분류하고, 승인 필요 도구를 만나면 실행 전에 사람에게 묻습니다. 거부 시 그 사유를 tool_result로 모델에게 돌려주면 모델이 다른 방법을 찾습니다.

# 부작용이 있어 사람 승인이 필요한 도구 집합
REQUIRES_APPROVAL = {"send_email", "delete_record", "create_order"}


def send_email(to: str, subject: str, body: str) -> str:
    # 실제로는 SMTP/SES 등으로 발송
    return json.dumps({"status": "sent", "to": to}, ensure_ascii=False)


TOOL_FUNCTIONS_HITL = {
    **TOOL_FUNCTIONS,
    "send_email": lambda inp: send_email(inp["to"], inp["subject"], inp["body"]),
}


def request_human_approval(name: str, tool_input: dict) -> tuple[bool, str]:
    """실제 서비스에서는 Slack 승인 버튼, 웹 대시보드 등으로 대체한다."""
    print(f"\n[승인 요청] 도구: {name}")
    print(f"  입력: {json.dumps(tool_input, ensure_ascii=False, indent=2)}")
    answer = input("  실행을 승인하시겠습니까? (y/n): ").strip().lower()
    if answer == "y":
        return True, ""
    reason = input("  거부 사유(모델에게 전달): ").strip()
    return False, reason or "사용자가 실행을 거부함"


def run_agent_with_approval(user_prompt: str, max_iterations: int = 10) -> str:
    messages = [{"role": "user", "content": user_prompt}]
    response = client.messages.create(
        model="claude-opus-4-8", max_tokens=2048,
        tools=tool_schemas_hitl, messages=messages,
    )

    iterations = 0
    while response.stop_reason == "tool_use":
        iterations += 1
        if iterations > max_iterations:
            raise RuntimeError("최대 반복 횟수 초과")

        messages.append({"role": "assistant", "content": response.content})
        tool_results = []

        for block in response.content:
            if block.type != "tool_use":
                continue

            # 승인 게이트: 실행 "전에" 사람에게 묻는다
            if block.name in REQUIRES_APPROVAL:
                approved, reason = request_human_approval(block.name, block.input)
                if not approved:
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": f"실행이 거부되었습니다. 사유: {reason}",
                        "is_error": True,
                    })
                    continue  # 도구를 실행하지 않고 거부 사유만 전달

            func = TOOL_FUNCTIONS_HITL.get(block.name)
            try:
                result, is_error = func(block.input), False
            except Exception as e:
                result, is_error = f"도구 오류: {e}", True
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result,
                "is_error": is_error,
            })

        messages.append({"role": "user", "content": tool_results})
        response = client.messages.create(
            model="claude-opus-4-8", max_tokens=2048,
            tools=tool_schemas_hitl, messages=messages,
        )

    return "".join(b.text for b in response.content if b.type == "text")

거부 사유를 is_error: true와 함께 돌려주는 것이 핵심입니다. 모델은 "이메일 발송이 거부됨, 사유: 외부 도메인 금지"를 받으면 사용자에게 대안을 제안하거나 내부 주소로 다시 시도합니다. 승인 UI는 CLI input() 대신 실서비스에서는 Slack 승인 버튼이나 웹 대시보드로 교체하면 됩니다.

무한루프 방지 — 최대 반복

에이전트 루프의 가장 현실적인 위험은 무한루프입니다. 모델이 같은 도구를 계속 호출하거나, 도구 오류를 보고 또 같은 도구를 시도하며 빠져나오지 못할 수 있습니다. 각 반복은 실제 API 비용과 지연을 발생시키므로 반드시 상한을 둬야 합니다.

위 코드들에 이미 max_iterations 카운터를 넣었습니다. 운영 환경에서는 여기에 몇 가지를 더합니다.

  • 반복 횟수 상한: 가장 기본. 초과 시 예외를 던지거나, 모델에게 "이제 도구 없이 최종 답을 정리하라"고 마지막 호출을 한 번 더 보냅니다.
  • 토큰·시간 예산: 누적 입력/출력 토큰이나 경과 시간(time.monotonic())으로도 끊습니다.
  • 동일 호출 반복 감지: 같은 (도구명, 입력) 조합이 반복되면 조기 종료합니다.

상한 초과 시 "강제 종료" 대신 "도구를 끄고 마지막 정리를 요청"하는 우아한 종료 패턴을 권장합니다.

def run_agent_safe(user_prompt: str, max_iterations: int = 8) -> str:
    messages = [{"role": "user", "content": user_prompt}]
    response = client.messages.create(
        model="claude-opus-4-8", max_tokens=2048,
        tools=tool_schemas, messages=messages,
    )

    seen_calls = set()  # 동일 호출 반복 감지용
    iterations = 0

    while response.stop_reason == "tool_use":
        iterations += 1

        # 상한 도달: 도구를 끄고(tool_choice=none) 최종 답변만 받는다
        if iterations >= max_iterations:
            messages.append({"role": "assistant", "content": response.content})
            # 미처리 tool_use에 대한 결과를 채워 대화 정합성을 맞춘다
            messages.append({"role": "user", "content": [
                {"type": "tool_result", "tool_use_id": b.id,
                 "content": "반복 한도 도달 — 추가 도구 호출 불가. 지금까지 정보로 답하세요.",
                 "is_error": True}
                for b in response.content if b.type == "tool_use"
            ]})
            final = client.messages.create(
                model="claude-opus-4-8", max_tokens=2048,
                tools=tool_schemas,
                tool_choice={"type": "none"},  # 도구 사용 금지 → 반드시 텍스트로 마무리
                messages=messages,
            )
            return "".join(b.text for b in final.content if b.type == "text")

        messages.append({"role": "assistant", "content": response.content})
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            sig = (block.name, json.dumps(block.input, sort_keys=True, ensure_ascii=False))
            if sig in seen_calls:
                content, is_error = "동일한 호출이 이미 수행됨. 다른 접근을 시도하세요.", True
            else:
                seen_calls.add(sig)
                func = TOOL_FUNCTIONS.get(block.name)
                try:
                    content, is_error = func(block.input), False
                except Exception as e:
                    content, is_error = f"오류: {e}", True
            tool_results.append({
                "type": "tool_result", "tool_use_id": block.id,
                "content": content, "is_error": is_error,
            })

        messages.append({"role": "user", "content": tool_results})
        response = client.messages.create(
            model="claude-opus-4-8", max_tokens=2048,
            tools=tool_schemas, messages=messages,
        )

    return "".join(b.text for b in response.content if b.type == "text")

tool_choice={"type": "none"}으로 마지막 호출을 보내면 모델이 도구를 더 호출할 수 없어 반드시 텍스트 응답으로 마무리됩니다. 이것이 무한루프를 깔끔하게 끊는 안전장치입니다.

오류 처리 — 타입드 예외와 자동 재시도

루프 안에서 API 호출 자체가 실패할 수도 있습니다. 레이트 리밋(429)이나 과부하(529)는 일시적이므로 재시도 대상입니다. Claude SDK는 429/5xx를 지수 백오프로 자동 재시도하지만(max_retries 조절 가능), 그래도 소진되면 타입드 예외가 올라옵니다. 문자열 매칭이 아니라 예외 타입으로 처리하세요.

import anthropic

def safe_create(**kwargs):
    try:
        return client.messages.create(**kwargs)
    except anthropic.RateLimitError:
        # SDK가 자동 재시도 후에도 소진된 경우
        raise
    except anthropic.OverloadedError:
        # 529 — 잠시 후 재시도하거나 더 가벼운 모델로 폴백
        raise
    except anthropic.APIStatusError as e:
        # 그 외 4xx/5xx — e.status_code, e.response 확인
        print(f"API 오류 {e.status_code}: {e.message}")
        raise

Laravel(PHP)에서의 에이전트 루프

독자 중 Laravel 개발자가 많으니 PHP SDK로도 동일한 루프를 보입니다. PHP SDK는 도구 키를 camelCase로 씁니다 — inputSchema, toolUseID, stopReason — 이 점이 Python과 다릅니다.

<?php

use Anthropic\Client;
use Anthropic\Messages\ToolUseBlock;

$client = new Client(apiKey: getenv("ANTHROPIC_API_KEY"));

$tools = [
    [
        "name" => "query_sales_db",
        "description" => "사내 매출 DB에서 상품별 매출을 조회한다. 매출/판매량을 물을 때 호출.",
        "inputSchema" => [  // camelCase! (input_schema 아님)
            "type" => "object",
            "properties" => [
                "product_keyword" => ["type" => "string", "description" => "상품 키워드"],
            ],
            "required" => ["product_keyword"],
        ],
    ],
];

function executeTool(string $name, array $input): string
{
    // 실제 도구 디스패치 — DB 쿼리, REST 호출 등
    return match ($name) {
        "query_sales_db" => json_encode(
            ["product" => $input["product_keyword"], "revenue" => 450000],
            JSON_UNESCAPED_UNICODE
        ),
        default => json_encode(["error" => "unknown tool"]),
    };
}

$messages = [
    ["role" => "user", "content" => "USB허브 매출 알려줘"],
];

$response = $client->messages->create(
    model: "claude-opus-4-8",
    maxTokens: 2048,
    tools: $tools,
    messages: $messages,
);

$iterations = 0;
$maxIterations = 10;

while ($response->stopReason === "tool_use") {  // camelCase 프로퍼티
    if (++$iterations > $maxIterations) {
        throw new RuntimeException("최대 반복 횟수 초과");
    }

    // assistant 턴(도구 호출 포함)을 추가
    $messages[] = ["role" => "assistant", "content" => $response->content];

    $toolResults = [];
    foreach ($response->content as $block) {
        if ($block instanceof ToolUseBlock) {
            $result = executeTool($block->name, $block->input);
            $toolResults[] = [
                "type" => "tool_result",
                "toolUseID" => $block->id,  // camelCase! (tool_use_id 아님)
                "content" => $result,
            ];
        }
    }

    // 도구 결과를 user 턴으로 추가
    $messages[] = ["role" => "user", "content" => $toolResults];

    $response = $client->messages->create(
        model: "claude-opus-4-8",
        maxTokens: 2048,
        tools: $tools,
        messages: $messages,
    );
}

// 최종 텍스트 응답
foreach ($response->content as $block) {
    if ($block->type === "text") {
        echo $block->text;
    }
}

구조는 Python과 완전히 동일합니다: while stopReason === "tool_use"로 돌고, assistant 응답을 통째로 추가하고, toolUseID로 결과 짝을 맞춥니다. PHP에서는 $block instanceof ToolUseBlock으로 타입을 좁히면 정적 분석에도 유리합니다.

SDK 자동 도구 러너는 언제 쓰나

지금까지 수동 루프를 직접 짰습니다. 사실 Claude SDK에는 베타 도구 러너(Python의 함수 데코레이터 기반, PHP의 toolRunner() 등)가 있어 이 루프를 자동으로 돌려줍니다. 그렇다면 언제 수동, 언제 자동일까요?

  • 자동 도구 러너: 도구에 부작용이 없고(읽기 전용·조회), 단순히 "모델이 도구를 다 쓸 때까지 돌려주기"만 필요할 때. 코드가 짧아집니다.
  • 수동 루프: 도구 실행 전 사람 승인이 필요하거나, 호출마다 커스텀 로깅·감사, 조건부 실행, 동일 호출 감지 같은 세밀한 제어가 필요할 때. 이 글의 휴먼인더루프·무한루프 방지가 정확히 이 경우입니다.

부작용이 있는 도구(이메일·결제·삭제·외부 쓰기)를 다룬다면 수동 루프로 가는 것이 안전합니다. 모델이 요청하는 모든 도구를 자동 실행하는 러너는 편리하지만, 승인 게이트를 넣을 자리가 없기 때문입니다.

자주 묻는 질문 (FAQ)

Q. 단일 호출과 에이전트 루프, 어떻게 선택하나요?
작업이 한 번에 끝나고 사전에 완전히 명세 가능하면(분류·요약·추출·단순 Q&A) 단일 호출을 쓰세요. 다단계이고 미리 모든 경로를 정할 수 없으며, 모델이 중간 결과를 보고 다음 행동을 결정해야 하면 에이전트 루프가 필요합니다. 항상 가장 단순한 방식부터 시도하고, 정말 필요할 때만 루프로 올라가는 것이 비용·지연 면에서 유리합니다.

Q. 루프가 멈추지 않고 같은 도구를 계속 호출합니다. 어떻게 막나요?
세 가지를 함께 쓰세요. ① max_iterations 반복 상한, ② 동일 (도구명, 입력) 조합 반복 감지, ③ 상한 도달 시 tool_choice={"type": "none"}으로 도구를 끄고 최종 텍스트 답변만 받는 우아한 종료. 단순히 예외로 죽이기보다, 도구를 끄고 "지금까지 정보로 답하라"고 한 번 더 호출하면 사용자에게 결과를 줄 수 있습니다.

Q. 이메일 전송 같은 위험한 도구에 사람 승인을 넣으려면?
수동 루프에서 도구를 실행하기 직전에 승인 게이트를 둡니다. 부작용 도구 집합(REQUIRES_APPROVAL)을 정의하고, 해당 도구를 만나면 사람에게 묻습니다. 거부되면 도구를 실행하지 않고 거부 사유를 tool_resultis_error: true로 담아 모델에게 돌려주세요. 그러면 모델이 대안을 찾습니다. SDK 자동 러너에는 이 자리가 없으므로 이런 경우엔 수동 루프가 정답입니다.

Q. 모델이 여러 도구를 한꺼번에 호출하는데, 병렬로 막거나 허용하려면?
기본적으로 Claude는 한 응답에 여러 tool_use 블록을 담을 수 있고, 루프에서 모든 블록을 순회해 처리하면 됩니다. 독립적인 읽기 도구는 ThreadPoolExecutor로 병렬 실행해 지연을 줄일 수 있습니다. 반대로 부작용이 있거나 순서가 중요하면 tool_choicedisable_parallel_tool_use: true를 넣어 응답당 도구를 1개로 제한하세요.

Q. 도구 결과를 모델에게 돌려줄 때 자주 하는 실수는?
가장 흔한 실수 두 가지입니다. ① assistant 응답에서 텍스트만 뽑아 메시지에 추가하는 것 — response.content를 통째로 추가해야 tool_use 블록이 보존됩니다. ② tool_resulttool_use_id를 빠뜨리는 것 — 각 결과는 반드시 해당 tool_use 블록의 id와 짝을 맞춰야 하며, 한 응답의 모든 도구 결과를 하나의 user 메시지로 묶어 보내야 합니다. 또한 block.input은 이미 파싱된 dict이므로 원시 문자열로 매칭하지 마세요.


AI 개발이 필요하신가요?

DevTeam은 OpenAI·Claude 등 AI API 연동, 챗봇, RAG, 업무 자동화를 설계부터 배포까지 구현합니다. AI 연동 개발 또는 무료 견적 문의.

개발 파트너가 필요하신가요?

DevTeam은 MVP·웹·앱·AI 개발을 설계부터 배포·운영까지 한 팀이 책임집니다.