서브에이전트

서브에이전트 (Subagents)

부모 에이전트가 호출할 수 있는 에이전트인 서브에이전트에 대해 설명하는 문서예요. 부모가 툴을 통해 작업을 위임하면, 서브에이전트가 자율적으로 실행한 뒤 결과를 반환해요.

출처: 문서

본문

서브에이전트(subagent)는 부모 에이전트가 호출할 수 있는 에이전트예요. 부모가 툴을 통해 작업을 위임하고, 서브에이전트는 결과를 반환하기 전에 자율적으로 실행돼요.

동작 방식 (How It Works)

  1. 서브에이전트 정의 - 자체 모델, 지시사항, 툴을 가진 서브에이전트를 정의해요
  2. 호출하는 툴 생성 - 메인 에이전트가 사용할 서브에이전트 호출 툴을 만들어요
  3. 독립 실행 - 서브에이전트가 자체 컨텍스트 윈도우로 독립적으로 실행돼요
  4. 결과 반환 - 결과를 반환해요 (UI로 진행 상황을 스트리밍할 수도 있어요)
  5. 모델 가시성 제어 - toModelOutput을 사용해 요약해서 모델이 보는 내용을 제어해요

서브에이전트 사용 시점 (When to Use Subagents)

서브에이전트는 지연(latency)과 복잡성을 추가해요. 이점이 비용보다 클 때 사용하세요:

서브에이전트 사용 시 서브에이전트 피해야 할 때
많은 토큰을 탐색해야 하는 작업 작업이 단순하고 초점이 명확할 때
독립적인 연구를 병렬화해야 할 때 순차 처리가 충분할 때
컨텍스트가 모델 한도를 초과할 때 컨텍스트가 관리 가능할 때
툴 접근을 기능별로 격리하고 싶을 때 모든 툴이 안전하게 공존할 수 있을 때

왜 서브에이전트를 사용하나요? (Why Use Subagents?)

컨텍스트가 무거운 작업 오프로딩 (Offloading Context-Heavy Tasks)

어떤 작업은 방대한 정보 탐색이 필요해요—파일 읽기, 코드베이스 검색, 주제 조사 등. 이런 작업을 메인 에이전트에서 실행하면 컨텍스트를 빠르게 소모해서 시간이 지날수록 에이전트의 일관성이 떨어져요.

서브에이전트를 사용하면 다음과 같은 일이 가능해요:

  • 수십만 개의 토큰을 사용하는 전용 에이전트를 생성하기
  • 초점 있는 요약(약 1,000 토큰)만 반환하게 하기
  • 메인 에이전트의 컨텍스트를 깔끔하고 일관성 있게 유지하기

서브에이전트가 무거운 작업을 처리하는 동안 메인 에이전트는 오케스트레이션에 집중해요.

독립 작업 병렬화 (Parallelizing Independent Work)

코드베이스 탐색 같은 작업에서는 여러 서브에이전트를 생성해 서로 다른 영역을 동시에 조사할 수 있어요. 각각 요약을 반환하고, 메인 에이전트가 결과를 종합해요—탐색에 드는 컨텍스트 비용은 지불하지 않으면서요.

전문화된 오케스트레이션 (Specialized Orchestration)

덜 흔하지만 유효한 패턴으로, 메인 에이전트를 순수 오케스트레이션에만 사용하고 서로 다른 유형의 작업을 전문화된 서브에이전트에 위임하는 방식이 있어요. 예를 들어:

  • 코드베이스를 조사하는 읽기 전용 툴을 가진 탐색 서브에이전트
  • 파일 편집 툴을 가진 코딩 서브에이전트
  • 특정 플랫폼이나 API용 툴을 가진 통합 서브에이전트

이렇게 하면 관심사가 명확하게 분리되지만, 컨텍스트 오프로딩과 병렬화가 서브에이전트 사용의 더 흔한 동기예요.

스트리밍 없는 기본 서브에이전트 (Basic Subagent Without Streaming)

가장 단순한 서브에이전트 패턴은 특별한 장치가 필요 없어요. 메인 에이전트의 툴 중 하나가 execute 함수에서 다른 에이전트를 호출하면 돼요:

import { ToolLoopAgent, tool } from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';

// Define a subagent for research tasks
const researchSubagent = new ToolLoopAgent({
  model: __MODEL__,
  instructions: `You are a research agent.
Summarize your findings in your final response.`,
  tools: {
    read: readFileTool, // defined elsewhere
    search: searchTool, // defined elsewhere
  },
});

// Create a tool that delegates to the subagent
const researchTool = tool({
  description: 'Research a topic or question in depth.',
  inputSchema: z.object({
    task: z.string().describe('The research task to complete'),
  }),
  execute: async ({ task }, { abortSignal }) => {
    const result = await researchSubagent.generate({
      prompt: task,
      abortSignal,
    });
    return result.text;
  },
});

// Main agent uses the research tool
const mainAgent = new ToolLoopAgent({
  model: __MODEL__,
  instructions: 'You are a helpful assistant that can delegate research tasks.',
  tools: {
    research: researchTool,
  },
});

이 방식은 서브에이전트의 진행 상황을 UI에 보여줄 필요가 없을 때 잘 동작해요. 툴 호출은 서브에이전트가 완료될 때까지 블록되고, 그 후 최종 텍스트 응답을 반환해요.

취소 처리 (Handling Cancellation)

사용자가 요청을 취소하면 abortSignal이 서브에이전트에 전파돼요. 정리를 위해 항상 전달하세요:

execute: async ({ task }, { abortSignal }) => {
  const result = await researchSubagent.generate({
    prompt: task,
    abortSignal, // Cancels subagent if main request is aborted
  });
  return result.text;
},

신호를 중단하면 서브에이전트가 실행을 멈추고 AbortError를 던져요. 메인 에이전트의 툴 실행이 실패하고, 이는 메인 루프를 멈추게 해요.

이후 메시지에서 불완전한 툴 호출에 대한 오류를 피하려면 convertToModelMessages와 함께 ignoreIncompleteToolCalls를 사용하세요:

import { convertToModelMessages } from 'ai';

const modelMessages = await convertToModelMessages(messages, {
  ignoreIncompleteToolCalls: true,
});

이렇게 하면 대응하는 결과가 없는 툴 호출이 필터링돼요. 자세한 내용은 convertToModelMessages 문서를 참고하세요.

서브에이전트 진행 상황 스트리밍 (Streaming Subagent Progress)

서브에이전트가 작업하는 동안 점진적인 진행 상황을 보여주고 싶다면 예비 툴 결과 (preliminary tool results)를 사용하세요. 이 패턴은 UI에 부분 업데이트를 내보내는 제너레이터 함수를 사용해요.

예비 툴 결과 동작 방식 (How Preliminary Tool Results Work)

execute 함수를 일반 함수에서 비동기 제너레이터(async function*)로 바꾸세요. 각 yield는 예비 결과를 프론트엔드로 보내요:

execute: async function* ({ /* input */ }) {
  // ... do work ...
  yield partialResult;
  // ... do more work ...
  yield updatedResult;
}

완전한 메시지 구성 (Building the Complete Message)

각 yield는 이전 출력을 완전히 대체해요 (추가하지 않아요). 즉 서브에이전트의 응답을 시간이 지나면서 커지는 완전한 메시지로 누적하는 방법이 필요해요.

readUIMessageStream 유틸리티가 이를 처리해요. 스트림에서 각 chunk를 읽어 지금까지 받은 모든 부분을 포함하는 점점 커지는 UIMessage를 만들어요:

import { readUIMessageStream, toUIMessageStream, tool } from 'ai';
import { z } from 'zod';

const researchTool = tool({
  description: 'Research a topic or question in depth.',
  inputSchema: z.object({
    task: z.string().describe('The research task to complete'),
  }),
  execute: async function* ({ task }, { abortSignal }) {
    // Start the subagent with streaming
    const result = await researchSubagent.stream({
      prompt: task,
      abortSignal,
    });

    // Each iteration yields a complete, accumulated UIMessage
    for await (const message of readUIMessageStream({
      stream: toUIMessageStream({ stream: result.stream }),
    })) {
      yield message;
    }
  },
});

각 yield된 message는 그 시점까지의 서브에이전트의 모든 부분(텍스트, 툴 호출, 툴 결과)을 포함한 완전한 UIMessage예요. 프론트엔드는 단순히 각 새 메시지로 자기 화면을 교체하면 돼요.

모델이 보는 내용 제어 (Controlling What the Model Sees)

여기서 서브에이전트가 컨텍스트 관리에 강력해져요. 서브에이전트의 모든 작업이 담긴 전체 UIMessage는 메시지 기록에 저장되고 UI에 표시돼요. 하지만 toModelOutput을 사용해 메인 에이전트의 모델이 실제로 보는 내용을 제어할 수 있어요.

동작 방식 (How It Works)

toModelOutput 함수는 툴의 출력을 모델에 보내는 토큰으로 매핑해요:

const researchTool = tool({
  description: 'Research a topic or question in depth.',
  inputSchema: z.object({
    task: z.string().describe('The research task to complete'),
  }),
  execute: async function* ({ task }, { abortSignal }) {
    const result = await researchSubagent.stream({
      prompt: task,
      abortSignal,
    });

    for await (const message of readUIMessageStream({
      stream: toUIMessageStream({ stream: result.stream }),
    })) {
      yield message;
    }
  },
  toModelOutput: ({ output: message }) => {
    // Extract just the final text as a summary
    const lastTextPart = message?.parts.findLast(p => p.type === 'text');
    return {
      type: 'text',
      value: lastTextPart?.text ?? 'Task completed.',
    };
  },
});

이 설정으로:

  • 사용자가 보는 것: 전체 서브에이전트 실행—모든 툴 호출, 모든 중간 단계
  • 모델이 보는 것: 최종 요약 텍스트만

서브에이전트가 탐색하고 추론하는 데 100,000개의 토큰을 사용할 수 있지만, 메인 에이전트는 요약만 소비해요. 이렇게 하면 메인 에이전트가 일관성 있고 초점을 유지할 수 있어요.

요약을 위한 서브에이전트 지시사항 작성 (Write Subagent Instructions for Summarization)

toModelOutput이 유용한 요약을 추출하려면 서브에이전트가 요약을 만들어야 해요. 다음과 같은 명시적인 지시사항을 추가하세요:

const researchSubagent = new ToolLoopAgent({
  model: __MODEL__,
  instructions: `You are a research agent. Complete the task autonomously.

IMPORTANT: When you have finished, write a clear summary of your findings as your final response.
This summary will be returned to the main agent, so include all relevant information.`,
  tools: {
    read: readFileTool,
    search: searchTool,
  },
});

이 지시사항이 없으면 서브에이전트가 포괄적인 요약을 만들지 못할 수 있어요. 그냥 "완료"라고만 말해서, toModelOutput이 추출할 유용한 내용이 없게 될 수 있어요.

UI에서 서브에이전트 렌더링 (with useChat) (Rendering Subagents in the UI)

스트리밍 진행 상황을 표시하려면 툴 부분의 state와 preliminary 플래그를 확인하세요.

툴 부분 상태 (Tool Part States)

상태 설명
input-streaming 툴 입력이 생성 중
input-available 툴이 실행할 준비가 됨
output-available 툴이 출력을 생성함 (preliminary 확인)
output-error 툴 실행 실패

스트리밍 vs 완료 감지 (Detecting Streaming vs Complete)

const hasOutput = part.state === 'output-available';
const isStreaming = hasOutput && part.preliminary === true;
const isComplete = hasOutput && !part.preliminary;

서브에이전트 출력의 타입 안전성 (Type Safety for Subagent Output)

UI 컴포넌트에 사용할 타입을 에이전트와 함께 내보내세요:

import { ToolLoopAgent, InferAgentUIMessage } from 'ai';

export const mainAgent = new ToolLoopAgent({
  // ... configuration with researchTool
});

// Export the main agent message type for the chat UI
export type MainAgentMessage = InferAgentUIMessage<typeof mainAgent>;

메시지와 서브에이전트 출력 렌더링 (Render Messages and Subagent Output)

이 예제는 위에서 정의한 타입을 사용해 메인 에이전트의 메시지와 서브에이전트의 스트리밍 출력을 모두 렌더링해요:

'use client';

import { useChat } from '@ai-sdk/react';
import type { MainAgentMessage } from '@/lib/agents';

export function Chat() {
  const { messages } = useChat<MainAgentMessage>();

  return (
    <div>
      {messages.map(message =>
        message.parts.map((part, i) => {
          switch (part.type) {
            case 'text':
              return <p key={i}>{part.text}</p>;
            case 'tool-research':
              return (
                <div>
                  {part.state !== 'input-streaming' && (
                    <div>Research: {part.input.task}</div>
                  )}
                  {part.state === 'output-available' && (
                    <div>
                      {part.output.parts.map((nestedPart, i) => {
                        switch (nestedPart.type) {
                          case 'text':
                            return <p key={i}>{nestedPart.text}</p>;
                          default:
                            return null;
                        }
                      })}
                    </div>
                  )}
                </div>
              );
            default:
              return null;
          }
        }),
      )}
    </div>
  );
}

주의사항 (Caveats)

서브에이전트의 툴 승인 없음 (No Tool Approvals in Subagents)

서브에이전트 툴은 toolApproval(또는 더 이상 사용되지 않는 needsApproval) 같은 승인 흐름을 사용할 수 없어요. 모든 툴은 사용자 확인 없이 자동으로 실행되어야 해요.

서브에이전트 컨텍스트 격리 (Subagent Context is Isolated)

각 서브에이전트 호출은 깨끗한 컨텍스트 윈도우로 시작해요. 이것이 서브에이전트의 핵심 이점 중 하나예요: 메인 에이전트의 누적된 컨텍스트를 상속하지 않아서, 메인 대화를 부풀리지 않고 무거운 탐색을 할 수 있어요.

서브에이전트에 대화 기록에 대한 접근을 주고 싶다면, 툴의 execute 함수에서 abortSignal과 함께 messages를 사용할 수 있어요:

execute: async ({ task }, { abortSignal, messages }) => {
  const result = await researchSubagent.generate({
    messages: [
      ...messages, // The main agent's conversation history
      { role: 'user', content: task }, // The specific task for this invocation
    ],
    abortSignal,
  });
  return result.text;
},

전체 기록을 전달하면 컨텍스트 격리의 이점 중 일부가 사라지므로 드물게 사용하세요.

스트리밍은 복잡성을 추가 (Streaming Adds Complexity)

기본 패턴(스트리밍 없음)이 구현하고 디버깅하기 더 단순해요. UI에 실시간 진행 상황을 보여줄 필요가 있을 때만 스트리밍을 추가하세요.

더 알아보기 (Learn more)