
TypeScript에서의 서브 에이전트 (Sub-Agents): 언제 생성하고 언제 인라인 (Inline) 처리할 것인가
요약
본 글은 LLM 기반 에이전트 설계 시 서브 에이전트와 도구 호출(Tool call)의 적절한 사용처를 비교 분석합니다. 서브 에이전트는 독립적인 컨텍스트 윈도우를 제공하여 중간 결과물이 많거나, 여러 관점에서의 격리된 작업이 필요할 때 유용하며, 결정론적이고 작은 결과물은 도구 호출로 처리하는 것이 효율적입니다.
핵심 포인트
- 서브 에이전트는 독립적인 컨텍스트 윈도우를 제공하여 부모의 컨텍스트 오염을 방지합니다.
- 중간 텍스트가 많거나, 여러 관점에서의 격리된 작업(검증자 등)에 서브 에이전트가 유리합니다.
- 파싱, 포맷팅, API 호출처럼 결정론적이고 작은 결과물은 도구 호출로 처리하는 것이 효율적입니다.
- 서브 에이전트는 '격리' 자체가 핵심 기능이며, 이는 단순한 도구 호출 이상의 가치를 제공합니다.
- 도서: AI That Plans
- 시리즈: AI in TypeScript — 첫 LLM 호출부터 프로덕션 환경의 에이전트까지 다루는 5권의 도서 — 다섯 권 모두 여기에서 확인
- 내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하는 개발자를 위한 IDE
- 나: xgabriel.com | GitHub
에이전트가 작동하기 시작하면, 그다음 단계는 당연히 더 많은 에이전트를 도입하는 것입니다. 연구자, 작가, 검토자 등 각각 자신만의 프롬프트 (Prompt)와 도구 (Tools)를 가진 에이전트들 말이죠.
때로는 그것이 옳은 선택일 수 있습니다. 하지만 많은 경우, 그것은 단지 코스튬을 입은 도구 호출 (Tool call)에 불과합니다. 즉, 하나의 함수를 별도의 턴 (Turn), 별도의 예산 (Budget), 그리고 길을 잃을 가능성을 가진 별도의 모델 루프 (Model loop)로 만들어 버린 것입니다.
질문해야 할 핵심은 명확합니다: 서브 에이전트 (Sub-agent)가 도구 (Tool)가 제공하지 못하는 무엇을 제공하는가?
실제로 얻는 것: 별도의 컨텍스트 윈도우 (Context window)
그것이 전부입니다. 서브 에이전트는 자신만의 메시지 기록 (Message history)을 가집니다. 따라서:
- 많은 중간 텍스트를 생성하는 작업이 부모 (Parent)의 컨텍스트를 오염시키지 않습니다.
- 부모는 전체 기록 (Transcript)이 아닌 **요약 (Summary)**을 받습니다.
- 서브 에이전트는 집중된 시스템 프롬프트 (System prompt)와 작은 도구 목록을 가집니다.
반대로, 도구 호출 (Tool call)은 전체 결과물을 부모의 컨텍스트에 집어넣으며, 이는 이후의 모든 턴마다 다시 전송됩니다.
이러한 프레임워크는 활용 가능한 규칙을 제공합니다. 만약 작업이 최종 답변보다 훨씬 더 많은 중간 결과물을 생성한다면, 격리 (Isolation)하는 것이 이득입니다. 만약 결과가 작고 작업이 결정론적 (Deterministic)이라면, 그것은 도구 (Tool)입니다.
격리가 이득인 세 가지 사례
검색 및 추출 (Search-and-distil). 12개의 문서를 읽고 한 단락을 반환하는 경우입니다. 인라인 (Inline) 방식으로 처리하면, 그 12개의 문서는 실행이 끝날 때까지 부모의 윈도우 (Window)에 남아 있으며, 매 턴마다 그 비용을 지불해야 합니다.
독립적인 병렬 작업 (Independent parallel work). 서로의 중간 상태 (Intermediate state)를 알 필요가 없는 세 가지 조사 작업. 분리된 컨텍스트 (Context)를 통해 혼란스러운 하나의 히스토리로 뒤섞이지 않고 병렬로 실행할 수 있습니다.
진정으로 다른 관점 (A genuinely different posture). 작성자의 추론을 보아서는 안 되는 비평가, 혹은 주장 (Claim)과 출처 (Source)만을 의도적으로 전달받는 검증자. 격리 (Isolation) 그 자체가 핵심 기능입니다. 이것이 바로 두 번째 의견을 독립적으로 만드는 요소입니다.
서브 에이전트를 사용하지 않는 네 가지 사례
결정론적 작업 (Deterministic work). 파싱 (Parsing), 포맷팅 (Formatting), 유효성 검사 (Validating), 알려진 파라미터로 API 호출하기. 이것은 하나의 함수입니다. 중간에 모델을 넣는 것은 아무런 이득 없이 지연 시간 (Latency), 비용, 그리고 변동성 (Variance)만 추가할 뿐입니다.
래퍼 프롬프트 (Wrapper prompt)를 동반한 단일 도구 호출. 만약 서브 에이전트의 역할이 "get_order를 호출하고 상태를 알려줘"라면, 당신은 값비싼 별칭 (Alias)을 만든 것에 불과합니다.
부모의 전체 컨텍스트가 필요한 작업. 대화 내용의 대부분을 서브 에이전트에게 전달하고 있다면, 격리는 일어나지 않고 있으며 동일한 토큰에 대해 비용을 두 번 지불하고 있는 것입니다.
지연 시간 경로 (Latency path)에 있는 모든 것. 서브 에이전트는 하나의 완전한 루프입니다. 즉, 여러 번의 모델 호출이 순차적으로 일어납니다. 몇 초가 걸리는 대화형 기능에서는 이 지연 시간을 숨길 수 없습니다.
타입으로 정의된 경계 (The boundary, typed)
서브 에이전트는 부모 입장에서 하나의 도구 (Tool)처럼 보여야 합니다. 인터페이스는 동일하지만 구현 방식이 다를 뿐입니다.
export type SubAgentSpec<S extends z.ZodType, R extends z.ZodType> = {
name: string;
description: string;
...
output은 이 구조를 작동하게 만드는 핵심 부분입니다. 스키마 (Schema)가 없다면 서브 에이전트는 임의의 길이의 산문 (Prose)을 반환하게 되고, 당신이 비용을 지불하며 확보한 격리는 부모의 윈도우 (Window)로 그대로 새어 나가게 됩니다.
const researcher = asTool({
name: "research_topic",
description:
...
.max(6)와 .max(300)은 단순한 장식이 아닙니다. 이는 "부모가 요약본을 받는다"는 원칙을 강제하는 것입니다.
예산과 권한(Capabilities)을 상속하되, 절대 확장하지 마라
서브 에이전트(Sub-agents)가 탈출구(escape hatch)로 변질되는 것을 막기 위해 두 가지 불변량(invariants)을 설정합니다.
function childCtx(parent: Ctx, spec: SubAgentSpec<any, any>): Ctx {
const caps = parent.caps.filter((c) => spec.tools.includes(capToTool(c)));
...
권한(Capabilities)은 좁아집니다. 예산(Budget)은 부모의 예산에 종속된 **자식(child)**입니다. 따라서 서브 에이전트가 20센트를 사용하면 부모의 예산이 20센트 줄어드는 것이지, 새로운 용돈이 생기는 것이 아닙니다.
child(limitUsd: number): Budget {
const cap = Math.min(limitUsd, this.remaining);
const b = new Budget(cap, this.maxCalls);
...
이러한 장치가 없다면, 각각 "적은 예산"을 가진 세 개의 서브 에이전트를 실행하는 것은 사실상 상한선이 없는 실행과 다름없게 됩니다.
또한, 서브 에이전트가 또 다른 서브 에이전트를 생성할 수 있으므로 깊이 제한(depth cap)이 필요합니다.
if (parent.depth >= 2) throw new TooDeep(parent.depth);
제가 본 모든 정당한 사례는 두 단계(two levels) 내에서 해결됩니다. 그보다 더 깊어진다는 것은 거의 항상 도구(tool)가 누락되었음을 의미합니다.

실패 모드(failure mode)를 처리하는 병렬 처리
const results = await Promise.allSettled(
topics.map((t) => limit(() => researcher.run({ question: t }, ctx))),
);
...
all 대신 allSettled를 사용합니다. 하나의 서브 에이전트가 호출 횟수 제한(turn cap)에 걸렸다고 해서, 성공한 다른 두 개의 결과를 버려서는 안 되기 때문입니다. 그리고 부모에게 몇 개가 실패했는지 알려주는 것이, 부모가 불완전한 답변을 완전한 것처럼 제시하는 것을 방지합니다.
동시성(concurrency)을 제한하십시오. 각각 8회의 호출 제한을 가진 세 개의 서브 에이전트는 최대 24개의 모델 호출(model calls)이 동시에 진행 중인 상태가 될 수 있습니다.
관측성(Observability)을 위한 트리 구조
logger.info("subagent finished", {
parentRunId: ctx.runId,
spec: spec.name,
...
runId를 parent.child 형태로 구성한다는 것은 단일 로그 쿼리만으로 트리를 재구성할 수 있음을 의미합니다. 이것이 없다면, 서브 에이전트 (sub-agent) 호출은 서로 관련 없는 실행 (runs)으로 나타나며, "왜 이 요청에 40센트나 들었는가?"라는 질문에 답할 수 없게 됩니다.
주시해야 할 지표는 실행 비용 중 서브 에이전트 비용의 비중입니다. 이 비중이 절반을 넘는다면 부모 에이전트는 라우터 (router)가 된 것이며, 이것이 의도된 것이라면 괜찮지만 그렇지 않다면 과도한 분해 (over-decomposition)의 신호입니다.
생성하기 전의 테스트
서브 에이전트의 작업을 먼저 함수 시그니처 (function signature)로 작성해 보세요.
research(question: string): Promise<{ findings: Finding[]; gaps: string[] }>
만약 검색 호출 (search call)과 약간의 필터링 (filtering)만으로 이를 구현할 수 있다면, 그렇게 하십시오. 만약 진정으로 읽기, 판단, 단서 추적, 그리고 언제 멈출지를 결정하는 과정이 필요하다면 — 그때는 루프 (loop)가 필요하며, 루프에는 자체적인 컨텍스트 (context)가 필요합니다.
대부분의 "서브 에이전트"는 그 테스트를 통과하지 못합니다. 테스트를 통과하는 것들은 대개 검색 및 추출 (search-and-distil) 작업이며, 이들은 비용을 들일 가치가 충분합니다.
이 글이 도움이 되었다면
AI That Plans는 멀티 에이전트 (multi-agent) 구조를 다룹니다 — 분해가 도움이 되는 시점, 감독자(supervisor) 및 작업자(worker) 토폴로지 (topologies), 예산 및 역량 상속 (budget and capability inheritance), 그리고 전체 트리의 관찰 가능성 (observability) 유지에 대해 설명합니다.

전체 시리즈는 xgabriel.com/ai-in-typescript에서 확인하실 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기