포스트

LLM 에이전트 기초 (6) - Planning and Subagents: 작업을 나누고 위임하기

한 에이전트가 감당하기 벅찬 작업을 서브에이전트에 위임하는 방법과, 위임이 손해가 되는 조건을 정리합니다.

LLM 에이전트 기초 (6) - Planning and Subagents: 작업을 나누고 위임하기

LLM 에이전트 기초 시리즈의 6편입니다. 4편에서 컨텍스트를 줄였지만, 조사할 대상이 늘면 한 에이전트로는 감당이 안 됩니다.

주문 50건을 조사해 달라는 요청

지금까지의 요청은 주문 한 건짜리였습니다. 이번에는 이렇습니다.

지난주 배송이 지연된 주문 50건을 전부 확인해서 지연 사유를 정리해 주세요.

0편ORDERS에는 주문이 두 건뿐이지만 같은 구조로 50건이 있다고 가정합니다. 주문 하나에 도구 호출이 두 번이니 100번이고, 3편run_agent는 이 100번을 하나의 messages 리스트에 쌓습니다. 1편에서 잰 150 토큰으로 누적량을 계산해 봅니다.

1
2
3
4
5
6
7
8
9
10
11
12
ORDER_TOKENS = 150       # 1편에서 잰 주문 한 건의 크기
BLOCK_OVERHEAD = 60      # tool_use 블록과 JSON 껍데기
CALLS = 100              # 주문 50건 x 도구 2번

history = 800            # 시스템 프롬프트와 도구 정의
total_input = 0
for _ in range(CALLS):
    total_input += history
    history += ORDER_TOKENS + BLOCK_OVERHEAD

print(total_input, history)
# 1119500 21800

마지막 컨텍스트는 21,800 토큰이라 100만 토큰 한도에는 못 미칩니다. 터지지는 않지만 누적 입력이 112만 토큰입니다. 매 호출마다 앞의 조사 결과를 전부 다시 실어 보내기 때문이고, 캐싱을 걸어도 도구 결과가 매 턴 추가되니 뒤로 갈수록 새로 계산하는 몫이 커집니다.

4편의 압축을 켜면 더 나빠집니다. 압축은 오래된 도구 결과를 요약으로 갈아끼우는 장치라, 30번째를 조사할 무렵이면 앞의 열 건이 “주문 여러 건의 배송 상태를 확인했습니다”로 뭉개집니다. 컨텍스트를 아끼려고 켠 장치가 결과물을 지웁니다. 게다가 100번의 호출이 전부 직렬입니다.

위임의 정체는 컨텍스트 격리입니다

서브에이전트(subagent) 는 별도의 messages 리스트를 가진 또 하나의 run_agent 호출입니다. 부모가 조사 지시를 문자열로 넘기면, 자식은 자기 컨텍스트에서 도구를 부르고 최종 답 하나만 돌려줍니다. 중요한 것은 자식이 부른 도구의 결과가 부모의 컨텍스트에 들어가지 않는다는 점입니다.

 부모가 직접 조사서브에이전트에 위임
도구 결과가 쌓이는 곳부모의 messages자식의 messages
부모에게 남는 것도구 결과 전부보고서 한 덩어리
자식의 messages없음자식이 끝나면 버려짐

조사 과정에서 도구 결과 300 토큰이 오갔더라도 부모가 받는 것은 “20260803-0447은 배송중, 도서산간으로 하루 지연” 한 줄입니다. 이 컨텍스트 격리가 위임의 진짜 이득이고, 흔히 말하는 “역할 분담”은 부수적입니다.

delegate 도구 만들기

3편의 run_agent를 그대로 재사용합니다. 다만 부모와 자식이 서로 다른 도구 목록을 가져야 하므로 loop.py의 시그니처에 tools: list[dict] = TOOL_SPECS를 더하고 client.messages.createtools=TOOL_SPECStools=tools로 바꿉니다. 기본값이 있으니 4편과 5편의 코드는 그대로 돌아갑니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# delegate.py
import threading

from agent import TOOL_FUNCS          # 2편
from loop import run_agent            # 3편
from tools import TOOL_SPECS          # 0편

MAX_DELEGATIONS = 5      # 한 요청에서 허용하는 위임 횟수
SUB_MAX_STEPS = 8        # 자식 하나의 최대 단계

SUB_SYSTEM = (
    "당신은 조사 담당자입니다. 주어진 지시 하나만 처리하고 보고합니다. "
    "범위를 넓히지 말고, 되묻지 말고, 확인한 사실만 다섯 줄 이내로 적습니다. "
    "확인 못한 항목은 추측하지 말고 확인 실패로 적습니다."
)

_used = 0
_lock = threading.Lock()

def delegate(instruction: str) -> dict:
    """지시 하나를 서브에이전트에 맡기고 최종 보고만 돌려줍니다."""
    global _used
    with _lock:
        if _used >= MAX_DELEGATIONS:
            return {"error": f"위임 한도 {MAX_DELEGATIONS}회를 모두 썼습니다. "
                             "남은 조사는 도구를 직접 불러 처리하세요."}
        _used += 1

    # TOOL_SPECS에는 delegate가 없으므로 자식은 위임할 수 없습니다.
    return {"report": run_agent(
        instruction, system=SUB_SYSTEM,
        max_steps=SUB_MAX_STEPS, tools=TOOL_SPECS,
    )}

DELEGATE_SPEC = {
    "name": "delegate",
    "description": (
        "독립적인 조사 작업 하나를 담당자에게 맡기고 보고서만 받습니다. "
        "대상이 여러 건이고 서로 영향을 주지 않으며, 각 건마다 도구를 두 번 "
        "이상 불러야 할 때만 씁니다. 담당자는 이 대화를 볼 수 없습니다."
    ),
    "input_schema": {
        "type": "object",
        "properties": {"instruction": {"type": "string"}},
        "required": ["instruction"],
    },
}

PARENT_TOOL_SPECS = TOOL_SPECS + [DELEGATE_SPEC]
TOOL_FUNCS["delegate"] = delegate     # run_tool이 이름으로 찾아 씁니다

부모는 run_agent(question, tools=PARENT_TOOL_SPECS)로 돌리고 자식은 TOOL_SPECS만 받습니다. 자식의 목록에 delegate가 없으니 손자 에이전트는 생길 수 없습니다. 재귀 위임은 비용이 기하급수로 늘고 추적도 불가능해집니다. 반환한 dict에 error 키가 있으면 3편의 루프가 is_error를 붙여 돌려주니, 한도를 넘긴 사실도 모델에게 전달됩니다.

병렬로 돌리기

Claude API는 병렬 도구 호출이 기본으로 켜져 있어서 부모가 한 응답에 delegate 블록을 다섯 개 담아 보낼 수 있습니다. 그런데 3편의 루프는 블록을 for 문으로 하나씩 실행하므로 다섯 건이 직렬로 돌면 100초가 걸립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# delegate.py
import json
from concurrent.futures import ThreadPoolExecutor

from agent import run_tool

MAX_WORKERS = 5

def _run_block(block) -> dict:      # 3편 루프의 도구 실행 부분
    try:
        output = run_tool(block.name, block.input)
        is_error = "error" in output
    except Exception as exc:
        output, is_error = {"error": str(exc)}, True
    return {"type": "tool_result", "tool_use_id": block.id, "is_error": is_error,
            "content": json.dumps(output, ensure_ascii=False)}

def run_blocks(blocks: list) -> list[dict]:
    """한 응답에 담긴 도구 호출을 동시에 실행합니다."""
    if len(blocks) == 1:
        return [_run_block(blocks[0])]
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
        return list(pool.map(_run_block, blocks))

3편 루프의 결과 수집 부분을 run_blocks(...) 호출로 갈아끼우면 됩니다. anthropic 클라이언트는 스레드 안전하므로 자식들이 같은 client를 공유해도 됩니다. 주의할 것은 MAX_WORKERS입니다. 자식 하나가 API 호출을 여러 번 하므로 다섯을 동시에 돌리면 순간 요청량이 다섯 배가 되어 레이트 리밋에 걸리기 쉽습니다.

1
2
3
[부모] delegate x5  ->  delegate x5
[부모] error: 위임 한도 5회를 모두 썼습니다
[부모] get_order x2  (직접 조사로 전환)

두 번째 묶음에서 한도에 걸리자 모델은 오류를 읽고 남은 조사를 직접 하는 쪽으로 방향을 틀었습니다. 상한이 없었다면 50건을 전부 위임했을 것입니다.

위임이 대부분 손해인 이유

여기까지 보면 위임이 만능 같지만 실제로는 반대입니다. 위임은 대부분의 경우 손해이고, 이득이 되는 조건은 좁습니다.

서브에이전트 한 번은 시스템 프롬프트와 도구 정의를 처음부터 다시 싣고(같은 내용이지만 다른 대화이므로 공유되지 않습니다), 무엇을 할지 다시 판단하고, 도구를 부르고, 결과를 문장으로 바꿔 보고서를 쓰고, 부모가 그것을 읽습니다. 실질적인 일은 도구를 부르는 한 단계뿐입니다. 도구를 한 번 부르고 끝나는 작업에 이 과정을 붙이면 이렇게 됩니다.

 부모가 직접위임
API 호출 횟수25
입력 토큰약 1,900약 5,400
출력 토큰약 150약 400
지연왕복 2회왕복 5회, 자식이 끝날 때까지 부모 대기
부모 컨텍스트 증가210 토큰200 토큰

호출은 두 배 넘게, 토큰은 세 배 가까이 늘었는데 부모의 컨텍스트가 줄어든 양은 10 토큰입니다. 위임의 이득은 감춘 중간 산출물의 크기에 비례하는데, 감출 것이 없으면 오버헤드만 남습니다.

자주 보이는 낭비가 검증용 서브에이전트입니다. “답을 낸 뒤 다른 에이전트에게 검토를 맡기자”는 발상인데, 대개 이렇게 무너집니다.

  • 자식은 부모의 대화를 보지 못합니다. 무엇을 검증할지 알려면 근거를 전부 지시에 옮겨 적어야 하고, 그러면 부모 컨텍스트만큼의 토큰을 다시 쓴 셈입니다.
  • 근거를 옮기지 않고 자식이 다시 조회하게 하면 같은 도구로 같은 데이터를 봅니다. 같은 입력에 같은 모델이면 대체로 같은 결론이 나옵니다. 독립적인 검증이 아닙니다.
  • 부모가 스스로 검증할 수 있는 경우가 많습니다. 조회 결과와 최종 답이 한 컨텍스트에 있으니 대조하면 됩니다.

검증이 필요하다면 8편의 자동 채점이나 5편의 승인 게이트처럼 결정적인 장치가 낫습니다. 모델에게 모델을 검사시키는 구조는 비용만 확실합니다.

위임 개수에 상한을 코드로 걸어야 하는 이유도 여기에 있습니다. 모델은 위임의 비용을 모릅니다. delegate가 도구 목록에 있으면 그것을 쓰는 것이 자기 일을 줄이는 길이므로, 설명에 쓰지 말라고 적어도 자주 어깁니다. 프롬프트로 부탁하는 것보다 코드로 막는 쪽이 확실합니다. 상한은 최악의 경우로 정합니다. SUB_MAX_STEPS가 8이고 MAX_DELEGATIONS가 5면 자식 쪽 호출만 최대 40번입니다.

위임이 이득인 조건 세 가지

세 조건을 모두 만족할 때만 위임이 남는 장사입니다. 하나라도 빠지면 부모가 직접 하는 쪽이 싸고 빠릅니다.

1. 진짜로 독립적인 작업입니다. 주문 A의 조사 결과가 주문 B의 조사 방식에 영향을 주지 않아야 합니다. 자식들은 서로의 컨텍스트를 볼 수 없으니 앞의 결과를 보고 다음을 정해야 하는 작업은 나눌 수 없습니다. “지연 사유를 분류한 다음 가장 많은 사유의 주문만 다시 본다”는 순차입니다.

2. 각 작업이 자체로 도구 호출을 여러 번 필요로 합니다. 자식이 도구를 한 번만 부른다면 부모가 직접 부르는 것과 결과가 같습니다. 앞 절의 계산이 그 경우이고, 최소 서너 번은 불러야 격리해서 얻는 것이 오버헤드를 넘습니다.

3. 중간 산출물이 부모 컨텍스트에 들어갈 필요가 없습니다. 가장 자주 어긋나는 조건입니다. 보고서에 원본 데이터를 인용해야 한다면 결국 자식이 본 것을 부모도 봐야 하고, 그럴 바에는 처음부터 부모가 조회하는 편이 낫습니다.

주문 50건 조사는 세 조건을 다 통과합니다. “이수진 고객이 이어폰 환불하고 싶대”는 하나도 통과하지 못합니다.

서브에이전트에게 지시를 주는 법

서브에이전트는 부모의 대화를 전혀 모릅니다. 부모가 확인한 사실도, 대화 중에 만든 축약어도, 사용자가 처음에 무엇을 요청했는지도 모릅니다. 아는 것은 SUB_SYSTEMinstruction 문자열뿐입니다.

1
2
3
4
5
6
7
8
9
10
delegate("아까 그 두 번째 건도 같은 방식으로 확인해 줘")   # 자식은 이해하지 못합니다

delegate(
    "주문번호 20260803-0447을 조사합니다.\n"
    "1. get_order로 status, ordered_at, delivered_at을 확인합니다.\n"
    "2. ordered_at 다음 영업일부터 delivered_at까지 며칠 걸렸는지 셉니다.\n"
    "   delivered_at이 null이면 오늘까지의 경과일로 셉니다.\n"
    "3. 3영업일을 넘었으면 지연으로 판정합니다. 환불은 접수하지 마십시오.\n"
    "보고 형식: 주문번호 / 지연 여부 / 경과일 / 판단 근거 한 줄"
)

위임 프롬프트에 반드시 들어가야 하는 것은 네 가지입니다.

  • 대상 식별자를 원본 그대로. “두 번째 건”이 아니라 20260803-0447입니다. 부모가 대화 중에 붙인 번호나 별칭은 자식에게 의미가 없습니다.
  • 부모가 이미 확인한 사실 중 자식에게 필요한 것. 이름과 주문번호의 대응을 부모가 알아냈다면 적어야 합니다. 안 적으면 자식이 search_orders부터 다시 부릅니다.
  • 판정 기준. “지연인지 확인해 줘”는 자식마다 다른 기준을 씁니다. 3영업일이라는 숫자를 적어야 보고가 같은 기준으로 나옵니다.
  • 권한 경계와 보고 형식. 자식도 TOOL_SPECS를 통째로 받으므로 request_refund를 부를 수 있습니다. 조사만 시킬 생각이면 명시하거나 조회 도구만 넘깁니다.

RAG 검색기를 도구로 붙이기

조사 중에 “3영업일이 맞나”를 확인하려면 배송 정책 문서를 봐야 합니다. RAG 기초 시리즈에서 같은 쇼핑몰의 정책 문서로 만들어 둔 검색기를 도구 하나로 감쌉니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# policy_tool.py
from retrieval import search_with_rerank      # RAG 기초 6편

def search_policy(question: str) -> dict:
    """정책 문서에서 질문과 관련된 대목을 찾아옵니다."""
    hits = search_with_rerank(question, k=3)
    if not hits:
        return {"error": "관련 정책을 찾지 못했습니다."}
    return {"results": [
        {"source": h["source"], "text": h["text"][:300]} for h in hits
    ]}

SEARCH_POLICY_SPEC = {
    "name": "search_policy",
    "description": (
        "환불, 배송, 회원 등급 정책 문서를 검색합니다. 기준일, 배송비, "
        "적립률처럼 규정을 근거로 답해야 할 때 호출합니다."
    ),
    "input_schema": {
        "type": "object",
        "properties": {"question": {"type": "string"}},
        "required": ["question"],
    },
}

search_with_rerank는 RAG 6편에서 하이브리드 검색과 reranking까지 붙여 만든 함수입니다. 감싸면서 한 일은 결과에서 score를 빼고 본문을 300자로 자른 것, 비었을 때 error를 돌려준 것뿐입니다. 4편의 원칙 그대로 판단에 필요 없는 필드는 컨텍스트에 넣지 않습니다.

검색을 파이프라인에 고정하지 않고 도구로 준다는 점이 중요합니다. 일반적인 RAG는 질문이 들어오면 무조건 검색부터 해서 “20260803-0447 상태 알려줘”에도 정책 문서를 세 개 붙입니다. 도구로 주면 상태만 물을 때는 get_order만 부르고 “왜 지연됐는지”를 물을 때만 search_policy를 부릅니다. 언제 검색할지를 코드가 아니라 모델이 정하는 것이 1편에서 말한 에이전트와 워크플로의 차이입니다. 대신 검색을 건너뛰고 아는 척할 위험이 생기므로 도구 설명에 호출 시점을 구체적으로 적어야 합니다.

이 편에서 도구가 두 개 늘어 다섯 개가 됐는데, tools.py에 셋, delegate.py에 하나, policy_tool.py에 하나로 흩어져 있습니다. 각각 함수와 스펙을 따로 정의하고 TOOL_FUNCS에 손으로 등록하고, 부모용과 자식용 목록을 따로 조립합니다. 도구 하나를 고치려면 에이전트를 다시 배포해야 하고, 다른 에이전트가 쓰려면 코드를 복사해야 합니다. 스무 개가 되면 도구가 에이전트 코드 안에 박혀 있는 구조 자체가 걸림돌이 됩니다. 다음 편에서 도구를 에이전트 밖으로 꺼냅니다.

다음 글: LLM 에이전트 기초 (7) - MCP: 도구를 표준 규격으로 분리하기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.