본문 바로가기
tech epilogue

Claude Agent SDK로 플래너 + 실행자 멀티 에이전트 만들어보기

by rami_ 2026. 9. 17.

시작하며

요즘 개발자들 사이에서 멀티 에이전트가 화제라, 직접 만들어보면서 배워보기로 했다. 목표는 단순했다.

플래너 에이전트가 작업 요청을 받아서 어떤 순서로 처리할지 계획만 세우고, 계획이 서면 실행자 서브에이전트를 호출해서 계획을 넘긴다. 그러면 실행자가 실제로 파일을 만들거나 코드를 작성하는 방식이다.

@anthropic-ai/claude-agent-sdk (TypeScript)로 구현했고, 처음부터 끝까지 직접 코드를 짜면서 부딪힌 문제들을 그대로 기록했다. 완성된 코드만 던져놓기보다는 어디서 왜 막혔는지, 그걸 어떻게 확인했는지를 남기는 게 더 값진 것 같아서 시행착오를 그대로 살렸다.

 

1단계. query() 한 번 호출해보기

가장 기본적인 형태부터 시작했다.

 
import { query } from "@anthropic-ai/claude-agent-sdk";

async function main() {
  const result = query({
    prompt: "지금 몇 번째 메시지를 보내고 있는지 말해줘",
    options: {
      cwd: process.cwd(),
    },
  });

  for await (const message of result) {
    console.log(JSON.stringify(message, null, 2));
  }
}

main();
 

query()는 await 한 번으로 끝나는 함수가 아니라 메시지를 하나씩 스트리밍해주는 async generator를 즉시 반환한다. 그래서 for await (const msg of query(...)) 패턴을 써야 한다.

npx tsx src/index.ts로 실행했더니 이런 결과가 나왔다.

 
{
  "modelUsage": {
    "claude-sonnet-5": {
      "inputTokens": 2,
      "outputTokens": 36,
      "cacheReadInputTokens": 0,
      "cacheCreationInputTokens": 19216,
      "costUSD": 0.077228,
      ...
    }
  }
}
 

내가 보낸 프롬프트는 2토큰밖에 안 되는데 거의 2만 토큰짜리 시스템 프롬프트가 캐시에 새로 생성됐다(cacheCreationInputTokens: 19216). 비용은 $0.077.

이유는 간단했다. options에 systemPrompt도 tools도 안 정했더니, SDK가 Claude Code의 기본 시스템 프롬프트와 기본 툴 전체를 통째로 로드한 거였다.

options를 이렇게 바꿔서 다시 실행했다.

 
options: {
  cwd: process.cwd(),
  systemPrompt: "당신은 친절한 어시스턴트입니다.",
  tools: [],
}
 

결과는 이렇다.

 
{
  "modelUsage": {
    "claude-sonnet-5": {
      "inputTokens": 500,
      "cacheCreationInputTokens": 0,
      "costUSD": 0.00265,
      ...
    }
  },
  "result": "죄송하지만, 저는 이 대화에서 몇 번째 메시지를 보내고 있는지 알 수 있는 정보를 가지고 있지 않습니다. 대화 세션 내에서 메시지 순번을 추적하는 기능이 없기 때문입니다."
}
 

cacheCreationInputTokens는 0으로, 총비용은 $0.0036 수준으로 떨어졌다. 답변도 조금 달라졌다. 기본 모드에서는 Claude Code 페르소나라 대충 답했을 텐데, 커스텀 systemPrompt를 주니까 "저는 그런 기능이 없습니다"라고 솔직하게 답했다. 시스템 프롬프트를 커스텀으로 바꾸면 Claude가 그 지시를 훨씬 곧이곧대로 따른다는 걸 눈으로 확인한 셈이다.

 

2단계. 커스텀 툴 만들기, 좋은 툴 설계란

 

시간을 알려주는 MCP 툴을 만들어봤다.

 
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";

const getTimeTool = tool(
  'getTime',
  '시간을 반환하는 mcp이다.',
  {},
  async () => {
    const date = new Date().getTime();
    return {
      content: [{ type: "text", text: date.toString() }],
    };
  },
);

const myServer = createSdkMcpServer({
  name: 'server',
  tools: [getTimeTool],
});

async function main() {
  for await (const message of query({
    prompt: "지금 몇 시야?",
    options: {
      cwd: process.cwd(),
      systemPrompt: "당신은 친절한 어시스턴트입니다.",
      mcpServers: { server: myServer },
      tools: ['mcp__server__getTime'],
      allowedTools: ['mcp__server__getTime'],
    },
  })) {
    console.log(JSON.stringify(message, null, 2));
  }
}

main();
 

date.getTime()으로 밀리초(epoch time)를 그대로 반환했다. 실행하니 tool_result엔 이렇게 찍혔다.

{ "content": [{ "type": "text", "text": "1789547871725" }] }
 

근데 최종 답변은 "현재 시각은 2026년 9월 16일 오후 5시 37분 51초 (KST) 입니다."로 왔다. Claude가 밀리초 숫자를 받고 직접 계산해서 사람이 읽을 수 있는 날짜로 변환해낸 거다. 근데 그 대가로 thinking_tokens: 815, duration_ms: 10793, 거의 11초가 걸렸다.

좋은 툴 설계는 모델이 계산을 대신 하게 만들지 않고, 이미 사람이 읽을 수 있는 형태로 가공해서 돌려주는 것이다. 그래서 툴을 이렇게 고쳤다.

 
const getTimeTool = tool(
  'getTime',
  '시간을 반환하는 mcp이다.',
  {},
  async () => {
    const hour = new Date().getHours();
    const minutes = new Date().getMinutes();
    return {
      content: [{ type: "text", text: `${hour}:${minutes}` }],
    };
  },
);
 

결과는 thinking_tokens: 0, duration_ms: 2464로 약 4배 빨라졌다. 답변도 "지금은 17:48입니다."처럼 짧고 정확하게 왔다.

전에는 날짜, 요일, 시간대까지 다 지어내듯 상세하게 답했는데 이번엔 툴이 준 그대로만 답한다. 이게 왜 중요하냐면, 툴 결과가 상세할수록 모델이 받은 그대로 답하고 툴 결과가 부족하면 모델이 나머지를 자기 나름대로 채워 넣기 때문이다. 툴 설계는 결국 모델의 답변 범위를 설계하는 것과 같다.

 

 

3단계. 서브에이전트 정의하기

options.agents에 서브에이전트를 정의하면 메인 스레드가 필요할 때 내장 툴을 통해 그 서브에이전트를 호출할 수 있다. 서브에이전트는 독립된 대화 컨텍스트를 가진 미니 에이전트다.

 
import { query, tool, createSdkMcpServer, type AgentDefinition } from "@anthropic-ai/claude-agent-sdk";

const getTimeTool = tool(
  'getTime',
  '시간을 반환하는 mcp이다.',
  {},
  async () => {
    const hour = new Date().getHours();
    const minutes = new Date().getMinutes();
    return {
      content: [{ type: "text", text: `${hour}:${minutes}` }],
    };
  },
);

const myServer = createSdkMcpServer({
  name: 'server',
  tools: [getTimeTool],
});

const timeTeller: AgentDefinition = {
  description: 'timeTellerAgent',
  prompt: '너는 시간 조회 전담 에이전트다. get_time 도구를 써서 답해라',
  tools: ['mcp__server__getTime'],
};

async function main() {
  for await (const message of query({
    prompt: "getTime 도구를 네가 직접 쓰지 말고, 반드시 timeTeller 서브에이전트한테 위임해서 시간을 확인해줘",
    options: {
      cwd: process.cwd(),
      systemPrompt: '',
      mcpServers: { server: myServer },
      tools: ['Task'],
      agents: { timeTeller },
      allowedTools: ['mcp__server__getTime', 'Task'],
    },
  })) {
    console.log(JSON.stringify(message, null, 2));
  }
}

main();
 

실행되는 순서를 로그로 뜯어보면 이렇다.

  1. 메인 스레드: tool_use.name: "Agent", input.subagent_type: "timeTeller"
  2. task_started 이벤트 (subagent_type: "timeTeller")
  3. 서브에이전트 내부: 이후 메시지들의 parent_tool_use_id가 방금 호출한 Agent 호출 ID로 채워짐
  4. task_updated(completed), 메인 스레드로 결과 반환
  5. 메인 스레드가 그걸 받아서 "timeTeller 서브에이전트에게 위임해서 확인한 결과, 현재 시간은 18:17입니다."라고 답함

여기서 재밌는 걸 하나 발견했다. 문서나 타입 주석엔 서브에이전트가 "Task tool"을 통해 호출된다고 적혀 있었는데, 실제로 로그에 찍힌 tool_use.name은 "Agent"였다. 문서만 믿었으면 놓쳤을 부분이다.

tools, allowedTools, disallowedTools를 갖고 여러 번 실험해보면서 결론이 하나 나왔다. Claude Agent SDK에서 "메인 스레드는 이 도구를 못 쓰게, 서브에이전트만 쓰게" 같은 세밀한 격리는 이 옵션들만으로는 깔끔하게 안 된다. 이 옵션들은 전부 세션 전역으로 적용되기 때문이다. 실전에서 위임을 유도하려면 결국 프롬프트로 지시하는 것(systemPrompt와 명확한 subagent description)이 현실적인 방법이고, 완벽하게 강제하고 싶다면 canUseTool 콜백으로 어느 에이전트가 호출했는지 직접 판별하는 커스텀 로직이 필요하다.

 

4단계. 플래너 + 실행자 완성하기

 

timeTeller를 확장해서 계획을 세우고 위임하는 흐름을 완성했다.

const executor: AgentDefinition = {
  description: '파일을 읽고 쓰는 실행 담당 에이전트. 시간 조회와 파일 생성/수정이 필요한 작업에 사용.',
  prompt: '시간 조회와 파일 쓰기를 모두 수행하는 실행 에이전트다. mcp__server__getTime으로 시간을 확인하고, Write로 파일을 작성해라.',
  tools: ['mcp__server__getTime', 'Write'],
  disallowedTools: ['Task'], // 이 서브에이전트는 또 다른 서브에이전트를 부를 수 없게
};

async function main() {
  for await (const message of query({
    prompt: "workspace/note.txt 파일을 만들고, 그 안에 지금 시각을 적어줘",
    options: {
      cwd: process.cwd(),
      systemPrompt: '',
      mcpServers: { server: myServer },
      tools: ['Task', 'Write'],
      allowedTools: ['mcp__server__getTime', 'Task', 'Write'],
      agents: { executor },
      maxTurns: 8,
    },
  })) {
    console.log(JSON.stringify(message, null, 2));
  }
}

main();

 

이번엔 완벽하게 성공했다.

  1. 메인 스레드가 Agent 호출로 executor한테 위임
  2. executor 안에서 mcp__server__getTime 호출
  3. 같은 executor 안에서 Write 호출
  4. 메인 스레드가 결과를 받아서 사용자에게 요약 보고
  5. spawned: 1, max_depth: 1로 깔끔하게 끝남

여기까지 오면서 확정된 교훈들을 정리하면 이렇다.

description은 위임 대상 선택 자체를 좌우한다.

Claude는 서브에이전트의 코드를 보는 게 아니라 description 문구만 보고 이 작업엔 누굴 써야 하나를 판단한다.

서브에이전트의 재귀 위임은 안전장치 없이는 위험하다. 실패했을 때 스스로 다시 위임을 시도하는 구조라, 통제가 없으면 비용이 기하급수적으로 늘 수 있다. 그래서 disallowedTools: ['Task']나 maxTurns로 상한을 걸어야 한다. (이 시점엔 이렇게 믿었는데, 아래 5단계에서 이 믿음이 그대로 깨진다.)

빌트인 툴과 MCP 커스텀 툴은 상속 규칙이 다르다. 빌트인 툴은 부모가 허용한 것의 부분집합만 서브에이전트가 쓸 수 있고, MCP 툴은 서버만 연결돼 있으면 부모의 tools 제한과 무관하게 항상 열려있다.

문서나 타입 주석과 실제 런타임이 미묘하게 다를 수 있다. "Task tool"이라 불리던 게 실제 wire name은 "Agent"였던 것처럼, AgentDefinition.tools(서브에이전트용, 허용 목록)와 최상위 Options.tools(메인용, 빌트인 기본 세트)도 이름은 같은데 의미가 다르다. 이런 건 코드만 봐서는 알 수 없고 직접 로그를 까봐야 드러난다.

 

 

5단계. 재귀 방지 안전장치, 검증했더니 뚫렸다

4단계에서 확신했던 두 번째 교훈, disallowedTools: ['Task']로 재귀 위임을 막았다는 그 믿음을 직접 검증해보기로 했다. 방법은 간단했다. executor가 확실히 못 하는 일(웹 검색)을 끼워 넣어서, 안전장치가 없었으면 재위임을 시도했을 상황을 일부러 만드는 거였다.

 
prompt: "workspace/note.txt를 만들고 지금 시각을 적은 다음, 오늘 서울 날씨도 검색해서 같이 적어줘",
 

executor는 mcp__server__getTime과 Write만 갖고 있고 웹 검색 도구가 없다. 그러니 disallowedTools: ['Task']가 제대로 작동한다면 검색을 포기하고 "날씨는 확인할 수 없다"고 솔직히 보고하며 끝나야 정상이었다.

결과는 예상과 완전히 달랐다.

 
"subagent_stats": {
  "spawned": 13,
  "max_depth": 3,
  "by_type": { "general-purpose": 8, "claude": 5 }
}
 

total_cost_usd: 0.589, 소요 시간 277초. 그리고 by_type을 보면 executor가 단 한 번도 안 나온다.

원인을 뜯어보니 이랬다. "시간 적기 + 날씨 검색"이라는 작업을 본 메인 스레드가, executor는 날씨 검색을 못 한다는 걸 (설명만 보고) 눈치채고는 처음부터 executor 대신 통제 불가능한 내장 타입(general-purpose, claude)을 선택해버린 거였다. 그 내장 에이전트들은 우리가 정의한 게 아니라서 disallowedTools를 걸 방법이 없고, 실패할 때마다 스스로 다시 다른 에이전트를 호출하며 깊이 3단계까지 재귀했다.

커스텀 서브에이전트에 안전장치를 걸어도 애초에 그 서브에이전트가 선택되지 않으면 아무 의미가 없다는 걸 이렇게 비싸게 배웠다.

더 당황스러운 건 maxTurns: 8을 걸어뒀는데도 전혀 안 먹혔다는 점이다. 결과의 num_turns는 고작 2였다.

 
"num_turns": 2,
 

메인 스레드 입장에서는 "Agent 호출 1번 + 결과 받기 1번"이면 2턴이 끝이고, 그 안에서 서브에이전트들이 얼마나 재귀하든 maxTurns는 전혀 세지 않는다. maxTurns는 메인 스레드 자신의 턴만 세지, 서브에이전트 내부의 재귀 턴은 카운트하지 않는다는 뜻이다.

진짜 필요했던 건 maxBudgetUsd

턴 수가 아니라 비용(달러) 자체에 하드 캡을 거는 옵션이 따로 있었다.

 
options: {
  ...,
  maxTurns: 8,
  maxBudgetUsd: 0.05, // 5센트 넘으면 강제 종료
},
 

같은 위험한 프롬프트로 다시 테스트하니 이번엔 이렇게 나왔다.

"terminal_reason": "budget_exhausted",
"subtype": "error_max_budget_usd",
"errors": ["Reached maximum budget ($0.05)"]
 
 
 

정확히 $0.05에서 강제 종료됐다. by_type: { "executor": 1 }로 이번엔 executor가 제대로 선택됐고 재귀도 없었지만, 혹시 몰라 걸어둔 예산 상한이 실제로 작동한다는 걸 확인했다.

한 가지 더 알게 된 게 있다. maxBudgetUsd를 초과하면 정상적인 result 메시지가 오는 게 아니라 for await 루프 자체에서 예외가 던져진다. main()에 try/catch가 없으면 스크립트가 uncaught exception으로 죽는다.

 
async function main() {
  try {
    for await (const message of query({ ... })) {
      console.log(JSON.stringify(message, null, 2));
    }
  } catch (err) {
    console.error("쿼리 중단:", err instanceof Error ? err.message : err);
  }
}

 

  


 

정리

  1. systemPrompt와 tools를 안 정하면 SDK는 Claude Code 기본 프롬프트와 툴 전체를 로드한다. 간단한 호출 하나에도 2만 토큰짜리 캐시가 생길 수 있다.
  2. 툴 결과는 가공해서 돌려주는 게 좋다. 원시 데이터를 던지면 모델이 알아서 계산해주긴 하지만 느리고 비싸고 틀릴 수 있다.
  3. description은 Claude의 유일한 판단 근거다. 커스텀 툴이든 서브에이전트든 마찬가지다.
  4. 권한 옵션(tools, allowedTools, disallowedTools)은 세션 전역이라 "이 에이전트만 못 쓰게" 같은 세밀한 격리에는 한계가 있다. 빌트인 툴과 MCP 툴의 상속 규칙도 서로 다르다.
  5. 턴 수 기반 안전장치(maxTurns)는 서브에이전트 재귀를 못 막는다. 실전에서 믿을 수 있었던 건 비용 기반 안전장치(maxBudgetUsd) 뿐이었다.
  6. 문서와 실제 런타임은 다를 수 있다. 설치된 패키지의 .d.ts를 직접 열어보고 실제 로그를 까보는 습관이, SDK를 문서만 보고 배우는 것보다 훨씬 정확했다.

멀티 에이전트는 "잘 만들면 알아서 협업한다"는 인상과 달리, 실제로는 권한 경계와 위임 실패 시나리오, 비용 폭주 가능성을 하나하나 직접 부딪혀봐야 감이 잡히는 영역이었다. 특히 5단계의 실패는 처음엔 당황스러웠지만, 오히려 이게 없었으면 "안전장치를 넣었으니 안전하다"고 착각한 채 끝났을 것 같다.