notebook
Anthropic · 2025.06 학습 노트 · 읽는 시간 14분

Multi-Agent
Research System

"Claude의 Research 기능은 단일 에이전트가 아니라 — Lead 한 명과 여러 Subagent의 협업이다. 그리고 그 협업이 만들어내는 토큰 비용은 우리가 처음 예상한 것의 15배였다."

제품 Claude.ai· Research
아키텍처 Orch–Worker
토큰 비용 ~15xvs chat
교훈 Prompts > Models
작성 2026.05.17
01

한 줄로 이해하기

리서치는 본질적으로 breadth-first 탐색이다. 하나의 에이전트가 thread 하나씩 순차적으로 따라가는 것보다, Lead 한 명이 plan을 세우고 여러 Subagent를 병렬로 보내 각자 다른 가지를 탐색하게 하는 게 훨씬 빠르고 깊다 — 단, 토큰 비용은 정직하게 청구된다.

i

왜 멀티 에이전트인가

리서치는 여러 가설을 동시에 탐색해야 하는 작업. 직렬 탐색은 비효율적. Subagent들이 독립 context로 서로 다른 쿼리·소스를 동시에 처리 → 시간을 N배 단축, 발견의 폭은 더 넓어진다.

ii

핵심 아키텍처: Orchestrator–Worker

Lead Agent가 질문을 받아 plan을 세우고 3~10개의 Subagent를 dispatch. 각 Subagent는 자신의 context · 도구 · scope로 작업 후 압축된 findings 리턴. Lead가 synthesize하고 Citation Agent가 인용을 붙인다.

iii

토큰 경제학 — 15x의 비용

단일 챗 대비 ~15배, 단일 에이전트 대비 ~4배 토큰 사용. ROI는 답의 가치가 토큰 비용을 정당화할 때만 양수. 높은 가치 · breadth-first · 병렬화 가능한 작업에만 적용해야 한다.

iv

프롬프트가 모델 업그레이드보다 중요했다

도구 선택 규칙을 명확히 쓰는 것 · Subagent에 scope를 정확히 전달하는 것 · 출력 포맷을 강제하는 것 — 이 셋이 모델 버전 업그레이드보다 큰 성능 향상을 가져왔다. Better prompts > Better models.

v

평가는 어렵다 — LLM judge로 풀어라

멀티 에이전트는 long-horizon + 비결정적 → 단위 테스트 불가. End-state 평가를 LLM judge로. 작은 평가셋(n=20)도 의미 있다 — early signal이 완벽한 셋보다 낫다.

vi

프로덕션 운영은 새로운 게임이다

에이전트는 stateful → 핫리스타트 불가, Rainbow deployment 필요. 비결정성으로 인한 heisenbug는 reproduce 불가능 → 모든 step을 trace에 남겨야 한다. 모니터링·관측이 곧 안정성.

02
Architecture & Numbers

그림으로 보는 구조

01 · 아키텍처Lead–Subagent Fan-out

Lead Agent는 사용자 질문을 받아 plan을 세우고, 독립 context를 가진 Subagent들을 병렬로 dispatch한다. 각 Subagent는 자신만의 도구 · scope · 메모리로 작업하고 압축된 findings(쿼리 결과 + 소스 링크)를 Lead에게 리턴한다.

Lead는 모든 findings를 모아 synthesize하고, 마지막에 Citation Agent가 주장 옆에 출처를 매핑한다. 이 분리(인용 ≠ 본문 작성)가 hallucination을 크게 줄였다.

핵심 결정

Subagent 수는 LLM 자체가 결정 (보통 3~5, 복잡한 질문은 10+). 너무 많으면 context 폭주 + 비용 폭주, 너무 적으면 직렬 작업과 차이 없음.

Lead Agentplan · delegate · synth
Sub 1tool A
Sub 2tool B
Sub 3tool C
Sub Ntool …
Synthesis
Citation Agent

02 · 비용토큰 경제학

Anthropic 내부 측정 기준 — 단일 채팅 대비 멀티 에이전트는 약 15배 토큰 사용, 단일 에이전트(예: research-with-tools)와 비교해도 ~4배다. 각 Subagent가 독립 context를 가지므로 system prompt · tool 정의가 N번 반복되고, Lead가 모든 findings를 다시 읽어 synthesize하기 때문.

그러므로 멀티 에이전트는 답 하나의 가치가 충분히 큰 작업에만 적용한다. FAQ 챗봇에 쓰면 ROI가 절대 안 나온다.

역치

답이 사람의 30분~수시간을 절감하는 작업 → ROI 양수. 답이 1분짜리면 적용 금지.

Chat (baseline)
1x
Single Agent
~4x
Multi-Agent
~15x

03 · 스케일링Effort Scaling

질문의 복잡도에 따라 동원되는 에이전트 수가 달라진다. 간단한 fact-finding은 도구 몇 번 호출하는 단일 에이전트로 충분하지만, "X 회사의 경쟁사 5곳을 비교하라" 같은 작업은 Subagent들에게 각자 다른 회사를 할당해 병렬 처리.

가장 복잡한 작업(10+ Subagent, 다중 레벨)에서는 Subagent가 자신의 Sub-subagent를 또 만든다 — 깊이가 늘수록 trace · 디버깅 복잡도가 기하급수적.

휴리스틱

"이 질문에 답하려면 독립적으로 탐색할 수 있는 가지가 몇 개인가?" → 그게 Subagent 수의 하한.

단순 fact
1 agent · 3~10 tool calls
직접 비교
1 lead · 2~4 subs
복잡 리서치
1 lead · 10+ subs · 다중 레벨

04 · 평가End-state Eval with LLM Judge

멀티 에이전트는 매번 다른 경로를 탐색한다 — 같은 질문도 path가 다르므로 중간 step의 단위 테스트는 의미가 없다. 대신 입력 · 출력 · (있다면) 정답 references만 가지고 end-state를 LLM judge로 평가.

n=20 정도의 작은 평가셋도 의미 있다. 완벽한 1000개 셋을 기다리지 말고, 작은 셋으로 시작해서 이상치를 잡아내는 데 사용하라.

피해야 할 것

step-level metric에 매몰되기 / 평가 LLM도 generator와 같은 시스템 프롬프트 사용 (confirmation bias).

Multi-Agent Run
query → output
LLM Judge
rubric · score
small set · early signal · iterate

05 · 운영프로덕션 도전과제

에이전트는 stateful + long-running. 단순한 stateless 웹 서비스 운영 노하우가 통하지 않는다. Rainbow deployment, observability, 메모리 관리, 비결정성 디버깅 — 네 가지가 곧 안정성의 결정 요인.

특히 heisenbug는 reproduce가 거의 불가능 — 같은 입력에도 다른 도구 호출 순서 → 다른 결과. 실패한 모든 step을 trace에 남겨두지 않으면 사후 분석 자체가 불가능하다.

도입 전 체크리스트

1) trace 인프라 / 2) rainbow 배포 / 3) 비용 캡 알림 / 4) HITL 체크포인트 — 이 넷이 모두 준비된 후에만 프로덕션.

[01]
Rainbow Deployment
in-flight 에이전트가 있어 핫리스타트 불가. 신·구 버전을 병렬 운영하며 자연 종료 대기.
[02]
Trace & Observability
모든 LLM 호출 · 도구 호출 · 결과를 보존. 비결정성 디버깅은 사후 trace에 100% 의존.
[03]
Memory across context
200K context 한도 초과 시 압축·재요약 메커니즘. Subagent와 Lead 간 상태 인계 패턴.
[04]
Heisenbugs
같은 입력에 다른 path → reproduce 불가. seed/온도 고정도 완벽한 해결책 아님. trace만이 진실.
[05]
Cost Caps & HITL
실행 중 비용 임계치 도달 시 자동 중단. 중요 분기에서 사람 승인 필수.
참고
Rainbow Deployment
"레인보우 디플로이먼트"

유래. Blue-Green Deployment의 확장. 단 두 색(blue·green)이 아니라 여러 색(버전)이 동시에 살아 운영되는 배포 전략이라 무지개라는 이름이 붙었다.

뜻. 신버전을 띄운 뒤에도 구버전 인스턴스가 in-flight 작업을 끝까지 마칠 때까지 살려둔다. 구버전은 새 요청을 받지 않고 자연 소멸 대기, 그동안 신버전이 새 요청을 처리. 배포 주기가 평균 작업 수명보다 짧으면 여러 색이 누적되어 무지개처럼 공존.

왜 멀티 에이전트에 필요한가. 일반 웹 서비스는 응답이 ms 단위라 구버전을 즉시 종료해도 무방. 그러나 에이전트는 long-running stateful — 한 요청 처리에 수십 초~수 분. 배포 중 강제 종료하면 사용자 작업이 통째로 손실된다.

참고
Heisenbug
/ˈhaɪzənbʌɡ/ · "하이젠버그"

유래. 양자역학의 베르너 하이젠베르크 불확정성 원리에서. "관측하려는 행위 자체가 대상을 바꿔버린다"는 원리를 버그에 빗댄 농담.

뜻. 디버깅하려고 하면 사라지거나 동작이 바뀌는 버그. 디버거를 붙이면 사라짐 · print() 한 줄 추가하면 사라짐 · 프로덕션에서만 발생 · 같은 입력으로 1000번 돌려도 17번째에서만 발생.

왜 멀티 에이전트에서 문제인가. LLM 호출은 본질적으로 비결정적(temperature, sampling). 거기에 도구 호출 순서가 매번 다르고 Subagent가 매번 다른 가지를 탐색 → 같은 input → 다른 output. "한 번 더 돌려봐"가 의미 없다. 모든 step의 trace 보존이 곧 유일한 진실.

원문에서 강조된 가장 반직관적 교훈 — "더 좋은 모델보다 더 좋은 프롬프트가 효과적이었다". 새 모델 출시 때마다 모두 업그레이드를 기대하지만, 실제로 사용자 만족도를 가장 크게 올린 것은 도구 description · Subagent scope · 출력 포맷에 대한 프롬프트 개선이었다.

그래서 — 언제 멀티 에이전트를 쓸까?

멀티 에이전트가 적합

  • 탐색 가지가 여러 개 (breadth-first)
  • 가지들이 서로 독립적 (병렬 가능)
  • 답 하나의 가치가 토큰 비용을 정당화
  • 지연보다 깊이가 중요
  • 예: 시장 조사, 경쟁사 비교, 문헌 리서치, 다중 소스 fact-check

적합하지 않음

  • 강한 sequential 의존성 (앞 결과로 다음 결정)
  • 실시간 요구사항 (대화형 UI)
  • 답이 짧고 단순 (FAQ · 분류 · 요약)
  • 비용 민감 / 대량 트래픽
  • 예: 챗봇, 자동완성, 단일 SQL 생성
03
Implementation

실전 코드

Anthropic SDK 기준 최소 구현. Lead → Subagent dispatch → 병렬 실행 → 합성 → Citation의 전 파이프라인을 프레임워크 없이 — 일반 Python 함수와 `asyncio`만으로 표현한다.

01 · Subagent를 도구로 정의

tool schema
from anthropic import Anthropic, AsyncAnthropic

DISPATCH_SUBAGENT = {
    "name": "dispatch_subagent",
    "description": (
        "하위 리서치 작업을 Subagent에게 위임한다. "
        "task에 'A 회사 매출 추이를 2020~2024 조사' 처럼 단일·구체 목표를 적어라. "
        "동시 dispatch 가능. 작업이 독립적일수록 좋다."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "task": {"type": "string", "description": "단일 목표, 한 문장"},
            "tools": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["task"],
        "additionalProperties": False,
    },
}
포인트. 도구 description의 품질이 곧 Lead의 분해 품질. 주니어에게 설명하듯 명확히 — "단일·구체 목표", "독립적일수록 좋다" 같은 가이드까지 description에 넣는 게 정답.

02 · Subagent 실행 (독립 context)

worker
aclient = AsyncAnthropic()

SUBAGENT_SYSTEM = """너는 리서치 Subagent다. 단일 task에만 집중하라.
- 결과는 사실 기반으로 압축. 5문장 이내.
- 각 사실 옆에 (source: URL) 표기.
- task 범위를 넘는 추가 탐색 금지."""

async def run_subagent(task: str, tools: list) -> dict:
    messages = [{"role": "user", "content": task}]

    for step in range(8):  # max_steps 안전핀
        resp = await aclient.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=SUBAGENT_SYSTEM,
            tools=tools,
            messages=messages,
        )
        if resp.stop_reason == "end_turn":
            return {"task": task, "findings": resp.content[0].text}

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = await run_tool(block.name, block.input)  # 외부 도구
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output[:8000],  # truncate — 결과 폭발 방지
                })
        messages.append({"role": "user", "content": results})

    return {"task": task, "findings": "<incomplete: max_steps>"}
포인트. Subagent의 system 프롬프트가 scope 강제의 핵심. "task 범위를 넘는 추가 탐색 금지"가 없으면 Subagent가 자기 판단으로 영역을 확장해 토큰을 폭주시킨다. tool_result도 반드시 truncate.

03 · Lead의 병렬 dispatch

orchestrator
import asyncio

LEAD_SYSTEM = """너는 Lead Research Agent다.
1) 사용자 질문을 받아 step-by-step으로 사고하라.
2) 독립적으로 병렬 탐색 가능한 sub-task로 분해.
3) 각 sub-task를 dispatch_subagent 도구로 한 번에 여러 개 호출.
4) findings가 모이면 synthesize.

규칙: sub-task 수는 3~8개. 강한 의존성이 있으면 분해하지 마라."""

async def lead_agent(user_query: str) -> str:
    messages = [{"role": "user", "content": user_query}]
    tools = [DISPATCH_SUBAGENT]

    for turn in range(5):
        resp = await aclient.messages.create(
            model="claude-opus-4-7",  # Lead는 비싸도 강한 모델
            max_tokens=4096,
            system=LEAD_SYSTEM,
            tools=tools,
            messages=messages,
        )
        if resp.stop_reason == "end_turn":
            return resp.content[0].text

        messages.append({"role": "assistant", "content": resp.content})

        # 같은 turn 안의 모든 tool_use를 모아 한 번에 병렬 실행
        dispatches = [b for b in resp.content if b.type == "tool_use"]
        sub_results = await asyncio.gather(
            *[run_subagent(b.input["task"], SUBAGENT_TOOLS) for b in dispatches],
            return_exceptions=True,
        )

        tool_results = []
        for b, r in zip(dispatches, sub_results):
            content = (f"<error>{r}</error>" if isinstance(r, Exception)
                       else r["findings"])
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": b.id,
                "content": content,
            })
        messages.append({"role": "user", "content": tool_results})

    raise RuntimeError("lead exhausted turns without end_turn")
포인트. 같은 assistant turn 안의 모든 tool_use 블록을 한 번에 모아 asyncio.gather로 처리하는 게 핵심. 직렬 실행하면 멀티 에이전트의 이점이 사라진다. return_exceptions=True로 부분 실패 허용.

04 · Citation Agent (인용 후처리)

post-processor
CITATION_SYSTEM = """너는 Citation Agent다. Lead가 작성한 본문의 각 주장 옆에
findings에 있는 (source: URL)을 [^N] 형태로 매핑하라.
- 본문은 그대로 두고 인용 표기만 추가.
- 근거가 없는 주장은 [^?] 로 표시 (사후 검토 대상)."""

async def add_citations(synthesis: str, findings: list[dict]) -> str:
    sources = "\n".join(f"- {f['task']}\n{f['findings']}" for f in findings)
    resp = await aclient.messages.create(
        model="claude-haiku-4-5",  # 후처리는 작은 모델로 충분
        max_tokens=4096,
        system=CITATION_SYSTEM,
        messages=[{"role": "user", "content":
            f"<synthesis>\n{synthesis}\n</synthesis>\n\n<findings>\n{sources}\n</findings>"}],
    )
    return resp.content[0].text
포인트. 인용을 본문 작성과 분리한 것이 hallucination을 크게 줄였다. 본문 작성 LLM은 사실을 외우려 하지 않고 findings만 참조, citation LLM은 매핑만 담당. 작은 모델로도 충분.

05 · LLM Judge로 end-state 평가

eval
JUDGE_RUBRIC = """다음 기준으로 0~10점 채점하라.
- 정확성 (claims가 sources에 의해 뒷받침되는가)
- 완전성 (질문의 모든 측면을 커버했는가)
- 인용 품질 (claim ↔ source 매핑이 정확한가)

마지막 줄: score=N (정수)."""

async def judge(query: str, output: str) -> int:
    resp = await aclient.messages.create(
        model="claude-opus-4-7",  # judge는 강한 모델로
        max_tokens=1024,
        system=JUDGE_RUBRIC,
        messages=[{"role": "user", "content":
            f"<query>{query}</query>\n<output>{output}</output>"}],
    )
    text = resp.content[0].text
    last_line = text.splitlines()[-1]
    return int(last_line.split("=")[1].strip())

# 평가셋 n=20도 의미 있다 — early signal
async def eval_suite(queries: list[str]):
    outputs = await asyncio.gather(*[lead_agent(q) for q in queries])
    scores = await asyncio.gather(*[judge(q, o) for q, o in zip(queries, outputs)])
    return sum(scores) / len(scores)
포인트. judge에 generator와 다른 모델 / 다른 시스템 프롬프트를 써야 confirmation bias를 피할 수 있다. 평가셋 크기보다 평가 기준의 명확성이 더 중요.

프로덕션에서는 여기에 trace 인프라(OpenTelemetry · LangFuse 등) · 비용 캡 알림 · rainbow 배포 게이트 · HITL 체크포인트를 더해야 한다. 위 코드는 시스템의 "뼈대"이고, 프로덕션의 90%는 그 주변 인프라가 차지한다.