본문으로 건너뛰기
AIPida

AI 에이전트 설계 패턴 완전 가이드

단일 LLM 호출에서 멀티에이전트 오케스트레이션까지, 실제로 동작하는 코드와 함정 회피법

실전AI 에이전트·

에이전트라는 단어가 모든 LLM 워크로드에 무차별적으로 붙는 시대다. 하지만 실무에서 "에이전트를 만든다"는 결정은 비용·지연·디버깅 난이도를 한 단계씩 끌어올리는 트레이드오프를 동반한다. 잘못 고른 패턴은 단일 API 호출이면 끝날 일을 10번의 round-trip으로 늘리고, 토큰 비용을 몇 배로 부풀리며, 재현 불가능한 버그를 만든다.

이 가이드는 에이전트 아키텍처를 네 개의 핵심 패턴으로 분해한다. ReAct(reasoning + acting 인터리빙), Planner(실행 전 계획 수립), Tool Use(LLM이 외부 세계와 상호작용하는 메커니즘), Multi-Agent(여러 에이전트의 협업). 각각은 독립된 기법이 아니라 같은 빌딩 블록 — 도구 정의, 에이전틱 루프, 컨텍스트 관리 — 을 어떻게 조합하느냐의 문제다.

예시 코드는 Anthropic의 공식 SDK(Python/TypeScript)와 Claude API를 기준으로 한다. 모델은 claude-opus-4-8을 기본값으로 쓴다. 다만 패턴 자체는 provider-agnostic이며, OpenAI·Gemini 등 다른 모델 API에서도 동일한 구조로 옮겨갈 수 있다. 핵심 메시지는 하나다: 가장 단순한 티어에서 시작하고, 작업이 진짜로 요구할 때만 복잡도를 올려라.

다루는 범위:

패턴언제 쓰나핵심 트레이드오프
단일 호출분류·요약·추출·Q&A가장 저렴·빠름, 다단계 불가
Workflow (코드 제어)단계가 명확히 정의된 파이프라인결정적·디버깅 쉬움, 유연성 낮음
ReAct 에이전트모델이 다음 행동을 스스로 결정유연함, 비용·지연·비결정성 증가
Planner계획-실행 분리가 이득인 복잡 태스크일관성↑, 토큰·지연↑
Multi-Agent독립적 워크스트림 병렬화확장성↑, 조율 복잡도 폭증

1. 에이전트를 만들기 전에: 4가지 판단 기준

에이전트 코드를 한 줄도 쓰기 전에 통과해야 할 게이트가 있다. 다음 네 가지 질문에 하나라도 "아니오"가 나오면 더 단순한 티어(단일 호출 또는 워크플로)에 머무르는 게 옳다.

  • Complexity (복잡도): 태스크가 다단계이고, 사전에 완전히 명세하기 어려운가? (예: "이 설계 문서를 PR로 만들어줘" vs "이 PDF에서 제목만 추출해줘")
  • Value (가치): 결과물이 높아진 비용과 지연을 정당화하는가?
  • Viability (실현성): 모델이 이 유형의 태스크를 실제로 잘 수행하는가?
  • Cost of error (오류 비용): 오류를 잡아내고 복구할 수 있는가? (테스트·리뷰·롤백)

의사결정 트리

무엇이 필요한가?

1. 단일 LLM 호출 (분류·요약·추출·Q&A)
   └── 단일 API 호출 — 요청 1, 응답 1

2. 다단계지만 단계가 코드로 명확히 제어되는 파이프라인
   └── Workflow — 당신이 루프를 제어 (tool use 포함)

3. 모델이 스스로 궤적을 결정하는 열린 태스크
   └── Agent — 에이전틱 루프 (최대 유연성)

흔한 함정

가장 비싼 실수는 단일 호출로 끝날 일을 에이전트로 만드는 것이다. "PDF에서 제목 추출"은 명세가 완전하고, 다단계가 아니며, 오류가 명확히 검출된다 — 에이전트가 아니라 단일 messages.create() 호출이다. 반대로 "레포지토리 전반의 리팩터링"은 사전 명세가 불가능하고 모델이 탐색하며 결정해야 한다 — 진짜 에이전트가 필요한 케이스다.

베스트 프랙티스

에이전트로 결정했다면, 계획을 작업의 일부로 분리하라. 실제 운영에서 검증된 루프는 Plan → Work → Review → Compound이다. 복잡한 태스크는 실행 전 리서치/계획 단계를 선행하고(전체 시간의 20%만 실제 실행), 다관점 리뷰로 검증한 뒤, 실패와 리뷰에서 학습을 추출해 규칙으로 축적한다. 이게 매 작업이 다음 작업을 더 쉽게 만드는 compound engineering의 기본 형태다.

2. Tool Use의 기초: 도구 정의와 에이전틱 루프

모든 에이전트 패턴은 결국 tool use 위에 서 있다. 도구 정의는 name, description, JSON Schema 입력의 세 부분으로 이뤄진다.

{
  "name": "get_weather",
  "description": "Get current weather for a location. Call this when the user asks about current conditions or a forecast.",
  "input_schema": {
    "type": "object",
    "properties": {
      "location": { "type": "string", "description": "City and state, e.g., San Francisco, CA" },
      "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
    },
    "required": ["location"]
  }
}

핵심은 description"무엇을 하는가"가 아니라 "언제 호출하는가" 중심으로 쓰는 것이다. 최근 모델일수록 도구를 보수적으로 호출하기 때문에, 트리거 조건("현재 가격이나 최근 사건을 물으면 호출")을 명시하면 호출률(should-call rate)이 측정 가능하게 올라간다.

에이전틱 루프 — 수동 제어

루프의 본질은 단순하다: 모델 호출 → tool_use 블록이 있으면 실행 → 결과를 tool_result로 되돌림 → stop_reasonend_turn이 될 때까지 반복.

import anthropic

client = anthropic.Anthropic()
tools = [...]  # 위의 도구 정의
messages = [{"role": "user", "content": user_input}]

while True:
    response = client.messages.create(
        model="claude-opus-4-8",
        max_tokens=16000,
        tools=tools,
        messages=messages,
    )

    if response.stop_reason == "end_turn":
        break

    # 서버사이드 도구가 반복 한도에 도달 → 그대로 다시 보내 재개
    if response.stop_reason == "pause_turn":
        messages.append({"role": "assistant", "content": response.content})
        continue

    # assistant 응답(tool_use 블록 포함)을 먼저 history에 추가
    messages.append({"role": "assistant", "content": response.content})

    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = execute_tool(block.name, block.input)  # 당신의 구현
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,   # tool_use 블록의 id와 반드시 일치
                "content": result,
            })

    messages.append({"role": "user", "content": tool_results})

흔한 함정

  • tool_use_id 누락/불일치: 모든 tool_use 블록에는 정확히 하나의 tool_result가 매칭되어야 한다. 누락되면 다음 요청이 400으로 거부된다.
  • assistant 응답을 history에 안 넣는 것: tool_use 블록이 포함된 response.content를 통째로 append해야 한다. 텍스트만 추출해 넣으면 모델이 자기가 무슨 도구를 불렀는지 잃어버린다.
  • 직렬화된 입력에 raw string 매칭: 모델이 만든 input JSON은 Unicode/슬래시 이스케이프가 모델마다 다를 수 있다. 항상 json.loads()로 파싱하고, block.input(이미 파싱된 객체)을 쓴다.
  • 에러를 숨기는 것: 도구 실행이 실패하면 "is_error": true와 함께 정보가 담긴 에러 메시지를 반환하라. 모델이 그걸 보고 다른 접근을 시도한다.
tool_result = {
    "type": "tool_result",
    "tool_use_id": tool_use_id,
    "content": "Error: Location 'xyz' not found. Provide a valid city name.",
    "is_error": True,
}

Tool Runner — 자동 루프

수동 루프가 필요 없다면 SDK의 tool runner(베타)가 호출→실행→결과 피드백을 자동 처리한다. Python은 @beta_tool 데코레이터로 함수에서 스키마를 자동 생성한다.

from anthropic import beta_tool

@beta_tool
def get_weather(location: str, unit: str = "celsius") -> str:
    """Get current weather for a location.

    Args:
        location: City and state, e.g., San Francisco, CA.
        unit: "celsius" or "fahrenheit".
    """
    return f"72°F and sunny in {location}"

runner = client.beta.messages.tool_runner(
    model="claude-opus-4-8",
    max_tokens=16000,
    tools=[get_weather],
    messages=[{"role": "user", "content": "What's the weather in Paris?"}],
)
for message in runner:
    print(message)

언제 수동 루프를 쓰나: human-in-the-loop 승인, 커스텀 로깅, 조건부 도구 실행처럼 루프에 개입해야 할 때다. 그 외에는 tool runner가 정답이다.

3. ReAct 패턴: 추론과 행동의 인터리빙

ReAct는 Reason + Act의 합성어로, 모델이 "생각 → 행동 → 관찰"을 반복하며 태스크를 풀어나가는 패턴이다. 핵심 통찰은 추론(어떤 도구를 왜 부를지)과 행동(실제 도구 호출)을 한 궤적 안에서 번갈아 수행하면, 한 번에 전부 계획하는 것보다 중간 관찰에 적응하며 더 견고하게 동작한다는 것이다.

Claude API에서 ReAct는 별도의 프레임워크가 아니라 adaptive thinking + 에이전틱 루프의 조합으로 자연스럽게 구현된다. 모델이 도구 호출 사이에 자동으로 thinking 블록을 생성하며(interleaved thinking), 이게 곧 ReAct의 "reason" 단계다.

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    thinking={"type": "adaptive", "display": "summarized"},
    output_config={"effort": "high"},
    tools=tools,
    messages=messages,
)

for block in response.content:
    if block.type == "thinking":
        print(f"[추론] {block.thinking}")    # ReAct의 'reason'
    elif block.type == "tool_use":
        print(f"[행동] {block.name}({block.input})")  # ReAct의 'act'
    elif block.type == "text":
        print(f"[응답] {block.text}")

adaptive thinking이 manual budget을 대체한다

예전 패턴(budget_tokens로 thinking 토큰을 고정)은 폐기됐다. claude-opus-4-8/4-7에서 thinking={"type": "enabled", "budget_tokens": N}400 에러를 던진다. 대신 thinking={"type": "adaptive"}를 쓰면 모델이 요청마다 얼마나 생각할지 동적으로 결정하고, 도구 호출 사이의 interleaved thinking도 자동 활성화된다.

thinking 깊이/비용은 output_config.effort로 조절한다:

effort언제
low짧고 범위가 명확한 태스크, 지연 민감 워크로드
medium비용 민감하지만 어느 정도 추론 필요
high대부분의 지능 민감 작업 (권장 최소값)
xhigh코딩·에이전틱 작업의 sweet spot
max정확성이 비용보다 압도적으로 중요할 때

display 기본값 함정

claude-opus-4-8에서 thinking display의 기본값은 "omitted"다. 즉 block.thinking 필드가 빈 문자열로 온다. ReAct 궤적을 UI나 로그에 보여주려면 반드시 display: "summarized"를 명시해야 한다. 이걸 빼먹으면 추론 단계가 "출력 전 긴 멈춤"으로 보일 뿐 아무 텍스트도 렌더링되지 않는다.

흔한 함정

  • 무한 루프: 모델이 같은 도구를 반복 호출하며 진전이 없을 수 있다. 루프에 max_iterations(예: 10~15)를 두고, 초과 시 사용자에게 보고로 폴백하라.
  • thinking 블록 변조: 같은 모델로 대화를 이어갈 때 thinking 블록을 받은 그대로 되돌려야 한다(빈 텍스트 블록 포함). API는 수정된 블록을 거부한다. 다른 모델로 넘기면 자동으로 드롭된다(과금 안 됨).
  • refusal 미처리: 안전 분류기가 거부하면 stop_reason: "refusal"로 HTTP 200이 온다. response.content[0]을 무조건 읽으면 인덱스 에러가 난다. 항상 stop_reason을 먼저 확인하라.

베스트 프랙티스

도구 사용 지시는 공격적이지 않게 작성한다. CRITICAL: You MUST use this tool 같은 문구는 최신 모델에서 오버트리거를 유발한다. Use [tool] when it would improve X처럼 부드럽게 쓰는 게 옳다. 모델은 시스템 프롬프트를 매우 충실히 따르므로, 과거의 반항을 극복하려고 쓴 강한 문구가 이제는 독이 된다.

4. Planner 패턴: 실행 전 계획 수립

ReAct가 "생각하며 행동"이라면, Planner는 "먼저 전부 계획하고 나서 실행"이다. 복잡한 다단계 태스크에서 계획과 실행을 분리하면 (1) 일관성이 올라가고, (2) 사용자가 실행 전에 계획을 검토·수정할 수 있으며, (3) 계획을 캐싱·재사용할 수 있다.

실무에서 Planner는 두 단계로 나뉜다: 계획 생성(structured output으로 단계 리스트를 받음)과 계획 실행(각 단계를 에이전틱 루프로 수행).

1단계: 구조화된 계획 생성

structured output(output_config.format)으로 계획을 강제된 JSON 스키마로 받는다. 이렇게 하면 파싱이 안전하고 다음 단계로 프로그램적으로 넘길 수 있다.

from pydantic import BaseModel
from typing import List

class PlanStep(BaseModel):
    step_number: int
    description: str
    tool: str        # 이 단계에서 쓸 도구
    depends_on: List[int]  # 선행 단계 번호

class Plan(BaseModel):
    goal: str
    steps: List[PlanStep]

plan_response = client.messages.parse(
    model="claude-opus-4-8",
    max_tokens=16000,
    output_config={"format": Plan},
    messages=[{
        "role": "user",
        "content": "Goal: Q1 매출 데이터를 분석하고 시각화 리포트를 만들어줘. "
                   "가용 도구: query_database, run_analysis, create_chart, write_report",
    }],
)

plan = plan_response.parsed_output  # 검증된 Plan 인스턴스
for step in plan.steps:
    print(f"{step.step_number}. {step.description} (도구: {step.tool})")

2단계: 계획 실행

각 단계를 순서대로(또는 의존성 그래프에 따라) 실행한다. 계획을 시스템 컨텍스트로 주입하고, 한 단계씩 에이전틱 루프를 돌린다.

execution_messages = [{
    "role": "user",
    "content": f"다음 계획을 단계별로 실행하라:\n{plan.model_dump_json(indent=2)}\n"
               f"각 단계 완료 후 결과를 보고하고 다음으로 넘어가라.",
}]
# 여기서부터 섹션 2의 에이전틱 루프(tools 포함)를 돌린다

Planner vs ReAct: 언제 무엇을

상황선택
단계 간 의존성이 복잡하고 미리 보이는가Planner
사용자가 실행 전 계획 승인을 원하는가Planner
중간 관찰에 따라 경로가 크게 바뀌는가ReAct
탐색적이고 결과를 예측하기 어려운가ReAct
둘 다?Planner로 골격, 각 단계 내부는 ReAct

Claude API 네이티브: Task Budget

Opus 4.7/4.8은 Task Budget(베타)으로 "전체 에이전틱 루프에 토큰을 N개 쓸 수 있다"고 모델에게 알려준다. 모델은 카운트다운을 보며 우선순위를 정하고 예산이 소진되면 우아하게 마무리한다. 이건 Planner가 하려는 "전체 작업량 인식"을 API 레벨에서 제공하는 것이다.

client.beta.messages.create(
    betas=["task-budgets-2026-03-13"],
    model="claude-opus-4-8",
    max_tokens=64000,
    thinking={"type": "adaptive"},
    output_config={
        "effort": "high",
        "task_budget": {"type": "tokens", "total": 128000},  # 최소 20000
    },
    messages=[...],
)

max_tokens(강제 per-response 한도, 모델은 모름)와 다르다. task_budget은 모델이 인식하는 누적 예산 제안이다.

흔한 함정

  • 계획이 곧 진실이라고 가정: 계획은 출발점이지 불변의 명세가 아니다. 실행 중 새 정보가 나오면 재계획이 필요할 수 있다. 단계 실행 사이에 "이 계획이 여전히 유효한가?" 체크를 넣어라.
  • 과도하게 prescriptive한 계획: 모든 단계를 너무 세밀하게 명시하면 모델의 유연성을 죽인다. 목표와 제약을 주고 단계는 큰 단위로 두는 게 최신 모델에서 더 좋은 결과를 낸다.
  • structured output에 thinking 끄기: structured output은 extended thinking과 함께 동작하지만, citations와는 충돌(400)한다.

5. 도구 표면(Tool Surface) 설계: bash vs 전용 도구

에이전트의 능력은 도구 표면 설계로 결정된다. 핵심 긴장은 bash 도구의 넓이 vs 전용 도구의 제어 가능성이다.

bash 도구는 모델에게 거의 모든 행동을 할 수 있는 광범위한 레버리지를 준다. 하지만 harness 입장에선 불투명한 명령 문자열 하나만 받는다 — 모든 행동이 같은 모양이다. 어떤 행동을 전용 도구로 승격하면, harness는 타입이 지정된 인자를 가진 행동별 훅을 얻어 그것을 가로채고, 렌더링하고, 감사하고, 병렬화할 수 있다.

전용 도구로 승격해야 할 때

  • 보안 경계: gating이 필요한 행동. 가역성이 유용한 기준이다. 되돌리기 어려운 행동(외부 API 호출, 메시지 발송, 데이터 삭제)은 사용자 확인 뒤에 둘 수 있다. send_email 도구는 쉽게 gate하지만 bash -c "curl -X POST ..."는 못 한다.
  • staleness 체크: 전용 edit 도구는 모델이 마지막으로 읽은 뒤 파일이 바뀌었으면 쓰기를 거부할 수 있다. bash는 이 불변식을 강제 못 한다.
  • 렌더링: 일부 행동은 커스텀 UI가 필요하다. Claude Code는 질문하기를 도구로 승격해 모달로 렌더링하고 옵션을 제시하며 답이 올 때까지 루프를 막는다.
  • 스케줄링: glob·grep 같은 읽기 전용 도구는 parallel-safe로 표시할 수 있다. bash로 같은 일을 하면 harness는 parallel-safe한 grep과 parallel-unsafe한 git push를 구분 못 해 직렬화해야 한다.

경험칙: 넓이를 위해 bash로 시작하고, gate·렌더·감사·병렬화가 필요해지면 전용 도구로 승격하라.

Anthropic 제공 도구

도구언제
Bash클라이언트셸 명령 실행
Text editor클라이언트파일 읽기/편집
Code execution서버직접 관리하기 싫은 샌드박스에서 코드 실행
Web search / fetch서버학습 컷오프 이후 정보, 특정 URL 내용
Memory클라이언트세션 간 컨텍스트 저장

클라이언트 측 도구는 Anthropic이 정의하지만 당신의 harness가 실행한다(레퍼런스 구현 제공). 서버 측 도구는 전적으로 Anthropic 인프라에서 돌아간다 — tools에 선언만 하면 끝이다.

# 서버 측 도구: 선언만 하면 모델이 알아서 실행
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    messages=[{"role": "user", "content": "최근 AI 에이전트 논문 트렌드를 검색해서 요약해줘"}],
    tools=[
        {"type": "web_search_20260209", "name": "web_search"},
        {"type": "code_execution_20260120", "name": "code_execution"},
    ],
)

도구 수가 많아질 때: Tool Search

도구가 수십~수백 개인데 요청당 몇 개만 관련 있다면, 모든 스키마를 컨텍스트에 올리지 마라. Tool Search는 모델이 도구 집합을 검색해 관련 스키마만 로드하게 한다. 중요한 건 도구 정의가 교체가 아니라 추가된다는 점 — 기존 프리픽스가 보존되어 prompt cache가 깨지지 않는다.

흔한 함정

  • 도구 과잉: 너무 많은 도구는 모델을 혼란시킨다. 집합을 좁고 집중되게 유지하라.
  • code_execution을 web 도구와 중복 선언: _20260209 web 도구는 dynamic filtering을 위해 이미 내부 실행 환경을 갖는다. 독립적인 코드 실행이 필요한 게 아니라면 standalone code_execution을 같이 넣지 마라 — 두 번째 실행 환경이 모델을 혼란시킨다.
  • 부작용 도구에 검증 누락: tool runner는 모델이 요청하면 자동 실행한다. 메일 발송·DB 수정·결제 같은 부작용 도구는 함수 내부에서 입력을 검증하거나, human-in-the-loop이 필요하면 수동 루프를 써라.

6. 컨텍스트 관리: 장시간 실행 에이전트의 생명선

에이전트가 오래 돌수록 컨텍스트는 부풀고, 오래된 도구 결과와 완료된 thinking이 쌓여 비용과 지연이 폭증한다. 세 가지 메커니즘으로 관리한다.

패턴언제동작
Context editing여러 턴에 걸쳐 컨텍스트가 stale해질 때임계치 기반으로 오래된 도구 결과·thinking 블록을 제거(요약 아님)
Compaction컨텍스트 윈도우 한계에 근접할 때이전 컨텍스트를 서버사이드에서 compaction 블록으로 요약
Memory세션 간 상태 지속이 필요할 때메모리 디렉터리에 파일 read/write, 프로세스 재시작 후에도 생존

선택 기준: context editing과 compaction은 세션 내부에서 동작한다 — editing은 stale한 턴을 쳐내고, compaction은 한계 근처에서 요약한다. Memory는 세션 지속을 위한 것이다. 장시간 에이전트는 셋 다 쓰는 경우가 많다.

Compaction 구현 (치명적 디테일)

import anthropic

client = anthropic.Anthropic()
messages = []

def chat(user_message: str) -> str:
    messages.append({"role": "user", "content": user_message})
    response = client.beta.messages.create(
        betas=["compact-2026-01-12"],
        model="claude-opus-4-8",
        max_tokens=16000,
        messages=messages,
        context_management={"edits": [{"type": "compact_20260112"}]},
    )
    # 치명적: 텍스트만이 아니라 response.content 전체를 append
    # compaction 블록이 보존되어야 다음 요청에서 압축된 history를 대체한다
    messages.append({"role": "assistant", "content": response.content})
    return next(b.text for b in response.content if b.type == "text")

가장 흔한 버그: response.content에서 텍스트 문자열만 뽑아 append하는 것. 그러면 compaction 블록이 사라지고 압축 상태가 소리 없이 유실된다. 반드시 response.content(블록 리스트) 전체를 되돌려야 한다.

캐싱과 컨텍스트의 상호작용

prompt caching은 prefix match다 — 프리픽스 어디든 1바이트만 바뀌면 그 뒤가 전부 무효화된다. 에이전트에서 특히 조심할 것:

제약에이전트 우회법
시스템 프롬프트를 세션 중 수정하면 캐시 무효화messages[]{"role": "system", ...} 메시지 추가(베타, 지원 모델). 캐시된 프리픽스 유지 + operator 권한으로 인식
세션 중 모델 전환 시 캐시 무효화서브태스크는 더 싼 모델의 서브에이전트로 분리, 메인 루프는 한 모델 유지
도구 추가/제거 시 캐시 무효화Tool search로 동적 발견 — 스키마를 교체가 아닌 추가로

캐시 적중은 usage.cache_read_input_tokens로 검증한다. 동일 프리픽스 반복 요청에서 0이 계속 나오면 silent invalidator(시스템 프롬프트의 datetime.now(), 정렬 안 된 JSON, 매번 바뀌는 도구 집합)가 있는 것이다.

흔한 함정

  • 시스템 프롬프트에 타임스탬프 주입: current date: X를 시스템 프롬프트에 넣으면 프리픽스 맨 앞에 앉아 매 요청 전체 캐시를 무효화한다. 동적 컨텍스트는 messages 뒤쪽에 넣어라.
  • 20-블록 lookback 윈도우: 각 breakpoint는 최대 20개 블록만 거슬러 올라가 캐시를 찾는다. 한 턴이 20블록을 초과하면(도구 호출이 많은 에이전틱 루프에서 흔함) 다음 요청이 캐시를 못 찾고 조용히 미스 난다. 긴 턴에는 ~15블록마다 중간 breakpoint를 둬라.

7. Multi-Agent 패턴: 언제, 어떻게 쪼갤 것인가

멀티에이전트는 "여러 LLM을 동시에 돌린다"가 아니다. 하나의 코디네이터가 컨텍스트가 격리된 여러 서브에이전트에게 위임하고, 각 서브에이전트가 자기 목표·도구·시스템 프롬프트로 독립적인 워크스트림을 처리하는 패턴이다.

멀티에이전트가 이득인 경우 — 그리고 아닌 경우

결정 규칙은 단순하다: 독립적이고 병렬화 가능한 워크스트림일 때만 쪼개라. 단일 파일 읽기나 순차 작업을 서브에이전트로 만드는 건 오버헤드만 늘린다.

쪼개라쪼개지 마라
여러 항목에 fan-out (여러 파일·여러 후보 검사)직접 grep 한 번이면 될 일
독립적이고 병렬 가능한 서브태스크단계 간 의존성이 강한 순차 작업
컨텍스트 격리가 이득(각자 다른 도메인)한 컨텍스트에 다 들어가는 작은 작업
다른 모델/effort가 적합(탐색=저비용 모델)동일 추론을 공유해야 하는 작업

직접 구현: 코디네이터 + 서브에이전트

자체 호스팅 환경에서는 메인 루프가 코디네이터 역할을 하고, 서브에이전트를 별도 API 호출로 spawn한다. 핵심은 캐시 보존을 위해 서브에이전트에 더 싼 모델을 쓰는 것이다(Claude Code의 Explore 서브에이전트가 Haiku를 이렇게 쓴다).

def spawn_subagent(task: str, model: str = "claude-haiku-4-5") -> str:
    """독립 워크스트림을 격리된 컨텍스트로 위임."""
    response = client.messages.create(
        model=model,                    # 탐색은 저비용 모델
        max_tokens=16000,
        output_config={"effort": "low"},  # 서브에이전트는 low effort
        system="You are a focused sub-agent. Complete the assigned task and "
               "report a concise result. Do not ask follow-up questions.",
        tools=read_only_tools,          # 읽기 전용 도구만
        messages=[{"role": "user", "content": task}],
    )
    return next(b.text for b in response.content if b.type == "text")

# 코디네이터: 여러 서브에이전트를 병렬로 fan-out
import concurrent.futures

tasks = ["src/auth 모듈의 보안 이슈 검토",
         "src/payment 모듈의 보안 이슈 검토",
         "src/api 모듈의 보안 이슈 검토"]
with concurrent.futures.ThreadPoolExecutor() as pool:
    results = list(pool.map(spawn_subagent, tasks))

# 코디네이터가 결과를 종합
synthesis = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    output_config={"effort": "high"},
    messages=[{"role": "user",
               "content": f"세 서브에이전트의 보안 검토 결과를 종합하라:\n\n"
                          + "\n\n---\n\n".join(results)}],
)

비동기 위임이 spawn-and-block을 이긴다

가장 강력한 패턴은 비동기 서브에이전트다. 서브에이전트가 오케스트레이터와 비동기로 통신하면: (1) 장수명 에이전트가 컨텍스트를 유지해 서브태스크마다 재구축하지 않는다(cache-read 절약), (2) 오케스트레이터가 가장 느린 서브에이전트에 병목되지 않는다, (3) 컨텍스트가 서브태스크 간 지속된다. spawn하고 블로킹하는 것보다 우월하다.

시스템 프롬프트로 위임을 유도한다:

독립적인 서브태스크는 서브에이전트에게 위임하고, 그들이 도는 동안 계속 작업하라. 서브에이전트가 궤도를 벗어나거나 관련 컨텍스트가 부족하면 개입하라.

흔한 함정

  • 공유 컨텍스트 가정: 서브에이전트는 코디네이터의 대화 히스토리나 도구를 공유하지 않는다. 코디네이터가 서브에이전트에게 무언가를 시키려면 위임 메시지에 필요한 모든 컨텍스트를 명시하거나 디스크에 써야 한다.
  • 모델 전환으로 캐시 깨기: 메인 루프와 서브에이전트가 다른 모델이면 캐시는 모델 스코프라 서브에이전트는 메인의 캐시를 못 읽는다. 의도된 트레이드오프(저비용 탐색)일 때만 받아들여라.
  • 불필요한 fan-out: 최신 모델은 서브에이전트를 보수적으로 spawn한다. 반대로 과거엔 과도하게 위임하는 경향이 있었다. 모델에 맞게 명시적 가이드를 줘라.

8. 서버 관리형 에이전트: Managed Agents로 루프를 위임

지금까지는 당신이 에이전틱 루프와 도구 실행 인프라를 직접 호스팅하는 패턴이었다. Managed Agents는 세 번째 표면이다 — Anthropic이 에이전트 루프를 돌리고, 세션마다 컨테이너(워크스페이스)를 프로비저닝해 bash·파일 작업·코드 실행이 거기서 돌아간다.

언제 직접 구현 vs Managed Agents

상황선택
컴퓨트를 직접 호스팅, 커스텀 도구 런타임Claude API + tool use (직접 루프)
Anthropic이 루프를 돌리고 샌드박스를 호스팅하길 원함Managed Agents
영속·버전 관리되는 에이전트 설정이 필요Managed Agents
세션별 컨테이너 + 파일 마운트 + SSE 이벤트 스트림Managed Agents

필수 흐름: Agent (한 번) → Session (매 실행)

Managed Agents의 가장 중요한 규칙: 에이전트를 먼저 생성하고, 세션이 그것을 ID로 참조한다. model/system/tools는 에이전트 객체에 있지 세션에 있지 않다.

# 1단계: 에이전트 생성 (한 번만, 재사용·버전 관리됨)
agent = client.beta.agents.create(
    name="Code Reviewer",
    model="claude-opus-4-8",
    system="You are a senior code reviewer.",
    tools=[{"type": "agent_toolset_20260401"}],  # bash·read·write·edit·glob·grep·web
)
# agent.id와 agent.version을 저장해 재사용

# 2단계: 환경 생성 (재사용 가능한 컨테이너 템플릿)
env = client.beta.environments.create(
    name="review-env",
    config={"type": "cloud", "networking": {"type": "unrestricted"}},
)

# 3단계: 세션 시작 (매 실행마다)
session = client.beta.sessions.create(
    agent=agent.id,                  # 문자열 = 최신 버전
    environment_id=env.id,
)

이벤트 스트리밍: stream-first 원칙

메시지를 보내기 전에 스트림을 먼저 열어라. 스트림은 열린 이후의 이벤트만 전달한다 — 먼저 보내고 나중에 열면 초기 이벤트가 한 배치로 버퍼링되어 실시간 반응 능력을 잃는다.

with client.beta.sessions.events.stream(session_id=session.id) as stream:
    client.beta.sessions.events.send(
        session_id=session.id,
        events=[{"type": "user.message",
                 "content": [{"type": "text", "text": "auth 모듈을 검토하라"}]}],
    )
    for event in stream:
        if event.type == "agent.message":
            for block in event.content:
                if block.type == "text":
                    print(block.text, end="", flush=True)
        elif event.type == "session.status_idle":
            if event.stop_reason.type == "requires_action":
                continue  # 도구 확인/커스텀 도구 결과 대기 — 처리해야 함
            break         # end_turn 또는 종료 — terminal
        elif event.type == "session.status_terminated":
            break

흔한 함정

  • 에이전트를 매 실행마다 생성: agents.create()를 핫패스 상단에서 부르면 고아 에이전트가 쌓이고 매번 생성 지연을 낸다. 한 번 생성→ID 저장→재사용이 정답이다.
  • session.status_idle만 보고 break: 세션은 일시적으로 idle이 된다(병렬 도구 실행 사이, 도구 확인 대기 중). idle이면서 terminal한 stop_reason일 때, 또는 terminated일 때 break하라.
  • 세션 정리 시 race: SSE가 status_idle을 큐잉 가능 상태보다 약간 먼저 emit한다. idle에서 바로 delete()/archive()를 부르면 간헐적으로 400("cannot delete while running")이 난다. 정리 전에 sessions.retrieve()로 폴링하라.
  • archive는 영구적: 에이전트·환경·세션 archive는 read-only로 만들고 unarchive가 없다. 프로덕션 에이전트를 정리 목적으로 archive하지 마라.

9. 비결정성과 디버깅: 에이전트가 어려운 진짜 이유

에이전트가 단일 호출보다 본질적으로 어려운 이유는 비결정성이다. 같은 입력이 다른 궤적을 만들 수 있고, 실패는 재현하기 어렵다. 디버깅 전략을 설계 단계에서 미리 박아둬야 한다.

모든 stop_reason을 처리하라

에이전틱 루프의 버그 대부분은 처리하지 않은 stop_reason에서 나온다.

stop_reason의미처리
end_turn자연스럽게 완료루프 종료
tool_use도구 호출 원함실행 후 계속
max_tokens토큰 한도 도달max_tokens 늘리거나 스트리밍
pause_turn서버 도구 반복 한도그대로 다시 보내 재개
refusal안전상 거부stop_details 확인, 같은 프롬프트로 재시도 금지
model_context_window_exceeded컨텍스트 윈도우 초과compact 또는 대화 분할
if response.stop_reason == "refusal":
    # content가 비어있을 수 있음 — 무조건 content[0] 읽으면 인덱스 에러
    if response.stop_details:
        print(f"거부 카테고리: {response.stop_details.category}")
    handle_refusal()
elif response.stop_reason == "model_context_window_exceeded":
    compact_or_split(messages)
elif response.stop_reason == "max_tokens":
    # 출력이 중간에 잘림 — 재시도
    retry_with_higher_max_tokens()

관측 가능성: request_id와 토큰 로깅

실패를 Anthropic에 보고하거나 추적하려면 _request_id를 로깅하라(밑줄 접두사지만 public 속성이다).

message = client.messages.create(...)
print(message._request_id)   # req_018Ee...
print(message.usage)         # input/output/cache 토큰 — 비용 추적의 진실 소스

usage.input_tokens는 캐시되지 않은 나머지만이다. 전체 프롬프트 크기 = input_tokens + cache_creation_input_tokens + cache_read_input_tokens. 에이전트가 몇 시간 돌았는데 input_tokens가 4K로 보인다면 나머지는 캐시에서 served된 것 — 단일 필드가 아니라 합을 봐라.

재시도와 백오프

SDK는 429(rate limit)와 5xx를 지수 백오프로 자동 재시도한다(기본 max_retries=2). 커스텀이 필요할 때만 직접 구현하라.

try:
    response = client.messages.create(...)
except anthropic.RateLimitError as e:
    retry_after = int(e.response.headers.get("retry-after", "60"))
except anthropic.APIStatusError as e:
    if e.status_code >= 500:
        # 서버 에러 — 재시도
        ...
    else:
        raise  # 4xx(429 제외)는 재시도하면 안 됨

검증을 명시적으로 만들어라

장시간 빌드에서는 모델에게 자체 검증 harness를 세우고 주기적으로 돌리라고 지시하라. 별도 컨텍스트의 fresh verifier 서브에이전트가 자기비판보다 낫다.

작업하며 자신의 결과를 검증할 방법을 세우고, [주기]마다 명세에 대해 서브에이전트로 검증하라.

흔한 함정

  • fabricated 진행 보고: 장시간 에이전트는 도구 결과로 뒷받침되지 않는 진행 상황을 보고할 수 있다. 시스템 프롬프트로 진행 주장을 도구 결과에 대해 감사하도록 강제하라: "진행을 보고하기 전에 각 주장을 이 세션의 도구 결과에 대해 감사하라. 증거를 가리킬 수 있는 작업만 보고하라."
  • early stopping: 긴 세션 깊숙이에서 모델이 도구 호출 없이 의도만 텍스트로 진술하고("이제 X를 실행하겠습니다") 턴을 끝낼 수 있다. 자율 파이프라인에는 "턴을 끝내기 전에 마지막 단락이 계획·질문·약속이면 지금 도구 호출로 그 작업을 하라"는 리마인더를 넣어라.

10. 비용·모델 선택·effort 튜닝

에이전트는 단일 호출보다 토큰을 훨씬 많이 쓴다 — 도구 호출, thinking, 반복 루프가 누적된다. 비용은 설계의 일급 관심사다.

모델 선택 전략

모델입력/출력 $/1M언제
claude-opus-4-8$5 / $25기본값 — 대부분의 에이전트 추론
claude-sonnet-4-6$3 / $15고볼륨 프로덕션, 속도/지능 균형
claude-haiku-4-5$1 / $5단순·속도 중요 서브에이전트

에이전트 메인 루프는 Opus로 두고, 탐색·읽기 같은 서브태스크는 서브에이전트에서 Haiku로 돌리는 게 검증된 비용 절감 패턴이다. 단, 모델 전환은 캐시를 깨므로(캐시는 모델 스코프) 메인 루프 안에서 모델을 바꾸지 말고 서브에이전트로 분리하라.

effort: 가장 강력한 비용/품질 레버

output_config.effort는 thinking 깊이와 전체 토큰 지출을 동시에 조절한다. effort가 낮으면 도구 호출이 더 적고 통합되며, preamble이 줄고, 확인이 간결해진다.

# 자율 멀티스텝 에이전트: high
client.messages.create(model="claude-opus-4-8",
    output_config={"effort": "high"}, ...)

# 서브에이전트·단순 태스크: low
client.messages.create(model="claude-haiku-4-5",
    output_config={"effort": "low"}, ...)

중요: Opus 4.8은 effort를 reflexively xhigh로 올리지 말고 high를 기본으로 시작해 이터레이트하라. 지능 천장이 높아져서, 더 높은 effort가 항상 더 나은 게 아니다. 자기 eval 셋에서 medium/high/xhigh를 스윕하고 라우트별로 지능↔지연↔비용 트레이드오프를 저울질하라. 에이전틱 작업에선 높은 effort가 오히려 턴 수를 줄여 총비용을 낮추기도 한다.

Prompt caching으로 반복 컨텍스트 절감

에이전트는 같은 시스템 프롬프트·도구 정의를 매 턴 재전송한다. 캐싱이 없으면 매번 풀 가격을 낸다.

response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=16000,
    system=[{
        "type": "text",
        "text": large_system_prompt,
        "cache_control": {"type": "ephemeral"},
    }],
    messages=messages,
)
# cache read는 base 입력 가격의 ~0.1×, cache write는 ~1.25× (5분 TTL)

경제성: 5분 TTL은 2번 요청이면 손익분기(1.25× + 0.1× = 1.35× vs 2× 비캐시), 1시간 TTL은 최소 3번 필요하다. 버스티 트래픽에 1시간 TTL이 유리하지만 쓰기 비용이 2배다.

토큰 카운팅으로 사전 추정

tiktoken을 쓰지 마라 — OpenAI 토크나이저이고 Claude 토큰을 ~15-20% 적게 센다. count_tokens 엔드포인트를 써라.

count = client.messages.count_tokens(
    model="claude-opus-4-8",
    messages=messages,
    system=system,
)
est_cost = count.input_tokens * 5e-6   # $5/1M 입력

max_tokens 함정

에이전트는 max_tokens를 낮게 잡지 마라 — 한도에 닿으면 출력이 중간에 잘려 재시도가 강제된다. 비스트리밍은 ~16000(SDK HTTP 타임아웃 회피), 스트리밍은 ~64000을 기본으로 두라. 128K까지 쓰려면 스트리밍이 필수다(.stream() + .get_final_message()).

흔한 함정

  • 비용 절감을 위한 임의 모델 다운그레이드: effort를 먼저 낮춰보고, 그래도 부족하면 모델을 내려라. 두 레버는 다른 트레이드오프를 가진다.
  • silent cache miss: 시스템 프롬프트의 datetime.now(), 정렬 안 된 json.dumps(), 매번 바뀌는 도구 집합이 캐시를 조용히 깬다. cache_read_input_tokens가 반복 요청에서 0이면 범인을 찾아 렌더링된 프롬프트 바이트를 diff하라.

11. 패턴 조합: 실전 아키텍처 결정 트리

지금까지의 패턴은 독립적 선택지가 아니라 조합 가능한 레이어다. 실전에서는 거의 항상 여러 패턴을 겹친다. 다음은 작업 유형별 권장 조합이다.

아키텍처 결정 트리

작업이 단일 호출로 끝나는가? (분류·추출·요약·Q&A)
├── 예 → 단일 messages.create()  [끝]
└── 아니오 ↓

단계가 코드로 명확히 제어되는가?
├── 예 → Workflow + tool use (당신이 루프 제어)  [끝]
└── 아니오 (모델이 궤적 결정) ↓

사전에 계획을 세우는 게 이득인가?
├── 예 → Planner로 골격 → 각 단계 내부는 ReAct 루프
└── 아니오 → ReAct 에이전트 (adaptive thinking + 에이전틱 루프)
         ↓
독립적·병렬 워크스트림이 있는가?
├── 예 → Multi-Agent (코디네이터 + 서브에이전트, 비동기 위임)
└── 아니오 → 단일 ReAct 에이전트로 충분
         ↓
루프·샌드박스를 직접 호스팅하기 싫은가?
├── 예 → Managed Agents (Anthropic이 루프·컨테이너 호스팅)
└── 아니오 → 직접 에이전틱 루프 (최대 제어)

실전 조합 예시

예 1 — 코드 리뷰 봇: Planner(리뷰 범위 계획) + Multi-Agent(모듈별 서브에이전트 fan-out, Haiku) + 코디네이터 종합(Opus, high effort). 검토 harness에는 "모든 finding을 confidence·severity와 함께 보고하고 필터링은 다운스트림에서"를 명시한다 — 최신 모델은 "high-severity만" 필터를 너무 충실히 따라 측정 recall이 떨어질 수 있다.

예 2 — 리서치 에이전트: ReAct(web_search + web_fetch 서버 도구) + context editing(오래된 검색 결과 정리) + memory(세션 간 발견 축적). effort는 high로, 검색을 충분히 하도록 시스템 프롬프트에 search-first 지시를 넣는다.

예 3 — 자율 코딩 에이전트: Managed Agents(세션별 컨테이너 + GitHub 레포 마운트) + Outcome(gradeable 루브릭으로 iterate→grade→revise) + Task Budget(누적 토큰 예산). 첫 턴에 전체 명세를 주고 high/xhigh effort로 돌린다.

최종 베스트 프랙티스

  1. 가장 단순한 티어에서 시작하라. 단일 호출과 워크플로가 대부분의 케이스를 처리한다. 작업이 진짜로 열린 탐색을 요구할 때만 에이전트로 올려라.
  2. 계획을 작업의 일부로 분리하라. 복잡한 태스크는 실행 전 계획 단계를 선행한다. 실행은 전체 시간의 20%면 충분하다.
  3. 검증을 명시적으로 만들어라. fresh-context verifier 서브에이전트가 자기비판보다 낫다. 진행 주장을 도구 결과에 대해 감사하게 하라.
  4. 컴파운드하라. 실패와 리뷰에서 학습을 추출해 living rules로 축적한다. 매 작업이 다음 작업을 더 쉽게 만든다.
  5. provider-agnostic하게 사고하되 SDK 헬퍼를 활용하라. 패턴(ReAct·Planner·Tool Use·Multi-Agent)은 모든 모델에 옮겨가지만, 각 SDK가 제공하는 tool runner·structured output·typed exception·캐싱 헬퍼는 직접 재구현하지 말고 써라.