AI 에이전트 설계 패턴 완전 가이드
단일 LLM 호출에서 멀티에이전트 오케스트레이션까지, 실제로 동작하는 코드와 함정 회피법
에이전트라는 단어가 모든 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_reason이 end_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 매칭: 모델이 만든
inputJSON은 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 도구와 중복 선언:_20260209web 도구는 dynamic filtering을 위해 이미 내부 실행 환경을 갖는다. 독립적인 코드 실행이 필요한 게 아니라면 standalonecode_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로 돌린다.
최종 베스트 프랙티스
- 가장 단순한 티어에서 시작하라. 단일 호출과 워크플로가 대부분의 케이스를 처리한다. 작업이 진짜로 열린 탐색을 요구할 때만 에이전트로 올려라.
- 계획을 작업의 일부로 분리하라. 복잡한 태스크는 실행 전 계획 단계를 선행한다. 실행은 전체 시간의 20%면 충분하다.
- 검증을 명시적으로 만들어라. fresh-context verifier 서브에이전트가 자기비판보다 낫다. 진행 주장을 도구 결과에 대해 감사하게 하라.
- 컴파운드하라. 실패와 리뷰에서 학습을 추출해 living rules로 축적한다. 매 작업이 다음 작업을 더 쉽게 만든다.
- provider-agnostic하게 사고하되 SDK 헬퍼를 활용하라. 패턴(ReAct·Planner·Tool Use·Multi-Agent)은 모든 모델에 옮겨가지만, 각 SDK가 제공하는 tool runner·structured output·typed exception·캐싱 헬퍼는 직접 재구현하지 말고 써라.