필사 모드: 프로덕션 에이전트 설계 패턴 2026 — Supervisor·CodeAct·Plan-Execute·Self-RAG·Handoff·Subagent를 실제로 조합하는 법
한국어프롤로그 — 입문서의 패턴 vs 프로덕션의 패턴
2023~2024년에 쏟아진 "LLM Agent 설계 패턴" 글들의 카탈로그는 대개 비슷하다. ReAct, Chain-of-Thought, Reflection, Plan-and-Solve, Tree of Thoughts, 그리고 마지막 챕터로 "Multi-Agent". 다섯 개의 깔끔한 박스. 데모는 잘 돈다.
문제는 그 패턴들 대부분이 **벤치마크용 토이 환경에서 검증된 것**이라는 점이다. HotpotQA 두세 홉, GSM8K 산수, ALFWorld 텍스트 게임. 거기서 의미 있던 것들은 실제 프로덕션 — 5,000줄짜리 코드베이스를 수정하고, 사내 데이터를 30개 시스템에서 끌어오고, 결제 API를 호출하고, 30분짜리 태스크 도중에 OOM이 나는 환경 — 에서는 다른 식으로 무너진다.
> 2026년 프로덕션 에이전트의 진실 한 줄: **단일 패턴으로 굴러가는 시스템은 거의 없다.** Supervisor + CodeAct + Subagent isolation + Hooks를 합쳐서 굴리거나, Plan-Execute + Self-RAG + Reflexion + Handoff를 합쳐서 굴린다. 책임은 분리되어 있고, 각 자리에서 다른 패턴이 일을 한다.
이 글은 **2024~2026년 실제 워크로드에서 살아남은 9가지 패턴**을 정리한다. 각 패턴마다 (1) 무엇인지, (2) 언제 빛나는지, (3) 어떻게 무너지는지, (4) 30~50줄짜리 최소 스케치. 그리고 마지막에 — 이들을 실제로 어떻게 조합하는지.
1장 · Supervisor / Orchestrator 패턴
**한 줄 정의:** 상위(supervisor) 에이전트 하나가 작업을 받고, 그것을 여러 전문(specialist) 에이전트에게 분배하고, 결과를 모아 다음 스텝을 결정한다.
LangGraph의 공식 "Supervisor" 가이드에서 권장하는 구조이고, 실제로 가장 많이 보는 멀티에이전트 토폴로지다. 자유분방한 swarm과 다르게, **누가 다음에 일할지**를 하나의 supervisor가 결정하기 때문에 통제 가능하다.
토폴로지
┌────────────┐
user task ────► │ Supervisor │
└─────┬──────┘
│ route by intent / state
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Research │ │ Coder │ │ Reviewer │
│ agent │ │ agent │ │ agent │
└────┬─────┘ └────┬─────┘ └────┬─────┘
└──────┬─────┴──────┬─────┘
▼ ▼
return to Supervisor (loop)
언제 빛나는가
- 워크플로의 단계가 **이질적**일 때 — 검색은 길고 비싸고, 코딩은 도구가 다르고, 리뷰는 짧고 결정론적이다. 한 에이전트가 다 하면 system prompt가 누더기가 된다.
- **상태 관찰**이 필요할 때. Supervisor는 매 스텝마다 전체 상태를 보고 "이번엔 누구"를 결정한다.
- 비용 비대칭이 큰 모델 믹스를 쓰고 싶을 때 (Opus를 supervisor에, Haiku를 specialist에).
어떻게 무너지는가
- **Supervisor가 컨텍스트 블랙홀이 된다.** 모든 specialist 출력이 supervisor의 context로 누적되어, 5번째 라운드쯤 되면 context window가 터진다. → 해법: specialist 결과를 supervisor에게 돌려줄 때 강제로 요약하거나, "툴 리턴값"으로만 보내고 raw chain은 버린다.
- **무한 라우팅 루프.** Supervisor가 "Research → Coder → Research → Coder" 를 6번 반복한 뒤 같은 결론에 도달한다. → 해법: hop budget을 결정론적 카운터로 둔다 (LangGraph의 `recursion_limit` 같은 것).
- **Specialist 출력 형식 불일치.** Coder가 raw diff를 주는데 Supervisor는 "PR description"을 기대한다. → 해법: 각 specialist의 return type을 schema로 못박는다.
최소 스케치
supervisor.py — LangGraph 스타일의 최소 supervisor
from typing import Literal, TypedDict
from langgraph.graph import StateGraph, END
class State(TypedDict):
task: str
messages: list
next: Literal["research", "code", "review", "done"]
hop: int
def supervisor(state: State) -> dict:
if state["hop"] >= 6:
return {"next": "done"}
prompt = f"Task: {state['task']}\nHistory: {state['messages'][-3:]}\nWho works next? research/code/review/done"
decision = llm(prompt) # 강제로 enum 중 하나만 리턴
return {"next": decision, "hop": state["hop"] + 1}
def specialist(name):
def run(state: State) -> dict:
result = run_agent(name, state["task"], state["messages"])
핵심: raw output을 통째로 넣지 말고 요약본만
return {"messages": state["messages"] + [{"agent": name, "summary": summarize(result)}]}
return run
g = StateGraph(State)
g.add_node("supervisor", supervisor)
g.add_node("research", specialist("research"))
g.add_node("code", specialist("code"))
g.add_node("review", specialist("review"))
g.set_entry_point("supervisor")
g.add_conditional_edges("supervisor", lambda s: s["next"],
{"research": "research", "code": "code", "review": "review", "done": END})
for s in ["research", "code", "review"]:
g.add_edge(s, "supervisor")
graph = g.compile()
핵심은 supervisor 노드가 **결정 함수**라는 것이다 — 일을 하지 않고, 누가 일할지만 정한다.
2장 · CodeAct — 액션 언어로 JSON 대신 Python 쓰기
**한 줄 정의:** 에이전트의 "Action" 출력 형식을 `tool_name(arg)` JSON 객체가 아니라 **실행 가능한 Python 코드 블록**으로 만든다. 한 스텝에서 변수에 담고, 분기하고, 루프 돌리고, 여러 툴을 한 번에 호출할 수 있다.
2024년 6월 Yang et al.의 "Executable Code Actions Elicit Better LLM Agents" (CodeAct) 논문이 출발점이고, 2025년 Manus 팀이 자사 에이전트의 액션 레이어로 채택했다고 공개 글에서 밝힌 패턴이다. HuggingFace `smolagents`의 `CodeAgent`도 같은 철학으로 만들어졌다.
비교
[기존 ReAct + JSON tool calls]
Action: {"tool": "search", "args": {"q": "kubernetes pod oom"}}
Observation: [50개 결과]
Action: {"tool": "search", "args": {"q": "kubernetes pod oom limits"}}
Observation: [30개 결과]
Action: {"tool": "summarize", "args": {"text": "..."}}
→ 3 LLM 호출, 3 라운드 트립
[CodeAct]
Action:
results_a = search("kubernetes pod oom")
results_b = search("kubernetes pod oom limits")
merged = list(set(results_a + results_b))[:20]
print(summarize("\n".join(r.snippet for r in merged)))
Observation: [요약 1개]
→ 1 LLM 호출, 1 라운드 트립
언제 빛나는가
- **여러 툴을 조합해야 하는 태스크.** 데이터 fetch → 변환 → 집계 → 차트 같은 파이프라인이 한 블록에 들어간다.
- **반복이 자연스러운 태스크** — 100개 파일을 처리해야 하는데 JSON tool call로는 100번 라운드 트립이 필요하다. CodeAct는 `for f in files:` 한 번.
- **수치 연산이 섞이는 태스크.** LLM이 직접 산수하지 않고 `numpy` 한 줄로 끝낸다.
- **상태가 누적되는 태스크** — 변수를 통해 이전 스텝의 결과를 자연스럽게 다음 스텝에서 쓸 수 있다.
어떻게 무너지는가
- **샌드박스가 새는 순간 보안이 끝난다.** Python 코드를 실행한다는 것은 임의 코드 실행이라는 뜻이다. → 해법: Docker, Firecracker, E2B, gVisor, 혹은 적어도 Python의 `restricted exec` + 시스템콜 필터.
- **`import os; os.system('rm -rf /')` 한 줄로 모든 게 끝난다.** LLM이 이런 코드를 쓰지 않는다는 보장은 없다. → 해법: allowlist 기반 import + 네트워크 격리 + ephemeral 파일시스템.
- **무한 루프.** `while True: search("x")` 한 번이면 토큰 청구서가 폭발한다. → 해법: 실행 타임아웃 + 출력 토큰 캡.
- **디버깅이 어렵다.** 한 코드 블록이 7개 툴을 호출하고 5번째에서 실패하면, 모델은 "왜 실패했는지"를 다시 파악해야 한다. → 해법: 실행 trace를 모델에게 그대로 돌려준다 (line-by-line stdout).
최소 스케치
codeact_executor.py — 격리된 환경에서 모델의 코드를 실행
ALLOWED_IMPORTS = {"json", "re", "math", "datetime", "tools"}
def execute_codeact(code: str, tools: dict, timeout_s: int = 30) -> str:
tools는 모델이 호출할 수 있는 함수 dict — 컨테이너 안에 미리 주입
wrapper = textwrap.dedent(f"""
from tools import {", ".join(tools.keys())}
try:
{textwrap.indent(code, " ")}
except Exception as e:
print(f"__ERROR__: {{type(e).__name__}}: {{e}}", file=sys.stderr)
""")
result = subprocess.run(
["docker", "run", "--rm", "-i", "--network=none",
"--memory=512m", "--cpus=1.0", "codeact-sandbox:latest", "python", "-"],
input=wrapper.encode(),
capture_output=True,
timeout=timeout_s,
)
stdout = result.stdout.decode()[-4000:] # 출력 캡
stderr = result.stderr.decode()[-1000:]
return f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}"
모델 루프
while not done:
code = llm_extract_codeact(messages) # ```python ... ``` 블록만 뽑기
obs = execute_codeact(code, tools)
messages.append({"role": "user", "content": f"Execution result:\n{obs}"})
핵심 디테일: **출력 캡** (마지막 4KB만), **네트워크 격리** (`--network=none` 이후 필요한 도메인만 추가), **메모리/CPU 제한**, **타임아웃**.
3장 · Plan-Execute — 계획을 먼저 세우고, 그다음 실행
**한 줄 정의:** 한 번의 큰 LLM 호출로 N단계 plan을 만든 뒤, plan을 따라 결정론적으로(또는 더 작은 모델로) 실행한다. ReAct가 "한 스텝 생각 → 한 스텝 행동"을 반복한다면, Plan-Execute는 "한 번에 N스텝 생각 → N스텝 행동"이다.
LangGraph의 공식 튜토리얼에 "plan-and-execute" 예제가 있고, Wang et al.의 "Plan-and-Solve Prompting"(2023) 이후로 가장 안정적인 구조로 자리 잡았다.
토폴로지
[Plan phase] [Execute phase]
┌──────────────┐ ┌──────────────┐
│ Planner LLM │ → step list → │ Executor LLM │ → results
│ (one shot) │ │ (per step) │
└──────────────┘ └──────┬───────┘
│
optional re-plan ◄─────┘
(if step fails)
언제 빛나는가
- **단계 수가 미리 예측 가능한 태스크** — "리포트 작성"은 (자료 수집 → 정리 → 초안 → 검토 → 다듬기)로 거의 항상 분해된다.
- **각 스텝이 독립적**이어서 작은 모델로 실행해도 되는 태스크. 비용이 절반 이하로 떨어진다.
- **재현 가능성이 중요한** 워크플로. Plan을 저장해두면 같은 입력에 같은 plan이 나온다 (temperature=0일 때).
어떻게 무너지는가
- **계획 단계에서 환각.** Planner가 "Step 3: query the `customers` table"이라고 했는데 그런 테이블이 없다. Execution이 시작되어야 발견된다. → 해법: planner에게 가능한 툴/리소스의 schema를 먼저 주거나, planner가 plan을 만들기 전에 "discover" phase를 거치게 한다.
- **세상은 plan대로 안 움직인다.** 3번째 스텝에서 외부 API가 401을 뱉으면? 4번째 스텝은 무의미하다. → 해법: re-planning 트리거를 둔다 (failure 또는 unexpected observation).
- **너무 잘게 쪼개면 plan-overhead가 커진다.** 4단계로 충분한 일을 12단계로 만든다. → 해법: planner system prompt에 "make plans at most N steps unless absolutely necessary"를 명시.
최소 스케치
plan_execute.py
def plan(task: str, tools_schema: dict) -> list[dict]:
prompt = f"""You will be given a task. Decompose it into 3-7 ordered steps.
Each step: {{"id": int, "goal": str, "tool": str | null, "depends_on": [int]}}
Available tools: {list(tools_schema.keys())}
Task: {task}
Return a JSON array."""
return json.loads(llm(prompt, temperature=0))
def execute_step(step: dict, prior_results: dict) -> dict:
context = "\n".join(f"step{i}: {prior_results[i]}" for i in step["depends_on"])
return run_executor_agent(step["goal"], context, tool=step["tool"])
def plan_execute(task: str, tools_schema: dict, max_replans: int = 2):
steps = plan(task, tools_schema)
results, replans = {}, 0
i = 0
while i < len(steps):
try:
results[steps[i]["id"]] = execute_step(steps[i], results)
i += 1
except UnexpectedObservation as e:
if replans >= max_replans: raise
steps = plan(f"{task}\nSo far: {results}\nProblem: {e}", tools_schema)
replans += 1
i = 0 # 새 plan으로 다시
return results
ReAct 대비 LLM 호출 수가 1/3 ~ 1/5로 떨어지는 게 일반적이다. 그래서 비용이 가장 큰 이점이다.
4장 · Self-RAG / Corrective RAG — 검색 여부 자체를 결정한다
**한 줄 정의:** 모든 쿼리에 검색을 붙이는 일반 RAG와 달리, 에이전트가 **"검색할지 말지", "결과가 충분한지", "다시 검색해야 할지"** 를 스스로 판단한다.
원조는 Asai et al.의 "Self-RAG"(2023). 이후 Yan et al.의 "Corrective Retrieval Augmented Generation"(CRAG, 2024)이 "검색 결과가 나쁘면 웹 검색으로 보완"하는 형태로 확장했다. 2025~2026년에는 LangGraph 튜토리얼의 "Adaptive RAG" 예제가 사실상 표준 reference가 되었다.
그래프
┌─────────────┐
query ───────────► │ Route LLM │
└──────┬──────┘
retrieve? │ direct?
┌────────────────┴────────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Retrieve │ │ Answer │
└────┬─────┘ │ directly │
▼ └──────────┘
┌──────────┐
│ Grade │ ──→ irrelevant ──→ rewrite query, retry
│ docs LLM │ ──→ partial ──→ web search supplement
└────┬─────┘ ──→ relevant ──→ answer
▼
┌──────────┐
│ Answer │ ──→ hallucinated? ──→ retry
│ + check │ ──→ grounded ──→ return
└──────────┘
언제 빛나는가
- **질문이 다양한 시스템.** 어떤 질문은 retrieval이 필요 없고("2 + 2는?"), 어떤 질문은 여러 검색이 필요하다 ("우리 회사 환불 정책과 2025 EU 소비자법 비교").
- **retrieval 품질이 들쭉날쭉한** 환경. 사내 위키 + 슬랙 + 노션 + 깃허브 이슈를 다 뒤지는 경우. 한 번에 다 나오지 않는다.
- **할루시네이션 비용이 큰** 도메인 — 법무·의료·금융. "검색 결과가 충분하지 않다"라고 말할 수 있는 게 중요하다.
어떻게 무너지는가
- **루프가 길어진다.** "다시 검색", "다시 검색", "다시 검색" → 토큰 청구서 폭발. → 해법: retrieval hop 캡 (보통 3).
- **grader가 grader 본인의 환각을 한다.** 문서가 답을 충분히 담고 있는데 "irrelevant"라고 판단. → 해법: grader를 작은 분류 모델로 분리하고 confidence threshold를 둔다.
- **rewrite가 원래 의도를 흐린다.** "환불 정책" → rewrite → "환불 절차 단계별 안내"로 바뀌면서 정작 정책의 예외 조항을 못 찾는다. → 해법: rewrite 시 원본 쿼리를 같이 보낸다.
최소 스케치
self_rag.py
def adaptive_rag(query: str, max_hops: int = 3):
route = llm_classify(query, choices=["direct", "retrieve"])
if route == "direct":
return llm_answer(query)
for hop in range(max_hops):
docs = retrieve(query, k=5)
grade = llm_grade(query, docs) # {"relevant", "partial", "irrelevant"}
if grade == "relevant":
answer = llm_answer_with_context(query, docs)
if llm_check_grounding(answer, docs) == "grounded":
return answer
환각이면 재시도
continue
if grade == "partial":
web_docs = web_search(query, k=3)
return llm_answer_with_context(query, docs + web_docs)
irrelevant → rewrite
query = llm_rewrite(query, hint=f"prior docs were off-topic: {summarize(docs)}")
return llm_answer_with_context(query, docs) # 마지막 시도
5장 · Reflexion / Self-Critique — verbal-RL 식 재시도 루프
**한 줄 정의:** 태스크를 시도하고, 실패하면, **"왜 실패했는지" 자연어 reflection**을 만들어 메모리에 저장하고, 다음 시도 때 이 reflection을 system prompt에 주입한다. 가중치는 안 바뀐다 — "verbal reinforcement learning"이다.
원조는 Shinn et al.의 "Reflexion: Language Agents with Verbal Reinforcement Learning"(NeurIPS 2023). HumanEval에서 GPT-4의 코딩 성공률을 80% → 91%로 올렸다.
사이클
┌──────────────────────────────────────┐
│ │
▼ │
Actor ──► Action ──► Env ──► Reward │
(LLM) │
▲ │
│ │
Evaluator (success/fail판단) │
│ │
▼ │
Self-Reflection LLM ──► "why I failed" ───┘
(memory buffer)
언제 빛나는가
- **명확한 실패 신호가 있는** 태스크. 코드 테스트 통과/실패, 수치 답 정/오, 사용자 거절. 시그널이 있으면 reflection이 잡을 게 있다.
- **시도가 싸고 빠른** 환경 — 코드 한 줄 고치고 unit test 돌리기. 시도 비용이 시간당 100달러짜리 외부 API라면 안 맞는다.
- **같은 종류의 태스크를 반복**할 때. Reflection memory가 누적되면서 학습 효과가 생긴다.
어떻게 무너지는가
- **잘못된 reflection이 오히려 다음 시도를 망친다.** "지난번엔 X 때문에 실패했어"라는 잘못된 결론이 다음 시도를 잘못된 방향으로 끌고 간다. → 해법: reflection에 confidence를 붙이고, 같은 reflection이 두 번 연속 나오면 다른 방향을 강제한다.
- **무한 시도.** "또 실패했네, 또 reflection, 또 시도…". → 해법: hard retry cap (보통 3~5).
- **memory bloat.** Reflection이 누적되면 context window를 다 잡아먹는다. → 해법: 최근 N개만 유지하거나, 의미적 유사도가 낮은 것만 남긴다.
최소 스케치
reflexion.py
def reflexion_loop(task: str, max_trials: int = 5):
reflections = []
for trial in range(max_trials):
sys_prompt = base_prompt + "\n\nPast reflections:\n" + "\n".join(reflections[-3:])
result = actor_llm(task, system=sys_prompt)
evaluation = evaluator(result, task) # {"success", "fail", reason}
if evaluation["status"] == "success":
return result
reflection = reflect_llm(task=task, attempt=result, why_failed=evaluation["reason"])
핵심: 중복 reflection 제거
if reflection not in reflections:
reflections.append(reflection)
else:
같은 깨달음 반복 → 강제 변형
reflection = reflect_llm(task=task, attempt=result, hint="try a completely different approach")
reflections.append(reflection)
return result # 마지막 시도 반환
6장 · Handoff — swarm 스타일 멀티에이전트
**한 줄 정의:** Supervisor가 없다. **각 에이전트가 자기 일이 끝나면 다음 에이전트에게 직접 "handoff"한다.** OpenAI가 2024년 10월에 공개한 Swarm 라이브러리의 핵심 패턴이고, Anthropic의 computer-use 데모도 결과적으로 비슷한 형태를 띤다.
Supervisor vs Handoff
[Supervisor] [Handoff]
┌─────────┐ A
│ Sup │ │ handoff_to(B)
└──┬──┬──┬┘ ▼
▼ ▼ ▼ B
A B C │ handoff_to(C)
▼
중앙집중 결정 C
"누가 다음에?" 분산 결정
"다음은 누구한테 넘길지 내가 안다"
OpenAI Swarm에서 handoff는 그냥 "다른 함수처럼 보이는 tool call"이다. `transfer_to_billing()` 같은 함수를 부르면 대화 컨텍스트가 billing 에이전트로 넘어간다.
언제 빛나는가
- **태스크가 명확하게 단계가 나뉘는** 워크플로 — "트리아지 에이전트가 받음 → 환불이면 billing으로, 기술 문제면 tech-support로 → 거기서 끝".
- **각 에이전트의 책임이 좁고 잘 정의**되어 있을 때. 좁은 책임 = 좋은 system prompt.
- **상태 누적이 작은** 대화. handoff 시점에 컨텍스트가 모두 다음 에이전트에게 넘어가기 때문에, 양이 적어야 한다.
어떻게 무너지는가
- **handoff 루프.** A → B → A → B. → 해법: handoff 횟수 카운터 + 같은 페어로 2번 이상 핑퐁 시 escalation.
- **컨텍스트가 모두 따라가서 비용 폭발.** 대화가 길어지면 매 handoff마다 같은 컨텍스트를 새 에이전트가 다시 처리한다. → 해법: handoff 시점에 요약 + 변수 패킷만 넘긴다.
- **system prompt 충돌.** Tech-support 에이전트가 "공감하라"인데 billing 에이전트는 "정확한 숫자만"이다. handoff 직후 톤이 갑자기 바뀐다. → 해법: 공통 베이스 prompt + 역할별 오버레이.
최소 스케치
swarm.py — OpenAI Swarm 스타일
def make_agent(name, instructions, tools, handoffs):
return {
"name": name,
"instructions": instructions,
"tools": tools + [{"name": f"transfer_to_{h.name}", "fn": lambda h=h: h} for h in handoffs],
}
triage = make_agent("triage",
"Route user to billing or tech_support based on intent.",
tools=[], handoffs=[billing, tech])
def run(agent, user_msg, history):
while True:
history.append({"role": "user", "content": user_msg})
out = llm(agent["instructions"], history)
if out.tool_call and out.tool_call.name.startswith("transfer_to_"):
agent = out.tool_call.fn() # handoff!
continue
return out, history
7장 · Specialist routing — intent classifier가 작업 분배
**한 줄 정의:** 사용자 입력이 들어오면, 먼저 **싸고 빠른 분류기**가 intent를 잡고, 거기에 해당하는 specialist 에이전트로 라우팅한다. Supervisor와 비슷해 보이지만, 차이는 **분류기가 LLM이 아닌 작은 분류 모델이거나 결정론적 규칙**이라는 점이다.
대부분의 프로덕션 챗봇이 이미 이렇게 한다. Anthropic의 customer-facing 에이전트, OpenAI ChatGPT의 GPTs 라우팅, Slack/Discord 봇의 명령어 라우팅.
토폴로지
user msg
│
▼
┌─────────────┐
│ Intent │ ← 작은 BERT 분류기, 혹은 regex 룰, 혹은 Haiku 같은 싼 LLM
│ classifier │
└──┬─────┬────┘
│ │
▼ ▼
agent_a agent_b ... agent_n
(각자 자기 system prompt, 자기 도구만)
언제 빛나는가
- **intent가 셀 수 있게 유한**할 때 ("환불 / 배송 조회 / 계정 / 기타").
- **각 intent의 응답이 매우 다른** 시스템 — 같은 prompt로 못 한다.
- **latency가 critical**한 시스템. 분류기를 BERT-base 같은 거 쓰면 50ms 안에 끝난다.
어떻게 무너지는가
- **잘못 분류된 입력은 영원히 잘못된 에이전트로 간다.** "결제가 안 됐는데 환불 안 해줘서 화났어"는 billing? complaint? → 해법: confidence가 낮으면 generalist로, 분류기 출력에 항상 "uncertain" 옵션을 둔다.
- **새 intent가 생기면 분류기 재학습 필요.** → 해법: 작은 LLM에게 "기존 N개 클래스 + other" 형태로 zero-shot 분류시키고, "other" 비율을 모니터링한다.
- **multi-intent.** "주문 취소하고 새로 주문하고 싶어" → cancel + create. → 해법: 분류를 multi-label로 두거나, generalist가 직접 처리한다.
최소 스케치
routing.py
INTENT_AGENTS = {
"billing": billing_agent,
"shipping": shipping_agent,
"account": account_agent,
"general": general_agent,
}
def classify(text: str) -> tuple[str, float]:
작은 분류기 — bert-base finetune 또는 Haiku zero-shot
logits = small_classifier(text)
intent, conf = top1(logits)
return intent, conf
def route(user_msg: str):
intent, conf = classify(user_msg)
if conf < 0.7 or intent == "uncertain":
return general_agent(user_msg) # fallback
return INTENT_AGENTS[intent](user_msg)
8장 · Subagent isolation — Claude Code의 컨텍스트 격리 패턴
**한 줄 정의:** 메인 에이전트가 큰 태스크 중간에, **완전히 새 컨텍스트 윈도**를 가진 자식 에이전트(subagent)를 띄워서 좁은 서브태스크를 시키고, 결과 요약만 받는다. 자식의 컨텍스트는 부모에 누적되지 않는다.
Claude Code의 핵심 패턴 중 하나로, Anthropic의 공식 문서가 "subagent / Task tool"로 다룬다. 비슷한 개념이 OpenAI의 "Agents SDK"의 subagent, Cursor의 "background agents"에도 있다.
그림
[main agent context]
┌────────────────────────────────────────────────┐
│ user task, system prompt, history (240k tok) │
│ │
│ ── spawn(subagent, "summarize 18 PRs") ──► │
│ │
│ [subagent context] │
│ ┌───────────────────┐ │
│ │ fresh window │ │
│ │ subtask + tools │ │
│ │ (returns: 400 tok)│ │
│ └─────────┬─────────┘ │
│ │ │
│ ◄────── result summary (400 tok) ── │
│ │
│ continues main task (context grew by 400 tok) │
└────────────────────────────────────────────────┘
언제 빛나는가
- **큰 검색/탐색**이 필요한데 raw 결과를 부모가 알 필요는 없을 때. "이 18개 PR을 다 읽고 stale한 거 찾아"는 subagent로 보내고, 부모는 결과 리스트만 받는다.
- **context rot 방지**가 중요한 긴 세션. 부모 컨텍스트가 80%를 넘기 전에 일을 위임한다.
- **병렬화** 가능한 독립 서브태스크. 3개 subagent를 동시에 띄울 수 있다.
어떻게 무너지는가
- **자식이 부모의 의도를 모른다.** "stale PR 찾아"라고만 하면 자식은 "왜?" 모른다. → 해법: 부모가 prompt 작성 시 충분한 컨텍스트를 명시적으로 패킹.
- **자식이 무한히 일을 키운다.** "PR 18개" → 자식이 PR마다 또 subagent를 띄움 → 재귀. → 해법: subagent 깊이 제한 + token budget 제한.
- **결과 통합이 어렵다.** 3개 자식이 서로 다른 포맷의 결과를 돌려준다. → 해법: subagent prompt에 출력 schema를 명시.
최소 스케치
subagent.py
def spawn_subagent(parent_state, subtask: str, tools: list,
max_tokens: int = 8000, depth: int = 0):
if depth >= 2:
raise RuntimeError("subagent depth limit reached")
sub_context = {
"system": SUB_AGENT_SYSTEM, # 부모와 다른 system prompt
"messages": [{"role": "user", "content": subtask}],
}
while not done(sub_context) and tokens(sub_context) < max_tokens:
sub_context = step(sub_context, tools, depth=depth + 1)
summary = final_summary(sub_context) # 자식이 자기 결과를 짧게 요약
return summary # 부모는 이것만 받는다
부모 루프 안에서
if parent_decides_to_delegate():
summary = spawn_subagent(state, "summarize the 18 open PRs in the repo", tools=[git_tool, gh_tool])
state["messages"].append({"role": "tool", "name": "subagent", "content": summary})
핵심은 **부모의 messages에 자식의 raw chain이 안 들어간다**는 것. 8,000 토큰짜리 자식 작업이 400 토큰짜리 요약으로 들어온다.
9장 · Hooks + 권한 게이트 — LLM 스텝 사이에 결정론적 가드레일
**한 줄 정의:** 에이전트 루프의 매 스텝 전/후에 **결정론적 코드(hook)**가 끼어들어 검사/변형/차단을 한다. LLM은 빠르고 유연하지만 신뢰할 수 없고, hook은 느리지만 100% 결정론적이다. 둘이 교대로 쓰인다.
Claude Code의 hook system (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`)이 대표적이고, LangGraph의 `interrupt_before`/`interrupt_after`, OpenAI Agents SDK의 guardrails, Anthropic Agent Skills의 permission gates가 같은 카테고리다.
어디에 끼어드는가
┌──────────────────────────────────────┐
│ Agent loop │
│ │
user │ ┌──── UserPromptSubmit hook ───┐ │
msg ──┼─►│ check policy, strip secrets │ │
│ └──┬────────────────────────────┘ │
│ ▼ │
│ ┌──── LLM step ──────────┐ │
│ │ propose tool call │ │
│ └──┬─────────────────────┘ │
│ ▼ │
│ ┌── PreToolUse hook ────┐ │
│ │ allow? deny? mutate? │ │
│ └──┬────────────────────┘ │
│ ▼ │
│ ┌── Tool execution ─────┐ │
│ └──┬────────────────────┘ │
│ ▼ │
│ ┌── PostToolUse hook ───┐ │
│ │ redact, audit │ │
│ └──┬────────────────────┘ │
│ ▼ │
│ loop │
└──────────────────────────────────────┘
언제 빛나는가
- **돌이킬 수 없는 액션**에 대한 인간 승인. `rm -rf`, 결제, 외부 이메일 발송. LLM이 "괜찮을 거야"라고 하든 말든, hook이 차단한다.
- **시크릿 누수 방지** — LLM 입력에 API 키, 토큰, PII가 들어오는지 매번 정규식으로 검사.
- **정책 강제** — "특정 폴더 외부 파일은 못 건드림", "특정 도메인 외부 URL은 못 요청함".
- **audit log** — 모든 tool call을 PostHook에서 영구 저장. 사고 났을 때 추적 가능.
어떻게 무너지는가
- **hook이 너무 빡빡하면 에이전트가 멈춰버린다.** "모든 파일 쓰기 거부" → 에이전트가 아무것도 못 한다. → 해법: deny-by-default 대신 allowlist + ask-user for borderline.
- **hook이 LLM에게 안 보이면 같은 시도 반복.** 거부당했는데 모델은 이유를 모르고 또 시도. → 해법: hook의 reject 메시지를 다음 LLM 입력에 포함.
- **hook 안에서 LLM 호출 = 비결정론.** "이게 위험한지 LLM에게 물어봄"은 hook의 의미를 깬다. → 해법: hook은 정적 규칙으로만, 모호하면 차단하고 사람에게 묻는다.
최소 스케치
hooks.py
def pre_tool_hook(tool: str, args: dict, state) -> tuple[bool, str | None]:
if tool == "bash":
cmd = args.get("command", "")
if "rm -rf" in cmd or "curl http" in cmd:
return False, "blocked: dangerous command pattern"
if "$SECRET" in cmd or re.search(r"sk-[a-zA-Z0-9]{20,}", cmd):
return False, "blocked: secret leak detected"
if tool == "write_file":
if not args["path"].startswith(state["allowed_root"]):
return False, f"blocked: outside allowed root {state['allowed_root']}"
return True, None
def agent_step(state):
proposal = llm_propose_action(state)
allowed, reason = pre_tool_hook(proposal.tool, proposal.args, state)
if not allowed:
state["messages"].append({"role": "tool", "content": f"BLOCKED: {reason}"})
return state
result = execute(proposal)
audit_log(proposal, result) # post hook
state["messages"].append({"role": "tool", "content": result})
return state
10장 · 실제 시스템은 이들을 어떻게 조합하는가
여기서부터가 진짜다. 단일 패턴으로 굴러가는 시스템은 거의 없다. 2026년의 잘 만들어진 프로덕션 에이전트는 **3~4개의 패턴을 합성**한다.
케이스 1 · 코딩 에이전트 (Claude Code, Cursor, Devin 류)
[user task]
│
▼
┌────────────────────────────────────────────────┐
│ Main agent (loop) — Plan-Execute │
│ - Reads task, builds a 3-7 step plan │
│ - Per step: │
│ ┌─── PreToolUse hook ────┐ │
│ │ permission + policy │ │
│ └─────────┬──────────────┘ │
│ ▼ │
│ ┌─── Tool / CodeAct exec ────┐ │
│ └─────────┬──────────────────┘ │
│ ▼ │
│ ┌─── PostToolUse hook ───┐ │
│ │ audit, redact │ │
│ └─────────┬──────────────┘ │
│ ▼ │
│ need deep exploration? ──► spawn subagent │
│ (isolated ctx)│
│ ▼ │
│ - On step failure: Reflexion-style retry │
└────────────────────────────────────────────────┘
조합: **Plan-Execute + Subagent isolation + Hooks + Reflexion** (4개).
케이스 2 · 고객 지원 에이전트 (Anthropic, Intercom Fin, Linear Asks)
[user msg]
│
▼
┌──── Specialist routing (intent classifier) ────┐
│ ▼ ▼ ▼ ▼ │
│ billing shipping account general │
│ │ │ │ │ │
└──────┼──────────┼─────────┼───────────┼────────┘
▼ ▼ ▼ ▼
[each specialist: Self-RAG over internal KB]
│
▼
can resolve? ──── yes ──► reply
│
no
▼
Handoff to human OR another specialist
조합: **Specialist routing + Self-RAG + Handoff** (3개).
케이스 3 · 데이터 분석 에이전트 (Manus, OpenAI Code Interpreter)
[user question over CSV / DB]
│
▼
Supervisor (Manus-style)
│
├─► Researcher subagent (subagent isolation)
│ └─ Self-RAG over schemas/docs
│
├─► Analyst subagent
│ └─ CodeAct (writes pandas / SQL in Python blocks)
│ └─ executes in sandbox with Hooks (network=none, fs scoped)
│
└─► Reporter subagent
└─ writes markdown summary
조합: **Supervisor + Subagent isolation + CodeAct + Self-RAG + Hooks** (5개).
케이스 4 · OS 자동화 에이전트 (Anthropic computer use)
[user goal: "book a flight"]
│
▼
Plan phase: high-level plan (find site → search → fill form → confirm)
│
▼
Execute phase: action loop
- screenshot
- decide click/type/key
- PreActionHook: dangerous-zone? (banking, settings) → halt + ask user
- execute
- PostActionHook: screenshot diff
- on unexpected screen → Reflexion-style replan
조합: **Plan-Execute + Hooks + Reflexion**.
패턴 조합 매트릭스
단순 | 중간복잡 | 매우복잡
입문서 ReAct만 | ReAct+Reflection | (벤치마크 거기서 멈춤)
실제 프로덕션 추천 Routing+ | Plan-Exec+ | Supervisor+Subagent+
Self-RAG | Hooks+Subagent| CodeAct+Hooks+Reflexion
11장 · 안티패턴 — 흔히 보는 실패
1. **모든 패턴 합치기.** "Supervisor 안에 또 supervisor, subagent 안에 plan-execute…". 디버깅이 불가능해진다. **추가하기 전에 빼라**가 황금률이다.
2. **Hooks 없이 CodeAct.** 며칠 안에 누군가 `rm -rf` 또는 secret 누수를 만든다. CodeAct는 sandbox + hooks가 prerequisite이다.
3. **Supervisor 컨텍스트에 모든 specialist raw output을 누적.** 5라운드 안에 240k 토큰을 다 쓴다.
4. **Reflexion을 hard cap 없이.** 12번 재시도가 청구서 3,000달러로 돌아온다.
5. **Plan-Execute를 plan에 너무 의존시킴.** Plan은 가설일 뿐이다. Execute 단계에서 plan과 다른 관찰이 나오면 replan을 허용해야 한다.
6. **Handoff 시 컨텍스트를 그대로 전달.** 매번 새 에이전트가 같은 컨텍스트를 다시 읽으니 비용이 N배가 된다. **요약 + 변수 패킷**만 넘긴다.
7. **Self-RAG의 grader를 자신과 같은 모델로.** Grader는 비싸지 않은 별도 모델이 낫다.
8. **Routing의 confidence 무시.** 분류기가 0.51의 confidence로 보낸 input도 그대로 specialist로 보낸다. → fallback 트랙 필수.
12장 · 패턴 선택 체크리스트
새 에이전트를 만들 때 던져야 할 질문:
- [ ] 태스크의 단계 수가 **미리 예측 가능**한가? → 가능하면 Plan-Execute, 아니면 ReAct + Reflexion.
- [ ] **이질적인 도구/지식 영역**이 섞이는가? → Specialist routing 또는 Supervisor.
- [ ] **계산/조합이 많은** 액션이 있는가? → CodeAct (단, sandbox는 prerequisite).
- [ ] **외부 지식**이 답에 필수인가? → 모두 RAG가 아니라 Self-RAG (필요할 때만).
- [ ] **명확한 success/fail 신호**가 있는가? → Reflexion 도입 검토.
- [ ] **돌이킬 수 없는 액션**이 있는가? → Hooks + 권한 게이트, 필수.
- [ ] **긴 세션 / 큰 탐색**이 있는가? → Subagent isolation.
- [ ] **여러 에이전트가 자연스럽게 핸드오프**되는 워크플로인가? → Handoff (단, hop cap 필수).
조합 가능 매트릭스를 직접 그려라. 패턴은 도구일 뿐이다.
에필로그 — 패턴 카탈로그가 아니라 시스템 사고
2024년의 글들이 "ReAct를 써라"라고 말했다면, 2026년의 답은 더 차분하다 — **"당신의 워크로드에 어떤 실패가 잦은가?"** 로 시작한다. 컨텍스트가 폭주하면 Subagent. 검색이 들쭉날쭉하면 Self-RAG. 단계가 흩어지면 Plan-Execute. 돌이킬 수 없는 액션이 있으면 Hooks. 책임이 너무 넓으면 Specialist routing이나 Supervisor.
패턴은 카드 덱이고, 당신은 손을 짠다. 손은 시스템에 따라 다르다. 그리고 한 번에 한 카드씩만 더하라 — 그게 가장 큰 교훈이다.
다음 글에서는 이 패턴들의 **평가**를 다룬다. Inspect AI, Phoenix, LangSmith로 multi-pattern 에이전트의 회귀를 어떻게 잡는지, 그리고 swarm/handoff 같은 비결정론적 토폴로지에서 "정답"이 무엇인지를 정의하는 법.
참고 / References
- Yao et al., "ReAct: Synergizing Reasoning and Acting in Language Models" (ICLR 2023). https://arxiv.org/abs/2210.03629
- Wang et al., "Plan-and-Solve Prompting" (ACL 2023). https://arxiv.org/abs/2305.04091
- Asai et al., "Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection" (ICLR 2024). https://arxiv.org/abs/2310.11511
- Yan et al., "Corrective Retrieval Augmented Generation" (CRAG, 2024). https://arxiv.org/abs/2401.15884
- Shinn et al., "Reflexion: Language Agents with Verbal Reinforcement Learning" (NeurIPS 2023). https://arxiv.org/abs/2303.11366
- Yang et al., "Executable Code Actions Elicit Better LLM Agents" (CodeAct, 2024). https://arxiv.org/abs/2402.01030
- LangGraph — Multi-Agent Supervisor tutorial. https://langchain-ai.github.io/langgraph/tutorials/multi_agent/agent_supervisor/
- LangGraph — Plan-and-Execute. https://langchain-ai.github.io/langgraph/tutorials/plan-and-execute/plan-and-execute/
- LangGraph — Adaptive RAG. https://langchain-ai.github.io/langgraph/tutorials/rag/langgraph_adaptive_rag/
- OpenAI Swarm — minimal multi-agent orchestration framework. https://github.com/openai/swarm
- Anthropic — "Computer use" (Claude 3.5 Sonnet). https://www.anthropic.com/news/3-5-models-and-computer-use
- Anthropic — Claude Code Subagents docs. https://docs.claude.com/en/docs/claude-code/sub-agents
- Anthropic — Claude Code Hooks docs. https://docs.claude.com/en/docs/claude-code/hooks
- HuggingFace `smolagents` — CodeAgent. https://huggingface.co/docs/smolagents/index
- Manus — agent architecture write-up. https://manus.im/blog
- OpenAI Agents SDK. https://github.com/openai/openai-agents-python
현재 단락 (1/500)
2023~2024년에 쏟아진 "LLM Agent 설계 패턴" 글들의 카탈로그는 대개 비슷하다. ReAct, Chain-of-Thought, Reflection, Plan-and-S...